Skip to content

LV019-升级安全机制 ​

一、 OTA 面临的安全威胁 ​

1. 攻击面 ​

OTA 是设备与外部世界交互最深入的通道:它下载可执行代码并让设备运行。攻击面按维度分成两类——三个空间位置(包从哪来、经过谁、落到哪)加一个时间维度(能不能退回过去):

维度攻击面攻击方式后果
空间传输通道中间人篡改、DNS 劫持、伪基站替换升级包为恶意固件
空间OTA 服务器 / 构建机入侵仓库、篡改构建产物下发带后门的"官方"固件
空间设备端本地注入升级包(U 盘 / 调试口)绕过网络直接刷入非法固件
时间历史版本重放一个旧的、签名仍然合法的包降级到有已知漏洞的版本,复现已修补的漏洞

这个划分直接决定了防线的分工:前三个空间位置由签名验签一道防线同时覆盖——无论包在哪个位置被换掉,只要设备端坚持验签,伪造的包都过不了。第四个时间维度签名管不了(旧包的签名本来就是合法的),必须另设防回滚机制,见本篇第四节。

2. 设计前提:不信任通道 ​

一切防护的出发点是"通道不可信":假设攻击者能看到、能修改、能重放所有传输的字节。因此安全边界不能建在传输层,而必须建在设备端的密码学校验上——升级包必须携带能证明其来源与完整性的密码学证据。

对应的三道防线:

(1)真实性:数字签名,证明包由厂商私钥签发(本篇第二节)

(2)完整性:哈希摘要 + 镜像 CRC,证明内容未被改动(LV007-升级包格式与传输机制)

(3)版本策略:防回滚,禁止降级到不安全的版本(本篇第四节)

这三道防线解决的都是"能不能信"。固件内容能不能被读是另一个正交问题,由加密解决(本篇第五节)——很多方案把两者混为一谈,以为签了名固件就安全了。

二、 数字签名与验签 ​

1. 原理 ​

非对称密码学提供了"私钥签名、公钥验证"的能力:

  • 厂商持有私钥,用它对升级包的哈希摘要签名,生成签名值
  • 设备内置厂商公钥,用公钥验证"这个签名确实对应这份摘要"
  • 攻击者没有私钥,无法伪造合法签名;改动包内任意一个字节,摘要变化,验签必然失败

算法选型建议:

算法签名长度适用
ECDSA P-256(secp256r1)64 字节MCU 首选,算力与长度均衡
Ed2551964 字节有硬件加速时性能更好
RSA-2048 / 3072256 / 384 字节老平台兼容,验签慢、占空间

2. 签名与验签流程 ​

升级包签名与验签流程

流程上有两点必须坚持:

  • 私钥不出签名服务器:构建流水线只把镜像摘要送进签名服务,拿回签名。这样即使构建机或 OTA 服务器被入侵,攻击者也无法签发新固件
  • 先验签,后落盘:设备端在验签通过前,升级包的任何一个字节都不写入目标槽,避免"半写入的非法固件"

3. 设备端验签的完整实现 ​

以 STM32 + mbedTLS(ECDSA P-256 + SHA-256)为例:

c
// ota_verify.c —— 设备端升级包验签(mbedTLS 实现)
#include "mbedtls/sha256.h"
#include "mbedtls/ecdsa.h"
#include "ota_package.h"
#include <string.h>

/* 厂商公钥:编译进 Bootloader / App,不可被 OTA 覆盖(放只读区) */
static const uint8_t VENDOR_PUBKEY[65] = {
    0x04, /* 未压缩点格式 */
    /* X 坐标 32 字节 */
    0x11, 0x22, /* ... 省略 ... */
    /* Y 坐标 32 字节 */
    0x33, 0x44, /* ... 省略 ... */
};

