差分包也要签名:一次”包是假的但签名是真的”的复盘,以及差分的基线依赖多出来的那几条缝

2026年10月10日 0 By admin

上一篇(LIN 总线主从调度)文末留了两个方向:差分升级和签名链怎么结合,或者 RT-Thread 设备框架下的传感器分层。这周的活先到了前者——一个客户的硬件安全评估报告摊在我面前,第 7 页写着”固件差分升级通道的完整性与来源验证机制需要重新评估”。报告用的是很客气的措辞,翻译过来是:你们的差分包,我们能换。

站里 10-05 那篇《MCUboot 安全启动与签名链》写了全量升级的签名链,09-22 那篇《差分升级 bsdiff》写了差分算法和落地。这两篇各写了一半,中间那块一直空着。这一篇来补。

引子:签名是真的,包是假的

先说评估方干的事。他们拿到我们的升级服务器地址(客户授权范围内的渗透测试),然后做了这么两件事:

第一步,抓了一个历史的差分包。 没什么技术含量——从 CDN 上直接下,我们的差分包 URL 是带时间戳的预测式路径,猜得出来。抓到的这个包,是 v1.3.1 → v1.3.2 的差分,27KB。

第二步,把这个包配上一个”新的”签名清单。 这是关键。他们拿到的签名清单本身也是我们已经发布过的合法清单——只不过对应的是另一个版本。他们做的是把老清单里的载荷摘要换掉,然后把清单和差分包重新拼在一起。

结果是:设备收下、验签通过、解包完成、写入 slot1、重启。

我第一反应是”不可能,签名怎么会过”。他们给我看了日志——[通过] 签名验证。然后我坐下来读自己的代码,二十分钟后我明白了,而且明白了之后出了一身冷汗:我们的签名盖的是清单,而清单里的载荷摘要只是一个字段;这个字段当然被签名保护了,问题是我从来没有验证过”这个清单和这个载荷是不是一起来的”。

说得更精确一点:我的清单里根本没有”这分包该有多大、内容是什么”的字段。签名的对象是清单,清单里写的是”新版本是 1.3.2″,而新版本的内容从哪里来,我靠的是”反正差分包会算出来”。这是个循环论证——我用 A 的内容去验证 B,又用 B 的内容去验证 A,中间没有任何一个锚点。

这篇不写”签名是什么”(站里 10-05 已写透),也不写”bsdiff 怎么用”(09-22 写透了)。这一篇写的是当”升级包”从一个自封闭的二进制,变成一个”清单 + 载荷 + 基线引用”的三段式结构之后,多出来的那些缝,以及怎么堵。这是差分升级独有的,全量升级时代你不需要操心的部分。

这一篇和站内已有两篇的边界:10-05 那篇讲的是全量镜像的签名链(信任层级、TLV、SEC_CNT 锚点、密钥轮换、镜像加密);09-22 那篇讲的是差分算法本身(bsdiff/bdiff 选型、内存公式、Mcu 侧流式解析、六个落地坑)。这一篇只讲交叉的那一小块:差分包自己怎么被签名覆盖、基线依赖如何攻击、防降级的计数器在”一次升级有两个成功点”之后要怎么改。三篇合起来才是完整的 OTA 安全。

先给结论

  1. 差分包的签名不能只盖”差分包”,也不能只盖”清单”。 正解是 SWUpdate 那套”清单签名 + 产物哈希”:签名只盖一个很小的清单(250 字节左右),清单里写死载荷的摘要、基线的摘要、安全计数、目标硬件 ID。签名成本与载荷大小解耦——这是差分包能兼顾安全和体积的唯一办法。
  2. 全量包的验证是自封闭的,差分包的验证是开放式的。 全量包只需要”我和我自己自洽”;差分包必须引用”设备现在跑的那一份”。这个引用一旦弱化(只比版本号、用 CRC32、或摘要不进签名),攻击者就能把基线换成他想要的那份。这是差分升级最大的、也是全量升级不存在的攻击面。
  3. old_crc32 只能防”路上坏了”,防不了”有人想让你坏”。 09-22 那篇里我把 old_crc32 称作”差分方案的安全绳”——那句话在工程可靠性的语境下是对的(它确实是拦基线错配的第一道闸),但在安全语境下它是错的。CRC 是公开的线性函数,构造等 CRC 碰撞在 PC 上是毫秒级的事。这一篇要修正这个说法。
  4. 从”一个计数器”改成”两个计数器”。 全量升级只有一个成功点,一个 SEC_CNT 够用。但差分升级的解包是多步过程(收块 → 解压 → 应用 → 落盘 → 验签),而且一次升级有两个不同的成功含义:① 新镜像落地并验签通过(可以抬 install_cnt)② 新镜像真跑起来并自检通过(才能抬 commit_cnt)。抬早了,回滚就被拒、设备变砖;抬晚了,重放有窗口。两个计数器才能同时保住这两件事。
  5. 断点续传是差分在弱网下的常态,也是新的攻击面。 逐块摘要必须做,而且块摘要列表本身也要进清单,否则续传的块归属无人验证。
  6. 差分包越小,签名的固定开销占比越难看。 27KB 的包加一个 256 字节的清单,开销 0.9%,可以忽略;但如果你的差分包只有 4KB(很常见,改了几行的补丁),256 字节就是 6.3%。这时候该讨论的不是”怎么把清单做小”,而是”这个升级该不该用差分”。

