x86 反汇编/代码混淆
代码混淆是指使程序的汇编代码或机器代码更难以反汇编或反编译的行为。术语“混淆”通常用来暗示故意增加难度,但许多其他做法会导致代码被混淆,而并非有意为之。软件供应商可能会试图混淆甚至加密代码,以防止逆向工程。有许多不同的混淆类型。请注意,许多代码优化(在上一章中讨论过)具有使代码更难以阅读的副作用,因此优化充当混淆。
混淆可以是很多东西
- 在运行时解密的加密代码。
- 在运行时解压缩的压缩代码。
- 包含加密部分和简单解密器的可执行文件。
- 以难以阅读的顺序排列的代码指令。
- 以非明显方式使用的代码指令。
本章将尝试考察一些常见的代码混淆方法,但不会深入研究破解混淆的方法。
优化编译器会参与一个称为交织的过程,以尝试在流水线处理器中最大限度地提高并行性。这种技术基于两个前提
- 某些指令可以按不同顺序执行,并仍然保持正确的输出
- 处理器可以同时执行某些任务对。
英特尔的NetBurst 架构将 x86 处理器分为两个不同的部分:支持硬件和基本核心处理器。处理器的基本核心包含能够以极快的速度执行某些计算的能力,但不包含你我熟悉的指令。处理器首先将代码指令转换为称为“微操作”的形式,然后由基本核心处理器处理。
处理器也可以分解为 4 个组件或模块,每个模块都能执行某些任务。由于每个模块都可以独立运行,因此处理器核心可以同时处理最多 4 个独立的任务,只要这些任务可以由所有 4 个模块执行
- 端口 0
- 双速整数运算、浮点加载、内存存储
- 端口 1
- 双速整数运算、浮点运算
- 端口 2
- 内存读取
- 端口 3
- 内存写入(写入地址总线)
因此,例如,处理器可以同时在端口 0 和端口 1 中执行 2 个整数运算指令,因此编译器通常会竭尽全力将算术指令彼此靠近。如果时序恰到好处,那么在一个指令周期内最多可以执行 4 个算术指令。
然而,请注意,写入内存特别慢(需要端口 3 发送地址,端口 0 写入数据本身)。浮点数需要先加载到 FPU 才能进行运算,因此浮点加载和浮点运算指令不能在一个指令周期内对单个值进行运算。因此,加载浮点值、操作整数值然后操作浮点值的做法并不少见。
优化编译器经常会使用不直观的指令。有些指令可以执行它们未设计的功能,通常作为有用的副作用。有时,一条指令可以比其他专门指令更快地执行任务。
唯一知道一条指令比另一条指令快的方法是查阅处理器文档。但是,了解一些最常见的替换对反汇编人员非常有用。
以下是一些示例。第一个框中的代码比第二个框中的代码运行速度更快,但执行完全相同的任务。
示例 1
快速
xor eax, eax
慢速
mov eax, 0
示例 2
快速
shl eax, 3
慢速
push edx
push 8
mul dword [esp]
add esp, 4
pop edx ;# edx is not preserved by "mul"
有时可以进行这样的转换,以使分析更困难
示例 3
快速
push $next_instr
jmp $some_function
$next_instr:...
慢速
call $some_function
示例 4
快速
pop eax
jmp eax
慢速
retn
- lea
- lea 指令具有以下形式
lea dest, (XS:)[reg1 + reg2 * x]
其中 XS 是段寄存器(SS、DS、CS 等),reg1 是基地址,reg2 是可变偏移量,x 是乘法缩放因子。lea 实际上做的就是将第二个参数指向的内存地址加载到第一个参数中。请看下面的示例
mov eax, 1
lea ecx, [eax + 4]
那么,ecx 的值是多少?答案是 ecx 的值为 (eax + 4),即 5。本质上,lea 用于对寄存器和字节或更小的常量(-128 到 +127)进行加法和乘法。
现在,考虑
mov eax, 1
lea ecx, [eax+eax*2]
现在,ecx 等于 3。
区别在于 lea 速度很快(因为它只添加了寄存器和一个小的常量),而add 和mul 指令更通用,但速度更慢。lea 经常以这种方式用于算术运算,即使编译器没有积极优化代码。
- xor
- xor 指令对两个操作数执行按位异或运算。然后考虑下面的示例
mov al, 0xAA
xor al, al
它做了什么?让我们看一下二进制代码
10101010 ;10101010 = 0xAA xor 10101010 -------- 00000000
答案是“xor reg, reg”将寄存器设置为 0。更重要的是,“xor eax, eax”将 eax 设置为 0 的速度更快(并且生成的代码指令更小),而等效的“mov eax, 0”则更慢。
- mov edi, edi
- 在 64 位 x86 系统上,这条指令会清除 rdi 寄存器的最高 32 位。
- shl, shr
- 在二进制运算中,左移等效于将操作数乘以 2。右移也等效于将操作数除以 2,但最低位会被丢弃。一般来说,左移 位会将操作数乘以 ,而右移 位等效于除以 。需要注意的是,结果是一个没有小数部分的整数。例如
mov al, 31 ; 00011111
shr al, 1 ; 00001111 = 15, not 15.5
- xchg
- xchg 用于交换两个寄存器的内容,或者一个寄存器和一个内存地址的内容。值得注意的是,xchg 的执行速度比移动指令更快。因此,当源中的值不再需要保存时,可以使用 xchg 将值从源移动到目标。
例如,考虑以下代码:
mov ebx, eax
mov eax, 0
这里,eax
中的值被存储在 ebx
中,然后 eax
被加载为零值。我们可以使用 xchg
和 xor
来完成相同的操作:
xchg eax, ebx
xor eax, eax
您可能会惊讶地发现,第二个代码示例的执行速度明显快于第一个代码示例。
混淆器
[edit | edit source]市面上有很多工具可以自动化代码混淆过程。这些产品会使用多种转换方法将代码片段转换为更难阅读的形式,但不会影响程序本身的流程(虽然这些转换可能会增加代码大小或执行时间)。
代码转换
[edit | edit source]代码转换是一种重排序代码的方式,使其执行完全相同的任务,但更难追踪和反汇编。我们可以通过例子来最好地说明这种技术。假设我们有两个函数,FunctionA 和 FunctionB。这两个函数都由三个不同的部分组成,这些部分按顺序执行。我们可以将此分解如下
FunctionA()
{
FuncAPart1();
FuncAPart2();
FuncAPart3();
}
FunctionB()
{
FuncBPart1();
FuncBPart2();
FuncBPart3();
}
我们有主程序,它执行这两个函数
main()
{
FunctionA();
FunctionB();
}
现在,我们可以将这些代码段重新排列成更复杂的格式(汇编语言)
main:
jmp FAP1
FBP3: call FuncBPart3
jmp end
FBP1: call FuncBPart1
jmp FBP2
FAP2: call FuncAPart2
jmp FAP3
FBP2: call FuncBPart2
jmp FBP3
FAP1: call FuncAPart1
jmp FAP2
FAP3: call FuncAPart3
jmp FBP1
end:
如您所见,这更难阅读,尽管它完全保留了原始代码的程序流程。这段代码对人来说很难阅读,但对于自动反汇编器(如 IDA Pro)来说却很容易阅读。
不透明谓词
[edit | edit source]不透明谓词是代码中无法在静态分析期间评估的谓词。这迫使攻击者执行动态分析以了解该行的结果。通常,这与分支指令有关,该指令用于防止静态分析理解所采取的代码路径。
代码加密
[edit | edit source]代码可以像任何其他类型的数据一样加密,但代码也可以用来加密和解密自身。加密程序无法直接反汇编。但是,这样的程序也不能直接运行,因为加密后的操作码无法被 CPU 正确解释。因此,加密程序必须包含某种方法在运行之前解密自身。
最基本的方法是包含一个小的存根程序,该程序解密可执行文件的其余部分,然后将控制权传递给已解密的例程。
反汇编加密代码
[edit | edit source]要反汇编加密的可执行文件,您必须首先确定代码是如何解密的。代码可以通过两种主要方式之一进行解密
- 一次全部解密。整个代码部分在一次传递中被解密,并在执行期间保持解密状态。使用调试器,允许解密例程完全运行,然后将解密后的代码转储到文件中以进行进一步分析。
- 按块解密。代码被加密成单独的块,每个块可能具有单独的加密密钥。块可以在使用之前被解密,并在使用之后再次被重新加密。使用调试器,您可以尝试捕获所有解密密钥,然后使用这些密钥来解密整个程序,或者您可以等待块被解密,然后将块单独转储到一个单独的文件中以进行分析。