MCUboot 安全启动与签名链实战:一份渗透测试报告上的”OTA 无签名”,逼我把信任链、防回滚、密钥轮换从头做了一遍

2026年10月5日 0 By admin

缘起:报告上那行字,把我三年的 OTA 打回了原形

客户那边做安全评估,给我们的表计设备出了一份报告。前面都是些常规项——串口无鉴权、调试口未关、日志含敏感信息。翻到第 11 页,一行字看得很不舒服:

中危:设备固件升级通道仅校验 CRC32,未做来源真实性验证。测试人员构造的任意固件均被设备接受并成功写入。

说实话,第一反应是”这不废话吗,升级通道是我们自己的私有协议,外人哪接得进来”。但报告后面附了复现步骤——把升级服务器劫持一下,或者干脆在设备侧伪造一个升级响应帧,CRC 自己算一遍照样过。那一瞬间我明白了问题所在:

CRC 是防错的,不是防伪的。 它保证”数据没传坏”,但不保证”数据是谁给的”。任何能碰到升级包的人,都能同时改固件和它的 CRC。这不是”通道安不安全”的问题,是设备根本没有能力分辨”这固件是不是我签的”。

而那台设备已经是第三代了——第一代手写 bootloader,第二代加了差分升级(见文末相关阅读),三代下来加的校验一直是 CRC、CRC、还是 CRC。

于是接下来六周,我把固件可信这件事从头做了一遍:信任链怎么搭、镜像怎么签、bootloader 怎么验、怎么防降级攻击、密钥怎么管怎么轮换。这篇把全过程记下来,附一份上产线前的自查清单。

先把威胁模型摆正:CRC、哈希、签名各防谁

动手前必须先分清三种校验的能力边界,否则很容易做出”看起来做了安全”的东西:

手段 防的是 不防的是 攻击者要伪造成功需要什么
CRC32 / 简单和校验 传输误码、Flash 位翻转 任何主动篡改 改完固件重算一遍即可,零门槛
纯哈希(SHA-256 明文比对) 传输误码 + 弱篡改 主动伪造 改固件、重算哈希、替换包里的哈希字段
非对称签名(ECDSA / RSA / Ed25519) 篡改 + 伪造 + 换包 密钥泄露、物理读取内存 拿到签名私钥;设备侧只有公钥,拿到了也只能验不能签

一句话结论:只存公钥的设备,拿不到私钥就伪造不出能过验的包。这就是从 CRC 升级到签名的全部意义。

顺带把两条常见的自我安慰提前否掉:

  • “我的升级包是二进制私有格式,别人看不懂”——安全靠格式保密(security by obscurity)不成立,抓几次包就逆向出来了;
  • “设备只在内网,攻击者进不来”——现场设备的调试口、SD 卡槽、4G 通道、代工厂工装,每一条都是入口。威胁模型里必须假定攻击者能改包,只讨论”他能改到哪一层”。

信任链:锚必须钉在不可改的那一层

签名只解决”包是谁给的”。但这里有个绕不开的问题:验签用的公钥存在哪?如果公钥本身能被改,整条链就是纸糊的。

所以结构必须是分层的信任链(chain of trust),每一层校验下一层:

层 内容 存放位置 能否更新 由谁校验
L0 芯片不可改引导 ROM 引导代码 + 公钥哈希(ROTPK hash) SoC 内部 eFuse / OTP 不能(一次性写) 芯片硬件自己
L1 一级 bootloader MCUboot 本体 + 内置公钥(或公钥哈希) 内部 Flash 起始保护区 极难(需要先验签自己的更新包) L0(用 OTP 里的哈希对 L1 做摘要比对)
L2 应用镜像 业务固件 + 签名 TLV slot0 / slot1 可以(OTA 常态) L1

关键点:不能更新的那一层,才有资格当链的根。