一、先把”缝”画出来:三段式结构天生比自封闭结构多三个接口

全量升级的结构是这样的:

[ 签名 ][ 镜像本体 ]        ← 一个自封闭的整体

设备拿到它,算一遍哈希、验一次签名,就成了。它不需要知道设备现在是什么状态。这是全量升级最大的结构性优点,以前我没意识到——直到做了差分才发现,这个”不需要知道”省掉了多少麻烦。

差分升级的结构变成了三段:

[ 签名 ][ 清单:基线摘要 / 载荷摘要 / 安全计数 ... ][ 差分包载荷 ]
              ↑                    ↑
          引用设备现状        引用载荷内容

三个新出现的接口:

接口 全量包 差分包 攻击者能做什么
镜像 → 签名 直接签 签的是清单,不是载荷 换载荷(清单签名照过,只要清单里的载荷摘要没用上)
镜像 → 设备现状 不存在 必须绑死一个唯一基线 换基线(用一个设备没有的版本当基线,或让设备以为自己是别的版本)
镜像 → 目标设备 可选(型号可写可不写) 必须写(否则基线无从谈起) 跨型号重放(把 A 型号的包发给 B)

这三个接口,就是那篇评估报告说”需要重新评估”的地方。而且注意:这三个接口的失效都不需要攻击者拿到私钥。它拿的都是合法产物,只是把它们错配起来。这类攻击有个名字,叫 mix-and-match(非匹配)——签名是真的,每个字节都是真的,只有组合是假的。

我第一版设计里,第二个接口是空的(清单里没有载荷摘要),第三个接口是明文(型号字段没进签名),第一个接口靠版本号字符串。三个接口全漏。


二、正解:清单签名 + 产物哈希

理清问题之后,解法其实很标准,不是我想出来的,是 SWUpdate 那套模型的直接搬运。我先把它讲清楚,再说我们怎么落地的。

2.1 为什么不能”直接签差分包”

有人会说:那就把签名直接盖在差分包上不就行了?技术上可行,但有两个问题:

  • 签名开销在差分包上被放大。 差分包 27KB,一个 ECDSA P-256 签名 64 字节 + 公钥指纹 32 字节 + 结构字段,加起来 250 字节。如果同时签”清单”和”载荷”,就是两份签名,500 字节。而如果只签清单、载荷靠摘要绑定,同样只要 250 字节。
  • 载荷摘要本来就是必须的。 不管签不签载荷,你都需要一个”落地后复验”的摘要(校验解包结果),和一个”传输完整性”的摘要(校验接收的块)。既然已经有了这个摘要,再单独签一次载荷就是重复劳动。

SWUpdate 的做法是:只有 sw-description 被签名,所有产物靠 sw-description 里声明的 SHA-256 验证。 官方文档的表述很直白——”只有 sw-description 文件被签名;单个产物用已签名描述中声明的哈希来验证”。这个模型的三个好处:

  1. 签名成本与产物数量、体积完全解耦;
  2. 一个清单可以描述多个产物(多镜像升级);
  3. 清单本身小到可以整体读进 RAM,解析简单。

2.2 我们的清单长什么样

我们把清单做到 249 字节(对齐 8 字节后 256),字段如下:

字段 字节 为什么要:
magic 4 格式识别,防”把别的文件当清单解”
format_ver 1 清单格式版本(不是固件版本!),用于将来扩展
header_len 2 清单自身长度,防截断攻击(只发前半段)
target_hw_id 8 目标硬件型号/板级 ID,防跨型号重放
security_cnt 4 被绑进签名的安全计数器,防降级核心
new_version 8 目标版本号(major/minor/patch/build)
base_version 8 基线版本号(仅供报文比对,非安全项)
base_digest 32 基线镜像 SHA-256 —— 防基线错配
new_digest 32 新镜像 SHA-256 —— 落地后复验
new_size 4 新镜像总长度
patch_size 4 差分包载荷长度
patch_digest 32 差分包自身 SHA-256 —— 防传输损坏/分块替换
min_boot_ver 4 最低可接受 bootloader 版本,防旧 BL 绕过新校验
chunk_size 4 分块传输块大小
chunk_count 4 块数
flags 2 位标志:加密 / 强制全量 / 降级白名单

清单未签名部分是 153 字节,加 64 字节签名(P-256)+ 32 字节 keyhash,合计 249。

有个细节值得单独拎出来说:base_version 和 base_digest 同时存在,但只有后者是安全项。 前者是给人看的(报文里好读、日志里好打),后者是给校验用的。我第一次设计时只放了 base_version,就是第一章说的”第二个接口是空的”。

判断标准很简单,可以用一句话概括,我把它抄在本子上:

