3. Modbus协议深度解析

Modbus 诞生于 1979 年,是工业现场最广泛使用的应用层协议之一。它的设计目标非常朴素:在串行链路上以主从方式读写少量寄存器数据。正因为简单,它被移植到 TCP/IP 上后依然流行,也正因为简单,它从设计之初就没有考虑身份认证、报文完整性和机密性。理解 Modbus 的安全问题,必须先把它的数据模型和三种封装方式看清楚,否则后续的报文构造、重放、篡改和拒绝服务分析都会失去落脚点。

一、寄存器模型:四种数据区与地址偏移

Modbus 把从站数据抽象为四张表,每张表有独立的地址空间和访问方向:

  • 线圈(Coils,0x 区):可读可写的 1 位量,常用于控制继电器、阀门、启停信号。协议地址从 00001 开始,PDU 中实际使用 0 基偏移。
  • 离散输入(Discrete Inputs,1x 区):只读 1 位量,用于采集开关状态、限位、故障触点。
  • 输入寄存器(Input Registers,3x 区):只读 16 位量,用于采集模拟量,如温度、压力、电流。
  • 保持寄存器(Holding Registers,4x 区):可读可写 16 位量,用于设定值、参数、累计量。

这里最容易出错的是“协议地址”和“文档地址”的换算。设备手册常写 40001,而报文中起始地址字段写的是 0x0000。换算关系是:文档地址减去区基址再减 1。例如 40011 对应保持寄存器偏移 10,即 0x000A。测试时若把 40011 直接填进报文,读到的将是完全不同的寄存器,这是现场排障中最常见的低级错误之一。

二、功能码:请求与响应的最小语义单元

功能码位于 PDU 第一个字节,决定操作类型。常用功能码如下表:

功能码名称作用典型请求字段
0x01读线圈读取 1 位量状态起始地址、数量
0x02读离散输入读取只读 1 位量起始地址、数量
0x03读保持寄存器读取 16 位量起始地址、数量
0x04读输入寄存器读取只读 16 位量起始地址、数量
0x05写单个线圈置位/复位一个线圈地址、0xFF00/0x0000
0x06写单个寄存器写一个 16 位值地址、值
0x0F写多个线圈批量写位量地址、数量、字节数、数据
0x10写多个寄存器批量写 16 位量地址、数量、字节数、数据

正常响应是“功能码 + 数据”,异常响应则把功能码最高位置 1,并附加一个异常码。例如读保持寄存器请求 0x03 若地址越界,从站返回 0x83 加异常码 0x02(非法数据地址)。异常码 0x01 表示非法功能,0x03 表示非法数据值,0x04 表示从站设备故障。测试中,异常响应本身就是重要信息:它说明从站活着、能解析报文,只是拒绝了这次请求,这与“无响应”有本质区别。

三、三种封装:TCP、RTU 与 ASCII

Modbus 的应用层 PDU 是统一的,差异在封装。RTU 使用二进制编码,帧结构为“从站地址 + PDU + CRC16”,帧间以至少 3.5 个字符时间的静默间隔分隔。CRC 低字节在前。ASCII 使用可打印字符,帧以冒号“:”开始、回车换行结束,内容为十六进制字符,校验采用 LRC。TCP 则去掉串行校验,改为“MBAP 头 + PDU”,MBAP 包含事务标识、协议标识、长度和单元标识,其中协议标识固定为 0x0000,长度字段表示后续字节数。

Modbus 三种封装的帧结构对比 Modbus TCP 事务标识 2B 协议标识 2B 长度 2B 单元标识 1B PDU(功能码 + 数据) Modbus RTU 从站地址 1B PDU(功能码 + 数据) CRC16 2B 帧间静默 ≥3.5T Modbus ASCII 起始 : 从站地址 2字符 PDU(十六进制字符) LRC 2字符 CR LF