很多项目栽在”把公钥打进应用固件”——那等于把锁和钥匙一起交给对方。公钥必须编译进 L1,L1 的公钥哈希必须在 L0 的一次性写区里。如果你用的芯片 ROM 不支持安全启动(很多老 F1/F0 就没有),那至少要把 L1 所在的 Flash 扇区做写保护 + 关闭调试口读保护(RDP L2),把攻击面从”改 bootloader”收窄到”物理开盖解密”。这不算完美,但比什么都不做强得多。

我们最后选的路:芯片有安全启动,OTP 里烧公钥哈希;MCUboot 作为 L1;应用镜像按 MCUboot 格式签名。下面就是这一层的所有细节。

MCUboot 的镜像长什么样

MCUboot 的镜像是”头 + 本体 + TLV + trailer”四段结构,先把布局认清楚,后面所有报错才好定位:

区域 典型长度 内容 是否被签名覆盖
header 32 字节起(含对齐填充,--header-size 决定) magic(0x96F3B83D)、load_addr、hdr_size、img_size、flags、version(4 字节) 不签头自身,但头的字段计入哈希
payload 固件本体 向量表 + 代码 + 只读数据 是
TLV 区 变长(约 200~300 字节,RSA 更大) 哈希 TLV、签名 TLV、KEYHASH、SEC_CNT… 哈希 TLV 盖住前面的全部内容
trailer 最后 16~32 字节 swap 状态(image_ok / copy_done / swap_size) 不签,由 bootloader 自己写

TLV 区是”带类型的键值对”,常见类型(不同版本略有增补,以你所用版本的 bootutil/image.h 为准):

TLV 类型 值 作用
KEYHASH 0x01 镜像自报”我是用哪把钥匙签的”,内容为公钥哈希
PUBLICKEY 0x02 可选:把公钥整个放包里(便于多密钥)
SHA256 / SHA384 / SHA512 0x10 / 0x11 / 0x12 载荷摘要
RSA2048 / RSA3072 0x20 / 0x21 RSA 签名
ECDSAP256 / ECDSAP384 0x22 / 0x23 ECDSA(P-256 最常见,签名仅 64 字节)
ED25519 0x24 Ed25519 签名
ENCRSA2048 0x30 被 RSA-OAEP 包裹的镜像加密密钥
ENCKW128 0x31 被 AES-KW-128 包裹的镜像加密密钥
SEC_CNT 0x40 安全计数器,防降级用
DEPENDENCY 0x50 依赖另一个镜像的版本(多镜像升级用)

这里有个非常容易想错的两级签名结构,我特意单拎出来:

  • 哈希 TLV 里的摘要,覆盖的是 header + payload + 它之前的所有 TLV;
  • 签名 TLV 里签的,是那个哈希 TLV 的内容(也就是”对摘要签名”,而不是对整个镜像再算一次大签名)。

这个结构带来一个好处:改 header 里的版本号也会被验出来(因为 header 计入哈希)。我第一次看到有人把”改版本号绕过防回滚”当思路时还担心了一下,确认结构后才放心——只要 SEC_CNT 校验也做了,改前面任何一个字节都过不了。

签名算法上,P-256 的签名 64 字节、公钥 64 字节,Flash 开销最小,验签也最快;RSA-2048 签名 256 字节,验签慢一个量级(但兼容老工具链)。我们的表计用 P-256,bootloader 里多出的公钥只占 64 字节。

构建侧:imgtool 那几行命令,每一行都是坑位

MCUboot 自带 imgtool 做签名和组包。别看命令短,参数每一个都对应一个事故:

# 1. 生成签名密钥。只做一次,私钥离线保管(后面密钥管理章节细说)
imgtool keygen -k sign-ec-p256.pem -t ecdsa-p256

# 2. 导出公钥 C 数组 —— 这个数组编译进 bootloader,它就是整条链的根
imgtool getpub -k sign-ec-p256.pem --minimal > root_ec_p256_pub.c

