☰
Zeek Modbus 协议分析:从 base 脚本到日志输出的完整实现指南
2026/10/9 1:57:13 网站建设 项目流程
  • 网络安全
  • 网络
  • IDS

【免费下载链接】zeek

Zeek is a powerful network analysis framework that is much different from the typical IDS you may know.

项目地址:https://gitcode.com/gh_mirrors/ze/zeek
点击查看免费下载

Zeek 内置的 Modbus 分析器用于监控工业控制系统中最常见的工控协议之一 Modbus/TCP。本文以 base/protocols/modbus/main.zeek 为核心骨架,完整讲解其端口注册、Modbus::Info日志记录结构、modbus.log日志流的创建、函数码/异常码的语义化映射,以及底层 binpac 解析器与 btest 测试的验证方式。读完本文,你将能够读懂并扩展 Zeek 的 Modbus 日志输出,掌握利用Modbus::log_policy钩子定制日志、结合Modbus::log_modbus事件做实时告警的方法。

一、模块概览与加载方式

Modbus 分析脚本位于 scripts/base/protocols/modbus/ 目录,属于 Zeek 的 base 脚本集,随 Zeek 默认加载。该目录的入口文件load.zeek 只有两行:

@load ./consts @load ./main

即依次加载两个脚本:

  • consts.zeek:定义 Modbus 函数码(function codes)与异常码(exception codes)的语义化名称表;
  • main.zeek:核心分析脚本,负责注册端口、定义日志记录类型Modbus::Info、创建modbus日志流,并消费底层分析器产生的事件。

main.zeek声明的命名空间为Modbus,并导入 consts.zeek。Zeek 官方文档对它的定位就是 "Base Modbus analysis script",即 Modbus 协议分析的基础支撑层。

二、端口注册:Modbus::ports

main.zeek中定义了一个可重定义(&redef)的常量,用于声明 Modbus 服务的已知端口:

## Well-known ports for Modbus. const ports = { 502/tcp } &redef;

其完整类型为set[port],默认值为{ 502/tcp }。502 是 Modbus/TCP 的 IANA 标准端口。该选项是redefinable(可重定义)的,这意味着部署人员可以按需扩展监听端口集合,例如:

redef Modbus::ports += { 1502/tcp };

在zeek_init()事件中,该端口集合被注册到 Zeek 的 TCP 应用层分析器分发机制:

event zeek_init() &priority=5 { Log::create_stream(Modbus::LOG, Log::Stream($columns=Info, $ev=log_modbus, $path="modbus", $policy=log_policy)); Analyzer::register_for_ports(Analyzer::ANALYZER_MODBUS, ports); }

其中Analyzer::register_for_ports(Analyzer::ANALYZER_MODBUS, ports)告诉 Zeek 引擎:凡目的端口(或源端口)落在ports集合内的 TCP 连接,都需要尝试挂载名为MODBUS的应用层分析器。这与 Zeek 的动态协议检测(DPD)机制配合:即使流量没有落在 502 端口,只要 DPD 识别出报文特征符合 Modbus 报文结构,分析器同样会被激活(这一点有专门测试用例modbus_and_non_modbus_on_port_502.test验证端口与非 Modbus 流量的混用行为)。

三、日志记录结构:Modbus::Info

Modbus::Info是modbus.log的列定义,它是一个 record 类型,各字段均带&log属性。原文档将其列为唯一的 Types 项,逐字段说明如下:

字段类型属性含义
tstime&log请求发生的时间
uidstring&log连接的唯一标识符
idconn_id&log连接标识(四元组:源/目的 IP 与端口)
tidcount&log &optionalModbus 事务 ID(Transaction ID)
unitcount&log &optional报文的目标单元标识符(Unit ID,即原 "slave address")
funcstring&log &optional所发送功能报文的名称(如READ_HOLDING_REGISTERS)
pdu_typestring&log &optional该 PDU 是响应("RESP")还是请求("REQ")
exceptionstring&log &optional若响应为失败,记录异常名称
track_addresscount&default=0 &optional仅当加载了 track-memmap.zeek 策略脚本时出现,用于跟踪内存映射地址

