
MsQuic v2.6.1에서 QUIC 호환 버전 협상 키 포이즌 핸드셰이크 DoS를 시연하는 개념 증명 및 실습 환경으로, UDP 주입 증거와 수정 분석을 포함합니다.
abraxaslabs.tech · github.com/abraxas · @abraxas_null · [email protected] · msquic-compat-vn-key-poison
MsQuic v2.6.1 (a01333cf) - Microsoft
호환 버전 협상은 클라이언트가 실제로 새 버전의 패킷을 처리할 수 있을 때까지 기다려야 한다. 이 트리는 long header가 호환 가능해 보이는 순간 버전을 커밋하고 Initial 키를 재생성한 뒤 계속 진행한다. 복호화 실패 시의 "되돌리기"는 버전 번호를 복원하지만 키는 v2에 그대로 남긴다. Handshake 타입이나 잘린 패킷은 그 되돌리기에조차 도달하지 않는다.
서버 4-튜플에서 온 UDP 데이터그램 하나가 기본 클라이언트 핸드셰이크를 중단시킨다. 프로세스는 살아 있다. RCE는 없다.
| ID | 아직 CVE 없음 |
| CWE | CWE-345, CWE-347, CWE-670 |
| CVSS | High: 7.5 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H |
| 제품 | MsQuic |
| 영향받는 버전 | v2.6.1 (a01333cf); 확인 시점에 main에도 여전히 존재 |
| 인증 | 인증되지 않은 UDP, 출발지가 서버 4-튜플이어야 함 (on-path, 또는 서버 IP 스푸핑과 클라이언트 포트) |
| 라이선스 | GNU Affero GPL v3.0 |
| 랩 | 127.0.0.1 전용 |
QUIC v1 핸드셰이크 중에 서버 주소와 포트를 출발지로 하는 long-header 데이터그램 하나를 보낸다. 버전은 QUIC v2 (RFC 9369, wire 6b 33 43 cf)이다. DestCidLength는 0이다. 기본 클라이언트는 배타적 연결 UDP 소켓을 사용하므로 길이 0인 DCID를 받아들인다. 라이브러리는 이를 AEAD 이전에 호환 버전 협상으로 취급하고, 새 Initial 키를 유도한 뒤, 복호화 실패 되돌리기에 도달하지 않거나(Handshake 타입은 지연됨; 잘린 Initial은 먼저 FALSE를 반환) Stats.QuicVersion을 되돌리고 키는 v2에 남긴다.
실제 서버는 계속 v1로 통신한다. 클라이언트는 v2 salt로 재전송한다. 그 시도는 Connected에 도달하지 못한다. 같은 프로세스의 다른 연결은 영향을 받지 않는다. 이는 핸드셰이크 DoS이며 RCE가 아니다.
Off-path에는 서버 IP 스푸핑과 클라이언트의 임시 UDP 포트가 필요하다. On-path면 충분하다. 배타적 바인딩 덕분에 공격자는 CID가 필요 없다.
먼저 microsoft/msquic의 2026 GHSA 물결을 읽었다: hostname MITM (GHSA-w5f4-fx9m-m4q7, 이 태그가 그 cherry-pick이다), path-UAF RCE (GHSA-92f5-vc22-8j33), ACK underflow (GHSA-gvvw-8j96-8g5r). 그 sink들은 v2.6.1에서 닫혀 있다.
그다음 패킷 헤더, CID, 버전 협상, retry, stateless reset, CIBIR을 살폈다. RFC 9368 호환 VN이 남은 부분이다. QuicConnRecvHeader는 버전 불일치를 보고, 호환 가능한지 묻고, OriginalQuicVersion을 기록하고, CompatibleVerNegotiationAttempted를 설정하고, Stats.QuicVersion을 할당하고, QuicCryptoOnVersionChange를 호출한 뒤 계속 진행한다. 주석에는 transport parameter가 나중에 ChosenVersion을 확인할 것이라고 되어 있다. 핸드셰이크가 복호화되지 않으면 그 TP들은 결코 도착하지 않는다.
복호화 실패는 수정처럼 보이지만, 읽어보면 그렇지 않다:
Connection->Stats.QuicVersion = Connection->OriginalQuicVersion;
Connection->State.CompatibleVerNegotiationAttempted = FALSE;
두 번째 QuicCryptoOnVersionChange는 없다. HKDF 레이블은 v2에 머문다. Handshake 타입 패킷은 지연되고(HANDSHAKE > ReadKey=INITIAL) RecvHeader가 FALSE를 반환하므로 그 되돌리기는 결코 실행되지 않는다.
src/tools/attack은 필요하지 않았다. 7바이트면 충분하다.
이미 기록된 우회로들: 서버의 listen 포트에 주입(클라이언트는 배타적 연결이라 그것을 보지 못함); loopback에서 poison 전에 Initial을 포워딩(핸드셰이크가 먼저 끝나고 poison이 늦게 도착); 샘플 서버의 대기로 getchar() 사용(Docker는 키보드가 아님); openssl 서브모듈이 빈 shallow clone(이미지가 v2.6.1을 가져오고 openssl을 init해야 함); 증명으로 아무 junk long header 사용(호환되지 않는 11223344는 여전히 Connected - 흥미로운 부분은 호환되는 v2이다). 연극: CRYPTO 페이로드, 리버스 셸, RCE. 증인은 핸드셰이크 결과다.
cd lab
./run.sh
UDP 프록시 127.0.0.1:18150이 클라이언트가 연결하는 주소다. 실제 quicsample 서버는 127.0.0.1:18151이다. 첫 클라이언트 데이터그램에서 PoC는 프록시 소켓으로부터 잘린 v2 Handshake f06b3343cf0000을 보낸 뒤 포워딩한다. loopback에 바인딩하라.
control_connected=1
inject_connected=0
negative_connected=1
inject_hex=f06b3343cf0000
SUCCESS MSQUIC-COMPAT-VN-KEY-POISON-WITNESS
CONTROL은 포워딩 전용이며 Connected여야 한다. NEGATIVE는 f0112233440000을 먼저 주입하고 여전히 Connected여야 한다. INJECT는 유일하게 죽는 단계다.
트리거 패킷이 복호화될 때까지 QuicCryptoOnVersionChange를 호출하지 말라. 커밋 이후의 모든 실패(RecvHeader FALSE 및 복호화 실패)에서 버전을 복원하고 키도 OriginalQuicVersion으로 다시 유도하라. Handshake / 0-RTT를 호환 VN 트리거로 무시하라. 새 버전의 복호화 가능한 Initial만 전환해야 한다.