
Preuve de concept et laboratoire démontrant un déni de service par empoisonnement de clé lors de la négociation de version compatible QUIC dans MsQuic v2.6.1, avec une preuve d'injection UDP et une analyse du correctif.
abraxaslabs.tech · github.com/abraxas · @abraxas_null · [email protected] · msquic-compat-vn-key-poison
MsQuic v2.6.1 (a01333cf) - Microsoft
La négociation de version compatible est censée attendre que le client puisse réellement traiter un paquet de la nouvelle version. Cet arbre valide la version et recrée les clés Initial dès qu'un long header semble compatible, puis continue. Le « undo » en cas d'échec de déchiffrement restaure le numéro de version et laisse les clés sur v2. Un paquet de type Handshake ou tronqué n'atteint même jamais ce undo.
Un seul datagramme UDP provenant du 4-tuple du serveur bloque une handshake client par défaut. Le processus reste actif. Pas de RCE.
| ID | pas encore de CVE |
| CWE | CWE-345, CWE-347, CWE-670 |
| CVSS | Élevé : 7.5 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H |
| Produit | MsQuic |
| Affecté | v2.6.1 (a01333cf) ; toujours sur main au moment de ma vérification |
| Auth | UDP non authentifié, la source doit être le 4-tuple du serveur (on-path, ou usurpation IP du serveur plus le port du client) |
| Licence | GNU Affero GPL v3.0 |
| Lab | 127.0.0.1 uniquement |
Pendant une handshake QUIC v1, envoyer un datagramme à long header dont la source est l'adresse et le port du serveur. La version est QUIC v2 (RFC 9369, wire 6b 33 43 cf). DestCidLength vaut 0. Les clients par défaut utilisent un socket UDP connecté exclusif, donc ils acceptent un DCID de longueur nulle. La bibliothèque traite cela comme une négociation de version compatible avant l'AEAD, dérive de nouvelles clés Initial, puis soit n'atteint jamais le revert en cas d'échec de déchiffrement (le type Handshake est différé ; un Initial tronqué retourne FALSE en premier), soit restaure Stats.QuicVersion et laisse les clés sur v2.
Le vrai serveur continue de parler v1. Le client retransmet avec les salts v2. Cette tentative n'atteint jamais Connected. Les autres connexions du même processus ne sont pas affectées. C'est un DoS de handshake, pas une RCE.
Off-path nécessite une usurpation IP du serveur plus le port UDP éphémère du client. On-path suffit. Le binding exclusif signifie que l'attaquant n'a pas besoin du CID.
J'ai d'abord lu la vague de GHSA 2026 sur microsoft/msquic : MITM de hostname (GHSA-w5f4-fx9m-m4q7, ce tag est le cherry-pick), RCE path-UAF (GHSA-92f5-vc22-8j33), underflow ACK (GHSA-gvvw-8j96-8g5r). Ces sinks sont fermés sur v2.6.1.
Ensuite les en-têtes de paquets, CID, négociation de version, retry, stateless reset, CIBIR. La VN compatible RFC 9368 est le reste. QuicConnRecvHeader voit une incompatibilité de version, demande si elle est compatible, écrit OriginalQuicVersion, définit CompatibleVerNegotiationAttempted, assigne Stats.QuicVersion, appelle QuicCryptoOnVersionChange, et continue. Le commentaire indique que les paramètres de transport vérifieront ChosenVersion plus tard. Ces TPs n'arrivent jamais si la handshake ne déchiffre jamais.
L'échec de déchiffrement ressemble à un correctif jusqu'à ce qu'on le lise :
Connection->Stats.QuicVersion = Connection->OriginalQuicVersion;
Connection->State.CompatibleVerNegotiationAttempted = FALSE;
Pas de second QuicCryptoOnVersionChange. Les labels HKDF restent sur v2. Les paquets de type Handshake sont différés (HANDSHAKE > ReadKey=INITIAL) et RecvHeader retourne FALSE, donc ce undo ne s'exécute jamais.
Je n'ai pas eu besoin de src/tools/attack. Sept octets suffisent.
Fausses pistes déjà consignées : injecter sur le port d'écoute du serveur (le client est en connexion exclusive, il ne verra pas cela) ; transférer l'Initial avant le poison sur loopback (la handshake se termine d'abord, le poison arrive trop tard) ; getchar() comme attente du serveur d'exemple (Docker n'est pas un clavier) ; clone superficiel avec submodule openssl vide (l'image doit récupérer v2.6.1 et initialiser openssl) ; n'importe quel long header bidon comme preuve (un 11223344 non compatible reste Connected - la partie intéressante est la v2 compatible). Théâtre : un payload CRYPTO, un reverse shell, une RCE. Le témoin est le résultat de la handshake.
cd lab
./run.sh
Le proxy UDP 127.0.0.1:18150 est l'adresse à laquelle le client se connecte. Le vrai serveur quicsample est 127.0.0.1:18151. Au premier datagramme du client, le PoC envoie un Handshake v2 tronqué f06b3343cf0000 depuis le socket du proxy, puis transfère. Le lier à loopback.
control_connected=1
inject_connected=0
negative_connected=1
inject_hex=f06b3343cf0000
SUCCESS MSQUIC-COMPAT-VN-KEY-POISON-WITNESS
CONTROL est en transfert seul et doit être Connected. NEGATIVE injecte f0112233440000 en premier et doit toujours être Connected. INJECT est la seule phase qui meurt.
Ne pas appeler QuicCryptoOnVersionChange tant que le paquet déclencheur n'est pas déchiffré. À chaque échec après un commit (RecvHeader FALSE et échec de déchiffrement), restaurer la version et redériver les clés vers OriginalQuicVersion. Ignorer Handshake / 0-RTT comme déclencheurs de VN compatible. Seul un Initial déchiffrable de la nouvelle version devrait basculer.
connection.c · crypto.c · binding.c · version_neg.c