21、Ollvm混淆对抗:Ollvm控制流平坦化、虚假控制流、基于符号执行的去混淆
各位好,今天我们来聊聊Ollvm混淆对抗。说实话,Ollvm在Android逆向圈里,几乎成了标配。你随便拿个加固后的APK,反编译一看,满屏的switch-case,基本就是它了。我个人习惯把Ollvm的混淆分为三个层次:控制流平坦化、虚假控制流,以及最头疼的基于符号执行的去混淆。咱们一个一个来拆解。
控制流平坦化:把直路变成迷宫
先说说控制流平坦化。它干了件什么事呢?原本你的代码逻辑是A→B→C,一条直线。Ollvm把它变成了一个巨大的switch-case,外加一个状态变量。每次执行完一个块,就更新状态变量,然后跳回switch入口,根据新状态决定下一步去哪。
我举个例子,你原本的代码可能是这样的:
int foo(int x) {
int y = x + 1;
if (y > 10) {
return y * 2;
} else {
return y + 3;
}
}
经过控制流平坦化后,它会变成类似这样:
int foo(int x) {
int state = 0;
int result = 0;
while (1) {
switch (state) {
case 0:
y = x + 1;
state = (y > 10) ? 1 : 2;
break;
case 1:
result = y * 2;
state = 3;
break;
case 2:
result = y + 3;
state = 3;
break;
case 3:
return result;
}
}
}
你看,原本清晰的逻辑,现在被拆成了好几个基本块,通过一个状态机来串联。逆向分析时,你看到的是一堆case,根本分不清哪个块是哪个。
核心思路:去平坦化的关键,就是找到那个状态变量,然后根据每个基本块之间的跳转关系,重建原始的控制流图。
我个人习惯的做法是,先用IDA Pro或Ghidra定位到那个while-switch结构。然后,我会手动标记每个case块,记录它们的状态值和跳转目标。嗯,这里要注意,Ollvm有时会插入一些冗余块,比如状态值不变但跳转目标相同的块,这些可以直接合并。
虚假控制流:真假难辨的干扰项
虚假控制流,说白了就是在平坦化的基础上,再给你塞一堆“假块”。这些块看起来像正常的逻辑,但实际上永远不会被执行到,或者执行了也不影响最终结果。它们的目的就是增加你分析时的噪音。
虚假控制流通常有两种实现方式:
- 不透明谓词:比如
if (x * x >= 0),这在整数范围内永远为真,但编译器不会优化掉。逆向时你看到这个条件,会以为有分支,其实没有。 - 死代码插入:在真实块之间插入一些计算,但这些计算的结果不会被后续使用。比如算了个a+b,然后a和b都没变,结果也没人用。
我曾经在分析一个金融类App时,遇到过特别狠的虚假控制流。它在一个函数里插了上百个假块,每个假块都做了一堆浮点运算。我一开始以为是加密算法,后来发现全是垃圾代码。那一次我花了整整两天才把真实逻辑抽出来。
避坑指南:我曾经被虚假控制流坑过,后来总结了一个经验:先找“有实际副作用”的块。比如写内存、调用系统API、返回值的计算,这些才是真实的。那些只做纯计算、结果又不被使用的块,基本可以跳过。
基于符号执行的去混淆:自动化利器
手动去混淆,对于小函数还行。但遇到几百个基本块的大函数,手动搞会疯掉。这时候,符号执行就派上用场了。
符号执行的核心思想是:把程序中的变量都当成符号,而不是具体值。然后模拟执行所有路径,收集路径约束。最后,通过约束求解器(比如Z3)来求解,得到每个路径的具体输入和输出。
对于Ollvm的去混淆,符号执行可以自动完成以下工作:
- 识别真实块:符号执行会遍历所有路径。那些永远无法到达的块(虚假块),会被自动排除。
- 重建控制流:根据每个真实块之间的跳转关系,自动生成原始的控制流图。
- 简化表达式:符号执行可以化简不透明谓词。比如
x * x >= 0,符号执行会直接判定为真,从而消除这个分支。
我常用的工具是angr,一个基于Python的符号执行框架。下面是一个简单的去混淆脚本示例:
import angr
# 加载目标二进制
proj = angr.Project('target.so', auto_load_libs=False)
# 设置起始地址(Ollvm平坦化函数的入口)
start_addr = 0x12345678
# 创建状态
state = proj.factory.blank_state(addr=start_addr)
# 创建符号执行管理器
simgr = proj.factory.simgr(state)
# 探索所有路径,直到找到返回指令
simgr.explore(find=0x12345ABC) # 假设返回地址是0x12345ABC
# 输出所有找到的路径
for found in simgr.found:
print("Found path:", found.history.bbl_addrs)
这个脚本会从平坦化函数的入口开始,符号执行所有路径,最终找到所有能到达返回指令的路径。每条路径对应一个基本块序列,把这些序列组合起来,就是原始的控制流。
注意:符号执行不是万能的。遇到循环次数不确定、或者依赖外部输入(比如网络数据)的情况,符号执行可能会路径爆炸。我一般会先手动分析,确定循环边界,再跑符号执行。
实战中的组合拳
在实际项目中,我很少只用一种方法。通常我的流程是这样的:
- 先用静态分析(IDA Pro)定位Ollvm函数,标记出switch结构和状态变量。
- 然后手动分析几个关键块,确认哪些是真实块,哪些是虚假块。
- 对于复杂函数,写一个angr脚本做符号执行,自动提取所有真实路径。
- 最后,把符号执行的结果导入到IDA Pro中,用Python脚本自动重建控制流图。
你想想看,这个过程就像剥洋葱。一层层去掉混淆的外壳,最终露出原始的逻辑。嗯,这里要提醒一下,Ollvm的版本不同,混淆的强度也不同。有些版本会加入反调试、反模拟器检测,符号执行时要注意绕过。
下面这张图,是我总结的Ollvm去混淆整体流程:
最后说一句,Ollvm对抗是个持续博弈的过程。加固厂商在升级,我们的技术也要跟着升级。但核心思路不变:理解混淆原理,善用自动化工具,再加上一点耐心。嗯,今天就到这里。
总结:控制流平坦化把代码变成switch-case,虚假控制流插入垃圾块,符号执行可以自动化去混淆。三者结合,基本能应对大部分Ollvm加固。