3、OLLVM框架解析:OLLVM的架构、Pass机制、如何生成控制流平坦化(flatten)与字符串加密(StringEncryption)Pass
OLLVM(Obfuscator-LLVM)是瑞士PoliMi团队在LLVM框架基础上开发的开源混淆工具集,它通过向LLVM中间表示(IR)阶段注入自定义Pass,在编译期对程序进行控制流与数据流变换。理解OLLVM的架构与Pass机制,是进行去平坦化还原与字符串解密的前提。本章将深入剖析OLLVM的模块化设计、Pass执行管线,并详细拆解控制流平坦化与字符串加密两个核心Pass的生成原理与代码特征。
3.1 OLLVM整体架构与Pass机制
核心原理:LLVM模块化编译框架
OLLVM并非一个独立的编译器,而是基于LLVM的二次开发。LLVM采用三段式架构:前端(Clang)将源码解析为中间表示IR,中端(opt + Pass)对IR进行优化与变换,后端(llc)将IR转换为目标机器码。OLLVM的核心工作集中在“中端”阶段,通过注入自定义Pass来改变IR的结构。每个Pass都是一个C++类,继承自llvm::ModulePass、llvm::FunctionPass或llvm::BasicBlockPass,分别作用于模块、函数或基本块粒度。Pass管理器(PassManager)按照注册顺序依次执行这些Pass,形成一条变换管线。
OLLVM的架构设计遵循“可插拔”原则。编译时通过-mllvm -fla、-mllvm -sub、-mllvm -strcry等参数启用对应Pass。每个Pass独立完成一种混淆变换,互不依赖,这为逆向分析提供了切入点——我们可以针对单一Pass的输出特征进行识别与还原。
Pass执行流程与注册机制
OLLVM的Pass通过LLVM的RegisterPass宏注册到PassRegistry中。以Flatten为例,其注册代码如下:
static RegisterPass<Flatten> X("fla", "Flattening");
// 使用方式:opt -load ./LLVMObfuscator.so -fla target.bc -o obf.bc
Pass执行时,runOnFunction或runOnModule方法被调用。Flatten的runOnFunction会遍历函数内所有基本块,收集块之间的跳转关系,然后重构控制流图。Pass机制还支持Pass依赖——例如StringEncryption需要先扫描模块中的全局变量,这要求该Pass在doInitialization阶段遍历模块。
| Pass类型 | 继承基类 | 作用粒度 | 典型应用 |
|---|---|---|---|
| ModulePass | llvm::ModulePass | 整个模块 | StringEncryption(扫描全局字符串) |
| FunctionPass | llvm::FunctionPass | 单个函数 | Flatten(重构函数内控制流) |
| BasicBlockPass | llvm::BasicBlockPass | 单个基本块 | Substitution(指令替换) |
opt -print-after-all或llvm-dis导出混淆前后的IR对比,能快速定位Pass变换的具体位置。实际逆向中,IDA Pro的Hex-Rays插件配合OLLVM的调试符号(如果保留)可以大幅提升还原效率。
3.2 控制流平坦化(Flatten)Pass深度解析
核心原理:将结构化控制流转为状态机
Flatten Pass的核心思想是将函数内所有基本块(除入口块和返回块外)的跳转关系打平,统一通过一个“分发器”(Dispatcher)进行间接跳转。原始的控制流图(CFG)是树状或网状结构,经过平坦化后变成“入口块 → 分发器 → 各真实块 → 分发器 → … → 返回块”的环形结构。分发器内部是一个switch-case语句,根据“状态变量”(State Variable)的值决定跳转到哪个真实块。每个真实块执行完毕后,会更新状态变量为下一个块的编号,然后跳回分发器。
具体实现步骤:
- 收集基本块:遍历函数,排除入口块(Entry)和返回块(Return),将剩余块编号为0,1,2…N。
- 创建分发器:在入口块之后插入一个新的基本块,内含switch指令,以状态变量为条件。
- 改写跳转:将每个真实块末尾的
br或ret指令替换为“更新状态变量 + 跳转到分发器”。 - 插入状态变量:在入口块中分配一个整型变量,初始化为第一个真实块的编号。
- 处理phi节点:如果原始块中有phi指令,需要将其移动到分发器或对应块中,保证数据流正确。
判断方法:识别平坦化特征
在IDA Pro或Ghidra中,平坦化代码具有明显的结构特征:
- 函数中存在一个包含大量
switch分支的块,且case数量与函数基本块数量相当。 - 存在一个被频繁读写的“状态变量”,通常为
int类型,在函数入口初始化。 - 几乎所有基本块都以“写状态变量 + 跳转到分发器”结尾,而非直接跳转到后继块。
- 函数控制流图中,分发器是唯一的高入度节点,所有真实块都指向它。
mov eax, [rbp+var_4]和jmp loc_XXXX(跳转到分发器地址)。
案例拆解:一个简单函数的平坦化前后对比
以下是一个简单的C函数:
int check(int x) {
if (x > 10) return 1;
else if (x < 0) return -1;
else return 0;
}
原始IR中,该函数有4个基本块:entry、if.then、if.else、return。经过Flatten后,IR变为:
define i32 @check(i32 %x) {
entry:
%state = alloca i32, align 4
store i32 0, i32* %state
br label %dispatcher
dispatcher:
%state_val = load i32, i32* %state
switch i32 %state_val, label %return [
i32 0, label %bb1
i32 1, label %bb2
i32 2, label %bb3
]
bb1: ; 原 if.then
%cmp = icmp sgt i32 %x, 10
br i1 %cmp, label %bb_return1, label %bb2
bb2: ; 原 if.else
%cmp2 = icmp slt i32 %x, 0
br i1 %cmp2, label %bb_return2, label %bb_return3
bb_return1:
store i32 1, i32* %state
br label %dispatcher
; ... 其他返回块类似
}
注意:真实块bb1和bb2中的条件跳转仍然保留,但目标被替换为“设置状态变量并跳回分发器”。这就是为什么去平坦化时需要先识别所有真实块,再根据条件跳转恢复原始边。
3.3 字符串加密(StringEncryption)Pass解析
核心原理:静态字符串转为运行时解密
StringEncryption Pass的目标是消除二进制中的明文敏感字符串(如密钥、URL、错误提示)。其原理是:在编译期将每个全局字符串的字符与一个密钥进行异或(XOR)运算,生成密文存储;同时生成一个解密函数,在程序运行时将密文还原为明文。解密函数通常被内联到使用字符串的位置,或通过构造函数在main之前执行。
实现步骤:
- 扫描全局字符串:遍历模块的
GlobalVariable,筛选出类型为[N x i8]且内容非空的常量。 - 生成密钥:随机生成一个字节(或短字节序列)作为密钥,存储在全局变量中。
- 加密:将字符串每个字符与密钥异或,生成新的全局常量数组。
- 替换引用:将所有对原字符串的
getElementPtr或bitcast引用替换为对解密函数调用的结果。 - 插入解密函数:生成一个函数,接受密文指针和长度,返回明文指针(通常通过栈上缓冲区或新分配内存)。
判断方法:识别加密字符串特征
- 二进制中字符串区域出现大量不可打印字符,且长度与正常字符串相近。
- 存在一个循环结构,对内存进行XOR运算,循环次数等于字符串长度。
- 引用字符串的代码附近有额外的函数调用,该函数内部包含XOR指令和循环。
- 使用
strings命令提取时,只能看到乱码或空字符串。
案例拆解:XOR加密的典型解密逻辑
假设原始字符串为"Hello, World!",密钥为0x5A。加密后密文为:
明文: 0x48 0x65 0x6C 0x6C 0x6F 0x2C 0x20 0x57 0x6F 0x72 0x6C 0x64 0x21
密钥: 0x5A 0x5A 0x5A 0x5A 0x5A 0x5A 0x5A 0x5A 0x5A 0x5A 0x5A 0x5A 0x5A
密文: 0x12 0x3F 0x36 0x36 0x35 0x76 0x7A 0x0D 0x35 0x28 0x36 0x3E 0x7B
在IDA中,你会看到类似以下的伪代码:
char *decrypt(char *cipher, int len) {
char *plain = malloc(len + 1);
for (int i = 0; i < len; i++) {
plain[i] = cipher[i] ^ 0x5A;
}
plain[len] = '\0';
return plain;
}
还原时,只需定位到该解密函数,提取密钥(0x5A),对密文再次XOR即可得到明文。如果密钥是动态生成的(如基于时间或随机数),则需要动态调试获取密钥值。
CyberChef或IDA Python脚本可以批量处理。
3.4 常见误区与总结复盘
误区一:混淆Pass顺序影响分析结果
OLLVM的Pass执行顺序并非固定。例如,先执行Flatten再执行StringEncryption,与先加密字符串再平坦化,产生的IR结构不同。前者字符串解密函数也被平坦化,后者则保持相对清晰。分析时需先判断Pass顺序,通常通过观察解密函数是否也被混淆来推断。
误区二:忽略不透明谓词的影响
OLLVM的Flatten Pass常与BogusControlFlow配合使用,在分发器中插入永远为真或假的谓词,增加分析难度。这些谓词通常表现为(x * (x+1)) % 2 == 0这类恒等式。还原时需要先识别并移除这些谓词,否则会误判真实块的数量。
误区三:字符串解密函数被内联
如果开启优化(-O2),解密函数可能被内联到每个调用点,导致二进制中出现多份解密代码。此时需要提取所有内联副本的密钥,逐一解密。使用BinDiff或Diaphora可以辅助识别相同逻辑的副本。
总结复盘
| 混淆类型 | 核心特征 | 还原思路 | 常用工具 |
|---|---|---|---|
| 控制流平坦化 | switch分发器 + 状态变量 | 识别真实块,恢复边关系 | IDAPython、Hex-Rays、angr |
| 字符串加密 | XOR循环 + 密文全局变量 | 定位解密函数,提取密钥 | CyberChef、Frida、GDB |
| 指令替换 | 算术运算被替换为等价复杂表达式 | 模式匹配,简化表达式 | LLVM opt、Triton |