📚 第八章 应用协议
📄 一句话概括

SNMP 的报文用 BER 编码:一种「标签 + 长度 + 内容」的嵌套写法。 v1 和 v2c 的团体名是明文的,public 在链路上原样可见。

📝 本节和前面几节的关系

这一节手搓一个 SNMP GetRequest,把每一层字节都拆开给你看。 这是全书唯一一次「不借助任何库,纯手工构造一个协议报文」。

一、BER:标签、长度、内容

SNMP 的报文格式叫 BER, 它的规则只有一条:每个东西都写成「一个标签字节、一段长度、然后是内容」。 内容本身可能又是一个这样的结构,所以整条报文能一层层拆开,像俄罗斯套娃。 长度字段还有一个约定:小于 128 就一个字节搞定; 大于等于 128 时,首字节的最高位置 1,低七位表示「后面还有几个字节是长度」。 所以 0x81 0x90 表示「后面跟 1 个字节的长度,值是 0x90」。 这套编码的好处是「不需要预先知道总长度」:解析器可以边读边算。 坏处是「变长」——长度不是固定位宽,所以没法「跳到第几个字段」。 现代的二进制协议(比如 Protobuf)也用了类似的思路,只是把「变长整数」做得更省。

二、一个 GetRequest 的六层

整条报文是「SEQUENCE 里装三样」:version、community、PDU。 version 是整数 1(表示 v2c),community 是明文的团体名, PDU 里再装 request-id、error-status、error-index、varbind。 request-id 的作用是「配对响应」:因为 SNMP 跑在 UDP 上,响应可能乱序或者丢失, 所以靠这个号认领。 error-status 和 error-index 是「哪一条出错」的两级结构—— 一个说「出错了」,一个说「是第几条」。 varbind 是「变量绑定」,它的结构是「一个 OID 加一个值」。 在 GetRequest 里,值是「空」(05 00 表示 null), 因为我们的意思是「请把这个 OID 的值给我」。 在 Response 里,值就被填上了。

三、161 和 162,都是 UDP

SNMP 只用两个端口:161 是「管理器来问」,162 是「设备主动报」。 两个都是 UDP。 为什么用 UDP 不用 TCP:因为「问一句答一句」用 UDP 更省,丢了再问一遍就行。 而且管理一台设备时,管理器和设备之间是「短交互」,为一次查询建一条 TCP 连接很不划算。 但 UDP 带来的问题也很实在:响应可能丢,所以客户端必须自己设超时和重试。 这也是为什么 SNMP 的默认超时通常是 1 秒、重试 3 次——这三个数成了几乎所有网管软件的默认值。

四、动手 Demo:手搓一个 SNMP 报文,看它到底长什么样

只用 bytes 和一个自己写的 BER 编码器,构造一条读 sysDescr.0 的 GetRequest。 然后把每一层拆开对照。

def ber_len(n):
    if n < 128:
        return bytes([n])
    out = b''
    while n:
        out = bytes([n & 0xFF]) + out
        n >>= 8
    return bytes([0x80 | len(out)]) + out

def ber_tlv(tag, payload):
    return bytes([tag]) + ber_len(len(payload)) + payload

def ber_int(v):
    out = b'' if v == 0 else b''
    while v:
        out = bytes([v & 0xFF]) + out
        v >>= 8
    return ber_tlv(0x02, out or b'\x00')

def ber_oid(dotted):
    parts = [int(x) for x in dotted.split('.')]
    out = bytes([parts[0] * 40 + parts[1]])
    for p in parts[2:]:
        out += bytes([p]) if p < 128 else bytes([0x80 | (p >> 7), p & 0x7F])
    return ber_tlv(0x06, out)

oid = '1.3.6.1.2.1.1.1.0'      # sysDescr.0
varbind = ber_tlv(0x30, ber_oid(oid) + ber_tlv(0x05, b''))
vbl = ber_tlv(0x30, varbind)
pdu = ber_tlv(0xA0, ber_int(0x1234) + ber_int(0) + ber_int(0) + vbl)
msg = ber_tlv(0x30, ber_int(1) + ber_tlv(0x04, b'public') + pdu)
print(len(msg), msg.hex())

实跑输出:

--------------------------------------------------------------------
手搓一个 SNMP 报文,看它到底长什么样
--------------------------------------------------------------------
**SNMP 的报文用 BER 编码**——**一种「标签 + 长度 + 内容」的嵌套写法,每一层都一样。**
**所以整条报文能一层层拆开,像俄罗斯套娃。**

**目标是读 `sysDescr.0`**,OID 是 `1.3.6.1.2.1.1.1.0`。
**整条 GetRequest 一共 41 字节**:

    302702010104067075626c6963a01a02
    021234020100020100300e300c06082b
    060102010101000500

**逐层拆开**:

  层                字节                          含义
  -------------------------------------------------------------------------
  外层 SEQUENCE     `30 27`                       **整个报文**
  version           `02 01 01`                    **1 表示 SNMPv2c**
  community         `04 06 70 75 62 6C 69 63`     **明文口令 public**
  PDU 类型          `A0`                          **A0 是 GetRequest**
  request-id        `02 02 12 34`                 **用来配对响应**
  error-status      `02 01 00`                    **0 表示没出错**
  error-index       `02 01 00`                    **0 表示不是某一条出错**
  varbind           `30 0C 06 08 …`               **要读哪个 OID**

**注意第二行**:**`community` 是明文的**,**`public` 这六个字母在链路上原样可见。**
**SNMPv1 和 v2c 都是这样**——**所以「用 SNMP 就别用默认团体名」是运维里最老的一条规矩。**
**v3 才加了认证和加密。**

**再看 OID 是怎么编码的**:

  OID                     编码                                字节
  -----------------------------------------------------------------
  `1.3.6.1.2.1.1.1.0`     `06 08 2b 06 01 02 01 01 01 00`     10
  `1.3.6.1.2.1.1.5.0`     `06 08 2b 06 01 02 01 01 05 00`     10
  `1.3.6.1.4.1.9.1.1`     `06 08 2b 06 01 04 01 09 01 01`     10

**第一个数被乘法合并了**:**`1.3` 变成 `1 乘 40 加 3`,也就是 43(0x2B)。**
**因为前两位的组合只有 6 种可能,一个字节足够。**
**后面每个数字如果小于 128 就占一字节,大了才用「多位拼接」。**
**所以 OID 的字节数不固定,只能现算。**

**SNMP 的端口只有两个**:**161 是「管理器来问」,162 是「设备主动报」。**
**两个都是 UDP**——**因为「问一句答一句」用 UDP 更省,丢了再问一遍就行。**

整条报文 41 字节,十六进制是 3027 020101 0406 7075626c6963 a01a ...。 开头的 30 27 就是「SEQUENCE,长度 39」,加上这两个字节正好 41。 注意第二层 04 06 7075626c6963: 04 是 OCTET STRING,06 是长度,后面六个字节就是 public 的 ASCII。 70 是 p、75 是 u、62 是 b…… 所以团体名在链路上就是明文,抓包一眼就能看出来。 OID 的编码有个小机关:前两位被乘法合并了——1.3 变成 1 乘 40 加 3,也就是 43(0x2B)。 因为前两位的组合只有 6 种可能,一个字节足够。 后面每个数字如果小于 128 就占一字节,大了才用「多位拼接」。 所以 OID 的字节数不固定,只能现算。

30 秒实验

  1. 算一下:1.3 合并成一个字节,值是多少?
  2. 想一下:为什么 SNMP 用 UDP 不用 TCP?
  3. 想一下:request-id 是干什么用的?

常见误区

  1. 以为「SNMP 的团体名是密码」。 它是明文口令,v3 才有认证和加密。
  2. 以为「BER 的长度是固定一字节」。 大于 128 就变长,这正是它难解析的地方。
  3. 以为「SNMP 用 TCP 更可靠所以更好」。 短交互用 UDP 更省,可靠性靠应用层重试。
自测三问(点开对答案) 1. SNMP 报文的三层结构是什么? —— **version、community、PDU**,整体装在 SEQUENCE 里。 2. `1.3.6.1` 的 `1.3` 怎么编码? —— **1 乘 40 加 3 等于 43**,也就是 `0x2B`。 3. 161 和 162 分别是什么? —— **161 是查询,162 是设备主动发的 Trap。**

下一节看 MIB:SNMP 查的那棵树。

相关笔记