← back

Breaking QUIC to Understand It

002 · 2026-03-23 · Decrypting Initial packets, exploiting PATH_CHALLENGE memory exhaustion, and hitting the wall of AEAD

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.

Initial Handshake 1-RTT (AEAD) keys from DCID — readable key exchange AES-128-GCM / ChaCha20 — opaque
QUIC connection security phases — exposed on the left, locked down on the right

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.

c0 00 00 00 01 08 83 94 c8 f0 3e 51 57 08 00 form version (0x00000001) len DCID (8 bytes) scid HKDF-Extract(salt, DCID) → client key + IV, server key + IV remaining bytes: token length + length + encrypted payload entire header visible on the wire — DCID is the key material
QUIC Initial packet — the DCID derives the decryption keys
// Decrypted CRYPTO frame → TLS ClientHello:
//   Extension: server_name (0x0000)
//     Server Name: example.com
initial_secret
client_key
client_iv
server_key
server_iv

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.
queued PATH_RESPONSEs poison cwnd + RTT flood PATH_CHALLENGEs time → OOM arrival rate drain rate queue depth
Challenges arrive faster than responses drain — queue grows until OOM
RTT inflation: 3x
arrival rate: 3x
queue: ~20,000 responses

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:

ScenarioTLS over TCPQUIC
Tampered packetTLS alert, connection torn downPacket silently dropped
Signal to attackerTCP RST on the wireNothing
Connection stateDestroyedUnaffected
TLS over TCP attacker injects tampered packet TLS alert → connection torn down TCP RST on the wire attacker knows it worked QUIC attacker injects tampered packet AEAD tag verification fails silent drop — no response attacker sees nothing
Tampered packet handling — TLS leaks signal, QUIC stays silent

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.
click any byte to flip a bit:
 
AEAD authentication — one flipped bit, entire packet dropped

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: