做车载ECU开发或者整车OTA的同学,对下面这个场景应该不陌生:云端推送一条升级任务,车机把升级包拉到本地,再通过CAN总线或者车载以太网把新固件刷进ECU,最后ECU重启上电。很多人以为到这一步升级就算完成了,其实真正的“鬼门关”才刚开始——ECU上电的一瞬间,Bootloader会对新固件再做一次签名验证,验证不过直接拒绝启动。
这也是我在好几个项目里反复看到的翻车点:团队只盯着升级包下发时的签名校验,忽略了上电阶段的Secure Boot验证。结果要么是刷进去的固件被人为改过还能照样跑,要么是升级包在车机本地被替换后ECU照样起来,整条安全链路形同虚设。
这篇文章我想结合自己搭建汽车OTA升级链路、做ECU安全启动的实战经历,把“升级包下发验签”和“ECU上电验签”这套双验证流程完整拆一遍:为什么必须验两次、签名怎么算、密钥怎么管、Bootloader怎么验,以及我在实际项目里踩过的坑。无论你是在车厂、Tier1做嵌入式,还是刚进入车联网安全领域,这篇都值得花十分钟仔细看一遍。
1. 先看懂整条链路:为什么一次升级要验两道签名
1.1 从“下载、刷写、启动”三个动作看OTA的两道关口
一段OTA升级,表面上只有一个“把新固件放进去”的动作,实际上可以分为三个阶段:下载、刷写、启动。
- 下载:云端把升级包推到车端,车机下载到本地存储。
- 刷写:车机通过诊断仪协议(最常见的是UDS,ISO 14229)把固件写到ECU的Flash里。
- 启动:ECU复位上电,Bootloader检查固件合法性,然后跳转到App运行。
绝大多数团队把安全精力放在第一阶段,也就是下载阶段做HTTPS、做校验,但我认为整条链路里最容易出问题的是第二阶段和第三阶段。原因很简单:升级包一旦落到了车端本地,就离开了云端的可控范围。攻击者可能拿到车机Root权限,替换本地升级包;可能拔掉Flash用编程器物理改写;甚至可能在OTA刷写过程中断电,导致Flash里的固件是不完整的。
所以行业里的做法是:升级包由云端私钥签名,车端在下载完成后验签一次,确认“这个包确实是云端发的、没被篡改过”;固件刷进ECU后,ECU在启动时再用硬件里烧录的公钥验签一次,确认“Flash里现在跑的这个镜像依然是合法的”。这两道验签,杀的其实是两拨完全不同的攻击者。
1.2 两道验签分别防什么:传输篡改与本地持久化攻击
第一道验签,发生在车端拿到升级包之后、刷写ECU之前。它防的是“传输链路被劫持”和“云端存储被污染”。比如中间人把升级包换成了一个带恶意代码的假包,或者CDN节点被入侵导致下发的包被人动过手脚,第一道验签会直接拦截。
第二道验签,发生在ECU上电瞬间。它防的是“本地持久化攻击”。升级包即便通过了第一道验签被成功刷写,ECU重启之后Bootloader校验的是Flash里的实际内容。如果攻击者在刷写完成后对Flash做物理篡改,或者通过调试接口、漏洞在App里注入了恶意代码,第二道验签会拒绝启动这个被污染过的固件。
为什么不能只靠其中一道?我打个比方:第一道验签相当于快递员送货上门时你检查了包裹完好、签了字;第二道验签相当于你把东西拿回家后,每次开机都要再确认一遍里面的东西真的是你要的。前者防的是“路上被掉包”,后者防的是“放家里被人换掉”。两道验签覆盖的是两条完全不同的攻击路径,缺一道都会留下缺口。
2. 升级包侧:签名、打包与密钥管理
2.1 签名算法选型与计算过程
在搭建签名服务之前,首先要确定用哪套非对称签名算法。目前行业里最常见的是RSA-2048 + SHA-256和ECDSA P-256 + SHA-256。
RSA的优势是成熟、工具链完善,几乎所有密码库都原生支持PKCS#1 v1.5和PSS两种填充方式;缺点是密钥尺寸大、验签性能偏慢。在几百MHz的MCU上,RSA-2048验签一次大约需要几十毫秒甚至上百毫秒,对于一些对启动时间极其敏感的ECU来说要仔细评估。
ECDSA P-256的优势是密钥短、签名体积小(64字节)、验签性能好,特别适合资源受限的MCU;缺点是实现细节多,随机数质量不过关会直接导致私钥泄露,而且有些老旧PKI工具链对ECDSA不够友好。
我自己的经验是:低算力的传感器ECU优先考虑ECDSA,域控制器、车机这类算力充足的平台可以放心用RSA-2048。如果你是第一次搭这套体系,建议先选RSA-2048 + SHA-256,不是因为性能最好,而是因为踩坑成本最低,网上资料多、排查工具多,等整条流程真的跑通了,再迁移到ECDSA也不迟。
签名计算流程本身不复杂,可以拆成四步:
- 对固件镜像做SHA-256哈希,得到摘要。
- 用私钥对摘要做非对称加密(RSA-PSS或ECDSA),得到签名值。
- 把固件镜像、镜像元信息(版本号、ECU类型、长度、CRC)、签名值一起打包成升级包。
- 车端/ECU端用公钥对镜像做同样哈希,再用公钥验签,比对摘要。
这里有个很多人容易搞错的点:签名的是“摘要”而不是“整个镜像”。非对称算法计算开销大,直接对几十MB的固件做RSA运算不现实,所以先通过哈希把任意长度的镜像压缩成固定长度的摘要,再对摘要做签名。SHA-256的抗碰撞性保证了“镜像变了,摘要几乎必然变”,因此只要摘要验签通过,就等价于镜像没被改过。
2.2 密钥分级、HSM与证书链
签名算法选好了,接下来的问题是:私钥放哪、怎么管、怎么用。
很多开发团队一开始图省事,把私钥放在一台普通开发机上,开发、测试、量产全用同一把密钥,这是我在审计项目里见过最多的高危隐患。汽车软件私钥一旦泄露,攻击者就可以用这把私钥给任意恶意固件签名,整条OTA安全体系直接报废。
正确做法是把密钥分级管理。至少分成两级:
- 根密钥(Root Key):最高权限,只在离线环境生成,私钥永不联网,通常存放在硬件加密机(HSM)或物理隔离的签名服务器里,用于签发次级密钥。
- 签名密钥(Signing Key):用于日常升级包签名,同样建议放进HSM或专业密钥管理系统,访问要留审计日志。
如果车型多、ECU型号多,还可以在根密钥和签名密钥之间插入中间CA证书,形成“根CA → 中间CA → 终端证书”的证书链。这样即使某个车型的签名密钥泄露,只需要吊销和重签该车型的中间证书,不用把根密钥也换掉。
硬件安全模块(HSM)是这套体系里很关键的一环。签名私钥一旦进入HSM,外部只能调用签名接口,永远拿不到私钥本身。哪怕是内部开发人员,也无法通过API导出私钥。我建议从项目启动第一天就把HSM纳入预算,因为产品量产后想再补密钥管理,成本和痛苦程度会翻好几倍。
2.3 打包格式约定
升级包格式是另一个容易被低估的问题。格式不统一,车机端解析脚本就越来越乱,验签也容易漏。
一个标准的升级包,建议至少包含以下字段:
| 字段 | 说明 |
|---|---|
| 包头魔数 | 用于识别升级包类型,例如“OTA1” |
| 协议版本 | 升级包格式版本号 |
| 目标ECU标识 | 对应哪个ECU或硬件平台 |
| 固件版本号 | 用于版本校验和防回滚 |
| 镜像长度 | 固件镜像字节数 |
| 镜像SHA-256哈希值 | 车端对镜像做哈希并与该字段比对 |
| 固件镜像数据 | 实际固件内容 |
| 签名值 | 对“元信息+镜像”整体计算出的签名 |
签名时要注意:签名的范围要覆盖整个元信息加镜像,而不只是镜像本身。否则攻击者可以不动镜像,偷偷把版本号改成旧版本,配合刷写旧固件实现回滚攻击。把版本号、ECU标识、长度一起纳入签名范围,是防回滚的第一道保险。
3. 下发链路:从云端到车端,怎么保证包没被掉包
3.1 车云通道的三层保护
升级包从云端到车端,传输链路本身需要保护。这个保护是三层叠加的,不是单选一个就完事。
第一层是传输层安全。车机和云端通信通常走HTTPS(TLS 1.2或1.3),至少保证升级包在公网传输过程中不被中间人窃听或篡改。这里涉及TLS证书校验,车机端必须锁固定根证书,避免攻击者用自签名证书做中间人攻击。
第二层是应用层验签。也就是下载完成后,车机先对升级包做一次整体验签,通过后才允许进入刷写流程。这一步的意义在于:即使TLS被攻破,攻击者拿到了传输中的升级包并替换成恶意包,应用层验签仍然会拒绝。
第三层是存储层保护。下载到车机本地存储的升级包,建议加密存储。为什么?因为车机的文件系统可能被Root、可能被物理拆解,明文升级包一旦被拷走,攻击者虽然无法伪造签名,但可以分析固件内容,反向挖掘漏洞。加密存储能让这种攻击的成本显著提高。
三层保护缺一不可,但真正的底气来自第二层应用层验签,因为只有这一层是端到端的信任校验,TLS只保护传输过程,保护不了端点本身。
3.2 差分包、断点续传与分块校验
车载OTA经常会遇到弱网环境,所以业界普遍用差分升级来减少下载流量。差分包只包含新旧固件之间的差异部分,常见的差分算法有bsdiff、hdiffpatch等。
这里要注意的是:差分包本身也要验签。有些团队在做差分升级时,由于包体较小,就在服务器端直接拼好完整镜像再签名,这样没问题;但有些场景下完整的镜像不会在车端组装,车端拿到的是差分包加补丁文件。这时必须对差分包做签名验证,否则攻击者只需替换差分包,就能在车端制造出一个带恶意代码的“新固件”。
断点续传功能同样不能在安全上打折扣。下载中断后重新续传,如果只校验分块的CRC而不校验整体签名,那攻击者可以精心构造一个CRC相同但内容不同的分块。正确做法是:每个分块到达后做CRC校验,全部下载完成后,对完整升级包做SHA-256哈希和整体验签。分块校验用于快速发现传输错误,整体验签才是真正的安全关口。
3.3 刷写前后不能漏掉的检查项
车端完成验签后,接下来是通过UDS把固件刷进ECU。刷写前后有三项检查不能漏:
- 刷写前要确认目标ECU标识和固件版本匹配,防止把A车型的固件刷进B车型的ECU。
- 刷写前要确认升级包里的版本号高于当前版本,低于或等于当前版本直接拒绝,这是防回滚的关键一道闸门。
- 刷写完成后,ECU要对Flash中的镜像做一次CRC或哈希校验,确认数据完整,然后才允许复位启动。
有些团队把“验签能不能过”当成唯一标准,忽略了版本号匹配和ECU匹配,结果出现过把测试固件刷进量产车的事故,这个锅最后往往还扣不到验签头上,纯属流程缺失。
4. ECU上电验签:Secure Boot的完整时序
4.1 信任根与三级引导链
第二道验签发生在ECU上电时,这就是通常所说的Secure Boot(安全启动)。它的核心思路是建立一条从硬件到应用层的信任链,每一级引导程序在跳转之前,都要验证下一级的签名。
典型的三级引导链是这样的:
- BootROM:芯片出厂固化的只读代码,是整个信任链的根。它校验Bootloader的第一阶段。
- Bootloader(分为BL1/BL2两个阶段):BL1完成基础硬件初始化和安全校验,然后加载BL2;BL2负责完整的启动逻辑,校验App镜像。
- App:应用固件,被Bootloader验证通过后才允许被执行。
每一级验证通过后,才把控制权交给下一级;任何一级验证失败,芯片都不会继续启动。这就像进公司大楼:保安确认你的工牌(BootROM验Bootloader),电梯闸机再确认一次(Bootloader验App),两道门都过了你才能进工位。
设备公钥通常烧录在一次性可编程存储(OTP/efuse)中,芯片出厂后无法改写。如果公钥都能被攻击者改写,安全启动就是空中楼阁。所以硬件上要确保两点:公钥区域不可改写,以及芯片有调试接口保护(比如JTAG/SWD的熔丝或者锁定机制),否则攻击者通过调试接口直接读Flash甚至修改内存,验签逻辑再强也没用。
4.2 验签失败的处理:回滚、恢复与看门狗
Secure Boot不只是“验签过了就启动,验签不过就死机”这么简单。实际工业产品必须设计好验签失败后的恢复路径,否则一次失败就变砖,售后成本会非常高。
我推荐的做法是设计一个带版本回滚的启动策略:
- Bootloader先在Flash的备份分区里保留上一版能正常启动的固件。
- 当主分区固件验签失败时,Bootloader尝试加载备份分区,并在启动后设置一个“回滚事件”标志。
- FOTA管理器读取这个标志,向云端上报本次升级失败,请求重新下发或者切换到回滚状态。
- 如果备份分区也验签失败,那是真出大事了,只能进入恢复模式,通过UDS重新刷写或者走售后刷机流程。
除了分区回滚,硬件看门狗也要参与进来。有些ECU在App侧跑飞后,Bootloader并没有死,但App一直没有喂狗,看门狗超时后会强制复位系统。复位后Bootloader要能识别“上次启动失败”的状态,自动进入恢复模式而不是反复尝试启动一个坏固件,否则就会出现“重启-失败-重启”的死循环。
安全启动最终要保证两件事:一是不合法的固件永远跑不起来,二是合法固件一旦失败,系统有路可退。
5. 实操过程与关键配置
5.1 用OpenSSL生成密钥对与CSR
聊了这么多原理,下面进入实操。工具我用的是OpenSSL,这几乎是行业标准,先演示怎么生成一套用于OTA签名的RSA-2048密钥。
生成私钥(推荐使用PKCS#8格式):
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ -out sign_private_key.pem从私钥导出公钥,用于车端/ECU端验签:
openssl pkey -in sign_private_key.pem -pubout -out verify_public_key.pem如果你的安全策略要求证书链,就先生成CSR,然后交给CA签发:
openssl req -new -key sign_private_key.pem -out signing_key.csr \ -subj "/CN=OTA-Signer-2024/O=YourCompany/C=CN"我强烈建议正式环境用硬件安全模块而不是普通文件保存私钥。上面这些文件操作适合开发测试,量产签名必须把私钥放进HSM,或者至少部署一个只有API访问权限的独立签名服务,私钥不落任何开发人员本地。
5.2 升级包签名与验签的最小示例
接下来演示一下最小可行的升级包签名与验签流程。假设我们有一个固件镜像文件firmware.bin:
计算镜像哈希并保存:
openssl dgst -sha256 -binary firmware.bin > firmware.bin.sha256使用私钥对哈希值签名,得到签名文件(这里用RSA-PSS):
openssl pkeyutl -sign \ -in firmware.bin.sha256 \ -inkey sign_private_key.pem \ -pkeyopt rsa_padding_mode:pss \ -pkeyopt rsa_pss_saltlen:32 \ -out firmware.bin.sig车端或ECU端验签时,使用公钥:
openssl pkeyutl -verify \ -in firmware.bin.sha256 \ -sigfile firmware.bin.sig \ -pubin -inkey verify_public_key.pem \ -pkeyopt rsa_padding_mode:pss \ -pkeyopt rsa_pss_saltlen:32如果输出“Signature Verified Successfully”,说明镜像摘要验签通过。实际工程里这一步会放到Bootloader中执行,但逻辑完全一致:先算哈希,再用硬件存储的公钥验签。
需要特别提醒:这里我只对镜像的SHA-256值签名了。实际产品里,签名的输入范围要包含版本号、ECU标识、镜像长度这些元信息,绝不能只签裸镜像。否则攻击者改掉版本号不影响验签,就能轻松构造回滚攻击。
5.3 在ECU侧接入验签的工程思路
在单片机端做验签,一般不会直接裸调OpenSSL,而是用mbedTLS或类似密码库。mbedTLS对RSA、ECDSA、SHA-256的支持都很完整,而且内存占用可控,非常适合车规MCU。
下面是一段示意性的伪代码,展示Bootloader里验签App镜像的逻辑:
int secure_boot_verify(const uint8_t *image, uint32_t image_len, const uint8_t *signature, const uint8_t *pubkey) { uint8_t hash[32]; mbedtls_sha256(image, image_len, hash, 0); mbedtls_rsa_context rsa; mbedtls_rsa_init(&rsa); mbedtls_rsa_import_pubkey(&rsa, pubkey); int ret = mbedtls_rsa_pkcs1_verify(&rsa, MBEDTLS_MD_SHA256, 32, hash, signature); mbedtls_rsa_free(&rsa); return ret; // 0 表示验签通过 }这段代码的关键点是:先对整个镜像做SHA-256,然后验签。注意这里用的是RSA-PKCS1-v1-5填充,如果你在签名侧用的是PSS,验签侧也要改成对应的PSS模式,两边的填充方式不一致是最常见的“签名能过验签不过”的原因之一。
公钥怎么存进ECU也有讲究。最稳妥的方式是烧录进OTP区或者受保护的KeyStore,配合芯片的Secure Boot硬件特性。如果MCU没有专门的KeyStore,也要确保公钥所在的Flash区域在量产线结束后被锁定为只读,线下再通过对Bootloader自身的签名校验保证Bootloader不会被替换。
6. 常见问题与排查技巧实录
6.1 常见问题速查表
下面这张表是我在项目里遇到的高频问题,整理成速查表,建议直接转给团队:
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 车端下载后验签失败 | 公钥与签名私钥不匹配 | 确认车端烧录的公钥和云端签名私钥是同一对 |
| 验签时哈希值一直对不上 | 签名时哈希的输入范围和验签时不一致 | 核对“元信息+镜像”的拼接顺序和范围 |
| RSA签名在PC端成功,MCU端失败 | 填充模式不一致 | 确认签名和验签都用PKCS1 v1.5,或都用PSS |
| 固件能刷写但ECU无法启动 | Bootloader验签使用的公钥被错误覆盖或锁定区域未生效 | 检查OTP/KeyStore烧录记录 |
| 升级后系统反复重启 | 固件验签通过但App运行异常,看门狗超时 | 检查看门狗超时策略与回滚机制 |
| 回滚被误判为升级失败 | 版本号比较逻辑写反 | 确认为“新版本大于当前版本”才能校验 |
| 下载到一半续传后验签失败 | 续传后拼接了不完整分块 | 整包下载完成后必须重新做全文校验 |
6.2 容易被忽略的几个坑
最后说几个我真实踩过、或者在别人项目里见过的坑,每一个都是拿排期和售后换来的。
第一,签名输入范围不统一。“开发环境的签名脚本只签了镜像,测试环境的脚本签了元信息加镜像”,这种不一致会直接导致同一个升级包在不同环境表现完全不同。建议在打包服务里做一次完整的范围约定,并在测试用例里强制覆盖“篡改元信息”和“篡改镜像内容”两种场景。
第二,私钥权限管理形同虚设。很多团队的签名脚本放在CI机器上,凡是能登录CI的人都能拿到私钥文件。一旦内部人员有意或无意泄露,整个OTA体系就要推倒重来。就算不上HSM,也至少要做到:私钥加密存储、每次签名操作有审计日志、不同开发环境使用不同的密钥。
第三,只验签不复核版本。我在一个项目里见过攻击者思路的“白帽测试”:把升级包里的固件内容换成正常的历史版本,由于签名依然有效,验签通过了,结果ECU被回滚到旧版本,旧版本的已知漏洞全都能用。解决方式很简单,版本号必须纳入签名范围,Bootloader在验签通过后还要再校验一次版本号是否大于等于最小允许版本。
第四,调试接口没关。Secure Boot做得再严密,如果JTAG/SWD调试口在量产固件里还开着,攻击者直接连上调试器改内存、改Flash,安全启动就成了摆设。量产前一定要锁定调试接口,并做一次实机渗透验证,别让最后一道防线漏风。
这套“双验签”体系,说到底不是某一个环节的技术,而是从密钥管理、打包签名、传输校验到Bootloader安全启动的整条链。我在项目里最大的体会是:安全设计必须提前进架构,等OTA跑通了再补安全,几乎等于把整个链路重做一遍。尤其是密钥分级和HSM采购,这些一定不要在项目后期才启动。
另外也想提醒大家,任何流程层面的安全措施,最后都要落到实车验证上。升级包在实车上能跑通一百次,都不如做一次“篡改固件之后还能不能启动”的安全用例来得踏实。如果你正要开始搭这套体系,建议先把签名、验签、回滚三个闭环跑通,再逐步加密钥分权、证书链和云端审计,不用一上来就追求完美。
这套方法并不神秘,但细节很多。希望这篇实战记录能帮你少踩几个坑。如果你在实际落地中遇到其他诡异问题,欢迎一起讨论。