3. 防回滚核心思想:版本号绑定、一次性编程(OTP)区域、安全存储区

防回滚,说白了就是让设备「只能往前,不能后退」。我做了这么多年嵌入式安全,见过太多因为回滚而翻车的案例。有个客户的产品,就因为攻击者把固件降级到了三年前的版本,结果整个产线都被迫停产整改。嗯,从那以后,我对防回滚的重视程度直接拉满。

这一章,我们来拆解防回滚的三个核心支柱。你理解透了它们,就掌握了固件版本控制的命脉。

3.1 版本号绑定:给固件贴上「身份证」

版本号绑定,是最基础也最容易被忽视的一环。它的逻辑很简单:固件镜像里必须包含一个单调递增的版本号,并且这个版本号要跟硬件绑死。

我习惯的做法是这样的:在固件头部预留 4 字节的版本字段,每次发布新版本时,这个数字必须比上一版大。Bootloader 在加载固件前,会先比较这个版本号。

核心原则:版本号必须存储在不可篡改的区域(如 OTP 或安全存储区),并且比较逻辑必须在可信执行环境(TEE)或 Boot ROM 中完成。

来看一个典型的版本号校验流程:

// 伪代码:Bootloader 中的版本校验
uint32_t current_version = read_otp_version();   // 从 OTP 读取当前版本
uint32_t new_version = parse_firmware_header();   // 从新固件头部解析版本

if (new_version <= current_version) {
    // 拒绝加载!版本号没有增加
    boot_error("Rollback detected! Version must increase.");
    halt();
}

// 通过校验,继续加载
verify_signature();
load_firmware();

这里有个坑,我曾经踩过:版本号不能是字符串。为什么?因为字符串比较太容易出幺蛾子了。"v2.0" 和 "v10.0" 按字典序比较,结果是 "v10.0" 小于 "v2.0"——攻击者直接改个字符串就能绕过。所以,我建议用整数,或者至少用固定长度的 BCD 码。

避坑指南:我曾经见过一个团队用 Git commit 的 SHA 值前 8 位当版本号。结果有一次两个不同分支的 commit 前 8 位撞了,导致合法升级被拒绝。版本号必须单调递增,不能依赖哈希碰撞概率。

3.2 一次性编程(OTP)区域:写一次,管终身

OTP,全称 One-Time Programmable。这玩意儿就像一张只能写一次的纸——写上去就擦不掉了。在防回滚场景里,OTP 用来存储「当前最高版本号」。

你想想看,如果版本号存在普通 Flash 里,攻击者完全可以把它改小,然后刷入旧固件。但 OTP 不一样,每个 bit 只能从 1 变成 0(或者反过来,取决于工艺),而且不可逆。

我常用的 OTP 方案有两种:

方案类型 实现方式 优点 缺点
eFuse 芯片内部熔丝,通过大电流熔断 物理不可逆,安全性极高 成本高,占用 die 面积
一次性寄存器 专用 OTP 存储器单元 成本低,集成度高 可能被物理探针攻击

我个人更倾向 eFuse,虽然贵,但胜在物理安全。记得有一次,客户为了省钱选了普通 OTP 寄存器,结果被攻击者用聚焦离子束(FIB)探针直接读出了版本号,然后通过激光切割重置了寄存器。嗯,从那以后,但凡涉及安全等级高的产品,我都建议上 eFuse。

实战技巧:OTP 区域不要只存一个版本号。我习惯把版本号、芯片唯一 ID、以及一个随机数种子一起烧进去。这样即使攻击者读到了版本号,也无法伪造完整的固件签名。

3.3 安全存储区:给关键数据上把锁

安全存储区,是芯片内部一块隔离出来的存储区域。它跟普通 Flash 最大的区别在于:只有特定的安全代码(如 Boot ROM、TEE)才能访问它。

为什么需要它?因为 OTP 虽然安全,但容量太小(通常只有几 KB)。而版本号、密钥、设备证书这些数据,有时候需要频繁更新。安全存储区就充当了「可更新的 OTP」角色。

我设计安全存储区时,会遵循这几个原则:

  • 访问控制:只有 Bootloader 和 TEE 能读写,普通固件无权访问。
  • 加密存储:数据在写入前先加密,密钥来自芯片唯一 ID。
  • 完整性校验:每次读取都做 HMAC 校验,防止篡改。
  • 版本回滚保护:存储区内部维护一个单调计数器,每次更新版本号时计数器加一。

来看一个安全存储区的典型布局:

// 安全存储区结构体定义
typedef struct {
    uint32_t magic;              // 魔数,用于识别有效数据
    uint32_t version_counter;    // 单调递增计数器
    uint8_t  firmware_version[4];// 当前固件版本号
    uint8_t  device_id[16];      // 设备唯一标识
    uint8_t  reserved[32];       // 预留空间
    uint8_t  hmac[32];           // 完整性校验值
} secure_storage_t;

避坑指南:我曾经见过一个产品,安全存储区里的版本号是用明文存的。攻击者通过电压故障注入,让 HMAC 校验跳过,然后直接修改了版本号。所以,永远不要信任安全存储区的物理隔离——加密和校验必须双管齐下。

3.4 三者如何协同工作?

版本号绑定、OTP、安全存储区,这三者不是孤立的。它们组成了一条完整的防回滚链条。我画了一张图,帮你理清它们的关系:

防回滚核心机制协同工作图 版本号绑定 固件头部嵌入版本号 OTP 区域 一次性编程,不可逆 安全存储区 加密隔离,可更新 步骤1:Bootloader 从 OTP 读取当前最高版本号 步骤2:从新固件头部解析版本号,与 OTP 中的版本号比较 步骤3:通过校验后,将新版本号写入安全存储区(加密+HMAC) 步骤4:下次启动时,从安全存储区读取版本号,同步到 OTP(如支持) 三者形成闭环:版本号绑定提供数据源,OTP 提供不可逆锚点,安全存储区提供可更新能力

这张图展示了完整的流程。你看,版本号绑定是「输入」,OTP 是「锚点」,安全存储区是「缓冲」。三者缺一不可。

我举个例子你就明白了:假设设备当前版本是 5,OTP 里存的是 5。攻击者想刷回版本 3。Bootloader 从 OTP 读到 5,从固件头部读到 3,3 <= 5,直接拒绝。如果攻击者想绕过 OTP,直接改安全存储区——抱歉,HMAC 校验会失败,Bootloader 会认为存储区已损坏,直接进入恢复模式。

总结一下:版本号绑定解决「谁来比较」的问题,OTP 解决「锚点不可篡改」的问题,安全存储区解决「可更新且安全」的问题。这三板斧砍下去,大部分回滚攻击都能防住。