
1. 从一次真实的ECU被刷写事件说起前两年帮一个做Tier1的朋友排查过一个问题他们给某主机厂供的网关控制器在售后市场被人用非官方的诊断工具刷进了一段未授权的固件导致车辆在特定工况下会偶发性地丢失CAN报文。事后复盘根因其实不复杂——Bootloader阶段没有做固件的完整性和来源校验只要诊断请求的Seed/Key算对了任何二进制都能写进去。这件事之后他们整个平台把安全启动Secure Boot列成了一级需求MCU选型也全面转向了带HSMHardware Security Module的型号。这个场景在汽车电子行业里其实非常典型。只要你的ECU有OTA能力、有诊断刷写接口、有外部通信通道CAN、CAN-FD、以太网它就天然暴露在一个可以被物理或远程访问的攻击面上。安全启动要解决的核心问题就一句话MCU从上电到跳转到应用代码之前必须逐级验证每一段要执行的代码是不是我授权的、有没有被改过一个字节。而HSM加CMAC是目前在成本、实时性和安全性之间平衡得比较好的一套工程方案。这篇文章面向的是做汽车电子MCU底层开发、Bootloader开发、信息安全相关的工程师尤其是正在用英飞凌TC3xx、NXP S32K3、瑞萨RH850这类带HSM核的芯片做项目的同行。我会把安全启动的完整链路拆开讲从信任根怎么建立、CMAC为什么适合做这件事、HSM在里面扮演什么角色一直到具体的分区设计、密钥管理和实测踩坑记录。代码层面我会用贴近AUTOSAR和HSM固件接口的伪代码来示意重点是让你看完能直接对着自己的项目做方案设计。2. 安全启动到底在防什么为什么非要用HSM2.1 威胁模型不是所有篡改都来自黑客很多人一提到安全启动就想到远程攻击其实在汽车电子里物理接触式的篡改反而更常见。我梳理过几类实际遇到过的威胁场景售后刷写非授权固件第三方维修点用破解的诊断工具刷入修改过标定的固件绕过排放或性能限制。产线误刷产线工装夹具出错把A项目的固件刷进了B项目的ECU如果没有校验车就带着错误代码出厂了。供应链投毒某个中间环节的固件被替换这种最隐蔽靠人工review根本发现不了。回滚攻击把已经修复了漏洞的固件版本替换回有漏洞的旧版本利用旧版本的已知缺陷。这四类威胁有一个共同点攻击者最终都要让MCU执行一段非预期的代码。安全启动的防线就设在这个执行入口上只要验证不过就不跳转直接停在Bootloader或者进入安全状态。2.2 为什么是CMAC而不是简单的CRC或哈希这里要解释一个很多人会问的问题我算个CRC32或者SHA256哈希不就行了吗为什么要用CMACCRC的问题最直接——它是线性的攻击者可以构造两个不同的固件让它们的CRC值相同这叫CRC碰撞构造难度极低。哈希比如SHA256解决了碰撞问题但它只保证完整性不保证来源。也就是说任何人只要改了固件重新算一遍SHA256附在后面校验照样通过。哈希本身不带密钥谁都能算。CMACCipher-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有2MBHSM 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写入一个扇区通常需要几十毫秒期间断电会导致配置区损坏。解决办法是用两个配置区交替写入每个配置区带一个序列号和CRCBootloader启动时选序列号大且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 CMAC2MB数据大约80msNXP 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的计算和比对逻辑不复杂复杂的是把它嵌入到一个可靠的、可维护的、可量产的流程里。