Party Cat0%

29/06/26

HTTPDesynciXS-LeakmitjançantRangeOracle

Final | SekaiCTF 2026

Thanks for sharing!

بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ

HTTP Desync i XS-Leak mitjançant Range Oracle
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, api o 127.0.0.1.
La ruta d'atac és:
  1. Feu que JS s'executi a l'origen del servidor intermediari -> llegiu /admin -> agafeu la clau de l'API
  2. 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·licitud

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ò:
El servidor intermediari envia Content-Length: 0 al navegador, però encara envia tot el cos amunt al sòcol TCP
Què 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 TCP
Quan 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 declarar CL: 0 i encara escriure tants bytes com vulgueu. Simplement no li importa:
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:

Pas 1: registreu el nostre servidor al proxy
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:
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:
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:
SMUGGLED_RESPONSE és una resposta HTTP completa i amb el format correcte:
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
Desync Flow

Etapa 2: filtració de la bandera

L'Oracle

El punt final de cerca de l'API fa això:
conditional=True activa les sol·licituds d'interval HTTP
Aquesta és la informació clau: la mida del cos de la resposta canvia en funció de si q és un prefix correcte:
Consulta qCoincidir?CosMida
SEKAI{{"results":["SEKAI{...full flag...}"]}~43 bytes
SEKAI{zno{"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 capturada 206 Partial Content a una trucada fetch(), la recuperació es rebutja. Però una resposta opaca 416 permet 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:
El motor d'àudio de Chrome emet automàticament dues sol·licituds d'interval de contingut d'àudio:
  1. Range: bytes=0- -> per començar
  2. Range: bytes=30- -> per obtenir la resta (després de veure els primers 30 bytes)
El nostre treballador de servei intercepta tots dos:
Després de disparar audio.onerror (sempre s'error perquè li hem donat dades d'àudio falses):
El cabal complet:
XS-Leak Oracle Flow

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:
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!

També et pot interessar