diff options
| author | srdusr <[email protected]> | 2026-05-26 09:16:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-05-26 09:16:00 +0200 |
| commit | c098f1742bb04fbe41fc6cf492cd334efef734eb (patch) | |
| tree | 8af2c79ec321357d2766fdab03d6a870ff7ceb6c | |
| parent | 2d010c9f851ea4eb851179db012c8977ce6e4bd5 (diff) | |
| download | packeteer-c098f1742bb04fbe41fc6cf492cd334efef734eb.tar.gz packeteer-c098f1742bb04fbe41fc6cf492cd334efef734eb.zip | |
Add deeper TLS (ServerHello, ALPN); fix a QUIC/TCP false-positive bug
TLS: ServerHello now reports the negotiated version and cipher suite
alongside the existing ClientHello SNI support, plus ClientHello's
ALPN extension. ServerHello's version prefers the supported_versions
extension over legacy_version when present - TLS 1.3 always sets
legacy_version to 0x0303 for middlebox compatibility, so reading only
that field would misreport every real TLS 1.3 connection as 1.2.
Cipher suite names are hardcoded only for TLS 1.3's five suites (a
small closed set); everything else reports as raw hex rather than a
guessed name from a "common suites" list.
Live-verifying that against a real Cloudflare TLS 1.3 handshake
surfaced a real, unrelated bug in the QUIC dissector added earlier: it was also being tried against TCP port-443 payloads
(a side effect of the earlier L7Registry port-sharing fix), and
produced false "QUIC" labels on TLS ciphertext continuation fragments
- large encrypted records split across multiple TCP segments, each
fed to the parser independently since this project doesn't reassemble
by default, so a later fragment's effectively random bytes
occasionally passed as a plausible QUIC header.
Fixed in two layers: parse_quic() now enforces RFC 9000's real 20-byte
cap on connection ID lengths, closing most of the long-header false-
positive surface; and L7Dissector gained a transport() method
(defaulting to kAny, so every other dissector's behavior is unchanged)
so QuicDissector can declare itself UDP-only - necessary because the
length cap alone can't touch QUIC's short-header form, which by design
has no structural signal beyond one bit once header protection can't
be removed without connection state.
Re-verified against the identical live scenario afterward: zero false
QUIC labels on the same Cloudflare TCP handshake, and a repeat of the
earlier real HTTP/3 capture confirmed genuine QUIC still decodes
correctly on UDP.
| -rw-r--r-- | PLAN.md | 40 | ||||
| -rw-r--r-- | include/packeteer/l7/dissector.hpp | 38 | ||||
| -rw-r--r-- | include/packeteer/l7/quic.hpp | 23 | ||||
| -rw-r--r-- | include/packeteer/l7/tls.hpp | 196 | ||||
| -rw-r--r-- | include/packeteer/summarize.hpp | 13 | ||||
| -rw-r--r-- | tests/test_dissector.cpp | 37 | ||||
| -rw-r--r-- | tests/test_quic.cpp | 19 | ||||
| -rw-r--r-- | tests/test_tls.cpp | 115 |
8 files changed, 437 insertions, 44 deletions
@@ -587,3 +587,43 @@ None currently open. correctly decoded a real RTP video stream (payload type 96, incrementing sequence numbers, one consistent SSRC across the whole stream) and a real RTCP Sender Report ffmpeg sent alongside it. +- Deeper TLS: ServerHello (negotiated version, cipher suite) alongside + the existing ClientHello (SNI) support, plus ClientHello's ALPN + extension. Both hellos share almost all of their wire structure, so + the record/handshake header parsing was factored into one shared + detail::read_tls_handshake() rather than duplicated a second time. + ServerHello's negotiated_version prefers the supported_versions + extension over legacy_version when present: TLS 1.3 always sets + legacy_version to 0x0303 (TLS 1.2) for middlebox compatibility, so + reading only that field would misreport every real TLS 1.3 + connection as 1.2. Cipher suite names are hardcoded only for TLS + 1.3's five suites (RFC 8446 B.4, a small closed set) - everything + else is reported as a raw hex value rather than guessed at from a + curated "common suites" list, which would be more misleading than a + plain number for the suites it didn't happen to cover. + Live-verified against a real Cloudflare TLS 1.3 handshake on + wlp1s0: "TLS ServerHello version=TLS1.3 cipher=TLS_AES_256_GCM_SHA384" + from cloudflare.com's actual production server, confirming both the + supported_versions override and the cipher-suite naming. + That same live capture surfaced a real, unrelated bug in the QUIC + dissector added earlier this session: QuicDissector was also being + tried against *TCP* port-443 payloads (a side effect of the + L7Registry fix that let QUIC and TLS share port 443 at all), and + produced real false "QUIC" labels on TLS 1.3 ciphertext continuation + fragments - large encrypted records split across multiple TCP + segments, each fed to the parser independently since this project + doesn't reassemble by default, so a later fragment's effectively + random bytes occasionally passed as a plausible QUIC header. Fixed + in two layers: (1) parse_quic() now enforces RFC 9000 17.2's real + 20-byte cap on connection ID lengths, closing most of the + long-header false-positive surface; (2) L7Dissector gained a + transport() method (default kAny, preserving every other + dissector's exact current behavior unchanged) so QuicDissector could + declare itself UDP-only - necessary because layer (1) alone + couldn't touch QUIC's short-header form at all, which by design has + no structural signal beyond one bit once header protection can't be + removed without connection state. Re-verified against the identical + live scenario: zero false QUIC labels on the same Cloudflare TCP + handshake afterward, and a repeat of the earlier real HTTP/3 capture + against google.com confirmed genuine QUIC traffic still decodes + correctly on UDP. diff --git a/include/packeteer/l7/dissector.hpp b/include/packeteer/l7/dissector.hpp index b640fcc..5a56f09 100644 --- a/include/packeteer/l7/dissector.hpp +++ b/include/packeteer/l7/dissector.hpp @@ -11,6 +11,8 @@ // without touching the L2-L4 decode path or main.cpp's dispatch logic. namespace packeteer::net { +enum class Transport { kTcp, kUdp, kAny }; + class L7Dissector { public: virtual ~L7Dissector() = default; @@ -21,6 +23,20 @@ public: // this later without changing the registry's shape. virtual std::uint16_t port() const = 0; + // Which transport this dissector applies to. Defaults to kAny -- + // for every protocol here except one, the port number alone was + // already an unambiguous filter (DNS/NTP/DHCP only ever run over + // UDP, HTTP/SSH/FTP only over TCP, and so on), so this was never + // needed until QUIC: it shares port 443 with TLS but runs + // *exclusively* over UDP. Trying it against TCP payloads produced + // real false positives - its short-header form in particular has + // no structural signal beyond one bit, since header protection + // can't be removed without connection state, so essentially any + // TCP ciphertext continuation fragment on port 443 could pass. + // Caught via live capture against real cloudflare.com TLS traffic, + // not by inspection. + virtual Transport transport() const { return Transport::kAny; } + // A one-line summary of the payload, or nullopt if it doesn't look // like this protocol (e.g. truncated/malformed). virtual std::optional<std::string> summarize(std::span<const unsigned char> payload) const = 0; @@ -30,18 +46,22 @@ 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, + // Tries every dissector registered for `port` whose transport + // matches (or declares kAny), 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) - 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, or on a transport it was never meant + // to run over at all. + std::optional<std::string> dissect(std::uint16_t port, Transport transport, std::span<const unsigned char> payload) const { for (const auto* dissector : dissectors_) { if (dissector->port() != port) continue; + if (dissector->transport() != Transport::kAny && dissector->transport() != transport) { + 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 index 9c9ac85..a675902 100644 --- a/include/packeteer/l7/quic.hpp +++ b/include/packeteer/l7/quic.hpp @@ -69,13 +69,25 @@ inline std::optional<QuicPacket> parse_quic(std::span<const unsigned char> bytes 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; + // RFC 9000 17.2: connection ID length MUST NOT exceed 20 bytes for + // this QUIC version - a real protocol constraint, not an invented + // heuristic, and one that matters here: this dissector runs on + // every unmatched TCP/UDP segment on port 443, including mid-record + // TLS ciphertext continuation fragments (large TLS records split + // across several TCP segments, each fed through independently + // since this project doesn't reassemble by default). Those + // fragments are effectively random bytes to this parser, and + // without this bound they passed the earlier checks often enough + // to produce real false "QUIC" labels on genuine TLS traffic -- + // caught via live capture against cloudflare.com, not by + // inspection. + if (dcid_len > 20 || 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; + if (scid_len > 20 || pos + scid_len > bytes.size()) return std::nullopt; std::vector<unsigned char> scid(bytes.begin() + pos, bytes.begin() + pos + scid_len); QuicLongPacketType type; @@ -122,6 +134,13 @@ class QuicDissector : public L7Dissector { public: std::uint16_t port() const override { return kQuicPort; } + // QUIC runs exclusively over UDP - declaring this (rather than + // the default kAny) is what actually stops this dissector from + // ever being tried against TCP:443 payloads at all, which the + // DCID/SCID length bound above can't do on its own for the + // short-header case (see dissector.hpp's Transport comment). + Transport transport() const override { return Transport::kUdp; } + std::optional<std::string> summarize(std::span<const unsigned char> payload) const override { auto pkt = parse_quic(payload); if (!pkt) return std::nullopt; diff --git a/include/packeteer/l7/tls.hpp b/include/packeteer/l7/tls.hpp index 40893fc..2d0d08f 100644 --- a/include/packeteer/l7/tls.hpp +++ b/include/packeteer/l7/tls.hpp @@ -1,23 +1,26 @@ #pragma once #include <cstdint> +#include <cstdio> #include <optional> #include <span> #include <string> +#include <vector> #include "packeteer/byteio.hpp" #include "packeteer/l7/dissector.hpp" -// TLS ClientHello -> SNI extension parsing. Most web traffic is TLS -// today, so HTTP alone covers a shrinking fraction of it - SNI is what +// TLS handshake parsing: ClientHello (SNI, ALPN) and ServerHello +// (negotiated version, cipher suite). Most web traffic is TLS today, +// so HTTP alone covers a shrinking fraction of it - this is what // makes a packet analyzer useful against that traffic without -// decrypting anything: the server name is sent in cleartext in the -// ClientHello, before any encryption starts, in every TLS version this -// parses (the ClientHello/extension wire format hasn't changed across -// versions - only what happens after it has). +// decrypting anything: everything read here is sent in cleartext, +// before any encryption starts, in every TLS version that uses this +// wire format for the handshake (only what happens *after* the +// handshake has changed across versions). // // Same scope as the other L7 dissectors: single-segment, best-effort. -// A ClientHello padded across multiple TCP segments (large cookie/PSK +// A hello padded across multiple TCP segments (large cookie/PSK // extensions, unusual but possible) is only partially visible here. // Every length field is bounds-checked against what's actually left in // the buffer before use - this is exactly the kind of nested, @@ -28,31 +31,51 @@ namespace packeteer::net { inline constexpr std::uint16_t kTlsPort = 443; inline constexpr std::uint8_t kTlsContentTypeHandshake = 0x16; inline constexpr std::uint8_t kTlsHandshakeTypeClientHello = 0x01; +inline constexpr std::uint8_t kTlsHandshakeTypeServerHello = 0x02; inline constexpr std::uint16_t kTlsExtensionServerName = 0x0000; +inline constexpr std::uint16_t kTlsExtensionAlpn = 0x0010; +inline constexpr std::uint16_t kTlsExtensionSupportedVersions = 0x002B; -struct TlsClientHello { - std::optional<std::string> server_name; // SNI, if the extension was present and well-formed +namespace detail { + +struct TlsHandshakeMessage { + std::uint8_t handshake_type; + std::span<const unsigned char> body; // exactly the declared handshake length, bounds-checked }; -inline std::optional<TlsClientHello> parse_tls_client_hello(std::span<const unsigned char> bytes) { - // Record header: ContentType(1) ProtocolVersion(2) Length(2) +// Record header: ContentType(1) ProtocolVersion(2) Length(2), then +// Handshake header: HandshakeType(1) Length(3, 24-bit BE). Shared by +// ClientHello and ServerHello - both are handshake messages inside a +// TLS record, differing only in handshake_type and everything after +// this point. +inline std::optional<TlsHandshakeMessage> read_tls_handshake(std::span<const unsigned char> bytes) { if (bytes.size() < 5) return std::nullopt; if (bytes[0] != kTlsContentTypeHandshake) return std::nullopt; std::uint16_t record_len = read_be16(bytes, 3); if (bytes.size() < static_cast<std::size_t>(5) + record_len) return std::nullopt; std::span<const unsigned char> handshake = bytes.subspan(5); - - // Handshake header: HandshakeType(1) Length(3, 24-bit BE) if (handshake.size() < 4) return std::nullopt; - if (handshake[0] != kTlsHandshakeTypeClientHello) return std::nullopt; std::uint32_t hs_len = (static_cast<std::uint32_t>(handshake[1]) << 16) | (static_cast<std::uint32_t>(handshake[2]) << 8) | static_cast<std::uint32_t>(handshake[3]); std::span<const unsigned char> body = handshake.subspan(4); if (body.size() < hs_len) return std::nullopt; - body = body.first(hs_len); // never read past the declared handshake body + return TlsHandshakeMessage{handshake[0], body.first(hs_len)}; // never read past hs_len +} + +} // namespace detail + +struct TlsClientHello { + std::optional<std::string> server_name; // SNI, if present and well-formed + std::optional<std::vector<std::string>> alpn_protocols; // in the client's preference order +}; + +inline std::optional<TlsClientHello> parse_tls_client_hello(std::span<const unsigned char> bytes) { + auto handshake = detail::read_tls_handshake(bytes); + if (!handshake || handshake->handshake_type != kTlsHandshakeTypeClientHello) return std::nullopt; + auto body = handshake->body; std::size_t offset = 0; @@ -82,7 +105,7 @@ inline std::optional<TlsClientHello> parse_tls_client_hello(std::span<const unsi offset += compression_len; TlsClientHello hello; - if (offset == body.size()) return hello; // no extensions block: no SNI, still a valid hello + if (offset == body.size()) return hello; // no extensions block: still a valid hello // extensions: length(2) + data if (body.size() < offset + 2) return std::nullopt; @@ -114,6 +137,25 @@ inline std::optional<TlsClientHello> parse_tls_client_hello(std::span<const unsi reinterpret_cast<const char*>(body.data() + name_start), name_len); } } + } else if (ext_type == kTlsExtensionAlpn && ext_len >= 2) { + // ProtocolNameList: list_len(2) + entries, each a + // length(1)-prefixed protocol name (e.g. "h2", "http/1.1"). + std::uint16_t list_len = read_be16(body, ext_data_start); + std::size_t list_start = ext_data_start + 2; + std::size_t list_end = list_start + list_len; + if (list_end <= ext_data_end) { + std::vector<std::string> protocols; + std::size_t pos = list_start; + while (pos < list_end) { + std::uint8_t name_len = body[pos]; + ++pos; + if (pos + name_len > list_end) break; // malformed entry: stop, keep what we have + protocols.emplace_back(reinterpret_cast<const char*>(body.data() + pos), + name_len); + pos += name_len; + } + if (!protocols.empty()) hello.alpn_protocols = std::move(protocols); + } } offset = ext_data_end; @@ -122,17 +164,127 @@ inline std::optional<TlsClientHello> parse_tls_client_hello(std::span<const unsi return hello; } +struct TlsServerHello { + std::uint16_t cipher_suite; // the single negotiated suite, not a list + std::uint16_t negotiated_version; // from supported_versions if present, else legacy_version +}; + +inline std::optional<TlsServerHello> parse_tls_server_hello(std::span<const unsigned char> bytes) { + auto handshake = detail::read_tls_handshake(bytes); + if (!handshake || handshake->handshake_type != kTlsHandshakeTypeServerHello) return std::nullopt; + auto body = handshake->body; + + std::size_t offset = 0; + + // legacy_version(2) - TLS 1.3 always sets this to 0x0303 (TLS + // 1.2) for middlebox compatibility; the real version, if 1.3, is + // only signaled via the supported_versions extension below. + if (body.size() < offset + 2) return std::nullopt; + std::uint16_t legacy_version = read_be16(body, offset); + offset += 2; + + // random(32) + if (body.size() < offset + 32) return std::nullopt; + offset += 32; + + // legacy_session_id_echo: length(1) + data + if (body.size() < offset + 1) return std::nullopt; + std::uint8_t session_id_len = body[offset]; + offset += 1; + if (body.size() < offset + session_id_len) return std::nullopt; + offset += session_id_len; + + // cipher_suite(2): a single value here, unlike ClientHello's list + if (body.size() < offset + 2) return std::nullopt; + std::uint16_t cipher_suite = read_be16(body, offset); + offset += 2; + + // legacy_compression_method(1) + if (body.size() < offset + 1) return std::nullopt; + offset += 1; + + TlsServerHello hello{cipher_suite, legacy_version}; + if (offset == body.size()) return hello; // no extensions: pre-TLS-1.3 hello, legacy_version stands + + // extensions: length(2) + data + if (body.size() < offset + 2) return std::nullopt; + std::uint16_t extensions_len = read_be16(body, offset); + offset += 2; + if (body.size() < static_cast<std::size_t>(offset) + extensions_len) return std::nullopt; + std::size_t extensions_end = offset + extensions_len; + + while (offset + 4 <= extensions_end) { + std::uint16_t ext_type = read_be16(body, offset); + std::uint16_t ext_len = read_be16(body, offset + 2); + std::size_t ext_data_start = offset + 4; + std::size_t ext_data_end = ext_data_start + ext_len; + if (ext_data_end > extensions_end) break; + + // In a ServerHello (unlike ClientHello), supported_versions + // carries exactly one 2-byte version - the one actually + // negotiated - not a list of offered ones. + if (ext_type == kTlsExtensionSupportedVersions && ext_len == 2) { + hello.negotiated_version = read_be16(body, ext_data_start); + } + + offset = ext_data_end; + } + + return hello; +} + +inline std::string tls_version_name(std::uint16_t version) { + switch (version) { + case 0x0301: return "TLS1.0"; + case 0x0302: return "TLS1.1"; + case 0x0303: return "TLS1.2"; + case 0x0304: return "TLS1.3"; + default: return "unknown"; + } +} + +// Only TLS 1.3's cipher suites are named - a small, closed set (five +// total, RFC 8446 B.4). Earlier TLS versions have hundreds of IANA- +// registered suites; guessing at a curated subset of "common" ones +// would be more misleading than useful, so anything else is reported +// by its raw hex value instead of a guessed name. +inline std::string tls_cipher_suite_name(std::uint16_t cipher_suite) { + switch (cipher_suite) { + case 0x1301: return "TLS_AES_128_GCM_SHA256"; + case 0x1302: return "TLS_AES_256_GCM_SHA384"; + case 0x1303: return "TLS_CHACHA20_POLY1305_SHA256"; + case 0x1304: return "TLS_AES_128_CCM_SHA256"; + case 0x1305: return "TLS_AES_128_CCM_8_SHA256"; + default: { + char buf[8]; + std::snprintf(buf, sizeof(buf), "0x%04x", cipher_suite); + return buf; + } + } +} + class TlsSniDissector : public L7Dissector { public: std::uint16_t port() const override { return kTlsPort; } std::optional<std::string> summarize(std::span<const unsigned char> payload) const override { - auto hello = parse_tls_client_hello(payload); - if (!hello) return std::nullopt; - - std::string out = "TLS ClientHello"; - if (hello->server_name) out += " SNI=" + *hello->server_name; - return out; + if (auto hello = parse_tls_client_hello(payload)) { + std::string out = "TLS ClientHello"; + if (hello->server_name) out += " SNI=" + *hello->server_name; + if (hello->alpn_protocols) { + out += " ALPN="; + for (std::size_t i = 0; i < hello->alpn_protocols->size(); ++i) { + if (i > 0) out += ","; + out += (*hello->alpn_protocols)[i]; + } + } + return out; + } + if (auto server_hello = parse_tls_server_hello(payload)) { + return "TLS ServerHello version=" + tls_version_name(server_hello->negotiated_version) + + " cipher=" + tls_cipher_suite_name(server_hello->cipher_suite); + } + return std::nullopt; } }; diff --git a/include/packeteer/summarize.hpp b/include/packeteer/summarize.hpp index 79ec283..c538cec 100644 --- a/include/packeteer/summarize.hpp +++ b/include/packeteer/summarize.hpp @@ -151,9 +151,10 @@ inline const net::L7Registry& l7_registry() { // to a well-known server port), then the source port (a server's // reply, coming from that same well-known port). inline std::optional<std::string> l7_summarize(std::span<const unsigned char> payload, - std::uint16_t src_port, std::uint16_t dst_port) { - if (auto summary = l7_registry().dissect(dst_port, payload)) return summary; - return l7_registry().dissect(src_port, payload); + std::uint16_t src_port, std::uint16_t dst_port, + net::Transport transport) { + if (auto summary = l7_registry().dissect(dst_port, transport, payload)) return summary; + return l7_registry().dissect(src_port, transport, payload); } // IPv4 and IPv6 headers carry different fields (ttl vs. hop_limit, @@ -184,7 +185,8 @@ inline std::string summarize_transport_and_above(const IpInfo& info) { tcp_flags_to_string(tcp->header.flags).c_str(), tcp->header.seq, tcp->header.ack, tcp->header.window); out += tcp_buf; - if (auto l7 = l7_summarize(tcp->payload, tcp->header.src_port, tcp->header.dst_port)) { + if (auto l7 = l7_summarize(tcp->payload, tcp->header.src_port, tcp->header.dst_port, + net::Transport::kTcp)) { out += " | " + *l7; } } @@ -194,7 +196,8 @@ inline std::string summarize_transport_and_above(const IpInfo& info) { std::snprintf(udp_buf, sizeof(udp_buf), " | UDP %u -> %u len=%u", udp->header.src_port, udp->header.dst_port, udp->header.length); out += udp_buf; - if (auto l7 = l7_summarize(udp->payload, udp->header.src_port, udp->header.dst_port)) { + if (auto l7 = l7_summarize(udp->payload, udp->header.src_port, udp->header.dst_port, + net::Transport::kUdp)) { out += " | " + *l7; } else if (auto rtcp = net::parse_rtcp_heuristic(udp->payload)) { // RTP/RTCP have no fixed port to key a real dissector diff --git a/tests/test_dissector.cpp b/tests/test_dissector.cpp index 9dd4135..4517ae7 100644 --- a/tests/test_dissector.cpp +++ b/tests/test_dissector.cpp @@ -23,9 +23,10 @@ private: class AlwaysMatchesDissector : public L7Dissector { public: - AlwaysMatchesDissector(std::uint16_t port, std::string label) - : port_(port), label_(std::move(label)) {} + AlwaysMatchesDissector(std::uint16_t port, std::string label, Transport transport = Transport::kAny) + : port_(port), label_(std::move(label)), transport_(transport) {} std::uint16_t port() const override { return port_; } + Transport transport() const override { return transport_; } std::optional<std::string> summarize(std::span<const unsigned char>) const override { return label_; } @@ -33,6 +34,7 @@ public: private: std::uint16_t port_; std::string label_; + Transport transport_; }; } // namespace @@ -46,7 +48,7 @@ TEST_CASE("L7Registry falls through to a later dissector on the same port " registry.add(&tls_like); registry.add(&quic_like); - auto result = registry.dissect(443, {}); + auto result = registry.dissect(443, Transport::kTcp, {}); REQUIRE(result.has_value()); CHECK(*result == "QUIC something"); } @@ -59,7 +61,7 @@ TEST_CASE("L7Registry still returns the first dissector to succeed, not the last registry.add(&first); registry.add(&second); - auto result = registry.dissect(80, {}); + auto result = registry.dissect(80, Transport::kTcp, {}); REQUIRE(result.has_value()); CHECK(*result == "first"); } @@ -70,5 +72,30 @@ TEST_CASE("L7Registry returns nullopt when no dissector on the port matches") { L7Registry registry; registry.add(&only); - CHECK_FALSE(registry.dissect(443, {}).has_value()); + CHECK_FALSE(registry.dissect(443, Transport::kTcp, {}).has_value()); +} + +TEST_CASE("L7Registry skips a dissector whose declared transport doesn't match, even on the " + "right port") { + // The actual fix for QUIC's false positives on TCP:443: a + // dissector that only claims UDP must never be tried against a + // TCP payload on the same port, regardless of what its own + // summarize() would have returned. + AlwaysMatchesDissector udp_only(443, "UDP thing", Transport::kUdp); + + L7Registry registry; + registry.add(&udp_only); + + CHECK_FALSE(registry.dissect(443, Transport::kTcp, {}).has_value()); + CHECK(registry.dissect(443, Transport::kUdp, {}).has_value()); +} + +TEST_CASE("L7Registry's default kAny transport matches either TCP or UDP") { + AlwaysMatchesDissector any(53, "DNS-like"); // default transport: kAny + + L7Registry registry; + registry.add(&any); + + CHECK(registry.dissect(53, Transport::kTcp, {}).has_value()); + CHECK(registry.dissect(53, Transport::kUdp, {}).has_value()); } diff --git a/tests/test_quic.cpp b/tests/test_quic.cpp index 2366176..4b02be5 100644 --- a/tests/test_quic.cpp +++ b/tests/test_quic.cpp @@ -78,6 +78,25 @@ TEST_CASE("parse_quic rejects a long-header packet whose DCID length exceeds the CHECK_FALSE(parse_quic(bytes).has_value()); } +TEST_CASE("parse_quic rejects a DCID/SCID length past RFC 9000's 20-byte cap even with room in " + "the buffer") { + // A real protocol bound, not a buffer-size check: plenty of bytes + // are available here, the claimed length is just illegal for this + // QUIC version. This is what actually stops a mid-record TLS + // ciphertext continuation fragment (effectively random bytes to + // this parser, since this project doesn't reassemble TCP by + // default) from occasionally passing as a plausible QUIC header -- + // found via live capture against real cloudflare.com traffic, not + // by inspection. + std::vector<unsigned char> bytes = {0xC0, 0x00, 0x00, 0x00, 0x01, 21}; + bytes.resize(bytes.size() + 21, 0xAA); // plenty of room for a 21-byte DCID + CHECK_FALSE(parse_quic(bytes).has_value()); + + std::vector<unsigned char> scid_bytes = {0xC0, 0x00, 0x00, 0x00, 0x01, 0, 21}; + scid_bytes.resize(scid_bytes.size() + 21, 0xAA); // plenty of room for a 21-byte SCID + CHECK_FALSE(parse_quic(scid_bytes).has_value()); +} + TEST_CASE("QuicDissector claims port 443 and formats an Initial packet") { QuicDissector dissector; CHECK(dissector.port() == kQuicPort); diff --git a/tests/test_tls.cpp b/tests/test_tls.cpp index 7a3ad27..bdb5300 100644 --- a/tests/test_tls.cpp +++ b/tests/test_tls.cpp @@ -22,7 +22,8 @@ void append_be16(std::vector<unsigned char>& out, std::uint16_t v) { // length instead of the real one (computed here, not via post-hoc // offset math into the finished buffer - equally fragile). std::vector<unsigned char> build_client_hello(const std::string& sni, - bool corrupt_sni_ext_len = false) { + bool corrupt_sni_ext_len = false, + std::vector<std::string> alpn_protocols = {}) { std::vector<unsigned char> body; body.push_back(0x03); body.push_back(0x03); // client_version: TLS 1.2 @@ -52,6 +53,20 @@ std::vector<unsigned char> build_client_hello(const std::string& sni, append_be16(extensions, ext_len); extensions.insert(extensions.end(), sni_ext_data.begin(), sni_ext_data.end()); } + if (!alpn_protocols.empty()) { + std::vector<unsigned char> protocol_list; + for (const auto& proto : alpn_protocols) { + protocol_list.push_back(static_cast<unsigned char>(proto.size())); + protocol_list.insert(protocol_list.end(), proto.begin(), proto.end()); + } + std::vector<unsigned char> alpn_ext_data; + append_be16(alpn_ext_data, static_cast<std::uint16_t>(protocol_list.size())); + alpn_ext_data.insert(alpn_ext_data.end(), protocol_list.begin(), protocol_list.end()); + + append_be16(extensions, kTlsExtensionAlpn); + append_be16(extensions, static_cast<std::uint16_t>(alpn_ext_data.size())); + extensions.insert(extensions.end(), alpn_ext_data.begin(), alpn_ext_data.end()); + } append_be16(body, static_cast<std::uint16_t>(extensions.size())); body.insert(body.end(), extensions.begin(), extensions.end()); @@ -73,6 +88,47 @@ std::vector<unsigned char> build_client_hello(const std::string& sni, return record; } +// Builds a real, well-formed TLS record containing a ServerHello. +// `negotiated_version`, when set, adds a supported_versions extension +// carrying that single 2-byte version - how a TLS 1.3 ServerHello +// actually signals its real version, since legacy_version is always +// 0x0303 there for middlebox compatibility. +std::vector<unsigned char> build_server_hello(std::uint16_t cipher_suite, + std::optional<std::uint16_t> negotiated_version = {}) { + std::vector<unsigned char> body; + append_be16(body, 0x0303); // legacy_version: always TLS 1.2 on the wire + body.insert(body.end(), 32, 0x00); // random + body.push_back(0x00); // legacy_session_id_echo length: 0 + append_be16(body, cipher_suite); + body.push_back(0x00); // legacy_compression_method: null + + std::vector<unsigned char> extensions; + if (negotiated_version) { + append_be16(extensions, kTlsExtensionSupportedVersions); + append_be16(extensions, 2); + append_be16(extensions, *negotiated_version); + } + append_be16(body, static_cast<std::uint16_t>(extensions.size())); + body.insert(body.end(), extensions.begin(), extensions.end()); + + std::vector<unsigned char> handshake; + handshake.push_back(kTlsHandshakeTypeServerHello); + std::uint32_t hs_len = static_cast<std::uint32_t>(body.size()); + handshake.push_back(static_cast<unsigned char>((hs_len >> 16) & 0xFF)); + handshake.push_back(static_cast<unsigned char>((hs_len >> 8) & 0xFF)); + handshake.push_back(static_cast<unsigned char>(hs_len & 0xFF)); + handshake.insert(handshake.end(), body.begin(), body.end()); + + std::vector<unsigned char> record; + record.push_back(kTlsContentTypeHandshake); + record.push_back(0x03); + record.push_back(0x03); + append_be16(record, static_cast<std::uint16_t>(handshake.size())); + record.insert(record.end(), handshake.begin(), handshake.end()); + + return record; +} + } // namespace TEST_CASE("parse_tls_client_hello extracts a real SNI extension") { @@ -130,3 +186,60 @@ TEST_CASE("TlsSniDissector claims port 443 and its summary matches parse_tls_cli CHECK(summary->substr(0, 3) == "TLS"); CHECK(summary->find("SNI=packeteer.test") != std::string::npos); } + +TEST_CASE("parse_tls_client_hello extracts ALPN protocols in order") { + auto record = build_client_hello("example.com", false, {"h2", "http/1.1"}); + auto hello = parse_tls_client_hello(record); + REQUIRE(hello.has_value()); + REQUIRE(hello->alpn_protocols.has_value()); + REQUIRE(hello->alpn_protocols->size() == 2); + CHECK((*hello->alpn_protocols)[0] == "h2"); + CHECK((*hello->alpn_protocols)[1] == "http/1.1"); +} + +TEST_CASE("parse_tls_client_hello leaves alpn_protocols unset when the extension is absent") { + auto record = build_client_hello("example.com"); + auto hello = parse_tls_client_hello(record); + REQUIRE(hello.has_value()); + CHECK_FALSE(hello->alpn_protocols.has_value()); +} + +TEST_CASE("parse_tls_server_hello extracts the negotiated cipher suite") { + auto record = build_server_hello(0x1301); // TLS_AES_128_GCM_SHA256 + auto hello = parse_tls_server_hello(record); + REQUIRE(hello.has_value()); + CHECK(hello->cipher_suite == 0x1301); +} + +TEST_CASE("parse_tls_server_hello uses legacy_version when there's no supported_versions extension") { + auto record = build_server_hello(0xC02F); // a TLS 1.2-era cipher suite + auto hello = parse_tls_server_hello(record); + REQUIRE(hello.has_value()); + CHECK(hello->negotiated_version == 0x0303); // TLS 1.2 +} + +TEST_CASE("parse_tls_server_hello prefers supported_versions over legacy_version for TLS 1.3") { + auto record = build_server_hello(0x1301, std::uint16_t{0x0304}); + auto hello = parse_tls_server_hello(record); + REQUIRE(hello.has_value()); + CHECK(hello->negotiated_version == 0x0304); // TLS 1.3, not the legacy 0x0303 on the wire +} + +TEST_CASE("parse_tls_server_hello rejects a ClientHello handshake type") { + auto record = build_client_hello("example.com"); + CHECK_FALSE(parse_tls_server_hello(record).has_value()); +} + +TEST_CASE("tls_cipher_suite_name names all five TLS 1.3 suites and falls back to hex otherwise") { + CHECK(tls_cipher_suite_name(0x1301) == "TLS_AES_128_GCM_SHA256"); + CHECK(tls_cipher_suite_name(0x1305) == "TLS_AES_128_CCM_8_SHA256"); + CHECK(tls_cipher_suite_name(0xC02F) == "0xc02f"); +} + +TEST_CASE("TlsSniDissector formats a ServerHello with version and cipher") { + TlsSniDissector dissector; + auto record = build_server_hello(0x1301, std::uint16_t{0x0304}); + auto summary = dissector.summarize(record); + REQUIRE(summary.has_value()); + CHECK(*summary == "TLS ServerHello version=TLS1.3 cipher=TLS_AES_128_GCM_SHA256"); +} |