三种封装的安全含义不同。RTU 和 ASCII 的校验只能发现随机误码,无法抵御人为篡改,因为 CRC 和 LRC 都是公开算法,攻击者改完数据可以重算校验。TCP 连校验都没有,完全依赖下层以太网和 IP 校验,而这两者同样不提供抗篡改能力。因此,任何能进入链路的人都可以伪造合法报文。

四、先天缺陷:无认证、无加密、无完整性

Modbus 的 PDU 里没有任何字段用于标识“谁在请求”。从站只看到从站地址和功能码,无法区分请求来自合法主站还是攻击者。这带来三个直接后果:

  • 无认证:任意主机只要知道从站 IP 和单元标识,就能读写寄存器。单元标识在 TCP 中常被忽略,很多设备对任意单元标识都响应。
  • 无加密:寄存器值、设定值、控制命令全部明文传输,抓包即可还原工艺参数。
  • 无完整性保护:攻击者可修改报文中的地址、数量或数据,从站无法察觉。RTU 的 CRC 可被重算,TCP 无校验。
不要把“有 CRC”等同于“有安全”。CRC 是差错检测码,不是消息认证码。它没有密钥,任何人都能计算,因此不能防止恶意篡改。

五、典型攻击面与测试切入点

基于上述缺陷,Modbus 的典型攻击面可以归纳为四类,测试时可按此逐项验证:

  1. 未授权读取:直接发送 0x03/0x04 读取输入寄存器和保持寄存器,遍历地址空间,获取温度、压力、配方、累计产量等敏感数据。测试时从 0x0000 开始按 125 个寄存器一批扫描,记录有响应的地址段。
  2. 未授权写入:发送 0x05/0x06/0x0F/0x10 修改线圈和保持寄存器,例如把设定值改为越界值、把启停线圈置位。测试应在授权和隔离环境进行,先读原值、再写测试值、再读回确认,最后恢复原值。
  3. 重放攻击:抓取一次合法的写命令报文,稍后原样重发。由于没有随机数和时间戳,从站会再次执行。测试时可用脚本记录报文并在数秒后重放,观察执行机构是否再次动作。
  4. 拒绝服务:大量并发连接或高频请求耗尽从站连接资源;或发送长度字段与实际不符的畸形 MBAP 报文,触发解析异常。测试时逐步增加并发数,观察响应时间、超时率和设备是否重启。

下面给出一段用 Python 构造并发送读保持寄存器请求的示例,用于验证未授权读取。示例参数为教学假设:目标 192.168.1.10:502,单元标识 1,读取保持寄存器偏移 0 开始的 10 个寄存器。

import socket, struct

def read_holding(ip, port, unit, start, count):
    # MBAP: 事务ID=0x0001, 协议ID=0x0000, 长度=6, 单元ID
    mbap = struct.pack('>HHHB', 0x0001, 0x0000, 6, unit)
    pdu  = struct.pack('>BHH', 0x03, start, count)
    s = socket.create_connection((ip, port), timeout=3)
    s.sendall(mbap + pdu)
    resp = s.recv(1024)
    s.close()
    return resp

resp = read_holding('192.168.1.10', 502, 1, 0, 10)
print(resp.hex())
# 正常响应示例: 000100000015010314....  (功能码0x03, 字节数0x14, 20字节数据)
# 异常响应示例: 000100000003018302     (0x83 + 异常码0x02 非法数据地址)

判断结果时看两点:若返回功能码 0x03 且字节数等于 2×count,说明未授权读取成功;若返回 0x83,说明从站拒绝但仍在响应,可继续调整地址或数量探测边界。若完全无响应,则可能是网络不通、端口未开或单元标识不匹配,需要先用扫描确认 502 端口和从站可达性。

测试前务必确认授权范围与隔离条件。Modbus 写操作可能直接驱动现场设备,任何未授权的写入测试都可能造成安全事故。建议在仿真从站或离线实验台上完成写入与重放验证,生产环境只做只读探测。