凡是决定”装什么 / 给谁装 / 能不能装”的字段,全部进签名清单。

留在明文里的,只能是展示性信息。

base_digest、new_digest、security_cnt、target_hw_id、min_boot_ver、patch_digest —— 这六个都是决定性的,必须进签名。版本字符串、发布日期、更新说明 —— 这些是展示性的,明文无妨。

2.3 min_boot_ver 那个字段,是我后来加的

这个字段一开始没有,是第二次评审的时候被人问出来的:“如果你的新校验逻辑在 bootloader 里,那我给你一个用老 bootloader 的设备发新格式的包,会发生什么?”

答案是:老 bootloader 不认识新格式,可能直接跳过校验放行,可能解析出错。升级通道的校验逻辑本身也需要防降级——这是很容易忽略的一层。加 min_boot_ver 之后,设备侧在读清单的第一时间就比对”我的 bootloader 版本够不够”,不够就拒,连签名都不用算。

配套的纪律是:bootloader 自己不能通过 OTA 升级(至少不能走同一条通道)。它得跟着产线走,或者走一个独立的、更严格的通道。我们选的是前者——bootloader 只随产线更新,现场永远不刷 BL。代价是”发现了 BL 的漏洞只能返厂”,但收益是”升级通道的信任根不会被升级通道自己改动”,这个交换我认为是划算的。


三、基线依赖:差分独有的那一刀

第二章说的”第二个接口”,是差分升级区别于全量升级的核心。值得单独一章。

3.1 为什么基线的绑定强度决定了差分包的安全性

设想攻击者要做的事:让设备装上一个他想要的内容。在差分包模型下,他有两条路:

  • 路 A:伪造一个差分包 + 伪造清单。需要私钥。走不通。
  • 路 B:不动签名,只动”设备认为自己的基线是什么”。不需要私钥。

路 B 为什么可行?因为设备是这么干活的:

新镜像 = 旧镜像(设备自己读出来) + 差分包(攻击者给的)

如果”旧镜像”这一项可以被攻击者影响,那么他控制了一半的输入。他能做到多好,取决于基线是怎么被绑定的。我们把这几种绑定的强度排一排:

基线绑定方式 字节 抗碰撞量级 攻击难度
只比版本号字符串 0 — 改个字段就行
CRC32 4 2^32 毫秒级构造等 CRC 碰撞
CRC64 8 2^64 位宽够,但 CRC 是线性函数,仍有构造性方法
SHA-256[:8](截断 8 字节) 8 2^64 工程上够用,但非标准、且碰撞成本比完整版低得多
SHA-256[:16] 16 2^128 够用
SHA-256(完整) 32 2^128 标准选择

我们的固件是 812KB,SHA-256 相对它占 32 / 831488 = 0.004%。算 SHA-256 要多久?按 1 MB/s 保守估(STM32F4 上的软件实现大概这个量级,有硬件加速更快),812KB 约 793ms。

32 字节和 793 毫秒,换一个对碰撞免疫的基线引用。没有任何理由不用完整的 SHA-256。 这个账我在评审时算了一遍,之后再没人提”能不能省点字节”。

3.2 三个现场最常见的弱化用法

评估报告之外,我自己在别的项目里见过这三种做法。都是我称之为”看起来做了”的做法:

A) 只比版本号字符串。 这个最普遍,因为它最自然——报文里本来就有版本号,顺手比一下就完了。问题是:同一个版本号可能对应多个不同构建。这在现场太常见了:

  • 现场工程师用 ST-Link 手工刷了一个改了 IP 宏的分支,版本号没动;
  • CI 没做可复现构建,同一份源码在两台机器上编出的二进制不同;
  • 客户拿了一个”内部测试版”,编译选项和生产不同。

我亲身遇到过一次:某台样机的 App 区被人用 ST-Link 刷了别的分支,设备自报版本号还是 v1.2.0。09-22 那篇里我提到是靠 old_crc32 拦下来的——这一条是真的。但拦住它的是 CRC 的”噪声检测”能力(内容确实不一样了),而不是对抗攻击者的能力。

B) 用 CRC32 做基线摘要。 见上一节,2^32 空间配线性函数,PC 上毫秒级构造碰撞。

C) 基线摘要不进签名。 这个最致命,也是我们被评估方打穿的那一条的变体。摘要明文放在包里,攻击者在改摘要的同时改别的地方——只要签名覆盖的范围不含这个摘要,签名照过,等于没绑。

这里我要修正 09-22 那篇里的一个表述。那篇里我写:

old_crc32 是整个差分方案的安全绳。

这句话在工程可靠性语境下是对的——它确实是拦基线错配的第一道闸,救过我一次。但在安全语境下它是错的,而且错得有点危险:它可能让人以为”我已经有了一个防基线的机制”。正确的说法应该是:

old_crc32 是可靠性闸门(防传输损坏、防错配),不是安全闸门。

安全闸门必须是”被签名覆盖的 SHA-256 基线摘要”。两者都要有,且不能互相替代。

