固件读出保护与签名固件升级实战:30 台退网网关被人用 USB-TTL 把 512KB 固件整锅端走——RDP 是 5 分钟的选项字节,真正要设计的是那 108 字节

2026年10月25日 0 By admin

上一篇《三种”顶满轨”的现场取证》文末预告了这篇:温度那条线连写九篇,换个轴,把欠了很久的《固件安全与逆向防护》先补上”读保护 + 升级链”这一半。与93 篇《MCUboot 安全启动与签名链》划个界:那篇是拿现成框架(MCUboot + imgtool)落地;这篇往下挖两层——芯片级的读出保护(RDP)和不依赖框架时,签名链里每一环为什么必须是它那个样子,外加那篇没碰的产线密钥注入工装。

结论先摆出来,五条:

  • RDP=0 的设备,BOOT0 + USB-TTL 十分钟把 512KB 固件整锅端走。 我们自己的 IAP bootloader 做得再严也拦不住——BOOT0 一拉高,芯片进的是 ROM 里的系统 loader,应用层的代码根本没机会说话。RDP 是选项字节里写一个数的事,量产的最低线是 Level 1。
  • RDP 是访问控制,不是加密,更不是混淆。 它挡”读取”,挡不住公开可查的电压毛刺攻击(主要针对 Level 1),更挡不住拿到设备后的行为级逆向。把 RDP 当成”防逆向”,是这个领域最常见的错觉。
  • CRC 和校验和不是签名。 CRC-32 的线性性意味着补 4 个字节就能命中任意目标值——2³² 次尝试,一台普通 PC 43 秒。
  • 签名防得住”推假固件”,防不住”推回旧版”。 降级攻击用的每一份包都是真签名验过的旧版本,反回滚必须单独做单调计数,而且版本号必须被签名覆盖。
  • 私钥永远不落设备。 设备侧只存公钥(或其 SHA-256 哈希),108 字节的元数据头加 240KB×2 的双槽,是 512KB Flash 上的标准账。

工具照例全离线:drafts/_tools/secure_boot_rdp_calc.py,13 个模式、27 项自检断言,文中每个数都能复算。与59 篇《STM32 Bootloader 与 IAP 远程升级》、101 篇《差分包也要签名》的口径衔接处会在文中标出。


一、现场:一批退网网关,固件被整锅端走

前年批量的 30 台 4G 数据网关退网回收,流到了二手平台。买家里有同行——收回去不拆壳,一根 USB-TTL 线接上 UART,BOOT0 拉高、复位,然后:

ROM 系统 loader(AN2606):
  0x7F        -> 同步
  0x00 0xFF   -> Get(读命令表)
  0x11 0xEE   -> Read Memory
  读 0x08000000 起 512KB 全量 dump

全程没有一滴我们写的代码在运行。固件被人整锅端走,两个月后兼容厂出板,Modbus 点表、私有协议、参数结构和我们一模一样。

复盘会上大家的第一反应是”上 IAP 的时候不是做了口令校验吗”。做了,而且做得不差——但那套校验跑在我们的 bootloader 里,而这次走的是芯片 ROM 的系统 loader,BOOT0 一拉高,我们 bootloader 的任何代码都没有被执行。应用层防护对芯片级逃生口无能为力,这条链的缺口必须在选项字节里补。

还有一个更隐蔽的同类缺口:调试口。产线为了排查便利,SWD 从来没关过,RDP 也是 0——43 篇里那把 J-Link不仅能调试,还能 savebin 一键导出全片。USB-TTL 和 J-Link 在这件事上是同一个洞的两个头。


二、RDP 三级:选项字节上的一个字节

STM32 的读出保护由选项字节里的 RDP 字节决定,就一个字节,三种状态:

RDP 字节 等级 含义
0xA5 Level 0 无保护,调试口/系统 loader 任意读
其他任意值(如 0x5A) Level 1 保护激活,回归 L0 触发整片擦除
0xCC Level 2 永久保护,调试口永久关闭,不可逆

0xA5 之外的任何值都是 Level 1——这是这套机制最反直觉的地方:“随便写一个数”就是开保护,”精确写对 0xA5″才是关保护。默认出厂值恰好是 0xA5,所以”没动过”等于”裸奔”。

三个等级的实际后果(secure_boot_rdp_calc.py rdp <byte> 可复算):

检查项 L0(0xA5) L1(其他) L2(0xCC)
SWD 读 Flash 任意读 总线错误 调试口永久关闭
系统 loader 读内存 任意读 禁用(AN2606) 按 AN2606 受限
降回 L0 —— 允许,但整片擦除 不可能

