OLLVM 指令替换与分割基本块
前言
在之前的文章中我们介绍了 OLLVM 控制流平坦化的原理与实现。除了控制流平坦化之外,OLLVM 还提供了两个同样重要的混淆 Pass:指令替换(Instruction Substitution,-sub)和分割基本块(Block Splitting,-spl)。这两个 Pass 的核心目标各不相同——前者让每条指令变得"面目全非",后者让代码结构变得"支离破碎"。当它们与控制流平坦化叠加使用时,逆向分析的难度会呈指数级上升。
本文将深入剖析这两个 Pass 的工作原理、常见替换模式,并给出实际的去混淆思路。
一、指令替换(Instruction Substitution)
1.1 概述
指令替换是 OLLVM 中最直观的混淆手段之一,通过编译器参数 -sub 启用。其核心思想非常简单:将一条简单的算术或逻辑运算,替换为数学上等价但形式上更复杂的表达式。
在 LLVM IR 的层面,一条 add 指令可能被展开成三到四条指令的组合。由于替换是在 IR 层进行的,后续的编译优化(如寄存器分配、指令调度)也不会将这些等价形式还原回去,最终生成的机器码中就保留了这些"多余"的运算。
1.2 常见替换模式
下面我们逐一分析 OLLVM 中最典型的几种替换模式。为了清晰起见,我们用 C 伪代码来展示替换前后的差异。
加法替换
原始代码:
int result = a + b;
经过指令替换后,可能变为以下任意一种等价形式:
// 形式一:利用相反数
int result = a - (-b);
// 形式二:利用补码特性(~b 等价于 -b-1)
int result = a - (~b) - 1;
// 形式三:利用位运算
int result = (a ^ b) + 2 * (a & b);
其中形式三的原理是经典的加法位运算展开:异或得到无进位和,与运算得到进位,两者之和等于加法结果。
减法替换
// 原始
int result = a - b;
// 替换形式一:利用补码
int result = a + (~b) + 1;
// 替换形式二
int result = (a + ~b) - (-1);
这里利用了数学恒等式 a - b = a + (-b),而 -b 在补码表示中等价于 ~b + 1。
布尔运算替换
// 原始
int result = a && b;
// 替换形式一:转化为位运算
int result = (a & b) == b;
// 替换形式二:利用德摩根定律
int result = !(!a | !b);
// 原始
int result = !(a && b);
// 替换:德摩根定律展开
int result = !a | !b;
位运算替换
// 原始
int result = a ^ b;
// 替换:利用异或的定义展开
int result = (~a & b) | (a & ~b);
异或的本质是"不同则为 1",所以 a ^ b 等价于 (a 为 0 且 b 为 1) 或 (a 为 1 且 b 为 0),这正是 (~a & b) | (a & ~b) 的含义。
1.3 多层替换组合
指令替换最令人头疼的地方在于多层叠加。OLLVM 并不是只对每条指令做一次替换,而是可以通过参数控制替换的层数。当 -sub_loop=3 时,每条指令会被连续替换 3 次,每一层的输出都会作为下一层的输入。
举个极端的例子,一条简单的加法经过 3 次替换后:
; 第 0 层:原始 IR
%result = add i32 %a, %b
; 第 1 层:第一次替换
%t1 = sub i32 0, %b ; -b
%result = sub i32 %a, %t1 ; a - (-b)
; 第 2 层:第二次替换(对 sub 指令也做替换)
%t2 = xor i32 %t1, -1 ; ~(-b) = b-1
%t3 = add i32 %t2, 1 ; ~(-b)+1 = b
%t4 = xor i32 %a, -1 ; ~a
%t5 = add i32 %t4, 1 ; ~a+1 = -a
%t6 = add i32 %t5, %t3 ; -a + b
%result = xor i32 %t6, -1 ; ~(-a+b)
%t7 = add i32 %result, 1 ; 最终结果
; 第 3 层:继续替换...
;(每一层都会使指令数量成倍增长)
经过 3 层替换后,原本 1 条 add 指令膨胀成了十几条 IR 指令。这就是混淆强度参数 -sub_loop 的威力——默认值为 3,意味着每条指令至少膨胀 3 倍以上。
1.4 混淆强度参数
| 参数 | 默认值 | 说明 |
|---|---|---|
-sub |
关闭 | 启用指令替换 Pass |
-sub_loop=N |
3 | 每条指令的替换迭代次数 |
-sub_loop 的值越大,替换层数越多,生成的代码越复杂。但需要注意的是,替换层数过高会导致编译后的二进制文件体积显著增大,运行时性能也会下降(更多的寄存器使用、更多的指令缓存未命中)。
二、分割基本块(Block Splitting)
2.1 概述
分割基本块通过编译器参数 -spl 启用。它的原理比指令替换更加"粗暴":在基本块的中间随机位置插入跳转指令,将一个大的基本块拆分成多个小基本块。
2.2 基本原理
在编译原理中,基本块(Basic Block)是指一段顺序执行的指令序列,只有一个入口和一个出口。基本块是控制流图(CFG)的基本单元。
OLLVM 的分割策略如下:
原始基本块:
┌─────────────────────────┐
│ 指令 1 │
│ 指令 2 │
│ 指令 3 │
│ 指令 4 │
│ 指令 5 │
│ 指令 6 │
│ 指令 7 │
│ 指令 8 │
└─────────────────────────┘
分割后:
┌───────────┐
│ 指令 1 │
│ 指令 2 │
├───────────┤ ──→
│ 指令 3 │ (跳转到下一个块)
│ 指令 4 │
├───────────┤ ──→
│ 指令 5 │
│ 指令 6 │
├───────────┤ ──→
│ 指令 7 │
│ 指令 8 │
└───────────┘
实际上被拆分出的块之间会通过 br(无条件跳转)指令连接。在汇编层面,这些跳转是顺序执行的,但它们的存在会让反编译工具(如 IDA Pro、Ghidra)将每个小片段当作独立的函数进行分析,大幅增加基本块的数量。
2.3 对逆向分析的影响
反编译代码膨胀
原本一条连贯的逻辑被拆成多个小块后,反编译工具通常会在每个块之间插入注释、标签和空行,导致反编译后的伪代码行数成倍增加。
控制流图复杂化
基本块数量的增加直接导致控制流图的节点和边数量增加。当配合 -fla(控制流平坦化)使用时,效果尤为显著——每个被分割出的小块都会成为平坦化 switch-case 中的一个 case 分支,使得分析者需要追踪更多的跳转路径。
交叉引用干扰
IDA Pro 等工具依赖于交叉引用(Cross References)来追踪数据流和控制流。基本块分割会引入大量的短距离跳转,这些跳转的交叉引用会淹没真正的关键跳转,降低逆向工程师的效率。
三、综合案例分析
下面我们通过一个完整的例子,来看指令替换和基本块分割叠加后的效果。
3.1 原始 C 代码
int calculate(int x, int y) {
int sum = x + y;
int diff = x - y;
if (sum > diff) {
return sum * 2;
}
return diff + 10;
}
3.2 原始 LLVM IR
define i32 @calculate(i32 %x, i32 %y) {
entry:
%sum = add i32 %x, %y
%diff = sub i32 %x, %y
%cmp = icmp sgt i32 %sum, %diff
br i1 %cmp, label %if.then, label %if.end
if.then:
%result = mul i32 %sum, 2
ret i32 %result
if.end:
%result2 = add i32 %diff, 10
ret i32 %result2
}
3.3 经 -sub -spl 混淆后的 IR(简化展示)
define i32 @calculate(i32 %x, i32 %y) {
entry:
; === 指令替换:x + y → x - (-y) ===
%sub.1 = sub i32 0, %y
%sum.1 = sub i32 %x, %sub.1
; === 指令替换:x - y → x + (~y) + 1 ===
%not.y = xor i32 %y, -1
%sum.2 = add i32 %not.y, 1
%diff.1 = add i32 %x, %sum.2
; === 指令替换:sum > diff → (sum - diff - 1) < 0 的等价形式 ===
; ... 多层替换展开 ...
br i1 %cmp.3, label %block.split.1, label %block.split.2
block.split.1:
; 原本 if.then 的前半部分被分割到这里
%t.1 = add i32 %sum.1, %sum.1
br label %block.split.3
block.split.2:
; 原本 if.end 的前半部分被分割到这里
%t.2 = add i32 %diff.1, 5
br label %block.split.4
block.split.3:
; 原本 if.then 的后半部分
%result.1 = add i32 %t.1, %t.1
ret i32 %result.1
block.split.4:
; 原本 if.end 的后半部分
%result.2 = add i32 %t.2, 5
ret i32 %result.2
}
可以看到,原本 4 个基本块、不到 10 条 IR 指令的函数,经过混淆后基本块数量和指令数量都显著增加。add 和 sub 被各种等价形式替换,同时 if.then 和 if.end 块也被随机分割成了更小的片段。
四、去混淆思路
面对指令替换和基本块分割的混淆,逆向工程师可以借助以下技术手段进行还原。
4.1 常量折叠(Constant Folding)
常量折叠是编译优化中最基础的技术之一。对于指令替换中产生的 sub i32 0, %y(即 -y)这类模式,如果 %y 是常量,可以直接计算出结果。即使 %y 是变量,许多模式也可以通过符号化执行来识别。
4.2 强度消减(Strength Reduction)
强度消减是一种将代价较高的运算替换为代价较低的等价运算的优化技术。这与指令替换恰好相反——指令替换是在"增加强度",而去混淆就是要把它"消减"回来。
常见的强度消减模式:
a - (-b) → a + b
a + (~b) + 1 → a - b
(~a & b) | (a & ~b) → a ^ b
(a & b) == b → a && b(当 b 为布尔值时)
4.3 基本块合并
对于基本块分割,最直接的去混淆方法就是合并连续的、只有一个前驱和一个后继的基本块。如果基本块 A 只跳转到基本块 B,且基本块 B 只有基本块 A 一个前驱,那么 A 和 B 就可以安全地合并为一个基本块。
在 IDA Pro 中可以通过脚本自动完成这种合并;在 LLVM 层面则可以使用 -simplifycfg Pass 来消除冗余的基本块。
4.4 工具辅助
- OLLVM-Deobfuscator:基于 LLVM 的去混淆框架,通过自定义 Pass 反向执行混淆变换
- Triton / Miasm:符号执行引擎,可以自动识别并简化等价的表达式
- angr:通过符号执行 + 约束求解来推断混淆后的实际逻辑
五、总结
| 混淆技术 | 编译参数 | 核心原理 | 主要影响 |
|---|---|---|---|
| 指令替换 | -sub |
等价表达式替换 | 指令膨胀、逻辑难以理解 |
| 分割基本块 | -spl |
随机拆分基本块 | CFG 复杂化、反编译代码冗长 |
| 控制流平坦化 | -fla |
引入 dispatcher | 控制流混淆、跳转关系复杂 |
在实际的加固方案中,这三个 Pass 通常组合使用。指令替换负责让"每一步"变得不可读,分割基本块负责让"结构"变得支离破碎,控制流平坦化则彻底打乱跳转关系。理解每种 Pass 的原理是制定去混淆策略的第一步——只有知道了混淆是怎么"加"上去的,才能知道该怎么"拆"下来。
下一篇:我们将继续深入 OLLVM 的最后一个 Pass —— 虚假控制流(Bogus Control Flow),看看它是如何通过插入永远不会执行的代码来进一步干扰分析的。