/* 验签:payload = 升级包负载,sig = 包头中的签名 */
int ota_verify_payload(const uint8_t *payload, uint32_t len,
                       const uint8_t *sig, uint32_t sig_len)
{
    uint8_t digest[32];
    int ret;

    /* 1. 计算负载摘要 */
    mbedtls_sha256_ret(payload, len, digest, 0);

    /* 2. 导入厂商公钥 */
    mbedtls_ecp_group grp;
    mbedtls_ecp_point Q;
    mbedtls_mpi r, s;
    mbedtls_ecp_group_init(&grp);
    mbedtls_ecp_point_init(&Q);
    mbedtls_mpi_init(&r);
    mbedtls_mpi_init(&s);

    ret = mbedtls_ecp_group_load(&grp, MBEDTLS_ECP_DP_SECP256R1);
    if (ret != 0) goto cleanup;

    ret = mbedtls_ecp_point_read_binary(&grp, &Q, VENDOR_PUBKEY,
                                        sizeof(VENDOR_PUBKEY));
    if (ret != 0) goto cleanup;

    /* 3. 拆出 r || s */
    ret = mbedtls_mpi_read_binary(&r, sig, 32);
    if (ret != 0) goto cleanup;
    ret = mbedtls_mpi_read_binary(&s, sig + 32, 32);
    if (ret != 0) goto cleanup;

    /* 4. 验证签名:返回 0 表示验签通过 */
    ret = mbedtls_ecdsa_verify(&grp, digest, sizeof(digest), &Q, &r, &s);

cleanup:
    mbedtls_ecp_group_free(&grp);
    mbedtls_ecp_point_free(&Q);
    mbedtls_mpi_free(&r);
    mbedtls_mpi_free(&s);
    return ret;                          /* 0 成功,非 0 一律视为不可信 */
}

【注意】

ret != 0 必须一律拒绝,不能把"验签函数报错"当成"验签通过"。这是嵌入式验签最常见的漏洞:出错分支忘记 return,直接继续刷写。

三、 安全启动信任链 ​

1. 信任根与逐级验签 ​

设备上电时没有任何可信参照物,因此必须有一个物理上不可修改的信任根。典型实现是把厂商公钥的哈希烧进芯片的 eFuse / OTP(一次性可编程),或者固化在掩膜 ROM 中:

安全启动与 OTA 的信任链

信任链的本质是归纳传递:如果 BootROM 不可篡改,且它只运行验签通过的下一级,那么下一级也是可信的;依此类推,整条链都可信。OTA 升级包在这一体系中享有"同级"待遇——用同一把公钥验签后,它才能成为一个新的可信节点。

2. MCU 上的折中做法 ​

多数 MCU(如 STM32F103)没有 eFuse 与安全启动硬件,能做的是:

(1)Bootloader 视为信任根:把公钥硬编码在 Bootloader 里,并保证 Bootloader 自身不可被 OTA 修改(参数区里不提供 Bootloader 槽位)

(2)读保护(RDP):开启 Flash 读保护,防止攻击者用调试口读出固件与公钥、分析逻辑

(3)调试口关闭:量产固件关闭 SWD/JTAG(STM32 通过选项字节实现),堵住本地注入通道

(4)接受残余风险:物理接触式攻击(拆片、侧信道)不在威胁模型内,这类设备通常也不值得如此高的攻击成本

有安全需求的型号应选支持硬件信任根的 MCU:STM32L5/U5(TrustZone + 安全启动)、STM32H5(产品状态生命周期)、STM32MP1 等。

3. Linux 平台的差异 ​

Linux 的信任链比 MCU 长得多,而且多出来的每一级都是独立的可篡改点:BootROM → SPL → U-Boot → 内核 → rootfs → 运行时文件。SoC 厂商普遍提供硬件信任根(i.MX 的 HAB、瑞芯微的 Secure Boot 等)覆盖前几级,但内核与 rootfs 的完整性必须靠额外机制补齐:

(1)内核层:FIT 镜像签名,让 U-Boot 在引导前验证内核与设备树

(2)rootfs 层:dm-verity 为只读分区建哈希树,运行时按块校验

(3)文件层:IMA/EVM 校验文件内容与属性,把信任延伸到运行时

开启硬件安全启动后,Bootloader 与内核的验签由硬件完成,OTA 框架只需负责自己打包的部分(rootfs 与应用)。这三层的具体配置、dm-verity 与差分升级的冲突处理,见 LV028-Linux平台OTA方案 第六节。

四、 防回滚机制 ​

1. 为什么必须防降级 ​

