2、固件安全基线:固件签名验证、安全启动链、固件加密存储、防回滚机制

固件安全,说白了就是设备的“基因安全”。

我见过太多团队,硬件设计得漂漂亮亮,结果固件一上来就被逆向、被篡改、被降级。设备出了厂,就跟裸奔一样。你想想看,如果连跑在芯片上的代码都不安全,那上层做再多应用层防护,都是白搭。

这一章,我们就把固件安全的四个核心基线讲透:签名验证、安全启动、加密存储、防回滚。这四个点,是物联网设备合规的硬门槛,也是我这些年踩坑最多的领域。

2.1 固件签名验证:给固件贴上“防伪标签”

固件签名,本质上就是给二进制文件打一个数字签名。谁签的?设备厂商的私钥。谁验的?设备里的公钥。

我在一个智能门锁项目里遇到过,客户说“我们固件有签名”,结果一看,签名算法用的是 MD5,密钥硬编码在 Flash 里,随便一个调试接口就能读出来。这跟没签有什么区别?

核心原则:

  • 签名算法必须用非对称加密,推荐 ECDSA 或 RSA-2048 以上
  • 公钥必须存储在芯片内部的一次性写入区域(OTP/eFuse)
  • 签名过程必须在安全的生产环境中完成,私钥不出 HSM

具体怎么做?我习惯把签名流程分成三步:

  1. 生成摘要:对固件二进制做 SHA-256 哈希,得到固定长度的摘要
  2. 签名:用厂商私钥对摘要加密,生成签名数据
  3. 打包:将原始固件 + 签名数据 + 证书链打包成一个文件

设备端验证时,用公钥解密签名,跟本地计算的摘要比对。一致,就说明固件没被改过。

我的习惯:签名时一定要带上固件版本号和芯片唯一 ID。这样能防止签名被复制到另一台设备上使用。这叫“防重放攻击”。

2.2 安全启动链:从第一行代码开始信任

安全启动,不是只检查一次。它是从芯片复位开始,每一级代码都要验证下一级。

典型的启动链是这样的:

  1. ROM Bootloader:芯片出厂固化的代码,不可更改。它验证下一级 Bootloader 的签名
  2. Bootloader:验证 OS Kernel 的签名
  3. Kernel:验证文件系统/应用固件的签名
  4. 应用:运行时验证动态加载模块的签名

每一级验证通过,才把控制权交出去。任何一级失败,设备就进入“恢复模式”或直接死机。

我曾经踩过的坑:有个项目,ROM Bootloader 验证了 Bootloader,但 Bootloader 没验证 Kernel。攻击者直接替换了 Kernel 分区,设备照样启动。记住,安全启动链不能有“断链”。

下面这张图,是我常用的安全启动链结构:

安全启动链流程图 安全启动链(Secure Boot Chain) ROM Bootloader (芯片固化,不可改) Bootloader (验证 Kernel) OS Kernel (验证文件系统) 验证签名 验证签名 验证失败 → 死机/恢复 验证通过 → 正常启动 ⚠️ 关键点 每一级都必须验证下一级 不能有“跳过”或“信任传递”

2.3 固件加密存储:别让固件“裸奔”在 Flash 里

签名防篡改,加密防泄露。这两个是不同的事。

我见过不少设备,固件在 Flash 里是明文存储的。攻击者用个编程器,把 Flash 拆下来一读,整个固件就拿到了。然后逆向分析,找漏洞,改代码,再写回去。设备就成别人的了。

固件加密存储,通常有两种做法:

方案 原理 优点 缺点
全盘加密 整个 Flash 用 AES 密钥加密,启动时由 Bootloader 解密 安全性高,实现简单 启动慢,密钥管理复杂
分区加密 只加密敏感分区(如文件系统、配置区) 性能影响小,灵活 需要仔细规划分区表
按需解密 运行时只解密正在执行的代码页 内存占用低,抗侧信道 实现复杂,需要 MMU 支持

我建议:对于资源受限的 MCU,优先用分区加密。把 Bootloader 和关键配置区加密,应用代码可以明文(但必须签名)。这样平衡了安全和性能。

密钥怎么存?嗯,这里要注意:

  • 绝对不能把密钥明文写在固件里
  • 利用芯片的硬件安全模块(HSM/SE),密钥生成在芯片内部
  • 或者用物理不可克隆函数(PUF),每个芯片的密钥都不同

2.4 防回滚机制:堵住“降级攻击”的漏洞

防回滚,很多人会忽略。但攻击者最喜欢这个。

为什么?因为新固件修了漏洞,但旧固件有漏洞。攻击者把设备固件降级到有漏洞的版本,然后利用已知漏洞入侵。这就是“降级攻击”。

我在一个摄像头项目里就遇到过。固件签名验证做得很好,但没做版本检查。攻击者把固件降级到三年前的版本,那个版本有个缓冲区溢出漏洞,直接 getshell 了。

防回滚的核心机制:

  • 固件头里必须包含版本号,且版本号单调递增
  • Bootloader 在验证签名前,先检查新固件版本号是否 >= 当前版本
  • 版本号存储在一次性写入区域(如 eFuse),升级后不可回退
  • 或者用“版本计数器”,每次升级加一,存储在安全存储区

具体实现时,我习惯用“版本槽位”的方式:

// 固件头结构示例
typedef struct {
    uint32_t magic;          // 魔数,用于识别固件格式
    uint32_t version;        // 版本号,单调递增
    uint32_t chip_id;        // 芯片唯一ID
    uint32_t firmware_size;  // 固件大小
    uint8_t  hash[32];       // SHA-256 摘要
    uint8_t  signature[256]; // ECDSA 签名
} firmware_header_t;

// 版本检查逻辑
if (new_fw->version <= current_version) {
    // 拒绝降级
    return ERROR_ROLLBACK_DETECTED;
}

避坑指南:

我曾经犯过一个错:版本号用字符串比较(如 "v2.1.3" > "v2.1.10" 会返回 false)。后来统一改成整数版本号,或者用语义化版本解析库。记住,版本比较必须用数值,不能用字符串。

2.5 四个基线的联动关系

这四个基线不是孤立的。它们是一个整体:

  • 签名验证是入口,确保固件来源可信
  • 安全启动是链条,确保每一级代码都可信
  • 加密存储是屏障,防止固件被静态分析
  • 防回滚是兜底,防止攻击者利用旧版本漏洞

少了任何一个,整个安全体系就有缺口。我见过太多合规审计,就是因为防回滚没做,被判定为“严重不合规”。