diff options
| -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"); +} |