diff options
Diffstat (limited to 'include')
| -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 |
4 files changed, 232 insertions, 38 deletions
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 |