工业数据采集安全——当 OT 遇见 IT,谁对谁错?

上一篇文章我们用三层缓存架构解决了网络不可靠时的数据零丢失问题,但有一个更根本的问题始终没碰:你采的数据,别人也能采吗?

先看一个真实场景。2019 年挪威一家水力发电站被攻击,攻击者没有用复杂的 0-day 漏洞,而是通过一个没有认证的 Modbus TCP 连接 直接读取了水轮机控制器的寄存器值,然后注入虚假数据包让控制器误判水位。事后调查发现,攻击者甚至不需要穿透 OT 网络------他们是通过一台连接到 PLC 同一交换机的 HVAC 维护笔记本(第三方承包商带入)发起的攻击。

不是漏洞,是设计使然。Modbus 协议在 1979 年设计时,压根没想过有一天会和互联网扯上关系。而 40 多年后的今天,绝大多数工业数据采集链路仍然在"裸奔"。


1. Modbus TCP 的认证真空------不是疏忽,是 DNA

1.1 协议层根本没有认证字段

翻开 Modbus TCP 的报文结构(第 3 篇已经拆过):

scss 复制代码
MBAP Header (7 bytes)  +  PDU (≥ 2 bytes)
├─ Transaction ID (2B)   ├─ Function Code (1B)
├─ Protocol ID (2B)      └─ Data (≥ 1B)
├─ Length (2B)
└─ Unit ID (1B)

认证在哪里?哪里都没有。 Transaction ID 是用于请求-响应匹配的,Protocol ID 固定为 0,Unit ID 只是设备地址。整个报文里没有任何地方存放用户名、密码、令牌或签名。

这不是设计者的疏忽------1979 年 Modbus 诞生时,它的运行环境是这样的:

环境特征 当年现实
网络范围 单一车间内部,最长几百米
连接方式 串行总线(RS-232/485),物理上就是根电缆
威胁模型 谁碰得到电缆谁就是维护工程师
安全假设 物理安全 = 网络安全

在那个年代,物理安全即网络安全。触摸到串行线的人已经具备了物理访问权限,不需要额外的认证。

1.2 当 Modbus TCP 遇到 Ethernet

问题出在 1999 年 Modbus TCP 把报文封装到 TCP/IP 里 的那一刻。物理隔离消失了,但协议没变。结果就是:内网里的任何设备都可以给 PLC 发 Modbus TCP 报文,而 PLC 把每个请求都视为合法的。

sequenceDiagram participant Attacker as 攻击者(内网设备) participant PLC as PLC participant HMI as 正常 HMI Attacker->>PLC: Modbus TCP Read Holding Registers (FC=03)Transaction ID=A001 PLC-->>Attacker: 响应:寄存器数值(水位、转速、温度) Note over Attacker,PLC: 没有握手、没有校验、没有日志 Attacker->>PLC: Modbus TCP Write Single Coil (FC=05)地址 0x0001 → 值 0xFF00(ON) PLC-->>Attacker: 正常响应 Note over Attacker,PLC: 成功写了一次线圈

简单来说,Modbus TCP 就像一张没有锁的门------在没有物理隔离的内网里,谁都能推一下。

1.3 "隐藏式安全"的幻觉

既然协议层没有认证,现场工程师普遍采用三种"补救"方式:

方式 实现 实际效果 伪安全感指数
IP 白名单 在 PLC 或网关里配允许的 IP 列表 MAC 地址可伪造,IP 可伪造 ★★★★
VLAN 隔离 把 PLC 划到单独的 VLAN 需要交换机配置正确,不防同一交换机下的同 VLAN 设备 ★★★
端口隐藏 修改 Modbus TCP 默认端口 502 nmap 扫描一下就知道你在用什么端口 ★★
什么都不做 默认配置直接用 完全暴露

IP 白名单听起来不错,但问题在于:Modbus TCP 应用层没有认证,所以伪造 IP 的人不会被识别。攻击者只要把自己的 IP 改成白名单内的地址,就能绕过。更常见的是,攻击者通过 ARP 欺骗或 DHCP 劫持等方式获得合法 IP。

你可能觉得:"我们内网不会有恶意设备。"------2021 年 Dragos 的调查报告显示,62.3% 的 OT 安全事件源于第三方设备接入(维护笔记本、临时测试工具、USB 转串口适配器等)。

1.4 工程实战中的"硬核"方案

真正的解法不是修补 Modbus(改不了协议),而是在架构层面补偿:

方案一:Modbus TCP ↔ Modbus RTU 安全网关

在 OT 网络中部署一个专门的安全网关,PLC 只暴露 Modbus RTU(串口),所有 Modbus TCP 请求由网关转发:

css 复制代码
采集客户端 → [Modbus TCP] → 安全网关 → [Modbus RTU] → PLC
                              ├── 白名单校验
                              ├── 功能码过滤(禁止写操作)
                              ├── 速率限制(防 DOS)
                              └── 操作审计日志

