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 手工还原的思路

如果你只有一两个函数要分析,手工还原也是可行的。我一般这么做:

  1. 定位分发器:找到那个 while(1) + switch(state) 的结构。分发器通常是一个循环,里面只有一个 switch。
  2. 提取所有 case 块:把每个 case 块里的指令复制出来,去掉 state 赋值和 break。
  3. 分析块间跳转:每个 case 块最后会修改 state 的值,指向下一个块。把这些跳转关系画出来。
  4. 重建控制流:根据跳转关系,把块重新连接成 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 去混淆后的验证

去混淆不是终点,你得验证还原后的代码对不对。我一般做三件事:

  1. 语义等价性检查:用相同输入跑原始函数和去混淆后的函数,看输出是否一致。
  2. 控制流图对比:把还原后的 CFG 和原始代码的 CFG 对比,看结构是否匹配。
  3. 人工审查关键路径:对于核心逻辑(比如加密、校验),我会手动走一遍,确保没有遗漏分支。

我曾经遇到过一个坑:去混淆后,代码逻辑看起来对了,但实际跑起来崩溃。后来发现,是因为符号执行漏掉了一个隐式的 state 更新——那个混淆器在某个 case 块里偷偷修改了 state 两次。嗯,这种「脏数据」问题,只能靠人工审查来补。

22.7 知识体系总览

下面这张图,概括了本章的核心内容:

OLLVM 混淆还原知识体系 OLLVM 混淆 控制流平坦化 指令替换 虚假控制流 手工还原 符号执行去混淆 deflat.py OLLVM_DeFlattener 验证:语义等价性 + CFG对比 + 人工审查 核心思路:定位分发器 → 提取块序列 → 符号执行探索路径 → 重建控制流

说白了,OLLVM 混淆还原就是一场「拆解与重建」的游戏。你拆得越细,重建得越准。我个人觉得,手工还原适合小函数,自动化适合大批量。两者结合,才是王道。

避坑指南:我曾经在去混淆后,发现函数里多了一个不可达的 case 块。那个块里藏了一个反调试的代码——如果符号执行没覆盖到,它会在运行时触发 crash。所以,去混淆后一定要做全路径验证,别漏掉任何一块。


公众号:蓝海资料掘金营,微信deep3321