版本号管理机制:单调递增版本号的设计原则、版本号存储的防篡改策略、版本号回滚检测逻辑
版本号管理,说白了就是固件安全的「门禁系统」。你想想看,如果攻击者能把旧版本固件刷回去,那之前修的所有漏洞都白干了。我这些年做固件安全评估,至少有一半的回滚漏洞,问题都出在版本号管理上。
单调递增版本号的设计原则
单调递增,听起来挺学术的。其实就一个要求:新固件的版本号,必须比旧的大。但这里面的坑,比你想的多得多。
核心原则:版本号只能向前走,不能往后退。哪怕你只是改了一行注释,版本号也得涨。
我见过不少团队用「主版本.次版本.修订号」这种语义化版本号。嗯,理论上没问题。但实际部署时,你得注意几个细节:
- 版本号必须可比较:别用字符串比较"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里的版本号缓存。嗯,那场面挺尴尬的。
版本号回滚检测逻辑
检测逻辑,说白了就是固件启动时的一段「安检代码」。这段代码写不好,前面的所有防护都白搭。
标准的回滚检测流程是这样的:
- 读取当前版本号:从安全存储区(比如OTP或签名固件头)读取当前运行的版本号。
- 读取新固件版本号:从待更新的固件镜像中提取版本号。
- 比较大小:新版本号必须大于当前版本号。
- 更新存储:如果通过,将新版本号写入安全存储区。
听起来简单吧?但这里有个关键点——比较操作必须在安全环境中执行。我曾经见过一个案例,比较逻辑写在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里的版本号已经更新了。这就尴尬了——旧固件再也刷不进来了。
下面这张图展示了完整的回滚检测流程:
避坑指南:我曾经遇到一个案例,回滚检测逻辑放在中断服务函数里。结果更新过程中来了个高优先级中断,检测逻辑被跳过,攻击者直接刷了个旧固件进去。所以,回滚检测必须在关中断的环境下执行,或者放在不可被中断的信任执行环境(TEE)里。
最后说一句,版本号管理不是孤立的。它需要和固件签名、安全启动、OTP管理配合起来。单独搞版本号,就像只装了个门锁但没装门——防不住人的。