三条必须刻进产线流程的规则:

  • L1 的”回归即整片擦除”是双向的。 防止别人读走固件,也意味着产线上误操作一次 RDP 写回 0xA5,这台设备的固件就没了——返修板重新升级前,先把这条讲清楚。
  • L2 写下去之前,调试、量产测试、BOOT0 启动路径必须全部验证通过。 L2 永久关闭调试口,之后芯片出了任何问题都只能靠 UART/应用层自救。我们的顺序是:全功能测试 → 复测 → 老化 → 最后一步才写 RDP。
  • RDP 不是加密。 Flash 里的内容还是明文指令,保护的是”读出来的通道”。芯片自己执行的时候想读自己 Flash 不受限制——这正是所有读保护攻击的立足点。

防得住谁、防不住谁,要诚实:

对手 L1 L2
同行拆机接调试器/loader 挡住 挡住
愿意花一周做电压毛刺的人 公开可查的绕过手段,F1/F4 时代型号均中招 难度大幅提高,但老工艺型号不可高估
拿到设备做行为级逆向(抓总线、翻协议) 挡不住,这不是 RDP 的职责 同左

所以口径要摆正:RDP 挡的是”顺手牵羊”,不是”蓄意破解”。挡顺手牵羊只要 5 分钟,性价比无限大,不做是失职;防蓄意破解要靠芯片选型(新一代带安全启动/TrustZone 的系列)和业务层设计,别指望选项字节包打天下。


三、为什么 CRC 不算签名:43 秒的账

升级链上第一个要砍掉的幻想是”我有 CRC 校验,固件不会被篡改”。

CRC-32 是一个线性函数:对任意报文 M,补上一个 4 字节的修正串 C,总可以让 CRC(M ‖ C) 等于攻击者想要的任意目标值。也就是说,攻击者改完固件后,不需要碰你的校验算法,不需要暴力搜索整个镜像,只需要算一次修正串——期望尝试次数 2³²:

python secure_boot_rdp_calc.py crcforge 100
  CRC-32 伪造期望 4294967296 次尝试,@100M 次/秒 ≈ 42.9 s

一台普通 PC,43 秒。就算换成 CRC-16 直接秒算,换成 64 位 CRC 也只是把 43 秒变成几十天——而签名验证基于单向性:拿到签名,伪造一份能通过验证的镜像在计算上不可行(128 bit 安全强度,不是 2³²,是 2¹²⁸)。这两者的差距不是”更难一点”,是数学性质不同。

哈希(SHA-256)也只完成一半:它保证完整性——改一个字节,哈希就对不上。但哈希本身没有”只有我能生成”的性质,攻击者改了固件可以顺手把哈希也改掉。认证必须来自非对称签名:私钥只存在于签发机,设备侧只有公钥。 这一环省不掉。


四、签名链怎么搭:108 字节的元数据头

自建签名链(不走 MCUboot 时)最小可用形态是在镜像前挂一个定长头:

偏移 字段 长度 说明
0x00 magic 4B 0x53454331(”SEC1″),识别镜像头
0x04 fw_version 4B 单调递增,反回滚判据
0x08 img_size 4B 不含头本身
0x0C sha256(img) 32B 完整性
0x2C sig(sha256) 64B ECDSA P-256 的 r‖s,认证
—— 头合计 108B App 二进制紧跟其后

三条设计规则,每条都是拿教训换的:

  • 签名必须覆盖哈希,哈希必须覆盖整个镜像——包括版本号。 对哈希签名而不是对镜像直接签名,是为了设备端只做一次昂贵运算的顺序问题;而版本号放进哈希覆盖范围,是反回滚能成立的前提(下一节)。101 篇讲过差分包”包是假的但签名是真的”的教训,这里同源:签名覆盖范围之外的一切字段都等于没保护。
  • 曲线选型按设备侧算力定,不为新而新。 三条常用曲线的账(ecc 模式):
曲线 公钥 签名 安全强度 MCU 侧实现
ECDSA P-256 64B 64B(r‖s) 128 bit micro-ecc 公开基准:M3 验签约 2.5M 周期
Ed25519 32B 64B 128 bit 实现多,常数时间实现要挑
RSA-2048 256B 256B 112 bit 验签慢、占 ROM,MCU 上少用

