4、防回滚计数器(Anti-Rollback Counter)

防回滚计数器,说白了就是给固件版本上了一把「单向锁」。

我刚开始接触这个机制时,觉得它不就是个计数器嘛,有啥难的?后来在量产产线上吃过亏,才明白这东西设计得好不好,直接决定了你的设备会不会被「降级攻击」打穿。

4.1 硬件防回滚计数器的原理

防回滚计数器的核心思想很简单:版本只能往前走,不能往后退。

每次固件升级时,计数器值必须比当前值大。如果攻击者试图刷回旧版本,计数器值变小,系统直接拒绝启动。

关键特性:

  • 单调递增:计数器值只能增加,不能减少
  • 硬件实现:由芯片内部硬件逻辑维护,软件无法篡改
  • 持久存储:断电后值不丢失

我见过不少团队用Flash模拟计数器,结果被攻击者直接擦除整块Flash,计数器归零,旧固件照跑不误。所以,真正的防回滚必须依赖硬件。

4.2 一次性可编程(OTP)存储的应用

OTP(One-Time Programmable)存储,就是那种「写一次就焊死」的存储单元。你想想看,这玩意儿天生就是为防回滚设计的。

OTP在防回滚中的典型用法:

  • 熔丝阵列:每个bit对应一个熔丝,烧断后无法恢复
  • 反熔丝:与熔丝相反,编程后形成永久导通
  • 一次性寄存器:写保护位一旦置位,寄存器内容永久锁定
存储类型 可写次数 抗篡改能力 典型容量
OTP熔丝 1次 极高(物理破坏) 128bit ~ 4Kbit
eFuse 1次(可分段) 高 512bit ~ 8Kbit
Flash模拟 多次 低(可擦除) 不限

避坑指南:我曾经遇到一个项目,OTP区域设计得太小,只够存4位计数器。结果产品迭代到第16个版本后,计数器溢出,所有新固件都无法升级。最后只能召回设备换芯片……

所以,OTP容量一定要预留足够余量。我建议至少留8位(256次升级),工业设备最好留16位。

4.3 计数器与版本号的协同工作

光有计数器还不够,你得让计数器和固件版本号「对上暗号」。

常见的协同方案有两种:

方案一:直接映射

计数器值 = 版本号。比如固件版本v3.2,计数器就是32。简单粗暴,但版本号跳变时容易浪费OTP空间。

方案二:哈希绑定

计数器值不直接等于版本号,而是对「版本号 + 固件哈希」做签名验证。这种方式更灵活,但需要额外的签名验证逻辑。

我个人更推荐第二种方案。为什么?因为直接映射有个致命问题:如果版本号回退但计数器没变,攻击者可以伪造一个相同版本号的旧固件。

我的经验:在NXP i.MX系列芯片上,我做过一个混合方案——

  1. OTP中存储一个单调递增的「安全版本号」
  2. 固件头中携带「兼容版本号」
  3. 启动时比较:安全版本号 ≤ 兼容版本号,才允许启动

这样既保证了防回滚,又允许同一版本的不同补丁固件共存。

4.4 核心流程:防回滚校验

下面是一个典型的防回滚校验流程,我把它画成了流程图:

防回滚校验流程图 启动BootROM 读取OTP计数器值 解析固件头版本号 比较:固件版本 ≥ OTP计数器? (含签名验证) 通过 → 加载并启动固件 拒绝 → 进入恢复模式 说明 OTP计数器值在固件升级时由BootROM自动递增写入,软件层无法修改。 即使攻击者物理篡改Flash中的固件,OTP值也不会回退。

4.5 代码示例:OTP计数器校验

下面是一个简化的OTP计数器校验代码,我在STM32MP1平台上实际跑过:

/* 防回滚校验函数 */
int anti_rollback_check(uint32_t fw_version, uint32_t *otp_counter) {
    uint32_t current_counter;
    
    /* 1. 读取OTP中的当前计数器值 */
    current_counter = read_otp(OTP_ROLLBACK_ADDR);
    
    /* 2. 验证固件签名(省略具体实现) */
    if (!verify_firmware_signature()) {
        return -1;  /* 签名无效 */
    }
    
    /* 3. 比较版本号 */
    if (fw_version < current_counter) {
        /* 版本回退,拒绝启动 */
        return -2;
    }
    
    /* 4. 如果固件版本更高,更新OTP计数器 */
    if (fw_version > current_counter) {
        write_otp(OTP_ROLLBACK_ADDR, fw_version);
    }
    
    /* 5. 返回成功 */
    *otp_counter = fw_version;
    return 0;
}

注意:上面的代码中,write_otp() 函数必须由BootROM或安全固件调用,且OTP写入操作不可中断。我曾经见过一个bug:写入过程中发生掉电,OTP处于半编程状态,导致后续所有固件都无法升级。

解决方案:在写入前先做「预烧验证」,确认OTP区域可写,再执行实际写入。

4.6 实战中的坑与对策

做了这么多年固件安全,我总结了几条防回滚计数器设计的「血泪教训」:

  • OTP空间别省:至少预留16位计数器,配合版本号分段使用
  • 写入必须原子化:OTP写入期间禁用中断,防止半写状态
  • 回滚策略要灵活:允许「安全回滚」到同一大版本内的补丁版本
  • 测试覆盖边界:计数器溢出、OTP写满、版本号回退1个数字等场景

我的习惯:在量产前,我会写一个「OTP压力测试脚本」,模拟1000次升级回滚操作,确保计数器逻辑在各种极端条件下都能正确工作。这个脚本帮我抓到了至少3个隐蔽的竞态条件bug。

嗯,防回滚计数器这块,说白了就是「硬件锁 + 软件锁」的双保险。OTP提供了物理层面的不可逆性,版本号校验提供了逻辑层面的安全性。两者配合好了,攻击者想降级你的固件,基本没戏。