srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt2
-rw-r--r--PLAN.md44
-rw-r--r--include/packeteer/l7/dissector.hpp11
-rw-r--r--include/packeteer/l7/quic.hpp143
-rw-r--r--include/packeteer/summarize.hpp9
-rw-r--r--tests/test_dissector.cpp74
-rw-r--r--tests/test_quic.cpp97
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
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 <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)");
+}