
MsQuic v2.6.1 における QUIC 互換のバージョンネゴシエーション鍵ポイズンハンドシェイク DoS を実証する PoC およびラボ。UDP インジェクションの証拠と修正分析を含む。
abraxaslabs.tech · github.com/abraxas · @abraxas_null · [email protected] · msquic-compat-vn-key-poison
MsQuic v2.6.1 (a01333cf) - Microsoft
互換バージョンネゴシエーションは、クライアントが実際に新バージョンのパケットを処理できるようになるまで待つことになっている。このツリーでは、ロングヘッダーが互換に見えた瞬間にバージョンを確定し、Initial 鍵を再生成してから処理を続行する。復号失敗時の「undo」はバージョン番号を復元するが、鍵は v2 のまま残す。Handshake タイプや切り詰められたパケットは、その undo にすら到達しない。
サーバーの 4 タプルからの UDP データグラム 1 個で、デフォルトクライアントのハンドシェイクが停止する。プロセスは稼働したまま。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 ハンドシェイク中に、サーバーのアドレスとポートを送信元とするロングヘッダーデータグラムを 1 個送信する。バージョンは QUIC v2 (RFC 9369、ワイヤ上 6b 33 43 cf)。DestCidLength は 0。デフォルトクライアントは排他的に接続された UDP ソケットを使用するため、長さ 0 の DCID を受け入れる。ライブラリはこれを AEAD の前に 互換バージョンネゴシエーションとして扱い、新しい Initial 鍵を導出し、その後、復号失敗による revert に到達しないか (Handshake タイプは遅延される。切り詰められた Initial は先に FALSE を返す)、Stats.QuicVersion を revert して 鍵を v2 のまま残す。
実際のサーバーは v1 で話し続ける。クライアントは v2 のソルトで再送する。その試行は Connected に到達しない。同一プロセス上の他の接続は影響を受けない。これはハンドシェイク DoS であり、RCE ではない。
Off-path の場合は、サーバーの IP スプーフィングとクライアントの一時 UDP ポートが必要。On-path なら十分。排他的バインディングのため、攻撃者は CID を必要としない。
まず microsoft/msquic の 2026 年の GHSA の波を読んだ: ホスト名 MITM (GHSA-w5f4-fx9m-m4q7、このタグはその cherry-pick)、path-UAF RCE (GHSA-92f5-vc22-8j33)、ACK アンダーフロー (GHSA-gvvw-8j96-8g5r)。これらのシンクは v2.6.1 では閉じられている。
次にパケットヘッダー、CID、バージョンネゴシエーション、retry、stateless reset、CIBIR。残っていたのは RFC 9368 互換 VN。QuicConnRecvHeader はバージョン不一致を検出し、互換かどうかを問い合わせ、OriginalQuicVersion を書き込み、CompatibleVerNegotiationAttempted を設定し、Stats.QuicVersion を代入し、QuicCryptoOnVersionChange を呼び出して、続行する。コメントには、トランスポートパラメータが後で ChosenVersion をチェックすると書かれている。ハンドシェイクが一度も復号されなければ、それらの TP は決して到着しない。
復号失敗は一見修正に見えるが、読んでみるとそうではない:
Connection->Stats.QuicVersion = Connection->OriginalQuicVersion;
Connection->State.CompatibleVerNegotiationAttempted = FALSE;
2 回目の QuicCryptoOnVersionChange はない。HKDF ラベルは v2 のまま。Handshake タイプのパケットは遅延され (HANDSHAKE > ReadKey=INITIAL)、RecvHeader は FALSE を返すため、その undo は決して実行されない。
src/tools/attack は必要なかった。7 バイトで十分。
すでに記録済みの回り道: サーバーのリッスンポートへの注入 (クライアントは排他的接続のため、それを見ない); ループバックでポイズンの前に Initial を転送 (ハンドシェイクが先に完了し、ポイズンが遅れて到着); サンプルサーバーの待機に getchar() (Docker はキーボードではない); openssl サブモジュールが空の shallow clone (イメージは v2.6.1 をフェッチして openssl を init する必要がある); 証明としての任意のジャンクロングヘッダー (非互換の 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 を送信し、その後転送する。ループバックにバインドすること。
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 のみが切り替えるべきである。