# 3. 签名 + 组包:版本、槽位大小、安全计数一次性定死
imgtool sign \
  --key sign-ec-p256.pem \
  --align 8 \
  --header-size 0x200 \
  --pad-header \
  --slot-size 0x30000 \
  --max-sectors 32 \
  --version 1.4.2 \
  --security-counter 7 \
  app.bin app-signed.bin

# 4. 出厂自检:CI 里必跑,确认签出来的包真能验过
imgtool verify --key sign-ec-p256.pem app-signed.bin && echo "sig OK"

逐条说坑:

  • --align:MCUboot 的写粒度对齐,必须 ≥ Flash 的编程粒度。STM32 是 8(双字),有些板子按 4 就够;对齐给错会在 swap 时出现”地址没对齐”的莫名失败。参数名与默认值各版本略有差异(老版本里 --pad --pad-sig 的写法也能见到),以你本机 imgtool --help 为准。
  • --header-size:镜像头部预留大小,必须等于或大于 bootloader 侧配置的 BOOT_MAX_ALIGN 相关值,且与链接脚本里应用起始偏移一致。
  • --slot-size:这个数必须和 bootloader 的分区表一模一样。我们踩过的坑:改了链接脚本把 slot0 从 0x28000 调到 0x30000,忘了改 CI 里的 imgtool 参数。结果是 imgtool 按 0x30000 组包、bootloader 按 0x28000 读,长度校验居然过了(因为镜像实际没写满),但 swap 到后面越界擦了参数区——症状是”升级成功但参数丢了”,查了两天才定位。结论:分区表必须单一来源,链接脚本、bootloader 配置、imgtool 参数从同一个文件生成。
  • --security-counter:防回滚用的计数器,见下一节。
  • --version:语义版本,写入 header,重启后应用可自报,运维侧核对用。

组包之后一定要做回读校验:用 imgtool verify 只是验了包本身,还得 xxd -l 32 确认第一个双字是 3d b8 f3 96(magic 0x96F3B83D 的小端字节序),确认头真的在前 32 字节、没有多余的引导数据被一起签进去。

设备侧:验签的六步顺序,任何一步不过就拒

bootloader 里的验证路径是这样的顺序(下面这段是按真实验证流程精简的示意代码,用于说明依赖关系,不是可直接编译的完整实现):

/* boot_verify.c —— 简化自 MCUboot 的验证路径,重点是顺序不可乱 */
int boot_image_check(const struct flash_area *fa, uint32_t *out_ver)
{
    /* 1) 头部合法性:magic 0x96F3B83D、hdr_size ≥ 头结构体大小 */
    if (boot_image_header_check(fa) != 0) {
        return -1;                      /* 不是 MCUboot 格式,直接拒 */
    }

    /* 2) 定位 TLV 区:紧跟在 payload 之后,偏移由 img_size 决定 */
    if (boot_find_tlv(fa, IMAGE_TLV_SHA256, &hash_tlv) != 0) {
        return -1;                      /* 没有哈希 TLV = 未签名 */
    }

    /* 3) 摘要比对:防止"路上被改坏"(已签名镜像里这一步几乎不会失败,
          但它能拦住"只改了 TLV 区一小段"之外的粗糙篡改) */
    if (boot_calc_hash(fa, hash_tlv) != 0) {
        return -1;
    }

    /* 4) 安全计数:不低于设备已记录的最高值,防降级攻击 */
    if (boot_check_security_counter(fa) != 0) {
        return -1;
    }

    /* 5) 密钥指纹:镜像自报的 KEYHASH 必须命中内置公钥的哈希 */
    if (boot_check_keyhash(fa) != 0) {
        return -1;                      /* 换了钥匙的包,到此为止 */
    }

    /* 6) 公钥验签:用 KEYHASH 命中的公钥验证"哈希 TLV"的签名 */
    if (boot_verify_signature(fa) != 0) {
        return -1;                      /* 伪造包,到此为止 */
    }
    return 0;
}

三点体会:

