srdusr
aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-06-25 22:11:00 +0200
committersrdusr <[email protected]>2025-06-25 22:11:00 +0200
commitd9bedcec1bce8d15de6403377702d51d1bcb862f (patch)
tree363621175b93fa347dc288f9e53a8ede2d453d6d
parentf8fc8806401596b08779cf8c88da31408c9b0547 (diff)
downloadpacketeer-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.txt2
-rw-r--r--PLAN.md31
-rw-r--r--include/packeteer/l7/dhcp.hpp116
-rw-r--r--include/packeteer/l7/ntp.hpp64
-rw-r--r--include/packeteer/summarize.hpp11
-rw-r--r--tests/test_dhcp.cpp111
-rw-r--r--tests/test_ntp.cpp64
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
diff --git a/PLAN.md b/PLAN.md
index 5c0f2f1..c384e20 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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);
+}