有一次车间做季度核查,负责安全的那位同事拿了一台普通笔记本,接上工位旁的网络端口,打开一个现成的 Modbus 调试工具,输入几个寄存器地址,直接改了一条在产线的保持寄存器数值,操作台没有任何提示。我站在旁边看得后背发凉:从 1979 年诞生至今,Modbus 一直是工业现场最普及的通信协议,但它天生不带身份认证,不带加密,不带完整性校验。也就是从那一刻起,我决定把云原生的零信任身份框架 SPIFFE 从 Kubernetes 集群里搬出来,想办法装进这些土生土长的 Modbus 传感器链路里。这篇文章就是那次折腾的完整记录,包含架构设计、具体实装步骤和几个让我失眠的坑,给同样想在工控网络里落地零信任的人做个参考。
1. 为什么我会想把 SPIFFE 拖进 Modbus 这潭老水
1.1 Modbus 与生俱来的安全债
Modbus 的优点是极简:一个主站轮询,多个从站应答,读线圈用功能码 01,读保持寄存器用功能码 03,写单个寄存器用功能码 06,报文结构一目了然。但极简的另一面就是裸奔。Modbus TCP 默认跑在 502 端口,报文里只有事务标识符、协议标识符、长度、单元标识符和功能码,没有任何身份概念。你只要知道从站地址和寄存器地址,就能直接读写现场数据,甚至覆盖设定值。
有人说 OT 网络和 IT 网络之间有物理隔离,实际上这个隔离远没有想象中牢固。常见的情况是:某条产线为了远程诊断开了一个维护通道,供应商的工程师需要不定期接入查看参数;或者车间里临时拉了一个无线网关,用来收设备状态数据。更普遍的是,很多传感器带的 Web 配置页面或者串口转网口网关,用的还是默认登录凭据。攻击者根本不需要攻破防火墙,只要找到一个能通到设备网络的端口,就能用现成的调试工具伪装主站,把假数据写进从站。
这种情况下谈零信任,不是赶时髦,而是现实需求:我们没法继续依赖“内网就是安全的”这个假设,必须在每一次访问发生时确认“谁在访问、以什么身份访问、是否有权限访问这个具体寄存器”。
1.2 SPIFFE 到底在解决什么问题
SPIFFE 的全称是 Secure Production Identity Framework for Everyone,翻译成人话就是“一套给每段工作负载发统一身份的工作规范”。它定义了两样东西:一个是 SPIFFE ID,形态类似spiffe://trust-domain/path,用来唯一标识一段工作负载;另一个是 SVID,通常是 X.509 证书,是这段身份在实际通信时出示的凭证。SPIRE 是 SPIFFE 规范的开源参考实现,相当于发证机构加发证窗口:SPIRE Server 负责签发和管理证书,SPIRE Agent 部署在业务节点上,通过 Workload API 把证书交给真正想拿身份的进程。
可以这样理解:SPIFFE ID 是员工工号,SVID 是带照片和公钥的工牌,SPIRE 是行政部,Workload API 是行政部的窗口。进程启动后去窗口报上自己的特征(比如运行路径、系统账号、容器标签),窗口核实没问题,就递出一张工牌。这张工牌有时效,过期了要再领。
这套机制在云原生环境里已经很成熟,微服务之间互相调用都靠它做 mTLS 认证和细粒度授权。但客户现场不是 Kubernetes,是 PLC、传感器、变送器和一条条串口总线。我当时的想法是:身份框架本身不挑环境,关键在于怎么让身份和这些笨重的工业设备产生关联。
2. 传感器不装 Agent:身份网关的落地思路
2.1 直接改固件的三条死路
最开始我的第一个念头是把 SPIRE Agent 直接塞进传感器。调研一圈之后放弃,原因有三。一是除了少数高端智能传感器,大部分变送器、智能仪表用的 MCU 主频只有几十兆赫,Flash 空间几百 KB,堆栈小得可怜,根本跑不动标准 SPIRE Agent 和 TLS 协议栈。二是现场设备的实时性要求很高,Modbus 轮询周期有的要 100ms 以内,加了加解密、证书校验这些重逻辑,响应时序立刻会乱。三是现场传感器型号庞杂,光我负责的这条线就有三种品牌、七种型号,连通信波特率遗留配置都不一样,挨个改固件不现实。
结论很清晰:不要把身份装进传感器,要让身份“罩”在传感器外面。
2.2 最终架构:双落地网关
我最后采用的方案是在通信链路两端分别落一个代理,中间跑 mTLS,两端对原系统透明。整体结构是这样:
- 左侧接入侧:SCADA 上位机、维护终端、数据采集程序,全部连本机的“可信代理”。这个代理进程向 SPIRE Agent 申请 SVID,并监听 502 端口,模拟一个 Modbus TCP 主站。
- 右侧现场侧:靠近传感器的位置部署一个边缘身份网关,进程同样持有自己的 SVID,负责接收左侧经过 mTLS 加密的流量,验明对方身份后,把请求还原成标准 Modbus TCP 帧,再发给下游传感器。
- SPIRE Server 放在管理区,只负责发证、吊销和策略下发,不参与业务数据通路。
这样设计的好处很明显:传感器侧完全无感,固件不用动,寄存器地址不用动,波特率不用动,原有轮询逻辑照跑。上位机侧多数情况下也不用改代码,只需要把它的目标 IP 从“传感器网关”改成“本地代理”,剩下的由代理处理。
2.3 身份的“粒度”怎么定
有了网关,还需要确定一件事:身份粒度。是一台网关一个身份,还是网关下面每个传感器都有自己的身份?答案是后者,否则“白名单控制到具体寄存器”就无从谈起。
我的做法是使用“物理槽位 + 功能”来定义身份。比如一个边缘网关上有四个串口槽位或四个网络通道,每个槽位下面挂一台温度变送器,那么每个槽位注册成一个独立的 SPIFFE ID。这样换传感器时,只要槽位不变,身份就不变;传感器挂了直接换新,也不用重新改上层白名单。
确定粒度之后,我建了一张映射表,把 Modbus 里的从站地址、寄存器区间和 SPIFFE ID 绑在一起。这张表就是整个零信任体系的资产台账,后面所有的授权规则都基于它。
3. 身份注册与 Modbus 地址映射表的设计
3.1 SPIFFE ID 命名就是资产台账
SPIFFE ID 的 Path 部分没有强制规则,但这恰恰是最需要动脑的地方。它一旦定下来,会出现在证书、审计日志、授权策略里,相当于资产的永久门牌号。我用了“工厂站点 / 产线 / 网关 / 槽位 / 功能”这个模式:
spiffe://plant-a/line-1/gw-01/slot-03/temperaturespiffe://plant-a/line-1/gw-01/slot-05/pressure
对应的物理信息在映射表中维护:
| SPIFFE ID | 网关编号 | 槽位 | 从站地址 | 寄存器区间 | 采集对象 |
|---|---|---|---|---|---|
| spiffe://plant-a/line-1/gw-01/slot-03/temperature | GW-01 | 串口3 | 3 | 40001-40010 | 循环水温度 |
| spiffe://plant-a/line-1/gw-01/slot-05/pressure | GW-01 | 网口2 | 7 | 30001-30020 | 液压站压力 |
| spiffe://plant-a/line-1/gw-01/slot-02/motor-status | GW-01 | 串口2 | 2 | 00001-00016 | 主电机运行状态 |
这里有个细节要注意:同一个物理设备如果既提供数值又提供状态,可以拆成两个身份,也可以合并成一个身份再在授权规则里区分寄存器区间。我倾向于合并成一个身份、用寄存器区间做权限细分,因为身份太多会让授权表膨胀,而且现场排查问题时,一张工牌对应一个设备最直观。
3.2 用 SPIRE Registration Entry 把“槽位”变成“身份”
注册身份在 SPIRE 里对应的操作是创建 Registration Entry。它的作用是把一组“环境特征”映射到一个 SPIFFE ID。环境特征叫 Selector,SPIRE Agent 支持进程路径、运行用户、容器镜像、系统属性等。因为我的身份网关是为每个槽位分别启动一个小进程的,我用了unix:path作为 Selector,区分起来非常干净。
注册槽位 3 温度身份的示例命令是这样的:
spire-server entry create \\ -spiffeID spiffe://plant-a/line-1/gw-01/slot-03/temperature \\ -parentID spiffe://plant-a/edge/gw-01 \\ -selector unix:path:/opt/edge-gateway/proxies/slot-03 \\ -selector unix:uid:498 \\ -ttl 3600-parentID表示这个身份的父级是网关本身,-selector是 Agent 在发证前用来匹配进程的特征,-ttl是 SVID 的寿命,我一开始设了 3600 秒,后面踩坑才发现这个参数不能拍脑袋定,细节放到第 5 节讲。
3.3 访问控制策略怎么做
有了身份之后,接下来就是把“谁可以访问谁”的规则落下来。我在边缘网关的代理进程里内置了一张 ACL 表,字段包括:请求方 SPIFFE ID、目标 SPIFFE ID、允许的功能码、允许的寄存器区间。
举几个典型规则:
| 请求方身份 | 目标身份 | 允许操作 | 说明 |
|---|---|---|---|
| spiffe://plant-a/scada/rtu-01 | spiffe://plant-a/line-1/gw-01/slot-03/temperature | 03(读寄存器) | 只读温度值 |
| spiffe://plant-a/scada/rtu-01 | spiffe://plant-a/line-1/gw-01/slot-02/motor-status | 01 / 03 | 只读状态 |
| spiffe://plant-a/engineer/laptop-zhang | spiffe://plant-a/line-1/gw-01/conf | 03 / 06 | 工程师可读可写架构参数 |
| spiffe://plant-a/etl/pi-archiver | spiffe://plant-a/line-1/gw-01/* | 03 / 04 | 数据归档服务只读所有采集量 |
网关代理收到请求后先做两步:第一步,从 mTLS 会话里取出对方的 SVID,解析出 SPIFFE ID;第二步,拿这个 ID 去查 ACL,如果目标身份匹配、功能码匹配、寄存器区间匹配,才把请求转发给底层传感器。任何一步不匹配,就直接返回异常响应并记录审计日志。这套逻辑比我原来预想的要简单,但效果很直接:就算有人拿到了网络访问权,没有合法身份也构造不出一条能被网关接受的 Modbus 请求。
4. 从零实装:SPIRE Server、Agent 与透明代理部署记录
4.1 部署 SPIRE Server
SPIRE Server 是整套体系里最敏感的组件,它掌握着整个信任域的根私钥。我在部署时就定了两条硬规矩:Server 所在的机器不能出外网,只开放必要的 8081 端口给 Agent 注册用;Server 的数据库和备钥文件要单独备份,不能和其他运维数据混在一起。
部署用的是官方提供的发布包,配置文件大致如下:
server { bind_address = "0.0.0.0" bind_port = "8081" socket_path = "/run/spire/server.sock" trust_domain = "plant-a" data_dir = "/var/lib/spire" log_level = "INFO" ca_ttl = "24h" default_x509_svid_ttl = "1h" }trust_domain是身份域的名字,所有 SPIFFE ID 的第一段都来自这里。ca_ttl是根 CA 证书的寿命,default_x509_svid_ttl是默认工作负载证书的寿命,这两个参数建议在测试环境先跑通再上生产,不要直接采用我给的值。
启动好 Server 后,要生成一个用于节点接入的 Join Token。这一步相当于给新增的边缘网关发一张“临时入场券”,网关注册完成后入场券立即作废:
spire-server token generate -spiffeID spiffe://plant-a/edge/gw-01 -ttl 6004.2 边缘网关上的 Agent 安装
边缘网关本身是一台小型工控机,装了精简版系统,上面只跑三个进程:SPIRE Agent、槽位代理进程组、日志转发。Agent 的配置文件核心是这样:
agent { trust_domain = "plant-a" server_address = "10.10.20.50" server_port = "8081" socket_path = "/run/spire/agent.sock" insecure_bootstrap = false }安装完成后,用上面生成的 Join Token 启动 Agent。启动成功后,Agent 会持续监听/run/spire/agent.sock。需要身份的下游进程到这个 socket 上申请 SVID。这个设计让身份申请和业务进程解耦:槽位代理进程即使崩溃重启,SPIRE Agent 也会在它恢复后重新发证,不会出现身份永久丢失的情况。
有个容易疏忽的点:边缘网关如果存在双网卡,所有 SPIRE 相关流量和 Modbus 业务流量最好走不同的网络接口,或者至少做路由隔离。否则一旦某个传感器网络被渗透,攻击者顺着流量就能摸到 SPIRE Server 的地址,等于把发证中心暴露在最前线。
4.3 上位机侧的透明代理改造
现场的上位机情况很杂,有的 SCADA 系统是组态软件,配置了自动化厂商提供的驱动,没法轻易改代码;有的只是自己写的 Python 采集脚本。我的处理方案是统一放一个透明代理,让上位机把本地代理当成“传感器网关”。
透明代理的核心逻辑很简单,用伪代码表示就是:
# 简化版透明代理逻辑 while True: conn = listen(port=502) # 监听本地 502 端口 target_id = lookup_route(conn) # 根据目标地址找到对应的 SPIFFE ID svid = get_svid(target_id) # 从 SPIRE Agent 申请目标身份的 SVID tls_conn = mTLS_connect(edge_gateway, svid) # 建立双向 TLS modbus_frame = read_modbus_request(conn) tls_conn.send(modbus_frame) response = tls_conn.recv() conn.send(to_modbus_tcp(response))这个代理本身不解析 Modbus 报文的具体业务字段,只是照原样转发。真正做身份校验和功能码过滤的,是对端的边缘网关。代理只需要把 SVID 管好,确保自己是以“SCADA 数据采集程序”的身份出现,而不是以“工程师笔记本”的身份出现。
如果上位机使用固定 IP,也可以让 SPIRE Agent 通过ip或macSelector 识别这个代理进程,省掉改代码的麻烦。但我在实践里强烈推荐用unix:path这类进程特征,因为 IP 在 OT 环境里也经常被调整,进程路径稳定得多。
4.4 证书轮换与长连接处理
SPIRE 默认的 SVID 是短寿命证书,到期后需要重新申请。对 HTTP 这种短连接协议来说无所谓,但 Modbus TCP 的很多采集链路是长连接,上位机一开就是好几天。如果不处理轮换,就会出现一个经典故障:证书在半夜 3 点过期,SPIRE Agent 拿到了新证书,但已建立的 mTLS 连接里还握着旧证书,对端验证失败后就地断开,SCADA 上表现为号站、通讯故障、数据瞬间变灰。
我最后用了一个比较土但很有效的方案:在网关和代理两侧都设置一个“证书到期前 30 秒主动断开所有长连接”的定时任务,断开后由协议栈自动重连,新连接自然使用新证书。为了避免断开瞬间采集数据丢失,我在代理层加了一个 200ms 的重试等待,让轮询周期自动覆盖这个空档。实测下来整体影响很小,但确实要把这个逻辑提前写进验收标准,不要指望协议本身做优雅热升级。
5. 现场踩过的坑:证书轮换、看门狗与 NTP
5.1 长连接断线:SVID 轮换导致的“隐性掉线”
第一个大问题就是上面说的长连接轮换。上线跑了一个星期,每隔几小时就有人在群里报“某设备掉线”,等我跑过去时数据又恢复了,查 SPIRE 日志才发现是证书到了 TTL。我当时第一反应是调大 TTL,比如直接改成 24 小时。但短证书才是安全性的基础,调大 TTL 等于自己削弱防护。
最后我结合现场实际情况,把 TTL 定在 4 小时,并且在两侧加了主动断线重连逻辑。修改后轮换时有个短暂的空隙,但数据链路会在几秒内恢复,不再影响监控。这个值不是通用的,如果现场设备较多、关键程度高,建议先做压测再定 TTL。
5.2 PLC 从站把身份握手的时延当成了通信故障
第二个坑和时序有关。现场有一台比较老的 PLC 从站,它的通信看门狗设置得很紧:主站在规定时间内没有发送合法请求,从站就判定链路故障,把输出强制切到安全状态。加入 mTLS 后,每次新连接建立都需要一次完整的 TLS 握手,握手本身要消耗几十毫秒。在某些极端情况下,比如网关同时收到多个从站的新连接请求,握手排队,导致某个从站在看门狗窗口内没等到任何请求,触发了保护逻辑。
这个问题的解决思路有两个,我两个都做了。一是把访问侧到边缘网关之间的连接做成“连接池”,代理启动时一次性建立多条 mTLS 长连接,日常轮询直接复用,避免频繁握手。二是把 PLC 从站的看门狗参数从 500ms 放宽到 1.2s,这个参数改动会经过设备工程师的整体评估,不是我自己拍脑袋定的。两者叠加后,通信超时的报警彻底消失了。
5.3 时间漂移让证书验证直接失败
最隐蔽的坑是时间同步。X.509 证书的有效期验证要求双方时间必须可信,而工厂现场设备通常没有统一的时钟源。有一台边缘网关用的工控机,主板电池没电后时间回退到了 2020 年,SPIRE Agent 向 Server 申请证书时时间对不上,证不成功,整个槽位所有设备全部离线。
排查过程很有意思,一开始根本没想到是时间问题,因为表面现象是“某个槽位全部传感器连不上”,看起来像通信模块坏了。后来看 Agent 日志才发现全是证书有效期错误。解决办法是在工厂网络里专门搭了一台本地 NTP 服务器,把边缘网关、SPIRE Server、访问侧代理全部加入同步域。这里特别提醒一句:NTP 服务器本身的时间要可靠,我的做法是让它对接上层授时源,同时保留手动校时的能力,防止整个网络的时间一起跑偏。
5.4 面板小习惯:串口网关的 Web 页面成了后门
最后一个坑和 SPIFFE 没有直接关系,但在现场太常见了,值得一说。很多串口网关、协议转换器出厂自带一个 Web 管理页面,默认地址、默认登录凭据,甚至允许直接从外网访问。这些设备的固件通常常年不更新,一旦暴露,攻击者能直接管理底层串口通道,绕过我辛辛苦苦搭的 mTLS 链路。
我在盘点完现场所有转换器后,把凡是支持本地配置页面的设备全部改成了“仅特定管理网段可访问”,并把默认登录名和口令全部改掉,存入了密码保险箱。这件事看起来很小,但如果不处理,等于身份体系外侧开了一扇后门,前面所有工作都白费。
6. 实测效果:攻击面收敛了多少
6.1 改造前后的访问链路对比
项目上线后,我拉着同事做了一轮对照测试,结论很明显:
| 对比项 | 改造前 | 改造后 |
|---|---|---|
| 通信加密 | 无,明文 Modbus TCP | mTLS 加密传输 |
| 身份识别 | 无,谁都能连 | SPIFFE ID 双向认证 |
| 访问控制 | 无,能通网就能读写 | 按功能码 + 寄存器区间白名单 |
| 审计 | 无,出事很难定位 | 每次访问都有身份和时间记录 |
| 默认端口暴露 | 502 端口全网可扫 | 502 只对本地代理开放 |
最直观的一点是扫描结果:改造前,在管理网段扫一圈能直接看到十几个 502 端口;改造后,外部视角下这些端口全部消失,只剩边缘网关暴露的单一接入点。而且就算有人摸到这个接入点,没有合法 SVID,TLS 握手阶段就会被拒绝。
6.2 性能开销与稳定性数据
性能是工厂最关心的问题。我记录了连续两周的数据:一次完整 mTLS 握手平均耗时约 12ms,日常轮询走的是复用后的长连接,业务请求的额外延迟在 3% 以内,基本可以忽略。证书轮换期间出现过极短暂的断连窗口,但因为有主动重连机制,单次恢复时间都在 2 秒以内,没有发生过超过 5 秒的采集中断。
稳定性方面,最开始的版本有一个致命问题:SPIRE Agent 占用内存暴涨,运行三天后就把边缘网关的内存吃光了。排查后发现是 Agent 内部默认开启了比较激进的工作负载缓存,槽位多的情况下内存占用会翻倍。我调整配置把逐槽位连接回收周期调短,内存稳定在了一个可接受的水平。
6.3 对现有运维习惯的影响
任何安全改造都会影响原有运维习惯,这一关躲不过去。供应商远程维护以前是拨号或者专线直连,现在必须走维护人员专用通道,先申请短期 SVID,用完之后自动失效。现场的仪表工换传感器时,以前是插上就用,现在需要在边缘网关上执行一条“槽位重新认领”的命令,把新设备的物理地址绑定到原 SPIFFE ID 上。
这条命令我做得尽量简单,因为一线操作人员不会关心什么身份框架,他们的诉求就一句话:快、别让产线停。我把认领做成了一个交互脚本,工人只需要输入槽位号和设备编号,脚本自动完成地址绑定和身份关联。尽量降低操作复杂度,比任何安全培训都管用。
7. 这件事给我留下的几个后遗症
项目收尾后,我再看到任何工业协议,第一反应都是“它的身份边界在哪里”。Modbus 没有身份,OPC UA 有但配置复杂,MQTT 靠用户名密码,BACnet 干脆默认裸奔——每个协议的安全边界都不一样,零信任落地不是买一套产品,而是先把身份和物理资产的映射关系彻底想清楚。
我现在的习惯是,在项目一开始就会问一句话:这个设备到底是谁的,谁能碰它,碰它的哪一部分。以前这个问题没人答得上来,而现在我们可以指着 SPIFFE ID 说,它的身份在注册表里,它的权限在 ACL 里,它的一举一动在日志里。这套组合拳打完,我心里踏实了不少。如果后面有机会,我打算把同样的思路往 OPC UA 和 MQTT-SN 方向延伸,因为工厂里越来越多的传感器开始走这两条路,身份混乱的问题只会更严重。