diff options
| -rw-r--r-- | CMakeLists.txt | 2 | ||||
| -rw-r--r-- | PLAN.md | 44 | ||||
| -rw-r--r-- | include/packeteer/l7/dissector.hpp | 11 | ||||
| -rw-r--r-- | include/packeteer/l7/quic.hpp | 143 | ||||
| -rw-r--r-- | include/packeteer/summarize.hpp | 9 | ||||
| -rw-r--r-- | tests/test_dissector.cpp | 74 | ||||
| -rw-r--r-- | tests/test_quic.cpp | 97 |
7 files changed, 377 insertions, 3 deletions
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 @@ -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 <expr>, 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<std::string> dissect(std::uint16_t port, std::span<const unsigned char> 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 <cstdint> +#include <cstdio> +#include <optional> +#include <span> +#include <string> +#include <vector> + +#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<unsigned char> dcid; + std::vector<unsigned char> scid; +}; + +struct QuicPacket { + bool is_long_header; + std::optional<QuicLongHeader> long_header; // set only when is_long_header +}; + +inline std::optional<QuicPacket> parse_quic(std::span<const unsigned char> 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<unsigned char> 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<unsigned char> 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<const unsigned char> 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<std::string> summarize(std::span<const unsigned char> 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 <doctest/doctest.h> + +#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<std::string> summarize(std::span<const unsigned char>) 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<std::string> summarize(std::span<const unsigned char>) 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 <doctest/doctest.h> + +#include <vector> + +#include "packeteer/l7/quic.hpp" + +using namespace packeteer::net; + +namespace { + +std::vector<unsigned char> long_header(std::uint8_t type_bits, std::uint32_t version, + std::vector<unsigned char> dcid, + std::vector<unsigned char> scid) { + std::vector<unsigned char> bytes; + bytes.push_back(static_cast<unsigned char>(0xC0 | (type_bits << 4))); // long form, fixed bit + bytes.push_back(static_cast<unsigned char>(version >> 24)); + bytes.push_back(static_cast<unsigned char>(version >> 16)); + bytes.push_back(static_cast<unsigned char>(version >> 8)); + bytes.push_back(static_cast<unsigned char>(version)); + bytes.push_back(static_cast<unsigned char>(dcid.size())); + bytes.insert(bytes.end(), dcid.begin(), dcid.end()); + bytes.push_back(static_cast<unsigned char>(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<unsigned char>{0xAA, 0xBB, 0xCC, 0xDD}); + CHECK(pkt->long_header->scid == std::vector<unsigned char>{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<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> bytes = {0x40, 0xAA, 0xBB, 0xCC}; + auto summary = dissector.summarize(bytes); + REQUIRE(summary.has_value()); + CHECK(*summary == "QUIC 1-RTT (short header)"); +} |