固件防逆向与混淆实战:把被端走的那份固件自己逆向了一遍——strings 十分钟出卖了我,而保住算法的从来不是控制流平坦化

2026年10月26日 0 By admin

上一篇《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 是无的放矢——真正带符号出厂的,是发布流程里的这三样:

  1. 发给产线的烧录 .elf / .hex(部分产线工具只吃 ELF);
  2. 发给 ODM/方案商的参考包,常直接带可调试的 .elf + .map;
  3. 归档在版本库 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 写放大对擦写寿命的真实影响,以及和第七节那张表的对账。