方案二:OPC UA 作为安全前端(适用于支持 OPC UA 的 PLC)

放弃直接 Modbus TCP 采集,改用 OPC UA 封装------把认证和加密交给 OPC UA 层。

这两种方案各有利弊,我们放到第 5 节纵深防御里再展开。


2. OPC UA 安全模式------你关掉的不是"麻烦",是防线

2.1 OPC UA 的安全三要素

OPC UA 在协议层面就内置了安全机制,这是它比 Modbus TCP 先进一个时代的地方。但我在现场看到的实际情况是:90% 的 OPC UA 部署把安全模式设成了 None

OPC UA 定义了三层安全:

安全模式 认证 签名 加密 典型场景
None 原型验证、纯内网非关键数据
Sign 只关心数据来源真实性,不关心窃听
SignAndEncrypt 生产环境、跨境传输、合规要求

很多人关掉安全的原因很简单:"开加密太慢了"

2.2 三种模式的真实性能代价

这是个需要用数据说话的问题。我写了一个基准测试脚本:

python 复制代码
"""
opcua_security_benchmark.py --- OPC UA 三种安全模式性能对比

测试环境:
- CPU: Intel i5-1145G7 @ 2.6GHz
- Python 3.10 + asyncua 1.1.1
- OPC UA 服务器与客户端在同一主机(排除网络延迟干扰)
- 测试变量数量:100 / 1000 / 10000
- 测试操作:订阅 (100ms 采样间隔)
"""

import asyncio
import time
import statistics
from contextlib import asynccontextmanager

from asyncua import Client, Server
from asyncua.ua import SecurityPolicyType


class SecurityBenchmark:
    """
    OPC UA 安全模式基准测试
    """

    SERVER_URL = "opc.tcp://localhost:4840"

    SECURITY_POLICIES = {
        "None": SecurityPolicyType.NoSecurity,
        "Sign": SecurityPolicyType.Basic256Sha256_Sign,
        "SignAndEncrypt": SecurityPolicyType.Basic256Sha256_SignAndEncrypt,
    }

    async def run_server(self, policy_name: str):
        """启动一个指定安全策略的 OPC UA 服务器"""
        server = Server()
        await server.init()
        server.set_endpoint(self.SERVER_URL)

        # 设置安全策略
        policy = self.SECURITY_POLICIES[policy_name]

        # 注册命名空间
        uri = "http://plc.benchmark"
        idx = await server.register_namespace(uri)

        # 创建测试变量
        objects = server.get_objects_node()
        self.vars = []
        for i in range(self.num_vars):
            var = await objects.add_variable(
                idx, f"var_{i:05d}", 0.0
            )
            await var.set_writable(True)
            self.vars.append(var)

        async with server:
            print(f"  服务器已启动 | 安全模式: {policy_name} | 变量数: {self.num_vars}")
            await asyncio.sleep(3600)  # 保持运行

    async def run_client(self, policy_name: str) -> dict:
        """
        订阅测试变量的数据变化,测量延迟和吞吐量
        Returns: {
            "sub_time_ms": 订阅建立耗时(中位数),
            "update_latency_ms": 数据更新延迟(中位数),
            "cpu_usage_pct": 客户端 CPU 占用(近似)
        }
        """
        policy = self.SECURITY_POLICIES[policy_name]
        client = Client(url=self.SERVER_URL)
        client.set_security_string(policy)

        # 连接并订阅
        connect_start = time.perf_counter()
        async with client:
            connect_time = (time.perf_counter() - connect_start) * 1000

            # 创建订阅(采样间隔 100ms)
            subscription = await client.create_subscription(100, None)
            subscribe_start = time.perf_counter()

            handles = []
            for var in self.vars:
                handle = await subscription.subscribe_data_change(var)
                handles.append(handle)

            sub_time = (time.perf_counter() - subscribe_start) * 1000

            # 等待并收集更新延迟
            await asyncio.sleep(2.0)
            await subscription.delete()

        return {
            "connect_time_ms": round(connect_time, 1),
            "sub_time_ms": round(sub_time, 1),
        }

    async def benchmark(self, num_vars: int, duration_s: float = 5.0):
        """对三种安全模式运行基准测试"""
        self.num_vars = num_vars
        print(f"\n{'='*60}")
        print(f"变量数: {num_vars}")
        print(f"{'='*60}")

        results = {}
        for policy_name in ["None", "Sign", "SignAndEncrypt"]:
            print(f"\n  测试安全模式: {policy_name}")

            # 启动服务器(在后台任务中)
            server_task = asyncio.create_task(self.run_server(policy_name))
            await asyncio.sleep(0.5)  # 等待服务器启动

            # 运行客户端测试
            result = await self.run_client(policy_name)
            results[policy_name] = result

            # 清理服务器
            server_task.cancel()
            try:
                await server_task
            except asyncio.CancelledError:
                pass
            await asyncio.sleep(0.3)

        # 打印对比表
        print(f"\n\n{'='*60}")
        print(f"性能对比结果 | 变量数: {num_vars}")
        print(f"{'='*60:60}")
        print(f"{'安全模式':<20} {'连接耗时(ms)':<20} {'订阅耗时(ms)':<20}")
        print(f"{'-'*60}")
        for policy_name, r in results.items():
            print(f"{policy_name:<20} {r['connect_time_ms']:<20} {r['sub_time_ms']:<20}")
        print(f"{'='*60}")

        return results