我后来在清单里保留了 old_crc32 的等价物(patch_digest + base_digest 双摘要),但它的角色变了:从”安全绳”降级为”第一道廉价的快速筛查”——CRC 算得比 SHA-256 快得多,可以先筛一遍,再用完整摘要确认。

3.3 设备侧怎么用这个摘要

关键的一句:设备侧必须用”自己算出来”的摘要去比对,不能信任包里报上来的摘要。

/* boot_delta_check.c —— 基线绑定的核心判断,简化自实际实现 */
static int delta_check_base(const struct delta_manifest *mf)
{
    uint8_t  running_digest[32];
    uint32_t img_cnt;

    /* 1) 读设备侧的计数器(两个都要读,见第四章) */
    uint32_t ins_cnt = nv_counter_get(NV_INSTALL_CNT);
    uint32_t cmt_cnt = nv_counter_get(NV_COMMIT_CNT);

    /* 2) 镜像侧安全计数:对两个计数器分别判定 */
    if (mf->security_cnt < cmt_cnt) {
        /* 低于已确认值 = 降级,直接拒 */
        boot_log_fail(SEC_FAULT_SEC_CNT_LOW);
        return -1;
    }
    if (mf->security_cnt < ins_cnt) {
        /* 低于已安装值 = 处于"已安排但未确认"的重放窗口,也拒 */
        boot_log_fail(SEC_FAULT_REPLAY_WINDOW);
        return -1;
    }

    /* 3) 硬件型号:清单里的字段已被签名覆盖,这里比对是有意义的 */
    if (mf->target_hw_id != hw_id_read()) {
        boot_log_fail(SEC_FAULT_HW_MISMATCH);
        return -1;
    }

    /* 4) 基线摘要:**自己算**,不信包里的说法 */
    if (sha256_flash(APP_SLOT0_BASE, mf->base_size, running_digest) != 0) {
        return -1;                       /* 读 Flash 都失败,别继续了 */
    }
    if (memcmp(running_digest, mf->base_digest, 32) != 0) {
        /* 基线不匹配 —— 不是攻击也常见于"现场刷过别的构建",
           两者处置相同:要全量包 */
        boot_log_fail(SEC_FAULT_BASE_MISMATCH);
        return -1;
    }
    return 0;
}

第 4 步那个注释很重要。基线错配的现场原因,八成不是攻击,是”现场动过固件”。 处置方式完全一样(要全量包),但上报时的措辞要照顾现场——如果日志写”检测到攻击”,现场工程师会先排查安全问题,方向就偏了。我们最终的上报文案是”基线不匹配,请使用全量升级包”,并在服务端把这类事件单独计数。如果某型号的 BASEMISMATCH 计数突然涨了,那才需要怀疑攻击。


四、双计数器:差分的”两个成功点”逼出来的改动

这一章是差分升级对防回滚机制提出的结构性修改,我认为是这篇最有价值的部分。

4.1 全量升级时代:一个计数器为什么够

10-05 那篇里我们建了 SEC_CNT 机制:镜像里带一个安全计数,设备侧在 OTP 或独立参数扇区记录”见过的最高值”,镜像的计数低于设备记录值就拒。一条纪律很关键:抬计数器的时机必须在”新固件确认可用”之后——如果刷完立刻抬,结果新固件启动失败要回滚,而计数器已经抬上去了,老版本计数更低 → 回滚被拒 → 设备变砖。

全量升级只有一个”成功点”:新镜像写入 → 重启 → 自检通过 → confirm → 抬计数。一条线,一个计数器,逻辑闭合。

4.2 差分升级时代:两个成功点

差分升级的流程变成这样:

收差分包(可能跨几次连接,断点续传)
  → 验签 + 验基线摘要(**此时还没动 Flash,只是检查**)
    → 解压应用,写出新镜像到 slot1
      → 复验 new_digest
        → 复位 swap 启动
          → 新固件自检通过 → confirm

这里有两个性质不同的成功点:

成功点 含义 掉了会怎样
S1 新镜像落地 + 验签通过 “我已经准备好了一个候选镜像” 掉电重来即可,slot0 完好
S2 新镜像健康启动 + confirm “这个候选镜像真的可用” 掉电后需要回滚到 slot0

如果只用一个计数器,你必须选一个时机去抬它,两个选择都是错的:

  • 在 S1 抬:S1 到 S2 之间失败了要回滚,但计数器已经抬了 → 回滚被拒 → 变砖。
  • 在 S2 抬:S1 到 S2 之间是一个窗口,此时设备里有一个”已落地的候选镜像”,而计数器还没动。攻击者可以在这个窗口里塞一个 security_cnt 等于或略高于老值的合法旧包,设备会接受它。

这个窗口在差分场景下比全量场景宽得多,因为差分的 S1 阶段本身就是多步的(收块 → 解压 → 应用 → 复验),可能持续几秒到几十秒,中间还可能有断点续传带来的小时级间隔。

4.3 双计数器:把两种成功分开记

解法很直接,就是拆成两个计数器:

