
Analisi tecnica di una vulnerabilità di request smuggling HTTP/1.1 basata su integer-wrap della dimensione dei chunk in NetScaler nsppe, con disassembly, flussi PoC a livello di rete e diff della patch del firmware.
Il parser del corpo chunked HTTP/1.1 di nsppe legge il campo chunk-size in un registro a 32 bit e non verifica mai l'overflow. Con nove o più cifre esadecimali il valore va silenziosamente in wrap: 100000005 (2^32 + 5) viene consumato come dimensione di chunk pari a 5.
È un simpatico bug del parser, finché non si ricorda cosa fa NetScaler con il corpo: inoltra al backend il framing chunked del client esattamente come ricevuto, ma decide dove termina la richiesta usando il proprio valore andato in wrap. Così il proxy ritiene che la richiesta #1 sia completa dopo cinque byte di corpo, mentre un backend corretto che legge gli stessi byte calcola 4.294.967.301 e si prepara a un corpo da 4 GB che non arriverà mai. Due macchine, un unico flusso di byte, due opinioni diverse su dove finisce una richiesta.
Tutto ciò che segue la versione di NetScaler di "la fine" viene riclassificato come una nuova richiesta in pipeline sulla stessa connessione client, poi inoltrato in modo indipendente sulla connessione condivisa proxy-to-backend.
Ciò che si ottiene dipende dal parser del backend stesso:
Una singola richiesta non autenticata. Configurazione predefinita. Nessuna precondizione oltre al raggiungimento di un vserver HTTP.
Il compito del parser è semplice: trasformare una riga di dimensione del chunk come 1a3f\r\n in un numero. Prende il buffer in rdi con la sua lunghezza in esi, accumula il valore esadecimale nello slot a 32 bit in *rdx (in input e output, così una dimensione divisa tra due pacchetti riprende correttamente), salta gli spazi bianchi e richiede il \r\n finale. Risponde con i byte consumati, 0 per "servono più dati" oppure -1 per input malformato.
L'intero ciclo, abbastanza piccolo da vedere il bug a occhio nudo:
20af5e0: movzx eax, BYTE PTR [rbx]
20af5e3: test BYTE PTR [rax+rax*1+0x2746c80], 0x10 ; ctype: hex digit?
20af5eb: je 20af60b
20af5ed: mov ecx, r11d
20af5f0: shl ecx, 0x4 ; value <<= 4
20af5f3: movzx r11d, BYTE PTR [rax+0x2746b80] ; hexval(digit)
20af5fb: add r11d, ecx ; value = value*16 + d
20af5fe: inc rbx
20af601: dec r8d
20af604: jne 20af5e0 ; next digit
r11d è l'accumulatore, ed è largo 32 bit. Ogni cifra esegue value <<= 4; value += digit. Nessun contatore di cifre, nessun limite di grandezza, nessun controllo di overflow, nessun jo da nessuna parte nel ciclo. La nona cifra non genera un errore; sposta la parte alta del numero oltre la fine del registro e nessuno se ne accorge. 0x100000005 & 0xffffffff = 5, e il parser ne è perfettamente soddisfatto.
C'è esattamente un controllo di sanità a valle, ed è sbagliato in modo quasi poetico. Il driver di dechunking (sub_20af6b0, punti di chiamata 0x20af719 / 0x20af9fb, dimensione memorizzata in [r12+0x344]) applica un test di segno al valore accumulato. Soffermati un attimo su questo: gli unici wrap che vengono intercettati sono quelli che finiscono nella metà negativa, [0x80000000, 0xffffffff]. Una dimensione soggetta a wrap i cui 32 bit bassi risultano inferiori a 0x80000000 passa inosservata come una dimensione di chunk del tutto normale. 0x100000005 che va in wrap a 5 è il caso canonico: soggetta a wrap, non notata, e devastante.
Il pacchetto si trasforma in un smuggle a causa di una singola decisione di progettazione nel modo in cui NetScaler tratta i corpi: inoltra il framing chunked del client al backend byte per byte, intatto, mentre usa la propria (soggetta a wrap) interpretazione di quel framing per decidere dove termina la richiesta del client.
Quindi NetScaler termina la richiesta #1 dopo cinque byte di corpo e accoda i 37 rimanenti come una seconda richiesta, in pipeline. Il backend, eseguendo l'aritmetica correttamente, legge 100000005 come 4.294.967.301 e continua ad attendere il corpo della richiesta #1. Da quel momento in poi, entrambi i sistemi leggono lo stesso flusso e vivono in mondi diversi.
In quale mondo ti ritrovi dipende dal backend.
Attacker NetScaler (<=73.30) Backend (RFC 64-bit)
| | |
| [1] one TCP segment, 122 bytes: | |
| POST /wrap HTTP/1.1 | |
| Host: poc | |
| Transfer-Encoding: chunked | |
| 100000005\r\nAAAAA\r\n0\r\n\r\n | |
| GET /SMUGGLED HTTP/1.1 | |
| Host: poc\r\n\r\n | |
|--[1]---------------------------------->| |
| | [2] parses "100000005" into a u32 |
| | wraps to 5; consumes AAAAA; |
| | sees terminating 0-chunk |
| | ==> request #1 COMPLETE |
| | trailing 37 bytes held as a |
| | pending pipelined request #2 |
| | |
| |--[3] 85 bytes, framing VERBATIM-------------->|
| | POST /wrap HTTP/1.1 |
| | Transfer-Encoding: chunked |
| | 100000005\r\nAAAAA\r\n0\r\n\r\n |
| | |
| | | [4] parses 0x100000005 (64-bit)
| | | = 4,294,967,301 bytes
| | | waiting for a body that never co
| | | [WEDGED: no response, ever]
| | |
| | GET /SMUGGLED waits in NetScaler's |
| | pipeline queue forever: response #1 |
| | never arrives |
| | |