其中track_address字段并不在main.zeek的定义中,而是由策略脚本通过redef record Modbus::Info += { track_address: count &default=0; }动态追加的,体现了 Zeek 脚本类型可扩展的设计。

源码中该记录的定义(main.zeek):

type Info: record { ts: time &log; uid: string &log; id: conn_id &log; tid: count &log &optional; unit: count &log &optional; func: string &log &optional; pdu_type: string &log &optional; exception: string &log &optional; };

四、连接记录扩展与日志流创建

main.zeek还做了两处类型层面的"重定义"(Redefinitions):

  1. 扩展Log::ID枚举:新增Modbus::LOG日志流标识:
redef enum Log::ID += { LOG };
  1. 扩展connection记录:为每个连接对象增加modbus字段,保存当前连接的 Modbus 状态信息:
redef record connection += { modbus: Info &optional; };

modbus字段由事件处理代码在收到第一条 Modbus 报文时惰性创建,随连接生命周期保存tid、unit、func、pdu_type等状态,供后续日志写入使用。

在zeek_init()中,通过Log::create_stream注册日志流:

Log::create_stream(Modbus::LOG, Log::Stream($columns=Info, $ev=log_modbus, $path="modbus", $policy=log_policy));

这里四个关键参数分别是:

  • $columns=Info:日志列即Modbus::Info记录;
  • $ev=log_modbus:日志流与log_modbus事件绑定,脚本可以在事件中截获将要写入日志的记录;
  • $path="modbus":日志文件名为modbus.log(默认写入 Zeek 的 logs 目录);
  • $policy=log_policy:挂接一个Log::PolicyHook类型的钩子,用于在写入前对记录做过滤或修改。

五、事件与钩子:log_modbus 与 log_policy

原文档列出的两个可编程扩展点如下。

5.1 Modbus::log_modbus 事件

类型为event(rec: Modbus::Info),在每条 Modbus 记录被发送到日志框架时触发。利用它可以对日志做二次加工,例如补字段或分流:

event Modbus::log_modbus(rec: Modbus::Info) { if ( rec?$func && rec$func == "READ_HOLDING_REGISTERS" ) # 对读保持寄存器的事务做特殊处理 NOTICE([$note=Modbus_Read_Holding_Registers, $conn=rec$id]); }

5.2 Modbus::log_policy 钩子

类型为Log::PolicyHook,是 Zeek 日志框架标准的策略钩子,可用来丢弃记录或改写字段。例如只保留请求方向的日志:

hook Modbus::log_policy(rec: Modbus::Info, id: Log::ID) { if ( rec?$pdu_type && rec$pdu_type != "REQ" ) break; }

break会终止后续钩子并阻止该记录写入日志文件。

六、核心事件处理逻辑:请求/响应与异常

main.zeek通过两个优先级相反的事件处理程序完成日志组装,这是理解modbus.log行为的关键。

6.1 组装阶段(priority=5)

modbus_message事件由 C++ 分析器对每一条Modbus 报文触发(无论该功能码是否被进一步解析)。priority=5 的处理程序负责填充记录:

event modbus_message(c: connection, headers: ModbusHeaders, is_orig: bool) &priority=5 { if ( ! c?$modbus ) c$modbus = Info($ts=network_time(), $uid=c$uid, $id=c$id); c$modbus$ts = network_time(); c$modbus$tid = headers$tid; c$modbus$unit = headers$uid; c$modbus$func = build_func(headers$function_code); c$modbus$pdu_type = is_orig ? "REQ" : "RESP"; }
  • headers是ModbusHeaders记录,包含tid(事务 ID)、pid(协议 ID)、uid(单元标识)、function_code(功能码)与len字段;
  • is_orig表示报文方向:来自 TCP 发起方(client)的是请求"REQ",来自响应方(server)的是响应"RESP";
  • build_func()负责把原始功能码转换为可读名称(见下节)。

6.2 写日志阶段(priority=-5)

同一个事件还挂了一个 priority=-5 的处理程序,负责真正落盘,并通过异常码位决定是否推迟写入:

event modbus_message(c: connection, headers: ModbusHeaders, is_orig: bool) &priority=-5 { # Don't log now if this is an exception (log in the exception event handler) if ( headers$function_code < 0x80 ) Log::write(LOG, c$modbus); }

Modbus 协议约定:正常响应的功能码与其请求相同(0x00–0x7F),而异常响应的功能码会把最高位(0x80)置 1。因此:

  • 若function_code < 0x80,说明是正常请求或正常响应,立即写日志;
  • 若function_code >= 0x80,说明是异常响应,暂不写日志,等modbus_exception事件把exception字段补全后再写。

6.3 异常处理

modbus_exception事件携带异常码,同样分为两个优先级:

event modbus_exception(c: connection, headers: ModbusHeaders, code: count) &priority=5 { c$modbus$exception = exception_codes[code]; } event modbus_exception(c: connection, headers: ModbusHeaders, code: count) &priority=-5 { Log::write(LOG, c$modbus); delete c$modbus$exception; }

priority=5 把数值异常码查表转换为语义名称(如ILLEGAL_FUNCTION)写入exception字段;priority=-5 写入日志后删除该字段,避免影响同一连接后续报文的日志内容。测试用例 exception_handling.test 专门覆盖了异常码的解析路径。

6.4 函数码名称转换 build_func

function build_func(func: count): string { local masked = func & ~0x80; 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; }

逻辑说明:

  1. 若func(原始功能码)本身在表中,直接返回其名称;
  2. 若剥掉异常位后的masked也不在表中(即完全未知的功能码),查表时触发&default分支,返回unknown-<数值>形式;
  3. 否则,用masked查到基础功能名,若func最高位为 1(异常响应),在名称后追加_EXCEPTION后缀。

因此异常响应日志中func字段会呈现类似READ_HOLDING_REGISTERS_EXCEPTION的形态,与exception字段共同刻画异常详情。

七、函数码与异常码常量表(consts.zeek)

consts.zeek 定义了main.zeek依赖的两张表,均由&default兜底(未知值显示为unknown-<数值>)并允许&redef扩展。

7.1 function_codes:Modbus 标准函数码

覆盖标准功能码与机器/厂商/网络专用功能码:

功能码名称说明
0x01READ_COILS读线圈
0x02READ_DISCRETE_INPUTS读离散输入
0x03READ_HOLDING_REGISTERS读保持寄存器
0x04READ_INPUT_REGISTERS读输入寄存器
0x05WRITE_SINGLE_COIL写单个线圈
0x06WRITE_SINGLE_REGISTER写单个寄存器
0x07READ_EXCEPTION_STATUS读异常状态
0x08DIAGNOSTICS诊断
0x0BGET_COMM_EVENT_COUNTER获取通信事件计数器
0x0CGET_COMM_EVENT_LOG获取通信事件日志
0x0FWRITE_MULTIPLE_COILS写多个线圈
0x10WRITE_MULTIPLE_REGISTERS写多个寄存器
0x11REPORT_SLAVE_ID上报从站 ID
0x14READ_FILE_RECORD读文件记录
0x15WRITE_FILE_RECORD写文件记录
0x16MASK_WRITE_REGISTER掩码写寄存器
0x17READ_WRITE_MULTIPLE_REGISTERS读改写多个寄存器
0x18READ_FIFO_QUEUE读 FIFO 队列
0x2BENCAP_INTERFACE_TRANSPORT封装接口传输(MEI)
0x5BOBJECT_MESSAGING对象消息
0x09/0x0A/0x0D/0x0E/0x12/0x13/0x28/0x29/0x5A/0x7D/0x7E/0x7FPROGRAM_484等机器/厂商/网络专用功能码

7.2 exception_codes:Modbus 异常码

异常码名称含义
0x01ILLEGAL_FUNCTION非法功能
0x02ILLEGAL_DATA_ADDRESS非法数据地址
0x03ILLEGAL_DATA_VALUE非法数据值
0x04SLAVE_DEVICE_FAILURE从站设备故障
0x05ACKNOWLEDGE已确认
0x06SLAVE_DEVICE_BUSY从站设备忙
0x08MEMORY_PARITY_ERROR存储器奇偶校验错误
0x0AGATEWAY_PATH_UNAVAILABLE网关路径不可用
0x0BGATEWAY_TARGET_DEVICE_FAILED_TO_RESPOND网关目标设备无响应

八、底层实现:binpac 解析器与 C++ 分析器

脚本层之上,Modbus 报文解析由位于 src/analyzer/protocol/modbus/ 的 binpac 描述文件与 C++ 代码完成(该分析器的开发得到了荷兰司法与安全部 Hermes、Castor、Midas 项目的资助,见源码文件头注释)。

8.1 TCP 报文头与 PDU 结构

modbus-protocol.pac 定义了 Modbus/TCP 的线格式。传输头为 7 字节大端序结构:

tid: uint16 # Transaction identifier(事务 ID) pid: uint16 # Protocol identifier(协议 ID,须为 0) len: uint16 # 其后负载长度(须 >= 2) uid: uint8 # Unit identifier(单元标识) fc: uint8 # MODBUS function code(功能码)

对应的 C++ 侧转换函数HeaderToVal()位于 modbus-analyzer.pac,它把该头组装成 Zeek 侧的ModbusHeaders记录(字段依次为 tid、pid、uid、fc、len),供脚本层事件消费。

请求与响应分别按功能码分发:请求侧ModbusTCP_Request根据fc值选择对应的具体结构(如ReadCoilsRequest、ReadHoldingRegistersRequest);响应侧先看fc & 0x80—— 为 0 走ModbusTCP_NormalResponse,否则走ModbusTCP_ExceptResponse(只含一个code: uint8异常码字节),并在deliver_Exception中触发脚本事件modbus_exception。

8.2 协议确认(Confirmation)逻辑

modbus-analyzer.pac 中通过三个成员变量跟踪协议确认状态:

bool confirmed; // 是否已确认 bool orig_pdu; // 是否成功解析过发起方 PDU bool resp_pdu; // 是否成功解析过响应方 PDU

只有当双向的完整 PDU 都被成功解析时,IsConfirmed()才返回真,分析器才会调用AnalyzerConfirmation()正式确认该连接为 Modbus。这一设计有效避免了把少量相似流量误判为 Modbus。

8.3 数据完整性校验

C++ 侧对多个功能码做了严格的长度/取值校验,违反时调用AnalyzerViolation(脚本层体现为weird.log中的bad_TCP_...类记录)或reporter->Weird,例如:

  • 读保持寄存器/读输入寄存器响应的byte_count必须为偶数(奇数时触发 violation);
  • 写单个线圈的值必须是0x0000或0xFF00;
  • 诊断(FC=8)各子功能的数据长度与取值约束(如RESTART_COMMUNICATIONS_OPTION只能是0x0000或0xFF00,其余大部分子功能数据须为0x0000,未知子功能触发modbus_diag_unknown_request_subfunction)。

九、事件体系:从 modbus_message 到功能级事件

由 events.bif 声明的 Modbus 事件构成两层体系:

  • 通用事件:modbus_message(任何 Modbus 报文,含未被深入解析的功能码)与modbus_exception(任何异常报文);
  • 功能级事件:按功能码细分的请求/响应事件,共 20 组,例如modbus_read_coils_request/response、modbus_read_holding_registers_request/response、modbus_write_single_coil_request/response、modbus_read_file_record_request/response、modbus_mask_write_register_request/response、modbus_diagnostics_request/response、modbus_encap_interface_transport_request/response等。

功能级事件携带解析后的语义参数,例如modbus_read_holding_registers_request带start_address与quantity,modbus_write_single_coil_request的value已被转换为bool。线圈类数据被封装为ModbusCoils向量(由bytestring_to_coils()按位展开),寄存器类数据封装为ModbusRegisters向量。

测试脚本 events.zeek 逐个订阅了这些事件并打印参数,同时用grep "^event modbus_"统计覆盖度,验证轨迹modbus.pcap与modbus-eit.pcap能触发的事件数。你可以照此模式编写自己的 Modbus 事件处理脚本。

十、扩展实战:track-memmap 策略脚本

原文档在track_address字段处引用了策略脚本 scripts/policy/protocols/modbus/track-memmap.zeek。该脚本展示了对 base 脚本的典型扩展方式:

  1. 新增日志流Modbus::REGISTER_CHANGE_LOG,输出到modbus_register_change.log;
  2. 新增可配置选项track_memmap: Host = ALL_HOSTS,可限定只跟踪特定从站地址;
  3. 扩展Modbus::Info,追加track_address字段;
  4. 消费功能级事件:在modbus_read_holding_registers_request中记录起始地址,在modbus_read_holding_registers_response中逐寄存器比对历史值,发现变化即触发Modbus::changed_register事件并写入日志,记录old_val、new_val与变化时间差delta。

启用方式为在local.zeek或命令行加载:

@load policy/protocols/modbus/track-memmap

其记录结构MemmapInfo包含ts、uid、id、register(设备内存偏移)、old_val、new_val、delta七列,适合直接对接资产监控与工控异常告警场景。

十一、验证与测试

仓库在 testing/btest/scripts/base/protocols/modbus/ 提供了完善的测试用例:

  • events.zeek:覆盖全部事件参数输出与覆盖率统计;
  • coil_parsing_big.zeek / coil_parsing_small.zeek / register_parsing.zeek:验证线圈与寄存器的位级/字级解析;
  • exception_handling.test:验证异常码处理与日志输出;
  • length_mismatch.zeek:验证长度不匹配时的违规检测;
  • modbus_and_non_modbus_on_port_502.test:验证 502 端口上 Modbus 与非 Modbus 流量的区分;
  • policy.zeek:验证策略脚本行为。

本地复现事件测试的命令(见 events.zeek 的@TEST-EXEC行):

zeek -b -r testing/btest/Traces/modbus/modbus.pcap testing/btest/scripts/base/protocols/modbus/events.zeek

运行后查看modbus.log,可以看到形如以下的记录(func为语义名称、pdu_type为REQ/RESP、异常时exception字段填充):

ts,uid,id,tid,unit,func,pdu_type,exception ...,...,...,...,...,READ_HOLDING_REGISTERS,REQ, ...,...,...,...,...,READ_HOLDING_REGISTERS,RESP, ...,...,...,...,...,READ_HOLDING_REGISTERS_EXCEPTION,RESP,ILLEGAL_DATA_ADDRESS

十二、小结

从 main.zeek 这一份 "Base Modbus analysis script" 出发,可以完整还原 Zeek Modbus 分析的全链路:

  1. 端口触发:Modbus::ports(默认502/tcp,可&redef扩展)注册分析器;
  2. 报文解析:binpac 描述文件解析 Modbus/TCP 传输头与各功能码 PDU,双向成功解析后确认协议;
  3. 事件分发:modbus_message/modbus_exception通用事件 + 20 组功能级请求/响应事件;
  4. 状态组装:脚本层build_func()完成功能码语义化,pdu_type区分请求/响应,异常码查表写入exception;
  5. 日志输出:modbus.log通过Log::create_stream注册,log_modbus事件与log_policy钩子提供写入前的定制入口;
  6. 策略扩展:如 track-memmap.zeek 所示,可叠加寄存器变更跟踪等业务能力。

掌握这六个环节,你就拥有了从原始工控流量到结构化modbus.log、再到定制化告警与内存映射监控的完整工具箱。

  • 网络安全
  • 网络
  • IDS

【免费下载链接】zeek

Zeek is a powerful network analysis framework that is much different from the typical IDS you may know.

项目地址:https://gitcode.com/gh_mirrors/ze/zeek
点击查看免费下载
上一篇:Python NFC开发终极指南:NFCpy从入门到实战
下一篇:beautiful-react-hooks:轻量级 React 自定义 Hook 集合库全解——从安装、Hook 目录到源码级设计剖析

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询