20、代码混淆基础:控制流平坦化、虚假控制流、指令替换
各位好,欢迎来到第二十讲。
说实话,代码混淆这玩意儿,我早年刚接触时觉得挺玄乎的。不就是把代码弄乱吗?后来真正在项目里被混淆过的样本折磨过几次,才明白这背后的门道有多深。今天咱们就聊聊三种最基础的混淆手段:控制流平坦化、虚假控制流、指令替换。掌握了这些,你再看那些花里胡哨的混淆,心里就有底了。
20.1 控制流平坦化:把直路变成迷宫
先说说控制流平坦化。这名字听着挺学术,说白了就是把你代码里那些自然的 if-else、switch-case 结构,全部打散,然后塞进一个巨大的 while 循环里,靠一个状态变量来跳转。
我举个例子。你原本的代码可能是这样的:
// 原始代码
if (a > 0) {
b = 1;
} else {
b = 2;
}
c = b + 10;
经过控制流平坦化之后,会变成这样:
// 平坦化后
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 + 10;
state = -1; // 结束
break;
}
if (state == -1) break;
}
你看,原本清晰的逻辑分支,现在全变成了一个 switch 里的 case。逆向分析时,你看到的是一大堆 state 赋值和跳转,很难一眼看出程序到底在干什么。
核心要点:控制流平坦化的本质,是把程序的控制依赖,转换成了数据依赖。你不再是通过分支结构控制流程,而是通过一个状态变量来控制。
我在分析一个恶意软件样本时遇到过这种情况。那个样本用了三层平坦化,状态变量还被加密了。我花了整整两天才把它的执行流理清楚。嗯,从那以后,我写分析工具时都会专门加一个「平坦化恢复」的模块。
20.2 虚假控制流:给你挖坑
虚假控制流,这招更损。它会在你的代码里插入一些「看似有用,实则无用」的分支。这些分支的条件永远为真或永远为假,但编译器看不出来,分析人员也容易被骗。
举个例子:
// 原始代码
x = y + z;
加上虚假控制流后:
// 虚假控制流
if (y * 0 == 0) { // 永远为真
x = y + z;
} else {
// 这里放一段看起来很复杂的死代码
for (int i = 0; i < 100; i++) {
fake_array[i] = fake_func(i);
}
}
你想想看,如果你在逆向时看到这个 if 语句,你会不会停下来分析那个 else 分支?我敢说,大部分人会。结果分析半天,发现那分支根本不会执行。这就是在浪费你的时间。
避坑指南:我曾经在一个 CTF 题目里被虚假控制流坑了三个小时。后来我学乖了——遇到这种可疑分支,先看条件表达式是否恒真或恒假。如果是,直接忽略 else 分支。别在死代码上浪费时间。
更高级的虚假控制流,会用一些编译器优化不掉的条件,比如基于线程 ID、时间戳、随机数等。这些条件在静态分析时无法确定,必须动态调试才能看出来。
20.3 指令替换:换汤不换药
指令替换,这个就比较好理解了。就是把一条指令,替换成一组功能等价的指令序列。目的嘛,就是让反汇编出来的代码看起来更复杂,增加分析难度。
常见的指令替换模式:
| 原始指令 | 替换后 | 说明 |
|---|---|---|
mov eax, 0 |
xor eax, eax |
清零操作,更短更快 |
add eax, 1 |
inc eax |
加1操作 |
push ebp; mov ebp, esp |
enter 0, 0 |
函数序言替换 |
jmp target |
push target; ret |
跳转替换,破坏静态分析 |
我个人习惯把指令替换分为两类:
- 语义等价替换:比如
mov eax, 0换成xor eax, eax,效果完全一样,但后者在反汇编器里看起来更「可疑」。 - 语义复杂化替换:比如把
add eax, ebx换成sub eax, -ebx或者用lea eax, [eax+ebx]。这些指令的语义虽然等价,但分析人员需要多花一步去理解。
小技巧:对付指令替换,最好的办法是用符号执行或污点分析。手动一条条去还原,太累了。我一般会写一些 IDA Python 脚本,自动识别常见的替换模式并还原。
20.4 三种混淆的联动效果
这三种混淆很少单独使用。在实际的加固方案中,它们通常是组合出现的。比如:先用控制流平坦化把函数结构打乱,然后在每个基本块里插入虚假控制流,最后再把基本块里的指令做替换。
我画了一张图,帮你理解这三者的关系:
你看,经过这三层处理,原始代码已经面目全非了。逆向分析时,你面对的不再是清晰的逻辑,而是一堆状态变量、死分支和奇怪的指令序列。
20.5 如何应对?
说了这么多混淆手段,那怎么应对呢?我分享几个实战经验:
- 对付平坦化:用符号执行工具,比如 angr 或 Triton。它们可以自动遍历所有状态,帮你重建控制流图。我个人的经验是,先找到状态变量,然后跟踪它的所有赋值路径,就能还原出原始的分支结构。
- 对付虚假控制流:动态调试是最好的办法。在可疑分支处下断点,看它到底走哪条路。如果发现某个分支永远不走,直接 nop 掉。另外,可以用一些反混淆插件,比如 IDA 的 deFlat 插件。
- 对付指令替换:建立指令模式库。把常见的替换模式收集起来,写脚本自动匹配和还原。比如遇到
push target; ret,直接替换成jmp target。
总结一下:代码混淆的本质,是增加代码的「认知复杂度」。它不会改变程序的语义,但会让分析人员花更多时间去理解。作为逆向工程师,我们的目标就是通过各种手段,把混淆后的代码还原成易于理解的形式。
好了,这一讲就到这里。这三种混淆手段,你可以在自己的项目里试试看,也可以找一些混淆过的样本练练手。记住,实践出真知。
公众号:蓝海资料掘金营,微信deep3321