~/home/study/intro-quic-protocol-fundamentals

Intro to QUIC Protocol: Fundamentals, Handshake & Practical Setup

Learn the core building blocks of QUIC, its packet format, 0-RTT/1-RTT handshakes, version negotiation, transport parameters, and get hands-on with quic-go and msquic. Includes traffic capture, decoding, and security considerations.

Introduction

QUIC (Quick UDP Internet Connections) is a transport-layer protocol that runs on top of UDP and integrates TLS 1.3, multiplexed streams, and connection migration into a single, low-latency design. Originally invented by Google and now standardized by the IETF (RFC 9000), QUIC powers major Internet services such as YouTube, Google Search, and Cloudflare.

Why is QUIC important for security professionals? It eliminates the TCP handshake latency, encrypts all transport headers, and moves many loss-recovery mechanisms into user space, which changes the attack surface dramatically. Understanding QUIC lets you assess modern web services, spot implementation bugs, and craft or defend against emerging threats like amplification attacks or replay of 0-RTT data.

Real-world relevance: By 2024 more than 40 % of global traffic is carried over QUIC. Cloud providers expose QUIC endpoints via HTTP/3, and many enterprise firewalls still treat it as an unknown UDP flow. A solid grasp of QUIC fundamentals is therefore a prerequisite for any modern network-security audit.

Prerequisites

  • TCP/IP fundamentals - IP addressing, UDP/TCP differences, and basic socket concepts.
  • TLS basics - handshake flow, cipher suites, and the concept of forward secrecy.
  • HTTP/2 fundamentals - multiplexed streams, header compression, and why HTTP/3 needed a new transport.

Core Concepts

At a high level QUIC can be split into three layers:

  1. Cryptographic Layer - TLS 1.3 provides key exchange, authentication, and the initial encryption keys.
  2. Transport Layer - Handles packet numbering, loss detection, congestion control, and stream management.
  3. Application Layer - Usually HTTP/3 but could be any protocol that wants reliable, ordered, or unordered streams.

Key properties:

  • Zero-Round-Trip (0-RTT) data: Clients can send encrypted application data in the first flight after they have cached server parameters.
  • Connection migration: Because QUIC is UDP-based, a client can change its IP address (e.g., switch from Wi-Fi to cellular) without tearing down the connection.
  • Built-in encryption of transport headers: Packet numbers, length fields, and stream IDs are all encrypted, making passive analysis harder.

Below is a textual diagram of a QUIC packet (simplified):


+-----------------------------------------------------------+
| Header (1-19 bytes) |
| - Form (Long/Short) |
| - Fixed bits, Type, Version (if Long) |
| - Destination Connection ID (DCID) |
| - Source Connection ID (SCID) |
| - Token (if Retry) |
| - Length |
+-----------------------------------------------------------+
| Payload (encrypted) |
| - Packet Number (PN) |
| - Frame(s) - e.g., STREAM, CRYPTO, ACK, PING, etc. |
+-----------------------------------------------------------+

The header can be either a Long Header (used during handshake) or a Short Header (used after encryption is established). The payload is protected with AEAD using keys derived from the TLS handshake.

QUIC architecture and packet structure

QUIC’s architecture separates concerns through frames. Each frame type serves a purpose:

  • STREAM - carries application data, identified by a stream ID.
  • CRYPTO - carries TLS handshake messages; these are the only unidirectional frames that appear before keys are established.
  • ACK - acknowledges received packet numbers, enabling loss detection.
  • PING - keep-alive, no payload.
  • MAX_DATA, MAX_STREAM_DATA - flow-control limits.
  • CONNECTION_CLOSE - graceful termination.

Because frames are variable-length, a single QUIC packet may contain many frames. This multiplexing eliminates head-of-line blocking that plagued HTTP/2 over TCP.

0-RTT and 1-RTT handshake flow

The handshake can complete in either one round-trip (1-RTT) or zero round-trip (0-RTT) depending on cached TLS state.

1-RTT Handshake (Full TLS 1.3)

  1. Client Hello (CH) - Sent in a QUIC CRYPTO frame inside a Long Header packet. Contains supported versions, initial DCID/SCID, and a random.
  2. Server Hello (SH) + Encrypted Extensions + Certificate + Finished - Server replies with its own DCID/SCID, selects a QUIC version, and sends its TLS handshake messages, also in CRYPTO frames.
  3. Client Finished - After verifying the server’s certificate, the client sends its Finished message.
  4. Both sides now derive 1-RTT keys and switch to Short Header packets for all subsequent traffic.

0-RTT Handshake (Early Data)

