3. 固件加密与混淆:把代码锁进保险柜

各位做 IoT 的同行,咱们都清楚一个现实:固件一旦被读出来,基本就等于裸奔。我见过太多产品,Flash 焊下来用编程器一读,明文代码、硬编码密钥、甚至云平台证书全在里面躺着。这哪是安全产品?这分明是给攻击者送人头。

所以这一章,咱们就聊聊怎么给固件穿上铠甲——加密与混淆。说白了,就是让攻击者即使拿到二进制,也看不懂、改不了、跑不起来。

3.1 对称加密 vs 非对称加密:选型不是拍脑袋

先解决第一个问题:用什么算法来加密固件?

我个人习惯把场景分成两类:固件分发和固件存储。这两者的需求完全不同。

对比维度 对称加密(AES) 非对称加密(RSA/ECC)
加密速度 快(硬件加速可达 Gbps) 慢(通常慢 1000 倍以上)
密钥管理 分发密钥困难 公钥公开,私钥保密
适用场景 固件镜像加密、存储加密 签名验证、密钥交换
典型算法 AES-128/256, ChaCha20 RSA-2048, ECDSA, Ed25519
硬件开销 低(多数 MCU 内置 AES 引擎) 高(需要大数运算协处理器)

核心原则:固件镜像本身用对称加密,非对称只用来保护对称密钥。这叫「混合加密体系」,业界标准做法。

为什么?你想想看,一个 2MB 的固件用 RSA 加密,光运算时间就够喝杯咖啡了。而 AES-128-CTR 模式在 Cortex-M4 上跑,硬件加速下每秒能处理几十 MB。这差距,不是一星半点。

3.2 固件镜像加解密流程:从产线到芯片

好,算法选定了,那具体怎么操作?我画了一张流程图,把整个链路串起来。

固件加解密与签名验证流程 固件加解密与签名验证流程 开发环境 固件编译 AES加密 + 签名 产线烧录 设备启动 密钥管理 生成AES密钥 RSA公钥签名 烧录AES密钥 RSA验签+AES解密 关键点 • AES密钥每台设备唯一 • RSA私钥离线保存 • 产线不接触明文固件 启动验证 1. BootROM验RSA签名 2. 通过后AES解密 3. 解密后CRC校验

流程其实不复杂,但有几个坑我得提醒你。

我曾经踩过的坑:某次产线烧录,AES 密钥是写死在烧录脚本里的。结果产线电脑被入侵,所有设备的密钥全部泄露。后来我改成每台设备独立密钥,密钥由 HSM 生成,烧录器只传密文。记住:密钥永远不要出现在脚本或配置文件中。

3.3 代码混淆与反逆向:让反汇编变成噩梦

加密解决了「读不到明文」的问题,但有个前提——攻击者得先拿到密文。如果设备已经被物理破解,Flash 被直接读取呢?这时候就需要第二道防线:代码混淆。

混淆不是加密,它不隐藏数据,而是让代码变得难以理解。我常用的手段有这几类:

  • 控制流平坦化:把正常的 if-else 循环全部打散,变成一张巨大的状态机跳转表。IDA 的 F5 反编译直接崩溃。
  • 虚假控制流:插入大量永远不会执行的分支,但静态分析看不出来。反编译器会生成一堆垃圾代码。
  • 字符串加密:所有敏感字符串(日志、API 路径、密钥提示)在编译时加密,运行时按需解密。
  • 指令替换:把标准指令替换成等效的复杂指令序列。比如 MOV R0, #0 改成 EOR R0, R0, R0。

我的经验:混淆不是越强越好。控制流平坦化会让代码体积膨胀 3-5 倍,对 Flash 紧张的 MCU 来说可能吃不消。我一般只在关键函数(密钥派生、签名验证、安全启动)上做混淆,其他部分保持正常。

3.4 实战:一个简单的固件加解密示例

光说不练假把式。下面是一个基于 OpenSSL 的固件加密脚本,我实际项目里改过几版,现在拿出来给你参考。

