版本号管理机制:单调递增版本号的设计原则、版本号存储的防篡改策略、版本号回滚检测逻辑

版本号管理,说白了就是固件安全的「门禁系统」。你想想看,如果攻击者能把旧版本固件刷回去,那之前修的所有漏洞都白干了。我这些年做固件安全评估,至少有一半的回滚漏洞,问题都出在版本号管理上。

单调递增版本号的设计原则

单调递增,听起来挺学术的。其实就一个要求:新固件的版本号,必须比旧的大。但这里面的坑,比你想的多得多。

核心原则:版本号只能向前走,不能往后退。哪怕你只是改了一行注释,版本号也得涨。

我见过不少团队用「主版本.次版本.修订号」这种语义化版本号。嗯,理论上没问题。但实际部署时,你得注意几个细节:

  • 版本号必须可比较:别用字符串比较"1.9.0"和"1.10.0",字符串比较会认为"1.9.0"更大。我建议用整数或BCD编码。
  • 预留增长空间:别把版本号字段定成1字节,255次更新后你就傻眼了。至少用4字节,甚至8字节。
  • 版本号不能复用:同一个版本号只能对应一个固件。如果发版后发现严重bug,撤回重发,版本号必须递增。

我个人习惯用单调递增的整数作为内部版本号。比如从1开始,每次发版加1。简单、粗暴、有效。对外展示可以转成语义化版本,但内部比较只用这个整数。

小技巧:把版本号拆成两部分——「安全版本号」和「功能版本号」。安全版本号只由安全团队控制,每次安全更新必须递增。这样即使功能版本号被绕过,安全版本号也能兜底。

版本号存储的防篡改策略

版本号存哪儿?怎么存?这是另一个大坑。我曾经遇到一个客户,把版本号明文存在SPI Flash的固定偏移处。攻击者用个十六进制编辑器就能改掉。这跟没存有什么区别?

防篡改,说白了就是让攻击者改不了,或者改了能被发现。我总结了几种常见策略:

策略 实现方式 安全强度 我踩过的坑
硬件熔丝 版本号写入OTP/eFuse,物理不可改写 ★★★★★ 成本高,一旦写错芯片报废
签名保护 版本号参与固件签名,验签时校验 ★★★★☆ 私钥泄露就全完蛋
加密存储 版本号用芯片唯一密钥加密后存储 ★★★☆☆ 密钥管理复杂,容易被侧信道攻击
冗余校验 版本号存多份,启动时交叉校验 ★★☆☆☆ 攻击者可以同时改掉所有副本

我个人最推荐的是硬件熔丝 + 签名保护的组合。用熔丝存一个「最小安全版本号」,固件里的版本号通过签名来保护。启动时,先验签,再比较固件版本号是否大于等于熔丝里的值。

注意:千万别把版本号存在可被DMA直接访问的内存区域。我见过一个案例,攻击者通过DMA绕过CPU直接修改了Flash里的版本号缓存。嗯,那场面挺尴尬的。

版本号回滚检测逻辑

检测逻辑,说白了就是固件启动时的一段「安检代码」。这段代码写不好,前面的所有防护都白搭。

标准的回滚检测流程是这样的:

  1. 读取当前版本号:从安全存储区(比如OTP或签名固件头)读取当前运行的版本号。
  2. 读取新固件版本号:从待更新的固件镜像中提取版本号。
  3. 比较大小:新版本号必须大于当前版本号。
  4. 更新存储:如果通过,将新版本号写入安全存储区。

听起来简单吧?但这里有个关键点——比较操作必须在安全环境中执行。我曾经见过一个案例,比较逻辑写在Bootloader里,但Bootloader本身没有做防回滚保护。攻击者先刷一个旧版Bootloader,这个旧版Bootloader里的比较逻辑是「版本号大于等于0就通过」。嗯,你懂的。

下面是一个简化的回滚检测代码示例:

// 回滚检测函数
// 返回值:0=通过,-1=回滚检测失败
int check_rollback(uint32_t new_version) {
    // 1. 从OTP读取当前安全版本号
    uint32_t current_version = read_otp(OTP_SECURE_VERSION);
    
    // 2. 检查OTP读取是否成功
    if (current_version == OTP_READ_ERROR) {
        // OTP损坏?直接拒绝更新
        return -1;
    }
    
    // 3. 比较版本号
    // 注意:这里用减法而不是大于号,防止整数溢出攻击
    if ((int32_t)(new_version - current_version) <= 0) {
        // 新版本不大于当前版本,拒绝
        return -1;
    }
    
    // 4. 更新OTP中的版本号
    // 注意:这一步必须在固件写入Flash之前完成
    if (write_otp(OTP_SECURE_VERSION, new_version) != 0) {
        // OTP写入失败,拒绝更新
        return -1;
    }
    
    return 0;
}

关键点:版本号比较一定要用有符号整数减法,而不是直接比较大小。为什么?因为攻击者可能把版本号设成0xFFFFFFFF,如果当前版本是1,直接比较会认为新版本更大。但用减法,(int32_t)(0xFFFFFFFF - 1) = -2,小于0,直接拒绝。这个小技巧,是我当年被攻破后才学到的。

另外,检测逻辑的执行时机也很重要。我建议在固件写入Flash之前就完成版本号比较和OTP更新。如果先写Flash再比较,万一写入过程中断电,下次启动时旧固件还在,但OTP里的版本号已经更新了。这就尴尬了——旧固件再也刷不进来了。

下面这张图展示了完整的回滚检测流程:

固件回滚检测流程图 开始固件更新 读取OTP当前版本号 解析新固件版本号 版本号比较(有符号减法) 拒绝更新 新 ≤ 旧 更新OTP版本号 新 > 旧 写入新固件到Flash 注意:OTP版本号更新必须在固件写入Flash之前完成,防止断电导致状态不一致

避坑指南:我曾经遇到一个案例,回滚检测逻辑放在中断服务函数里。结果更新过程中来了个高优先级中断,检测逻辑被跳过,攻击者直接刷了个旧固件进去。所以,回滚检测必须在关中断的环境下执行,或者放在不可被中断的信任执行环境(TEE)里。

最后说一句,版本号管理不是孤立的。它需要和固件签名、安全启动、OTP管理配合起来。单独搞版本号,就像只装了个门锁但没装门——防不住人的。