CAN 总线安全加固实战:报文注入防御与 OBD 加固

CAN 总线安全的难点在于协议本身没有认证:总线上任何节点广播的报文,其它节点都会照单全收。这意味着一次报文注入就可能覆盖刹车、转向或车门控制信号。本文从实验室复现出发,给出 CAN 总线安全加固的四层配置——接收侧内核过滤、白名单转发网关、报文频率异常检测、UDS 诊断服务白名单,所有代码均可直接在 SocketCAN 环境执行。

适用场景

  • 整车厂或 Tier1 在做零部件渗透测试前的基线核查;
  • T-Box、车载网关、域控制器的固件开发与出厂安全配置;
  • 车联网平台侧对车辆上报数据的完整性校验设计;
  • 售后诊断设备(OBD 掌机、诊断仪)接入企业车队前的准入评估;
  • 使用 vcan 虚拟总线做安全测试环境搭建,无需实车。

需要说明的是,本文所有注入操作均在 vcan 虚拟总线或台架环境 完成,不涉及实车道路测试;实车测试必须由具备资质的团队在封闭场地进行。

前置条件

  • Linux 主机(内核 4.19+,已编译 can、can_raw、vcan 模块);
  • 安装 can-utils 与 python-can:apt-get install -y can-utils python3-pip && pip3 install python-can;
  • 若要接真实总线,需要 USB-CAN 分析仪(如 PEAK PCAN-USB、Kvaser)并确认驱动加载;
  • 已拿到目标车型的 DBC 文件,知道各报文 ID 与信号布局;
  • root 权限(配置网络设备与内核模块需要)。

原理说明:CAN 的四个先天缺陷

无认证、无加密

CAN 帧只有 11 位(标准帧)或 29 位(扩展帧)仲裁 ID 和最多 8 字节数据,没有源地址字段,也没有任何签名或 MAC。接收方无法判断这一帧是 ECU 发的还是攻击者发的。

仲裁机制可被滥用

CAN 用 ID 数值做优先级仲裁,ID 越小优先级越高。攻击者只要持续发送 ID 更小的帧,就能在仲裁阶段一直获胜,把合法节点挤出发送窗口,形成拒绝服务。

广播总线没有隔离

传统 CAN 是一条共享总线,诊断口(OBD-II)直接挂在总线上时,插上诊断仪就等于接入全部报文。OBD 转 Wi-Fi/蓝牙的第三方设备把这个暴露面进一步放大到无线侧。

诊断服务默认信任

UDS(ISO 14229)的服务 0x2E 写数据、0x31 例程控制、0x34-0x37 传输数据,配合 0x27 安全访问就能刷写 ECU。而 0x27 的种子-密钥算法在不少车型上被逆向公开,安全访问形同虚设。

操作步骤

第一步:搭建可复现的虚拟总线

# 加载虚拟 CAN 模块并创建 vcan0
sudo modprobe vcan
sudo ip link add dev vcan0 type vcan
sudo ip link set up vcan0
ip -details link show vcan0

# 接真实总线时按车型波特率配置(常见 500 kbps)
sudo ip link set can0 type can bitrate 500000 restart-ms 100
sudo ip link set up can0
ip -details -statistics link show can0

restart-ms 100 表示总线关闭(Bus-Off)后自动恢复的等待时间,台架调试建议加上;生产环境网关要评估自动恢复是否会让异常状态被掩盖。

第二步:复现报文注入与重放

# 终端 A:抓包并落盘,便于比对注入前后差异
candump -L vcan0

# 终端 B:注入一帧,ID 0x123,8 字节数据
cansend vcan0 123#DEADBEEF00112233

# 重放攻击:把录制到的报文按原序发回,无需知道信号含义
canplayer -I candump-2026-10-22.log -l 1

这一步验证的是「不掌握 DBC 也能攻击」:攻击者只需录一段正常行车报文,在目标时刻重放,就能伪造状态。防御因此不能只依赖报文内容合法性,必须叠加来源与频率约束。

第三步:接收侧内核过滤

SocketCAN 支持在绑定套接字时下推过滤器到内核,非目标 ID 的帧不会进入用户态,既省 CPU 又能第一层削减注入面。python-can 写法:

import can

bus = can.Bus(
    interface="socketcan",
    channel="can0",
    can_filters=[
        {"can_id": 0x100, "can_mask": 0x7FF, "extended": False},
        {"can_id": 0x2A0, "can_mask": 0x7FF, "extended": False},
    ],
)

for msg in bus:
    print(f"id=0x{msg.arbitration_id:03X} data={msg.data.hex()}")

can_mask=0x7FF 表示 11 位 ID 全匹配。只关心某一组 ID 时可以用掩码放宽,例如 can_mask=0x700 可以匹配 0x1xx 整段。

第四步:白名单转发网关

内核过滤只管「收」,管不了「发」。真正的隔离要在两段总线之间加一层白名单网关,只转发 DBC 中定义过的 ID:

import can

IN_CH, OUT_CH = "can0", "can1"
ALLOWED_IDS = {0x100, 0x101, 0x200, 0x2A0, 0x3C0}
MAX_DLC = 8

bus_in = can.Bus(interface="socketcan", channel=IN_CH)
bus_out = can.Bus(interface="socketcan", channel=OUT_CH)

for msg in bus_in:
    if msg.is_error_frame or msg.is_remote_frame:
        continue
    if msg.arbitration_id not in ALLOWED_IDS:
        print(f"DROP id=0x{msg.arbitration_id:03X} len={msg.dlc}")
        continue
    if msg.dlc > MAX_DLC:
        print(f"DROP oversize id=0x{msg.arbitration_id:03X}")
        continue
    bus_out.send(can.Message(
        arbitration_id=msg.arbitration_id,
        data=msg.data,
        is_extended_id=False,
    ))