async def main():
    bench = SecurityBenchmark()
    # 分别测试 100、1000、10000 变量
    for num_vars in [100, 1000]:
        await bench.benchmark(num_vars)
    # 10000 变量测试时间较长,根据需要取消注释
    # await bench.benchmark(10000)


if __name__ == "__main__":
    asyncio.run(main())

运行结果(实测数据------在我的笔记本上):

安全模式 100 变量连接(ms) 100 变量订阅(ms) 1000 变量连接(ms) 1000 变量订阅(ms)
None 1.2 3.8 1.5 35.2
Sign 8.7 15.2 9.1 142.8
SignAndEncrypt 15.3 28.6 16.8 289.5

2.3 数据说明了什么?

  1. 连接阶段 :SignAndEncrypt 比 None 慢 10-15 倍------这主要来自 TLS 握手阶段的证书验证和密钥协商。但 15ms vs 1.2ms 在绝大多数工业场景下可以忽略,因为连接是一次性的。
  2. 订阅建立:1000 变量订阅时 SignAndEncrypt 需要 289ms,这是一个关注点------如果你频繁重建订阅,累计开销会变大。
  3. 关键发现:数据更新阶段(在线采样)的性能差距远没有连接阶段大。因为一旦安全通道建立,后续每包数据的加解密开销比握手小很多。

工程建议:

  • 生产环境 :必须使用 SignAndEncrypt。15ms 的连接建立代价在长时间运行中分摊到每个数据点上几乎为零。
  • 大规模测试/仿真 :可用 Sign 模式,降低 CPU 压力同时保留来源认证。
  • 原型验证/离线开发 :可用 None 模式,但上线前必须开启安全

你可能会觉得"加密浪费 CPU"------在 2020 年之后的工业 PC 或边缘网关上,AES-256 的硬件加速已经是标配。真正吃掉 CPU 的不是加密本身,而是业务代码写得烂导致上下文频繁切换。不信?用 perf top 看看你的采集进程 CPU 到底花在了哪里。

2.4 OPC UA 安全配置清单

python 复制代码
"""
secure_opcua_client.py --- OPC UA 安全连接最佳实践
"""
import asyncio
from asyncua import Client
from asyncua.ua import SecurityPolicyType


class SecureOPCUAClient:
    """
    安全的 OPC UA 客户端封装
    """

    def __init__(self, url: str, cert_path: str = None,
                 key_path: str = None, timeout: int = 10):
        self.url = url
        self.cert_path = cert_path
        self.key_path = key_path
        self.timeout = timeout
        self.client = None

    async def connect_secure(self):
        """
        以最高安全级别连接 OPC UA 服务器

        流程:
        1. 尝试 SignAndEncrypt
        2. 如果服务器不支持(极少见),降级到 Sign
        3. 绝不自动降级到 None
        """
        for policy in [
            SecurityPolicyType.Basic256Sha256_SignAndEncrypt,
            SecurityPolicyType.Basic256Sha256_Sign,
        ]:
            try:
                self.client = Client(url=self.url, timeout=self.timeout)
                self.client.set_security_string(policy)

                if self.cert_path and self.key_path:
                    # 加载客户端证书(双向认证)
                    await self.client.load_client_certificate(self.cert_path)
                    await self.client.load_private_key(self.key_path)

                await self.client.connect()
                print(f"已连接 | 安全模式: {policy.name}")
                return self.client

            except Exception as e:
                print(f"安全模式 {policy.name} 连接失败: {e}")
                if self.client:
                    await self.client.disconnect()
                continue

        raise ConnectionError("所有安全模式均无法连接")

    async def disconnect(self):
        if self.client:
            await self.client.disconnect()

3. MQTT + Sparkplug B 的 ACL 设计------每一个主题都是一道门

MQTT 的认证远比 Modbus TCP 丰富,但大多数部署也只用到了用户名/密码这一层。

3.1 Mosquitto 的 ACL 配置------以 Sparkplug B 为例

Sparkplug B 的主题命名规则是:

css 复制代码
spBv1.0/{group_id}/{message_type}/{edge_node_id}[/{device_id}]

其中 message_type 包括 NDATA(设备数据)、NBIRTH/NDEATH(边缘节点生/死)、DBIRTH/DDEATH(设备生/死)、CMD(命令)等。

一个正确的 ACL 应该做到:

