From 3af3e356d6fdf43e6772dc2e91b322f8e8148f62 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Sun, 16 Nov 2025 22:17:00 +0200 Subject: Add a cleartext-only QUIC dissector; fix a port-collision bug in L7Registry QUIC decodes only what RFC 9000 sends in cleartext at the framing level: long/short header form, version, long-packet type, and both connection IDs. Everything past that is encrypted from the first protected byte onward, even for Initial packets - decrypting that is real crypto machinery this project deliberately doesn't take on, the same call already made for TLS's SNI-only extraction. Caught a real, previously-latent bug while wiring this in, by design review rather than live-traffic debugging: L7Registry::dissect() returned on the first dissector whose port() matched, even if that dissector's summarize() then failed. Harmless while every registered port was unique, but QUIC is the first protocol here to genuinely share a well-known port with something already registered (443: TLS over TCP, QUIC over UDP - the registry has no transport dimension). Without the fix, tls_dissector would silently claim every port-443 lookup and QUIC would never be reachable. Fixed to try each same-port dissector until one actually succeeds, locked in with stub-dissector tests independent of any real protocol's parsing. Live-verified thoroughly: real HTTP/3 traffic to google.com via `curl --http3-only`, captured on wlp1s0, correctly decoding the full connection lifecycle against Google's actual production QUIC implementation - Initial packets (including a genuine connection ID migration mid-handshake), Handshake packets, and 1-RTT short-header packets. This also confirms the L7Registry fix live: without it none of this would have decoded at all. --- CMakeLists.txt | 2 + PLAN.md | 44 +++++++++++- include/packeteer/l7/dissector.hpp | 11 ++- include/packeteer/l7/quic.hpp | 143 +++++++++++++++++++++++++++++++++++++ include/packeteer/summarize.hpp | 9 +++ tests/test_dissector.cpp | 74 +++++++++++++++++++ tests/test_quic.cpp | 97 +++++++++++++++++++++++++ 7 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 include/packeteer/l7/quic.hpp create mode 100644 tests/test_dissector.cpp create mode 100644 tests/test_quic.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index bcf68b1..d08d6ad 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -108,6 +108,8 @@ add_executable(packeteer_tests tests/test_ftp.cpp tests/test_smtp.cpp tests/test_tftp.cpp + tests/test_quic.cpp + tests/test_dissector.cpp tests/test_http.cpp tests/test_tls.cpp tests/test_pcapng.cpp diff --git a/PLAN.md b/PLAN.md index 3fa8c1d..392e02e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -30,8 +30,10 @@ unowned buffers) via a real-world capture pipeline. 3. [done] pcapng read/write 4. [done] Bounded channel + drop-on-backpressure between capture and render 5. [in progress] L7 dissector interface, add protocols incrementally -- - interface + DNS + HTTP + TLS SNI + mDNS + SSH banner done - (packeteer/l7/); more protocols can still be added incrementally, + interface + DNS + HTTP + TLS SNI + mDNS + SSH banner + NTP + DHCP + + FTP + SMTP + TFTP + QUIC done (packeteer/l7/); ARP/VLAN/IGMP done + at the L2/L3 level too (packeteer/net/); more protocols can still + be added incrementally, by design 6. [done] Filtering (-f , libpcap's own BPF compiler - see Decisions) 7. [done] Drop privileges after opening the capture handle (see Decisions) @@ -500,3 +502,41 @@ None currently open. re-verified against the same live traffic afterward, confirmed clean, and a regression test locks in the exact byte pattern that triggered it. +- QUIC (l7/quic.hpp), decoding only what RFC 9000 actually sends in + cleartext at the framing level: long vs. short header form, version, + long-packet type (Initial/0-RTT/Handshake/Retry/Version + Negotiation), and both connection IDs. Everything past that -- + packet numbers, frames, the payload - is encrypted from the first + protected byte onward, even for Initial packets (whose keys derive + via HKDF from a public per-version salt, then AES-GCM); actually + decrypting that is real crypto machinery this project deliberately + doesn't take on, the same call already made for TLS's SNI-only + extraction. A short-header packet's destination connection ID has no + length field in the packet itself - the receiver already knows it + from earlier connection state a passive observer doesn't have - so + short-header packets are reported by form alone. + Discovered and fixed a real, previously-latent bug while wiring this + in, caught by design review before it ever touched live traffic: + L7Registry::dissect() returned on the *first* dissector whose + port() matched, even if that dissector's summarize() then failed -- + harmless while every registered port was unique, but QUIC is the + first protocol here to genuinely share a well-known port with + something else already registered (443: TLS over TCP, QUIC over + UDP; the registry has no transport dimension, only a port number). + Without the fix, tls_dissector (registered first) would silently + claim every port-443 lookup and return nullopt for all QUIC traffic, + forever, regardless of registration order past it. Fixed to try each + same-port dissector until one actually succeeds; locked in with + tests/test_dissector.cpp using two stub dissectors on a shared port, + independent of any real protocol's parsing logic. + Live-verified about as thoroughly as anything in this project: real + HTTP/3 traffic to google.com via `curl --http3-only` (curl here + links ngtcp2/nghttp3), captured on wlp1s0, correctly decoding the + full connection lifecycle - Initial packets (including a genuine + connection ID migration mid-handshake, dcid changing from + a2d81abd... to e2d81abd..., real QUIC behavior, not a parsing + artifact), Handshake packets, and finally 1-RTT short-header + packets - against Google's actual production QUIC implementation. + This also serves as a real-traffic confirmation that the + L7Registry fix works: without it, none of this would have decoded + at all, since tls_dissector claims port 443 first. diff --git a/include/packeteer/l7/dissector.hpp b/include/packeteer/l7/dissector.hpp index e918baf..b640fcc 100644 --- a/include/packeteer/l7/dissector.hpp +++ b/include/packeteer/l7/dissector.hpp @@ -30,10 +30,19 @@ class L7Registry { public: void add(const L7Dissector* dissector) { dissectors_.push_back(dissector); } + // Tries every dissector registered for `port`, not just the + // first: two different protocols can genuinely share a + // well-known port number when one runs over TCP and the other + // over UDP (443 is TLS/HTTPS over TCP *and* QUIC/HTTP3 over UDP) + // - this registry has no transport dimension, only a port + // number, so without this a dissector registered earlier for the + // same port would permanently shadow a later one the moment both + // exist, even on payloads the earlier one can't actually parse. std::optional dissect(std::uint16_t port, std::span payload) const { for (const auto* dissector : dissectors_) { - if (dissector->port() == port) return dissector->summarize(payload); + if (dissector->port() != port) continue; + if (auto summary = dissector->summarize(payload)) return summary; } return std::nullopt; } diff --git a/include/packeteer/l7/quic.hpp b/include/packeteer/l7/quic.hpp new file mode 100644 index 0000000..9c9ac85 --- /dev/null +++ b/include/packeteer/l7/quic.hpp @@ -0,0 +1,143 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "packeteer/byteio.hpp" +#include "packeteer/l7/dissector.hpp" + +// RFC 9000 QUIC, decoding only what's genuinely sent in cleartext at +// the framing level: whether a packet uses the long or short header +// form, its version and long-packet type (Initial/0-RTT/Handshake/ +// Retry/Version Negotiation), and the connection IDs. Packet numbers, +// frames, and the payload itself are encrypted from the very first +// protected byte onward - even for Initial packets, whose keys are +// derived via HKDF from a public per-version salt and then used for +// AES-GCM. Actually decrypting that is real, substantial crypto +// machinery this project deliberately doesn't take on, the same call +// already made for TLS: SNI is read because it's a plaintext +// extension in ClientHello; nothing past the handshake is ever +// decrypted there either. +// +// A short-header packet's destination connection ID has no length +// field in the packet itself - the receiver already knows it from +// earlier connection state (a length one endpoint chose and +// communicated during the handshake). A passive observer with no +// connection state genuinely cannot know where it ends, so +// short-header packets are reported by form alone, nothing decoded +// further. +namespace packeteer::net { + +inline constexpr std::uint16_t kQuicPort = 443; + +enum class QuicLongPacketType { + kVersionNegotiation, + kInitial, + kZeroRtt, + kHandshake, + kRetry, +}; + +struct QuicLongHeader { + QuicLongPacketType type; + std::uint32_t version; + std::vector dcid; + std::vector scid; +}; + +struct QuicPacket { + bool is_long_header; + std::optional long_header; // set only when is_long_header +}; + +inline std::optional parse_quic(std::span bytes) { + if (bytes.empty()) return std::nullopt; + + std::uint8_t first = bytes[0]; + if ((first & 0x40) == 0) return std::nullopt; // Fixed Bit must be 1 (RFC 9000 17.2/17.3) + + bool is_long = (first & 0x80) != 0; + if (!is_long) return QuicPacket{false, std::nullopt}; + + if (bytes.size() < 5) return std::nullopt; // header form byte + 4-byte version + std::uint32_t version = read_be32(bytes, 1); + + std::size_t pos = 5; + if (pos >= bytes.size()) return std::nullopt; + std::uint8_t dcid_len = bytes[pos++]; + if (pos + dcid_len > bytes.size()) return std::nullopt; + std::vector dcid(bytes.begin() + pos, bytes.begin() + pos + dcid_len); + pos += dcid_len; + + if (pos >= bytes.size()) return std::nullopt; + std::uint8_t scid_len = bytes[pos++]; + if (pos + scid_len > bytes.size()) return std::nullopt; + std::vector scid(bytes.begin() + pos, bytes.begin() + pos + scid_len); + + QuicLongPacketType type; + if (version == 0) { + // Version Negotiation (RFC 9000 17.2.1): the long-packet-type + // bits aren't meaningful here - this packet form predates + // that field's assignment and repurposes the whole byte. + type = QuicLongPacketType::kVersionNegotiation; + } else { + switch ((first >> 4) & 0x03) { + case 0: type = QuicLongPacketType::kInitial; break; + case 1: type = QuicLongPacketType::kZeroRtt; break; + case 2: type = QuicLongPacketType::kHandshake; break; + default: type = QuicLongPacketType::kRetry; break; + } + } + + return QuicPacket{true, QuicLongHeader{type, version, std::move(dcid), std::move(scid)}}; +} + +inline std::string quic_long_type_name(QuicLongPacketType type) { + switch (type) { + case QuicLongPacketType::kVersionNegotiation: return "Version Negotiation"; + case QuicLongPacketType::kInitial: return "Initial"; + case QuicLongPacketType::kZeroRtt: return "0-RTT"; + case QuicLongPacketType::kHandshake: return "Handshake"; + case QuicLongPacketType::kRetry: return "Retry"; + } + return "unknown"; // unreachable: every enumerator is handled above +} + +inline std::string quic_bytes_to_hex(std::span bytes) { + static constexpr char kHex[] = "0123456789abcdef"; + std::string out; + out.reserve(bytes.size() * 2); + for (auto b : bytes) { + out += kHex[b >> 4]; + out += kHex[b & 0x0F]; + } + return out; +} + +class QuicDissector : public L7Dissector { +public: + std::uint16_t port() const override { return kQuicPort; } + + std::optional summarize(std::span payload) const override { + auto pkt = parse_quic(payload); + if (!pkt) return std::nullopt; + + if (!pkt->is_long_header) return std::string("QUIC 1-RTT (short header)"); + + const auto& h = *pkt->long_header; + std::string out = "QUIC " + quic_long_type_name(h.type); + if (h.type != QuicLongPacketType::kVersionNegotiation) { + char buf[16]; + std::snprintf(buf, sizeof(buf), " v=0x%08x", h.version); + out += buf; + } + out += " dcid=" + (h.dcid.empty() ? "(empty)" : quic_bytes_to_hex(h.dcid)); + return out; + } +}; + +} // namespace packeteer::net diff --git a/include/packeteer/summarize.hpp b/include/packeteer/summarize.hpp index 66b2e18..57f6f6f 100644 --- a/include/packeteer/summarize.hpp +++ b/include/packeteer/summarize.hpp @@ -16,6 +16,7 @@ #include "packeteer/l7/http.hpp" #include "packeteer/l7/mdns.hpp" #include "packeteer/l7/ntp.hpp" +#include "packeteer/l7/quic.hpp" #include "packeteer/l7/smtp.hpp" #include "packeteer/l7/ssh.hpp" #include "packeteer/l7/tftp.hpp" @@ -109,6 +110,7 @@ inline const net::L7Registry& l7_registry() { static const net::FtpDissector ftp_dissector; static const net::SmtpDissector smtp_dissector; static const net::TftpDissector tftp_dissector; + static const net::QuicDissector quic_dissector; static const net::L7Registry registry = [] { net::L7Registry r; r.add(&dns_dissector); @@ -121,6 +123,13 @@ inline const net::L7Registry& l7_registry() { r.add(&ftp_dissector); r.add(&smtp_dissector); r.add(&tftp_dissector); + // Registered after tls_dissector deliberately: both claim + // port 443 (TLS over TCP, QUIC over UDP) - see + // L7Registry::dissect()'s fallback-on-no-match behavior for + // why registration order past the first claimant doesn't + // actually matter for correctness, just for which one gets + // tried first. + r.add(&quic_dissector); return r; }(); return registry; diff --git a/tests/test_dissector.cpp b/tests/test_dissector.cpp new file mode 100644 index 0000000..9dd4135 --- /dev/null +++ b/tests/test_dissector.cpp @@ -0,0 +1,74 @@ +#include + +#include "packeteer/l7/dissector.hpp" + +using namespace packeteer::net; + +namespace { + +// A dissector that claims a port but never actually matches any +// payload - stands in for e.g. TlsSniDissector receiving QUIC bytes +// on port 443: same port, wrong protocol, never succeeds. +class NeverMatchesDissector : public L7Dissector { +public: + explicit NeverMatchesDissector(std::uint16_t port) : port_(port) {} + std::uint16_t port() const override { return port_; } + std::optional summarize(std::span) const override { + return std::nullopt; + } + +private: + std::uint16_t port_; +}; + +class AlwaysMatchesDissector : public L7Dissector { +public: + AlwaysMatchesDissector(std::uint16_t port, std::string label) + : port_(port), label_(std::move(label)) {} + std::uint16_t port() const override { return port_; } + std::optional summarize(std::span) const override { + return label_; + } + +private: + std::uint16_t port_; + std::string label_; +}; + +} // namespace + +TEST_CASE("L7Registry falls through to a later dissector on the same port " + "when an earlier one fails to match") { + NeverMatchesDissector tls_like(443); + AlwaysMatchesDissector quic_like(443, "QUIC something"); + + L7Registry registry; + registry.add(&tls_like); + registry.add(&quic_like); + + auto result = registry.dissect(443, {}); + REQUIRE(result.has_value()); + CHECK(*result == "QUIC something"); +} + +TEST_CASE("L7Registry still returns the first dissector to succeed, not the last") { + AlwaysMatchesDissector first(80, "first"); + AlwaysMatchesDissector second(80, "second"); + + L7Registry registry; + registry.add(&first); + registry.add(&second); + + auto result = registry.dissect(80, {}); + REQUIRE(result.has_value()); + CHECK(*result == "first"); +} + +TEST_CASE("L7Registry returns nullopt when no dissector on the port matches") { + NeverMatchesDissector only(443); + + L7Registry registry; + registry.add(&only); + + CHECK_FALSE(registry.dissect(443, {}).has_value()); +} diff --git a/tests/test_quic.cpp b/tests/test_quic.cpp new file mode 100644 index 0000000..2366176 --- /dev/null +++ b/tests/test_quic.cpp @@ -0,0 +1,97 @@ +#include + +#include + +#include "packeteer/l7/quic.hpp" + +using namespace packeteer::net; + +namespace { + +std::vector long_header(std::uint8_t type_bits, std::uint32_t version, + std::vector dcid, + std::vector scid) { + std::vector bytes; + bytes.push_back(static_cast(0xC0 | (type_bits << 4))); // long form, fixed bit + bytes.push_back(static_cast(version >> 24)); + bytes.push_back(static_cast(version >> 16)); + bytes.push_back(static_cast(version >> 8)); + bytes.push_back(static_cast(version)); + bytes.push_back(static_cast(dcid.size())); + bytes.insert(bytes.end(), dcid.begin(), dcid.end()); + bytes.push_back(static_cast(scid.size())); + bytes.insert(bytes.end(), scid.begin(), scid.end()); + return bytes; +} + +} // namespace + +TEST_CASE("parse_quic decodes a long-header Initial packet's version and connection IDs") { + auto bytes = long_header(0x00, 0x00000001, {0xAA, 0xBB, 0xCC, 0xDD}, {0x11, 0x22}); + auto pkt = parse_quic(bytes); + REQUIRE(pkt.has_value()); + CHECK(pkt->is_long_header); + REQUIRE(pkt->long_header.has_value()); + CHECK(pkt->long_header->type == QuicLongPacketType::kInitial); + CHECK(pkt->long_header->version == 0x00000001); + CHECK(pkt->long_header->dcid == std::vector{0xAA, 0xBB, 0xCC, 0xDD}); + CHECK(pkt->long_header->scid == std::vector{0x11, 0x22}); +} + +TEST_CASE("parse_quic decodes each long-packet type from its type bits") { + CHECK(parse_quic(long_header(0x00, 1, {}, {}))->long_header->type == + QuicLongPacketType::kInitial); + CHECK(parse_quic(long_header(0x01, 1, {}, {}))->long_header->type == + QuicLongPacketType::kZeroRtt); + CHECK(parse_quic(long_header(0x02, 1, {}, {}))->long_header->type == + QuicLongPacketType::kHandshake); + CHECK(parse_quic(long_header(0x03, 1, {}, {}))->long_header->type == + QuicLongPacketType::kRetry); +} + +TEST_CASE("parse_quic treats version 0 as Version Negotiation regardless of type bits") { + auto pkt = parse_quic(long_header(0x02, 0x00000000, {0xAA}, {})); + REQUIRE(pkt.has_value()); + CHECK(pkt->long_header->type == QuicLongPacketType::kVersionNegotiation); +} + +TEST_CASE("parse_quic recognizes a short-header packet without decoding past the first byte") { + std::vector bytes = {0x40, 0xAA, 0xBB, 0xCC}; // short form, fixed bit set + auto pkt = parse_quic(bytes); + REQUIRE(pkt.has_value()); + CHECK_FALSE(pkt->is_long_header); + CHECK_FALSE(pkt->long_header.has_value()); +} + +TEST_CASE("parse_quic rejects a packet with the Fixed Bit clear") { + std::vector bytes = {0x00, 0xAA, 0xBB, 0xCC}; + CHECK_FALSE(parse_quic(bytes).has_value()); +} + +TEST_CASE("parse_quic rejects a long-header packet truncated before the version field") { + std::vector bytes = {0xC0, 0x00, 0x00}; + CHECK_FALSE(parse_quic(bytes).has_value()); +} + +TEST_CASE("parse_quic rejects a long-header packet whose DCID length exceeds the buffer") { + std::vector bytes = {0xC0, 0x00, 0x00, 0x00, 0x01, 20}; // claims 20-byte DCID + CHECK_FALSE(parse_quic(bytes).has_value()); +} + +TEST_CASE("QuicDissector claims port 443 and formats an Initial packet") { + QuicDissector dissector; + CHECK(dissector.port() == kQuicPort); + + auto bytes = long_header(0x00, 0x00000001, {0xAA, 0xBB}, {}); + auto summary = dissector.summarize(bytes); + REQUIRE(summary.has_value()); + CHECK(*summary == "QUIC Initial v=0x00000001 dcid=aabb"); +} + +TEST_CASE("QuicDissector formats a short-header packet distinctly, without a fake dcid") { + QuicDissector dissector; + std::vector bytes = {0x40, 0xAA, 0xBB, 0xCC}; + auto summary = dissector.summarize(bytes); + REQUIRE(summary.has_value()); + CHECK(*summary == "QUIC 1-RTT (short header)"); +} -- cgit v1.2.3