srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/include
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-07-01 00:40:00 +0200
committersrdusr <[email protected]>2025-07-01 00:40:00 +0200
commit407249eb5d654b5a951c43bc1722fd397d5d922c (patch)
treec367bf95c3855152d477a7eed5e709b3094a427c /include
parent9046e6a10fd2d987cf6f6dc601ed3a75d286793f (diff)
downloadpacketeer-407249eb5d654b5a951c43bc1722fd397d5d922c.tar.gz
packeteer-407249eb5d654b5a951c43bc1722fd397d5d922c.zip
Add FTP, SMTP, TFTP, and IGMP; fix an IGMPv3 group-address misparse
FTP and SMTP share HTTP's line-based response-code-or-command shape but keep their own command vocabularies in separate files rather than sharing a parser. FTP passwords are shown as-is, not redacted - FTP sends them in the clear regardless, matching Wireshark's own behavior. TFTP is a small binary opcode protocol (RFC 1350) instead. IGMP sits directly on IP like ICMP, so it's dispatched by protocol number rather than through the port-keyed L7Registry the other three use. Live-verified: FTP/SMTP against minimal real TCP servers written for this (nothing installed locally), a full command/response exchange decoded correctly in both directions. TFTP against a real atftpd server and atftp client - the RRQ decoded correctly even though the transfer itself didn't complete (an atftpd sandbox issue, not this code). IGMP against real multicast traffic on wlp1s0, including a genuine query from the actual router. That live IGMP traffic caught a real bug before it shipped further: parse_igmp() read bytes[4:8] as a group address for every message type, but IGMPv3 reports use those bytes for Reserved+RecordCount instead - a real V3 report showed "group=0.0.0.1" (0 reserved, 1 record, misread as an IP). Fixed by only populating group for the types where it's genuinely an address; re-verified against the same live traffic, and a regression test locks in the exact pattern.
Diffstat (limited to 'include')
-rw-r--r--include/packeteer/l7/ftp.hpp94
-rw-r--r--include/packeteer/l7/smtp.hpp92
-rw-r--r--include/packeteer/l7/tftp.hpp116
-rw-r--r--include/packeteer/net/igmp.hpp69
-rw-r--r--include/packeteer/net/ipv4.hpp1
-rw-r--r--include/packeteer/summarize.hpp19
6 files changed, 391 insertions, 0 deletions
diff --git a/include/packeteer/l7/ftp.hpp b/include/packeteer/l7/ftp.hpp
new file mode 100644
index 0000000..c8f9edc
--- /dev/null
+++ b/include/packeteer/l7/ftp.hpp
@@ -0,0 +1,94 @@
+#pragma once
+
+#include <cctype>
+#include <cstdint>
+#include <optional>
+#include <span>
+#include <string>
+#include <string_view>
+
+#include "packeteer/l7/dissector.hpp"
+
+// RFC 959 FTP control channel (not the separate data connection, which
+// has no fixed port and carries the actual file/listing bytes rather
+// than commands). Line-based like HTTP: a response is a 3-digit code
+// plus text; a command is a known keyword plus an optional argument.
+// Same single-segment, best-effort scope as HTTP/DNS. Passwords sent
+// via PASS are shown as-is, not redacted - FTP itself sends them in
+// the clear, so this reflects what's genuinely on the wire rather than
+// adding any exposure a real capture wouldn't already have.
+namespace packeteer::net {
+
+inline constexpr std::uint16_t kFtpPort = 21;
+
+struct FtpMessage {
+ bool is_response;
+ std::string command_or_code; // response: 3-digit code; command: the keyword
+ std::string argument;
+};
+
+inline std::optional<FtpMessage> parse_ftp(std::span<const unsigned char> payload) {
+ std::string_view text(reinterpret_cast<const char*>(payload.data()), payload.size());
+
+ std::size_t line_end = text.find("\r\n");
+ if (line_end == std::string_view::npos) {
+ line_end = text.find('\n');
+ if (line_end == std::string_view::npos) return std::nullopt;
+ }
+ std::string_view line = text.substr(0, line_end);
+ if (line.empty()) return std::nullopt;
+
+ if (line.size() >= 3 && std::isdigit(static_cast<unsigned char>(line[0])) &&
+ std::isdigit(static_cast<unsigned char>(line[1])) &&
+ std::isdigit(static_cast<unsigned char>(line[2]))) {
+ FtpMessage msg;
+ msg.is_response = true;
+ msg.command_or_code = std::string(line.substr(0, 3));
+ std::size_t arg_start = 3;
+ while (arg_start < line.size() && (line[arg_start] == ' ' || line[arg_start] == '-')) {
+ ++arg_start;
+ }
+ msg.argument = std::string(line.substr(arg_start));
+ return msg;
+ }
+
+ static constexpr std::string_view kCommands[] = {
+ "USER", "PASS", "ACCT", "CWD", "CDUP", "SMNT", "QUIT", "REIN", "PORT", "PASV",
+ "TYPE", "STRU", "MODE", "RETR", "STOR", "STOU", "APPE", "ALLO", "REST", "RNFR",
+ "RNTO", "ABOR", "DELE", "RMD", "MKD", "PWD", "LIST", "NLST", "SITE", "SYST",
+ "STAT", "HELP", "NOOP", "EPSV", "EPRT", "FEAT",
+ };
+ std::size_t sp = line.find(' ');
+ std::string_view word = (sp == std::string_view::npos) ? line : line.substr(0, sp);
+
+ bool known = false;
+ for (auto cmd : kCommands) {
+ if (word == cmd) {
+ known = true;
+ break;
+ }
+ }
+ if (!known) return std::nullopt;
+
+ FtpMessage msg;
+ msg.is_response = false;
+ msg.command_or_code = std::string(word);
+ msg.argument = (sp == std::string_view::npos) ? "" : std::string(line.substr(sp + 1));
+ return msg;
+}
+
+class FtpDissector : public L7Dissector {
+public:
+ std::uint16_t port() const override { return kFtpPort; }
+
+ std::optional<std::string> summarize(std::span<const unsigned char> payload) const override {
+ auto msg = parse_ftp(payload);
+ if (!msg) return std::nullopt;
+
+ std::string out = "FTP " + msg->command_or_code;
+ if (!msg->argument.empty()) out += " " + msg->argument;
+ return out;
+ }
+};
+
+} // namespace packeteer::net
diff --git a/include/packeteer/l7/smtp.hpp b/include/packeteer/l7/smtp.hpp
new file mode 100644
index 0000000..fc0b49c
--- /dev/null
+++ b/include/packeteer/l7/smtp.hpp
@@ -0,0 +1,92 @@
+#pragma once
+
+#include <cctype>
+#include <cstdint>
+#include <optional>
+#include <span>
+#include <string>
+#include <string_view>
+
+#include "packeteer/l7/dissector.hpp"
+
+// RFC 5321 SMTP. Same line-based response-code-or-command shape as
+// FTP's control channel (RFC 959 predates and clearly influenced SMTP
+// here), but a distinct command vocabulary - kept as its own file
+// rather than sharing code with ftp.hpp, matching how DNS and mDNS
+// stayed separate dissector *registrations* even where mDNS reuses
+// DNS's actual parser: the wire-format overlap here is real but
+// shallower than DNS/mDNS's identical format, not worth coupling two
+// otherwise-independent protocols over.
+namespace packeteer::net {
+
+inline constexpr std::uint16_t kSmtpPort = 25;
+
+struct SmtpMessage {
+ bool is_response;
+ std::string command_or_code;
+ std::string argument;
+};
+
+inline std::optional<SmtpMessage> parse_smtp(std::span<const unsigned char> payload) {
+ std::string_view text(reinterpret_cast<const char*>(payload.data()), payload.size());
+
+ std::size_t line_end = text.find("\r\n");
+ if (line_end == std::string_view::npos) {
+ line_end = text.find('\n');
+ if (line_end == std::string_view::npos) return std::nullopt;
+ }
+ std::string_view line = text.substr(0, line_end);
+ if (line.empty()) return std::nullopt;
+
+ if (line.size() >= 3 && std::isdigit(static_cast<unsigned char>(line[0])) &&
+ std::isdigit(static_cast<unsigned char>(line[1])) &&
+ std::isdigit(static_cast<unsigned char>(line[2]))) {
+ SmtpMessage msg;
+ msg.is_response = true;
+ msg.command_or_code = std::string(line.substr(0, 3));
+ std::size_t arg_start = 3;
+ while (arg_start < line.size() && (line[arg_start] == ' ' || line[arg_start] == '-')) {
+ ++arg_start;
+ }
+ msg.argument = std::string(line.substr(arg_start));
+ return msg;
+ }
+
+ static constexpr std::string_view kCommands[] = {
+ "HELO", "EHLO", "MAIL", "RCPT", "DATA", "RSET",
+ "VRFY", "EXPN", "HELP", "NOOP", "QUIT", "STARTTLS",
+ };
+ std::size_t sp = line.find(' ');
+ std::string_view word = (sp == std::string_view::npos) ? line : line.substr(0, sp);
+
+ bool known = false;
+ for (auto cmd : kCommands) {
+ if (word == cmd) {
+ known = true;
+ break;
+ }
+ }
+ if (!known) return std::nullopt;
+
+ SmtpMessage msg;
+ msg.is_response = false;
+ msg.command_or_code = std::string(word);
+ msg.argument = (sp == std::string_view::npos) ? "" : std::string(line.substr(sp + 1));
+ return msg;
+}
+
+class SmtpDissector : public L7Dissector {
+public:
+ std::uint16_t port() const override { return kSmtpPort; }
+
+ std::optional<std::string> summarize(std::span<const unsigned char> payload) const override {
+ auto msg = parse_smtp(payload);
+ if (!msg) return std::nullopt;
+
+ std::string out = "SMTP " + msg->command_or_code;
+ if (!msg->argument.empty()) out += " " + msg->argument;
+ return out;
+ }
+};
+
+} // namespace packeteer::net
diff --git a/include/packeteer/l7/tftp.hpp b/include/packeteer/l7/tftp.hpp
new file mode 100644
index 0000000..b8620f7
--- /dev/null
+++ b/include/packeteer/l7/tftp.hpp
@@ -0,0 +1,116 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <span>
+#include <string>
+#include <utility>
+
+#include "packeteer/byteio.hpp"
+#include "packeteer/l7/dissector.hpp"
+
+// RFC 1350 TFTP. Unlike FTP/SMTP/HTTP, this is a small binary
+// protocol, not line-based: a 2-byte opcode followed by a shape that
+// depends on it (two null-terminated strings for RRQ/WRQ, a 2-byte
+// block number for DATA/ACK, a 2-byte error code + string for ERROR).
+// OACK (RFC 2347) is recognized by opcode but not decoded further --
+// its option/value pairs aren't common enough to be worth the parsing
+// for a one-line summary.
+namespace packeteer::net {
+
+inline constexpr std::uint16_t kTftpPort = 69;
+
+inline constexpr std::uint16_t kTftpRrq = 1;
+inline constexpr std::uint16_t kTftpWrq = 2;
+inline constexpr std::uint16_t kTftpData = 3;
+inline constexpr std::uint16_t kTftpAck = 4;
+inline constexpr std::uint16_t kTftpError = 5;
+inline constexpr std::uint16_t kTftpOack = 6;
+
+struct TftpMessage {
+ std::uint16_t opcode;
+ std::optional<std::string> filename; // RRQ/WRQ
+ std::optional<std::string> mode; // RRQ/WRQ
+ std::optional<std::uint16_t> block; // DATA/ACK
+ std::optional<std::uint16_t> error_code; // ERROR
+ std::optional<std::string> error_message; // ERROR
+};
+
+namespace detail {
+
+// Reads a null-terminated string starting at `start`; returns the
+// string and the offset just past its terminator, or nullopt if no
+// terminator is found before the end of the buffer.
+inline std::optional<std::pair<std::string, std::size_t>> read_cstr(
+ std::span<const unsigned char> bytes, std::size_t start) {
+ std::size_t i = start;
+ while (i < bytes.size() && bytes[i] != 0) ++i;
+ if (i >= bytes.size()) return std::nullopt;
+ return std::make_pair(std::string(reinterpret_cast<const char*>(bytes.data() + start), i - start),
+ i + 1);
+}
+
+} // namespace detail
+
+inline std::optional<TftpMessage> parse_tftp(std::span<const unsigned char> bytes) {
+ if (bytes.size() < 2) return std::nullopt;
+ std::uint16_t opcode = read_be16(bytes, 0);
+ if (opcode < kTftpRrq || opcode > kTftpOack) return std::nullopt;
+
+ TftpMessage msg{};
+ msg.opcode = opcode;
+
+ if (opcode == kTftpRrq || opcode == kTftpWrq) {
+ auto filename = detail::read_cstr(bytes, 2);
+ if (!filename) return msg; // opcode decoded; filename truncated
+ msg.filename = filename->first;
+ if (auto mode = detail::read_cstr(bytes, filename->second)) msg.mode = mode->first;
+ return msg;
+ }
+ if (opcode == kTftpData || opcode == kTftpAck) {
+ if (bytes.size() >= 4) msg.block = read_be16(bytes, 2);
+ return msg;
+ }
+ if (opcode == kTftpError) {
+ if (bytes.size() >= 4) {
+ msg.error_code = read_be16(bytes, 2);
+ if (auto message = detail::read_cstr(bytes, 4)) msg.error_message = message->first;
+ }
+ return msg;
+ }
+ return msg; // OACK: opcode reported, options not decoded
+}
+
+inline std::string tftp_opcode_name(std::uint16_t opcode) {
+ switch (opcode) {
+ case kTftpRrq: return "RRQ";
+ case kTftpWrq: return "WRQ";
+ case kTftpData: return "DATA";
+ case kTftpAck: return "ACK";
+ case kTftpError: return "ERROR";
+ case kTftpOack: return "OACK";
+ default: return "opcode=" + std::to_string(opcode);
+ }
+}
+
+class TftpDissector : public L7Dissector {
+public:
+ std::uint16_t port() const override { return kTftpPort; }
+
+ std::optional<std::string> summarize(std::span<const unsigned char> payload) const override {
+ auto msg = parse_tftp(payload);
+ if (!msg) return std::nullopt;
+
+ std::string out = "TFTP " + tftp_opcode_name(msg->opcode);
+ if (msg->filename) out += " " + *msg->filename;
+ if (msg->mode) out += " (" + *msg->mode + ")";
+ if (msg->block) out += " block=" + std::to_string(*msg->block);
+ if (msg->error_code) {
+ out += " code=" + std::to_string(*msg->error_code);
+ if (msg->error_message) out += " " + *msg->error_message;
+ }
+ return out;
+ }
+};
+
+} // namespace packeteer::net
diff --git a/include/packeteer/net/igmp.hpp b/include/packeteer/net/igmp.hpp
new file mode 100644
index 0000000..6ab4878
--- /dev/null
+++ b/include/packeteer/net/igmp.hpp
@@ -0,0 +1,69 @@
+#pragma once
+
+#include <algorithm>
+#include <cstdint>
+#include <cstdio>
+#include <optional>
+#include <span>
+#include <string>
+
+#include "packeteer/net/ipv4.hpp"
+
+// RFC 2236 (IGMPv2) / RFC 3376 (IGMPv3). Like ICMP, IGMP sits directly
+// on IP (protocol 2) rather than over TCP/UDP, so it's dispatched by
+// IP protocol number in summarize_transport_and_above() rather than
+// through the port-keyed L7Registry - the same reasoning as ICMP.
+// IGMPv1/v2's fixed 8-byte header (type + max-resp-time + checksum +
+// group address) is decoded fully. IGMPv3 membership reports use a
+// different, variable-length group-record format; only the message
+// type is reported for those; decoding each record isn't worth it for
+// a one-line summary.
+namespace packeteer::net {
+
+inline constexpr std::uint8_t kIgmpMembershipQuery = 0x11;
+inline constexpr std::uint8_t kIgmpV1MembershipReport = 0x12;
+inline constexpr std::uint8_t kIgmpV2MembershipReport = 0x16;
+inline constexpr std::uint8_t kIgmpLeaveGroup = 0x17;
+inline constexpr std::uint8_t kIgmpV3MembershipReport = 0x22;
+
+struct IgmpMessage {
+ std::uint8_t type;
+ // Only set for the types where bytes[4:8] genuinely is a group
+ // address (query/v1/v2 report/leave) - IGMPv3 reports put a
+ // different field there entirely (reserved + record count), so
+ // reading it as an address there would print a garbage IP rather
+ // than the actual group(s), which live inside the group records
+ // this dissector doesn't decode.
+ std::optional<Ipv4Address> group;
+};
+
+inline std::optional<IgmpMessage> parse_igmp(std::span<const unsigned char> bytes) {
+ if (bytes.size() < 8) return std::nullopt;
+
+ IgmpMessage msg{};
+ msg.type = bytes[0];
+ if (msg.type == kIgmpMembershipQuery || msg.type == kIgmpV1MembershipReport ||
+ msg.type == kIgmpV2MembershipReport || msg.type == kIgmpLeaveGroup) {
+ Ipv4Address group{};
+ std::copy_n(bytes.begin() + 4, 4, group.bytes.begin());
+ msg.group = group;
+ }
+ return msg;
+}
+
+inline std::string igmp_type_name(std::uint8_t type) {
+ switch (type) {
+ case kIgmpMembershipQuery: return "Membership Query";
+ case kIgmpV1MembershipReport: return "V1 Membership Report";
+ case kIgmpV2MembershipReport: return "V2 Membership Report";
+ case kIgmpLeaveGroup: return "Leave Group";
+ case kIgmpV3MembershipReport: return "V3 Membership Report";
+ default: {
+ char buf[16];
+ std::snprintf(buf, sizeof(buf), "type=0x%02x", type);
+ return buf;
+ }
+ }
+}
+
+} // namespace packeteer::net
diff --git a/include/packeteer/net/ipv4.hpp b/include/packeteer/net/ipv4.hpp
index ee77c17..c6cb656 100644
--- a/include/packeteer/net/ipv4.hpp
+++ b/include/packeteer/net/ipv4.hpp
@@ -11,6 +11,7 @@
namespace packeteer::net {
inline constexpr std::uint8_t kProtoIcmp = 1;
+inline constexpr std::uint8_t kProtoIgmp = 2;
inline constexpr std::uint8_t kProtoTcp = 6;
inline constexpr std::uint8_t kProtoUdp = 17;
diff --git a/include/packeteer/summarize.hpp b/include/packeteer/summarize.hpp
index 302b380..66b2e18 100644
--- a/include/packeteer/summarize.hpp
+++ b/include/packeteer/summarize.hpp
@@ -1,5 +1,6 @@
#pragma once
+#include <array>
#include <cstdio>
#include <optional>
#include <span>
@@ -11,14 +12,18 @@
#include "packeteer/l7/dhcp.hpp"
#include "packeteer/l7/dissector.hpp"
#include "packeteer/l7/dns.hpp"
+#include "packeteer/l7/ftp.hpp"
#include "packeteer/l7/http.hpp"
#include "packeteer/l7/mdns.hpp"
#include "packeteer/l7/ntp.hpp"
+#include "packeteer/l7/smtp.hpp"
#include "packeteer/l7/ssh.hpp"
+#include "packeteer/l7/tftp.hpp"
#include "packeteer/l7/tls.hpp"
#include "packeteer/net/arp.hpp"
#include "packeteer/net/ethernet.hpp"
#include "packeteer/net/icmp.hpp"
+#include "packeteer/net/igmp.hpp"
#include "packeteer/net/ipv4.hpp"
#include "packeteer/net/ipv6.hpp"
#include "packeteer/net/tcp.hpp"
@@ -101,6 +106,9 @@ inline const net::L7Registry& l7_registry() {
static const net::SshDissector ssh_dissector;
static const net::NtpDissector ntp_dissector;
static const net::DhcpDissector dhcp_dissector;
+ static const net::FtpDissector ftp_dissector;
+ static const net::SmtpDissector smtp_dissector;
+ static const net::TftpDissector tftp_dissector;
static const net::L7Registry registry = [] {
net::L7Registry r;
r.add(&dns_dissector);
@@ -110,6 +118,9 @@ inline const net::L7Registry& l7_registry() {
r.add(&ssh_dissector);
r.add(&ntp_dissector);
r.add(&dhcp_dissector);
+ r.add(&ftp_dissector);
+ r.add(&smtp_dissector);
+ r.add(&tftp_dissector);
return r;
}();
return registry;
@@ -174,6 +185,14 @@ inline std::string summarize_transport_and_above(const IpInfo& info) {
" seq=" + std::to_string(*icmp->sequence);
}
}
+ } else if (info.proto == net::kProtoIgmp) {
+ if (auto igmp = net::parse_igmp(info.payload)) {
+ out += " | IGMP " + net::igmp_type_name(igmp->type);
+ static constexpr std::array<unsigned char, 4> kZero{0, 0, 0, 0};
+ if (igmp->group && igmp->group->bytes != kZero) {
+ out += " group=" + ipv4_to_string(*igmp->group);
+ }
+ }
} else if (info.proto == net::kNextHeaderIcmpv6) {
if (auto icmp = net::parse_icmpv6(info.payload)) {
out += " | ICMPv6 " + net::icmpv6_type_name(icmp->type);