签名只证明"包是厂商发的",不证明"这个包该不该装"。如果攻击者拿到了一个旧版本的合法签名包(例如从旧固件镜像中提取,或从官方历史版本下载),就可以:

(1)把设备降级到存在已知漏洞的旧版本

(2)利用该漏洞提权或植入持久化后门

(3)由于旧包签名合法,所有签名校验都会通过

因此需要一条独立于签名的规则:只允许升级到不低于当前安全等级的版本。

2. 单调版本计数 ​

实现方式是维护一个单调递增、只能增不能减的版本计数器,并记录该计数器在不可篡改的介质中:

c
// anti_rollback.c —— 单调版本计数(以 OTP / 备份寄存器为例)
#include <stdint.h>
#include <stdbool.h>

#define OTP_VERSION_ADDR   0x1FFF7800u   /* 示意:OTP 区起始地址 */
#define OTP_MAX_WRITE      8u            /* 最多可写 8 次(每次 1 bit) */

/* 升位计数:初始 0x00,每次"提级"把下一个 bit 从 0 写成 1
 * 值 = 已写 bit 数,只增不减,且每次提级不可逆 */
int anti_rollback_raise(uint8_t new_level)
{
    uint8_t cur = anti_rollback_current();
    if (new_level <= cur) {
        return 0;                        /* 无需提级 */
    }
    if (new_level > OTP_MAX_WRITE) {
        return -1;                       /* 超出可写次数 */
    }

    uint8_t *otp = (uint8_t *)OTP_VERSION_ADDR;
    uint8_t  next = 0;
    for (uint8_t i = 0; i < new_level; i++) {
        next |= (uint8_t)(1u << i);
    }
    if (next == *otp) {
        return 0;
    }
    /* OTP 只能把 0 写 1,这里恰好是"逐位补 1",天然满足单调性 */
    extern int otp_program_byte(uint32_t addr, uint8_t value);
    return otp_program_byte(OTP_VERSION_ADDR, next);
}

uint8_t anti_rollback_current(void)
{
    uint8_t v = *(volatile uint8_t *)OTP_VERSION_ADDR;
    uint8_t count = 0;
    while (count < 8 && (v & (uint8_t)(1u << count))) {
        count++;
    }
    return count;                        /* 已置 1 的位数即当前等级 */
}

/* 升级前检查:目标版本的安全等级不得低于当前等级 */
bool anti_rollback_allow(uint8_t target_level)
{
    return target_level >= anti_rollback_current();
}

要点说明:

  • OTP / eFuse 的物理特性是"只能把 0 写成 1",用"已置 1 的位数"表示版本等级,天然得到单调性与不可逆性
  • 没有 OTP 时,退而求其次:在受读保护的 Flash 参数区维护版本号并同时写两份,提高篡改成本(但仍不如硬件方案)
  • 等级提升的时机是"新固件确认转正(mark good)之后",绝不能提前,否则一次失败升级就会永久封锁旧版本

3. 与回滚机制的配合 ​

防回滚与"芯片内回滚"是两件事,不能混为一谈:

概念含义由谁执行
回滚(rollback)新固件异常时切回旧固件Bootloader(LV016-选槽与回滚机制)
防回滚(anti-rollback)禁止安装低安全等级的固件验签后的版本策略检查

两者兼容的关键是:防回滚只约束"外部新包的版本下限",不影响"设备自己回退到本地旧槽"。旧槽镜像在设备内部,本来就没经过安装流程。

五、 升级包加密(机密性) ​

1. 签名与加密的区别 ​

很多方案把"签名"和"加密"混为一谈,二者解决的是不同问题:

机制解决的问题密钥位置能否防伪能否防读
签名(ECDSA / Ed25519)真实性、完整性私钥在厂商,公钥在设备能不能
加密(AES-GCM / ChaCha20)机密性对称密钥在设备与厂商不能能

典型误区:以为签了名固件就"安全"了。签名只保证固件没被篡改、来源可信,但固件内容仍以明文传输,攻击者抓包即可完整读出代码逻辑。若固件里包含商业算法、密钥、专利逻辑,必须再加密。

2. AES-GCM 加密实现 ​

AES-GCM 同时提供加密与完整性认证(AEAD),是升级包加密的推荐算法:

c
// ota_decrypt.c —— 升级包 AES-256-GCM 解密(mbedTLS 实现)
#include "mbedtls/gcm.h"
#include <string.h>

/* 设备端预置的 32 字节密钥(生产环境应放安全存储,不要明文硬编码) */
static const uint8_t OTA_AES_KEY[32] = { /* ... */ };

/* 解密升级包负载
 * nonce  : 12 字节随机数(随包下发,每次升级必须不同,绝不能复用)
 * aad    : 附加认证数据(通常用包头,防包头被替换)
 * input  : 密文负载
 * tag    : 16 字节认证标签(随包下发)
 */
int ota_decrypt_payload(const uint8_t *nonce, const uint8_t *aad, uint32_t aad_len,
                        const uint8_t *input, uint32_t len,
                        const uint8_t *tag, uint8_t *output)
{
    mbedtls_gcm_context gcm;
    int ret;

    mbedtls_gcm_init(&gcm);
    ret = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES,
                             OTA_AES_KEY, 256);
    if (ret != 0) goto cleanup;

    ret = mbedtls_gcm_crypt_and_tag(&gcm, MBEDTLS_GCM_DECRYPT, len,
                                    nonce, 12, aad, aad_len,
                                    input, output, 16, (uint8_t *)tag);

cleanup:
    mbedtls_gcm_free(&gcm);
    return ret;                          /* 0 成功(含标签验证通过) */
}

【注意】

AES-GCM 的 nonce 绝不能重复使用——同一密钥下复用 nonce 会直接泄露明文异或关系并破坏认证安全性。每次升级的 nonce 必须由签名服务器随机生成并随包下发。

3. 加密与签名的组合顺序 ​

正确顺序是"先签名,后加密":

text
明文固件 → 计算哈希 → 私钥签名 → 明文+签名一起 AES-GCM 加密 → 下发
设备端:解密(验证 GCM 标签)→ 得到明文与签名 → 公钥验签 → 写槽
  • GCM 标签保证"传输过程没被改"
  • 验签保证"解密出的明文确实是厂商发的"

反过来"先加密后签名"会让设备必须先解密才能验签,解密失败与验签失败难以区分,排查困难。

4. 密钥存储 ​

对称密钥放在哪里,决定了加密的实际强度:

存储位置强度适用
明文硬编码在固件里极低(可被读出)仅开发调试
代码混淆 + 读保护(RDP)低(可被分析)一般消费类
OTP / eFuse中MCU 主流
安全芯片(SE / ATECC608)高高价值固件
TrustZone 安全区高Cortex-M33 / A 核

量产建议:密钥放 OTP 或安全芯片,固件里不放明文;即使是 RDP 方案,也应假设攻击者能读出,从而只把加密当作"提高分析门槛"而非"绝对机密"。

六、 传输安全与工程要点 ​

1. TLS 与包签名的分工 ​

两者不能互相替代:

  • TLS 保护"这一段链路",防止中间人。它无法阻止被入侵的服务器下发恶意包
  • 包签名保护"包本身",无论经过多少跳、被谁缓存,验签结果都一样

正确做法是两者都上,但把签名当作信任的最终依据。

2. 密钥轮换与生产烧录 ​

  • 公钥要能升级:预留"下一把公钥的哈希"字段,用当前可信号签新公钥,实现平滑轮换
  • 生产环节:私钥离线保存(HSM / 专用签名机),公钥在产线随固件一起烧录;签名权限与构建权限分离,避免单人即可发布
  • 泄露响应:预留吊销通道(服务端下发"最低允许版本 + 新公钥"的强制更新指令)

3. 常见误区 ​

(1)只校验 CRC:CRC 只能发现随机错误,攻击者改完数据顺手改 CRC 即可绕过

(2)验签失败仅记录日志:必须硬失败并丢弃升级包

(3)公钥存在可写区:OTA 能改公钥等于没有验签,公钥必须放在 OTA 触达不到的只读区

(4)忽略时间与重放:只验签不验版本,攻击者可反复重放旧包

(5)调试口留着:量产固件务必关闭调试接口并开启读保护


本文档由 markdowncli 技能辅助生成