
Análise técnica de uma falha de contrabando de requisições por estouro de inteiro no tamanho de chunk HTTP/1.1 do NetScaler nsppe, com desmontagem, fluxos de PoC em nível de rede e diff de patch de firmware.
O parser de corpo chunked do HTTP/1.1 do nsppe lê o campo chunk-size em um registrador de 32 bits e nunca verifica overflow. Com nove ou mais dígitos hexadecimais, o valor sofre wrap silencioso: 100000005 (2^32 + 5) é consumido como um tamanho de chunk de 5.
Isso é apenas um bug engraçado de parser até você lembrar o que o NetScaler faz com o corpo: ele encaminha o enquadramento chunked do cliente para o backend exatamente como recebido, mas decide onde a requisição termina usando seu próprio valor com wrap. Assim, o proxy acredita que a requisição #1 está completa após cinco bytes de corpo, enquanto um backend correto lendo os mesmos bytes calcula 4.294.967.301 e se prepara para um corpo de 4 GB que nunca chegará. Duas máquinas, um único fluxo de bytes, duas opiniões diferentes sobre onde uma requisição termina.
Tudo após a versão do NetScaler de "o fim" é reclassificado como uma nova requisição pipelined na mesma conexão do cliente e então encaminhado de forma independente na conexão compartilhada proxy-para-backend.
O que você obtém depende do próprio parser do backend:
Uma requisição não autenticada. Configuração padrão. Nenhuma pré-condição além de alcançar um vserver HTTP.
A tarefa do parser é simples: transformar uma linha de chunk-size como 1a3f\r\n em um número. Ele recebe o buffer em rdi com seu comprimento em esi, acumula o valor hexadecimal no slot de 32 bits em *rdx (entrada e saída, para que um tamanho dividido entre dois pacotes seja retomado sem problemas), ignora espaços em branco e exige o \r\n final. Ele responde com os bytes consumidos, 0 para "precisa de mais dados" ou -1 para malformado.
O loop inteiro, pequeno o suficiente para ver o bug a olho nu:
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 é o acumulador, e tem 32 bits de largura. Cada dígito faz value <<= 4; value += digit. Sem contador de dígitos, sem limite de magnitude, sem verificação de overflow, sem jo em lugar algum do loop. O nono dígito não gera erro; ele desloca o topo do número para fora do fim do registrador e ninguém percebe. 0x100000005 & 0xffffffff = 5, e o parser fica perfeitamente satisfeito com isso.
Existe exatamente uma verificação de sanidade a jusante, e ela está errada de uma forma quase poética. O driver de dechunking (sub_20af6b0, pontos de chamada 0x20af719 / 0x20af9fb, tamanho armazenado em [r12+0x344]) aplica um teste de sinal ao valor acumulado. Reflita sobre isso por um segundo: os únicos wraps que são capturados são os que caem na metade negativa, [0x80000000, 0xffffffff]. Um tamanho com wrap cujos 32 bits baixos resultam abaixo de 0x80000000 passa como um tamanho de chunk completamente normal. 0x100000005 com wrap para 5 é o caso canônico: com wrap, despercebido e devastador.
O pacote se transforma em um smuggle por causa de uma decisão de design na forma como o NetScaler trata corpos: ele encaminha o enquadramento chunked do cliente para o backend byte a byte, intocado, enquanto usa sua própria compreensão (com wrap) desse enquadramento para decidir onde a requisição do cliente termina.
Assim, o NetScaler encerra a requisição #1 após cinco bytes de corpo e enfileira os 37 restantes como uma segunda requisição, pipelined. O backend, fazendo a aritmética corretamente, lê 100000005 como 4.294.967.301 e continua esperando pelo corpo da requisição #1. A partir desse ponto, ambas as máquinas estão lendo o mesmo stream e vivendo em mundos diferentes.
Em qual mundo você acaba depende do 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 |
| | |