LV019-升级安全机制
一、 OTA 面临的安全威胁
1. 攻击面
OTA 是设备与外部世界交互最深入的通道:它下载可执行代码并让设备运行。攻击面按维度分成两类——三个空间位置(包从哪来、经过谁、落到哪)加一个时间维度(能不能退回过去):
| 维度 | 攻击面 | 攻击方式 | 后果 |
|---|---|---|---|
| 空间 | 传输通道 | 中间人篡改、DNS 劫持、伪基站 | 替换升级包为恶意固件 |
| 空间 | OTA 服务器 / 构建机 | 入侵仓库、篡改构建产物 | 下发带后门的"官方"固件 |
| 空间 | 设备端 | 本地注入升级包(U 盘 / 调试口) | 绕过网络直接刷入非法固件 |
| 时间 | 历史版本 | 重放一个旧的、签名仍然合法的包 | 降级到有已知漏洞的版本,复现已修补的漏洞 |
这个划分直接决定了防线的分工:前三个空间位置由签名验签一道防线同时覆盖——无论包在哪个位置被换掉,只要设备端坚持验签,伪造的包都过不了。第四个时间维度签名管不了(旧包的签名本来就是合法的),必须另设防回滚机制,见本篇第四节。
2. 设计前提:不信任通道
一切防护的出发点是"通道不可信":假设攻击者能看到、能修改、能重放所有传输的字节。因此安全边界不能建在传输层,而必须建在设备端的密码学校验上——升级包必须携带能证明其来源与完整性的密码学证据。
对应的三道防线:
(1)真实性:数字签名,证明包由厂商私钥签发(本篇第二节)
(2)完整性:哈希摘要 + 镜像 CRC,证明内容未被改动(LV007-升级包格式与传输机制)
(3)版本策略:防回滚,禁止降级到不安全的版本(本篇第四节)
这三道防线解决的都是"能不能信"。固件内容能不能被读是另一个正交问题,由加密解决(本篇第五节)——很多方案把两者混为一谈,以为签了名固件就安全了。
二、 数字签名与验签
1. 原理
非对称密码学提供了"私钥签名、公钥验证"的能力:
- 厂商持有私钥,用它对升级包的哈希摘要签名,生成签名值
- 设备内置厂商公钥,用公钥验证"这个签名确实对应这份摘要"
- 攻击者没有私钥,无法伪造合法签名;改动包内任意一个字节,摘要变化,验签必然失败
算法选型建议:
| 算法 | 签名长度 | 适用 |
|---|---|---|
| ECDSA P-256(secp256r1) | 64 字节 | MCU 首选,算力与长度均衡 |
| Ed25519 | 64 字节 | 有硬件加速时性能更好 |
| RSA-2048 / 3072 | 256 / 384 字节 | 老平台兼容,验签慢、占空间 |
2. 签名与验签流程
流程上有两点必须坚持:
- 私钥不出签名服务器:构建流水线只把镜像摘要送进签名服务,拿回签名。这样即使构建机或 OTA 服务器被入侵,攻击者也无法签发新固件
- 先验签,后落盘:设备端在验签通过前,升级包的任何一个字节都不写入目标槽,避免"半写入的非法固件"
3. 设备端验签的完整实现
以 STM32 + mbedTLS(ECDSA P-256 + SHA-256)为例:
// 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 中:
信任链的本质是归纳传递:如果 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. 单调版本计数
实现方式是维护一个单调递增、只能增不能减的版本计数器,并记录该计数器在不可篡改的介质中:
// 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),是升级包加密的推荐算法:
// 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. 加密与签名的组合顺序
正确顺序是"先签名,后加密":
明文固件 → 计算哈希 → 私钥签名 → 明文+签名一起 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 技能辅助生成