If the client has previously completed a full handshake with the same server and stored the session ticket, it can embed early data in the first flight:

  1. Client sends CH with an early_data flag and includes the PSK (pre-shared key) derived from the ticket.
  2. Server validates the ticket. If accepted, it immediately processes the early data and sends its own SH (still in the same packet flow).
  3. Both sides derive 0-RTT keys for the early data and 1-RTT keys for the rest of the connection.

Security note: 0-RTT data is replay-able because the server cannot bind it to a unique handshake. Mitigations include idempotent APIs and server-side replay detection.

Version negotiation and transport parameters

QUIC is versioned to allow future extensions without breaking existing deployments.

  • Version Negotiation: When a server receives a packet with an unsupported version, it replies with a Version Negotiation packet (Long Header, type 0x01) listing the versions it does support. The client then restarts the handshake using one of those versions.
  • Transport Parameters: After the TLS handshake, both endpoints exchange a set of key-value pairs that configure the connection. Example parameters include:
    • initial_max_stream_data_bidi_local - flow-control limit for locally-initiated bidirectional streams.
    • max_idle_timeout - maximum idle time before closing.
    • disable_active_migration - whether the client may change IP addresses.
    • preferred_address - a server-provided address for migration.

Transport parameters are encoded as a series of varint length-prefixed fields, making the format extensible.

Setting up a local QUIC server/client (quic-go, msquic)

Below are step-by-step instructions for spinning up a minimal QUIC echo server using the Go library quic-go, and a Windows client using Microsoft’s msquic library.

Prerequisite Installation

# Install Go (>=1.20)
brew install go # macOS
# Or on Ubuntu
sudo apt-get update && sudo apt-get install -y golang

# Get quic-go
go get -u github.com/lucas-clemente/quic-go

# Install msquic (requires Visual Studio Build Tools on Windows)
# See https://github.com/microsoft/msquic for detailed steps

quic-go Server (Go)

package main

import ( "crypto/tls" "log" "net" "github.com/lucas-clemente/quic-go"
)

func generateTLSConfig() *tls.Config { // For demo purposes we generate a self-signed cert on the fly. cert, err := tls.LoadX509KeyPair("server.crt", "server.key") if err != nil { log.Fatalf("failed to load cert: %v", err) } return &tls.Config{Certificates: []tls.Certificate{cert}, NextProtos: []string{"quic-echo"}}
}

func main() { listener, err := quic.ListenAddr("localhost:4242", generateTLSConfig(), nil) if err != nil { log.Fatalf("listen error: %v", err) } log.Println("QUIC echo server listening on :4242") for { sess, err := listener.Accept(context.Background()) if err != nil { log.Printf("session accept error: %v", err) continue } go handleSession(sess) }
}

func handleSession(sess quic.Session) { defer sess.CloseWithError(0, "bye") for { stream, err := sess.AcceptStream(context.Background()) if err != nil { log.Printf("stream accept error: %v", err) return } go echoStream(stream) }
}

