SNMP 协议简介。

这里先对 SNMP v2c 及之前版本的协议概念做简单介绍。

概念

SNMP(Simple Network Management Protocol,简单网络管理协议)是在 SGMP(Simple Gateway Monitoring Protocol,简单网关监控协议)的基础上实现的。在 SGMP 的基础上,SNMP 添加了新的管理信息结构和管理信息库。

SNMP 协议用于收集和整理 IP 网络上被管理设备的信息,它可以修改这些信息,从而改变设备的行为。

SNMP 是基于 UDP 协议的。

基本组件

SNMP 由一组网络管理标准组成,包括应用层协议、数据库模式和一组数据对象。

  • Manager 负责监视网络上的主机或设备;
  • 每个被管理的系统会运行 Agent,通过 SNMP 协议向 Manager 报告信息。

三个关键组件:

  • 被管理的设备
  • Agent,代理进程,是运行在被管理设备上的软件
  • 网络管理系统 Network management station (NMS) 是运行在 Manager 上的软件。

在 NMS 使用 SNMP 协议向 Agent 查询被管理设备上的信息时,Agent 根据 SNMP 的请求将所需的数据公开为变量。

SNMP 协议本身并不定义系统应该提供哪些变量,而是使用可扩展的设计,让应用程序定义自己的层次结构,这些层次结构被表述为管理信息库(Management information base, MIB)。

MIB 描述了设备子系统的管理数据结构,它们使用包含对象标识符(object identifiers, OID)的分层命名空间。每个 OID 标识一个可以通过 SNMP 读取或设置的变量。

MIB 使用管理信息结构 2.0 版(SMIv2、RFC 2578)(ASN.1 的子集)定义的符号。

工作方式

SNMP 端口是 SNMP 通信端点,SNMP 消息传输通过 UDP 进行,通常使用 UDP 端口号161/162。有时也使用传输层安全性(TLS)或数据报传输层安全性(DTLS)协议,端口使用情况如下表所示。

过程协议端口号
代理进程接收请求信息UDP161
NMS 与代理进程之间的通信UDP161
NMS 接收通知信息UDP162
代理进程生成通知信息-任何可用的端口
接收请求信息TLS/DTLS10161
接收通知信息TLS/DTLS10162

版本

SNMP 有三种版本:SNMPv1,SNMPv2c 和 SNMPv3。

  • SNMPv1:SNMP 的第一个版本,它提供了一种监控和管理计算机网络的系统方法,它基于团体名认证,安全性较差,且返回报文的错误码也较少。它在 RFC 1155 和 RFC 1157 中定义。
  • SNMPv2c:第二个版本 SNMPv2c 引入了 GetBulk 和 Inform 操作,支持更多的标准错误码信息,支持更多的数据类型。它在 RFC 1901,RFC 1905和 RFC 1906中定义。
  • SNMPv3:鉴于 SNMPv2c 在安全性方面没有得到改善,IETF 颁布了 SNMPv3 版本,提供了基于 USM(User Security Module)的认证加密和基于 VACM(View-based Access Control Model)的访问控制,是迄今为止最安全的版本。SNMPv3 在 RFC 1905,RFC 1906,RFC 2571,RFC 2572,RFC 2574 和 RFC 2575中定义。

用法

使用 net-snmp 工具管理 Agent。

Get 请求:

snmpget -v2c -c public 192.168.202.173 1.3.6.1.4.1.9.9.95.1.3.1.1.7

GetNext 请求:

snmpgetnext -v2c -c public 192.168.202.173 .1

Set 请求

snmpset -c public -v 1 192.168.202.152 1.3.6.1.2.1.1.5.0 s test

GetBulk 请求

snmpbulkget -v2c -c public 192.168.202.152 iso.0.8802.1.1.2.1.3.3.0

可以看到,我们可以进行一些请求(get、getnext、set、bulkget、etc.);设置 snmp 版本、设置 community、设置 OID 等操作,那么这些操作和设置都代表什么含义呢?

OID