真正上车时这层逻辑应落在网关 MCU 里,而不是 Linux 用户态;本文的 Python 版本作用是快速验证白名单集合是否正确,以及给台架测试提供可观测的丢弃日志。

第五步:报文频率异常检测

周期型报文在 DBC 里都有标称周期(如 10ms、100ms)。注入与重放会导致某个 ID 的频率显著偏离。下面脚本按秒统计,超阈值即告警:

import time
import collections
from can import Bus

LIMIT_PER_SEC = 50          # 单 ID 每秒上限,按 DBC 周期留 2 倍余量
bus = Bus(interface="socketcan", channel="can0")
window = collections.Counter()
last = time.time()

while True:
    msg = bus.recv(timeout=0.05)
    now = time.time()
    if now - last >= 1.0:
        for cid, cnt in window.items():
            if cnt > LIMIT_PER_SEC:
                print(f"ALERT flood id=0x{cid:03X} rate={cnt}/s")
        window.clear()
        last = now
    if msg is not None:
        window[msg.arbitration_id] += 1

阈值不要照抄,必须按 DBC 里每个 ID 的实际周期单独设定。100ms 周期报文正常约 10 帧/秒,一旦稳定跑到 200 帧/秒就基本可判定为注入或重放。

第六步:UDS 诊断服务白名单

诊断是权限最高的一类流量。网关侧应按 SID 做白名单,只放通读类服务,写与刷写类服务在非产线模式下直接丢弃:

ALLOWED_UDS = {0x10, 0x11, 0x19, 0x22, 0x27, 0x3E}   # 会话/复位/读DTC/读数据/安全访问/保持连接
BLOCKED_UDS = {0x2E, 0x2F, 0x31, 0x34, 0x35, 0x36, 0x37, 0x3D}

def allow_uds(data: bytes) -> bool:
    if not data:
        return False
    sid = data[0]
    if sid in BLOCKED_UDS:
        return False
    return sid in ALLOWED_UDS

配套的网络层收敛:OBD 口只允许诊断设备网段访问,DoIP(基于 IP 的诊断)端口 13400/TCP 在车外网侧必须关闭。

# 关闭车辆外网侧的 DoIP 入站
iptables -A INPUT -p tcp --dport 13400 -j DROP

# 诊断网段例外
iptables -I INPUT 1 -p tcp --dport 13400 -s 192.168.10.0/24 -j ACCEPT

第七步:OTA 固件签名与安全启动

刷写通道一旦被利用,前面所有报文级防御都失效。OTA 包必须携带签名并由引导程序验签:

# 发布侧:用 Ed25519 私钥对固件签名
openssl pkeyutl -sign -inkey private.pem -rawin   -in firmware_v1.2.3.bin -out firmware_v1.2.3.sig

# 车端:用内置公钥验签,失败则拒绝刷写
openssl pkeyutl -verify -pubin -inkey pub.pem -rawin   -in firmware_v1.2.3.bin -sigfile firmware_v1.2.3.sig

验签公钥应固化在安全启动链的只读分区,且与 OTA 服务端的签名私钥物理隔离。私钥一旦泄露,等于所有在网车辆都能被刷入任意固件。

配置验证

# 1) 确认 vcan/can 接口处于 UP 且参数正确
ip -details link show can0 | grep -E "state|bitrate|berr-counter"

# 2) 验证白名单网关:发送一个不在白名单的 ID,应打印 DROP
cansend can0 7FF#1122334455667788

# 3) 验证频率告警:循环发送同一 ID,观察 ALERT 输出
for i in $(seq 1 300); do cansend can0 100#0000000000000000; done

# 4) 验证内核过滤:只绑定 0x100 时,发送 0x101 不应被收到
candump -f 100:7FF,can0

四项都能得到预期输出,说明接收过滤、转发白名单与频率检测三条链路都已生效。补充一项:用 ip -statistics link show can0 观察 bus_error 与 error_warning 计数,注入压测后若错误计数飙升,说明总线负载已接近上限,需要重新评估带宽分配。

常见问题

Q1:没有实车,只做 vcan 实验有意义吗?

有意义。协议层的注入、重放、频率异常、白名单丢弃逻辑在 vcan 上行为与实车一致,可以把防御代码和阈值先用虚拟总线跑通,再上台架验证时序。实车阶段主要补的是物理层差异(终端电阻、线束干扰、Bus-Off 恢复)。

Q2:白名单网关会不会影响正常功能?

会,如果白名单是从 DBC 反推但漏了网关透传的诊断或多帧传输报文。上线前应做一段完整的行车工况录制(上电、怠速、行驶、停车、诊断),把所有出现过的 ID 跑一遍比对,把差异项逐个确认后补入白名单。建议先以「告警模式」运行一周,只记录不丢弃,确认无误后再切到阻断模式。

Q3:频率阈值应该怎么定?

按 DBC 标称周期的 1.5 到 2 倍定。周期 10ms 的报文正常约 100 帧/秒,阈值设 200;周期 100ms 的设 20 到 25。事件型报文(非周期)没有固定基线,改用「突发窗口内累计计数」判定,不要套用固定阈值。

总结

CAN 总线安全无法靠加密一步到位,现实路径是分层削减攻击面:接收侧用内核过滤器把无关 ID 挡在用户态之外,总线之间用白名单网关只转发 DBC 定义过的报文,监控侧用频率基线发现注入与重放,诊断侧用 UDS 服务白名单与端口收敛限制刷写能力,最后用固件签名与安全启动守住最后一道门。五层里任何一层单独使用都不够,组合起来才能把「接上 OBD 就能控制车辆」降级为「需要先突破网关与验签」。