MsQuic, compatible-VN commits Initial keys before AEAD
MsQuic v2.6.1 default client treats a compatible-VN long header as a version switch before AEAD, recreates Initial keys, then either never reverts or restores only the version number. One UDP datagram from the server 4-tuple stops the handshake. No CVE yet.
- Name
- MsQuic, compatible-VN commits Initial keys before AEAD
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Microsoft
- Affected
- MsQuic v2.6.1 (a01333cf); leftover of RFC 9368 compatible VN. Default client, exclusive binding, DCID length 0. Still on main when checked. Handshake DoS, not RCE.
- Published
- 30 Sept 2026
- Updated
- 30 Sept 2026
- Tags
- 0-day, msquic, quic, version-negotiation, cwe-345, cwe-347, unauthenticated
compatible is not authenticated
The proof of concept is on GitHub: abraxas/msquic-compat-vn-key-poison (loopback client; @abraxas_null). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.
This is MsQuic v2.6.1 (a01333cf), Microsoft. No CVE yet. CWE-345 / CWE-347 / CWE-670. 7.5 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H). Unauthenticated UDP. Source must be the server 4-tuple. Handshake DoS. Not RCE.
How I found it
I read the 2026 GHSA wave first. GHSA-w5f4-fx9m-m4q7 is OpenSSL/QuicTLS hostname MITM; this tag is the cherry-pick. GHSA-92f5-vc22-8j33 is the path-UAF RCE, guarded. GHSA-gvvw-8j96-8g5r is ACK underflow, also guarded. Those are closed on v2.6.1.
Then packet headers, CID, version negotiation, retry, stateless reset, CIBIR. RFC 9368 compatible VN is the leftover. The client is supposed to switch only after it can successfully process a packet of the new version. This tree commits first and hopes transport parameters will argue later.
QuicConnRecvHeader sees a version mismatch on a long header. If the connection is a client, compatible VN has not been attempted, and the version is in the compatibility list (v2 and MS_1 for a default v1 client), it writes OriginalQuicVersion, sets the flag, assigns Stats.QuicVersion, calls QuicCryptoOnVersionChange, and continues. No AEAD yet.
QuicCryptoOnVersionChange frees Initial keys, updates TLS HKDF labels, and derives new keys from the new version salt. Decrypt-fail looks like a fix until you read it: it restores Stats.QuicVersion and clears the flag. It does not derive the old keys back. Handshake-type packets defer (HANDSHAKE > ReadKey=INITIAL) and RecvHeader returns FALSE, so that undo never runs. Truncated packets fail Length/PN parse the same way.
Default client is exclusive-connected UDP (ShareBinding = FALSE). Preprocess accepts DestCidLength 0 on that binding. The attacker does not need the CID. They need the server 4-tuple.
I did not need src/tools/attack. Seven bytes is enough.
Wrong turns already recorded:
- Injecting at the server's listen port. The client is exclusive-connected. It will not see a datagram that is not from the address it called. I spent a minute yelling at the wrong socket.
quicsamplehardcodes port 4567 and waits ongetchar(). That is a fine server API if you are a human with a keyboard. Docker is not. The image patches-port:Nand a SIGTERM loop.- Shallow clone of msquic has an empty openssl submodule. The image clones tag v2.6.1 and inits openssl itself.
- Forward the client Initial, then inject. On loopback the handshake is done before the poison lands. Fashionably late. Inject first, then forward.
- Treat any junk long header as the proof. Non-compatible version
11223344stillConnected. The interesting part is compatible v2. - A CRYPTO payload, a reverse shell, RCE. Theatre. The witness is handshake outcome.
commit, then continue
Exclusive binding, DCID 0:
if (Binding->Exclusive) {
if (Packet->DestCidLen != 0) {
QuicPacketLogDrop(Binding, Packet, "Non-zero length CID on exclusive binding");
return FALSE;
}Then the switch, before AEAD:
if (QuicConnIsClient(Connection) &&
!Connection->State.CompatibleVerNegotiationAttempted &&
QuicVersionNegotiationExtIsVersionCompatible(Connection, Packet->Invariant->LONG_HDR.Version)) {
Connection->OriginalQuicVersion = Connection->Stats.QuicVersion;
Connection->State.CompatibleVerNegotiationAttempted = TRUE;
Connection->Stats.QuicVersion = Packet->Invariant->LONG_HDR.Version;
QuicConnOnQuicVersionSet(Connection);
if (QUIC_FAILED(QuicCryptoOnVersionChange(&Connection->Crypto))) {
return FALSE;
}
//
// Do not return FALSE here, continue with the connection.
//Decrypt-fail undo, version number only:
if (Connection->State.CompatibleVerNegotiationAttempted &&
!Connection->State.CompatibleVerNegotiationCompleted) {
Connection->Stats.QuicVersion = Connection->OriginalQuicVersion;
Connection->State.CompatibleVerNegotiationAttempted = FALSE;
}What an attacker can do
During a QUIC v1 handshake, send one long-header datagram sourced from the server address and port. Version is QUIC v2 (RFC 9369, wire 6b 33 43 cf). DestCidLength is 0. The default client treats that as compatible version negotiation before AEAD, derives new Initial keys, then either never hits the decrypt-fail revert or reverts the version number and leaves the keys on v2.
The real server keeps speaking v1. The client retransmits with v2 salts. That attempt never reaches Connected. The process stays up. Other connections on the same process are untouched. Handshake DoS, not RCE.
Off-path needs IP spoof of the server plus the client's ephemeral UDP port. On-path is enough.
The lab (run this at home)
Source of truth is lab/. Image builds MsQuic v2.6.1 a01333cf plus patched quicsample. UDP proxy 127.0.0.1:18150 is the address the client connects to. Real server is 127.0.0.1:18151. Bind it to loopback.
git clone https://github.com/abraxas/msquic-compat-vn-key-poison
cd msquic-compat-vn-key-poison/lab
./run.shThe proof of concept is a host wrapper around that. Do not publish the port off loopback.
Three phases:
- CONTROL, forward-only. Client must print
Connected. - INJECT truncated v2 Handshake
f06b3343cf0000on the first client datagram, then forward. Client must not printConnected. - NEGATIVE truncated
f0112233440000first, then forward. Client stillConnected.
Last lab run, trimmed:
control_connected=1
inject_connected=0
negative_connected=1
inject_hex=f06b3343cf0000
SUCCESS MSQUIC-COMPAT-VN-KEY-POISON-WITNESSA few other ways to lose without learning anything:
- Injecting at the server listen port. Exclusive client will not see it.
- Forward first on loopback. Handshake wins the race.
- Non-compatible version as the positive. That is the negative.
- Waiting on
getchar()in a container.
What this is not
It is not the 2026 path-UAF RCE. That is patched. It is not ACK underflow. That is patched. It is not hostname MITM. That cherry-pick is this tag. It is not RCE. It is not a process crash. It is one connection attempt that never reaches Connected.
The fix
Do not call QuicCryptoOnVersionChange until the triggering packet decrypts. On every failure after a commit (RecvHeader FALSE and decrypt-fail) restore the version and derive keys back to OriginalQuicVersion. Ignore Handshake / 0-RTT as compatible-VN triggers. Only a decryptable Initial of the new version should switch.
I am not going to print a raw UDP inject you can paste at someone else's QUIC port. The pre-AEAD key commit is the useful part. If you own the box, run the proof of concept against loopback.
References
- Proof of concept: abraxas/msquic-compat-vn-key-poison · msquic-compat-vn-key-poison-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · poc.py - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-345 · CWE-347 · CWE-670
- Tree: MsQuic v2.6.1 ·
connection.c·crypto.c·binding.c·version_neg.c - Nearby patched: GHSA-w5f4-fx9m-m4q7 · GHSA-92f5-vc22-8j33 · GHSA-gvvw-8j96-8g5r
- RFC 9368 · RFC 9369
- Product: MsQuic