固件防逆向与混淆实战:把被端走的那份固件自己逆向了一遍——strings 十分钟出卖了我,而保住算法的从来不是控制流平坦化
上一篇《Flash 存储与参数管理》还了 131 篇的账,但 130 篇《固件读出保护与签名固件升级》留下的站内空白还有后半:RDP 和签名链管住了”读出”和”推包”,管不住固件落到别人手里之后的事。那 30 台退网网关的 512KB 固件已经流出去了,追不回来——所以本篇换一个打法:扮演攻击者,把自己那份固件逆向一遍,看十分钟、一天、一周分别能挖出什么,然后逐层算每一道防御的账,决定哪些值得上、哪些是裸机上的负资产。
结论先摆出来,五条:
- 防逆向的第一件事不是上混淆,是把”固件里不该有的秘密”清出去。 字符串清理 + 密钥出固件,运行时成本为零,挡掉攻击者第一分钟的绝大部分速赢。
- strings 是攻击者打开固件用的第一个工具。 1180 条字符串里 214 条敏感:服务器域名、MQTT 主题前缀、AT 命令全集、连变量名都带着的调试格式串——这 214 条是我自己写进去的。
- 符号表的泄漏面不在 .bin,在发布流程。 .bin 里本来就没有 symtab/strtab;真正的泄漏是发给产线和 ODM 的 .elf——3500 个符号约 130KB,strip 是零成本动作,但要写进发布脚本,而不是”记得做”。
- 控制流平坦化在 M3 上的真实代价是周期不是体积。 平均块长 30 周期的真实控制流,开销 +23%;块越碎越贵。而且它只挡静态阅读,不挡单步——它是”提高阅读成本”,不是”保密”。
- 裸机得不偿失清单:白盒密码、壳与自解压、纯 XOR 字符串。 白盒 AES 的表比 512KB flash 还大;XOR 字符串只要 16 字节已知明文就整条还原。
工具照例全离线:drafts/_tools/obfuscation_cost_calc.py,7 个模式、17 项自检断言,文中每个数都能复算。与130 篇、93 篇《MCUboot 安全启动与签名链》的锚点衔接处在文中标出。
一、现场:扮演攻击者,逆向自己
还是那台 512KB 的 4G 数据网关:STM32F103 高容量,Cortex-M3 @72MHz。130 篇的事故里,30 台退网设备被人用 USB-TTL 整锅端走了固件——RDP 当时还没开。事故之后我们把 RDP 和签名链都补上了,但流出去的那份固件是 2023 年的版本,RDP 0,什么保护都没有,它就是本篇的分析对象。
方法论一句话:攻击者拿到固件后做的事有强次序,防御就该按同一张账单逐条对账。 大致是:
- 第一分钟:
strings firmware.bin,grep 域名、密钥特征、AT 指令; - 第十分钟:
binwalk扫分区结构,把 bootloader / app / 参数区的边界切出来; - 第一小时:Ghidra 加载,M3 的 Thumb 反编译质量是所有 MCU 里最好的一档;
- 第一天起:找协议处理函数——从 MQTT 回调或串口接收中断入手,顺着函数调用图往下挖。
对应的防御账单也就四行:清字符串(对第一分钟)、裁符号(对结构阅读)、密钥出固件(对一切离线分析)、混淆(对特定算法的最后一段)。逐条算。
二、第一分钟:strings 泄了什么
对那份固件跑 strings(长度 ≥4 可打印),得到 1180 条。逐条分类统计:
| 类别 | 条数 | 典型样本(脱敏口径) | 泄了什么 |
|---|---|---|---|
| 服务器域名与端口 | 23 | iot.example-api.com:8883 |
整个基础设施入口 |
| MQTT 主题前缀 | 41 | powermon/%s/data、%s/energy |
主题命名规则 + %s 是 IMEI |
| AT 命令全集 | 96 | AT+QMTOPEN=, AT+CGDCONT= |
模组型号、附着流程 |
| 调试格式串 | 54 | "soc=%.1f, rint=%d mR" |
变量名、单位、内部算法痕迹 |
| 合计敏感 | 214 | 占 18.1% | 第一分钟的信息源 |
四类各有各的死法。域名和主题前缀直接把攻击者从”离线分析”推进到”在线接入”——拿着前缀和 IMEI 格式就能尝试仿冒接入(好在服务端有设备证书,79 篇《MQTT over TLS 实战》的功课没白做);AT 全集告诉攻击者用的是哪家教哪个系列的模组,对应的勘误和已知漏洞一查就有;调试格式串最冤——它把”这个固件里有个叫 soc 的浮点变量、内阻单位是毫欧”直接写在明文里,等于给反编译出的汇编自动补了变量名。
处置零运行时成本,全是编译期动作:
- ☐ Release 版按日志级别裁剪:LOG_DEBUG/LOG_INFO 的格式串整类编译掉,不是运行时不打印,是根本不进 .rodata
- ☐ 服务器地址走编译注入 + 环境区分,敏感前缀拆成”编译期常量 + 运行时拼接”只对绝对必要的那几个生效(注意:拼接挡 grep 不挡分析,收益有限,别高估)
- ☐ 单位、变量名不出现在用户可见的格式串里
- ☐ 清理后回归:对固件再跑一次 strings,敏感条数要有数字目标(本篇口径:214 → 11,剩的是协议栈自身字符串)
关键认知:这 214 条不是”被逆向出来的”,是我自己编译进去的。 防逆向的第一课不是对抗,是收拾自己。
三、符号表:.bin 没有,.elf 有——泄漏面在发布流程
这里要先纠正一个我自己也犯过的误解:objcopy -O binary 出来的 .bin 里本来就没有 symtab 和 strtab。从 .bin 反编译看到的全是裸地址,函数名一栏全是 FUN_0800xxxx。所以”strip 固件”这句话对 .bin 是无的放矢——真正带符号出厂的,是发布流程里的这三样:
- 发给产线的烧录 .elf / .hex(部分产线工具只吃 ELF);
- 发给 ODM/方案商的参考包,常直接带可调试的 .elf + .map;
- 归档在版本库 release 目录里的调试产物,谁有权限谁就能拿到。
泄漏量有多少账可算:每条符号在 .symtab 里占固定 16 字节(Elf32_Sym),名字在 .strtab 里均长约 22 字节。这台网关 3500 个符号:
| 项 | 数值 | 说明 |
|---|---|---|
| .symtab | 3500 × 16 = 56,000 B | 固定结构 |
| .strtab | 3500 × 22 = 77,000 B | 名字均长 22B(含 NUL) |
| 合计可裁 | 133,000 B ≈ 130 KB | strip 一次到位 |
130KB 的 symtab+strtab 意味着什么:攻击者在 Ghidra 里导入带符号 ELF 后,mqtt_publish、param_restore_default、aes_set_key 这些函数名原样出现在反编译窗口里——逆向工作量直接从”第一天”变成”第一小时”。这不是夸张,是我这次自己当攻击者时最切身的落差:对着裸 .bin 认了半小时的函数,换带符号 .elf 十分钟就认全了。
处置也是零成本,但有一条工程纪律:strip 必须写进发布脚本,不能靠”记得”。发布管线固定动作:产线烧录用 strip --strip-unneeded 后的 .elf(保留动态定位所需的最低限度,纯 MCU 场景可全 strip),带符号 .elf 只进受限权限的内部归档,.map 文件与 .elf 同级管理。验证方式:对发布产物跑 readelf -S,symtab/strtab 不存在才算过。
四、字符串混淆:XOR 的账,和 16 字节的死穴
字符串清理之后,如果还有少量非敏感但不想被一眼扫到的串(版本串、内部标记),常见做法是 XOR 存储、启动时解码。先把成本算清楚:8KB 字符串、1 周期/字节(M3 上字操作展开后的均摊口径):
| 项 | 数值 | 说明 |
|---|---|---|
| 待解码总量 | 8192 B | 清洗后残留串 |
| 解码周期 | 8192 cy | 1 cy/B |
| 启动耗时 | 114 us @72MHz | 一次性,可接受 |
| RAM 明文缓冲 | 8192 B | 明文必须在 RAM 里出现 |
| 已知明文还原密钥 | 16 B 已知即全破 | 见下 |
成本能接受,问题在收益。XOR 是重复密钥流,密钥 16 字节:攻击者只要有一条 ≥16 字节的已知明文,密钥整条还原。固件里的”已知明文”管够——AT+ 开头的命令、\r\nOK\r\n 应答、协议常量、甚至 long 库函数自己的字符串。拿到密钥后 8KB 全部明文一次还原,和没加密没区别。
更根本的一条:解码后的明文必然完整出现在 RAM 里,挂上调试器(哪怕只挂一次,dump 一次 RAM)就全泄。所以 XOR 字符串混淆的真实定位只有一句话:挡 grep,不挡任何花了半天时间的人。值不值得做,取决于你把”半天”定价多少——我的口径是:绝对敏感的内容(密钥、证书)根本不该在固件里,剩下的大多不值得连这个 114us 和 8KB RAM 都省不掉的伪保护。
五、控制流平坦化:M3 上的真实代价
控制流平坦化(control flow flattening)把顺序/分支的基本块改写成”状态机分发”:每个块执行完写一个状态变量,跳回分发器,由 switch 决定下一个块。这是 OLLVM 系工具里对反编译打击最大的一档——Ghidra 对扁平化的函数反编译出一坨 switch 嵌套,可读性断崖式下跌。
代价模型(本篇工具口径,M3 指令周期模型):
| 项 | 数值 | 说明 |
|---|---|---|
| 基本块数 | 140 | 控制主循环口径 |
| 平均块长 | 30 cy | 真实控制流普遍偏碎 |
| 每块新增周期 | 7 cy | 状态写入 2 + 分发跳转 3~5 |
| 周期开销 | +23% | = 7/30,块越碎越贵 |
| 体积开销 | +1120 B | 140 × 8B,另有分发表 |
开销对块长极其敏感,这是选不选的关键判据:
| 平均块长 | 20 cy | 30 cy | 60 cy | 120 cy |
|---|---|---|---|---|
| 周期开销 | +35% | +23% | +12% | +6% |
对号入座到实时预算:1kHz 控制环,500μs 预算 = 36000 cy @72MHz。140 块 × 30 cy 的主循环原来占 11.7%,平坦化后占 14.4%——单个循环看不出致命,但实时系统的 margin 是全系统共享的:控制环 +2.7%、MQTT 心跳处理 +20%、协议解析 +23%,一个个加起来,原本留出来的 20% 裕量就没了。所以本篇的纪律是:平坦化只允许用在”非实时 + 高价值”的函数上(比如授权校验、私有算法核心),且逐函数评估,绝不做全片选项。
还有两条必须说透的边界:
- 平坦化挡静态阅读,不挡动态跟踪。 J-Link 单步跑一遍,状态变量的迁移序列明明白白,分发表还原成执行顺序只要一天。它的真实定位是把”读汇编”的成本抬高到”得先跑一遍”——对只想抄协议文档的人是墙,对认真逆向的人是减速带。
- 裸机 M3 上别碰更重的变换。 混淆等价变形叠加几轮后,编译器优化和人工维护两头都崩:调试时源码与汇编完全对不上,代码评审失效,而收益(多挡一天)不再增长——这是”得不偿失”最典型的形态。
六、得不偿失清单:白盒、壳、反调试在裸机上的位置
白盒密码:把密钥融进查找表,让密钥不以明文形式存在于任何位置。听上去正对”密钥在固件里”的死穴,但代价:Chow 系典型实现查表体积 700KB 量级——占 512KB flash 的 138%,物理上放不下;精简方案表 20~30KB、RAM 10KB——占 20KB RAM 的一半,而且公开文献里白盒实现被攻破的速度远快于其工程成本。正确解法不在密码学花活里:密钥出固件——每设备一密由产线注入(130 篇的密钥注入工装就是为此),或干脆服务端持有、设备只持证书。固件里没有密钥,白盒要保护的”东西”就不存在了。
壳与自解压:PC 世界的 UPX 类壳依赖”解压到内存再执行”,在带 MMU 的平台上有 RAM 和地址空间腾挪余地;裸机 M3 上代码就地执行(XIP),自解压意味着先把整片解压到 20KB RAM——放不下;解压到 RAM 运行的方案则把 512KB flash 换成 RAM 需求,本末倒置。结论:裸机上没有工程化的壳生态,硬造一个的复杂度会先压垮自己的维护性。
反调试:PC 上有 ptrace 检测、调试端口占用一大套;M3 上能做的只有 DBGMCU 寄存器配置和 RDP——而 DBGMCU 检测自己在调试器断电重连后状态就复位,绕过成本接近零。裸机反调试的正解就是 130 篇那道 5 分钟的选项字节:RDP Level 1 挡住调试器读 flash 本身,比任何检测都彻底。
一句话汇总这三项:它们都是在回答错误的问题。 白盒在保护”不该存在的密钥”,壳在压缩”不存在的存储”,反调试在检测”RDP 早就该挡住的访问”。先把威胁模型答对,这些花活大半可以不做。
七、威胁模型总账:三层资产、五道防线
把 130 篇和本篇合成一张总表。资产分三层,价值不同,防线的配给不同:
| 资产层 | 内容 | 丢失后果 | 配给防线 |
|---|---|---|---|
| 凭据 | 设备证书、每设备密钥 | 仿冒接入、业务被刷 | 不进固件:产线注入 + 服务端持有 |
| 协议 | 主题规则、指令集、帧格式 | 竞品兼容、误用接入 | 字符串清理(0 成本)+ 服务端鉴权兜底 |
| 算法 | 私有控制策略、校验逻辑 | 核心 IP 泄漏 | strip(0 成本)+ 局部平坦化(+23% 周期,仅此层) |
防线成本总表(顺序即优先级):
| 防线 | 运行时成本 | 挡住什么 | 对应篇 |
|---|---|---|---|
| RDP 读保护 | 0 周期 / 0 字节 | 调试器读出 | 130 篇 |
| 签名固件升级 | 验签 2.5M cy,仅启动一次 | 恶意推包 | 130 / 93 篇 |
| 字符串清理 + 密钥出固件 | 0 周期 | 第一分钟的速赢 | 本篇二、四节 |
| 符号裁剪进发布流程 | 0 周期(省 130KB) | 结构化阅读 | 本篇三节 |
| 局部控制流平坦化 | +23% 周期(仅高价值非实时函数) | 静态阅读 | 本篇五节 |
看清楚这张表的形状:前三行几乎零成本,第五行最贵且收益最小。 我见过(也写过)反过来排的方案——RDP 没开、strings 一把 214 条敏感串,却在纠结上不上控制流平坦化。混淆的钱花在秘密还在固件里的时候,每一分都是白花。
八、验收清单
- ☐ Release 固件 strings 敏感条数有数字目标(本篇口径 214 → 11),且清理进 CI 而非一次性手工
- ☐ 日志按级别编译期裁剪,DEBUG 格式串不进 .rodata
- ☐ 设备密钥/证书不在固件镜像中任何位置(对镜像全量 grep 验证),产线注入流程有记录
- ☐ 发布脚本固定执行 strip,发布产物
readelf -S验证无 symtab/strtab - ☐ .map 与带符号 .elf 受权限管理,不入公开归档
- ☐ 平坦化(若使用)逐函数评估:非实时、周期开销实测、margin 影响入预算表
- ☐ 未使用白盒/壳/反调试,或使用前有书面的威胁模型论证
- ☐ 用 Ghidra 对自己的发布固件做过一次”一小时逆向演练”,产出敏感面清单并闭环
最后一条最重要。逆向自己一次,比读十篇防逆向文章有用——你知道函数叫什么、秘密在哪,你的逆向速度就是攻击者的上限。
最后收三句话。第一,防逆向的第一性原理是”固件里不该有秘密”,不是”秘密藏得更好”。 密钥出固件、字符串清理、符号裁剪,三件事零运行时成本,做完之后固件里剩下的东西,才是”值得混淆”的候选——顺序反了,全是白花。
第二,混淆的账必须用周期算,不能用”安全等级”算。 M3 上平坦化 +23% 周期(块长 30)、敏感性从 +6% 到 +35%,这笔钱要进实时预算表和评审清单;而它换来的只是”静态阅读变动态跟踪”的降速带,不是保密。定价这个差距,再决定买不买。
第三,威胁模型决定配给。 凭据层零容忍(不进固件),协议层防速赢(0 成本动作),算法层才谈混淆。那张三层五防线的表建议存下来,下次有人问”咱们固件要不要上混淆”,先看表再回答。
工具:drafts/_tools/obfuscation_cost_calc.py(7 个模式、17 项自检断言,纯标准库、全离线)。口径:M3 指令周期按 Cortex-M3 手册典型值建模(72MHz、零等待按理想口径,实际含取指等待开销更高);符号体积按 Elf32_Sym 固定 16B + 名字均长 22B 的模型;白盒表体积为公开实现量级经验值非精确引用;平坦化开销为模型推算,上片前请以你自己的工程实测为准。文中现场案例按口径重构,不是原始日志。
文中设备型号与参数为写作口径下的重构,不针对任何具体厂商产品;文内数字均可用配套工具复算,与你的器件数据手册冲突时以数据手册为准。
下一篇预告《Flash 上 littlefs 落地实测》——131 篇第七节把选型账算完了(环形页 63.9 年、littlefs 是”为掉电就地设备设计的”),但选型账不是实测账。下一篇把 littlefs 真挂到那 512KB 的独立分区上:挂载耗时、掉电窗口实测、CoW 写放大对擦写寿命的真实影响,以及和第七节那张表的对账。