22、代码混淆实战:Ollvm混淆还原、基于符号执行的去混淆
说实话,Ollvm 这东西,我第一次碰到是在一个 Android 的 native 层加固里。那时候反编译出来,满屏的 switch-case 和不可达块,看得我头皮发麻。后来才知道,这就是 OLLVM 的「控制流平坦化」——把正常的 if-else 逻辑,全拆成一个个基本块,再用一个分发器来回跳转。
嗯,今天我们就来聊聊怎么对付它。我会从原理讲起,再给出手工还原和自动化去混淆的思路。我个人习惯,先搞懂它怎么混淆的,再去想怎么解。
22.1 OLLVM 的三种混淆手段
OLLVM 全称是 Obfuscator-LLVM,基于 LLVM 的 pass 机制做代码混淆。它主要有三招:
- 控制流平坦化(CFG Flattening):把函数拆成多个基本块,所有块通过一个 switch 分发器连接。真实逻辑被埋在一堆块里,顺序完全打乱。
- 指令替换(Instruction Substitution):把简单的运算(比如 a + b)替换成等价的复杂表达式,比如 (a ^ b) + 2 * (a & b)。
- 虚假控制流(Bogus Control Flow):插入大量永远不会执行的分支和死代码,增加分析负担。
其中,最恶心的是控制流平坦化。我曾在项目中遇到一个 2000 多行的函数,平坦化后变成了 8000 多个基本块,手动分析基本不可能。
22.2 控制流平坦化的结构
先看一个简单的例子。原始代码是这样的:
int foo(int x) {
int result = 0;
if (x > 0) {
result = x * 2;
} else {
result = x + 1;
}
return result;
}
经过 OLLVM 平坦化后,会变成类似这样:
int foo(int x) {
int result = 0;
int state = 0;
while (1) {
switch (state) {
case 0:
if (x > 0) state = 1;
else state = 2;
break;
case 1:
result = x * 2;
state = 3;
break;
case 2:
result = x + 1;
state = 3;
break;
case 3:
return result;
}
}
}
你看,原来的 if-else 结构被拆成了 4 个 case 块。每个块执行完,通过修改 state 变量来决定下一个执行哪个块。真实的执行路径被隐藏了,你很难一眼看出逻辑。
核心思路:去平坦化的关键,就是找到那个 state 分发变量,然后根据每个 case 块之间的跳转关系,重建出原始的控制流图。
22.3 手工还原的思路
如果你只有一两个函数要分析,手工还原也是可行的。我一般这么做:
- 定位分发器:找到那个 while(1) + switch(state) 的结构。分发器通常是一个循环,里面只有一个 switch。
- 提取所有 case 块:把每个 case 块里的指令复制出来,去掉 state 赋值和 break。
- 分析块间跳转:每个 case 块最后会修改 state 的值,指向下一个块。把这些跳转关系画出来。
- 重建控制流:根据跳转关系,把块重新连接成 if-else 或循环结构。
举个例子,上面那个例子中:
- case 0 根据 x > 0 跳转到 case 1 或 case 2
- case 1 和 case 2 都跳转到 case 3
- case 3 返回
所以原始结构就是 if-else + return。
小技巧:在 IDA Pro 里,可以用 Ctrl+W 生成微码,然后手动 patch 掉分发器。不过说实话,函数一多就累死人。我曾经花了一整晚手工还原一个 500 块的函数,第二天眼睛都是花的。
22.4 基于符号执行的自动化去混淆
手工搞不定的时候,就得靠自动化了。我个人最推荐的方法,是用符号执行 + 约束求解来做去平坦化。
原理其实不复杂:
- 把混淆后的函数当作一个符号执行引擎的输入
- 符号执行会遍历所有可能的执行路径
- 对于每条路径,它会记录下经过的块和条件
- 最后,根据路径和条件,重建出原始的控制流
我用的是 Triton 这个符号执行框架,配合 Z3 求解器。下面是一个简化的去混淆脚本思路:
# 伪代码,展示核心逻辑
from triton import *
import z3
def deobfuscate(func_addr):
ctx = TritonContext()
ctx.setArchitecture(ARCH.X86_64)
# 1. 加载二进制
ctx.loadBinary("target_binary")
# 2. 符号化 state 变量
state_sym = ctx.symbolizeRegister(ctx.registers.rax)
# 3. 开始符号执行
paths = []
ctx.processing(func_addr, callback=collect_paths)
# 4. 对每条路径,提取块序列和条件
for path in paths:
blocks = path['blocks']
condition = path['condition']
# 5. 用 Z3 求解条件,确定分支方向
solver = z3.Solver()
solver.add(condition)
if solver.check() == z3.sat:
# 这个分支是可达的
record_edge(blocks[-2], blocks[-1], condition)
# 6. 根据边重建 CFG
rebuild_cfg(edges)
这个脚本的核心,是把 state 变量符号化。这样,当符号执行遇到 switch(state) 时,它会自动探索所有可能的 state 值,从而覆盖所有 case 块。然后,我们根据每个分支的条件,就能知道哪些块是真实可达的,哪些是死代码。
注意:符号执行有个问题——路径爆炸。如果函数有 100 个 case 块,理论上可能有 100! 条路径。实际中,OLLVM 的平坦化结构是线性的,每个块只跳转到下一个块,所以路径数等于块数。但如果混淆器加了循环或递归,路径数会指数增长。这时候需要加一些启发式剪枝,比如限制路径深度。
22.5 实战:用 unflattener 工具
如果你不想自己写脚本,社区里有一些现成的工具。我试过 deflat.py(基于 angr)和 OLLVM_DeFlattener(基于 Triton)。
以 deflat.py 为例,用法很简单:
python deflat.py --binary target_binary --func 0x401000
它会自动做符号执行,然后输出去平坦化后的函数。不过,我遇到过几次它卡死的情况——因为 angr 的符号执行在某些复杂指令上会超时。这时候,我会手动指定一些符号化的寄存器,或者跳过某些块。
我的经验:工具不是万能的。遇到 deflat.py 搞不定的情况,我会先用 IDA 看一遍混淆后的结构,找到那个 state 分发变量,然后手动 patch 掉分发器,再用工具做局部符号执行。这样成功率能到 90% 以上。
22.6 去混淆后的验证
去混淆不是终点,你得验证还原后的代码对不对。我一般做三件事:
- 语义等价性检查:用相同输入跑原始函数和去混淆后的函数,看输出是否一致。
- 控制流图对比:把还原后的 CFG 和原始代码的 CFG 对比,看结构是否匹配。
- 人工审查关键路径:对于核心逻辑(比如加密、校验),我会手动走一遍,确保没有遗漏分支。
我曾经遇到过一个坑:去混淆后,代码逻辑看起来对了,但实际跑起来崩溃。后来发现,是因为符号执行漏掉了一个隐式的 state 更新——那个混淆器在某个 case 块里偷偷修改了 state 两次。嗯,这种「脏数据」问题,只能靠人工审查来补。
22.7 知识体系总览
下面这张图,概括了本章的核心内容:
说白了,OLLVM 混淆还原就是一场「拆解与重建」的游戏。你拆得越细,重建得越准。我个人觉得,手工还原适合小函数,自动化适合大批量。两者结合,才是王道。
避坑指南:我曾经在去混淆后,发现函数里多了一个不可达的 case 块。那个块里藏了一个反调试的代码——如果符号执行没覆盖到,它会在运行时触发 crash。所以,去混淆后一定要做全路径验证,别漏掉任何一块。