第一,顺序里藏着依赖关系。 第 3 步不通过就没必要验签(省一轮 ECC 运算);第 5 步不通过就别浪费算力去验签(KEYHASH 比对是常量时间 32 字节比对,几乎免费)。我们最早的实现顺序是把签名验证放前面,结果是”刷了个错密钥的包,bootloader 要多花 40ms 才发现”——虽然不影响安全,但升级失败时用户等待变长,体验差别能感觉到。

第二,验签失败绝不能”降级放行”。 我见过一个很危险的设计:验签失败时打个日志就跳转执行(”反正包里 CRC 是对的”)。这等于白做。正确行为是:不跳转、不执行,停在该层并进入可恢复状态(比如停留在 bootloader 的 DFU 模式,或复位重试),并且把失败原因(是 magic 错、哈希错、KEYHASH 错还是签名错)上报出去——这四个原因对应的排查方向完全不同。

第三,验签要覆盖全镜像,不能只挑头部。 曾见过”只对前 4KB 验签”的偷懒实现,理由是”省时间”。攻击者只要把恶意代码放在第 4KB 之后就能过。签名是对整包签的,就别只验半包。

防回滚:SEC_CNT 和那个”把秤砣交给被测量对象”的坑

签名解决了伪造,但没解决一个更隐蔽的攻击:降级攻击。攻击者不需要伪造新固件——他只需把设备刷回一个你已经签过、但后来被修补了漏洞的旧版本,然后利用那个老漏洞。包是真的、签名是有效的,唯一的问题是”它太老了”。

MCUboot 的对策是镜像里的 SEC_CNT(安全计数器):imgtool --security-counter N 写进 TLV,bootloader 校验”这个 N 不低于设备已经记录的最高值”。

但这里有个必须警惕的设计陷阱,我们用一句话总结:

如果”最高值”是从被刷的那个镜像里读出来的,那就是把秤砣交给了被测量的对象。

也就是说,那个”设备记住的最高安全计数”必须锚在镜像之外、且攻击者改不动的地方:OTP / eFuse、独立的一次性写扇区、或受写保护的 bootloader 参数区。只做”镜像里自带的 SEC_CNT 与镜像里自带的 version 比一下”,等于没做防回滚。

设备侧维护计数器的代码骨架(寄存器地址随芯片,这里是伪代码,重点在单调性纪律):

/* 安全计数器的物理落点:一次性写区 / 独立参数扇区,绝不在 slot 区里 */
int boot_check_security_counter(const struct flash_area *fa, uint32_t img_cnt)
{
    uint32_t dev_cnt = nv_counter_get();       /* 读设备侧记录,0xFFFFFFFF 表示未写过 */

    if (dev_cnt == 0xFFFFFFFFu) {              /* 全新设备,首次记录 */
        return 0;
    }
    if (img_cnt < dev_cnt) {
        boot_log_fail(BOOT_FAULT_SEC_CNT_LOW);
        return -1;                             /* 老包,拒之门外 */
    }
    /* 注意:这里"允许通过",但先不抬计数 —— 抬计数器要等新固件自检通过后,
       由 confirm 阶段落实。原因见下 */
    return 0;
}

三条纪律,每一条都是血:

  1. 计数器只能单调增,永不回退 —— 这是它之所以能防回滚的物理前提。
  2. 抬计数器的时机必须在”新固件确认可用”之后 —— 如果刷完包立刻抬,结果新固件启动失败、要回滚到老版本,而计数器已经抬上去了,老版本 SEC_CNT 更低 → 回滚被拒 → 设备变砖。这个顺序搞反的代价是设备报废。
  3. 产线烧写要对计数器做二次确认 —— 计数器一旦写死不可回退,烧错一个号这板子就再也刷不回低版本固件。我们产线的做法是:工装读到待烧写计数后弹出确认,且数值来自生产工单而非人工输入。

这里还要提一下版本号与安全计数器的分工,很多团队会混:

手段 拦的是什么 攻击者能绕过吗 用在什么场景
version(1.4.2) 普通降级(人肉刷错、量产刷反) 能——改包的 version 字段重签(因为版本只是元数据) 运维友好、可读、用于显示与比对
SEC_CNT 恶意降级 不能——只要计数器锚在改不动的地方 安全边界,必须配合硬件单调计数

两条都要有,但只有 SEC_CNT 是安全项。 顺便说一句,”支持降级”在有些业务里是刚需(客户买断老版本、验证回滚能力),那就用 SEC_CNT 的”大于等于”策略并配合显式的降级白名单,而不是干脆不做。

槽位与 swap:A/B 布局、scratch,以及四种升级模式的取舍

这套机制能落地,前提是 Flash 分区先规划对。以一颗 512KB 内部 Flash 的 MCU 为例,我们的分区表长这样:

区域 偏移 大小 说明
bootloader 0x08000000 48KB MCUboot + 内置公钥,配置写保护
slot0(运行区) 0x0800C000 192KB 当前运行的镜像
slot1(下载区) 0x0803C000 192KB OTA 下载落点,与 slot0 等大
scratch(交换区) 0x0806C000 16KB ≥ --max-sectors × 扇区大小
参数 + 安全计数器 0x0807F000 4KB 独立扇区,不放进 slot 区

关于 scratch 的大小:如果你选默认的”swap using scratch”策略,scratch 必须能容纳”一次搬运的最大数据块”,即 max-sectors × 扇区大小。我们一开始 scratch 只留了 4KB、max-sectors 却写了 32,结果升级跑到一半报”scratch too small”——这两个数字是一对,必须联算。

MCUboot 支持四种升级模式,选型时会直接影响成本:

模式 需要 scratch 吗 能否回滚 优点 代价
swap using scratch(默认) 要 能 最成熟、掉电可恢复 擦写最频繁、耗时最长
swap using move 不用(借用未用扇区) 能 省下一块 scratch,分区紧凑 中断恢复逻辑复杂,耗时更长
direct XIP 不用 不能(只切换启动槽) 不做搬运,启动最快,零额外擦写 需要两个槽都能被地址空间映射,且镜像必须就地可执行
RAM load 不用 视实现 绕过 Flash 搬运,任意槽位位置 受 RAM 限制,每次上电都要装载

我们选了默认的 scratch 模式,理由很实在:升级耗时 8 秒、多占 16KB Flash,换来的是”任意时刻断电都能起来”。产品是装在配电箱里的,断电极其频繁,这个钱不能省。

升级状态机:那个”升级成功了,重启又变回老版本”的经典事故

MCUboot 有一套”测试镜像”机制,专门防止”刷进去一个能开机但功能全废”的固件把设备占死:

写入 slot1 → 标记为 pending(测试镜像)→ 复位
   → bootloader 验签通过 → swap 到 slot0 → 启动新固件
      → 新固件自检通过 → boot_set_confirmed()  ← 落地为“正式镜像”
      → 若没有 confirm:下次复位触发回滚,回到老版本

这套机制本身很漂亮,但它有一个所有团队都会踩的坑:

新固件的 confirm 逻辑如果放在”网络连接成功之后”,而现场网络恰好不通——它永远不 confirm → 每次掉电都回滚 → 客户看到的界面永远是老版本,而服务器上明明显示”升级成功”。

我们第一次上线就撞上了这个,用户投诉”你们的升级功能是假的”。修正后的原则是:confirm 的判断条件必须是本地可判定的,绝不依赖网络、服务器或任何外部条件:

/* app_main.c —— 上电后的自检与确认,全部本地可判定 */
#define APP_VER_STR "1.4.2"

static int app_self_check(void)
{
    if (clock_is_ok()      != 0) return -1;   /* 时钟树 + 外设时钟 */
    if (flash_id_read()    == 0) return -1;   /* 关键外设逐个探活,不联网 */
    if (rtc_is_running()   != 0) return -1;
    if (meter_cal_loaded() != 0) return -1;   /* 参数区完整性 */
    return 0;
}