计数器 物理位置 何时抬 作用
install_cnt OTP / 独立参数扇区 S1 之后(新镜像落地 + 验签通过) 拦住”已安排安装”状态下的重放替换
commit_cnt 同上 S2 之后(confirm) 保住合法回滚能力 + 防降级

判定规则:

放行条件:镜像.security_cnt >= install_cnt  且  >= commit_cnt

用这个规则重新走四条路径:

P1 正常升级(install=7, commit=7,镜像 cnt=8):

验签通过 → 落地 → 抬 install(7→8)→ 复位 swap → 自检通过 → confirm → 抬 commit(7→8)。稳定态 install=8、commit=8,与镜像一致。

P2 升级后自检失败要回滚(install=8, commit=7):

新固件自检失败不 confirm,触发回滚,回到老固件(cnt=7)。此时判定 7 >= commit(7) ✓ 但 7 < install(8) ✗ —— 按上面的规则会被拒!

这就是为什么我在脚本里把 P2 单独列成”设计陷阱”。要让它工作,回滚路径必须读 commit_cnt 而不是 install_cnt:

/* 回滚到 slot0 时,只对 commit_cnt 判定 —— 因为 install_cnt 抬高的是
   "曾经安排过一次安装"这个事实,不代表 slot0 变得不可信。
   但如果新固件已经 confirm(install=commit=8),那就绝不能回滚到 cnt=7 的老固件。 */
if (rollback_target_cnt < nv_counter_get(NV_COMMIT_CNT)) {
    boot_log_fail(SEC_FAULT_ROLLBACK_DENIED);   /* 已确认过新版本,不许回退 */
    return -1;
}

两个计数器读的对象不同,这是整个设计的枢纽。 正常启动路径读 commit_cnt(严格,因为这是”要跑到什么版本”);安装判定读 install_cnt(更严格,因为要防”已安排安装”期间被替换);回滚判定读 commit_cnt(宽松,因为要保住回退能力)。我第一次实现时三处都读 install_cnt,结果 P2 路径直接变砖,靠 mbed 仿真才发现。

P3 攻击者重放旧包(install=8, commit=8,重放 cnt=6 的老包):

6 < commit(8) → 拒。攻击者想改 install_cnt 也没用,它在 OTP 里。

P4 差分基线被替换(设备实际是 v1.2.0-buildB,自报”v1.2.0″,服务器按 buildA 生成差分):

设备算 base_digest 实际值 = H(buildB),清单里是 H(buildA) → 拒,请求全量包。这条靠的是摘要绑定,跟计数器无关——但它说明了一件事:差分升级的防线是分层的,计数器和基线摘要各管一段,谁都不能替代谁。

4.4 一条操作纪律:install_cnt 每次升级只抬一次

差分升级的解包是流式多步的(09-22 那篇里的 apply_patch() 循环,1KB 缓冲写完就落盘)。install_cnt 只能在整包复验通过后抬一次,不能每应用一块就抬。

理由很实在:如果每块都抬,中途掉电重启,重来的时候镜像的 security_cnt 已经低于抬高的 install_cnt 了 —— 自己把自己锁在门外,而且这次不是攻击,是掉电。这种 bug 在实验室里几乎测不出来(谁会想到升级到一半拔电),现场一拔一个准。

我们在评审时把这条写成了硬约束:所有 nv_counter_inc() 的调用点必须能被 grep 出来,且总共不超过三个(install / commit / 产线烧写)。超过三个就说明有人把计数器当普通变量用了。


五、断点续传:弱网下绕不开的第三块攻击面

这一章短,但是差分升级在现场最容易翻车的地方——因为弱网下的断点续传对差分包是常态,对全量包只是可选。

5.1 为什么差分包必须做续传

全量包 812KB,弱网 30KB/s 下要 27 秒,重传一次多一点也无所谓(只要流量不心疼)。差分包 27KB,1 秒就下完——但如果你的设备在 NB-IoT 上(那才是差分的真正用武之地),27KB 可能要跑 5 分钟,期间网络抖动、信号丢失是必然的。

所以差分 + 弱网 = 必做断点续传。 而一旦做了续传,就引入一个新的问题:怎么知道”我续传上来的这一块,属于这个包”?

5.2 整包摘要为什么不够

最朴素的做法是”全下完再算一次整包摘要,不对就重来”。这在弱网下是灾难——5 分钟的传输,最后一块坏了,全部重来。

所以必须逐块校验。但只加逐块摘要还有洞:

做法 结果 原因
只有整包摘要,无逐块摘要 攻破 整包摘要最后才算;续传过程中块的归属无人验证
逐块 SHA-256,但不绑块序号 部分拦住 块大小相同时可交换两块位置
逐块 SHA-256 + 块序号绑定 + 列表进签名清单 拦住 推荐做法

5.3 我们的块结构

块结构最后定成这样,清单里放 chunk_size / chunk_count,以及一个块摘要列表(也进签名覆盖):

清单(签名覆盖):
  chunk_size   = 2048
  chunk_count  = 14
  chunk_digests[14]   ← 每块 SHA-256 的前 16 字节(截断够用,且省 448 字节)