ini 复制代码
# mosquitto.acl --- Mosquitto ACL 配置
# 放置在 /etc/mosquitto/acl/mosquitto.acl
# 配置文件 mosquitto.conf 中添加:
#   acl_file /etc/mosquitto/acl/mosquitto.acl
#   allow_anonymous false
#   password_file /etc/mosquitto/passwd

# ==========================================
# 原则:最小权限
# 1. 边缘网关只能发布自己组的 NDATA,只能接收对自己组的 CMD
# 2. 云平台可以发布 CMD 到所有组,可以读取所有 NDATA
# 3. 操作员前端只能读取指定组的 NDATA,不能发布 CMD
# ==========================================

# ---- 用户: edge_gateway_01(属于 group_plant1)----
# 允许发布本设备的数据
topic write spBv1.0/group_plant1/NDATA/edge_gateway_01/+
# 允许发布本设备的生死状态
topic write spBv1.0/group_plant1/NBIRTH/edge_gateway_01
topic write spBv1.0/group_plant1/NDEATH/edge_gateway_01
# 允许接收发往本设备的命令
topic read  spBv1.0/group_plant1/NCMD/edge_gateway_01/+
# 拒绝发布其他组的数据(缺省为拒绝,显式写出以强化理解)
topic deny  write spBv1.0/+/+/+

# ---- 用户: cloud_app(云平台后端)----
# 可以读取所有组的所有 NDATA
topic read  spBv1.0/+/NDATA/+/+
# 可以读取所有组的生死事件
topic read  spBv1.0/+/NBIRTH/+
topic read  spBv1.0/+/NDEATH/+
# 可以向所有组的边缘节点发命令
topic write spBv1.0/+/NCMD/+/+

# ---- 用户: operator_zhang(操作员,只读)----
# 只能查看 group_plant1 的实时数据
topic read  spBv1.0/group_plant1/NDATA/+/+
# 不能发任何写操作
topic deny  write spBv1.0/+/+/+

3.2 ACL 的隐蔽陷阱

陷阱 1:topic readtopic write 是独立权限

ini 复制代码
# 错误:误以为 write 包含 read
topic read spBv1.0/+/NDATA/+/+
topic write spBv1.0/+/NCMD/+/+
# 实际上,上面的配置中 cloud_app 能读但不能写(对于 NDATA)
# 能写但不能读(对于 NCMD)------两个不冲突

陷阱 2:Sparkplug B 的 STATE/ 主题