void app_main(void)
{
    boot_log_version(APP_VER_STR, boot_get_running_version());

    if (app_self_check() == 0) {
        /* 关键:确认要尽早做,1~2 秒内落实;网络相关的问题不该拦着 confirm */
        boot_set_confirmed();
    } else {
        /* 自检失败不 confirm,让下次复位回滚 —— 这是"防砖"的唯一出路 */
        boot_log_fatal(FAULT_SELF_CHECK_FAIL);
    }

    /* 业务启动、联网、上报都放在 confirm 之后 */
    app_network_start();
}

两条经验:

  • 自检要”快而硬”:只在 1~2 秒内做能确定性判定的检查(时钟、关键外设、参数区)。别把”采集精度是否正常””电池曲线是否合理”这种需要长时间观察的项塞进自检——那些属于业务级异常,应该走”主动申请回滚”的另一条通道。
  • 主动回滚要有独立开关:给运维留一个”当前版本判定不可用,请求回滚”的指令,走 MQTT/私有协议下发,让设备在下一次复位时回滚到老版本。这条路线上线后,客服处理”升级后功能异常”的效率高了很多。

密钥管理:私钥不进流水线,公钥分三套环境

前面所有机制的安全性,最终收敛到一句话:私钥不能泄露,公钥不能打错。

我们犯过一个至今想起来还冒冷汗的错误:早期的开发包直接用了仓库里的 dev 密钥签名,而某个小批量试产的工装镜像里内置的也是 dev 公钥。这意味着任何拿到那份 dev 私钥的人(它在 git 历史里躺着),都能给这批设备刷任意固件。发现后连夜重签、重烧了那批 200 台板子。

从此我们定了三套环境隔离:

环境 私钥存放 公钥落点 允许调试口 说明
dev 开发者本机(仓库里有,仅用于本地调试) 开发板 bootloader 允许 严禁进任何试产/量产工装
staging CI 签名服务(受权限控制) 小批量试产板 允许 流程与产线完全一致
prod HSM / 签名服务器,私钥不出硬件 量产 bootloader(OTP 烧公钥哈希) 禁止(RDP L2 + 安全启动) 流水线上只出现”已签名 bin”

配套的四条硬规矩:

  1. 签名步骤在签名服务里完成,流水线只上传未签名 bin、下载已签名 bin——私钥不进 CI 环境变量,更不进代码仓库。
  2. 量产工装拿到的是已签名 bin,不是私钥——工装无法自造固件,只能烧已知合法的包。
  3. 每台设备烧写后回读比对 + 自报公钥指纹——设备通过日志/上报给出”我正在信任的公钥哈希前 8 字节”,运维侧与预期值核对。这条能第一时间发现”烧成 dev 公钥”的事故。
  4. 签名产物记录进台账:版本号 + 安全计数 + keyhash + 构建哈希,一一对应。出问题时能立刻回答”现场那台设备跑的是哪个包”。

密钥轮换与泄漏应急:三段式过渡 + 一张决策表

密钥是有寿命的:算法要升级、人员会流动、审计会要求轮换。但设备侧只有一把内置公钥的架构下,换密钥是个危险动作——旧固件只认旧公钥,新固件只认新公钥,直接切会有一半设备变砖。

我们最后用的”三段式”过渡(MCUboot 默认只认编译期那一把公钥;要做多公钥需配合 KEYHASH 白名单/自定义验证钩子,具体能力以你所用版本与厂商 SDK 为准):

阶段 bootloader 侧认哪些公钥 本轮发布的固件 目的
1 铺路 {old} 用 old 签,包内附带 new 公钥,应用把它写入备份区 设备先”认识”新钥匙
2 切换 {old, new}(按哈希命中) 用 new 签 新老都能验,全网平滑切换
3 收紧 {new} 用 new 签 老钥匙彻底失效,轮换完成

每一段之间留一个完整的现场升级周期(我们留了 6 周,因为现场有设备长期离线)。绝不允许跳过阶段 1 直接进阶段 2——那样离线设备回来时会拿到一把它不认识的钥匙。

