"喂,你们那设备上电之后随便拿个串口工具就能读固件?"
客户这句话是下午三点发过来的。当时我正在调试另一块板子,看了一眼消息,心想不至于吧。结果试了一下——好家伙,Reset拉低、BOOT0拉高、上电,OpenOCD连上去二话不说就把整个flash读出来了。客户说得客气,但意思很明确:你们这东西跟裸奔没区别。
说实话之前不是没想过安全问题。但像大多数做单片机开发的团队一样,总觉得"我们这玩意儿又不连公网"、"谁会闲得蛋疼来读你这破固件"——直到这东西真的被客户部署到别人家的机房里,你才发现:信任边界一旦超出你控制的范围,一切假设都不成立。
secure boot 这东西,很多低端MCU压根不支持。但如果你用的芯片好歹有个OTP区域或者efuse,那就能做点文章。拿STM32举例子,它的RDP(Read Protection)等级分三级:
- Level 0:无保护,你随便读
- Level 1:禁止调试接口读flash,但SRAM还能访问,可以通过SRAM里的代码间接读flash
- Level 2:彻底锁死,调试接口永久失效,不可逆
我当时第一个反应是直接上Level 2。但一查手册——Level 2一旦设置,连你自己都没办法通过调试器更新固件了,只能走ISP或者OTA。如果bootloader崩了,这板子就只能当砖头扔了。Level 2这玩意儿就是个回不了头的选择。
后来折中了一下,选了Level 1 + 自定义校验。Level 1意味着SWD/JTAG直接读flash读不出来,但还是要防SRAM攻击。STM32有一个叫RDP Regression的功能——如果你设置了Level 1,然后又想降级回Level 0,芯片会自动擦除整个flash。这是个好机制。
但问题在于:光靠硬件读保护还不够。
/* 设置RDP Level 1的代码,很简短 */ HAL_FLASH_OB_Unlock(); FLASH_OBProgramInitTypeDef ob = {0}; ob.OptionType = OPTIONBYTE_RDP; ob.RDPLevel = OB_RDP_LEVEL_1; HAL_FLASHEx_OBProgram(&ob); HAL_FLASH_OB_Launch();这段代码跑完,重新上电,读保护就生效了。但你知道我踩了什么坑吗?它只保护了flash的静态数据。如果攻击者把你板子上的flash芯片(如果是外部spi flash)直接拆下来用编程器读呢?如果你用的是外部QSPI Flash存代码呢?那就不是RDP能管的事了。
去年有个项目用了W25Q64做代码存储,结果生产的时候发现烧录效率太低,就把flash座子喷了——结果客户现场,维修工直接把座子上的flash拆下来,插到自己的烧录器上读走了固件。物理访问 = 完全控制,这个基本假设你得接受。
所以后来做了这么几件事:
第一,bootloader里加了一层签名验证。在固件bin文件的末尾附上RSA签名,bootloader启动时用公钥验签。公钥放在芯片内部flash的OTP区域,改不了。这样就算固件被读走了,它也没法篡改后刷回去。
/* bootloader验签简化版 */ bool verify_firmware(const uint8_t *fw, uint32_t len) { /* 假设fw的最后256字节是RSA-2048签名 */ uint32_t sig_len = 256; const uint8_t *signature = fw + len - sig_len; uint8_t hash[32]; mbedtls_sha256(fw, len - sig_len, hash, 0); return mbedtls_rsa_pkcs1_verify( &public_key, MBEDTLS_MD_SHA256, hash, signature) == 0; }别笑,就这一行验签函数,省了后面多少破事。
第二,外部flash的数据做了AES加密。密钥存在MCU内部的OTP里,bootloader启动时用内部flash的OTP key解密外部flash的代码。这样就算别人拆了flash芯片,拿到的也是密文,根本用不了。
/* AES解密外部flash内容到内部SRAM */ void decrypt_firmware(uint32_t ext_addr, uint32_t *dest, uint32_t size) { uint8_t key[32]; read_otp_key(key); /* 从OTP读密钥 */ mbedtls_aes_context aes; mbedtls_aes_setkey_dec(&aes, key, 256); uint8_t iv[16] = {0}; /* 注意这里的iv不能全0,生产环境下要从eFuse/UID派生 */ for (uint32_t i = 0; i < size; i += 16) { uint8_t buf[16]; /* 从外部flash读 */ spi_flash_read(ext_addr + i, buf, 16); mbedtls_aes_crypt_cbc(&aes, MBEDTLS_AES_DECRYPT, 16, iv, buf, dest + i/4); } }当然AES-CBC模式有个问题:如果攻击者能多次上电,观察不同iv下的解密行为,理论上可以进行padding oracle attack。虽然对嵌入式场景来说攻击成本很高,但如果你的产品要过FIPS或者等保测评,建议换成GCM或者CTR。
第三,加了一道防篡改检测,虽然不是软件层面的——在PCB上埋了几条细走线,一旦外壳被打开,走线被切断,立即触发DMAC(死亡中断),调用RTC backup register里存的标志位,下次启动直接进recovery模式。硬件层级的防护比软件更不好绕过。
那OTA升级的安全性呢?
一旦开启了OTA,攻击面就从一个物理接触口扩大到了网络。最开始我图省事,OTA固件包直接用明文传。直到有次做渗透测试,对方通过抓包拿到了固件镜像,逆向分析找到了里面硬编码的阿里云key……那个云账号下的所有设备数据全等于白送。
那一版之后,OTA包至少要做:签名(防篡改)+ 加密(防窥探)+ 版本号校验(防降级攻击)。缺一个都不行。
OTA包的格式大概是这样的:
| 固件头 (64B) | 固件数据 (加密) | RSA签名 (256B) | |- magic: 4B | | | |- version: 4B | | | |- length: 4B | | | |- iv: 16B | | | |- reserved | | |bootloader收到OTA包后,先验签名,确认版本不低于当前运行的版本(防止rollback attack),然后解密写入flash。
说到防降级攻击,我见过一个真实案例:某厂商的智能门锁,攻击者拿到了旧版本的固件包(旧版本有个硬编码的管理员口令),然后模拟服务器伪装成OTA下发,把门锁降级到了那个版本,直接拿管理员权限开门。所以版本号校验是硬性要求,而且版本号要跟签名一起验证,不能被篡改。
最后说一句:安全这种事,没有银弹。你加了读保护、上了签名验证、做了加密存储——还是会有人拿探针去刮开芯片封装,用FIB(聚焦离子束)修改变压器电路来绕过OTP熔丝。但问题是,你的攻击者愿意花多少钱来搞你?
对于大部分IoT设备来说,把防护成本做到比你的产品售价高,就已经赢了。几千块钱买回来的设备,攻击者不太可能花几万美金去做芯片级逆向。除非你的设备控制的是核电站阀门或者几百万人的支付数据——那另当别论。
对了,后来我跟客户说已经加了读保护和签名验证。他没回消息。第二天他发了一张照片过来——一台用钢锯锯开的设备壳子,旁边丢着个逻辑分析仪。底下配文:"这个怎么防?"