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,长度字段表示后续字节数。
三种封装的安全含义不同。RTU 和 ASCII 的校验只能发现随机误码,无法抵御人为篡改,因为 CRC 和 LRC 都是公开算法,攻击者改完数据可以重算校验。TCP 连校验都没有,完全依赖下层以太网和 IP 校验,而这两者同样不提供抗篡改能力。因此,任何能进入链路的人都可以伪造合法报文。
四、先天缺陷:无认证、无加密、无完整性
Modbus 的 PDU 里没有任何字段用于标识“谁在请求”。从站只看到从站地址和功能码,无法区分请求来自合法主站还是攻击者。这带来三个直接后果:
- 无认证:任意主机只要知道从站 IP 和单元标识,就能读写寄存器。单元标识在 TCP 中常被忽略,很多设备对任意单元标识都响应。
- 无加密:寄存器值、设定值、控制命令全部明文传输,抓包即可还原工艺参数。
- 无完整性保护:攻击者可修改报文中的地址、数量或数据,从站无法察觉。RTU 的 CRC 可被重算,TCP 无校验。
五、典型攻击面与测试切入点
基于上述缺陷,Modbus 的典型攻击面可以归纳为四类,测试时可按此逐项验证:
- 未授权读取:直接发送 0x03/0x04 读取输入寄存器和保持寄存器,遍历地址空间,获取温度、压力、配方、累计产量等敏感数据。测试时从 0x0000 开始按 125 个寄存器一批扫描,记录有响应的地址段。
- 未授权写入:发送 0x05/0x06/0x0F/0x10 修改线圈和保持寄存器,例如把设定值改为越界值、把启停线圈置位。测试应在授权和隔离环境进行,先读原值、再写测试值、再读回确认,最后恢复原值。
- 重放攻击:抓取一次合法的写命令报文,稍后原样重发。由于没有随机数和时间戳,从站会再次执行。测试时可用脚本记录报文并在数秒后重放,观察执行机构是否再次动作。
- 拒绝服务:大量并发连接或高频请求耗尽从站连接资源;或发送长度字段与实际不符的畸形 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 端口和从站可达性。