如果私钥疑似/确认泄露,动作和”常规轮换”完全不同,这里给一张决策表:

场景 现场设备可回收? 处置动作
dev 私钥泄露 是 直接换、重置 dev 环境;确认没有量产设备信任这把公钥
prod 私钥疑似泄露(未确认) 设备可联网 抢时间窗:立刻用旧私钥签一个”收紧公钥”的中间固件(此刻旧私钥还有效),把新公钥推下去并收紧白名单,之后旧私钥作废
prod 私钥确认泄露 设备不可回收 承认 OTA 通道的可信性已失去:关闭远程升级、改走线下工装升级,配合召回;或启用硬件侧的密钥吊销位(需 OTP 预留),把损失限定在新固件分发范围

第二行那个”用泄露的私钥签一个补丁”听起来反直觉,但它是唯一能批量救回现场设备的窗口——因为此时设备还认这把钥匙。前提是行动够快:从确认泄露到固件下发,必须在攻击者利用之前完成。

加密镜像:它防的是谁,不防的是谁

讲完签名,顺带把常被误解的”镜像加密”说清楚。我们这类表计的固件里有计量算法和通信密钥,客户提过”能不能别让人把固件读出来”。

MCUboot 支持镜像加密,机制是:

  • 只有 payload 被加密,header 和 TLV 区保持明文;
  • 加密算法是 AES-CTR-128,密钥每次签名随机生成(绝不复用);
  • 被加密的镜像要在 header 里置 ENCRYPTED 标志(0x04),并把 AES 密钥用非对称方案包裹后放进 TLV——RSA-OAEP(TLV 0x30)或 AES-KW-128(TLV 0x31);
  • 设备侧用自己烧进去的私钥/密钥解密出 AES 密钥,先把每个数据块解密,再送去做哈希校验。

最后这一条是最容易想反的地方,我第一次画流程图时就画错了箭头:

哈希和签名覆盖的仍然是明文数据。 不是”签名盖在密文上”,而是”解密后再验”。所以别按”签名针对最终落盘字节”的直觉去推,这个直觉在这里不成立。

加密镜像的威胁模型边界也必须说清楚(MCUboot 文档里写得很直白):

它能防 它不能防
外部 Flash / SD 卡上的固件被复制走做逆向 JTAG/SWD 直读内部 Flash 拿明文
升级包在传输途中被抓取后反编译 开盖 + 侧信道等物理攻击
工装临时存储被拷走 设备自身被拆解后从运行内存 dump

也就是说:加密镜像 ≠ 防抄板。真要防物理读取,必须叠加芯片级手段(RDP L2、安全启动、Flash 加密外设)。另外两个实现细节要注意:

  • scratch 区是不加密的,所以它必须落在内部 Flash 里;如果为了省钱把 slot1 放在外部 SPI Flash,swap 过程中明文会在内部 scratch 短暂出现——这个窗口要知道。
  • 加密会带来轻微的解密开销和每条镜像一个 TLV 的体积(RSA-2048 包裹约 256 字节;AES-KW-128 只要几十字节),但 AES-CTR 本身很快,有硬件加速基本无感。

我们的结论:表计设备加上了(成本可接受、客户有明确要求),但如果你的产品只是普通控制器、客户也没提,把签名链做扎实的收益远大于加密——先做对签名,再谈加密。

怎么证明它真的拦得住:五连测

