From 5fda34a4761d8dc002a9cdf6b47e1e7ad766b5c8 Mon Sep 17 00:00:00 2001 From: hayzam Date: Wed, 11 Feb 2026 16:11:12 +0530 Subject: [PATCH] analyzer: make http3/quic handling more reliable --- analyzer/udp/internal/quic/payload.go | 93 +++++++++++++++++++++------ analyzer/udp/quic.go | 43 ++++++++++++- 2 files changed, 114 insertions(+), 22 deletions(-) diff --git a/analyzer/udp/internal/quic/payload.go b/analyzer/udp/internal/quic/payload.go index 75d9da8..6bb670f 100644 --- a/analyzer/udp/internal/quic/payload.go +++ b/analyzer/udp/internal/quic/payload.go @@ -12,6 +12,16 @@ import ( "golang.org/x/crypto/hkdf" ) +var defaultPNMaxGuesses = []int64{ + 0, 1, 2, 3, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, +} + +type ReadCryptoFramesOptions struct { + AdditionalConnectionIDs [][]byte + TryServerSecret bool + PacketNumberMaxGuesses []int64 +} + func ReadCryptoPayload(packet []byte) ([]byte, error) { frs, err := ReadCryptoFrames(packet) if err != nil { @@ -26,6 +36,11 @@ func ReadCryptoPayload(packet []byte) ([]byte, error) { // ReadCryptoFrames decrypts a QUIC Initial client packet and returns CRYPTO frames. func ReadCryptoFrames(packet []byte) ([]CryptoFrame, error) { + return ReadCryptoFramesWithOptions(packet, nil) +} + +// ReadCryptoFramesWithOptions decrypts a QUIC Initial packet and returns CRYPTO frames. +func ReadCryptoFramesWithOptions(packet []byte, opts *ReadCryptoFramesOptions) ([]CryptoFrame, error) { hdr, offset, err := ParseInitialHeader(packet) if err != nil { return nil, err @@ -38,13 +53,6 @@ func ReadCryptoFrames(packet []byte) ([]CryptoFrame, error) { return nil, errors.New("invalid packet") } - initialSecret := hkdf.Extract(crypto.SHA256.New, hdr.DestConnectionID, getSalt(hdr.Version)) - clientSecret := hkdfExpandLabel(crypto.SHA256.New, initialSecret, "client in", []byte{}, crypto.SHA256.Size()) - key, err := NewInitialProtectionKey(clientSecret, hdr.Version) - if err != nil { - return nil, fmt.Errorf("NewInitialProtectionKey: %w", err) - } - pp := NewPacketProtector(key) // https://datatracker.ietf.org/doc/html/draft-ietf-quic-tls-32#name-client-initial // // "The unprotected header includes the connection ID and a 4-byte packet number encoding for a packet number of 2" @@ -52,21 +60,49 @@ func ReadCryptoFrames(packet []byte) ([]CryptoFrame, error) { return nil, fmt.Errorf("packet is too short: %d < %d", len(packet), offset+hdr.Length) } packetView := packet[:offset+hdr.Length] - pnMaxGuesses := []int64{0, 1, 2, 3, 4, 8, 16} + + candidateConnIDs := [][]byte{hdr.DestConnectionID} + if opts != nil { + candidateConnIDs = append(candidateConnIDs, opts.AdditionalConnectionIDs...) + } + candidateConnIDs = uniqueNonEmptyConnectionIDs(candidateConnIDs) + + pnMaxGuesses := defaultPNMaxGuesses + if opts != nil && len(opts.PacketNumberMaxGuesses) > 0 { + pnMaxGuesses = opts.PacketNumberMaxGuesses + } + + labels := []string{"client in"} + if opts != nil && opts.TryServerSecret { + labels = append(labels, "server in") + } + var lastErr error - for _, pnMax := range pnMaxGuesses { - packetCopy := append([]byte(nil), packetView...) - unProtectedPayload, err := pp.UnProtect(packetCopy, offset, pnMax) - if err != nil { - lastErr = err - continue + for _, connID := range candidateConnIDs { + initialSecret := hkdf.Extract(crypto.SHA256.New, connID, getSalt(hdr.Version)) + for _, label := range labels { + secret := hkdfExpandLabel(crypto.SHA256.New, initialSecret, label, []byte{}, crypto.SHA256.Size()) + key, err := NewInitialProtectionKey(secret, hdr.Version) + if err != nil { + lastErr = fmt.Errorf("NewInitialProtectionKey: %w", err) + continue + } + pp := NewPacketProtector(key) + for _, pnMax := range pnMaxGuesses { + packetCopy := append([]byte(nil), packetView...) + unProtectedPayload, err := pp.UnProtect(packetCopy, offset, pnMax) + if err != nil { + lastErr = err + continue + } + frs, err := extractCryptoFrames(bytes.NewReader(unProtectedPayload)) + if err != nil { + lastErr = err + continue + } + return frs, nil + } } - frs, err := extractCryptoFrames(bytes.NewReader(unProtectedPayload)) - if err != nil { - lastErr = err - continue - } - return frs, nil } if lastErr != nil { return nil, lastErr @@ -301,6 +337,23 @@ func skipN(r *bytes.Reader, n uint64) error { return err } +func uniqueNonEmptyConnectionIDs(ids [][]byte) [][]byte { + out := make([][]byte, 0, len(ids)) + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + if len(id) == 0 { + continue + } + k := string(id) + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + out = append(out, append([]byte(nil), id...)) + } + return out +} + // assembleCryptoFrames assembles multiple crypto frames into a single slice (if possible). // It returns an error if the frames cannot be assembled. This can happen if the frames are not contiguous. func assembleCryptoFrames(frames []CryptoFrame) []byte { diff --git a/analyzer/udp/quic.go b/analyzer/udp/quic.go index cb51fc8..2e39f0d 100644 --- a/analyzer/udp/quic.go +++ b/analyzer/udp/quic.go @@ -1,6 +1,7 @@ package udp import ( + "bytes" "errors" "sort" @@ -43,6 +44,7 @@ type quicStream struct { debugCount int frames map[int64][]byte maxEnd int64 + connIDs [][]byte } func (s *quicStream) Feed(rev bool, data []byte) (u *analyzer.PropUpdate, done bool) { @@ -51,13 +53,34 @@ func (s *quicStream) Feed(rev bool, data []byte) (u *analyzer.PropUpdate, done b // + compression methods (2 bytes) + no extensions const minDataSize = 41 - frs, err := quic.ReadCryptoFrames(data) + var parsedHdr *quic.Header + if hdr, _, err := quic.ParseInitialHeader(data); err == nil { + parsedHdr = hdr + s.rememberConnID(hdr.DestConnectionID) + s.rememberConnID(hdr.SrcConnectionID) + } + frs, err := quic.ReadCryptoFramesWithOptions(data, &quic.ReadCryptoFramesOptions{ + AdditionalConnectionIDs: s.connIDs, + TryServerSecret: true, + }) if err != nil { if errors.Is(err, quic.ErrNotInitialPacket) { return nil, false } if s.debugCount < 4 { - s.logger.Debugf("failed to read QUIC CRYPTO frames: %v", err) + if parsedHdr != nil { + s.logger.Debugf( + "failed to read QUIC CRYPTO frames: version=%x dcid_len=%d scid_len=%d token_len=%d conn_id_hints=%d err=%v", + parsedHdr.Version, + len(parsedHdr.DestConnectionID), + len(parsedHdr.SrcConnectionID), + len(parsedHdr.Token), + len(s.connIDs), + err, + ) + } else { + s.logger.Debugf("failed to read QUIC CRYPTO frames: %v", err) + } s.debugCount++ } s.invalidCount++ @@ -164,3 +187,19 @@ func (s *quicStream) contiguousPayloadFromZero() []byte { } return out } + +func (s *quicStream) rememberConnID(cid []byte) { + if len(cid) == 0 { + return + } + for _, existing := range s.connIDs { + if bytes.Equal(existing, cid) { + return + } + } + // Keep this bounded; QUIC CID rotations are small for handshake parsing. + if len(s.connIDs) >= 8 { + s.connIDs = s.connIDs[1:] + } + s.connIDs = append(s.connIDs, append([]byte(nil), cid...)) +}