每个块(线格式):
  [seq:2][payload:2048][pad:0~2047]
  seq 必须等于清单里的下标,且不接受重复 seq

chunk_digests 我用了截断的 16 字节。理由:块摘要的作用是防”传输损坏”和”块错配”,不是防有算力的攻击者——后者根本不需要攻击块,直接攻击清单就行(而清单有完整签名)。16 字节 = 2^128 空间,对”偶发损坏”来说是天文数字,同时省下 14 × 16 = 224 字节(完整 32 字节的话要多花 448 字节,占 27KB 包的 1.6%)。

这个取舍的前提是”块摘要列表进了签名清单”。 如果列表没进签名,那攻击者可以改列表,16 字节就真的不够了。截断摘要的安全性依赖于”列表本身被完整签名保护”这个前提——这一点我在文档里特意写了一句,因为后来有人问”为什么不用完整 32 字节”。

5.4 一个续传导致的现场故障

顺手记一个真实故障,就是 09-22 那篇最后提到的那个:实验室局域网测得好好的,到了现场 30KB/s 的弱网环境,分块重传时暴露了”old 指针状态没保存”的问题。

根因是:apply_patch() 是有状态的——old_pos 和 written 是跨块连续推进的。我在分块传输时只保存了”收了哪些块”,没保存”应用到哪一块了”,导致续传后从头开始应用,但 old_pos 是上次的残留值 → 补丁错位。

修法是把状态机做成可重入的:要么保证”要么全应用要么不应用”(用一块临时区做 staging),要么把 old_pos/written 也持久化进参数区,每次恢复时校验一致性。

我们选了第一条,因为它更简单也更安全:差分包先完整落到一块临时区(我们用的是 slot1 的空间,因为最终也要写到那儿),全部收齐、逐块验完、整包验完,才开始应用。 代价是需要额外的存储空间放差分包——但差分包只有 27KB,这点空间换掉一整类状态机 bug,太划算了。

这条也顺带解决了第五章开头说的”install_cnt 只能抬一次”的问题:因为应用阶段是不可中断的原子过程(要么没开始,要么已完成,中间掉电靠临时区的完整性判断),install_cnt 的抬升点自然就唯一了。


六、把我们踩过的坑列出来

前面四章讲的是设计,这一章讲的是从”能跑”到”评估通过”之间踩过的东西。

坑 1:签名盖了清单,但清单里没有载荷摘要

就是引子里那个。攻击者把老差分包配上新清单,设备照收。根因:我把”签名”理解成”保护清单不被改”,但没有意识到”清单本身要能证明它描述的是哪一份载荷”。

修法:清单加 patch_digest 和 new_digest,且设备侧在应用补丁之前就要验 patch_digest(不是应用完再验)。

教训:签名保护的是”清单内容的完整性”,不是”清单与载荷的绑定关系”。绑定关系必须显式写进清单。

坑 2:old_crc32 被我当成了安全机制

见 3.2 节。old_crc32 留着,但角色改写清楚:它是廉价的第一道筛查,不是安全边界。安全边界是签名的 SHA-256 基线摘要。

这个坑的隐蔽之处在于:它能拦住所有”意外”,但拦不住任何”故意”。 因为意外导致的内容变化是随机的(大概率和 CRC 不符),而攻击者的变化是构造的(确保 CRC 相符)。测试时你无论如何都测不出问题——因为没有攻击者。

坑 3:min_boot_ver 缺失,老 bootloader 放行新格式包

见 2.3 节。这个是从评审里被问出来的,不是现场踩出来的。但我认为它迟早会在现场出现,因为升级通道的校验逻辑跟着 bootloader 走,而 bootloader 更新周期通常远长于应用。

坑 4:install_cnt 在应用阶段就抬了,回滚路径变砖

见 4.3 的 P2。我第一版三处判定都读 install_cnt,结果”升级后自检失败要回滚”这条路径直接进不去,设备卡死。

修法是把三个读点分开:正常启动 / 安装判定 / 回滚判定,各自读不同的计数器。这个修法听起来很 trivial,但第一版真的没分开——因为在全量升级的思维里,”设备记住的最高版本”就是一个值,读它就完了。

坑 5:断点续传的状态没有持久化,old_pos 残留

见 5.4。修法是把应用阶段做成原子的。

坑 6:现场的 BASE_MISMATCH 被当成攻击告警,虚惊好几场

前面提过。根因是日志文案写得太吓人。基线错配的现场原因九成是”现场刷过别的构建”,把日志改成”请使用全量升级包”,并给现场一份操作指引(怎么切全量、切换后怎么核对),这类工单量立刻降下来。

安全机制的可运维性,和它的技术正确性同等重要。 一个每次都误报攻击的机制,会在第三个月被现场手动关掉。


七、怎么证明它真的拦得住:六连测

10-05 那篇给了全量签名的五连测(篡改 / 错密钥 / 降级 / 断电 / 指纹)。差分升级要在那五条之外,加一条针对基线绑定的测试,而且篡改测试要细化成三类:

