先看懂平坦化长什么样
正常函数的 CFG(控制流图)是树状的:入口 → 分支 → 汇合 → 返回,层次分明。被 OLLVM 控制流平坦化(Control Flow Flattening)处理过的函数,CFG 变成一个大循环套 switch:
入口
↓
state = 初始值
↓
┌─────────────┐
│ while(1) │ ←──┐
│ switch(state) { │ │
│ case 0: 真实块A; state = X; break; │
│ case 1: 真实块B; state = Y; break; │
│ ...分发器块... │ │
│ } │ │
└─────────────┘ │
↑___________┘所有真实代码块被拍平到同一层级,执行顺序由 state 变量驱动。读这种代码就像看被洗牌的电影——每个镜头都在,但顺序全靠猜。
识别:三个特征一眼确认
在 IDA 里判断一个函数是否被平坦化:
- CFG 形态:按空格看图形视图,大量基本块汇聚到一两个"分发器"块,分发器又指回所有块——典型的"轮辐状"结构
- 状态变量:存在一个变量被反复赋值(
mov w8, #0x1234后立即用于比较),且赋值来源分散在各个真实块里 - 块数量异常:逻辑上很简单的函数(比如就做个加法)却有几十个基本块
手工还原:小函数的可行解
对关键小函数(比如签名函数),手工还原是可行的:
第一步:找到状态变量。在 IDA 里找被所有块引用、且参与 switch 比较的变量,重命名为 state。
第二步:建立状态转移表。逐个基本块记录:case 值 → 真实逻辑 → 下一个 state 值。比如:
case 0x2A: 读取参数 → state = 0x7B
case 0x7B: 异或运算 → 条件跳转: 真→0xC1, 假→0x55
case 0x55: 返回结果第三步:按转移表重排逻辑。把状态链翻译成正常控制流:顺序执行的部分连成直线,条件分叉还原成 if-else,回边还原成循环。
工具还原:大函数的唯一解
手工还原超过 20 个块的函数就是酷刑,必须上工具:
- angr 符号执行:用 angr 的 CFG 分析 + 符号执行,自动求解状态转移关系。社区有现成的 deflat 脚本(基于 angr),对 ARM64 的支持需要适配。
- D810(IDA 插件):基于微码(microcode)的优化插件,能自动识别并折叠平坦化模式,IDA 7.5+ 可用,ARM64 SO 效果实测不错。
- Unicorn 模拟执行:对关键路径做模拟执行,记录实际执行轨迹,按轨迹重排代码块。
# D810 使用:IDA 里 Edit -> Plugins -> D810,勾选 unflattener 规则
# 对目标函数按 F5 反编译,插件自动优化掉大部分分发逻辑精髓:三个实战认知
- 平坦化只增加阅读成本,不增加执行成本。所以"不还原,直接用"经常是更聪明的选择——Frida 直接调用被平坦化的函数,或 unidbg 模拟执行,绕过阅读环节。还原是为了理解算法,理解不是目的时别硬刚。
- 真假块分辨有技巧。平坦化常配合虚假控制流(bogus control flow),插入永真/永假条件的废块。识别废块:块内计算结果从未被后续使用,或条件里出现
x*(x-1) % 2 == 0这类恒真式(不透明谓词)。 - 指令替换(substitution)常和平坦化叠加。
a + b被改写成a - (-b)再展开成一堆位运算。这种局部膨胀用 IDA 的 F5 反编译结果看比汇编清晰——伪 C 代码里模式很明显。
总结
- 平坦化特征:轮辐状 CFG + 状态变量 + 块数量异常
- 小函数手工建状态转移表,大函数上 D810/angr
- 能调用就别还原:Frida/unidbg 直调绕过阅读
- 警惕叠加的虚假控制流和指令替换
交流微信:run1255