简介:一套聚焦工业互联网安全测试的PPT课件,系统讲解工控协议基础与扫描插件应用,面向工业控制系统安全测试人员、网络安全学习者及工业网络运维工程师。内容涵盖EPA现场总线标准、Ethernet/IP报文类型、Modbus TCP通信体系结构等关键协议,并介绍BACnet设备枚举、施耐德Modicon PLC识别、Rockwell EtherNet/IP探测等典型Nmap扫描插件的实际命令用法,以及Metasploit在工控漏洞检测中的应用。资源共1个pptx文件,压缩包约3.17MB,轻量易用;页面按“工控协议介绍—工控扫描插件介绍”两大模块组织,图文并茂地展示协议分层模型、报文种类与扫描命令示例,便于快速建立工控安全测试的知识框架。该资源已有167人学习,适合作为工业互联网安全技术课程、企业安全培训或自学入门的参考资料。
1. 工控协议不是加了壳的 TCP:从一次 Modbus 端口误报说起
把一个 TCP 的 SYN 扫描结果当成工控资产清单,是工业互联网安全测试里最常见的错误开局。502、102、44818 这些端口听着像普通服务端口,实际上背后跑的是 Modbus TCP、S7comm、EtherNet/IP 这类工控协议,它们在连接建立、报文长度、功能码语义上都和 HTTP 完全不同。用通用扫描器做一次全端口扫描,常常把真实 PLC 当成开放式数据库,又把挂着 502 端口的网关误判成 Modbus 从站。真正的工控协议扫描不是“看端口开没开”,而是先构造符合协议状态机的探测帧,确认对方能对功能码作出响应,再把响应里的设备标识、模块信息、版本号提取出来。本文要讲的就是这些协议怎么认、扫描插件怎么写、参数怎么调,以及到了现场哪些传统扫描习惯必须丢掉。
2. 主流工控协议的指纹特征与识别命令:Modbus TCP / S7comm / DNP3 / IEC 104
工控协议扫描的前提是分清两类目标:一类是 PLC、RTU、DCS 控制站这类原生设备,另一类是协议网关、数据采集网关这类转发节点。前者的协议栈实现完整,对状态机的响应可靠;后者往往只做载荷转发,报文特征有截断痕迹。识别这两类,靠的是协议指纹差异,而不是端口号。
2.1 端口只是线索,协议状态机才算指纹
以 Modbus TCP 为例,502 端口只是 TCP 层入口。真正的指纹在报文里:MBAP 头固定 7 字节,事务标识符占 2 字节,协议标识符取 0x0000,长度字段随后,单元标识符最后。主动识别时发一帧 0x2B 0x0E 读设备标识请求,合法的从站会返回厂商名、产品代码、版本号;如果只是普通 TCP 中间件,通常会直接关闭连接或回一个 RST。DNP3 的 20000 端口判断更难,它要求先发 0x05 0x64 开启握手,链路层地址字段带源和目标两个地址,拿到 ACK 后才算真正“通着”。
S7comm 的情况更特殊,TCP 102 端口之上先跑 TPKT/COTP,COTP 连接请求通过后才会进入 S7comm 层。很多扫描器在 COTP 这一层就放弃了,所以对西门子 PLC 的识别要单独用s7-info这类插件,靠读取 SZL 系统状态列表拿到机架号和插槽号。IEC 60870-5-104 走 2404 端口,报文以 0x68 起始,区分 I 帧、S 帧、U 帧的方式也与其他协议完全不同。
2.2 直接用 Nmap 官方插件做一轮资产识别
识别这些协议,Nmap 官方脚本库里的插件已经够用,关键是端口和参数要选对:
nmap -Pn -sT -p502 --script modbus-discover --script-args modbus-discover.unit-id=1 10.10.12.0/24 nmap -Pn -sT -p102 --script s7-info --script-args s7-info.rack=0,s7-info.slot=1 10.10.12.3 nmap -Pn -sT -p20000 --script dnp3-info 10.10.12.5 nmap -Pn -sU -p44818 --script enip-info 10.10.12.6modbus-discover的unit-id参数默认是 1,遇到网关背后的从站可以逐个递增尝试;s7-info的rack和slot直接决定能否读到模块列表,西门子 S7-300/400 常见 0/2,S7-1200/1500 常见 0/1,模错了就换一组。enip-info走的是 UDP 44818,CIP 协议会把 Vendor ID 和设备类型带回,拿到值后对照设备描述文件就能定位型号。
| 协议 | 默认端口 | 识别命令 | 返回的关键字段 |
|---|---|---|---|
| Modbus TCP | 502/tcp | modbus-discover | 厂商名、产品码、版本号 |
| S7comm | 102/tcp | s7-info | 机架号、插槽号、模块类型 |
| DNP3 | 20000/tcp | dnp3-info | 固件版本、设备地址 |
| EtherNet/IP | 44818/tcp+udp | enip-info | Vendor ID、序列号 |
| IEC 104 | 2404/tcp | 无通用 NSE | 需结合 Python 确认链路 |
IEC 104 是目前 Nmap 官方库里覆盖最弱的,轮询超时、链路测试帧这类无状态报文用现成插件很难表达。对 2404 端口的资产,我一般先用端口扫描确认开放状态,再在后续的 Python 确认阶段发送 0x68 起始的 U 帧看对方是否回确认,这也是 IEC 104 识别比较可靠的做法。
2.3 服务指纹与协议指纹的差异
-sV服务版本探测把 502 识别成 modbus 不算难,但通常只给出版本名,拿不到设备厂商和模块型号。真正的差距在于,服务探测发送的是通用 TCP 探测串,而协议扫描插件发送的是符合状态机的功能码请求。一个 Modbus 从站收到 0x2B 0x0E 后,会按照标准组织响应;收到一串任意字节时,很多设备会直接断开连接,甚至对异常请求不做任何回复。这也是为什么在工控环境里不做协议层确认,就永远不知道端口背后是 PLC 还是仅仅一个透传盒子。
3. 用 Nmap NSE 写一个 Modbus TCP 扫描插件:从探测帧到设备指纹
官方插件覆盖常用协议,但真实项目里总会遇到非标准端口、私有功能码或特殊单元标识。比如某个老设备的 Modbus 服务跑在 1502 端口,或者网关需要先设置单元号才能访问背后从站,这时就需要自己写 NSE 插件。Nmap 的 NSE 机制对工控协议扫描比较友好,它把 TCP 连接、超时、重传都交给引擎处理,插件只需要专注协议帧的构造和解析。
3.1 NSE 插件的基本结构和探测帧构造
一个最简的 Modbus TCP 设备识别插件,核心就做三件事:连接 502 端口、发送读设备标识请求、解析响应里的厂商字符串。
local shortport = require "shortport" local stdnse = require "stdnse" local nmap = require "nmap" description = [[ 发送 Modbus TCP 读设备标识请求(0x2B 0x0E), 根据响应中的厂商字段标记工控设备。 ]] author = "scanlab" license = "Same as Nmap--See https://nmap.org/book/man-legal.html" categories = {"safe", "discovery"} portrule = shortport.port_or_service({502}, "modbus") action = function(host, port) local unit = stdnse.get_script_args("modbus-device-id.unit-id") or 1 local txn = 1 -- MBAP 头: 事务ID(2) + 协议ID(2) + 长度(2) + 单元ID(1) local mbap = string.char( math.floor(txn / 256), txn % 256, 0x00, 0x00, 0x00, 0x04, unit ) -- PDU: 功能码 0x2B + MEI类型 0x0E + 读ID 0x01 + 对象0x00 local pdu = string.char(0x2B, 0x0E, 0x01, 0x00) local payload = mbap .. pdu local socket = nmap.new_socket() local status, err = socket:connect(host, port) if not status then return nil end socket:send(payload) local response = socket:receive_bytes(1) socket:close() if response == nil then return nil end -- 解析响应,跳过9字节的MBAP头和功能码字段 local body = response:sub(10) if #body < 3 then return nil end return string.format("unit=%d vendor=%s", unit, body:sub(3)) end这段脚本里最值得关注的是 MBAP 长度字段。0x00 0x04表示后续还有 4 字节,也就是单元 ID、功能码、MEI 类型和对象 ID 各占 1 字节。事务 ID 用math.floor(txn/256)拆成高字节和低字节,因为 Modbus TCP 采用大端字节序。响应解析从第 10 字节开始,跳过了 MBAP 的 7 字节和功能码的 2 字节,然后从第 3 字节位置读取厂商名字符串,因为响应里还有对象数量和对象长度的描述字段。
3.2 调试 NSE 插件的三个命令
写完脚本后,先不要直接在目标网段上跑。把插件放到~/.nmap/scripts/目录,然后执行nmap --script-updatedb刷新脚本数据库,再用下面三个方式验证:
nmap -Pn -p502 --script modbus-device-id 127.0.0.1 nmap -Pn -p1502 --script modbus-device-id --script-args modbus-device-id.unit-id=1 10.10.12.10 nmap -Pn -p502 --script modbus-device-id --script-trace 10.10.12.10--script-trace会把发送和接收的十六进制字节全部打印出来,这是排查报文构造错误最快的手段。如果看到响应在 TCP 层就有 RST,多半是目标根本不是 Modbus 设备;如果连接正常但脚本没有输出,说明响应解析的分支没有覆盖设备返回的异常码,这时把--script-trace的输出保存下来对照协议规范重新数一遍偏移量。
3.3 多单元标识的批量探测
实际测试中,一个网关背后可能挂着几十个 Modbus 从站,单元标识从 1 到 247 逐个试会很慢。常见做法是用 NSE 的stdnse.iterate_random或直接在 action 里循环单元号,把每个单元的结果拼接成一张表。注意不要一次把 247 个请求全部发出,网关系数弱的设备会在这类压力下丢掉部分请求,导致漏报。我一般把循环切分成 10 个一组,每组之间加一个短暂延时,保证响应帧和请求帧一一对应。
提示:NSE 脚本的
categories字段建议只写safe和discovery,不要加intrusive,这样后续使用--script "safe"批量调用时不会误扫到生产 PLC。
4. 从端口探测到功能码枚举:用 Python 在 502/102/44818 端口上做协议确认
Nmap 的输出适合做资产清单,但安全测试还需要知道协议层的更多行为:哪些功能码被禁用、哪些寄存器区间可读、S7comm 的通信建立是否要求凭证。这一层靠 NSE 插件做起来比较绕,用 Python 直接构造报文更顺手。
4.1 用 Scapy 和原始套接字做 Modbus 功能码枚举
对 Modbus 从站做功能码枚举,本质是逐个发送支持的功能码,观察响应是正常回包还是异常码。最常见的读操作功能码包括 0x01 读线圈、0x03 读保持寄存器、0x04 读输入寄存器、0x17 读写多个寄存器。下面的脚本用原始 socket 发送读保持寄存器请求,通过响应内容区分设备行为:
import socket import struct def modbus_read_holding(ip, unit=1, address=0, count=1, port=502, timeout=3): pdu = struct.pack(">BHH", 0x03, address, count) mbap = struct.pack(">HHHB", 1, 0, len(pdu) + 1, unit) sock = socket.create_connection((ip, port), timeout=timeout) sock.sendall(mbap + pdu) resp = sock.recv(1024) sock.close() if len(resp) < 9: return None func = resp[7] if func & 0x80: return f"exception-code={resp[8]}" return f"byte-count={resp[8]}" if __name__ == "__main__": for fc in [0x01, 0x02, 0x03, 0x04, 0x17]: try: print(f"FC {fc:#04x}: {modbus_read_holding('10.10.12.10', fc=fc)}") except Exception as e: print(f"FC {fc:#04x}: failed={type(e).__name__}")这段代码里的struct.pack(">BHH", 0x03, address, count)表示功能码、起始地址、寄存器数量,>BHH指定了大端序。响应里第 7 字节是功能码回显,如果最高位被置 1,说明从站返回的是异常码,异常码本身放在第 8 字节。枚举时看到exception-code=1表示功能码不受支持,exception-code=3表示数据地址越界,这两种结果说明从站对请求做了语义检查,反过来也能证明协议栈是真实存在的。
4.2 读保持寄存器与状态确认的边界
功能码枚举只做读操作,尽量不要碰 0x05 写单线圈和 0x06 写单保持寄存器。写操作一旦连到真实设备,轻则改变工艺参数,重则触发急停逻辑。即使只做读操作,也要控制频率,因为某些老式 PLC 的串口转以太网网关对并发请求的缓冲能力有限,高频轮询会让看门狗误判通信故障。
4.3 S7comm 的 COTP 层确认
S7comm 的确认方式和 Modbus 不一样,必须先完成 COTP 连接请求,才能继续发送 S7comm 作业请求。用 Python 做最小验证时,发送一个固定的 COTP 连接请求帧,看响应中是否包含连接确认标志:
import socket def s7_cotp_confirm(ip, port=102, timeout=5): sock = socket.create_connection((ip, port), timeout=timeout) cotp_cr = bytes.fromhex("0300001611e00000000100c0010ac1020100c2020101") sock.sendall(cotp_cr) resp = sock.recv(1024) sock.close() if b"\x02\xf0\x80" in resp: return "s7-cotp-confirm" return f"unknown-response={resp.hex()}" if __name__ == "__main__": for ip in ["10.10.12.3", "10.10.12.4"]: try: print(ip, s7_cotp_confirm(ip)) except socket.timeout: print(ip, "timeout")COTP 连接请求的报文结构是固定的:TPKT 头 4 字节标明总长度,COTP 头里 0xE0 表示连接请求,后面跟着源引用和参数。响应里的0x02 0xf0 0x80是 TPDU 连接确认的特征字节。如果目标端口开放但迟迟不回这个特征串,说明通讯对端可能不是西门子 PLC,而是一个只监听不响应的仿真器或防火墙策略拦截了应用层数据。
4.4 EtherNet/IP 的 UDP 确认
EtherNet/IP 的 44818 通常是 UDP 优先,直接发一个 List Identity 请求,目标设备会返回 Vendor ID、设备类型、序列号等结构化信息。用enip-info拿到的信息其实来自同一机制。如果 Nmap 的 UDP 探测被过滤,可以换 TCP 44818 再试一次,两类设备存在差异时本身就是一条很有价值的线索,说明该节点可能在两个网络平面分别提供协议服务。
5. 工业互联网现场扫描的参数边界:限速、超时与半连接扫描的取舍
在办公网里调得飞快的 Nmap 扫到工控网段,结果往往不是返回慢,而是 PLC 直接断连。问题出在 TCP 半连接扫描-sS和默认的重传机制上。工控协议栈的实现侧重实时性,没有完整处理 SYN flood、无序重传这类对抗性场景的能力,高并发扫描包会把 CPU 周期消耗在协议栈响应上,导致控制任务得不到调度。
5.1 为什么半连接扫描在工控环境不适用
-sS只发 SYN 不完成三次握手,普通服务器无所谓,但工控设备的协议栈通常同时肩负控制周期,快速接收队列被半连接占满后,新的过程数据帧会被直接丢弃。现场表现就是扫描期间模拟量上传中断,严重的会触发从站的通信超时报警。所以工控网段测试我基本只用-sT全连接扫描,代价是速度慢一点,但三次握手完成后协议栈的负担会明显下降。
5.2 一张参数表解决扫描节奏控制
针对不同类型的现场,我习惯把参数拆成三档,慢速档适用于在线生产系统,中速档适用于停机检修窗口,快速档只用于实验室环境:
| 参数 | 慢速档 | 中速档 | 快速档 |
|---|---|---|---|
--min-rate | 10 | 50 | 200 |
--max-rtt-timeout | 3000ms | 1500ms | 800ms |
--scan-delay | 5s | 1s | 不设置 |
--host-timeout | 60m | 30m | 10m |
--max-retries | 0 | 1 | 2 |
--max-retries默认在丢包严重的网络里会重试很多次,但对 PLC 来说,多次重试不会让它更愿意响应,只会把连接表塞满。慢速档直接设成 0 个重试,一次探不到就跳过,靠后续的协议确认阶段再补。--scan-delay在慢速档设 5 秒,主要是照顾串口转以太网网关,这类设备处理一个请求需要几百毫秒,连续发多个请求会导致内部缓冲区溢出。
5.3 分网段扫描而不是全段平推
工业互联网的网段划分通常比办公网复杂,PLC 网段、HMI 网段、历史数据库网段可能在逻辑上互相隔离。全网段平推会触发工业防火墙的连接阈值告警,导致后续扫描被封锁。常见做法是先扫边界网段,把资产清单拿到后,再按 PLC 和 HMI 分组做协议确认。分组扫描要配合超时参数,对无响应的地址快速跳过,宁可漏一个端口,不要让一轮扫描拖几小时。
注意:测试开始前确认停机和联调窗口,扫描时如果触发设备的看门狗重启,你得到的结果本身就不可信,先恢复现场再继续。
5.4 扫描工具的选择不只有 Nmap
拿着 Burp Suite 的习惯做工控渗透的人,第一次面对 502 端口往往无从下手,因为 Web 扫描器构造的 HTTP 请求在 Modbus 协议栈里只会被当作乱码丢弃。工控扫描场景里,Nmap 只承担端口发现和基础协议识别,后续的协议深度交互还是要靠 Python 脚本和专用测试工具。工具链的取舍标准只有一个:能不能精确控制报文的字节序、长度和时序。
6. 用响应差异复核结果:排除中间件与网关的三种验证技巧
端口发现和协议确认之后,清单里仍会出现“假阳性”资产,把网关误认成控制器,把协议转换器误认成真实从站。复核阶段靠的不是更高频率扫描,而是从响应差异里找线索。
6.1 技巧一:重复请求对比事务 ID 回显
真实 Modbus 从站收到请求后,会原样回显事务标识符,处理过程由协议栈完成,逻辑上不可能不回显。网关转发通常会把请求拆成内部格式再重组,对事务 ID 的处理存在不一致的情况。以下这段脚本直接在 bash 里验证:
for ip in $(nmap -Pn -p502 --open 10.10.12.0/24 -oG - | awk '/open/{print $2}'); do timeout 3 bash -c "exec 3<>/dev/tcp/$ip/502; printf '\x00\x01\x00\x00\x00\x04\x01\x2b\x0e\x01\x00' >&3; head -c 8 <&3 | xxd | grep -q '0001' && echo \"$ip txn-ok\"" done代码里exec 3<>/dev/tcp/$ip/502在 bash 中建立双向 TCP 连接,请求帧固定事务 ID 为 0x0001,响应头 8 字节里前 2 字节必须与请求一致。如果设备返回的事务 ID 不同,或者响应超过 3 秒都没到,就值得怀疑它是不是原生的 Modbus 从站。
6.2 技巧二:跨协议交叉验证
同一台 PLC 不太可能同时响应 Modbus TCP 和 DNP3,不同协议的响应必然指向不同的协议栈。在资产清单上把多个协议的结果做一次笛卡尔交叉,凡是同一 IP 在 502 和 20000 端口上都返回完整协议确认帧的,多半是测试靶机、协议仿真器或统一网关。这个判断不绝对,但能帮你快速圈定需要人工复核的目标范围。
6.3 技巧三:用 TCP 选项字段识别网关
转发网关通常跑在商用操作系统上,TCP 握手中的窗口大小、时间戳选项、MSS 值都带着宿主机特征。真实的 PLC 实时协议栈对这些参数往往是固定的,两种响应特征差异明显。对比同一网段内已知 PLC 的指纹和待确认设备的指纹,如果后者的 TCP 选项表现出极高的灵活性,就要结合协议层响应一起判断。这个技巧不能单独作为定性依据,但作为复核流程的最后一道过滤,能把真正需要登录设备做确认的目标缩小到个位数。
本文还有配套的精品资源,点击获取