srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/include
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2025-11-18 22:04:00 +0200
committersrdusr <[email protected]>2025-11-18 22:04:00 +0200
commita8f4866576fd70894ef0080c7797708db664880e (patch)
tree4a3e0c7cd6f6f585c8e58fa0b3d721dbe5250343 /include
parent3af3e356d6fdf43e6772dc2e91b322f8e8148f62 (diff)
downloadpacketeer-a8f4866576fd70894ef0080c7797708db664880e.tar.gz
packeteer-a8f4866576fd70894ef0080c7797708db664880e.zip
Add SNMP (v1/v2c) with a minimal local ASN.1 BER reader
First dissector needing actual ASN.1 decoding - a small local tag/length/value reader, not a general ASN.1 decoder, just enough to walk SNMP's own SEQUENCE/INTEGER/OCTET STRING structure. v3 wraps the PDU in its own security-parameters header instead of a plain community string and can be encrypted, so it's reported by version alone, the same "don't take on real crypto" call already made for TLS/QUIC. Community strings are shown as-is, matching FTP's PASS precedent -- v1/v2c send them in the clear regardless. SnmpDissector takes its port in the constructor so it can be registered twice, at 161 (agent) and 162 (trap receiver). Unlike DHCP's 67/68, trap traffic never touches 161 on either side (ephemeral source port straight to 162), so there's no shared port for l7_summarize()'s dst-then-src fallback to land on - both ports need explicit registration. Live-verified against a real snmpd (net-snmp 5.9.5.2) on loopback: a real snmpget GetRequest/GetResponse exchange decoded correctly with matching request-ids across both directions, and a real snmptrap SNMPv2-Trap on port 162 confirmed the second registered port actually gets used.
Diffstat (limited to 'include')
-rw-r--r--include/packeteer/l7/snmp.hpp178
-rw-r--r--include/packeteer/summarize.hpp10
2 files changed, 188 insertions, 0 deletions
diff --git a/include/packeteer/l7/snmp.hpp b/include/packeteer/l7/snmp.hpp
new file mode 100644
index 0000000..0bf5011
--- /dev/null
+++ b/include/packeteer/l7/snmp.hpp
@@ -0,0 +1,178 @@
+#pragma once
+
+#include <cstdint>
+#include <cstdio>
+#include <optional>
+#include <span>
+#include <string>
+
+#include "packeteer/l7/dissector.hpp"
+
+// SNMPv1 (RFC 1157) / SNMPv2c (RFC 1901+), both wrapped in the same
+// ASN.1 BER-encoded SEQUENCE { version, community, pdu }. SNMPv3 (RFC
+// 3411+) wraps the PDU in its own security-parameters header instead
+// of a plain community string, and the PDU itself can be encrypted --
+// reported by version alone, not decoded further, the same "don't
+// take on real crypto" call already made for TLS/QUIC.
+//
+// A minimal BER TLV reader lives in the detail namespace below: just
+// enough tag/length/value walking to get through SNMP's own
+// SEQUENCE/INTEGER/OCTET STRING structure, not a general ASN.1
+// decoder (no support for indefinite-length encoding, multi-byte tag
+// numbers, or any type SNMP itself doesn't use for the fields read
+// here).
+namespace packeteer::net {
+
+inline constexpr std::uint16_t kSnmpAgentPort = 161; // get/set requests and their responses
+inline constexpr std::uint16_t kSnmpTrapPort = 162; // traps/informs, sent from an ephemeral port
+
+namespace detail {
+
+struct BerTlv {
+ std::uint8_t tag;
+ std::span<const unsigned char> value;
+ std::size_t next_offset; // offset just past this TLV, relative to the buffer read from
+};
+
+inline std::optional<BerTlv> read_ber_tlv(std::span<const unsigned char> bytes,
+ std::size_t offset) {
+ if (offset >= bytes.size()) return std::nullopt;
+ std::uint8_t tag = bytes[offset];
+ std::size_t pos = offset + 1;
+ if (pos >= bytes.size()) return std::nullopt;
+
+ std::uint8_t len_byte = bytes[pos++];
+ std::size_t length;
+ if ((len_byte & 0x80) == 0) {
+ length = len_byte; // short form
+ } else {
+ std::uint8_t num_len_bytes = len_byte & 0x7F;
+ // 0 here is BER's indefinite-length form (not used by DER/SNMP's
+ // canonical encoding); >4 would overflow a reasonable length
+ // for anything SNMP actually sends. Both rejected rather than
+ // guessed at.
+ if (num_len_bytes == 0 || num_len_bytes > 4) return std::nullopt;
+ if (pos + num_len_bytes > bytes.size()) return std::nullopt;
+ length = 0;
+ for (std::uint8_t i = 0; i < num_len_bytes; ++i) length = (length << 8) | bytes[pos++];
+ }
+ if (pos + length > bytes.size()) return std::nullopt;
+ return BerTlv{tag, bytes.subspan(pos, length), pos + length};
+}
+
+// BER INTEGER: big-endian two's complement. SNMP's own fields
+// (version, request-id, error-status/index) all fit well within
+// 64 bits, so a fixed-width sign-extending read is enough.
+inline std::optional<std::int64_t> read_ber_integer(std::span<const unsigned char> value) {
+ if (value.empty() || value.size() > 8) return std::nullopt;
+ std::int64_t result = (value[0] & 0x80) ? -1 : 0;
+ for (auto b : value) result = (result << 8) | b;
+ return result;
+}
+
+} // namespace detail
+
+inline constexpr std::uint8_t kBerTagInteger = 0x02;
+inline constexpr std::uint8_t kBerTagOctetString = 0x04;
+inline constexpr std::uint8_t kBerTagSequence = 0x30;
+inline constexpr std::uint8_t kSnmpPduTrapV1 = 0xA4; // the one PDU with no request-id field
+
+struct SnmpMessage {
+ std::int64_t version; // wire value: 0 = v1, 1 = v2c, 3 = v3
+ // Set only for v1/v2c - v3 replaces the plain community string
+ // with its own security-parameters header (RFC 3412), and the PDU
+ // itself may be encrypted, so neither is populated for it.
+ std::optional<std::string> community;
+ std::optional<std::uint8_t> pdu_tag;
+ std::optional<std::int64_t> request_id;
+};
+
+inline std::optional<SnmpMessage> parse_snmp(std::span<const unsigned char> bytes) {
+ auto outer = detail::read_ber_tlv(bytes, 0);
+ if (!outer || outer->tag != kBerTagSequence) return std::nullopt;
+
+ auto version_tlv = detail::read_ber_tlv(outer->value, 0);
+ if (!version_tlv || version_tlv->tag != kBerTagInteger) return std::nullopt;
+ auto version = detail::read_ber_integer(version_tlv->value);
+ if (!version) return std::nullopt;
+
+ // Wire values, not sequential: v1=0, v2c=1, v3=3 (RFC 3412's
+ // msgVersion). 2 was an abandoned SNMPv2 variant (SNMPv2p/2u) that
+ // never saw wide deployment and isn't handled here either.
+ if (*version == 3) return SnmpMessage{*version, std::nullopt, std::nullopt, std::nullopt};
+ if (*version != 0 && *version != 1) return std::nullopt;
+
+ auto community_tlv = detail::read_ber_tlv(outer->value, version_tlv->next_offset);
+ if (!community_tlv || community_tlv->tag != kBerTagOctetString) return std::nullopt;
+ std::string community(reinterpret_cast<const char*>(community_tlv->value.data()),
+ community_tlv->value.size());
+
+ auto pdu_tlv = detail::read_ber_tlv(outer->value, community_tlv->next_offset);
+ if (!pdu_tlv) return std::nullopt;
+
+ SnmpMessage msg{*version, std::move(community), pdu_tlv->tag, std::nullopt};
+
+ // Every PDU except v1's Trap-PDU starts with request-id; Trap-PDU's
+ // own first field is an enterprise OID instead (RFC 1157 4.1.6),
+ // which isn't decoded here.
+ if (pdu_tlv->tag != kSnmpPduTrapV1) {
+ if (auto request_id_tlv = detail::read_ber_tlv(pdu_tlv->value, 0)) {
+ if (request_id_tlv->tag == kBerTagInteger) {
+ msg.request_id = detail::read_ber_integer(request_id_tlv->value);
+ }
+ }
+ }
+ return msg;
+}
+
+inline std::string snmp_pdu_type_name(std::uint8_t tag) {
+ switch (tag) {
+ case 0xA0: return "GetRequest";
+ case 0xA1: return "GetNextRequest";
+ case 0xA2: return "GetResponse";
+ case 0xA3: return "SetRequest";
+ case kSnmpPduTrapV1: return "Trap";
+ case 0xA5: return "GetBulkRequest";
+ case 0xA6: return "InformRequest";
+ case 0xA7: return "SNMPv2-Trap";
+ default: {
+ char buf[16];
+ std::snprintf(buf, sizeof(buf), "pdu=0x%02x", tag);
+ return buf;
+ }
+ }
+}
+
+// Takes the port to claim in its constructor rather than a fixed
+// override, so it can be registered twice - once for 161 (agent
+// requests/responses) and once for 162 (traps, sent from an ephemeral
+// source port to the trap receiver's well-known port, so l7_summarize's
+// dst-then-src fallback can't find 161 on either side the way it can
+// for e.g. DHCP's two ports). Community strings are shown as-is, not
+// redacted: SNMPv1/v2c send them in the clear regardless, the same
+// reasoning FTP's PASS command follows here.
+class SnmpDissector : public L7Dissector {
+public:
+ explicit SnmpDissector(std::uint16_t port) : port_(port) {}
+
+ std::uint16_t port() const override { return port_; }
+
+ std::optional<std::string> summarize(std::span<const unsigned char> payload) const override {
+ auto msg = parse_snmp(payload);
+ if (!msg) return std::nullopt;
+
+ if (msg->version == 3) return std::string("SNMP v3 (encrypted/authenticated, not decoded)");
+
+ std::string out = "SNMP ";
+ out += (msg->version == 0) ? "v1 " : "v2c ";
+ out += snmp_pdu_type_name(*msg->pdu_tag);
+ out += " community=" + *msg->community;
+ if (msg->request_id) out += " request-id=" + std::to_string(*msg->request_id);
+ return out;
+ }
+
+private:
+ std::uint16_t port_;
+};
+
+} // namespace packeteer::net
diff --git a/include/packeteer/summarize.hpp b/include/packeteer/summarize.hpp
index 57f6f6f..aeff60d 100644
--- a/include/packeteer/summarize.hpp
+++ b/include/packeteer/summarize.hpp
@@ -18,6 +18,7 @@
#include "packeteer/l7/ntp.hpp"
#include "packeteer/l7/quic.hpp"
#include "packeteer/l7/smtp.hpp"
+#include "packeteer/l7/snmp.hpp"
#include "packeteer/l7/ssh.hpp"
#include "packeteer/l7/tftp.hpp"
#include "packeteer/l7/tls.hpp"
@@ -111,6 +112,13 @@ inline const net::L7Registry& l7_registry() {
static const net::SmtpDissector smtp_dissector;
static const net::TftpDissector tftp_dissector;
static const net::QuicDissector quic_dissector;
+ // Two instances, not one: SNMP genuinely uses two well-known ports
+ // (161 agent, 162 trap receiver), and unlike DHCP's 67/68 there's
+ // no port shared by both directions of trap traffic for
+ // l7_summarize()'s dst-then-src fallback to land on - a trap goes
+ // from an ephemeral source port to 162, never touching 161 at all.
+ static const net::SnmpDissector snmp_agent_dissector{net::kSnmpAgentPort};
+ static const net::SnmpDissector snmp_trap_dissector{net::kSnmpTrapPort};
static const net::L7Registry registry = [] {
net::L7Registry r;
r.add(&dns_dissector);
@@ -130,6 +138,8 @@ inline const net::L7Registry& l7_registry() {
// actually matter for correctness, just for which one gets
// tried first.
r.add(&quic_dissector);
+ r.add(&snmp_agent_dissector);
+ r.add(&snmp_trap_dissector);
return r;
}();
return registry;