- 网络安全
- 网络
- IDS
【免费下载链接】zeek
Zeek is a powerful network analysis framework that is much different from the typical IDS you may know.
Modbus 是工业控制系统(ICS/SCADA)中最常见的应用层协议之一,对它的深度解析是网络流量安全监控的关键环节。本指南以 Zeek 仓库中 doc/scripts/base/protocols/modbus/index.rst 所描述的base/protocols/modbus脚本包为主线,完整讲解 Zeek 如何分析 Modbus/TCP 流量、定义 Modbus 函数码与异常码常量、产出modbus.log日志,以及如何利用脚本层事件实现自定义监控。读完本文,你将掌握 Zeek 中 Modbus 分析的完整数据流:从底层 binpac 协议解析器 → 中间层 BIF 事件 → 脚本层日志记录,并能独立编写针对 Modbus 主站/从站、寄存器读写、异常响应的检测脚本。
包结构与加载方式
base/protocols/modbus脚本包位于 scripts/base/protocols/modbus/,由三个脚本文件组成,包入口load.zeek 按顺序加载两个模块:
@load ./consts @load ./main- consts.zeek:定义 Modbus 标准函数码与异常码的常量映射表;
- main.zeek:核心分析脚本,负责创建
modbus日志流、注册默认端口、填充日志记录并输出日志。
在 Zeek 脚本中只需一行即可启用完整的 Modbus 分析能力:
@load base/protocols/modbus该脚本包是基础(base)脚本,默认随 Zeek 的init-default.zeek加载,也就是说在标准安装下无需任何额外配置,Modbus 流量一旦出现就会被解析并记录。
底层协议解析器:从原始字节到 Modbus 结构
脚本层之上,Zeek 使用基于 binpac 生成的协议解析器完成二进制报文解析,源码位于 src/analyzer/protocol/modbus/,其中 modbus.pac 是解析器的总装入口,它声明了ModbusTCP分析器,并为双向流量各建立一个ModbusTCP_Flow(upflow/downflow分别对应请求与响应方向)。
Modbus/TCP 传输头(MBAP)
modbus-protocol.pac 定义了协议报文结构。每一个 Modbus/TCP PDU 由 7 字节的传输头加函数体组成,传输头字段如下:
| 字段 | 宽度 | 含义 |
|---|---|---|
tid | 2 字节 | 事务标识符(Transaction identifier),用于请求/响应配对 |
pid | 2 字节 | 协议标识符,Modbus 协议规定必须为 0(解析器通过&enforce(pid == 0)强制校验) |
len | 2 字节 | 后续字节长度,解析器要求len >= 2 |
uid | 1 字节 | 单元标识符(Unit identifier,即传统意义上的从站地址) |
fc | 1 字节 | 功能码(Function code) |
整体 PDU 的定义体现了"请求/响应方向决定结构"的建模思路:ModbusTCP_PDU(is_orig)依据is_orig选择解析为ModbusTCP_Request还是ModbusTCP_Response。值得注意的两点设计:
- 响应区分正常与异常:
ModbusTCP_Response通过header.fc & 0x80判断——功能码最高位(0x80)被置位即为异常响应(ModbusTCP_ExceptResponse,仅含 1 字节异常码),否则走ModbusTCP_NormalResponse按功能码分发到具体响应结构。 - 未识别功能码兜底:请求与响应的 case 分发均有
default -> unknown: bytestring &restofdata,对暂未建模的功能码,余下数据以原始字节保留,保证解析器不会因未知功能码而崩溃。
功能码覆盖范围
解析器在 function_codes 枚举中建模了以下功能码的请求/响应结构:READ_COILS (0x01)、READ_DISCRETE_INPUTS (0x02)、READ_HOLDING_REGISTERS (0x03)、READ_INPUT_REGISTERS (0x04)、WRITE_SINGLE_COIL (0x05)、WRITE_SINGLE_REGISTER (0x06)、DIAGNOSTICS (0x08)、WRITE_MULTIPLE_COILS (0x0F)、WRITE_MULTIPLE_REGISTERS (0x10)、READ_FILE_RECORD (0x14)、WRITE_FILE_RECORD (0x15)、MASK_WRITE_REGISTER (0x16)、READ_WRITE_MULTIPLE_REGISTERS (0x17)、READ_FIFO_QUEUE (0x18)、ENCAP_INTERFACE_TRANSPORT (0x2B)、OBJECT_MESSAGING (0x5B),以及厂商/机型相关的PROGRAM_*、POLL_*等专有功能码。READ_EXCEPTION_STATUS (0x07)、GET_COMM_EVENT_COUNTER (0x0B)、GET_COMM_EVENT_LOG (0x0C)、REPORT_SLAVE_ID (0x11)在枚举中被注释掉,未建立专门的解析结构。
解析结果的校验与确认机制
modbus-analyzer.pac 承担两层职责:
- 协议确认:连接对象维护
confirmed/orig_pdu/resp_pdu三个布尔状态。只有当双向各成功解析出至少一个完整 PDU(SetPDU置位对应方向标志)后,IsConfirmed()才返回真,此时才调用AnalyzerConfirmation()确认该连接为 Modbus。若后续报文解析失败,则会触发AnalyzerViolation。 - 数据合法性校验:例如读保持寄存器/读输入寄存器响应的
byte_count必须为偶数(寄存器 2 字节对齐),写单线圈的value只能是0x0000或0xFF00,多寄存器写入的byte_count必须与 quantity 匹配,否则报告AnalyzerViolation并拒绝该 PDU。诊断请求(FC=0x08)则依据 diagnostic_subfunctions 枚举进一步校验子功能码:数据长度必须是 2 的倍数,除 "Return Query Data (0x00)" 外其余子功能数据必须恰好 2 字节,各子功能的取值还有专门约束(如RESTART_COMMUNICATIONS_OPTION只允许0x0000/0xFF00),违规时通过 reporter 产生modbus_diag_invalid_request_data等 weird 日志。
事件体系:脚本层与解析器的接口
解析器解析出的每一个结构都会通过 events.bif 定义的 BIF 事件派发到脚本层。这些事件分为三类:
- 通用事件(每条消息都会触发):
modbus_message(c, headers, is_orig):任何 Modbus 消息,无论该功能码是否被进一步支持;modbus_exception(c, headers, code):任何 Modbus 异常响应。
- 按功能码细分的请求/响应事件:
modbus_read_coils_request/response、modbus_read_discrete_inputs_request/response、modbus_read_holding_registers_request/response、modbus_read_input_registers_request/response、modbus_write_single_coil_request/response、modbus_write_single_register_request/response、modbus_write_multiple_coils_request/response、modbus_write_multiple_registers_request/response、modbus_read_file_record_request/response、modbus_write_file_record_request/response、modbus_mask_write_register_request/response、modbus_read_write_multiple_registers_request/response、modbus_read_fifo_queue_request/response、modbus_diagnostics_request/response、modbus_encap_interface_transport_request/response。
所有事件均携带headers: ModbusHeaders参数,该记录由解析器在 modbus-analyzer.pac 的 HeaderToVal 中构造,包含tid、pid、uid、fc、len五个字段。线圈与寄存器值被转换为ModbusCoils(布尔向量)与ModbusRegisters(count 向量)类型的 Zeek 值:线圈数据按位解包——bytestring_to_coils中逐位取出(coils[i/8] >> (i % 8)) % 2转为布尔值;寄存器数据则按大端序拆为 16 位无符号整数。
脚本层核心:main.zeek 详解
main.zeek 是脚本包的主体,其核心逻辑分为四部分。
默认端口
const ports = { 502/tcp } &redef;Modbus/TCP 的 IANA 标准端口为 502/TCP。zeek_init事件中通过Analyzer::register_for_ports(Analyzer::ANALYZER_MODBUS, ports)完成端口到分析器的注册。由于是&redef常量,你可以在本地脚本中扩展监听端口:
redef Modbus::ports += { 1502/tcp };日志流与 Info 记录
脚本包redef enum Log::ID += { LOG }扩展了日志 ID 枚举,并在zeek_init中创建日志流:
Log::create_stream(Modbus::LOG, Log::Stream($columns=Info, $ev=log_modbus, $path="modbus", $policy=log_policy));日志路径为modbus(即输出modbus.log)。Info记录的字段就是modbus.log的列,其完整定义如下:
| 字段 | 类型 | 说明 |
|---|---|---|
ts | time | 请求发生时间 |
uid | string | 连接的唯一标识符 |
id | conn_id | 连接四元组(orig_h/orig_p/resp_h/resp_p) |
tid | count(可选) | Modbus 事务标识符 |
unit | count(可选) | 单元(从站)标识符 |
func | string(可选) | 发送的功能消息名称,如READ_HOLDING_REGISTERS |
pdu_type | string(可选) | REQ(请求)或RESP(响应) |
exception | string(可选) | 响应失败时的异常名称 |
此外脚本包还通过redef record connection += { modbus: Info &optional; }在连接记录上挂载modbus字段,用于在连接生命周期内暂存待输出的记录(注意:track_address字段是在加载策略脚本 track-memmap.zeek 后才会出现)。
功能码名称映射:build_func
build_func(func: count): string函数负责把原始功能码数值翻译为可读字符串,其核心是处理异常响应标志位:
function build_func(func: count): string { local masked = func & ~0x80; # If the function code is in function_codes, use it. Also, # if the masked value isn't in function_codes, use function_codes # &default functionality. if ( func in function_codes || masked !in function_codes ) return function_codes[func]; local s = function_codes[masked]; # Suffix exceptions with _EXCEPTION. if ( func & 0x80 == 0x80 ) s += "_EXCEPTION"; return s; }逻辑要点:当功能码高位置位(异常响应,如0x83)但对应的正常功能码已知时,返回READ_HOLDING_REGISTERS_EXCEPTION这样的名称;当功能码完全未知时,返回function_codes表&default函数生成的unknown-<数值>字符串。
日志记录的两个优先级事件
modbus_message事件被声明了两次,利用事件优先级实现"先填充、后输出"的流水线:
&priority=5版本:若连接尚无modbus记录则初始化,随后依次写入ts(network_time())、tid(来自headers$tid)、unit(来自headers$uid)、func(build_func(headers$function_code))、pdu_type(is_orig ? "REQ" : "RESP")。注意这里请求/响应的判定依据是消息来源方向:来自 TCP 发起方即为请求。&priority=-5版本:如果功能码小于0x80(正常响应或请求),调用Log::write(LOG, c$modbus)输出日志。
异常响应的记录则由modbus_exception事件承担:
&priority=5版本:把exception_codes[code]翻译后的异常名称写入c$modbus$exception;&priority=-5版本:输出日志并delete c$modbus$exception清理字段,避免污染下一条消息。
因此,正常流量与异常响应最终都会进入modbus.log,区别在于异常条目多了一个exception列。测试 exception_handling.test 专门验证了模糊流量下异常处理路径的正确性——binpac 生成的解析器抛出的越界异常必须被正确捕获,且不影响 Zeek 稳定性。
函数码与异常码常量表:consts.zeek
consts.zeek 提供了两张可直接用于脚本编程的映射表,二者均带&default兜底函数与&redef属性,允许用户扩展。
Modbus::function_codes
标准 Modbus 功能码映射(节选核心部分):
| 功能码 | 名称 | 功能码 | 名称 |
|---|---|---|---|
| 0x01 | READ_COILS | 0x10 | WRITE_MULTIPLE_REGISTERS |
| 0x02 | READ_DISCRETE_INPUTS | 0x11 | REPORT_SLAVE_ID |
| 0x03 | READ_HOLDING_REGISTERS | 0x14 | READ_FILE_RECORD |
| 0x04 | READ_INPUT_REGISTERS | 0x15 | WRITE_FILE_RECORD |
| 0x05 | WRITE_SINGLE_COIL | 0x16 | MASK_WRITE_REGISTER |
| 0x06 | WRITE_SINGLE_REGISTER | 0x17 | READ_WRITE_MULTIPLE_REGISTERS |
| 0x07 | READ_EXCEPTION_STATUS | 0x18 | READ_FIFO_QUEUE |
| 0x08 | DIAGNOSTICS | 0x2B | ENCAP_INTERFACE_TRANSPORT |
| 0x0B | GET_COMM_EVENT_COUNTER | 0x5B | OBJECT_MESSAGING |
| 0x0C | GET_COMM_EVENT_LOG | ||
| 0x0F | WRITE_MULTIPLE_COILS |
同时包含厂商/机型专有功能码:PROGRAM_484 (0x09)、POLL_484 (0x0A)、PROGRAM_584_984 (0x0D)、POLL_584_984 (0x0E)、PROGRAM_884_U84 (0x12)、RESET_COMM_LINK_884_U84 (0x13)、PROGRAM_CONCEPT (0x28)、MULTIPLE_FUNCTION_CODES (0x29)、PROGRAM_UNITY (0x5A)、FIRMWARE_REPLACEMENT (0x7D)、PROGRAM_584_984_2 (0x7E)、REPORT_LOCAL_ADDRESS (0x7F)。这些厂商专用码来自公开的 Modbus 扩展资料与逆向分析(代码注释中标注了来源)。未知功能码通过&default=function(i: count):string { return fmt("unknown-%d", i); }呈现为unknown-<n>。
Modbus::exception_codes
Modbus 异常响应码(响应功能码最高位置位时携带的 1 字节异常码):
| 异常码 | 名称 | 异常码 | 名称 |
|---|---|---|---|
| 0x01 | ILLEGAL_FUNCTION | 0x08 | MEMORY_PARITY_ERROR |
| 0x02 | ILLEGAL_DATA_ADDRESS | 0x0A | GATEWAY_PATH_UNAVAILABLE |
| 0x03 | ILLEGAL_DATA_VALUE | 0x0B | GATEWAY_TARGET_DEVICE_FAILED_TO_RESPOND |
| 0x04 | SLAVE_DEVICE_FAILURE | ||
| 0x05 | ACKNOWLEDGE | ||
| 0x06 | SLAVE_DEVICE_BUSY |
未知异常码同样回退到unknown-<n>格式。由于两张表均为&redef,可以在本地策略脚本中为私有功能码补充名称:
redef Modbus::function_codes += { [0x6B] = "VENDOR_READ_REGISTERS" };modbus.log 实战解读
以一段典型的"主站读取保持寄存器"交互为例,modbus.log中会产生两条记录:
#separator \x09 #fields ts uid id.orig_h id.orig_p id.resp_h id.resp_p tid unit func pdu_type exception 192.168.1.10 CFG... 192.168.1.10 49152 192.168.1.20 502 4521 1 READ_HOLDING_REGISTERS REQ - 192.168.1.10 CFG... 192.168.1.10 49152 192.168.1.20 502 4521 1 READ_HOLDING_REGISTERS RESP -若从站返回异常(例如功能码 0x83、异常码 0x02),则响应记录变为:
... tid=4521 unit=1 func=READ_HOLDING_REGISTERS_EXCEPTION pdu_type=RESP exception=ILLEGAL_DATA_ADDRESS关键点:请求与响应的tid相同(事务配对依据),pdu_type区分方向;异常响应的func带_EXCEPTION后缀且exception列有值。这些字段组合可用于检测异常率过高、非法从站地址访问等 ICS 安全场景。
策略脚本:主从设备发现与寄存器内存映射跟踪
除了基础脚本包,scripts/policy/protocols/modbus/ 还提供了两个可选策略脚本,可按需@load。
known-masters-slaves.zeek:发现 Modbus 主站与从站
known-masters-slaves.zeek 监听modbus_message事件,将连接的发起方(c$id$orig_h)识别为MODBUS_MASTER、响应方(c$id$resp_h)识别为MODBUS_SLAVE,维护带 1 天过期时间的集合modbus_nodes,并将新发现的设备写入known_modbus.log(列:ts、host、device_type)。示例输出:
#fields ts host device_type 2026-10-08T19:40:59Z 192.168.1.10 MODBUS_MASTER 2026-10-08T19:40:59Z 192.168.1.20 MODBUS_SLAVEtrack-memmap.zeek:跟踪保持寄存器内存映射变化
track-memmap.zeek 是更具纵深价值的脚本:它维护每个从站设备的寄存器内存映射device_registers: table[addr] of Registers(Registers为table[count] of RegisterValue,记录last_set时间与value)。工作流程如下:
- 在
modbus_read_holding_registers_request事件中,用c$modbus$track_address = start_address+1记录本次读操作的起始地址(注意脚本从 1 开始计数,与协议中 0 基地址相差 1); - 在
modbus_read_holding_registers_response事件中,若响应方匹配track_memmap选项(默认ALL_HOSTS,可通过option track_memmap: Host = ...限制仅跟踪特定主机),则逐寄存器比对旧值与新值; - 值发生变化时触发
changed_register事件,计算变化时间间隔delta,写入modbus_register_change.log(列:ts、uid、id、register、old_val、new_val、delta)。
该脚本对于检测 PLC 寄存器被非法改写(如针对 RTU/PLC 的恶意写操作)非常实用,测试与日志样例可参考 testing/btest/scripts/base/protocols/modbus/ 目录下的用例。
自定义检测脚本示例
结合基础脚本包提供的事件与常量,可以快速编写针对性检测逻辑。以下示例检测对保持寄存器的异常写入(写单寄存器功能码 0x06),并输出 notice:
@load base/protocols/modbus event modbus_write_single_register_request(c: connection, headers: ModbusHeaders, address: count, value: count) { if ( address < 100 ) { NOTICE([$note=Weird::Activity, $msg=fmt("Modbus write to protected register %d = %d (unit %d)", address, value, headers$uid), $conn=c]); } }而 testing/btest/scripts/base/protocols/modbus/events.zeek 展示了官方测试中如何逐一打印全部 Modbus 事件,适合作为学习事件参数的参考模板;coil_parsing_big.zeek 与 register_parsing.zeek 等用例则覆盖了线圈位解析、寄存器数组解析等边界场景。
小结
Zeek 对 Modbus 的分析是一条完整的流水线:binpac 解析器(src/analyzer/protocol/modbus/)把 Modbus/TCP 报文解析为带强校验的结构 → 解析结果经 events.bif 的 30 余个事件派发到脚本层 → main.zeek 与 consts.zeek 完成功能码/异常码翻译并输出modbus.log。在此之上,策略脚本 known-masters-slaves.zeek 与 track-memmap.zeek 提供了设备发现与寄存器变化跟踪能力,所有细节均可通过 testing/btest/scripts/base/protocols/modbus/ 下的测试用例验证。无论是常规流量审计还是 ICS 安全监控,这套脚本包都能直接作为基线使用,并可通过redef、事件处理与Log::PolicyHook(Modbus::log_policy)自由扩展。
- 网络安全
- 网络
- IDS
【免费下载链接】zeek
Zeek is a powerful network analysis framework that is much different from the typical IDS you may know.
相关推荐
Zeek 的 IRC 协议分析:base/protocols/irc 包结构、日志模型与 DCC 文件提取实战指南
Zeek 的 IRC 协议分析:base/protocols/irc 包结构、日志模型与 DCC 文件提取实战指南 本指南围绕 Zeek 仓库中 base/pr
网络安全网络IDSZeek DNP3 协议分析:base/protocols/dnp3 脚本包的加载入口与实现解析
Zeek DNP3 协议分析:base/protocols/dnp3 脚本包的加载入口与实现解析 导读 :本文围绕 Zeek 仓库中 doc/scripts/b
网络安全网络IDSZeek 的 Modbus 协议分析基础脚本解析:加载机制、日志字段与源码实现
Zeek 的 Modbus 协议分析基础脚本解析:加载机制、日志字段与源码实现 导读 本文围绕 Zeek 仓库中 doc/scripts/base/protoc
网络安全网络IDS
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考