编号 测试 怎么做 期望
1a 载荷调包 拿包 A 的差分包,配包 B 的清单(重新拼装) 拒,报 PATCH_DIGEST
1b 基线调包 拿包 A 的清单,把 base_digest 换成包 B 的 拒,报 SIG_FAIL(因为摘要进了签名)
1c 型号调包 把 A 型号的合法包发给 B 型号 拒,报 HW_MISMATCH
2 错密钥 用另一对密钥签同一份清单 拒,报 KEYHASH
3 降级 签一个 security_cnt 低于 commit_cnt 的包 拒,报 SEC_CNT_LOW
4 断电 在差分特有的三个阶段分别切电:解包中 / 应用完未复位 / 启动后未 confirm,各随机 20 次 每次上电要么新版本要么老版本,不变砖
5 指纹回读 设备上报版本 + keyhash 前 8 字节 + security_cnt + install_cnt + commit_cnt 五元组 与台账逐项核对
6 块错配 把包 A 的第 3 块替换成包 B 的第 3 块;再把块 3 和块 4 互换 分别报 CHUNK_DIGEST 和 CHUNK_SEQ

第 4 条特别值得说:差分升级的断电测试点比全量升级多。 全量升级只有”写入中 / 写入完未复位 / 未 confirm”三个点;差分多了”解包中(此时 slot1 是半成品)”、”块传输中(此时临时区不完整)”这两个。我们把断电注入的点位从 3 个扩到 5 个,每个点 20 次,总共 100 次。这 100 次里抓出了两个真 bug(一个是 install_cnt 抬升点的,一个是临时区完整性判断的),个人认为这是整件事里投入产出比最高的部分。

第 1b 条也值得说:如果你发现测试 1b 报的是 PATCH_DIGEST 而不是 SIG_FAIL,那说明 base_digest 没进签名。 这两个原因码的区别就是设计对不对的判据。


八、差分还是全量?这张表要加两行

09-22 那篇给过一张”差分 vs 全量”的决策表(窄带网络 / 固件大小 / 版本跨度 / 版本分散度 等条件)。加了签名安全层之后,要补两行:

新增条件 推荐
差分包 < 8KB(改了几行的补丁) 全量。签名清单 256 字节占比会到 6% 以上,且这类补丁往往意味着”你其实可以攒一批再发”
设备不支持签名验签(Flash 太小放不下 ECC 验签代码 / 无硬件加速) 先别做差分。没有签名的差分升级,攻击面比全量还大——因为基线依赖引入了新的缝,而你没有堵它的工具
差分包 > 全量包 60% 全量(沿用 09-22 的阈值)

最后一行”设备不支持验签”这条,是我想强调的。差分升级和签名链不是两个可以独立选择的功能。 差分引入的攻击面(基线依赖、清单与载荷分离、断点续传的块归属)必须靠签名机制来堵;如果设备装不下验签代码,那差分升级在你这里就是一个纯风险项——它省了流量,但把”谁能改我的设备”这件事从”需要私钥”降级成了”需要会拼装文件”。

顺便给一个量级参考:ECDSA P-256 的验签代码(裸实现 + SHA-256)在 Cortex-M4 上大约 8~12KB Flash、1KB 栈,验签耗时 30~60ms(无硬件加速)。如果用 mbedTLS 的 p256 优化实现(MBEDTLS_ECP_DP_SECP256R1_ENABLED + MBEDTLS_ECDSA_DETERMINISTIC),Flash 会到 15KB 左右。这颗预算必须提前留——别等 bootloader 写满了才发现放不下。


九、差分包安全自查清单

按这次的经验整理。带 ★ 的是”这一篇新增的、全量升级不需要操心的”:

清单设计

  • ☐ ★ 清单被签名,载荷不重复签名,靠清单里的摘要绑定
  • ☐ ★ base_digest(基线 SHA-256)、new_digest、patch_digest 三项齐全
  • ☐ ★ security_cnt、target_hw_id、min_boot_ver 三个决定性字段都在签名覆盖范围内
  • ☐ ★ 明文里只留展示性字段(版本字符串、日期、更新说明)
  • ☐ ★ header_len 存在且被校验(防截断)
  • ☐ format_ver 独立于固件版本,且扩展时保持老字段偏移不变
  • ☐ 头部有 magic,且 magic 在签名覆盖范围内

基线绑定

  • ☐ ★ 基线摘要是完整 SHA-256,不是 CRC32/CRC64/截断值
  • ☐ ★ 设备侧用自己算出的摘要比对,不信任包内声明
  • ☐ ★ 基线摘要被签名覆盖(测试 1b 应报 SIG_FAIL 而非 PATCH_DIGEST)
  • ☐ old_crc32(或等价物)保留为廉价预筛,但角色是可靠性闸门、非安全闸门
  • ☐ 文档里写清”版本号字符串不作为安全判据”

