This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
QUIC
QUIC is a transport protocol built on UDP. It gives what TCP gives (reliable, ordered streams), with TLS 1.3 encryption built in, several independent streams per connection, and connections that survive a change of address. HTTP/3 runs on it, on UDP port 443.
Nearly everything in QUIC is encrypted, headers included. But the very first packets, the Initial packets, are encrypted with keys anyone can compute from the packet itself. That's how NetOtter, without being part of the connection, reads which site a browser is connecting to.
Packets
A UDP datagram holds one or more QUIC packets. There are two header forms:
- Long header (first bit 1): used during the handshake, with types Initial, 0-RTT, Handshake and Retry. It carries the version and both connection ids.
- Short header (first bit 0): everything after the handshake. Only the destination connection id is visible.
Long header
+-+-+-+-+-+-+-+-+
|1|1|T T|R R|P P| header form 1, fixed bit 1, type, reserved, packet number length − 1
+-+-+-+-+-+-+-+-+
| Version (32) |
| DCID Len (8) | Destination Connection ID (0..160) |
| SCID Len (8) | Source Connection ID (0..160) |
Initial only:
| Token Length (varint) | Token |
| Length (varint): packet number + payload |
| Packet Number (8..32), protected |
| Payload: encrypted frames + 16-byte tag |
The low 4 bits of the first byte, and the packet number, are hidden by header protection (see below).
Varints: QUIC's numbers have a variable length. The top 2 bits of the first byte give the total length (00 1 byte, 01 2, 10 4, 11 8 bytes), and the other bits hold the value. 80 00 04 7c is a 4-byte varint worth 0x47c = 1148.
Example: an Initial packet
curl --http3-only https://example.com/ against an address with no server. The client's ClientHello filled two Initial packets (#38 and #39). The server's system answered each with an ICMP error, and curl gave up with a third Initial packet (#42) holding only a CONNECTION_CLOSE frame (type 0x1c, error 0, no reason). NetOtter shows:
#38 [veth0] 5.298915 192.0.2.1:58732 -> 192.0.2.2:443 ttl 64 UDP 1208 B
#39 [veth0] 5.298927 192.0.2.1:58732 -> 192.0.2.2:443 ttl 64 UDP 1208 B
#40 [veth0] 5.298978 192.0.2.2 -> 192.0.2.1 ttl 64 ICMP 556 B [port unreachable 192.0.2.2:443/udp]
#41 [veth0] 5.298984 192.0.2.2 -> 192.0.2.1 ttl 64 ICMP 556 B [port unreachable 192.0.2.2:443/udp]
#42 [veth0] 5.313466 192.0.2.1:58732 -> 192.0.2.2:443 ttl 64 UDP 1208 B
#43 [veth0] 5.313491 192.0.2.2 -> 192.0.2.1 ttl 64 ICMP 556 B [port unreachable 192.0.2.2:443/udp]
The ICMP packets are "port unreachable" errors, since nothing listens on the server side. The first 64 bytes of #38's 1200-byte UDP payload:
0000 c9 00 00 00 01 14 36 5e 57 cc 4c b8 f0 36 31 bd
0010 0e d1 df 99 c0 a8 09 87 51 b4 14 e3 9b 30 85 a7
0020 82 23 3e 81 e5 5e ce 0b 01 eb ee 66 b4 9a 5d 00
0030 80 00 04 7c ad bb d8 d7 7c d4 28 6d 87 c7 eb 53
| Bytes | Field | Value |
|---|---|---|
c9 |
First byte | 1100 1001: long header, fixed bit, type 00 = Initial. The low 4 bits are protected. |
00 00 00 01 |
Version | QUIC v1 (RFC 9000) |
14 + 20 bytes |
DCID | 365e 57cc 4cb8 f036 31bd 0ed1 df99 c0a8 0987 51b4, chosen at random by the client |
14 + 20 bytes |
SCID | e39b 3085 a782 233e 81e5 5ece 0b01 ebee 66b4 9a5d |
00 |
Token Length | 0: no token |
80 00 04 7c |
Length | 1148: packet number + encrypted payload |
ad |
Packet number | protected (the real value is 0) |
bb d8 d7 … |
Payload | encrypted frames, ending with a 16-byte AES-GCM tag |
The packet is 52 + 1148 = 1200 bytes. Clients must pad Initial datagrams to at least 1200 bytes, so a server can't be tricked into amplifying traffic towards a forged source address.
Decrypting the Initial packet
Every step below uses only public values: a constant from the RFC, and the DCID read above.
1. The keys
salt = 38762cf7f55934b34d179ae6a4c80cadccbb7f0a (QUIC v1, RFC 9001 §5.2)
initial_secret = HKDF-Extract(salt, DCID)
= 332b6a6b084b23d329b1fe96c60cd9ea27e0c062ab5b10aa4092ad679914d02b
client_secret = HKDF-Expand-Label(initial_secret, "client in", "", 32)
= ac2e8b7de9970e7d94ce79fd3cc95d1a05cf55d51eaa68db185c508b89434c9e
key = HKDF-Expand-Label(client_secret, "quic key", "", 16) = cd1ed893223eed8281628b4193e55b41
iv = HKDF-Expand-Label(client_secret, "quic iv", "", 12) = 01d20ca224723e9c92ba6adb
hp = HKDF-Expand-Label(client_secret, "quic hp", "", 16) = 9f1af1ee7242eaeb9f81831664fde75b
HKDF-Extract is HMAC-SHA256 with the salt as key. HKDF-Expand-Label is TLS 1.3's: the label is prefixed by tls13 . QUIC v2 (RFC 9369) uses another salt and the labels quicv2 key, quicv2 iv and quicv2 hp.
2. Removing header protection
sample = 16 bytes starting 4 bytes after the packet number's position
= 7cd4286d87c7eb53765edda48039b71a
mask = AES-128-ECB(hp, sample) = 69 ad ca f2 73 …
first byte: c9 XOR (69 AND 0f) = c0 → packet number length = (c0 AND 3) + 1 = 1 byte
packet number: ad XOR ad = 00 → packet number 0
The sample is taken as if the packet number were 4 bytes long, so the receiver can compute the mask before knowing its real length.
3. Decrypting the payload
nonce = iv XOR packet number (right-aligned) = 01d20ca224723e9c92ba6adb
AES-128-GCM(key, nonce, associated data = the unprotected header, 52 + 1 bytes)
The 1148 − 1 = 1147 remaining bytes are 1131 bytes of frames and a 16-byte tag. If the tag doesn't verify, the packet isn't QUIC, or was damaged. NetOtter relies on this to tell QUIC from any other UDP traffic on port 443.
4. The frames
The plaintext is a list of frames:
| Type | Frame | Here |
|---|---|---|
00 |
PADDING | 34 bytes of zeros in the first packet, 574 in the second |
01 |
PING | twice, in the second packet |
06 |
CRYPTO: offset (varint), length (varint), data | fragments of the TLS ClientHello: 11 in the first packet, 21 in the second |
1c |
CONNECTION_CLOSE | in the third packet (#42): error 0, frame type 0, no reason, then 1127 bytes of padding |
The ClientHello starts 01 00 05 eb: handshake type 1 (ClientHello), length 1515. With the 4-byte header, that's 1519 bytes, more than one 1200-byte datagram can carry: modern clients send a large post-quantum key share. The client spread it over two packets, shuffled the fragments, and cut the region around the server name into pieces of 1 to 7 bytes:
packet 0: offsets 0, 166, 222, 250, 264, 271, 279, 393, 621, 849, 963 (ranges of 7 to 228 bytes)
packet 1: offsets 137, 144, 147, 151, 154, 155, 156, 157, 158, 162, 1077 … 1463
The server_name extension sits at offset 132 of the ClientHello: 00 00 00 10 00 0e 00 00 0b followed by example.com. Its 11 letters run from offset 132 + 9 = 141 to 151: they are split over the first four fragments of packet 1 (137, 144, 147 and 151, of 7, 3, 4 and 3 bytes), while packet 0 stops at 137 and starts again at 166. Shuffling and splitting like this resist middleboxes that look for the name in a single packet. A reader must put the whole ClientHello back together first.
What NetOtter does with it
quic_learn() and its helpers (see Hostname learning):
- Filter: UDP datagrams of 1200 bytes or more, long header, type Initial, version 1 or 2.
- Unprotect and decrypt: derive the keys from the DCID, remove header protection, decrypt (
quic_crypto.c, through OpenSSL). - Collect: read the CRYPTO frames into the connection's buffer, at their offsets (
quic_flows.c). Flows are keyed by addresses and DCID, with a 16 KiB buffer and a bitmap of bytes received each, and 32 flows at most. - Read the name: once the ClientHello is complete, read its SNI with the same parser as TLS over TCP.
- Map it: map the destination address to the name. In the analysis view,
ithen showsDest. host example.com, and thelline shows the app category when the name is a known service.
NetOtter can't go further:
- The Handshake and 1-RTT keys come from a Diffie-Hellman exchange: only the two ends know them.
- With Encrypted Client Hello (ECH), the visible name is the provider's public name, not the real site.
Read more
User guide
- Overview
- Installation
- Getting started
- Command-line options
- Interactive commands
- Display filters
- Analysis view
- Reverse DNS and privacy
- Anomaly detection
- Save files
- Memory and limits
- Troubleshooting
Developer documentation
- Overview
- Building
- Architecture
- OS backends
- Packet parsing
- Packet storage
- Save format
- Hostname learning
- Reverse DNS internals
- Filters and analysis internals
- Anomaly lab
- Coding conventions
- Function explorer
Learning