
我把标题拆开来看核心是三个词工控现场、恶意扫描、Modbus。这三个词放在一起意味着我们已经越过“要不要做工控安全”的讨论阶段直接进入“扫描事件已经发生怎么护住现场”的状态。先说一个我在项目里遇到过的场景某天凌晨值班系统弹出一条告警说上位机到PLC的控制网段出现了大量异常请求。我上去一看告警内容不是DDoS也不是溢出攻击而是某台设备的IP在不断向整个网段发起Modbus功能码探测。第一反应是排查人员误操作但仔细一查发现请求在遍历从站地址已经超出了任何正常组态软件的活动范围。这类事情在工控现场出现的频率比大多数人想象的高得多而且它往往不是攻击链条的终点而是起点。这篇文章就围绕“Modbus恶意扫描”这条线展开我会从协议本身的特性讲起再讲到流量里到底有什么特征、怎么从告警一路排查到具体主机、为什么防火墙ACL和PLC密码都挡不住它最后给出一套可以在VMware里复现的实验环境。不管是做工控运维、等保整改、还是刚接触OT安全的同行都应该能从里面找到直接能用的东西。1. 为什么“Modbus扫描”在工控现场比漏洞利用更值得警惕1.1 扫描不是“小动作”而是攻击链的第一环很多现场运维人员看到扫描告警的第一反应是“无害”因为扫描本身不修改任何寄存器也不下发任何写操作顶多是多了一些读请求设备又不会因为这些请求停机。这种判断在单次事件上确实成立但在攻防对抗的视角下完全是另一回事。攻击者不会一上来就瞄准某个具体的PLC地址和寄存器位置发起写入因为控制网内部对攻击者来说是黑盒。Modbus扫描的核心价值就是把这个黑盒变成白盒它告诉攻击者哪些IP活着、哪些端口是502、哪些从站地址有响应、哪些功能码是被允许的、设备型号是什么。这一步做完后续不管是写线圈还是写保持寄存器都像是拿着地图在作案。让我换一种说法扫描相当于攻击者正在敲每一扇门听哪扇门背后有人。你听到敲门声不应该只觉得“门没坏”而应该意识到“有人正在摸清我这栋楼的结构”。在工控现场一次完整的横向移动往往从扫描开始到非授权写操作结束中间可能只隔几个小时。如果扫描事件没有被发现、没有被记录、没有被纳入后续监测基线那后面真出现写操作的时候安全团队面对的将是完全没有上下文的孤立事件溯源会难非常多。1.2 Modbus协议本身为什么是明文的“裸奔”状态Modbus是上世纪70年代末诞生的协议当时设计它的目标就是简单可靠没有任何人预见到它会暴露在互联网或者复杂的攻击环境中。Modbus TCP的报文结构非常直接MBAP头事务标识符、协议标识符、长度、单元标识符加功能码加数据区。它不支持认证不支持加密不支持会话状态也没有任何访问控制的概念。任何一个能触达TCP 502端口的主机都可以直接向PLC发送读线圈、读寄存器、写线圈、写寄存器指令而PLC对请求来源不做任何合法性校验。这意味着什么意味着在Modbus的世界里“知道地址的人就是主人”。攻击者不需要用户名密码不需要漏洞利用只要通过扫描确认了从站地址和寄存器范围就可以名正言顺地“INITIATE”读写操作。很多现场连PLC的密码保护都没有启用因为运维人员觉得“内网很安全”但内网里的一台运维笔记本一旦被蠕虫利用就能变成攻击的跳板PLC密码这点防御在无认证协议面前形同虚设。这个特性的直接后果就是检测必须前置到网络层。因为我们不可能从Modbus报文里判断“这次请求是不是本厂员工发的”只能通过行为特征判断“这个请求序列是否符合正常业务语义”。这也是为什么后续章节里我会花大量篇幅讲行为基线而不是简单靠一个规则库去匹配。1.3 合法的资产梳理与恶意扫描之间的灰色地带这里要提一个非常现实的灰色地带很多工控现场内部每年都会做资产梳理运维团队会用扫描工具去盘点网段内有哪些PLC、哪些DCS节点、寄存器怎么分布。这种合法梳理产生的流量和恶意扫描在报文层面几乎没有区别。我见过不止一个客户安全设备把自家运维的资产梳理扫成了“恶意探测”产生大量误报导致安全管理平台SOC平台把这类事件屏蔽结果真正出现恶意扫描时整个检测通道已经哑火了。所以做检测不能只看报文特征还要结合当时的业务上下文。比如是否处于审批过的变更窗口、源IP是否是已知运维终端、扫描的目标网段是否存在生产业务。我的经验是先把扫描行为当成一个中性事件记录下来下一步再判断“合理性”而不是一上来就定性为攻击。这样既能保住合规检测能力又能让后续人工研判有据可查。我还会在后文的检测规则里专门说明如何用白名单和基线把这种误报压低。2. 识别特征从抓包里把恶意扫描给挑出来2.1 正常Modbus轮询和扫描行为的关键差异先说一件最容易被忽视的事Modbus在正常的工控系统里流量其实是“有节奏”的。上位机SCADA/HMI每隔固定周期去读一次某个或某几个PLC的寄存器周期可能是50毫秒、100毫秒也可能是一秒但整体规律非常稳定。寄存器地址往往集中在组态工程里定义好的那几段很少会跳变到完全无关的地址区间。而恶意扫描的特征恰恰体现在“无节奏”和“无边界”上。扫描器不会遵循轮询周期它往往以毫秒级间隔连续发送请求从站地址从1到247逐个遍历或者在一个C段内快速跳变寄存器地址范围被系统性地划过而不是集中在一两个业务区间。这两类行为在Wireshark或者抓包里放在一起看几乎是肉眼可辨的。一张表格可以把这个对比看得更直白对比维度正常Modbus轮询恶意Modbus扫描请求目的读取/写入已知寄存器探测存活从站、枚举功能码从站地址范围固定几个站号1~247遍历或大段扫描寄存器地址分布集中在业务配置的区间线性递增、步进整齐请求频率与轮询周期一致稳定毫秒级突发密集发包功能码使用按业务需求固定多个功能码连续尝试会话持续时间长期维持稳定连接短连接、快速建立拆除这些差异点里最有判别力的其实是“从站地址范围”和“功能码组合”。曾经有一次我抓到的异常请求里同一源IP在5秒内访问了26个从站地址且功能码在0x01、0x03、0x05之间轮换——正常的上位机不可能在这种短时间内跨这么多站号切换业务。2.2 请求特征细拆功能码、单元ID、地址分布再往细里说Modbus扫描的报文有几个地方值得专门盯第一是功能码的组合逻辑。常见的扫描工具会按顺序枚举0x01读线圈、0x02读离散输入、0x03读保持寄存器、0x04读输入寄存器、0x05写单线圈、0x06写单寄存器以及0x2B读设备标识。如果在一段连续流量里看到同一个源IP在短时间内把这几个功能码全部用了一遍基本可以断定是在做设备能力探测。尤其0x2B这个功能码它是用来读取设备标识的正常业务轮询几乎不用扫描器却非常喜欢用它来确定“对面是什么牌子的PLC”。我建议在检测规则里把0x2B单独标一个高权重。第二是单元标识符Unit ID和TCP连接行为。Modbus TCP的单元标识符通常对应串行链路上的从站地址扫描器经常会用单元ID0或255来探测网关设备或者遍历1~247。Wireshark里如果看到大量单元ID是0的请求而且每次TCP连接都是新建的这也不是正常上位机的行为——正常组态软件一般会复用TCP连接。所以“新连接数/每秒”本身就是一个很好的异常指标。第三是寄存器地址的步进规律。扫描器的地址往往从0开始步进一次加1或者从1开始加到65535这种线性分布非常“数学化”而业务请求的寄存器地址通常是离散的中间有大段空跳。如果你有镜像端口和历史流量可以用脚本算一下请求的寄存器地址方差方差极大且无明显区间聚类的就非常可疑。2.3 写一个低成本的流量识别规则不一定要上多么昂贵的检测设备只要网络里有镜像口用现成的抓包工具加一个脚本也能做基础识别。下面这个规则思路不是用AI而是用“行为阈值白名单”的方式实现只对白名单之外的源IP进行统计当它在滑动窗口内满足以下任一条件时触发告警——60秒内访问的从站地址数超过20个60秒内出现的功能码种类大于等于4种60秒内新建的TCP 502连接数超过50任意5秒内寄存器地址出现线性步进步长≤2超过30次。我在脚本里会把白名单放在最前面允许运维人员录入合法上位机IP和PLC站号映射表这样两边都干净该防的防住该放行的放行。详细脚本示例在第五节实验环境里会给这里先把判断逻辑说清楚因为逻辑对了用什么工具实现只是顺手的事。3. 实战排查链路从告警到肇事主机的完整过程3.1 网络侧留痕别急着封IP扫描告警出来以后最常见的错误操作是立刻在交换机上做ACL把源IP封掉。这样做有两个问题第一攻击者如果用的是跳板机封掉一个IP他马上换下一个第二一旦封了后续所有的取证都会失去这条路径上的观测能力连“他还在不在扫”都不知道。我的做法是先留痕再行动。第一时间打开Tcpdump持续抓包把源IP到目标网段的双向流量落盘同时记录当前时间、交换机端口、MAC地址、告警开始时间等信息。这些数据在后续溯源里就是最硬的时间线证据。留痕期间不要闲着要看三个东西源IP是否在资产台账里、最近有没有计划内的运维变更、该IP的MAC地址在交换机上的接入端口是哪个。大多数现场扫描事件最终定位下来要么是误装扫描工具的上位机要么是运维人员用了来路不明的第三方组态工具要么是已经被控制的外联主机。把这三个问题查清楚基本能确定方向。3.2 走交换机把MAC地址钉到物理端口上如果源IP对应的主机不在资产台账里或者MAC地址无法关联到已知网卡就要通过交换机定位物理接入位置。以常见三层组网为例排查顺序是先查网关交换机上的ARP表找到IP对应的MAC再用MAC地址去查交换机的MAC地址表确定它从哪个VLAN、哪个物理端口学到的如果是汇聚的环境需要逐级往下找直到找到最末端的接入交换机端口。这个过程中要记录每一跳的交换机名称、端口编号、VLAN ID形成一条完整的“网络路径链路”。到了接入端口这一步现场往往是机柜里某台设备在跑。不要急着拔网线先拍下端口指示灯状态和端口标签再顺着线缆找到对端设备。有可能对端是一台工业交换机、一台协议转换器、一个边缘网关甚至是一个临时接入的无线AP。我曾经排查过一个案例扫描源头的设备是一台被人遗忘在机柜顶部的边缘网关因为固件老旧被人利用成为了内网探测的跳板。如果当初直接封IP这台网关会继续留在现场下次换IP后还会重复同样的问题。3.3 主机侧取证与责任判定要注意的事定位到物理设备后进入主机侧取证阶段。这里强调一下工业现场的取证和办公网完全不同你不能直接把硬盘拔了带走也不能对PLC做重启很多关键设备停机时间以分钟计。我的建议是优先做“在线取证”——在设备运行状态下记录进程列表、网络连接列表特别是与TCP 502端口相关的连接、计划任务、自启动项、最近运行的程序路径和文件哈希值。如果条件允许把可疑可执行文件直接导出副本放到隔离环境里分析。责任判定方面也要谨慎。扫描工具本身有很多种来源可能是资产盘点软件的一部分可能是组态软件自带的诊断功能可能是攻击载荷的投递结果。运维人员自己下载一个扫描工具做测试和木马在内网自动扩散虽然报文形态类似但处理流程完全不同。所以笔录环节要问清楚近期该设备的操作人、修改过的配置、是否接入过外网介质。不要只看抓包结果就给人定性要结合操作记录和实际业务影响来综合判断。4. 防护建议防火墙ACL和PLC密码都不是“正解”4.1 为什么说“给Modbus加密码”是个误区我在各种场合都听过“我们PLC设了密码没事”这种话这其实是把IT网络的概念生搬硬套到OT网络上。Modbus协议本身没有认证和加密字段PLC的密码保护作用在于阻止别人修改固件、上传下载程序或更改配置参数但它并不会有选择性地拒绝某个来源的功能码请求。攻击者发起读保持寄存器操作时PLC并不会要求先输入密码。只要知道寄存器地址就能读到生产数据。换句话说Modbus密码只锁住了“工程师通道”但完全没锁“数据通道”。防火墙ACL也是同理单纯的在三层做IP端口放行只能划定“谁能访问PLC”却完全管不住“这人拿到权限后在干什么”。比如在生产网段里一台运维终端被攻击者拿下攻击者用它向所有PLC发起功能码探测时ACL是默许的因为从IP端口看它完全合法。要真正把MODBUS扫描处理到可控状态依靠的必须是“看到功能码、看到寄存器地址、看到请求频率”的应用层策略而不是简单的网络层通断。4.2 控制网的分区分域与白名单基线业界常说“分区、分域、分层”但真正落地时很多客户把次序搞反了。正确的做法是先梳理业务流再划ACL。对每个PLC要明确清楚谁需要访问它、通过哪些功能码访问、访问哪些寄存器区、在什么时间段有访问需求。把这些信息固化成一张白名单基线然后按“默认拒绝、白名单放行”的方式配置。对来源不在白名单里的设备启动任何动作前都要触发告警而不仅仅是关注写入操作。从策略上讲要把“读”也当成敏感操作因为恶意扫描大量依赖读请求。实际配置时如果PLC的网关支持应用层过滤最理想的情况是限制每个来源IP只能对特定从站地址和寄存器区间发起请求。如果不支持就要通过旁路检测来弥补。我在项目里常用的一种做法是在PLC的前置交换机上做镜像把流量送到检测引擎检测引擎维护一份“源IP-允许的从站列表-允许的功能码列表”基线一旦偏离立刻推送告警并联动交换机把端口做物理隔离。这种模式不依赖Modbus协议自身的认证能力而是用外部强制手段把无认证协议拉回到可控范围。4.3 流量监测与联动处置的落地配置联动处置这件事在工控现场要非常谨慎不能因为误报把正在生产的PLC连接给断了。所以配置上我建议分级处理一级是只告警不动作适用于扫描行为首次出现、源IP不在任何资产台账里的情况二级是提示值机人员人工确认适用于触发规则但源IP与已知资产有部分关联的情况三级才是自动端口隔离仅在目标网段是重要资产、且扫描行为特征极度明确比如0x2B设备标识探测加上大量遍历从站地址时启用。同时自动隔离后要能快速恢复我一般会设置一个定时复核机制默认15分钟自动重新放开端口由人判断是否继续处置。这里额外提示一点检测引擎本身的部署位置最好是在各生产子网的汇聚交换机的镜像口上而不是只在核心交换机的出口。因为Modbus恶意扫描通常是跨子网横向进行的在很多现场核心出口未必能看到生产子网内部的全部东西。我在做方案时一定会先画出工控网络的物理拓扑和流量拓扑再决定在哪里旁路部署探针避免出现“核心看到一堆、子网内部一片空白”的盲区。5. 自己搭一套Modbus扫描与检测实验环境5.1 环境拓扑与组件选型如果你所在的环境允许我建议用三台虚拟机搭一套最小实验环境把“扫描、抓包、检测”整个链路亲手跑一遍。方案不需要真实PLC用软件模拟就能获得同等效果攻击机1台安装Nmap含Modbus脚本和ModbusPal或者自己写的Python扫描脚本从站设备1台用Pymodbus的ModbusServer模式模拟PLC从站监听502端口提供线圈和保持寄存器数据监测机1台安装Wireshark和Tcpdump做镜像抓包同时用来跑基础检测规则脚本。需要注意的是模拟从站的IP地址一定要配置成工控网段比如192.168.10.0/24不要跟宿主机原有业务网段混淆。攻击机与模拟从站之间要确保能互通监测机能同时看到两台机器的流量最简单的方法是用虚拟化软件的虚拟交换机做同一广播域。组件选型上我推荐Pymodbus因为它跨平台、依赖少、文档齐全几分钟就能起一个从站。如果在国内环境下载依赖不方便也可以直接用ModbusPal或Simply Modbus Slave这类软件效果类似。5.2 攻击模拟脚本从扫描地址到功能码探测下面给一个轻量级的Python脚本作用是遍历从站地址和常用功能码模拟恶意扫描的实际行为。它做的事情和真实扫描工具类似但代码更简短方便读者对照理解。脚本中我会控制在发送频率避免在实验环境里把自己网络打爆如果是在生产网段里操作一定要先获得授权并且只在隔离的实验网里跑。import socket import time MODBUS_PORT 502 # 常见扫描功能码读线圈、读离散输入、读保持寄存器、读输入寄存器、写单线圈、写单寄存器 FUNCTION_CODES [0x01, 0x02, 0x03, 0x04, 0x05, 0x06] def build_modbus_pdu(unit_id, function_code, address0x0000, value0x0000): # 简单构造读/写功能码的PDU返回MBAPPDU # 事务ID和协议ID这里固定为0长度字段由框架填充 trans_id 0x0001 protocol_id 0x0000 length 0x0001 4 if function_code 0x05 else 0x0001 4 pdu bytearray() pdu trans_id.to_bytes(2, big) pdu protocol_id.to_bytes(2, big) pdu length.to_bytes(2, big) pdu bytes([unit_id]) pdu bytes([function_code]) pdu address.to_bytes(2, big) if function_code in [0x05, 0x06]: pdu value.to_bytes(2, big) return bytes(pdu) def scan_target(ip, unit_start1, unit_end247): for unit_id in range(unit_start, unit_end 1): for fc in FUNCTION_CODES: try: s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(2) s.connect((ip, MODBUS_PORT)) pkt build_modbus_pdu(unit_id, fc) s.send(pkt) resp s.recv(1024) if resp: print(f[*] target {ip} unit{unit_id} func{hex(fc)} - response) else: print(f[-] target {ip} unit{unit_id} func{hex(fc)} - no response) s.close() time.sleep(0.1) # 实验用限速避免瞬时大量并发 except Exception as e: print(f[-] target {ip} unit{unit_id} func{hex(fc)} - error: {e}) if __name__ __main__: scan_target(192.168.10.100, 1, 10)这个脚本在实验里跑一轮监测机上立刻能看到从站地址逐渐递增的请求序列Wireshark跟踪TCP流时MBAP的单元标识符也在逐步变化是很典型的扫描形态。对着Wireshark看一遍才能真切感受到“正常轮询”和“扫描”的视觉差异。5.3 检测端脚本基于异常模式的实时判定检测端我用纯Python实现一个轻量规则引擎思路和商业设备的检测模块一致滑动窗口内统计从站地址数、功能码种类数、TCP新连接数超过阈值就输出告警。脚本本身做不了IDS但它能让你理解“特征怎么转为检测逻辑”。from collections import deque import time # 滑动窗口大小和阈值 WINDOW_SECONDS 60 THRESHOLD_UNITS 20 # 窗口内出现的从站地址数 THRESHOLD_FUNCS 4 # 窗口内出现的功能码种类 THRESHOLD_CONNS 50 # 窗口内新建TCP 502连接数 class ModbusScanDetector: def __init__(self): self.events deque() def event(self, src_ip, unit_id, function_code, new_connFalse): now time.time() self.events.append((src_ip, unit_id, function_code, new_conn, now)) while self.events and now - self.events[0][4] WINDOW_SECONDS: self.events.popleft() units set() funcs set() conn_count 0 for src, unit, fc, new_conn, _ in self.events: if src src_ip: units.add(unit) funcs.add(fc) conn_count 1 if new_conn else 0 if len(units) THRESHOLD_UNITS: print(f[ALERT] source{src_ip} units{len(units)}) if len(funcs) THRESHOLD_FUNCS: print(f[ALERT] source{src_ip} funcs{funcs}) if conn_count THRESHOLD_CONNS: print(f[ALERT] source{src_ip} new_conns{conn_count}) detector ModbusScanDetector() # 把抓包结果按行灌进来每个tcp 502新连接或请求都调一次event()这个检测器虽然简陋但把三个关键维度都覆盖了从站地址宽度、功能码种类、新建连接密度。在实验里正常Pymodbus从站模拟的“轮询”触发不了告警但第5.2节的扫描脚本在窗口内必然触发。这个对照实验非常有说服力每次我给客户做演示时都会把这两类流量同时播给安全团队看他们一下就明白“为什么不能只依靠黑名单”。最后我自己在实际项目里体会最深的一点是Modbus扫描检测的难点从来不是技术而是把它放在什么位置上去看。安全意识足够强的团队会把扫描当成重大事件的先兆来响应应对一夜之间就能闭环意识薄弱的现场扫描就是“异常日志”里的一个普通条目直到几周后出现非授权写入才追悔莫及。如果你在维护工控系统建议给自己留一道功课回看过去一周的网络镜像流量找出源IP访问从站地址超过10个的节点做一个清单不需要立刻封禁但要清楚认识的每个清单条目代表什么。做完这个动作你对自己所维护网络的安全底数会有完全不同的理解。