防降级(双计数器)

  • ☐ ★ 拆分 install_cnt(S1 后抬)和 commit_cnt(S2 后抬)
  • ☐ ★ 三个判定点读不同的计数器:正常启动读 commit、安装判定读 install、回滚判定读 commit
  • ☐ ★ install_cnt 在整包复验通过后只抬一次,不是每块都抬
  • ☐ ★ nv_counter_inc() 的调用点全项目不超过 3 个(可 grep 验证)
  • ☐ 两个计数器的物理落点在 OTP / 独立参数扇区,不在 slot 区
  • ☐ 计数器只能单调增,永不回退(代码层面无递减路径)
  • ☐ 柜台账与产线工单绑定,烧写前二次确认

传输与续传

  • ☐ ★ 逐块 SHA-256 + 块序号绑定(不接受重复 seq、不接受乱序)
  • ☐ ★ 块摘要列表本身进签名清单
  • ☐ ★ 差分包先完整落到临时区、逐块验完、整包验完,才开始应用
  • ☐ 应用阶段的 old_pos / written 状态要么持久化、要么靠”原子应用”规避
  • ☐ 断点续传的进度记录本身有完整性判断(防止被篡改成”某块已收”)

升级通道自身

  • ☐ ★ min_boot_ver 存在且校验,且 bootloader 不通过 OTA 升级(或走独立严格通道)
  • ☐ 六连测(调包 ×3 / 错密钥 / 降级 / 断电 / 指纹 / 块错配)全部跑通并归档
  • ☐ ★ 断电测试点位至少 5 个(含”解包中”和”块传输中”),每点 ≥20 次
  • ☐ 失败原因码分类上报(≥5 类),现场能据此分流
  • ☐ ★ 基线错配的日志文案不提”攻击”,并配现场操作指引(切全量)
  • ☐ 服务端永久保留所有历史全量包(差分包是推导物)
  • ☐ 服务端有”差分不划算就退回全量”的兜底(阈值 60%)
  • ☐ 固件零 malloc、业务路径无 %f(Release 版本 nm 验过)

结语:差分的账,最终算的是”复杂度花在哪”

这件事做完,我把三篇 OTA 文章串起来看了一遍,得到一个比技术细节更有意思的观察:

  • 09-15 那篇(Bootloader/IAP):解决的是”能不能升级”;
  • 09-22 那篇(bsdiff 差分):解决的是”升级省不省流量”;
  • 10-05 那篇(MCUboot 签名链):解决的是”升上来的东西是不是我们的”;
  • 这一篇:解决的是”当上面三件事同时成立时,它们中间的接口有没有缝”。

而这四件事里,只有第一件是功能,后三件全是”可靠性 + 安全性”的投入。它们不产生新功能,不提升性能,客户看不见。它们的价值只有在出问题的时候才显现——而一旦出问题,代价是整批设备。

差分升级最容易被误解的一点,是把它当成一个”压缩技术”。从压缩的角度,它就是把 812KB 变成 27KB,很漂亮。但真正花时间的从来不是压缩算法,而是:

  • 那 27KB 怎么被证明是”我们发的那 27KB”;
  • 这 27KB 和设备的现状之间那个引用,怎么被证明没被篡改;
  • 一次升级的两个成功点,怎么被分开记录;
  • 断电 100 次之后,设备还能不能起来。

差分省下的是流量,付出的是”多出来的三个接口”。 这三个接口的堵法,就是我们这次花两个星期做的事情——其中写代码大概三天,剩下是设计评审、断电测试和改日志文案。

评估报告的结论最后改成了”差分升级通道的完整性与来源验证机制经复核后未发现可绕过缺陷”。第 7 页那行字,从”需要重新评估”变成”未发现可绕过缺陷”,中间是:一个 256 字节的清单、两个计数器、一次断点续传的原子化改造,和 100 次断电。

有在做差分升级、或者被安全评估盯上 OTA 通道的朋友,欢迎留言交流——尤其是”你们怎么处理基线错配”这件事,我很好奇别人的做法。


相关阅读


本文配套脚本:_tools/delta_sec_calc.py —— 差分包签名与防降级现场估算器。mode: layout(签名清单字节布局与体积预算,可换 hash/sig 算法看开销占比)、bind(基线绑定强度对照:CRC32 / 各长度截断 SHA-256 / 完整 SHA-256 的碰撞成本对比)、counter(双计数器时序推演,四条典型路径 P1~P4 的判定结果,含 P2 那个”抬早了就变砖”的设计陷阱)、nmatch(非匹配 / 重放攻击面枚举 S1~S6,每条给出”攻破/拦住”的判定与原因)、size(差分 vs 全量 + 签名开销的总账,含带宽与解包时间的时间账)、verify(设备侧八步判定顺序模拟,给定输入输出放行/拒绝与原因码)、triage(现场症状分诊表)。纯标准库,全离线,不联网。

下次打算写:RT-Thread 设备框架下的传感器驱动分层(这个选题挂在候选里好几期了,正好手上有个新项目要按这个架构搭),或者 CAN/LIN 之外的 24V 系统与 SAE J2602 差异。哪个先写,看这周的活先到哪个。