安全功能最怕”自我感觉良好”。上线前我们做了五组验证,每一组都必须看到”被拒绝”的证据,而不是”看起来没问题”:

  1. 篡改测试:用 imgtool sign 签一个正常包,然后用脚本改 payload 中间某个字节(不动头、不动 TLV),装进 slot1 → 设备必须拒刷,日志报”hash 不符”。再改一次 header 里的版本号 → 也必须拒(这条验证两级签名结构真的生效了)。
  2. 错密钥测试:用另一对密钥签同一份固件 → 必须拒,报”KEYHASH 不符”。这一条特别重要,它验证的是”公钥真的是链的根”。
  3. 降级测试:签一个 1.4.1 版本、SEC_CNT 低于设备现值的包 → 必须拒。这条最容易”以为做了其实没做”——很多项目镜像里带了 SEC_CNT,但设备侧的计数器没有独立锚点,测的时候能拦住、换个手法就能绕。
  4. 断电测试:把升级过程在 swap 中间用继电器随机切电 20 次,每次上电设备都要能起来(要么新版本要么旧版本,不能变砖、不能卡死)。这一条验证的是”掉电可恢复”,只有真实断电才算数。
  5. 指纹回读测试:让设备上报当前镜像的版本号 + keyhash 前 8 字节 + SEC_CNT,与签名台账逐项核对。这一条能暴露”烧错公钥””空中包与台账不符”这类配置层事故。

顺手把验证结果整理成一份按季度跑一次的回归用例,密钥轮换、升级模式调整之后必跑。这五条一共花不到半天,但它们给出的信心比读十遍文档都实在。

上产线前的自查清单

签名链、防回滚、密钥管理、状态机四块都做完之后,按这张单子逐项过一遍再上线:

  • ☐ 公钥(或公钥哈希)已编译进 L1 bootloader,未出现在应用固件中
  • ☐ L0 的 OTP/eFuse 已烧公钥哈希,且安全启动已实际开启(不是”配置了但没生效”)
  • ☐ L1 bootloader 所在扇区已写保护,调试口已按产品定位设置读保护(RDP 等级已确认)
  • ☐ 分区表单一来源:链接脚本、bootloader 配置、imgtool --slot-size 由同一份定义生成
  • ☐ --header-size、--align 与 bootloader 侧配置一致,且已通过回读 magic 确认头部位置
  • ☐ scratch 大小 ≥ max-sectors × 扇区大小(已联算,不是拍的)
  • ☐ 安全计数器的物理落点不在 slot 区,且已验证”刷包不会擦掉它”
  • ☐ 抬安全计数器的动作发生在 confirm 之后,不是刷完包立刻抬
  • ☐ 验签失败的行为是”拒绝跳转 + 停在可恢复状态 + 上报原因”,没有任何降级放行分支
  • ☐ 新固件的 boot_set_confirmed() 判断条件全部本地可判定,不依赖网络/服务器
  • ☐ dev / staging / prod 三套密钥已物理隔离,量产工装内只有已签名 bin、没有私钥
  • ☐ 每台设备烧写后有”公钥指纹回读 + 与台账核对”的环节
  • ☐ 密钥轮换的三段式方案已文档化,且留有一个完整现场升级周期的窗口
  • ☐ 五连测(篡改 / 错密钥 / 降级 / 断电 / 指纹)全部跑通,结果已归档为季度回归用例

结语:签名链的价值是”把可信变成可验证”

六周做完,最大的感受是:这套东西的收益不在”防住了某个具体攻击”,而在于它把”这固件是我们发的”从一句口头承诺,变成了一条设备自己能算出来的结论。

它逼着我们回答了几个以前一直含糊的问题:公钥到底在哪一层?”设备记得的最高版本”存在哪个物理位置?升级成功的标志到底是”服务器收到了 ACK”还是”设备自检通过并确认”?密钥出了事,我们能在多久内把全网设备换到新钥匙上?

这些问题的答案,比签名字节本身有价值得多。而且有意思的是:做完这套,顺便把 OTA 的可靠性也提高了一个档次——因为”回滚””自检””确认”这些机制,本来就是升级功能该有的东西,只是以前一直靠人肉兜着。

那台设备上个月过了复测,报告第 11 页现在写的是”固件升级通道具备基于非对称签名的来源验证,未发现可绕过的实现缺陷”。从”中危”到”通过”,中间是一整套信任链。有在过安全评估、或者正在给 OTA 补签名的朋友,欢迎留言交流。


相关阅读: