diff options
| author | srdusr <[email protected]> | 2025-11-18 22:04:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-11-18 22:04:00 +0200 |
| commit | a8f4866576fd70894ef0080c7797708db664880e (patch) | |
| tree | 4a3e0c7cd6f6f585c8e58fa0b3d721dbe5250343 /include | |
| parent | 3af3e356d6fdf43e6772dc2e91b322f8e8148f62 (diff) | |
| download | packeteer-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.hpp | 178 | ||||
| -rw-r--r-- | include/packeteer/summarize.hpp | 10 |
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; |