我对 OID 具体的实现并不感兴趣,而是对怎样使用它更感兴趣。如果有机会的话以后再整理吧。

我们可以使用 snmpwalk 获取指定 Agent 的所有 OID。

snmpwalk 也是 Net-SNMP 工具集合的一部分,它扫描 OID 的方法很简单:

snmpwalk -v <snmp version> -c <community> <ip> <oid>

想要通过 SNMP 2c 协议扫描 IP 为 192.168.x.y,community 为 public 的所有 OID,我们可以输入:

snmpwalk -v 2c -c public 192.168.x.y .1

它的格式形如 iso.<num1>.<num2>.<num n>,其中的 num 可以是任意数字。

PDU

SNMPv1 指定了五种核心协议数据单元(protocol data units, PDUs),SNMPv2 中添加了 GetBulkRequestInformRequest PDU,SNMPv3 添加了 Report PDU。

操作类型发送端描述备注
GetNMSGet 操作可以从 Agent 中提取一个或多个参数值。-
GetNextNMSGetNext 操作可以从 Agent 中按照字典序提取下一个参数值。-
SetNMSSet 操作可以设置 Agent 的一个或多个参数值。-
ResponseAgentResponse 操作可以返回一个或多个参数值。这个操作是由 Agent 发出的,它是 GetRequest、GetNextRequest、SetRequest 和 GetBulkRequest 四种操作的响应操作。Agent 接收到来自 NMS 的 Get/Set 指令后,通过 MIB 完成相应的查询/修改操作,然后利用 Response 操作将信息回应给 NMS。-
TrapAgentTrap 信息是 Agent 主动向 NMS 发出的信息,告知管理进程设备端出现的情况。-
GetBulkNMSGetBulk 操作实现了 NMS 对被管理设备的信息群查询。SNMPv1版本不支持 GetBulk 操作
InformAgentInformRequest 是 Agent 向 NMS 主动发送告警。与 Trap 告警不同的是,被管理设备发送 Inform 告警后,需要 NMS 回复 InformResponse 来进行确认。SNMPv1版本不支持 Inform 操作
Report

协议格式

一般来说,整个 SNMP 消息三个字段组成:

  • SNMP Version:SNMP 的版本;
  • SNMP Community:团体字;
  • SNMP PDU:上文介绍的 PDU。

PDU 是复杂的,它由多个数据构建而成:

其中:

  • Request ID 用来标注特定 SNMP 的请求,它会在 Agent 返回的消息中被设置;
  • Error 在发送时的默认值是 0,只有 Agent 处理出错时才会被设置。具体的错误类型见 附录 1
  • Error Index 保存了发生错误时导致出错的对象的指针,默认为 0。
  • Varbind 由两个字段组成,第一个字段是 OID,用于指定特定参数,第二个字段是包含特定参数的值。
    • 这里的问题是,怎样使用 net-snmp 一次发送多个 Varbind?

协议解析

SNMP 报文格式是经典的 TLV(Type-Length-Value)字段,对于一些简单的数据,它的格式是这样的:

而对于复杂的嵌套数据(比如 Sequence 和 PDUs),则是嵌套保存的:

对 OID 编码需要遵循两个原则:

  1. OID 的前两个数字 x.y 需要使用公式 (40*x)+y 编码为一个值。例如,一般的 OID 前两个数字总是 1.3,那么按照公式就可以编码为 43(0x2B);
  2. OID 剩余的数字编码为一个字节;
    • 但是对于大数字有特殊的规则,因为一个字节只能表示不超过 255 的数字,因此规定任何超过 127 的数字都必须用一个字节以上进行编码,并用字节的最高位作为标志,让接收者知道这个数字会用多个数字编码。
    • 例如,想要编码数字 2680,就需要分成两个字节 0x94 0x78,在计算的时候用 (0x94-0x80)*0x80+0x78=2680 来计算。

参考协议格式协议解析这两段,我们可以对任意 SNMP 报文进行解析,下面给出一个书意图:

SNMP v3

(以 Cisco 的设备为例)

新的元素

  • SNMP 视图(SNMP View):定义可以在 Agent 上看到的内容,避免未授权访问;
  • SNMP 用户(SNMP User):SNMP User 和 SNMP Group 相关联,在关联时定义了用户名、密码、加密和身份验证级别;
  • SNMP 群组(SNMP Group):SNMP View 和 SNMP Group 关联,定义了访问类型(如只读/读写)。与设备交互期间启动的安全方法由 SNMP Group 决定。

认证模型

  • 身份验证:用于确保只有预期的收件人才能读取 trap。创建消息时,会根据实体的 EngineID 为其赋予一个特殊密钥。该密钥与预期接收者共享并用于接收消息。
  • 加密 - 隐私对 SNMP 消息的有效负载进行加密,确保未经授权的用户无法读取。任何被截获的 trap 信息都将充满乱码,无法读取。在 SNMP 信息必须通过互联网传输的应用中,隐私功能尤其有用。

安全级别

SNMP Group 中有三种安全级别:

  • noAuthnoPriv - 无需验证和隐私的通信;
  • authNoPriv - 有身份验证但无隐私的通信。用于身份验证的协议是 MD5 和 SHA;
  • authPriv - 带有身份验证和隐私保护功能的通信。用于身份验证的协议有 MD5 和 SHA,用于隐私保护的协议有 DES 和 AES。

参考资料

附录 1. PDU 错误类型

  • 0x00 — No error occurred
  • 0x01 — Response message too large to transport
  • 0x02 — The name of the requested object was not found
  • 0x03 — A data type in the request did not match the data type in the SNMP agent
  • 0x04 — The SNMP manager attempted to set a read-only parameter
  • 0x05 — General Error (some error other than the ones listed above)

附录 2. 原始数据类型

Primitive Data TypesIdentifierComplex Data TypesIdentifier
Integer0x02Sequence0x30
Octet String0x04GetRequest PDU0xA0
Null0x05GetResponse PDU0xA2
Object Identifier0x06SetRequest PDU0xA3

附录 3. scapy 实现不完整问题

scapy 提供了一个 snmpwalk 函数,位于 scapy.layers.snmp

def snmpwalk(dst, oid="1", community="public"):
    try:
        while True:
            r = sr1(IP(dst=dst) / UDP(sport=RandShort()) / SNMP(community=community, PDU=SNMPnext(varbindlist=[SNMPvarbind(oid=oid)])), timeout=2, chainCC=1, verbose=0, retry=2)  # noqa: E501
            if r is None:
                print("No answers")
                break
            if ICMP in r:
                print(repr(r))
                break
            print("%-40s: %r" % (r[SNMPvarbind].oid.val, r[SNMPvarbind].value))
            oid = r[SNMPvarbind].oid
 
    except KeyboardInterrupt:
        pass
 

我们可以轻松调用这个函数:

snmpwalk(dst="192.168.202.152", oid=".1", community="public")

行为差异

理论上,Net-SNMP 和 scapy 提供的 snmpwalk 应当行为相同,但在用 scapy 的时候会出现这样的问题:

1.0.8802.1.1.2.1.3.3.0                  : <ASN1_STRING[b'ohmytest']>
1.0.8802.1.1.2.1.3.4.0                  : <ASN1_STRING[b'MikroTik RouterOS 7.1.1 (stable) x86']>
1.0.8802.1.1.2.1.3.5.0                  : <ASN1_STRING[b'(']>
... ...
1.3.6.1.2.1.31.1.1.1.3.1                : 0x0 <ASN1_COUNTER32[0]>
1.3.6.1.2.1.31.1.1.1.4.1                : 0x0 <ASN1_COUNTER32[0]>
1.3.6.1.2.1.31.1.1.1.5.1                : 0x0 <ASN1_COUNTER32[0]>
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
Cell In[2], line 1
----> 1 snmpwalk(dst="192.168.202.152", oid=".1", community="public")

