diff options
| author | srdusr <[email protected]> | 2025-06-25 22:11:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-06-25 22:11:00 +0200 |
| commit | d9bedcec1bce8d15de6403377702d51d1bcb862f (patch) | |
| tree | 363621175b93fa347dc288f9e53a8ede2d453d6d | |
| parent | f8fc8806401596b08779cf8c88da31408c9b0547 (diff) | |
| download | packeteer-d9bedcec1bce8d15de6403377702d51d1bcb862f.tar.gz packeteer-d9bedcec1bce8d15de6403377702d51d1bcb862f.zip | |
Add NTP and DHCP L7 dissectors
NTP decodes the fixed header's version/mode/stratum - the timestamp
fields need NTP era/fraction fixed-point math to render meaningfully
and add nothing a one-line summary needs, so they're left alone.
DHCP decodes RFC 2131's BOOTP fixed header + magic cookie, then walks
the TLV options bounds-safely for option 53 (message type), plus
yiaddr when the server has assigned one. It's the first dissector
needing two well-known ports (67 server, 68 client) rather than one;
registering at just 67 still matches both directions since
l7_summarize() already falls back from dst_port to src_port.
Verified live: NTP against a real pool.ntp.org query on wlp1s0 (a
genuine stratum-2 reply came back). DHCP over loopback with a
synthetic-but-wire-format-real DISCOVER/OFFER exchange rather than a
real lease renewal, to avoid disrupting this machine's actual network
state - caught a test-setup mistake in the process (both packets
sent from the same ephemeral port instead of the OFFER actually
originating from port 67), not a dissector bug.
| -rw-r--r-- | CMakeLists.txt | 2 | ||||
| -rw-r--r-- | PLAN.md | 31 | ||||
| -rw-r--r-- | include/packeteer/l7/dhcp.hpp | 116 | ||||
| -rw-r--r-- | include/packeteer/l7/ntp.hpp | 64 | ||||
| -rw-r--r-- | include/packeteer/summarize.hpp | 11 | ||||
| -rw-r--r-- | tests/test_dhcp.cpp | 111 | ||||
| -rw-r--r-- | tests/test_ntp.cpp | 64 |
7 files changed, 398 insertions, 1 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 0216e4c..0f313c9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -102,6 +102,8 @@ add_executable(packeteer_tests tests/test_dns.cpp tests/test_mdns.cpp tests/test_ssh.cpp + tests/test_ntp.cpp + tests/test_dhcp.cpp tests/test_http.cpp tests/test_tls.cpp tests/test_pcapng.cpp @@ -406,3 +406,34 @@ None currently open. split-segment HTTP scenario used to verify -a on the CLI and GUI: both "checksums: IP=ok TCP=..." and "[reassembled request: ... Host: ... (72 bytes so far)]" appeared correctly inline in the packet list. +- NTP (packeteer/l7/ntp.hpp) and DHCP (packeteer/l7/dhcp.hpp) + dissectors. NTP decodes only the first two header bytes (version, + mode, stratum) - the timestamp fields need NTP era/fraction + fixed-point math to render meaningfully and add nothing a one-line + summary needs, so they're left alone. DHCP decodes RFC 2131's fixed + 236-byte BOOTP header + 4-byte magic cookie, then walks the + variable-length TLV options bounds-safely to find option 53 (message + type) - the one field that actually says DISCOVER/OFFER/REQUEST/ACK/ + etc; other options are skipped over, not decoded. yiaddr (the address + being offered/assigned) is shown when non-zero since it's genuinely + new information, not a repeat of the IPv4 line's src/dst above it -- + formatted with a small local snprintf helper inside dhcp.hpp rather + than reusing summarize.hpp's ipv4_to_string, since summarize.hpp + already depends on this header and the reverse include would be + circular (same reasoning as arp_summary living in summarize.hpp + instead of arp.hpp, just resolved in the other direction here). + DHCP is the first dissector needing two well-known ports (67 server, + 68 client) rather than one; registering at just 67 still matches + both directions because l7_summarize() already falls back from + dst_port to src_port. Verified live: NTP against a real query to + pool.ntp.org on wlp1s0 ("NTP v4 client stratum=0" request, "NTP v4 + server stratum=2" reply from an actual stratum-2 timeserver at + 196.10.55.57). DHCP verified over loopback with a synthetic-but- + wire-format-real DISCOVER/OFFER exchange (not a real DHCP renewal, + to avoid disrupting this machine's actual network state) - caught a + test-setup mistake in the process, not a dissector bug: the first + attempt sent both packets from the same arbitrary ephemeral port, so + the OFFER's source port never matched DHCP's server port and nothing + decoded; fixed by actually binding the "server" send to port 67 (as + real DHCP servers do), which then correctly decoded "DHCP OFFER + yiaddr=192.168.1.50" on top of "DHCP DISCOVER" for the request. diff --git a/include/packeteer/l7/dhcp.hpp b/include/packeteer/l7/dhcp.hpp new file mode 100644 index 0000000..ac2662b --- /dev/null +++ b/include/packeteer/l7/dhcp.hpp @@ -0,0 +1,116 @@ +#pragma once + +#include <algorithm> +#include <cstdint> +#include <cstdio> +#include <optional> +#include <span> +#include <string> + +#include "packeteer/l7/dissector.hpp" +#include "packeteer/net/ipv4.hpp" + +// RFC 2131 DHCP: BOOTP's fixed 236-byte header, a 4-byte magic cookie +// (99.130.83.99), then variable-length TLV options. Only option 53 +// (DHCP Message Type) is decoded - the one field that actually says +// what kind of message this is (DISCOVER/OFFER/REQUEST/ACK/...); +// every other option is walked over (to find option 53, and to stay +// bounds-safe scanning past it) but not decoded itself. +// +// DHCP is unusual among this project's L7 dissectors in genuinely +// using two well-known ports - 67 (server) and 68 (client) - rather +// than one. Registering at just 67 still matches traffic in both +// directions: l7_summarize() already falls back from dst_port to +// src_port (built for exactly this: a client's request and a server's +// reply use the well-known port on opposite sides), and one side of a +// DHCP exchange is always 67. +namespace packeteer::net { + +inline constexpr std::uint16_t kDhcpServerPort = 67; + +inline constexpr std::uint8_t kDhcpDiscover = 1; +inline constexpr std::uint8_t kDhcpOffer = 2; +inline constexpr std::uint8_t kDhcpRequest = 3; +inline constexpr std::uint8_t kDhcpDecline = 4; +inline constexpr std::uint8_t kDhcpAck = 5; +inline constexpr std::uint8_t kDhcpNak = 6; +inline constexpr std::uint8_t kDhcpRelease = 7; +inline constexpr std::uint8_t kDhcpInform = 8; + +struct DhcpMessage { + std::uint8_t op; // 1 = BOOTREQUEST, 2 = BOOTREPLY + Ipv4Address yiaddr; // "your" (client) IP; all-zero if not yet assigned + std::optional<std::uint8_t> message_type; // option 53, if present +}; + +inline std::string dhcp_message_type_name(std::uint8_t type) { + switch (type) { + case kDhcpDiscover: return "DISCOVER"; + case kDhcpOffer: return "OFFER"; + case kDhcpRequest: return "REQUEST"; + case kDhcpDecline: return "DECLINE"; + case kDhcpAck: return "ACK"; + case kDhcpNak: return "NAK"; + case kDhcpRelease: return "RELEASE"; + case kDhcpInform: return "INFORM"; + default: return "type=" + std::to_string(type); + } +} + +inline std::optional<DhcpMessage> parse_dhcp(std::span<const unsigned char> bytes) { + if (bytes.size() < 240) return std::nullopt; // 236-byte fixed header + 4-byte magic cookie + if (bytes[236] != 0x63 || bytes[237] != 0x82 || bytes[238] != 0x53 || bytes[239] != 0x63) { + return std::nullopt; // not the DHCP magic cookie - bare BOOTP, or not this protocol + } + + DhcpMessage msg{}; + msg.op = bytes[0]; + std::copy_n(bytes.begin() + 16, 4, msg.yiaddr.bytes.begin()); + + std::size_t pos = 240; + while (pos < bytes.size()) { + std::uint8_t opt_type = bytes[pos]; + if (opt_type == 0xFF) break; // End option + if (opt_type == 0x00) { ++pos; continue; } // Pad option: no length byte follows + if (pos + 1 >= bytes.size()) break; // truncated: no room for a length byte + std::uint8_t opt_len = bytes[pos + 1]; + if (pos + 2 + opt_len > bytes.size()) break; // truncated option data + if (opt_type == 53 && opt_len >= 1) { + msg.message_type = bytes[pos + 2]; + } + pos += 2 + opt_len; + } + + return msg; +} + +class DhcpDissector : public L7Dissector { +public: + std::uint16_t port() const override { return kDhcpServerPort; } + + std::optional<std::string> summarize(std::span<const unsigned char> payload) const override { + auto msg = parse_dhcp(payload); + if (!msg) return std::nullopt; + + std::string out = "DHCP " + (msg->message_type ? dhcp_message_type_name(*msg->message_type) + : std::string(msg->op == 1 ? "request" + : "reply")); + + // yiaddr is genuinely new information here, not a repeat of + // the IPv4 line's src/dst above it - it's the address being + // offered/assigned, the actual point of DISCOVER/OFFER/ + // REQUEST/ACK. A tiny local formatter rather than reusing + // summarize.hpp's ipv4_to_string: summarize.hpp already + // depends on this header, so the reverse include would be + // circular. + const auto& b = msg->yiaddr.bytes; + if (b[0] || b[1] || b[2] || b[3]) { + char buf[24]; + std::snprintf(buf, sizeof(buf), " yiaddr=%u.%u.%u.%u", b[0], b[1], b[2], b[3]); + out += buf; + } + return out; + } +}; + +} // namespace packeteer::net diff --git a/include/packeteer/l7/ntp.hpp b/include/packeteer/l7/ntp.hpp new file mode 100644 index 0000000..a62566a --- /dev/null +++ b/include/packeteer/l7/ntp.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include <cstdint> +#include <optional> +#include <span> +#include <string> + +#include "packeteer/l7/dissector.hpp" + +// RFC 5905 NTP. Only the first two bytes matter for a one-line summary +// - version, mode (client/server/broadcast/...), and stratum - not +// the timestamp fields, which need 64-bit fixed-point NTP era/fraction +// math to render meaningfully and add nothing a one-line summary needs. +namespace packeteer::net { + +inline constexpr std::uint16_t kNtpPort = 123; + +struct NtpHeader { + std::uint8_t version; // 3 bits + std::uint8_t mode; // 3 bits + std::uint8_t stratum; +}; + +inline std::string ntp_mode_name(std::uint8_t mode) { + switch (mode) { + case 1: return "symmetric-active"; + case 2: return "symmetric-passive"; + case 3: return "client"; + case 4: return "server"; + case 5: return "broadcast"; + case 6: return "control"; + default: return "mode=" + std::to_string(mode); + } +} + +inline std::optional<NtpHeader> parse_ntp(std::span<const unsigned char> bytes) { + if (bytes.size() < 48) return std::nullopt; // the fixed header, no extension fields/MAC + + NtpHeader header{}; + header.version = static_cast<std::uint8_t>((bytes[0] >> 3) & 0x07); + header.mode = static_cast<std::uint8_t>(bytes[0] & 0x07); + header.stratum = bytes[1]; + return header; +} + +class NtpDissector : public L7Dissector { +public: + std::uint16_t port() const override { return kNtpPort; } + + std::optional<std::string> summarize(std::span<const unsigned char> payload) const override { + auto ntp = parse_ntp(payload); + if (!ntp) return std::nullopt; + + std::string out = "NTP v" + std::to_string(ntp->version) + " " + ntp_mode_name(ntp->mode); + // Stratum only means something once a role is established -- + // 0 on a client request is just "not applicable yet". + if (ntp->mode == 3 || ntp->mode == 4) { + out += " stratum=" + std::to_string(ntp->stratum); + } + return out; + } +}; + +} // namespace packeteer::net diff --git a/include/packeteer/summarize.hpp b/include/packeteer/summarize.hpp index 7ff06a4..4143e44 100644 --- a/include/packeteer/summarize.hpp +++ b/include/packeteer/summarize.hpp @@ -8,10 +8,12 @@ #include <pcap.h> +#include "packeteer/l7/dhcp.hpp" #include "packeteer/l7/dissector.hpp" #include "packeteer/l7/dns.hpp" #include "packeteer/l7/http.hpp" #include "packeteer/l7/mdns.hpp" +#include "packeteer/l7/ntp.hpp" #include "packeteer/l7/ssh.hpp" #include "packeteer/l7/tls.hpp" #include "packeteer/net/arp.hpp" @@ -87,13 +89,18 @@ inline std::string tcp_flags_to_string(std::uint8_t flags) { // DNS's own parser (same wire format, different port/label) at // essentially no extra cost. SSH is the first dissector whose *entire* // protocol is one cleartext line before everything else encrypts -- -// unlike TLS's SNI, there's nothing further to ever add here. +// unlike TLS's SNI, there's nothing further to ever add here. NTP is +// as simple as DNS/mDNS - a fixed, small header, no TLVs. DHCP is the +// first dissector to genuinely need two well-known ports (67 and 68); +// see dhcp.hpp for why registering at just one still works. inline const net::L7Registry& l7_registry() { static const net::DnsDissector dns_dissector; static const net::HttpDissector http_dissector; static const net::TlsSniDissector tls_dissector; static const net::MdnsDissector mdns_dissector; static const net::SshDissector ssh_dissector; + static const net::NtpDissector ntp_dissector; + static const net::DhcpDissector dhcp_dissector; static const net::L7Registry registry = [] { net::L7Registry r; r.add(&dns_dissector); @@ -101,6 +108,8 @@ inline const net::L7Registry& l7_registry() { r.add(&tls_dissector); r.add(&mdns_dissector); r.add(&ssh_dissector); + r.add(&ntp_dissector); + r.add(&dhcp_dissector); return r; }(); return registry; diff --git a/tests/test_dhcp.cpp b/tests/test_dhcp.cpp new file mode 100644 index 0000000..e9cbc16 --- /dev/null +++ b/tests/test_dhcp.cpp @@ -0,0 +1,111 @@ +#include <doctest/doctest.h> + +#include <algorithm> +#include <array> +#include <vector> + +#include "packeteer/l7/dhcp.hpp" + +using namespace packeteer::net; + +namespace { + +// Builds a minimal BOOTP fixed header + magic cookie + a message-type +// option (53), optionally with yiaddr set. +std::vector<unsigned char> dhcp_message(std::uint8_t op, std::uint8_t message_type, + std::array<unsigned char, 4> yiaddr = {0, 0, 0, 0}) { + std::vector<unsigned char> bytes(240, 0); + bytes[0] = op; + std::copy(yiaddr.begin(), yiaddr.end(), bytes.begin() + 16); + bytes[236] = 0x63; + bytes[237] = 0x82; + bytes[238] = 0x53; + bytes[239] = 0x63; + + // Option 53 (message type), length 1, then End. + bytes.push_back(53); + bytes.push_back(1); + bytes.push_back(message_type); + bytes.push_back(0xFF); + return bytes; +} + +} // namespace + +TEST_CASE("parse_dhcp decodes a DISCOVER") { + auto msg = parse_dhcp(dhcp_message(1, kDhcpDiscover)); + REQUIRE(msg.has_value()); + CHECK(msg->op == 1); + REQUIRE(msg->message_type.has_value()); + CHECK(*msg->message_type == kDhcpDiscover); +} + +TEST_CASE("parse_dhcp decodes an OFFER with yiaddr set") { + auto msg = parse_dhcp(dhcp_message(2, kDhcpOffer, {192, 168, 1, 50})); + REQUIRE(msg.has_value()); + REQUIRE(msg->message_type.has_value()); + CHECK(*msg->message_type == kDhcpOffer); + CHECK(msg->yiaddr.bytes == std::array<unsigned char, 4>{192, 168, 1, 50}); +} + +TEST_CASE("parse_dhcp rejects a payload without the magic cookie") { + std::vector<unsigned char> bytes(240, 0); // right size, but cookie bytes are zero + CHECK_FALSE(parse_dhcp(bytes).has_value()); +} + +TEST_CASE("parse_dhcp rejects a payload shorter than the fixed header + cookie") { + std::vector<unsigned char> bytes(100, 0); + CHECK_FALSE(parse_dhcp(bytes).has_value()); +} + +TEST_CASE("parse_dhcp handles a message with no options gracefully") { + std::vector<unsigned char> bytes(240, 0); + bytes[0] = 1; + bytes[236] = 0x63; bytes[237] = 0x82; bytes[238] = 0x53; bytes[239] = 0x63; + auto msg = parse_dhcp(bytes); + REQUIRE(msg.has_value()); + CHECK_FALSE(msg->message_type.has_value()); +} + +TEST_CASE("parse_dhcp skips pad options while scanning for message type") { + std::vector<unsigned char> bytes(240, 0); + bytes[236] = 0x63; bytes[237] = 0x82; bytes[238] = 0x53; bytes[239] = 0x63; + bytes.push_back(0x00); // pad + bytes.push_back(0x00); // pad + bytes.push_back(53); + bytes.push_back(1); + bytes.push_back(kDhcpAck); + bytes.push_back(0xFF); + + auto msg = parse_dhcp(bytes); + REQUIRE(msg.has_value()); + REQUIRE(msg->message_type.has_value()); + CHECK(*msg->message_type == kDhcpAck); +} + +TEST_CASE("DhcpDissector claims port 67 and names message types") { + DhcpDissector dissector; + CHECK(dissector.port() == kDhcpServerPort); + + auto summary = dissector.summarize(dhcp_message(2, kDhcpOffer, {192, 168, 1, 50})); + REQUIRE(summary.has_value()); + CHECK(*summary == "DHCP OFFER yiaddr=192.168.1.50"); +} + +TEST_CASE("DhcpDissector omits yiaddr when it's all-zero") { + DhcpDissector dissector; + auto summary = dissector.summarize(dhcp_message(1, kDhcpDiscover)); + REQUIRE(summary.has_value()); + CHECK(*summary == "DHCP DISCOVER"); +} + +TEST_CASE("DhcpDissector falls back to op when message type is absent") { + DhcpDissector dissector; + std::vector<unsigned char> bytes(240, 0); + bytes[0] = 1; + bytes[236] = 0x63; bytes[237] = 0x82; bytes[238] = 0x53; bytes[239] = 0x63; + + auto summary = dissector.summarize(bytes); + REQUIRE(summary.has_value()); + CHECK(*summary == "DHCP request"); +} diff --git a/tests/test_ntp.cpp b/tests/test_ntp.cpp new file mode 100644 index 0000000..2c93302 --- /dev/null +++ b/tests/test_ntp.cpp @@ -0,0 +1,64 @@ +#include <doctest/doctest.h> + +#include <vector> + +#include "packeteer/l7/ntp.hpp" + +using namespace packeteer::net; + +namespace { + +std::vector<unsigned char> ntp_client_request() { + std::vector<unsigned char> bytes(48, 0); + bytes[0] = (4 << 3) | 3; // version 4, mode 3 (client) + bytes[1] = 0; // stratum: not applicable on a client request + return bytes; +} + +std::vector<unsigned char> ntp_server_reply() { + std::vector<unsigned char> bytes(48, 0); + bytes[0] = (4 << 3) | 4; // version 4, mode 4 (server) + bytes[1] = 2; // stratum 2 + return bytes; +} + +} // namespace + +TEST_CASE("parse_ntp decodes a client request") { + auto ntp = parse_ntp(ntp_client_request()); + REQUIRE(ntp.has_value()); + CHECK(ntp->version == 4); + CHECK(ntp->mode == 3); +} + +TEST_CASE("parse_ntp decodes a server reply with its stratum") { + auto ntp = parse_ntp(ntp_server_reply()); + REQUIRE(ntp.has_value()); + CHECK(ntp->mode == 4); + CHECK(ntp->stratum == 2); +} + +TEST_CASE("parse_ntp rejects a payload shorter than the fixed header") { + std::vector<unsigned char> bytes(20, 0); + CHECK_FALSE(parse_ntp(bytes).has_value()); +} + +TEST_CASE("NtpDissector claims port 123 and includes stratum for client/server modes") { + NtpDissector dissector; + CHECK(dissector.port() == kNtpPort); + + auto summary = dissector.summarize(ntp_server_reply()); + REQUIRE(summary.has_value()); + CHECK(*summary == "NTP v4 server stratum=2"); +} + +TEST_CASE("NtpDissector omits stratum for non-client/server modes") { + NtpDissector dissector; + std::vector<unsigned char> bytes(48, 0); + bytes[0] = (4 << 3) | 5; // mode 5: broadcast + bytes[1] = 1; + + auto summary = dissector.summarize(bytes); + REQUIRE(summary.has_value()); + CHECK(summary->find("stratum") == std::string::npos); +} |