2、控制流混淆基础:基本块、控制流图(CFG)、常见混淆模式
好,咱们正式开始聊控制流混淆。说实话,很多人在逆向分析时一碰到混淆代码就头大,觉得无从下手。其实你只要把基础概念吃透了,再花哨的混淆也就是那几招。今天我就带你把这些基础打扎实。
2.1 基本块:代码的最小单元
先说说基本块。什么叫基本块?说白了,就是一段只能从入口进、只能从出口出的连续指令序列。中间没有跳转,没有分支,一条道走到黑。
我举个例子你就明白了:
0x100: mov eax, [ecx]
0x103: add eax, 1
0x106: mov [edx], eax
0x109: jmp 0x200 ; 这里结束基本块
从 0x100 到 0x106 这三条指令,就是典型的一个基本块。为什么?因为执行顺序是固定的,没有岔路。到了 0x109 的 jmp,基本块就结束了——因为控制流从这里开始分叉了。
2.2 控制流图(CFG):程序的交通地图
基本块是节点,那控制流图就是把这些节点用有向边连起来。每个节点是一个基本块,每条边代表一个可能的执行路径。
你想想看,一个正常的函数,它的 CFG 长什么样?通常是有规律的:一个入口,几个分支,几个循环,最后汇聚到一个出口。就像城市交通图,主干道清晰,岔路不多。
但混淆后的 CFG 就不一样了。我见过最夸张的一个样本,一个只有 50 行代码的函数,反编译出来的 CFG 有 200 多个基本块。密密麻麻的箭头,看得人头皮发麻。
下面这张图展示了正常 CFG 和混淆后 CFG 的对比:
2.3 常见混淆模式之一:不透明谓词
不透明谓词,名字听着唬人,其实原理很简单。就是构造一个结果永远固定的条件判断,但让分析工具看不出来。
比如这段代码:
int x = 1, y = 2;
if (x * x + y * y == 5) { // 永远为真
// 真实逻辑
} else {
// 虚假逻辑,永远不会执行
}
你看,1² + 2² = 5,这个条件永远成立。但静态分析工具很难在编译前就确定这一点。于是它不得不把两个分支都分析一遍,白白浪费时间和精力。
不透明谓词常见的构造方式有几种:
| 类型 | 原理 | 例子 |
|---|---|---|
| 算术不透明 | 利用数学恒等式 | (x+1)² - x² - 2x ≡ 1 |
| 指针不透明 | 利用内存别名分析困难 | 两个指针指向同一地址 |
| 并发不透明 | 利用线程调度不确定性 | 竞争条件判断 |
2.4 常见混淆模式之二:控制流平坦化
这个就更有意思了。控制流平坦化,说白了就是把一个正常的、有层次结构的 CFG,拍成一张扁平的大饼。
怎么拍的呢?核心思路是引入一个状态变量和一个主分发器。原来的每个基本块都被改造成一个 case 分支,通过修改状态变量来跳转到下一个基本块。
看个例子就明白了。原始代码是这样的:
if (a > 0) {
b = 1;
} else {
b = 2;
}
c = b + 3;
经过控制流平坦化后,变成这样:
int state = 0;
while (1) {
switch (state) {
case 0:
if (a > 0) state = 1;
else state = 2;
break;
case 1:
b = 1;
state = 3;
break;
case 2:
b = 2;
state = 3;
break;
case 3:
c = b + 3;
state = -1; // 结束
break;
}
if (state == -1) break;
}
你发现没有?原来的 if-else 结构完全消失了。取而代之的是一个巨大的 switch 循环。所有基本块都在同一层级,看不出哪个是分支、哪个是循环。
2.5 两种混淆的对比
咱们把这两种混淆放在一起对比一下:
| 特性 | 不透明谓词 | 控制流平坦化 |
|---|---|---|
| 目标 | 增加虚假路径 | 消除结构层次 |
| CFG 变化 | 增加分支数量 | 变成扁平大循环 |
| 分析难度 | 中等 | 较高 |
| 常见组合 | 常与平坦化配合 | 常与不透明谓词配合 |
好了,这一章的基础概念就讲到这里。基本块、CFG、不透明谓词、控制流平坦化——这四个概念是理解后续所有混淆技巧的基石。下一章我们会深入实战,看看如何用符号执行来破解这些混淆。