File ~/anaconda3/envs/fuzz/lib/python3.9/site-packages/scapy/layers/snmp.py:293, in snmpwalk(dst, oid, community)
    291             print(repr(r))
    292             break
--> 293         print("%-40s: %r" % (r[SNMPvarbind].oid.val, r[SNMPvarbind].value))
    294         oid = r[SNMPvarbind].oid
    296 except KeyboardInterrupt:

File ~/anaconda3/envs/fuzz/lib/python3.9/site-packages/scapy/packet.py:1327, in Packet.__getitem__(self, cls)
   1325     else:
   1326         name = cast(str, lname)
-> 1327     raise IndexError("Layer [%s] not found" % name)
   1328 return ret

IndexError: Layer [SNMPvarbind] not found

在对 OID 1.3.6.1.2.1.31.1.1.1.5.1 进行 next 时报错了,这是 Net-SNMP 中 snmpwalk 不会出现的问题。

复现包

我们可以通过 scapy 构造一个简单的报文复现:

def create_getnext_request(ver="v2c", community="public", ip="127.0.0.1", oid=".1"):
    get_payload = IP(dst=ip) / UDP(sport=161, dport=161) / SNMP(
        version=ver, community=community, PDU=SNMPnext(
            varbindlist=[SNMPvarbind(oid=ASN1_OID(oid))]))
    return get_payload
 
p = sr1(create_getnext_request(ip="192.168.202.152", oid="1.3.6.1.2.1.31.1.1.1.5.1"))

得到的 p 是:

Begin emission:
Finished sending 1 packets.
.*
Received 2 packets, got 1 answers, remaining 0 packets

p

<IP  version=4 ihl=5 tos=0x0 len=75 id=7276 flags=DF frag=0 ttl=63 proto=udp chksum=0x195f src=192.168.202.152 dst=172.31.206.118 |<UDP  sport=snmp dport=snmp len=55 chksum=0xdb74 |<Raw  load='0-\x02\x01\x01\x04\x06public\\xa2 \x02\x01\x00\x02\x01\x00\x02\x01\x000\x150\x13\x06\x0b+\x06\x01\x02\x01\x1f\x01\x01\x01\x06\x01F\x04\x01\x0c3\\xd3' |>>>

hexdump(p)

0000  45 00 00 4B 4D 05 40 00 3F 11 E8 C5 C0 A8 CA 98  E..KM.@.?.......
0010  AC 1F CE 76 00 A1 00 A1 00 37 88 66 30 2D 02 01  ...v.....7.f0-..
0020  01 04 06 70 75 62 6C 69 63 A2 20 02 01 00 02 01  ...public. .....
0030  00 02 01 00 30 15 30 13 06 0B 2B 06 01 02 01 1F  ....0.0...+.....
0040  01 01 01 06 01 46 04 01 0C 42 26                 .....F...B&

可以看到,收到的包被解析成了 Raw 格式,无法解析成 SNMP 报文,也难怪上面会报错。我们对比 snmpgetnext:

$ snmpgetnext -v2c -c public 192.168.202.152 1.3.6.1.2.1.31.1.1.1.5.1
iso.3.6.1.2.1.31.1.1.1.6.1 = Counter64: 17590663

可以看到它正常返回了下一个 OID 和对应的值:iso.3.6.1.2.1.31.1.1.1.6.1

那 scapy 中正常的报文长什么样呢?我们对 .1 NEXT 试试:

p = sr1(create_getnext_request(ip="192.168.202.152", oid=".1"))

得到的结果为:

Begin emission:
Finished sending 1 packets.
.*
Received 2 packets, got 1 answers, remaining 0 packets

p

