起因是网络安全课的一个 extra-credit 作业,要求看一个安全相关的 talk 写 reflection。我捡了 37C3 的 Breaking “DRM” in Polish Trains,看完顺手把 38C3 的 follow-up 也补了。reflection 已经交上去了,这里把整件事里最值得留下的部分单独写一下 —— 厂商干了什么,在 talk 里讲过太多;真正有工程价值的是他们怎么把这套东西挖出来的。
事件本身的背景一句话讲完
接下来几节按调查的实际顺序走:从他们拿到一辆”软件全绿但不动”的车开始,到怎么把固件拿出来、怎么读懂、怎么定位锁。
起点 全绿的车开不了
SPS Mińsk Mazowiecki 是个第三方车间,接了一辆 Newag Impuls 上来。所有 hardware-level 排查都过了,司机推油门 —— brake 释放、HMI 报告一切正常、四个 inverter 拒绝出力、车纹丝不动。
车间排到没招了之后,经理 Google 了一下 “polish hackers”,翻到一篇 Dragon Sector 的访谈,直接发了封冷邮件过去。这是 2022 年的事。
整车是分布式系统,五条 CAN bus 跑 CANopen。CANopen 在 CAN 之上加了一层对象字典 —— 把 device 的状态变量 / 控制变量映射成 typed 的 object dictionary entry,所有节点共享一份命名空间。这层是后面调查的入口。
第一步:在工作正常的车和卡死的车之间 diff CAN 流量。差异很干净 —— PLC 发给 inverter 的那帧里,4 个 enable bit 全是 0,功率字段也是 0。上游所有传感器都报绿,但 PLC 主动发了”别跑”。问题被锁死在 PLC 固件里。
第一关:固件怎么 dump 出来
PLC 是 Selectron CPU831,基于 Infineon TriCore TC1130。开发用 IEC 61131-3(Structured Text + Function Block Diagram),IDE 叫 CAP1131 —— Selectron 自家的。代码在 IDE 里写,编译成 C,再编译成 TriCore 二进制,通过 UDP 或 RS-232 烧到 PLC 上。
CAP1131 没有”从 PLC 读取程序”的按钮。Selectron 的设计意图是合理的:工程师从 IDE 把程序烧上去,以后只在 IDE 里改,PLC 上跑的就当成黑盒。
但 CAP 自带一个调试子系统叫 CISM。开发用,不是面向最终运维的。CISM 跑在 PLC 上,接受远程指令,提供的能力包括 —— 按地址范围读任意内存。
认证机制:静态用户名 + 静态密码。在老固件版本里改不掉。
只要你有这对凭证(它们能从 IDE 安装包里提到)、能连到 PLC 的调试端口,就能像 dd if=/dev/mem 一样把整个运行中的镜像端到端拽下来。Dragon Sector 自己写了个 CISM 客户端,把固件全拿了。
这一步是整个披露能成立的根本原因。如果 CISM 是动态凭证 / 一次一密 / 物理接入,故事到这里就停了 —— 没有固件就没有逆向,没有逆向就没有锁的证据,没有证据就只剩一辆莫名其妙不动的车和厂商一句”工艺老化”。
业界的同构问题非常多。任何工业设备上的 vendor debug channel,只要走静态凭证 + 任意读路径,本质都是一个未声明的后门 —— 区别只是面向谁。Newag 这次面向的是攻击者(他们自己)。下一次面向的可能就是别人。
第二关:逆向 TriCore + 一个叫 OCOPT 的全局数组
固件拿到了,塞进 Ghidra,然后撞墙。
TriCore 不是 x86,也不是 ARM,Ghidra 的支持远没有那么成熟。两个具体问题:
一、调用约定。TriCore 有两套独立的寄存器堆,数据寄存器(D0-D15)和地址寄存器(A0-A15)。函数参数按类型分配到不同的堆 —— int 走 D,指针走 A。Ghidra 默认的 calling convention 不知道这件事,导致每个函数入口的参数列表都对错。每个函数你都得手动核对。
二、指令模型错的。某些指令 Ghidra 实现得不对。Talk 里点名的是 nor 加 bit operand 的变种,反编译出来语义跟 CPU 实际行为不一样。这种错误特别坑 —— 程序不会 crash,反编译看起来跑通了,但结果是错的。
更深层的问题在编译器一侧。CAP 把 IEC 61131-3 程序编译成 C 之后再编译成 TriCore 二进制。它的编译模式有个特征:所有跨 function block 的数据流都走一个全局数组,叫 OCOPT,动态下标索引。
什么意思:在 IEC 61131-3 里你写 FB(function block)和它们之间的连线,正常的实现会编译成 struct + 函数参数,类型信息能保留。CAP 不这样 —— 它把所有 inter-block 的中间值都塞进 OCOPT[i],i 在运行时根据控制流计算。
后果OCOPT[34] = ...; OCOPT[57] = OCOPT[34] + ...,而看不到任何”FB A 的输出连到 FB B 的输入”的结构。所有类型信息被擦除,反编译器没法做 dataflow analysis,跨函数追变量基本失效。
修这玩意儿是大量 Ghidra 脚本工作。Talk 里没展开细节(后面我会提为什么),但能看出来他们建了一套 OCOPT-aware 的分析 pass,把动态下标在控制流里的实际值反推出来,重新建立 FB 之间的连接关系。这一步把不可读的反编译结果变回了大致能看的 FBD 图。
光做静态没用。他们另外买了一台同型号的 PLC 单独搁实验台上,用来:做差分实验、写 NVRAM 位、观察 PLC 行为变化、不影响任何在跑的实车。这是负责任的研究方法 —— 在你完全理解一个机制之前,绝不在生产车上动手。
第三关:跨版本 diff 把锁逼出来
有了 dump、有了能读的反编译,下一个问题是 —— 锁的代码在哪。
PLC 镜像不大,但完全人肉读不现实。突破口是规模:
- 30 辆车上拿到的固件
- 24 辆里有锁
- 26 个不同的 firmware 版本
锁逻辑会随时间演化(更多触发条件、更多条件组合、更新的 GPS 围栏坐标),它在不同版本之间的差异最大。所以做跨版本 binary diff,变化最频繁的 region 就是嫌疑区。这套思路很经典,但只在你有足够多样本时才管用 —— Newag 自己提供了样本,通过频繁更新固件这件事。
具体看到的特征:
- 接近 1,000,000 的常数 → odometer 阈值
- 阈值过线时 NVRAM 里有 bit 翻转
- 更多触发条件出现在新版本里 → NVRAM 里出现更多 bit
- 锁车和正常车上的 NVRAM 位状态不同
到这里反推机制
验证靠台架 PLC:把 NVRAM 对应位手动置位,看 PLC 输出 → inverter enable bit 变 0、compressor enable 线变低。锁机制确认。但没有人在生产车上动 NVRAM —— 先在台架上 100% 理解,再考虑下一步。
锁清单
发现的锁触发器一共这些(技术细节,排除法律 / 商业讨论):
- 空闲计时器。车必须连续 3 分钟 > 60 km/h,在一个滚动窗口内。窗口最初 10 天,被运营商抱怨后改 21 天。通勤车一天跑 20 小时,这个条件实际上只在车被拉去维修时才会触发。
- 地理围栏 + 空闲计时器。21 天版本只在硬编码 GPS 多边形内部生效。多边形坐标对应
Bydgoszcz、Bezza 在 Miłosna 的车间、SPS 计划在 Szadów 的厂、SPS Mińsk Mazowiecki、以及 Newag 自己的厂(带 debug 开关可以关掉)。其中一个多边形画歪了,圈到了一段在用线路 —— 后来一辆 Koleje Mazowieckie 载客车在 Miłosna 站附近就这么死了一次,这事是 38C3 才公开的。 - 组件序列号 attestation。CAN3 bus extender 和 WTB(wire train bus)适配器在启动时核对预期序列号。常规维修换一个就死。
- Inverter 固件版本钉。预期版本写死,不匹配就死。
- Odometer 一致性。多个设备上的里程读数差超过 100 km 就死。
- 硬编码日期。某辆车上有一段 21 November 2022 的日期比较,触发副气泵 logic bomb。比较实现里有 off-by-one,实际效果是”在 2100 年和 2121 年这种年份才会触发”。Talk 里把整段 FBD 走了一遍 —— 经过两个 latch、写到
nvram_lock_enabled,最后 gate 到 CAN I/O extender 上的 compressor enable 线。 - 解锁键码。司机面板上一组特定按键组合可以清掉所有 NVRAM 锁。Newag 在调查进行中静默删了这个键码,新版本换成”开驾驶舱门 + 按厕所 SOS 按钮”作为计数器复位。这是 38C3 才披露的新变种。
执行方式有个共同点:锁不报警。HMI 上一切正常。锁的实现方式是 gate output:
- PLC → inverter CAN 帧里 4 个 per-inverter enable bit 强制清零
- 同一帧的 “reserved / emergency stop” bit 强制清零
- 副气泵 enable 线拉低 —— 表面上以”compressor failure”形式出现
车间看到的就是司机推油门没反应,而所有诊断都没问题。
远程锁通道
至少一辆车上装了一个 UDP-to-CAN + Modbus-RTU 转换器。它把锁所在的 CAN3 桥接到车上一个本地 telematics 网络 —— 跑乘客信息系统、带 GSM modem。
至少有一次记录在案的锁触发,值是从这个转换器收到的。
转换器本身 37C3 时还没逆向完,38C3 也没补上。但意思已经摆在那:存在一条从蜂窝网络到 lock-relevant CAN ID 的可达路径。中间可能有 VPN,可能没有。具体的协议、认证机制、攻击面 —— 不知道。
这是整个披露里 network security 角度最吓人的部分。前面讲的所有锁都是 vendor 主动行为,但这条路径意味着锁是可以被实时触发的 —— 谁能触发、能不能 spoof、能不能 replay,Talk 都没回答。
归因 自己把 Newag 卖了
Newag 公开的说法是 logic bomb 是”黑客或者别的什么人”塞进去的。这套话不需要 Dragon Sector 反驳 —— CAP 工具链自己把它否定了。
每次 build 出来的 PLC 二进制里嵌了两个东西:
- 工程师 Windows 机器上的完整文件路径(类似
D:\Newag\Projects\...) - 编译时时间戳
每次 flash 操作 PLC 自己也会写一条 log entry,记到 PLC 内部存储。
这两份记录交叉一对,事情就清楚了:
- 哪些 build 写在哪些 PLC 上
- 写入时间是哪天
- build 那台机器属于哪台,在 Newag 内网
- 每一次 flash 都精确发生在车被送去第三方车间维修的前 1 到 3 天
这是免费的归因证据链。如果你想搞 vendor sabotage,起码不要让你的工具链把开发机的路径名嵌进二进制。
回到工程教训这一面。这次披露能跑通,链条是这样的:
CAN/CANopen 默认无认证(于是流量 diff 拿到第一个嫌疑) → CISM 静态凭证任意读(于是固件能 dump) → 26 个固件版本(于是 binary diff 能定位锁) → CAP 嵌入 build path(于是法庭归因成立) → Dragon Sector 自己买台架 PLC + 拒绝在生产车上做未理解的实验(于是能在不伤人的前提下推到底)。
任何一环换个设计,故事都讲不下去 —— 披露这件事天然脆弱。debug channel 用动态凭证、固件版本更新得不那么频繁、build 元数据不嵌路径,任何一项,这案子都会卡在某一步。Newag 没有隐藏得多好;是研究者在每一关都恰好有路可走。
工业控制系统普遍处于这个状态 —— 调试通道宽松、CAN 总线开放、build pipeline 把开发机信息嵌进 artifact —— 这套生态原本是为了让现场工程师能修问题。到 vendor 把它当 DRM 用的时候,同样的设计变成了揭穿 vendor 的工具。
研究者把完整技术报告故意压着没发,等诉讼。Talk 里你看不到 OCOPT 的具体重建脚本、CISM 协议的精确握手、UDP-to-CAN 转换器的逆向。这些短期内不会公开,因为发出去就是给 Newag 律师送弹药。这种基于策略性沉默的披露姿态本身也是这个案子的一部分。
至于诉讼那一面 —— 38C3 之后到 2026 年 5 月又有一些更新,跟技术无关,我作业 PDF 里写了,这里就不展开了。
Talks
- 37C3 Breaking “DRM” in Polish Trains(2023-12,~62 min): https://www.youtube.com/watch?v=XrlrbfGZo2k
- 38C3 We’ve not been trained for this: life after the Newag DRM disclosure(2024-12,~44 min): https://www.youtube.com/watch?v=8OB2NqcSDXQ
- 原始披露
@ck Warsaw,2023-12-05