工控协议深度扫描:从Modbus TCP到S7comm的指纹识别实战 简介一套聚焦工业互联网安全测试的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/COTPCOTP 连接请求通过后才会进入 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-id1 10.10.12.0/24 nmap -Pn -sT -p102 --script s7-info --script-args s7-info.rack0,s7-info.slot1 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/2S7-1200/1500 常见 0/1模错了就换一组。enip-info走的是 UDP 44818CIP 协议会把 Vendor ID 和设备类型带回拿到值后对照设备描述文件就能定位型号。协议默认端口识别命令返回的关键字段Modbus TCP502/tcpmodbus-discover厂商名、产品码、版本号S7comm102/tcps7-info机架号、插槽号、模块类型DNP320000/tcpdnp3-info固件版本、设备地址EtherNet/IP44818/tcpudpenip-infoVendor ID、序列号IEC 1042404/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-id1 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, unit1, address0, count1, port502, timeout3): pdu struct.pack(BHH, 0x03, address, count) mbap struct.pack(HHHB, 1, 0, len(pdu) 1, unit) sock socket.create_connection((ip, port), timeouttimeout) sock.sendall(mbap pdu) resp sock.recv(1024) sock.close() if len(resp) 9: return None func resp[7] if func 0x80: return fexception-code{resp[8]} return fbyte-count{resp[8]} if __name__ __main__: for fc in [0x01, 0x02, 0x03, 0x04, 0x17]: try: print(fFC {fc:#04x}: {modbus_read_holding(10.10.12.10, fcfc)}) except Exception as e: print(fFC {fc:#04x}: failed{type(e).__name__})这段代码里的struct.pack(BHH, 0x03, address, count)表示功能码、起始地址、寄存器数量BHH指定了大端序。响应里第 7 字节是功能码回显如果最高位被置 1说明从站返回的是异常码异常码本身放在第 8 字节。枚举时看到exception-code1表示功能码不受支持exception-code3表示数据地址越界这两种结果说明从站对请求做了语义检查反过来也能证明协议栈是真实存在的。4.2 读保持寄存器与状态确认的边界功能码枚举只做读操作尽量不要碰 0x05 写单线圈和 0x06 写单保持寄存器。写操作一旦连到真实设备轻则改变工艺参数重则触发急停逻辑。即使只做读操作也要控制频率因为某些老式 PLC 的串口转以太网网关对并发请求的缓冲能力有限高频轮询会让看门狗误判通信故障。4.3 S7comm 的 COTP 层确认S7comm 的确认方式和 Modbus 不一样必须先完成 COTP 连接请求才能继续发送 S7comm 作业请求。用 Python 做最小验证时发送一个固定的 COTP 连接请求帧看响应中是否包含连接确认标志import socket def s7_cotp_confirm(ip, port102, timeout5): sock socket.create_connection((ip, port), timeouttimeout) 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 funknown-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-rate1050200--max-rtt-timeout3000ms1500ms800ms--scan-delay5s1s不设置--host-timeout60m30m10m--max-retries012--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 选项表现出极高的灵活性就要结合协议层响应一起判断。这个技巧不能单独作为定性依据但作为复核流程的最后一道过滤能把真正需要登录设备做确认的目标缩小到个位数。本文还有配套的精品资源点击获取