P-256 和 Ed25519 安全强度相同、签名同长,选哪个主要看生态:要过某些行业认证/对接既有工具链,P-256 现成;纯自研链路 Ed25519 的公钥少一半。RSA-2048 强度只有 112 bit,新设计不建议再选。

  • 设备侧存公钥哈希,不存裸公钥。 把 SHA-256(公钥) 烧进 OTP 区(F2/F4/L4/G0 等多数系列都有几百字节 OTP),bootloader 先算出公钥哈希与 OTP 比对、再导入公钥验签。这样即使 bootloader 本身有可升级通道被攻破,攻击者也没法换上自己的公钥——换公钥必须换 OTP,而 OTP 写不回来。碰撞概率可以放心(keyhash 模式):一百万台设备、64 bit 截断的生日碰撞概率也只有 2.7×10⁻⁸,用全 256 bit 就是零顾虑。

五、反回滚:签名防不住的降级攻击

这是整个链条里最容易被漏掉的一环,漏洞藏在逻辑里而不是代码里:

签名验的是”这份包是不是我签的”,不验”这份包是不是最新的”。v1.2 有个已修复的洞,v2.0 是当前版——v1.2 的升级包当年签过名,签名永远有效。渗透测试只要把缓存/历史渠道里的 v1.2 推回设备,用的每一份材料都是真签名,设备欢天喜地地装回有洞的旧版。

对策是单调递增的版本计数,落地方式三选一:

方案 容量 擦写代价 适用
元数据 4B 版本号 + bootloader 内固化的”已见最高版本”记录 受记录区容量限制 每次升级写一次记录区 通用首选
OTP 版本计数(部分系列支持 OTP 递增写入) 一次成型 无擦除 版本节奏可控的产品
位图页:每升级一个版本翻一个位,版本=已翻位数 2KB 页可记 16384 个版本 每 16384 次升级才擦一页 无 OTP 的老型号

两条附加规则:

  • 版本号必须被哈希覆盖(即被签名覆盖),否则攻击者改一个字节把 v2.0 改成 v1.2,”合法降级”——上一节的规则在这里闭环。
  • 位数按产品全生命周期算,别抠。 4 bit 只能表达 16 个版本,发布到 v11 就只剩 5 个坑(rollback 4 11);8 bit 是 256 个版本,v10 时还剩 245 次。OTA 活跃的产品直接上 16/32 bit,多 4 字节的事。

六、Flash 布局与掉电安全:240KB×2 的标准账

512KB 的账(layout 512 16 2 14 --ab):

区 起始 KB 大小 KB
Bootloader 0 16
元数据 16 2
App 槽 A 18 240
App 槽 B 258 240
参数区 498 14

为什么必须双槽,单槽不行吗:单槽升级要”擦自己、写自己”,写一半掉电就是砖,只能依赖 bootloader 里另一份搬运代码,而那份代码同样在掉电窗口里裸奔。双槽把升级变成”写另一个槽、验签、切指针”,任何时刻两个槽里至少有一个是完整可验签的镜像。

掉电安全的升级流程四步(与 59 篇 IAP 的差异就在验签插入的位置):

  1. 下载到备用槽(B),此时 A 槽的旧版毫发无损;
  2. 验签 B 槽(哈希 + 签名 + 版本单调性,三关全过)——失败就丢弃,A 槽继续跑;
  3. 置”试运行”标志 + 启动计数,切到 B 启动;
  4. B 跑起来后由应用主动确认(连上服务器、参数区落盘),清标志、固化指针;没确认就被看门狗咬死重启(49 篇的容错逻辑在这里复用),计数耗尽 bootloader 回滚到 A。

启动时间要付账(boottime 模式,SHA-256 按 M3 软实现 20 周期/字节、验签按 micro-ecc 公开基准 2.5M 周期计):

镜像大小 SHA-256 P-256 验签 合计 @72MHz
64KB 18.2ms 34.7ms 52.9ms
128KB 36.4ms 34.7ms 71.1ms
256KB 72.8ms 34.7ms 107.5ms
512KB 145.6ms 34.7ms 180.4ms

180ms 换”每次上电都验过签名”,绝大多数设备付得起;真付不起的(比如秒级上电要求),只验 A→B 切换时新槽、之后信任标志位——但标志位所在参数区要带 CRC 且掉电安全写入,这又回到参数区设计,下篇细说。24MHz 的低功耗型号同样 512KB 要 541ms,这种就该考虑只校验关键段或换带硬件加密的型号。

擦写寿命顺带算清(bank 模式,按 1 万次擦写 endurance):每天 1 次整槽升级能用 27.4 年,每天 30 次是 0.9 年——正常 OTA 节奏下固件槽磨损不是问题,真正会磨损的是参数区和高频日志,那是109 篇和下篇的领地。

另外一条红线:bootloader 自身永不在线升级。它只有 16KB,功能从简(验签、搬运、跳转),任何”bootloader 也要迭代”的冲动都把它变成了最大的攻击面——它一坏,双槽结构整个失效。


七、产线密钥注入:私钥的最后一公里

