1. 从一次真实的ECU被刷写事件说起
前两年帮一个做Tier1的朋友排查过一个问题:他们给某主机厂供的网关控制器,在售后市场被人用非官方的诊断工具刷进了一段未授权的固件,导致车辆在特定工况下会偶发性地丢失CAN报文。事后复盘,根因其实不复杂——Bootloader阶段没有做固件的完整性和来源校验,只要诊断请求的Seed/Key算对了,任何二进制都能写进去。这件事之后,他们整个平台把安全启动(Secure Boot)列成了一级需求,MCU选型也全面转向了带HSM(Hardware Security Module)的型号。
这个场景在汽车电子行业里其实非常典型。只要你的ECU有OTA能力、有诊断刷写接口、有外部通信通道(CAN、CAN-FD、以太网),它就天然暴露在一个可以被物理或远程访问的攻击面上。安全启动要解决的核心问题就一句话:MCU从上电到跳转到应用代码之前,必须逐级验证每一段要执行的代码是不是我授权的、有没有被改过一个字节。而HSM加CMAC,是目前在成本、实时性和安全性之间平衡得比较好的一套工程方案。
这篇文章面向的是做汽车电子MCU底层开发、Bootloader开发、信息安全相关的工程师,尤其是正在用英飞凌TC3xx、NXP S32K3、瑞萨RH850这类带HSM核的芯片做项目的同行。我会把安全启动的完整链路拆开讲:从信任根怎么建立、CMAC为什么适合做这件事、HSM在里面扮演什么角色,一直到具体的分区设计、密钥管理和实测踩坑记录。代码层面我会用贴近AUTOSAR和HSM固件接口的伪代码来示意,重点是让你看完能直接对着自己的项目做方案设计。
2. 安全启动到底在防什么,为什么非要用HSM
2.1 威胁模型:不是所有篡改都来自黑客
很多人一提到安全启动就想到远程攻击,其实在汽车电子里,物理接触式的篡改反而更常见。我梳理过几类实际遇到过的威胁场景:
- 售后刷写非授权固件:第三方维修点用破解的诊断工具刷入修改过标定的固件,绕过排放或性能限制。
- 产线误刷:产线工装夹具出错,把A项目的固件刷进了B项目的ECU,如果没有校验,车就带着错误代码出厂了。
- 供应链投毒:某个中间环节的固件被替换,这种最隐蔽,靠人工review根本发现不了。
- 回滚攻击:把已经修复了漏洞的固件版本,替换回有漏洞的旧版本,利用旧版本的已知缺陷。
这四类威胁有一个共同点:攻击者最终都要让MCU执行一段非预期的代码。安全启动的防线就设在这个执行入口上,只要验证不过,就不跳转,直接停在Bootloader或者进入安全状态。
2.2 为什么是CMAC而不是简单的CRC或哈希
这里要解释一个很多人会问的问题:我算个CRC32或者SHA256哈希不就行了吗,为什么要用CMAC?
CRC的问题最直接——它是线性的,攻击者可以构造两个不同的固件,让它们的CRC值相同,这叫CRC碰撞,构造难度极低。哈希(比如SHA256)解决了碰撞问题,但它只保证完整性,不保证来源。也就是说,任何人只要改了固件,重新算一遍SHA256附在后面,校验照样通过。哈希本身不带密钥,谁都能算。
CMAC(Cipher-based Message Authentication Code)是基于分组密码(通常是AES)的消息认证码。它有两个关键特性:第一,带密钥,只有持有正确密钥的一方才能生成合法的CMAC值;第二,抗伪造,没有密钥的情况下,即使知道明文和对应的CMAC,也无法为新的明文生成合法CMAC。这就同时解决了完整性和来源认证两个问题。
在汽车MCU上选CMAC而不是HMAC-SHA256,还有一个很现实的工程原因:HSM核里通常内置了AES硬件加速器,但没有SHA加速器。AES-128的CMAC在硬件加速下,吞吐量可以做到几十MB/s,而软件跑SHA256可能只有几百KB/s。对于一个2MB的Application固件,CMAC校验可能只要几十毫秒,SHA256软件实现要好几秒,这在Bootloader的启动时间预算里是不可接受的。
2.3 HSM在启动链路里的位置
HSM不是一个软件模块,它是MCU内部一个独立的核,有自己的CPU、RAM、Flash和加密外设,物理上和主核(Host Core)隔离。这种隔离带来两个好处:
一是密钥安全。CMAC的密钥存在HSM的密钥存储区里,主核通过HSM接口请求计算,但永远读不到密钥本身。即使主核的固件被攻破,密钥也不会泄露。
二是信任根独立。HSM的固件和配置在芯片出厂或产线阶段就固化,主核无法修改HSM的代码。这样HSM就成了整个信任链的锚点(Root of Trust)。
典型的启动流程是这样的:上电后HSM先启动,自检自己的固件完整性;然后主核启动,Bootloader通过HSM接口请求对Application分区做CMAC校验;HSM用内部密钥算出CMAC,和存储在OTP或HSM Flash里的参考值比对,返回通过或失败;Bootloader根据结果决定是否跳转。
3. 信任根、密钥与分区设计:方案落地前的三个决定
3.1 信任根放在哪里最稳
信任根(Root of Trust)是整个安全启动的起点,它本身必须是不能被篡改的。在带HSM的MCU上,信任根通常有三个可选位置:
| 存储位置 | 安全性 | 灵活性 | 适用阶段 |
|---|---|---|---|
| OTP(一次性可编程) | 最高,写入后不可改 | 最低,量产前必须定死 | 量产件 |
| HSM专用Flash | 高,主核不可访问 | 中,可通过HSM固件更新 | 开发/量产 |
| 主核Flash | 低,可被刷写 | 高 | 仅开发调试 |
我的建议是:开发阶段用HSM Flash存参考CMAC,方便迭代;量产阶段把HSM固件和根密钥的哈希烧进OTP。这样既保证了开发效率,又保证了量产件的不可篡改性。需要注意的是,OTP一旦烧录就无法回退,所以烧录脚本必须经过严格验证,我见过因为OTP烧错导致整批芯片报废的案例。
3.2 密钥体系怎么分层
直接用一把密钥算所有分区的CMAC是不安全的,一旦泄露全盘皆输。工程上通常做两级密钥派生:
- 根密钥(Root Key):存在HSM密钥槽里,永远不导出,只用于派生下级密钥。
- 分区密钥(Partition Key):由根密钥加上分区标识(比如分区ID、版本号)通过KDF派生出来,每个分区一把。
这样做的好处是,即使某个分区的密钥在派生过程中被侧信道分析出来,也影响不到其他分区。派生函数可以用AES-CMAC本身来实现,比如PartitionKey = AES-CMAC(RootKey, PartitionID || Version),这样不需要额外的KDF硬件。
3.3 Flash分区怎么切
安全启动要求逐级验证,所以Flash必须按信任级别分区。一个典型的布局是这样的:
- HSM固件区:信任根,主核不可读写。
- Bootloader区:包含启动逻辑和校验代码,由HSM固件验证。
- Application区:主应用代码,由Bootloader通过HSM验证。
- Calibration/Data区:标定数据,单独校验,因为它在售后可能会被合法更新。
- NvM区:非易失存储,一般不参与启动校验,但敏感数据要加密。
每个分区在链接脚本里要明确起始地址和长度,并且长度要按AES块大小(16字节)对齐,否则CMAC计算时最后一个块的填充处理容易出错。我踩过一次坑:Application区长度不是16的倍数,CMAC计算结果和参考值总是不一致,查了两天才发现是填充模式没对齐。
4. CMAC校验的完整实现链路
4.1 CMAC算法本身的关键细节
CMAC的计算过程分几步:先把密钥通过一个子密钥生成算法派生出K1和K2;然后对消息按16字节分块,最后一块根据是否完整分别用K1或K2处理;中间块用AES加密后与前一块异或。这里不展开数学推导,重点讲工程实现里容易出错的三个点。
第一,子密钥生成。K1和K2的生成需要对一个全零块做AES加密,然后根据最高位做左移和异或。如果HSM硬件加速器不直接支持CMAC,只支持裸AES,这部分要自己用软件实现,注意左移时的位宽处理,C语言里要用uint8_t数组手动移位,不能直接用移位运算符。
第二,最后一块的处理。如果消息长度正好是16的倍数,用K1异或;否则先填充0x80再补0x00到16字节,然后用K2异或。这个判断条件写错的话,对于长度恰好对齐的固件会校验失败。
第三,字节序。AES是按大端处理的,但MCU的Flash读取可能是小端,CMAC输入数据的字节序要和生成参考值时保持一致。我建议在生成参考值的工具链里明确标注字节序,并且在Bootloader里做一次自检。
4.2 HSM接口调用示例
不同芯片厂商的HSM接口不一样,但抽象出来都是“请求-计算-返回”的模式。下面是一段贴近实际的伪代码,展示Bootloader怎么调用HSM做CMAC校验:
/* 定义分区描述结构 */ typedef struct { uint32_t startAddr; uint32_t length; uint8_t partitionId; uint8_t referenceCmac[16]; } PartitionInfo_t; /* HSM CMAC计算请求 */ Std_ReturnType Hsm_CalculateCmac(uint8_t partitionId, const uint8_t *data, uint32_t length, uint8_t *cmacOut) { HsmJob_t job; job.serviceId = HSM_CMD_CMAC; job.keyId = DeriveKeyId(partitionId); /* 派生分区密钥 */ job.inputPtr = (uint32_t)data; job.inputLen = length; job.outputPtr = (uint32_t)cmacOut; if (Hsm_SubmitJob(&job) != E_OK) { return E_NOT_OK; } return Hsm_WaitForCompletion(&job, HSM_TIMEOUT_MS); } /* 分区校验 */ boolean VerifyPartition(const PartitionInfo_t *part) { uint8_t computedCmac[16]; uint8_t *flashPtr = (uint8_t *)part->startAddr; if (Hsm_CalculateCmac(part->partitionId, flashPtr, part->length, computedCmac) != E_OK) { return FALSE; } /* 常量时间比较,防止时序攻击 */ return ConstantTimeMemcmp(computedCmac, part->referenceCmac, 16); }这里有个细节值得说:比较CMAC时要用常量时间比较函数,不能用普通的memcmp。因为memcmp在遇到第一个不相等字节时就返回,攻击者可以通过测量比较耗时来逐字节猜测正确的CMAC值。虽然在实际车辆攻击场景里这种时序攻击难度很高,但作为安全代码的基本素养,这个习惯要养成。
4.3 启动流程的时序设计
安全启动不是简单地在跳转前算一次CMAC就完事,它要和看门狗、启动时间要求配合。一个完整的时序是这样的:
- 上电,HSM先启动,自检HSM固件CMAC(约5-10ms)。
- 主核复位释放,Bootloader开始执行。
- Bootloader初始化时钟、看门狗,看门狗超时设为安全启动最大耗时的1.5倍。
- Bootloader请求HSM校验Bootloader自身(如果支持双区)。
- 校验通过后,请求HSM校验Application区CMAC。
- 校验通过,跳转到Application;失败则进入安全状态或请求重新刷写。
这里的关键是看门狗超时时间。如果Application有2MB,HSM CMAC吞吐量按20MB/s算,校验耗时约100ms,加上Flash读取和HSM通信开销,实际可能到150-200ms。看门狗如果设成100ms,启动过程中就会复位。我一般建议安全启动阶段先把看门狗关掉或者设一个很长的超时,等跳转到Application后再由应用重新配置看门狗。
5. 实操过程:从零搭建一个可验证的安全启动
5.1 工具链准备与参考值生成
在动手写Bootloader之前,先要把参考CMAC的生成工具做出来。这个工具跑在PC上,输入是编译好的固件二进制和分区密钥,输出是16字节的CMAC值。工具可以用Python的cryptography库快速实现:
from cryptography.hazmat.primitives.cmac import CMAC from cryptography.hazmat.primitives.ciphers import algorithms def generate_cmac(firmware_path, key_hex): key = bytes.fromhex(key_hex) with open(firmware_path, 'rb') as f: data = f.read() c = CMAC(algorithms.AES(key)) c.update(data) return c.finalize().hex() # 分区密钥由根密钥派生 root_key = "00112233445566778899aabbccddeeff" partition_id = 0x01 # 派生:AES-CMAC(root_key, partition_id) derived = generate_cmac_bytes(root_key, bytes([partition_id])) print("Partition Key:", derived.hex()) print("Firmware CMAC:", generate_cmac("app.bin", derived.hex()))生成出来的CMAC值要写进Bootloader的配置区或者HSM的参考值存储区。这里有个工程上的选择:参考值是编译进Bootloader,还是单独存一个区域?编译进Bootloader的话,每次固件更新都要重新编译Bootloader,不现实。单独存一个区域的话,这个区域本身也要被保护。我的做法是在Flash里划一个安全配置区,存所有分区的参考CMAC和版本号,这个区域由Bootloader在刷写流程中更新,并且它的完整性由HSM用另一把密钥保护。
5.2 Bootloader里的校验代码实现
Bootloader的校验逻辑要尽量精简,因为它本身也是攻击目标。核心流程就是遍历分区表,逐个调用HSM校验。下面是一个更完整的实现框架:
/* 安全配置区结构 */ typedef struct { uint32_t magic; /* 0x5A5A5A5A */ uint8_t version; uint8_t partitionCount; PartitionInfo_t partitions[MAX_PARTITIONS]; uint8_t configCmac[16]; /* 配置区自身的CMAC */ } SecureConfig_t; /* 启动校验主流程 */ void SecureBoot_Main(void) { SecureConfig_t *cfg = (SecureConfig_t *)SECURE_CONFIG_ADDR; /* 第一步:校验配置区自身 */ if (cfg->magic != 0x5A5A5A5A) { SecureBoot_EnterRecovery(); return; } if (!VerifyConfigIntegrity(cfg)) { SecureBoot_EnterRecovery(); return; } /* 第二步:逐分区校验 */ for (uint8_t i = 0; i < cfg->partitionCount; i++) { if (!VerifyPartition(&cfg->partitions[i])) { /* 记录失败分区ID到诊断故障码 */ Dem_SetEventStatus(DTC_SECURE_BOOT_FAIL, cfg->partitions[i].partitionId); SecureBoot_EnterRecovery(); return; } } /* 第三步:全部通过,跳转 */ JumpToApplication(cfg->partitions[APP_PARTITION_INDEX].startAddr); }这段代码里,SecureBoot_EnterRecovery是一个安全状态处理函数,通常会点亮故障灯、记录DTC、保持在Bootloader里等待合法的刷写请求。注意不要在校验失败后直接复位,否则会陷入复位循环,诊断仪也连不上。正确的做法是停在Bootloader,让诊断仪有机会读取故障码并重新刷写。
5.3 刷写流程中的CMAC更新
安全启动不是只读的,固件更新时参考CMAC也要更新。这个流程必须原子化,否则刷写中途断电会导致ECU变砖。我的做法是双区备份加事务标记:
- 诊断仪请求刷写,Bootloader先擦除备份区。
- 新固件写入备份区,同时计算CMAC。
- 写入完成后,HSM验证备份区CMAC。
- 验证通过,更新安全配置区的参考CMAC和版本号,并设置一个“切换中”标记。
- 复位,Bootloader检测到切换标记,把备份区激活为主区,清除标记。
- 如果第4步之后断电,复位后Bootloader看到切换标记,重新执行激活流程。
这个流程的关键是第4步的配置区更新必须是原子的。Flash写入一个扇区通常需要几十毫秒,期间断电会导致配置区损坏。解决办法是用两个配置区交替写入,每个配置区带一个序列号和CRC,Bootloader启动时选序列号大且CRC正确的那个。
6. 实测踩坑与常见问题排查
6.1 CMAC校验失败的五大原因
在实际项目中,CMAC校验失败是最常见的问题,我整理了一个排查表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 所有分区都失败 | 根密钥不一致 | 对比PC工具和HSM里的密钥哈希 |
| 只有Application失败 | 固件长度未对齐 | 检查链接脚本的段长度 |
| 偶发失败 | Flash读取时序问题 | 降低HSM读取时钟或加等待周期 |
| 更新后失败 | 参考值未更新 | 检查配置区写入流程 |
| 特定批次失败 | OTP烧录不一致 | 读取OTP区域对比 |
其中偶发失败最让人头疼。我遇到过一次,CMAC校验在实验室100%通过,到了整车环境大概每100次启动失败1次。后来用示波器抓HSM和Flash的接口信号,发现是低温下Flash读取建立时间不够,HSM读到了错误数据。解决办法是在HSM的Flash控制器配置里增加等待周期,这个参数在芯片手册的“Flash时序”章节里有计算公式,按最差温度条件算出来的值比默认值大不少。
6.2 启动时间超预算怎么办
安全启动会增加启动时间,这是不可避免的。如果主机厂给的启动时间预算是500ms,而安全启动就占了200ms,那就很紧张了。几个优化方向:
- 并行校验:如果HSM支持多任务,可以同时校验多个分区。但要注意HSM的算力有限,并行不一定比串行快。
- 增量校验:只校验上次启动后发生变化的分区,用版本号或时间戳判断。但这会削弱安全性,因为攻击者可能伪造版本号。
- 缓存校验结果:在HSM的安全RAM里缓存校验通过的哈希,下次启动只校验哈希。这个方案需要HSM支持安全RAM的掉电保持,不是所有芯片都有。
- 硬件加速:确认HSM的AES加速器是否真的被用上了。我见过有的项目HSM固件配置错误,CMAC实际是软件跑的,速度差了20倍。
实测数据供参考:英飞凌TC397的HSM做AES-128 CMAC,2MB数据大约80ms;NXP S32K344的HSM大约120ms;瑞萨RH850的ICUM大约100ms。这些数据会随HSM固件版本和时钟配置变化,建议在自己的板子上实测。
6.3 密钥管理的几个禁忌
最后说几个密钥管理上的禁忌,都是血泪教训:
不要把密钥硬编码在Bootloader源码里。源码会进版本管理系统,会发给同事,会出现在CI日志里。密钥应该由产线工具在烧录阶段注入HSM。
不要用同一把密钥做CMAC和加密。CMAC密钥和加密密钥必须分开,这是密码学的基本要求,混用会引入安全弱点。
不要在调试口输出密钥或CMAC中间值。调试信息在量产件上必须关闭,我见过因为调试串口没关导致密钥泄露的案例。
不要忽略密钥的销毁流程。如果ECU报废或返修,HSM里的密钥要能被安全擦除,防止从报废件里提取密钥。
7. 写在最后的一点个人体会
安全启动这个事,方案设计阶段看起来复杂,但真正落地之后会发现,最难的不是算法,而是流程。密钥怎么从产线工具传到HSM、参考CMAC怎么在OTA流程里原子更新、校验失败后怎么保证诊断仪还能连上——这些工程细节才是决定项目成败的地方。我见过算法选得很漂亮但产线烧录流程没设计好,导致量产时良率掉到80%的项目。
另外,安全启动不是一劳永逸的。芯片可能有漏洞,HSM固件可能需要升级,密钥可能需要轮换。所以在设计之初就要把可更新性考虑进去,HSM固件要支持安全更新,密钥槽要预留轮换空间。我个人的习惯是在项目早期就把安全启动的测试用例写好,包括正常启动、篡改固件、回滚版本、断电恢复这几个场景,每次代码提交都跑一遍,这样能尽早发现问题。
如果你正在做类似的项目,建议先从一个小分区开始验证整条链路,跑通了再扩展到全部分区。CMAC的计算和比对逻辑不复杂,复杂的是把它嵌入到一个可靠的、可维护的、可量产的流程里。