<IP  version=4 ihl=5 tos=0x0 len=78 id=29917 flags=DF frag=0 ttl=63 proto=udp chksum=0xc0ea src=192.168.202.152 dst=172.31.206.118 |<UDP  sport=snmp dport=snmp len=58 chksum=0x9381 |<SNMP  version='v2c' 0x1 <ASN1_INTEGER[1]> community=<ASN1_STRING[b'public']> PDU=<SNMPresponse  id=0x0 <ASN1_INTEGER[0]> error='no_error' 0x0 <ASN1_INTEGER[0]> error_index=0x0 <ASN1_INTEGER[0]> varbindlist=[<SNMPvarbind  oid=<ASN1_OID['.1.0.8802.1.1.2.1.3.3.0']> value=<ASN1_STRING[b'ohmytest']> |>] |> |>>>

hexdump(p)

0000  45 00 00 4E 74 DD 40 00 3F 11 C0 EA C0 A8 CA 98  E..Nt.@.?.......
0010  AC 1F CE 76 00 A1 00 A1 00 3A 93 81 30 30 02 01  ...v.....:..00..
0020  01 04 06 70 75 62 6C 69 63 A2 23 02 01 00 02 01  ...public.#.....
0030  00 02 01 00 30 18 30 16 06 0A 28 C4 62 01 01 02  ....0.0...(.b...
0040  01 03 03 00 04 08 6F 68 6D 79 74 65 73 74        ......ohmytest

这是一个正常的 SNMP 报文,但是奇怪的事情发生了:我们注意到,在这次请求中,我们实际上收到了两个包;而在上一次错误的请求中,我们只收到了一个包!

抓包

这里我们抓包看一下 Net-SNMP 和 scapy 构造包的区别。在这里我发了四个包,分别用 上文实现的 create_getnext_request 和 Net-SNMP 的 snmpgetnext 对 oid=".1"oid=1.3.6.1.2.1.31.1.1.1.5.1 进行发送。最终收到了 10 个包,如下图所示,前六个包是上文实现的函数发送接收的。

可以看到,实际上不论是 Net-SNMP 和 scapy 都正常收到了 SNMP 报文,那为什么在 oid=1.3.6.1.2.1.31.1.1.1.5.1 的时候显示为没有接收呢?又为什么 scapy 发送的报文会多一条 Port unreachable 的回复呢?

戳啦,再回头看一下,它们的行为是一致的,只是 scapy 没有解析出来而已。收到的 Raw 包为:

p[Raw].fields['load']

b'0-\x02\x01\x01\x04\x06public\xa2 \x02\x01\x00\x02\x01\x00\x02\x01\x000\x150\x13\x06\x0b+\x06\x01\x02\x01\x1f\x01\x01\x01\x06\x01F\x04\x01\r\x94\xc9'

0000   00 15 5d 98 cb 68 00 15 5d 1c 3d 19 08 00 45 00
0010   00 4b 6a 55 40 00 3f 11 cb 75 c0 a8 ca 98 ac 1f
0020   ce 76 00 a1 00 a1 00 37 3b f1 30 2d 02 01 01 04
0030   06 70 75 62 6c 69 63 a2 20 02 01 00 02 01 00 02
0040   01 00 30 15 30 13 06 0b 2b 06 01 02 01 1f 01 01
0050   01 06 01 46 04 01 0c b7 72

和正常的报文对比没有什么区别

结论

我们可以用 scapy 发包,但是解析包可能有问题。可能是 scapy SNMP 的实现有问题。它的实现为:

class SNMP(ASN1_Packet):
    ASN1_codec = ASN1_Codecs.BER
    ASN1_root = ASN1F_SEQUENCE(
        ASN1F_enum_INTEGER("version", 1, {0: "v1", 1: "v2c", 2: "v2", 3: "v3"}),  # noqa: E501
        ASN1F_STRING("community", "public"),
        ASN1F_CHOICE("PDU", SNMPget(),
                     SNMPget, SNMPnext, SNMPresponse, SNMPset,
                     SNMPtrapv1, SNMPbulk, SNMPinform, SNMPtrapv2)
    )
 
    def answers(self, other):
        return (isinstance(self.PDU, SNMPresponse) and
                isinstance(other.PDU, (SNMPget, SNMPnext, SNMPset)) and
                self.PDU.id == other.PDU.id)