#!/bin/bash
# 固件加密脚本 - 产线使用
# 用法:./encrypt_firmware.sh firmware.bin

INPUT=$1
AES_KEY="$(openssl rand -hex 32)"  # 生成256位AES密钥
IV="$(openssl rand -hex 16)"       # 生成128位IV

# 1. 用AES-256-CTR加密固件
openssl enc -aes-256-ctr \
  -in "$INPUT" \
  -out "${INPUT}.enc" \
  -K "$AES_KEY" \
  -iv "$IV"

# 2. 用RSA公钥加密AES密钥
openssl rsautl -encrypt \
  -inkey rsa_public.pem \
  -pubin \
  -in <(echo -n "$AES_KEY$IV") \
  -out "${INPUT}.key.enc"

# 3. 计算签名
openssl dgst -sha256 -sign rsa_private.pem \
  -out "${INPUT}.sig" \
  "${INPUT}.enc"

echo "加密完成!"
echo "输出文件:${INPUT}.enc"
echo "密钥文件:${INPUT}.key.enc"
echo "签名文件:${INPUT}.sig"

对应的设备端解密流程(伪代码):

// 设备端解密 - 在安全环境中执行
int decrypt_firmware(uint8_t* enc_fw, uint32_t len,
                     uint8_t* enc_key, uint8_t* signature) {
    
    // 1. 验证RSA签名
    if (!rsa_verify(enc_fw, len, signature, rsa_pub_key)) {
        return ERR_SIGNATURE_INVALID;  // 签名不匹配,拒绝执行
    }
    
    // 2. 用RSA私钥解密AES密钥和IV
    uint8_t aes_key[32], iv[16];
    rsa_decrypt(enc_key, aes_key, rsa_priv_key);
    
    // 3. AES解密固件
    aes_ctr_decrypt(enc_fw, len, aes_key, iv);
    
    // 4. CRC校验
    if (crc32(enc_fw, len) != EXPECTED_CRC) {
        return ERR_CRC_MISMATCH;
    }
    
    return SUCCESS;
}

注意:设备端的 RSA 私钥必须存储在安全元件(SE)或 TrustZone 中。如果私钥被读出来,整个加密体系就崩塌了。我见过有人把私钥放在普通 Flash 里,结果被 JTAG 读出来——那加密还有什么意义?

3.5 反逆向技术:不止是混淆

混淆只是第一步。真正的反逆向,需要从硬件和软件两个层面同时下手。

我常用的组合拳:

  • 反调试检测:在关键函数入口检查调试寄存器(DHCSR、DEMCR),如果发现调试器附着,直接跳入死循环或擦除 Flash。
  • 时序攻击防护:加密解密操作加入随机延时,防止攻击者通过功耗分析推断密钥。
  • 自修改代码:部分关键函数在运行时从加密区解密到 RAM 执行,执行完立即擦除。这样即使 Flash 被读,也拿不到完整代码。
  • JTAG/SWD 熔断:量产最后一步,永久禁用调试接口。这是最有效但最容易被忽略的一步。

我曾经犯过的错:有个产品量产时忘了熔断 JTAG,结果被安全研究员用 20 块钱的调试器就把固件读出来了。后来我定了个规矩:产线烧录完成后,必须做 JTAG 熔断验证测试,没熔断的算不良品。

3.6 小结:加密与混淆的平衡

说了这么多,其实就一句话:没有绝对的安全,只有不断增加的攻击成本。

加密让攻击者拿不到明文,混淆让拿到明文的人也看不懂,反调试让看懂的人也跑不起来。三层叠加,攻击者要付出的代价就指数级上升。但代价是双向的——你的开发调试也会变麻烦。所以我的建议是:

  • 对消费级 IoT 产品,AES 加密 + 基本混淆就够了
  • 对工业/医疗/金融设备,必须上全套:混合加密 + 控制流混淆 + 反调试 + SE
  • 永远不要相信「我的产品没人会破解」这种话——你永远不知道攻击者有多闲