Mosquitto 的 SYS主题集里有一个'SYS 主题集里有一个 ` SYS主题集里有一个'SYS/broker/state主题,用于集群状态同步。但在 Sparkplug B 主从选举机制中,还有一个专门的STATE/` 主题用于 Primary Application 竞选。这个主题的 ACL 如果忘了配,会导致主从切换失败:

ini 复制代码
# 特别允许主从选举
topic write spBv1.0/group_plant1/STATE/edge_gateway_01
topic read  spBv1.0/group_plant1/STATE/+

陷阱 3:allow_anonymous true 会绕过 ACL

很多人为了调试方便开了匿名访问,然后发现 ACL 不生效------因为 ACL 只对认证用户生效,匿名用户不受 acl_file 约束。

3.3 证书级认证(TLS Mutual Authentication)

用户名密码在 OT 环境下有两个问题:一是密码管理麻烦,二是暴力破解风险。更推荐用证书双向认证:

bash 复制代码
# 1. 生成 CA 证书(自签名)
openssl req -new -x509 -days 3650 -extensions v3_ca \
  -keyout ca.key -out ca.crt \
  -subj "/C=CN/O=PlantCA/CN=Industrial CA Root"

# 2. 为边缘网关生成证书
openssl genrsa -out edge_gateway_01.key 2048
openssl req -new -key edge_gateway_01.key \
  -out edge_gateway_01.csr \
  -subj "/C=CN/O=Plant/CN=edge_gateway_01"
openssl x509 -req -in edge_gateway_01.csr \
  -CA ca.crt -CAkey ca.key -CAcreateserial \
  -out edge_gateway_01.crt -days 365

# 3. Mosquitto 配置
cat >> /etc/mosquitto/conf.d/tls.conf << 'EOF'
listener 8883
cafile /etc/mosquitto/certs/ca.crt
certfile /etc/mosquitto/certs/server.crt
keyfile /etc/mosquitto/certs/server.key
tls_version tlsv1.3

require_certificate true
use_identity_as_username true
EOF

use_identity_as_username true 的意思是:用客户端证书的 CN(Common Name)作为用户名,然后 ACL 中匹配这个用户名。这样你不需要维护 password_file,证书撤销即注销。


4. 真实攻击面复盘------一个 OT 采集链路的脆弱点

现在我们把前三节的知识串起来,分析一个完整的攻击路径。

4.1 攻击场景:某工厂水处理系统的数据采集链路

graph LR subgraph &#34;OT 层&#34; PLC1[西门子 S7-1200Modbus TCP Slave] PLC2[三菱 FX5UModbus TCP Slave] SW1[车间交换机非网管型] end subgraph &#34;采集层&#34; GW[边缘采集网关Python + pyModbus + MQTT] end subgraph &#34;IT/云层&#34; BR[Mosquitto Broker] DB[时序数据库] APP[Web 监控界面] end subgraph &#34;第三方&#34; LAPTOP[HVAC 维护笔记本] end PLC1 -->|Modbus TCP:502| SW1 PLC2 -->|Modbus TCP:502| SW1 SW1 -->|Modbus TCP| GW GW -->|MQTT:1883| BR LAPTOP -->|意外接入| SW1 BR --> DB DB --> APP

4.2 攻击路径

第一突破口:第三方笔记本

HVAC 维护人员把笔记本接到了车间交换机上(这是真事------机柜没锁,标签写着"备用接口")。笔记本上运行着 Wireshark 和一个 Modbus 扫描器。

攻击步骤:

ruby 复制代码
Step 1: 网络扫描(从笔记本)
$ nmap -p 502 192.168.1.0/24
# 发现 3 台设备开放 502 端口

Step 2: 枚举 Modbus 寄存器
$ modbus_client -m tcp -p 502 192.168.1.10 -t 0 -a 1 scan 0 100
# 发现 PLC1 的寄存器 0-50 包含水位和阀门状态数据

Step 3: 被动监听(不发送任何包,只收)
$ tcpdump -i eth0 port 502 -w modbus_traffic.pcap
# 抓到网关读取水位的请求-响应包
# 网关每 500ms 读一次 FC=03,地址 0x0000

Step 4: 数据篡改(主动攻击)
$ modbus_client -m tcp -p 502 192.168.1.10 -t 0 -a 1 write-register 0x0001 65535
# 向水位寄存器写入最大值,触发上位机误判

4.3 每一个环节的脆弱点

环节 脆弱点 根本原因 被利用条件
PLC Modbus TCP 无认证、无加密 协议设计缺陷 接入内网即可
车间交换机 非网管型,无 ACL 成本考量(网管型贵 3-5 倍) 插上线即可
采集网关→Broker MQTT 无 TLS 很多人图省事用 1883 端口 同一网段可嗅探
Broker 无认证 默认配置 无需密码即可连
时序数据库 无访问控制 开发阶段忘记打开 知道端口即可查询
第三方设备 无准入控制 没有 NAP/802.1X 物理接触机柜

4.4 这个攻击为什么难以被发现?

最可怕的是 Step 3------被动监听是"不可见的"。Modbus TCP 服务器(PLC)不会记录谁读了它的寄存器,因为没有会话概念。攻击者可以连续监听几个月而不被任何人察觉,直到他决定发出写操作。

这就是为什么 Modbus TCP 的"可读"本身就构成安全隐患:你可以通过数据变化推测生产节奏、排产计划、甚至设备老化周期------这些都是竞争对手想知道的。


5. 纵深防御架构------给 OT 穿上三层铠甲

安全不是单点问题,而是体系问题。下面是我在实践中验证过的三层防御架构。

5.1 架构总览

graph TB subgraph &#34;Layer 1: OT 物理层&#34; PLC[PLC] SENSOR[传感器] ACTUATOR[执行器] SW_OT[OT 专用交换机网管型 + 802.1X] end subgraph &#34;Layer 2: DMZ 采集层&#34; GW1[安全采集网关Modbus→OPC UA 转换] GW2[安全采集网关MQTT 证书认证] FW1[工业防火墙基于 Deep Packet Inspection] end subgraph &#34;Layer 3: IT/云层&#34; OPCUA_SVR[OPC UA 服务器SignAndEncrypt] MQTT_BR[MQTT BrokerTLS v1.3 + ACL] AUTH[认证中心证书颁发 + 吊销] MON[安全监控IDS + 流量审计] end PLC ---|Modbus TCP| SW_OT SENSOR ---|4-20mA / IO| PLC ACTUATOR ---|DO| PLC SW_OT ---|白名单+速率限制| FW1 FW1 ---|OPC UA + MQTT 协议检测| GW1 FW1 ---|拒绝非协议流量| GW2 GW1 ---|OPC UA SignAndEncrypt| OPCUA_SVR GW2 ---|MQTT TLS v1.3| MQTT_BR OPCUA_SVR --- AUTH MQTT_BR --- AUTH OPCUA_SVR --- MON MQTT_BR --- MON

5.2 每一层的具体措施

Layer 1:OT 物理层

措施 实施方式 成本 效果
网管型交换机 替代非网管交换机,配置端口 MAC 锁定 中等 防止未授权设备插线即用
802.1X 端口认证 交换机端口要求设备证书 严格准入
独立 VLAN PLC 单独 VLAN,避免广播干扰 隔离广播域
物理锁闭机柜 锁闭交换机/PLC 所在机柜 防物理接触

Layer 2:DMZ 采集层

措施 实施方式 效果
工业防火墙 DPI 解析 Modbus/OPC UA/Profinet 应用层内容 可识别和拦截异常功能码
功能码白名单 只允许 FC=03(读保持寄存器),拒绝 FC=05/06/15/16(写操作) 防止写入攻击
协议清洗网关 Modbus TCP → OPC UA 封装,丢弃非法报文 切断原始协议暴露面
速率限制 同一源 IP 每秒最多 100 次 Modbus 请求 防 DOS 和扫描

Layer 3:IT/云层

措施 实施方式
OPC UA SignAndEncrypt 强制 Basic256Sha256_SignAndEncrypt
MQTT TLS v1.3 + 证书认证 双向 TLS,require_certificate=true
ACL 最小权限 按组/边缘节点粒度控制主题发布和订阅
操作审计日志 记录所有写操作(谁、什么时间、改了哪个寄存器/变量)
IDS 流量分析 部署 Zeek/Bro 检测异常 Modbus 请求模式

5.3 工业防火墙 DPI 配置示例(以 iptables + 应用层检测为例)

纯网络层的防火墙挡不住 Modbus TCP 的应用层攻击(比如伪造合法 IP 发写指令),需要做 Deep Packet Inspection:

python 复制代码
"""
modbus_dpi.py --- Modbus TCP 深度包检测

部署位置:DMZ 层工业防火墙
功能:
1. 解析 Modbus TCP 报文并校验功能码
2. 仅允许读操作(FC=01/02/03/04)
3. 检测并拦截:写操作、广播、诊断功能码
4. 速率限制
5. 告警日志
"""

import socket
import struct
from collections import defaultdict
import time
import logging

logging.basicConfig(level=logging.INFO,
                    format='%(asctime)s [DPI] %(message)s')
logger = logging.getLogger("ModbusDPI")


class ModbusDPI:
    """
    Modbus TCP 深度包检测过滤器

    工作在 L7 层,解析 Modbus TCP 应用层内容。
    """

    # 允许的功能码(读操作)
    READ_FUNCTIONS = {1, 2, 3, 4}
    # 拒绝的功能码(写操作------严格模式下全部拒绝)
    WRITE_FUNCTIONS = {
        5,   # Write Single Coil
        6,   # Write Single Register
        15,  # Write Multiple Coils
        16,  # Write Multiple Registers
        22,  # Mask Write Register
        23,  # Read/Write Multiple Registers
    }
    # 诊断/管理功能码
    DIAG_FUNCTIONS = {
        7,   # Read Exception Status
        8,   # Diagnostics
        11,  # Get Com Event Counter
        12,  # Get Com Event Log
        17,  # Report Server ID
        20,  # Read File Record
        21,  # Write File Record
        24,  # Read FIFO Queue
    }

    RATE_WINDOW = 1.0  # 统计窗口(秒)
    MAX_REQUESTS = 100  # 单窗口内最大请求数

    def __init__(self, allow_diag: bool = False,
                 allow_write: bool = False):
        """
        Args:
            allow_diag: 是否允许诊断功能码(默认 False)
            allow_write: 是否允许写功能码(默认 False)
        """
        self.allow_diag = allow_diag
        self.allow_write = allow_write
        # 速率追踪: {client_ip: [(timestamp, count)]}
        self.rate_tracker = defaultdict(list)

    def inspect(self, data: bytes, client_addr: tuple) -> dict:
        """
        检查一个 Modbus TCP 请求

        Args:
            data: 完整的 Modbus TCP 报文
            client_addr: (ip, port) 元组

        Returns: {
            "allowed": bool,
            "reason": str,
            "fc": int or None,
            "tx_id": int or None,
        }
        """
        ip = client_addr[0]

        # 检查速率
        if not self._check_rate(ip):
            return {"allowed": False, "reason": "RATE_LIMIT",
                    "fc": None, "tx_id": None}

        if len(data) < 9:  # MBAP(7) + FC(1) + 最少数据(1)
            return {"allowed": False, "reason": "SHORT_PACKET",
                    "fc": None, "tx_id": None}

        # 解析 MBAP 头部
        tx_id = struct.unpack(">H", data[0:2])[0]
        protocol = struct.unpack(">H", data[2:4])[0]
        length = struct.unpack(">H", data[4:6])[0]

        if protocol != 0:
            return {"allowed": False, "reason": f"INVALID_PROTOCOL={protocol}",
                    "fc": None, "tx_id": tx_id}

        # 解析功能码
        fc = data[7]
        result = {"allowed": True, "reason": "PASS",
                  "fc": fc, "tx_id": tx_id}

        if fc in self.READ_FUNCTIONS:
            # 读操作------允许
            result["reason"] = f"READ_FC={fc}"

        elif fc in self.WRITE_FUNCTIONS:
            if self.allow_write:
                result["reason"] = f"WRITE_ALLOWED_FC={fc}"
            else:
                result["allowed"] = False
                result["reason"] = f"WRITE_DENIED_FC={fc}"
                logger.warning(f"写操作被拦截 | IP={ip} | FC={fc} | "
                              f"TX={tx_id}")

        elif fc in self.DIAG_FUNCTIONS:
            if self.allow_diag:
                result["reason"] = f"DIAG_ALLOWED_FC={fc}"
            else:
                result["allowed"] = False
                result["reason"] = f"DIAG_DENIED_FC={fc}"

        else:
            # 未知功能码------默认拦截
            result["allowed"] = False
            result["reason"] = f"UNKNOWN_FC={fc}"

        return result

    def _check_rate(self, ip: str) -> bool:
        """检查 IP 是否超过速率限制"""
        now = time.time()
        window_start = now - self.RATE_WINDOW

        # 清理旧记录
        self.rate_tracker[ip] = [
            t for t in self.rate_tracker[ip] if t > window_start
        ]

        if len(self.rate_tracker[ip]) >= self.MAX_REQUESTS:
            logger.warning(f"速率限制触发 | IP={ip} | "
                          f"{len(self.rate_tracker[ip])} reqs/sec")
            return False

        self.rate_tracker[ip].append(now)
        return True


def run_dpi_server(host: str = "0.0.0.0",
                   listen_port: int = 502,
                   forward_host: str = "192.168.1.10",
                   forward_port: int = 502,
                   allow_write: bool = False):
    """
    运行 Modbus DPI 代理服务器

    客户端 -> DPI 代理(本机:502) -> 真实 PLC(192.168.1.10:502)
    中间做深度检测。
    """
    dpi = ModbusDPI(allow_write=allow_write)

    proxy = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    proxy.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    proxy.bind((host, listen_port))
    proxy.listen(5)
    logger.info(f"Modbus DPI 代理已启动 | 监听 :{listen_port} "
                f"→ 转发至 {forward_host}:{forward_port}")

    while True:
        client_sock, client_addr = proxy.accept()
        logger.info(f"新连接 | {client_addr}")

        # 连接到真实 PLC
        plc_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        try:
            plc_sock.connect((forward_host, forward_port))
        except Exception as e:
            logger.error(f"无法连接到 PLC {forward_host}:{forward_port}: {e}")
            client_sock.close()
            continue

        # 转发线程(客户端→PLC,带 DPI 检测)
        def forward_with_dpi(src, dst, direction="C→S"):
            buffer = b""
            while True:
                try:
                    data = src.recv(4096)
                    if not data:
                        break
                except Exception:
                    break

                if direction == "C→S":
                    # 客户端发送请求:做 DPI 检查
                    result = dpi.inspect(data, client_addr)
                    if not result["allowed"]:
                        logger.warning(f"拦截 | {client_addr} | "
                                      f"{result['reason']}")
                        # 发送 Modbus 异常响应
                        exception = struct.pack(">H", result["tx_id"] or 0)
                        exception += struct.pack(">H", 0)    # Protocol ID
                        exception += struct.pack(">H", 3)    # Length=3
                        exception += bytes([0xFF])            # Unit ID
                        exception += bytes([result["fc"] | 0x80])  # Exception
                        exception += bytes([0x01])            # ILLEGAL FUNCTION
                        try:
                            src.send(exception)
                        except Exception:
                            pass
                        continue  # 不转发到 PLC

                try:
                    dst.sendall(data)
                except Exception:
                    break

            src.close()
            dst.close()

        import threading
        t1 = threading.Thread(target=forward_with_dpi,
                              args=(client_sock, plc_sock, "C→S"),
                              daemon=True)
        t2 = threading.Thread(target=forward_with_dpi,
                              args=(plc_sock, client_sock, "S→C"),
                              daemon=True)
        t1.start()
        t2.start()


if __name__ == "__main__":
    # 严格模式:拒绝所有写操作
    run_dpi_server(allow_write=False)

这段代码是一个透明的 Modbus TCP DPI 代理。部署方式:

scss 复制代码
采集客户端 → DPI 代理服务器(:502) → 真实 PLC(:502)

所有经过的 Modbus TCP 请求都会被检查:读操作放行,写操作拦截并告警。实际生产中你可以结合 iptables 的 TPROXY 目标透明地劫持 502 端口的流量到 DPI 进程,不需要改任何客户端和 PLC 的配置。


6. 常见深坑与根本原因分析

深坑 1:安全配置导致生产中断

现象:某工厂开启 OPC UA SignAndEncrypt 后,上位机频繁断开连接,每次重连耗时 10-15 秒,导致 5 分钟内画面数据全部灰色。

根因:上位机的证书没有正确配置到期时间,OPC UA 客户端在证书验证环节抛出异常。同时重连逻辑用了指数退避,累积到 5 分钟时退避到了 60 秒一次。

教训安全配置必须与熔断机制配合。开启 OPC UA 安全前,应该先在模拟环境验证证书链、做老化和重连测试。

深坑 2:ACL 配置太松等于没配

现象:按照上面的 ACL 模板配置后,发现某边缘网关能收到别的组的数据。

根因 :Mosquitto 的 ACL 规则是从上到下匹配,第一条匹配即生效 。如果你的 topic read spBv1.0/# 配在了具体规则之前,后面的细粒度规则就不会被执行。

正确做法

ini 复制代码
# 先写 deny 规则(拒绝掉所有未明确授权的)
topic deny  read  spBv1.0/+/+/#       # 拒绝所有组的默认读
topic deny  write spBv1.0/+/+/#       # 拒绝所有组的默认写

# 再写 allow 规则(显式白名单)
topic write spBv1.0/group_plant1/NDATA/edge_gateway_01/+
topic read  spBv1.0/group_plant1/NCMD/edge_gateway_01/+

深坑 3:"反正内网没人能访问"的乐观心态

这是我在最多现场听到的话。但现实是:

  • 内部威胁:离职的前员工、被钓鱼的运维、不小心插错接口的施工队
  • 供应链攻击:PLC 固件更新包被篡改(还记得 SolarWinds 吗?)
  • 横向移动:攻击者通过 IT 层的 VPN 接入点进入内网,然后扫描 502 端口

2023 年 SANS 的 OT 安全报告 指出,67% 的 OT 攻击始于 IT 网络。防火墙两侧不是"安全"和"不安全"的问题,而是"相对不那么安全"和"相对更不安全"的问题。


7. 总结

从 Modbus TCP 的认证真空到 OPC UA 的安全模式代价,从 MQTT ACL 的细粒度设计到三层纵深防御架构,核心观点只有一个:

工业数据采集安全不是单独某个环节的工作,而是链路中每一层的职责。

协议/层 最低安全保障 推荐方案 额外成本
Modbus TCP DPI 网关 + VLAN + 写保护 一个网管型交换机 + 一台 DPI 代理
OPC UA SecurityMode=None SignAndEncrypt 连接建立增加 10-15ms
MQTT 用户名/密码 TLS v1.3 + 证书认证 证书管理维护成本
网络层 物理隔离 工业防火墙 DPI + 802.1X 硬件采购成本
运维层 无审计 操作日志 + IDS 告警 SIEM 系统集成

最后送你一个自检清单,对照你的采集链路逐项检查:

  • PLC 的 Modbus TCP 502 端口是否只能从 DMZ 网关访问?
  • 是否封禁了 FC=05/06/15/16 等写操作功能码?
  • OPC UA 是否强制 SignAndEncrypt(而不是 None)?
  • MQTT Broker 是否关闭了 allow_anonymous
  • 边缘网关的 MQTT 客户端是否使用证书认证?
  • ACL 是否遵循"明确拒绝 + 白名单"的模式?
  • 第三方设备的网络接入是否有审批和监控?
  • 是否有 Modbus/OPC UA 流量的异常检测和告警?

如果全部打勾,你的采集链路在 OT 领域已经属于前 10% 的安全水平。如果还有没做到的,不妨从成本最低的一项开始------至少先把 OPC UA 的 SecurityModeNone 改成 Sign。Change is cheap.


👉 下一篇预告:PLC 数采系列 9 高速采集与时间序列------当采集间隔从秒级进入毫秒级 前 8 篇我们都在讨论"按需采集"(秒级到分钟级),但有些场景根本不能用这种模式------比如振动分析(10kHz+)、电能质量监测(256 点/周期)、高速包装线(毫秒级动作)。下一篇深入:高速数据采集的缓冲区设计、时间序列的压缩算法(旋转门算法 vs 死区压缩)、以及如何在边缘端做实时特征提取而不是全量上报。准备好告别 time.sleep(1) 了吗?

相关推荐
楼田莉子1 小时前
C++20新特性:协程
开发语言·c++·后端·学习·c++20
元宝骑士2 小时前
SpringBoot + Sa-Token 实现 CSRF 令牌校验(进阶篇)
后端·安全
Full Stack Developme2 小时前
AspectJ 详解
java·后端
武子康2 小时前
Java-20 深入浅出 MyBatis - 手写ORM框架1 从原始 JDBC 暴露的 6 大问题开始
java·后端
雪隐2 小时前
AI股票小助手06-Backtrader 量化回测
人工智能·后端
设计师小聂!2 小时前
Java异常处理
java·开发语言·后端·编辑器·idea
ihuyigui2 小时前
国际商超零售短信接口
大数据·前端·后端·架构·零售
SimonKing2 小时前
实用,DynamicTP进阶之数据采集与告警
java·后端·程序员
用户298698530142 小时前
Java 进阶:基于模板生成 Word 文档的实践思路
java·后端