混淆基础:代码混淆的动机、常见混淆技术及对抗思路
在软件安全领域,代码混淆并非一种“加密”手段,而是一种语义保持变换。其核心目标是在不改变程序外部行为(即输入输出映射关系)的前提下,刻意增加程序内部结构的复杂度,使逆向工程师或自动化分析工具难以理解程序的控制流、数据流和核心算法。对于OLLVM(基于LLVM的混淆框架)而言,它提供了一套系统化的编译期混淆方案,是当前Android Native层加固与恶意软件对抗中的主流技术。理解混淆的动机与各类技术的内在原理,是后续进行去平坦化与字符串解密实战的基石。
上图展示了OLLVM混淆技术的整体对抗框架。从原始代码到混淆后代码,攻击者(逆向工程师)需要逆向这一变换过程。本章将深入剖析这四大核心混淆技术的原理、特征与对抗切入点,为后续章节的实战操作建立坚实的理论基础。
2.1 代码混淆的动机与对抗目标
核心原理:为什么要混淆?
代码混淆的动机主要源于三个维度的威胁模型:知识产权保护(防止核心算法被白嫖)、安全机制隐藏(防止密钥、校验逻辑被定位)、恶意行为规避(防止特征码查杀)。在移动安全领域,攻击者通常使用反汇编器(IDA Pro、Ghidra)和调试器(LLDB、GDB)对目标进行静态与动态分析。混淆的直接目的就是提高分析者的时间成本与人力成本,迫使攻击者放弃。
对抗目标:还原语义而非还原源码
对抗混淆的核心目标不是将代码恢复成与原始源码逐行一致的形式,而是恢复程序的语义等价逻辑。例如,一个平坦化的函数,我们只需要还原出真实的块执行顺序和条件分支关系,而不必关心原始源码中变量名是什么。对抗思路分为静态与动态两条路线:静态上通过模式匹配识别混淆特征(如switch-case分发器),动态上通过插桩记录执行轨迹来重建控制流。
2.2 控制流平坦化(Control Flow Flattening)
核心原理:消除基本块间的直接跳转
控制流平坦化是OLLVM中最具破坏力的混淆技术。它将一个函数中原本具有清晰逻辑关系的基本块(Basic Block)拉平,使其处于同一层级。所有真实块(Original Block)的执行顺序由一个分发器(Dispatcher)和状态变量(State Variable)控制。原始代码中的条件跳转(如if-else)被替换为对状态变量的赋值,然后统一跳转到分发器,由分发器根据状态变量的值决定下一个执行哪个真实块。
// 原始代码
int a = 10;
if (a > 5) {
b = a * 2;
} else {
b = a + 1;
}
return b;
// 平坦化后伪代码
int state = 0;
while(1) {
switch(state) {
case 0: a = 10; state = 1; break;
case 1: if (a > 5) state = 2; else state = 3; break;
case 2: b = a * 2; state = 4; break;
case 3: b = a + 1; state = 4; break;
case 4: return b;
}
}
判断方法:识别分发器与状态变量
在IDA Pro中,平坦化函数具有极高的辨识度:函数调用图呈“星形”或“蜘蛛网”状,中心是一个巨大的switch跳转块,周围环绕着多个小基本块。具体特征包括:
- 大量序言代码:函数入口处有对状态变量的初始化赋值。
- switch-case结构:分发器通常编译为跳转表(Jump Table)或密集的条件比较。
- 状态变量赋值:每个真实块的末尾都有对状态变量的写入操作,且该变量在分发器中被读取。
- 缺少直接跳转:真实块之间没有直接的jump指令,所有跳转都经过分发器。
案例拆解:定位状态变量
假设我们分析一个so文件中的native函数。在IDA中,按F5反编译后看到如下伪代码:
v3 = 0;
do {
v4 = v3;
if (v4 > 7) break;
switch (v4) {
case 0: // 真实块1
v5 = 0;
v3 = 1;
break;
case 1: // 真实块2
v5 = v5 + 1;
v3 = 2;
break;
// ... 其他case
}
} while (v3 != 8);
这里的v3就是状态变量。对抗的第一步是识别出所有case块,并分析每个case块对状态变量的赋值,从而重建原始的控制流图。去平坦化的核心操作就是将状态变量的赋值关系转换为块间的跳转关系。
2.3 虚假控制流与不透明谓词
核心原理:增加不可达路径
虚假控制流(Bogus Control Flow)通过插入不透明谓词(Opaque Predicate)来制造垃圾基本块。不透明谓词是一种表达式,其值在运行时恒为真或恒为假,但静态分析难以直接判定。例如,表达式 x * (x + 1) % 2 == 0 对于任意整数x恒为真。攻击者利用这一点,将原始代码块包裹在恒真条件中,并在恒假分支中填充垃圾代码(如无意义的运算、死循环)。
判断方法:寻找恒真/恒假条件
在反汇编代码中,虚假控制流通常表现为:
- 复杂的算术比较:如
((a ^ b) + (a & b)) << 1 == (a + b) << 1这类恒等式。 - 基于全局变量的条件:条件判断依赖于一个从未被修改的全局变量。
- 冗余的跳转:垃圾块中可能存在大量无意义的jmp指令,但实际执行不到。
对抗思路:动态调试与符号执行
对抗虚假控制流最有效的手段是动态插桩。通过Frida或Unicorn引擎,记录程序实际执行过的基本块地址。那些从未被执行到的块,即为垃圾块,可以直接标记并删除。符号执行引擎(如Angr)也能通过路径约束求解来判断哪些分支不可达。
2.4 指令替换与字符串加密
指令替换:隐藏算法特征
指令替换(Instruction Substitution)将简单的算术或逻辑运算替换为功能等价但形式复杂的指令序列。例如,a + b 可以被替换为 a - (-b) 或 (a ^ b) + 2 * (a & b)。这种技术主要对抗基于模式匹配的静态分析工具,增加人工阅读的难度。对抗方法通常是使用模式化简,即通过LLVM的优化Pass(如InstCombine)尝试将复杂表达式化简为简单形式。
字符串加密:隐藏关键信息
字符串加密是OLLVM中对抗字符串搜索(如strings命令)的利器。它将程序中的全局字符串(如日志标签、算法名称、URL)加密存储,在运行时通过解密函数动态还原。解密函数通常被内联到使用该字符串的函数中,且密钥可能硬编码在代码中。
| 混淆技术 | 静态特征 | 动态特征 | 对抗切入点 |
|---|---|---|---|
| 控制流平坦化 | 巨型switch-case、状态变量 | 执行轨迹呈线性顺序 | 识别状态变量,重建跳转关系 |
| 虚假控制流 | 不透明谓词、垃圾块 | 垃圾块无执行记录 | 动态插桩过滤未执行块 |
| 指令替换 | 复杂算术表达式 | 运算结果与简单指令一致 | LLVM InstCombine化简 |
| 字符串加密 | 密文数据段、解密循环 | 运行时内存中出现明文 | Hook解密函数或内存dump |
案例拆解:字符串解密定位
假设我们在JNI_OnLoad中发现一个函数sub_1234,其内部循环对一段字节进行异或操作。我们可以在Frida中Hook该函数,在调用前后dump内存,对比明文与密文。或者更简单,直接调用该函数并读取返回值。对抗字符串加密的核心是定位解密函数。通常解密函数具有以下特征:包含循环结构、对字节数组进行算术运算(xor、add)、引用了一个固定长度的密钥数组。
2.5 综合对抗策略与总结
在实际的逆向工程中,OLLVM的混淆往往是组合使用的。一个函数可能同时包含平坦化、虚假控制流和指令替换。因此,对抗策略必须遵循“动静结合,逐层剥离”的原则。
- 静态预分析:使用IDA脚本(IDAPython)识别函数中的平坦化特征和可疑的不透明谓词。
- 动态轨迹采集:使用Frida Stalker或Unicorn对目标函数进行插桩,记录所有执行过的基本块地址。
- 控制流重建:基于动态轨迹,将执行过的块按顺序连接,忽略未执行的垃圾块,初步还原控制流图。
- 数据流清洗:对还原后的控制流进行常量传播和死代码消除,简化指令替换带来的冗余运算。
- 字符串还原:针对加密字符串,定位解密函数并动态调用,获取明文。