Breaking QUIC to Understand It
I wanted to understand QUIC's security properties, so I tried to break it. Captured packets off localhost, wrote a key derivation from scratch, read the CVEs. Some of it was surprisingly easy. Then it wasn't.
THE ENCRYPTION THAT ISN'T
QUIC Initial packets are "encrypted." The keys are derived from the Destination Connection ID—which is right there in the packet header, in plaintext. Read the DCID off the wire, run HKDF, you have the keys. About 20 lines of Rust.
RFC 9001, Section 5.2 just says it outright:
Initial packets are protected with keys derived from the Destination Connection ID field of the first Initial packet sent by the client.
The derivation:
// initial_salt = 0x38762cf7f55934b34d179ae6a4c80cadccbb7f0a (QUIC v1)
// initial_secret = HKDF-Extract(initial_salt, DCID)
// client_initial_secret = HKDF-Expand-Label(initial_secret, "client in", "", 32)
// server_initial_secret = HKDF-Expand-Label(initial_secret, "server in", "", 32)
//
// key = HKDF-Expand-Label(secret, "quic key", "", 16)
// iv = HKDF-Expand-Label(secret, "quic iv", "", 12)
// hp = HKDF-Expand-Label(secret, "quic hp", "", 16)
In Rust:
use hkdf::Hkdf;
use sha2::Sha256;
const INITIAL_SALT_V1: &[u8] = &[
0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3,
0x4d, 0x17, 0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad,
0xcc, 0xbb, 0x7f, 0x0a,
];
fn derive_initial_keys(dcid: &[u8]) -> (Vec<u8>, Vec<u8>) {
let hkdf = Hkdf::<Sha256>::new(Some(INITIAL_SALT_V1), dcid);
let mut client_secret = vec![0u8; 32];
hkdf_expand_label(&hkdf, b"client in", &mut client_secret);
let mut server_secret = vec![0u8; 32];
hkdf_expand_label(&hkdf, b"server in", &mut server_secret);
(client_secret, server_secret)
}
fn hkdf_expand_label(hkdf: &Hkdf<Sha256>, label: &[u8], out: &mut [u8]) {
let length = (out.len() as u16).to_be_bytes();
let full_label = [b"tls13 ", label].concat();
let info = [
&length[..],
&[full_label.len() as u8],
&full_label,
&[0u8],
].concat();
hkdf.expand(&info, out).expect("HKDF expand failed");
}
Decrypt the Initial, and you're looking at a TLS ClientHello. The SNI—hostname the client is connecting to—is sitting right there.
// Decrypted CRYPTO frame → TLS ClientHello:
// Extension: server_name (0x0000)
// Server Name: example.com
This isn't theoretical. China's Great Firewall does this at scale—parsing Initial packets, extracting SNI from the decrypted ClientHello, making censorship decisions. They've been doing it since at least 2024. The "encryption" doesn't even slow them down.
So why encrypt at all? Ossification. Middleboxes—firewalls, NATs, load balancers—have a bad habit of parsing protocol headers and then breaking when those headers change. TCP options are basically frozen because of this. QUIC encrypts the transport headers so middleboxes can't read them, which means they can't build assumptions around them. The protocol stays evolvable.
It's not encryption against attackers. It's encryption against Cisco.
STARVING THE SCHEDULER
CVE-2023-49295. This one hit quic-go, quiche, quicly. The root cause wasn't an implementation bug—it was in RFC 9000 itself.
QUIC has PATH_CHALLENGE frames for path validation. You send one, the peer has to respond with a PATH_RESPONSE containing the same 8-byte payload. The RFC says to respond "immediately." Simple enough.
The problem: you can pack hundreds of PATH_CHALLENGE frames into a single QUIC packet. And before you start flooding, you poison the peer's congestion state—send selective ACKs to inflate the congestion window, delay your own ACKs to inflate the RTT estimate. Now the peer thinks it has huge bandwidth and a long round trip.
// 1. Establish a real QUIC connection (this needs a valid handshake)
//
// 2. Poison congestion state:
// - Selective ACKs → inflated cwnd
// - Delayed ACKs → inflated RTT estimate
//
// 3. Flood PATH_CHALLENGE frames:
//
// Attacker → Peer:
[PATH_CHALLENGE data=0x01] [PATH_CHALLENGE data=0x02]
[PATH_CHALLENGE data=0x03] [PATH_CHALLENGE data=0x04]
... hundreds per packet, thousands per second
//
// 4. Peer queues a PATH_RESPONSE for each one:
//
[PATH_RESPONSE 0x01] ← can't send, congestion window
[PATH_RESPONSE 0x02] ← waiting
[PATH_RESPONSE 0x03] ← waiting
...
[PATH_RESPONSE 0x4e20] ← still waiting
//
// 5. Challenges arrive faster than responses drain.
// Inflated RTT means slow drain. Queue grows. OOM.
The peer is following the RFC the entire time. Queuing responses like it's supposed to. It just runs out of memory.
You need an established connection—can't spoof this. But one connection is enough. The attacker controls arrival rate (unlimited) and has already manipulated the drain rate (poisoned congestion state). The queue only grows.
The fix: cap the PATH_RESPONSE queue. Queue full? Drop the challenge. This technically violates the RFC, which says respond to everything. The implementations did it anyway. An RFC-compliant server that OOMs is not a correct server.
quic-go patched it. quiche patched it. quicly patched it. Different queue limits each time.
Quinn wasn't listed in the CVE. Whether it's actually exposed is an open question—I haven't audited it yet. The underlying pattern (unbounded queuing of mandatory responses, under adversarial congestion state) is subtle enough that I wouldn't assume any implementation got it right without checking.
THE WALL
Once the handshake finishes, you're done. Everything after that is AEAD—AES-128-GCM or ChaCha20-Poly1305.
The packet header is authenticated as associated data. Not encrypted—authenticated. Change one byte anywhere in the packet—header or payload—and the auth tag fails.
Compare to TLS over TCP:
| Scenario | TLS over TCP | QUIC |
|---|---|---|
| Tampered packet | TLS alert, connection torn down | Packet silently dropped |
| Signal to attacker | TCP RST on the wire | Nothing |
| Connection state | Destroyed | Unaffected |
TLS sees a bad record, sends an alert, tears down the connection. One injected packet kills the session. The RST is visible on the wire—the attacker knows it worked.
QUIC drops it. No alert. No reset. The connection keeps going like the packet never existed.
// Flip one bit in an encrypted QUIC packet:
//
// Original: 40 73 a5 ... [payload] ... [16-byte auth tag]
// Modified: 40 73 a5 ... [payload ^ 0x08] ... [auth tag]
//
// Receiver:
// 1. Remove header protection → packet number
// 2. Nonce = IV ⊕ packet number
// 3. AES-128-GCM decrypt:
// key: from handshake
// nonce: step 2
// aad: packet header
// ciphertext: payload + tag
// 4. Tag verification: FAIL
// 5. Increment failure counter
// 6. Drop. No response.
Endpoints do count failed verifications. For AES-128-GCM the bound is 252 failures—after that, the connection closes with AEAD_LIMIT_REACHED. That's 4.5 quadrillion packets. At 10 million forged packets per second, 14 years. Not a real attack.
The handshake has real attack surface—Initial packets are readable by anyone on the path, and you can OOM a server through its own RFC compliance. After the handshake, it's AEAD. Silent discard. No signal, no error, no way in.
Reading:
- RFC 9001 — Using TLS to Secure QUIC
- RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport
- GHSA-ppxx-5m9h-6vxg — quic-go PATH_CHALLENGE advisory
- How the Great Firewall of China Detects and Blocks Fully Encrypted Traffic — USENIX Security 2024