签名链在纸面上是完美的,压垮它最常见的不是数学,是密钥管理——私钥进了 git、进了部门共享盘、进了产线工装的明文配置。产线流程七步(inject 模式可输出清单):

  • ☐ 离线机生成 P-256 密钥对:私钥永不出离线机,不进 git、不进内网共享盘
  • ☐ SHA-256(公钥) 全 32B 烧进 OTP(或 bootloader 代码内固化该哈希)
  • ☐ 工装顺序:烧 bootloader → 烧 OTP → 全功能测试 → 老化 → 最后写 RDP
  • ☐ L2 写入前必须完成复测;L2 一旦写下不可逆,返修路径只能走 UART/应用层
  • ☐ 抽检:RDP 后抽查 SWD 连接(应拒绝/不可读)、BOOT0 系统 loader 读(应禁用)
  • ☐ OTP 烧写校验值、RDP 字节回读值入产线数据库;失败板单独隔离
  • ☐ 签发机物理隔离 + 双人操作 + 签发日志;泄露预案 = 预留第二公钥哈希槽

两条经验:

  • “每台设备唯一密钥”多数时候是过度设计。 唯一密钥意味着一台泄露不波及全家,但产线要为每台设备单独记录、单独烧写;而 bootloader 里放不下 1000 把公钥的比对表。批量产品”全家族一把签名钥 + 设备侧只认哈希”已经把”伪造固件”的门槛抬到了”攻破签发机”——真正的风险重心在签发机的物理安全,不在每台设备。
  • 泄露预案比防线本身更重要。 OTP 认的是公钥哈希,换钥 = 新哈希写不进已满的 OTP——所以首批产品就要预留第二哈希槽位(哪怕出厂全 0xFF),这是十分钟的决定,救的是未来的整批召回。

八、验收清单

  • ☐ 量产 RDP ≥ Level 1;RDP 字节回读值在产线数据库里逐台可查
  • ☐ Level 2 设备:写入前全功能复测已通过,且留有 UART 自救路径的验证记录
  • ☐ OTA 包验签失败时:拒绝安装、保留旧槽、上报计数,三件事都有日志
  • ☐ 版本号被签名覆盖;反回滚计数单调,位数按产品生命周期留够
  • ☐ 双槽切换的每个掉电点都有对应的恢复路径(断电测试做过每个窗口)
  • ☐ 试运行确认超时回滚路径实测过(把确认代码注释掉,看它真的回得去)
  • ☐ 公钥哈希进 OTP,且预留了第二槽位
  • ☐ 私钥所在机器物理隔离,签发操作有双人日志

最后收三句话。第一,RDP 是十分钟的事,不做是失职;但把 RDP 当成安全设计的全部,是把门锁当保险柜。 选项字节管访问控制,管不了毛刺,管不了逆向,更管不了升级通道——升级通道是另一条独立的防线。

第二,升级链的每一环防的是一种具体的攻击,不是仪式。 哈希防篡改,签名防伪造,版本单调防降级,双槽防掉电,试运行确认防”升级成功但固件起不来”。少一环,那一种攻击就是敞开的;多一环没想清楚防什么,就删掉,别用复杂度感动自己。

第三,这套链条里最贵的资产是私钥,最脆弱的环节是管私钥的人的流程。 数学上的 2¹²⁸ 很硬,硬不过 git push 一下。

工具:drafts/_tools/secure_boot_rdp_calc.py(13 个模式、27 项自检断言,纯标准库、全离线)。口径:RDP 字节/等级与回归后果按 STM32F1 选项字节定义(0xA5/其他/0xCC),系统 loader 行为按 AN2606;SHA-256 按 M3 软实现 20 周期/字节、ECDSA P-256 验签按 micro-ecc 公开基准 2.5M 周期(Cortex-M3)计,具体到自己的芯片请按实测替换;CRC 伪造耗时按 2³² 次尝试折算。文中现场案例按口径重构,不是原始日志。

另:9 月 26 日那篇《ESD/浪涌防护实战》”12.5kV 应为 12.5V”的笔误,本期已顺手修掉(100nH × 125A/µs = 12.5V,地电位抬升是”十几伏”量级,不是”上万伏”,原文叙述已一并改对)。站内《Flash 存储与参数管理》仍是空白主题。

下一篇预告《Flash 存储与参数管理》——就是上文反复让路的那个参数区:109 篇讲了掉电保持,这篇把另一半补全:磨损均衡的账怎么算、坏块什么时候出现、小型文件系统(还是自建环形页)怎么选,以及升级槽、反回滚计数、业务参数挤在同几页 Flash 里时,擦写预算怎么分。