func echoStream(s quic.Stream) { defer s.Close() buf := make([]byte, 1024) for { n, err := s.Read(buf) if err != nil { return } s.Write(buf[:n]) // echo back }
}

Compile and run:

go run echo_server.go

msquic Client (C)

The following C snippet creates a QUIC client that connects to the Go server and sends a single message.

#include <msquic.h>
#include <stdio.h>
#include <string.h>

static const char* SERVER_NAME = "localhost";
static const uint16_t SERVER_PORT = 4242;
static const char* MESSAGE = "Hello QUIC!";

static HQUIC Configuration;
static HQUIC Connection;
static HQUIC Stream;

static const QUIC_BUFFER Alpn = { (uint16_t)strlen("quic-echo"), (uint8_t*)"quic-echo" };

static void CALLBACK StreamCallback(HQUIC Stream, void* Context, QUIC_STREAM_EVENT* Event) { switch (Event->Type) { case QUIC_STREAM_EVENT_SEND_COMPLETE: printf("Data sent, shutting down stream...
"); MsQuic->StreamShutdown(Stream, QUIC_STREAM_SHUTDOWN_FLAG_GRACEFUL, 0); break; case QUIC_STREAM_EVENT_RECEIVE: printf("Received %llu bytes: %.*s
", (unsigned long long)Event->RECEIVE.TotalBufferLength, (int)Event->RECEIVE.TotalBufferLength, (char*)Event->RECEIVE.Buffers[0].Buffer); break; case QUIC_STREAM_EVENT_SHUTDOWN_COMPLETE: printf("Stream closed.
"); MsQuic->ConnectionClose(Connection); break; default: break; }
}

static void CALLBACK ConnectionCallback(HQUIC Conn, void* Context, QUIC_CONNECTION_EVENT* Event) { switch (Event->Type) { case QUIC_CONNECTION_EVENT_CONNECTED: printf("Connected to %s:%u
", SERVER_NAME, SERVER_PORT); MsQuic->StreamOpen(Conn, QUIC_STREAM_OPEN_FLAG_NONE, StreamCallback, NULL, &Stream); MsQuic->StreamStart(Stream, QUIC_STREAM_START_FLAG_IMMEDIATE); { QUIC_BUFFER buf = { (uint32_t)strlen(MESSAGE), (uint8_t*)MESSAGE }; MsQuic->StreamSend(Stream, &buf, 1, QUIC_SEND_FLAG_FIN, NULL); } break; case QUIC_CONNECTION_EVENT_SHUTDOWN_COMPLETE: printf("Connection closed.
"); MsQuic->Close(&Configuration); MsQuic->Close(&Connection); break; default: break; }
}

int main() { const QUIC_API_TABLE* MsQuic = NULL; if (QUIC_FAILED(MsQuicOpenVersion(&MsQuic, QUIC_API_VERSION))) { printf("Failed to open msquic API
"); return -1; } QUIC_SETTINGS Settings = {0}; Settings.IdleTimeoutMs = 5000; // 5 seconds idle timeout if (QUIC_FAILED(MsQuic->ConfigurationOpen(NULL, &Alpn, 1, &Settings, sizeof(Settings), NULL, &Configuration))) { printf("Configuration open failed
"); return -1; } if (QUIC_FAILED(MsQuic->ConnectionOpen(Configuration, ConnectionCallback, NULL, &Connection))) { printf("Connection open failed
"); return -1; } QUIC_ADDR Addr = {0}; QuicAddrSetFamily(&Addr, QUIC_ADDRESS_FAMILY_INET6); QuicAddrSetPort(&Addr, SERVER_PORT); QuicAddrSetFromString(&Addr, SERVER_NAME); MsQuic->ConnectionStart(Connection, Configuration, QUIC_ADDRESS_FAMILY_UNSPEC, SERVER_NAME, SERVER_PORT); // Block until connection shutdown. MsQuic->SetParam(Connection, QUIC_PARAM_LEVEL_CONNECTION, QUIC_PARAM_CONN_IDLE_TIMEOUT_MS, sizeof(Settings.IdleTimeoutMs), &Settings.IdleTimeoutMs); // Wait for shutdown (simplified). getchar(); return 0;
}

Compile with MSVC linking against msquic.lib. When run, you should see the echo of Hello QUIC! printed by the client.

Capturing and decoding QUIC traffic with Wireshark/qlog

Because QUIC encrypts most of its payload, traditional packet inspection shows only the header and encrypted payload. However, Wireshark (v3.6+) can decode the header and display CRYPTO frames when the TLS keys are provided.

Step-by-step capture

  1. Start Wireshark and set a capture filter for UDP port 4242 (or the port you used): udp port 4242.
  2. Run the server and client from the previous sections.
  3. Locate a packet with a Long Header. Wireshark will label it “QUIC Initial”.
  4. Export the TLS session keys from the client. For quic-go you can set the environment variable SSLKEYLOGFILE to capture keys:
export SSLKEYLOGFILE=$HOME/quic_keys.log
go run echo_server.go & # the client will automatically write keys
  1. In Wireshark, go to Edit → Preferences → Protocols → TLS and add the path to quic_keys.log. Wireshark will now decrypt the CRYPTO frames and show the TLS handshake details.
  2. To view the raw QUIC frames in a portable format, enable qlog export in the client (quic-go supports it via the --qlog flag). The resulting .qlog file can be visualized with qlog viewer.

Key observations you can make:

  • Packet numbers are not sequential due to the variable-length encoding.
  • Retransmissions are marked with the same packet number but different ACK ranges.
  • 0-RTT data appears as a STREAM frame in the first packet after the server’s SH.

Practical Examples

Below are three realistic scenarios that illustrate how QUIC is used and how its quirks affect security testing.

Example 1 - Detecting QUIC-based DoS amplification

Because QUIC runs over UDP, a poorly-configured firewall may allow large unverified packets. An attacker can send a burst of 0-RTT packets with minimal payload, forcing the server to perform costly crypto operations.

# Simple amplification test using h2load (QUIC support) on a remote host
h2load -n 10000 -c 1000 -m https://target.example.com:443 -p quic

Mitigation: Rate-limit UDP 443 traffic, enforce a strict max_idle_timeout, and configure the server to require a valid token before processing 0-RTT.

Example 2 - Replaying 0-RTT requests

Capture a 0-RTT request with Wireshark, then resend it using a raw UDP tool (e.g., scapy) to see if the server processes it twice.

from scapy.all import *

# Load a captured packet (pcap) and resend
pkts = rdpcap('quic_0rtt.pcap')
for pkt in pkts: sendp(pkt)

If the server is a payment endpoint, this could lead to double-charging. Deploy anti-replay tokens at the application layer or disable 0-RTT for non-idempotent APIs.

Example 3 - Bypassing network IDS

Traditional IDS signatures look for TCP SYN, FIN, and known HTTP/2 frames. QUIC traffic appears as opaque UDP, so many IDS miss it. By configuring a rule to flag unusually large UDP packets (>1350 bytes) on port 443, you gain visibility.

# Suricata rule example
alert udp any any -> any 443 (msg:"Potential QUIC traffic"; dsize:>1350; sid:1000001; rev:1;)

Tools & Commands

  • quic-go - Go implementation; provides qlog support and SSLKEYLOGFILE integration.
  • msquic - Native C library for Windows and Linux; useful for low-level testing.
  • Wireshark - Decode QUIC headers; requires TLS key log file for decryption.
  • qlog - JSON-based logging format; can be visualized with qlog viewer.
  • h2load - HTTP/2 and HTTP/3 load testing tool (part of nghttp2).
  • scapy - Python library for crafting and replaying raw QUIC packets.

Sample command to start a QUIC echo server with logging enabled:

SSLKEYLOGFILE=~/quic_keys.log go run echo_server.go &

Capture traffic and write to a pcap:

sudo tcpdump -i any udp port 4242 -w quic_demo.pcap

Defense & Mitigation

  • Control 0-RTT exposure: Disable 0-RTT for non-idempotent endpoints via the disable_0rtt transport parameter.
  • Enforce token validation: Require a Retry packet for first-time clients to mitigate amplification.
  • Rate-limit UDP 443: Apply per-source IP throttling on edge firewalls.
  • Monitor QUIC connection IDs: Unexpected changes may indicate hijacking or migration attacks.
  • Patch libraries promptly: QUIC implementations have a history of CVEs (e.g., CVE-2022-46169 in quic-go).

Common Mistakes

  • Assuming Wireshark will automatically decrypt QUIC payload - you must provide TLS keys.
  • Confusing QUIC version numbers with HTTP/3 version - they are independent.
  • Neglecting to set max_idle_timeout - leads to idle connections that consume resources.
  • Reusing the same session ticket across different services - can cause cross-service replay.
  • Forgetting that UDP is connectionless - firewalls must track state manually for QUIC.

Real-World Impact

Enterprises that have migrated to HTTP/3 often see a 30 % reduction in latency for mobile users due to connection migration. However, the same feature opens avenues for session hijacking if the connection ID is guessed. In 2023, a major CDN suffered a brief outage when a mis-configured rate-limit allowed an attacker to flood the edge with 0-RTT packets, exhausting CPU on the TLS handshake.

My experience consulting for a fintech firm showed that disabling 0-RTT for payment APIs eliminated a replay vulnerability without perceptible performance loss, because the additional round-trip was masked by the overall transaction time.

Trend outlook: As HTTP/3 adoption climbs, expect more IDS/NGFW vendors to ship native QUIC inspection modules. Attackers will likely focus on abusing the migration feature to bypass IP-based blacklists, so monitoring connection ID entropy will become a best practice.

Practice Exercises

  1. Build a QUIC echo service using quic-go. Capture the handshake with Wireshark, provide the key log, and verify you can see the TLS ClientHello and ServerHello inside the CRYPTO frames.
  2. Replay a 0-RTT request: Record a 0-RTT packet from a client, then resend it with scapy. Observe server behavior and document whether the request was processed twice.
  3. Implement connection migration: Start the client on Wi-Fi, then switch to Ethernet without closing the QUIC connection. Use msquic’s API to trigger QUIC_CONNECTION_EVENT_MIGRATED and confirm the server continues without a new handshake.
  4. Write a qlog parser: Take the .qlog file generated by quic-go and extract the round-trip time (RTT) per packet. Plot the results with Python’s matplotlib.

Further Reading

  • RFC 9000 - QUIC: A UDP-Based Multiplexed Transport
  • RFC 9001 - Using TLS to Secure QUIC
  • QUIC Working Group drafts - for upcoming extensions like QUIC-Loss-Recovery.
  • “QUIC Security Considerations” - IETF draft draft-ietf-quic-security-23.
  • Blog post: Understanding QUIC at Cloudflare
  • GitHub: quic-go repository - explore test vectors.

Summary

QUIC merges transport and cryptographic responsibilities into a single UDP-based protocol, delivering low-latency connections while complicating traditional network security analysis. Mastering its packet structure, handshake (0-RTT & 1-RTT), version negotiation, and transport parameters equips security professionals to audit modern services, detect abuse, and apply robust mitigations. Practical hands-on with quic-go and msquic, combined with Wireshark/qlog decoding, closes the knowledge loop and prepares you for the evolving QUIC-centric threat landscape.