差分包也要签名:一次”包是假的但签名是真的”的复盘,以及差分的基线依赖多出来的那几条缝
上一篇(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 安全。
先给结论
- 差分包的签名不能只盖”差分包”,也不能只盖”清单”。 正解是 SWUpdate 那套”清单签名 + 产物哈希”:签名只盖一个很小的清单(250 字节左右),清单里写死载荷的摘要、基线的摘要、安全计数、目标硬件 ID。签名成本与载荷大小解耦——这是差分包能兼顾安全和体积的唯一办法。
- 全量包的验证是自封闭的,差分包的验证是开放式的。 全量包只需要”我和我自己自洽”;差分包必须引用”设备现在跑的那一份”。这个引用一旦弱化(只比版本号、用 CRC32、或摘要不进签名),攻击者就能把基线换成他想要的那份。这是差分升级最大的、也是全量升级不存在的攻击面。
old_crc32只能防”路上坏了”,防不了”有人想让你坏”。 09-22 那篇里我把old_crc32称作”差分方案的安全绳”——那句话在工程可靠性的语境下是对的(它确实是拦基线错配的第一道闸),但在安全语境下它是错的。CRC 是公开的线性函数,构造等 CRC 碰撞在 PC 上是毫秒级的事。这一篇要修正这个说法。- 从”一个计数器”改成”两个计数器”。 全量升级只有一个成功点,一个 SEC_CNT 够用。但差分升级的解包是多步过程(收块 → 解压 → 应用 → 落盘 → 验签),而且一次升级有两个不同的成功含义:① 新镜像落地并验签通过(可以抬 install_cnt)② 新镜像真跑起来并自检通过(才能抬 commit_cnt)。抬早了,回滚就被拒、设备变砖;抬晚了,重放有窗口。两个计数器才能同时保住这两件事。
- 断点续传是差分在弱网下的常态,也是新的攻击面。 逐块摘要必须做,而且块摘要列表本身也要进清单,否则续传的块归属无人验证。
- 差分包越小,签名的固定开销占比越难看。 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 文件被签名;单个产物用已签名描述中声明的哈希来验证”。这个模型的三个好处:
- 签名成本与产物数量、体积完全解耦;
- 一个清单可以描述多个产物(多镜像升级);
- 清单本身小到可以整体读进 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 通道的朋友,欢迎留言交流——尤其是”你们怎么处理基线错配”这件事,我很好奇别人的做法。
相关阅读
- MCUboot 安全启动与签名链实战:一份渗透测试报告上的”中危”评语,逼我把信任链从头搭了一遍 —— 本篇的上半篇:信任层级、TLV 结构、SEC_CNT 锚点、密钥轮换、镜像加密、五连测,全部在那里
- 差分升级(bsdiff/bdiff)实战:把 OTA 包从 812KB 压到 27KB,以及落地时踩过的 6 个坑 —— 本篇的下半篇:算法选型、内存公式、MCU 侧流式解析器代码、六个落地坑,全部在那里。注意本篇 3.2 节对那篇里”
old_crc32是安全绳”一说做了修正 - STM32 Bootloader 与 IAP 远程升级实战:让设备在现场”越用越好” —— 整个 OTA 系列的起点,分区表、跳转、中断反初始化这些地基都在这篇
- 看门狗与系统容错:让设备更可靠的实用技巧 —— 第四章”install_cnt 只能抬一次”的约束,本质是同一类问题:在不可中断的关键过程中,任何副作用都必须能重放
- 嵌入式 C 代码规范与防御性编程实践 —— 本篇里到处在强调”每个返回值都要检查””失败要分类上报原因码”,那篇是这套纪律的系统版本;
nv_counter_inc()调用点限定在 3 个以内,也是这类”用结构约束人”的思路 - CAN 总线错误帧排查实战:一条”隔几小时报一次总线-off”的产线设备 —— 同样是”靠计数与状态定位问题”的思路,CAN 那边是 TEC/REC,这边是 install/commit
本文配套脚本:_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 差异。哪个先写,看这周的活先到哪个。