x86 反汇编/分支
计算机科学教授告诉他们的学生要避免使用跳转和 **goto** 指令,以避免众所周知的“意大利面条代码”。不幸的是,汇编语言只有跳转指令来控制程序流程。本章将探讨许多人避之不及的主题,并将尝试展示如何将汇编语言的“意大利面条”翻译成高级语言中更熟悉的控制结构。具体来说,本章将重点介绍 **如果-则-否则** 和 **开关** 分支指令。
让我们考虑一个使用伪代码的通用 **if** 语句,以及其使用跳转的等效形式
if (condition) then do_action; if not (condition) then goto end; do_action; end: |
这段代码做什么?用英语来说,代码检查条件,只有在条件为 *false* 时才执行跳转。考虑到这一点,让我们比较一些实际的 C 代码及其汇编语言翻译
if(x == 0)
{
x = 1;
}
x++;
|
mov eax, $x
cmp eax, 0
jne end
mov eax, 1
end:
inc eax
mov $x, eax
|
请注意,当我们翻译成汇编语言时,我们需要 *否定* 跳转的条件,因为——正如我们上面所说——我们只有在条件为假时才跳转。为了重建高级代码,只需再次否定该条件即可。
如果你不注意,否定比较可能会很棘手。以下是正确的双重形式
指令 | 意思 |
---|---|
JNE | **J**ump if **n**ot **e**qual |
JE | **J**ump if **e**qual |
JG | **J**ump if **g**reater |
JLE | **J**ump if **l**ess than or **e**qual |
JL | **J**ump if **l**ess than |
JGE | **J**ump if **g**reater or **e**qual |
以下是一些示例。
mov eax, $x //move x into eax
cmp eax, $y //compare eax with y
jg end //jump if greater than
inc eax
mov $x, eax //increment x
end:
...
由以下 C 语句生成
if(x <= y)
{
x++;
}
如您所见,只有当 x **小于或等于** y 时,x 才会递增。因此,如果它大于 y,它将不会像汇编代码中那样递增。同样,C 代码
if(x < y)
{
x++;
}
生成以下汇编代码
mov eax, $x //move x into eax
cmp eax, $y //compare eax with y
jge end //jump if greater than or equal to
inc eax
mov $x, eax //increment x
end:
...
在 C 代码中,只有当 x **小于** y 时,x 才会递增,因此汇编代码现在在它大于或等于 y 时跳转。这种事情需要练习,因此我们将尝试在本节中包含大量示例。
现在,让我们看一下更复杂的情况:**如果-则-否则** 指令。
if (condition) then do_action else do_alternative_action; if not (condition) goto else; do_action; goto end; else: do_alternative_action; end: |
现在,这里发生了什么?与之前一样,if 语句只有在条件为假时才跳转到 else 子句。但是,我们还必须在“then”子句的末尾安装一个 *无条件* 跳转,这样我们就不会直接在之后执行 else 子句。
现在,这里是一个实际 C 如果-则-否则的示例
if(x == 10)
{
x = 0;
}
else
{
x++;
}
它被翻译成以下汇编代码
mov eax, $x
cmp eax, 0x0A ;0x0A = 10
jne else
mov eax, 0
jmp end
else:
inc eax
end:
mov $x, eax
如您所见,添加一个无条件跳转可以为我们的条件添加一个额外的选项。
**开关-情况** 结构在汇编语言中看起来非常复杂,因此我们将检查几个示例。首先,请记住,在 C 中,在 switch 语句中通常会使用几个关键字。以下是一个回顾
- 开关
- 此关键字测试参数,并启动 switch 结构
- 情况
- 这会创建一个标签,执行将切换到该标签,具体取决于参数的值。
- 断开
- 此语句跳转到 switch 块的末尾
- 默认
- 这是执行跳转到的标签,仅当它与任何其他条件不匹配时才进行跳转
假设我们有一个通用的 switch 语句,但在末尾有一个额外的标签,如下所示
switch (x)
{
//body of switch statement
}
end_of_switch:
现在,每个 **break** 语句将立即被以下语句替换
jmp end_of_switch
但是,其余的语句会变成什么?case 语句可以解析为任意数量的任意整数值。我们如何测试它?答案是我们使用“开关表”。以下是一个简单的 C 示例
int main(int argc, char **argv)
{ //line 10
switch(argc)
{
case 1:
MyFunction(1);
break;
case 2:
MyFunction(2);
break;
case 3:
MyFunction(3);
break;
case 4:
MyFunction(4);
break;
default:
MyFunction(5);
}
return 0;
}
当我们使用 **cl.exe** 编译它时,我们可以生成以下列表文件
tv64 = -4 ; size = 4
_argc$ = 8 ; size = 4
_argv$ = 12 ; size = 4
_main PROC NEAR
; Line 10
push ebp
mov ebp, esp
push ecx
; Line 11
mov eax, DWORD PTR _argc$[ebp]
mov DWORD PTR tv64[ebp], eax
mov ecx, DWORD PTR tv64[ebp]
sub ecx, 1
mov DWORD PTR tv64[ebp], ecx
cmp DWORD PTR tv64[ebp], 3
ja SHORT $L810
mov edx, DWORD PTR tv64[ebp]
jmp DWORD PTR $L818[edx*4]
$L806:
; Line 14
push 1
call _MyFunction
add esp, 4
; Line 15
jmp SHORT $L803
$L807:
; Line 17
push 2
call _MyFunction
add esp, 4
; Line 18
jmp SHORT $L803
$L808:
; Line 19
push 3
call _MyFunction
add esp, 4
; Line 20
jmp SHORT $L803
$L809:
; Line 22
push 4
call _MyFunction
add esp, 4
; Line 23
jmp SHORT $L803
$L810:
; Line 25
push 5
call _MyFunction
add esp, 4
$L803:
; Line 27
xor eax, eax
; Line 28
mov esp, ebp
pop ebp
ret 0
$L818:
DD $L806
DD $L807
DD $L808
DD $L809
_main ENDP
让我们逐步分析它。首先,我们看到第 10 行设置了我们的标准堆栈帧,它还保存了 ecx。它为什么要保存 ecx?扫描整个函数,我们从未看到相应的“pop ecx”指令,因此似乎该值从未恢复过。事实上,编译器根本没有保存 ecx,而是只是在堆栈上保留了空间:它正在创建一个局部变量。然而,原始的 C 代码没有任何局部变量,所以也许编译器只需要一些额外的临时空间来存储中间值。为什么编译器不执行更常见的“sub esp, 4”命令来创建局部变量?**push ecx** 只是一个更快的指令,它做同样的事情。这个“临时空间”通过 ebp 的 *负偏移量* 引用。**tv64** 在列表的开头定义为具有值 -4,因此每次调用“tv64[ebp]”都是对这个临时空间的调用。
关于函数本身,我们需要注意到一些事情
- 标签 $L803 是 end_of_switch 标签。因此,每个“jmp SHORT $L803”语句都是一个 **break**。这可以通过逐行比较 C 代码来验证。
- 标签 $L818 包含一个硬编码的内存地址列表,这些地址在这里是代码段中的标签!请记住,标签解析为指令的内存地址。这必须是我们谜题的重要部分。
为了解决这个谜题,我们将深入研究第 11 行
mov eax, DWORD PTR _argc$[ebp]
mov DWORD PTR tv64[ebp], eax
mov ecx, DWORD PTR tv64[ebp]
sub ecx, 1
mov DWORD PTR tv64[ebp], ecx
cmp DWORD PTR tv64[ebp], 3
ja SHORT $L810
mov edx, DWORD PTR tv64[ebp]
jmp DWORD PTR $L818[edx*4]
此序列执行以下伪 C 操作
if( argc - 1 >= 4 ) { goto $L810; /* the default */ } label *L818[] = { $L806, $L807, $L808, $L809 }; /* define a table of jumps, one per each case */ // goto L818[argc - 1]; /* use the address from the table to jump to the correct case */
原因如下...
mov eax, DWORD PTR _argc$[ebp]
mov DWORD PTR tv64[ebp], eax
mov ecx, DWORD PTR tv64[ebp]
sub ecx, 1
mov DWORD PTR tv64[ebp], ecx
argc 的值被移入 eax。eax 的值然后立即被移入临时空间。临时空间的值然后被移入 ecx。这听起来像是将同一个值移入许多不同位置的一种非常复杂的方式,但请记住:我关闭了优化。然后,ecx 的值被减 1。为什么编译器没有使用 **dec** 指令?也许该语句是一个通用语句,在本例中它恰好有一个参数为 1。我们不知道确切的原因,我们只知道这一点
- eax = “临时存储区”
- ecx = eax - 1
最后,最后一行将 ecx 的新减 1 值 *移回临时存储区*。非常低效。
cmp DWORD PTR tv64[ebp], 3
ja SHORT $L810
临时存储区的值与值 3 进行比较,如果 *无符号* 值大于 3(4 或更多),执行将跳转到标签 $L810。我如何知道该值是无符号的?我知道是因为 **ja** 是一个无符号条件跳转。让我们回顾一下原始的 C 代码 switch
switch(argc)
{
case 1:
MyFunction(1);
break;
case 2:
MyFunction(2);
break;
case 3:
MyFunction(3);
break;
case 4:
MyFunction(4);
break;
default:
MyFunction(5);
}
请记住,临时存储区包含值 (argc - 1),这意味着此条件只有在 argc > 4 时才会触发。当 argc 大于 4 时会发生什么?函数将进入默认条件。现在,让我们看一下接下来的两行
mov edx, DWORD PTR tv64[ebp]
jmp DWORD PTR $L818[edx*4]
edx 获取了暂存区的值(argc - 1),然后发生了一个非常奇怪的跳转:执行跳转到由值(edx * 4 + $L818)指向的位置。什么是 $L818?我们现在来研究一下。
$L818:
DD $L806
DD $L807
DD $L808
DD $L809
$L818 是代码段中指向代码段指针列表的指针。这些指针都是 32 位值(DD 是一个双字)。让我们回到我们的跳转语句
jmp DWORD PTR $L818[edx*4]
在这个跳转中,$L818 不是偏移量,而是基址,edx*4 是偏移量。如前所述,edx 包含(argc - 1)的值。如果 argc == 1,我们跳转到 [$L818 + 0],即 $L806。如果 argc == 2,我们跳转到 [$L818 + 4],即 $L807。明白了吗?快速查看标签 $L806、$L807、$L808 和 $L809,我们就能看到我们期望看到的内容:来自上面原始 C 代码的 case 语句的主体。每个 case 语句都调用函数“MyFunction”,然后中断,然后跳转到 switch 块的末尾。
再次强调,学习的最好方法是实践。因此,我们将通过一个简短的例子来解释三元运算符。考虑以下 C 代码程序
int main(int argc, char **argv)
{
return (argc > 1)?(5):(0);
}
cl.exe 生成了以下汇编清单文件
_argc$ = 8 ; size = 4
_argv$ = 12 ; size = 4
_main PROC NEAR
; File c:\documents and settings\andrew\desktop\test2.c
; Line 2
push ebp
mov ebp, esp
; Line 3
xor eax, eax
cmp DWORD PTR _argc$[ebp], 1
setle al
dec eax
and eax, 5
; Line 4
pop ebp
ret 0
_main ENDP
第 2 行设置了一个堆栈帧,第 4 行是一个标准的退出序列。没有局部变量。很明显,第 3 行是我们想要查看的地方。
指令“xor eax, eax”只是将 eax 设置为 0。有关该行的更多信息,请参阅关于 不直观的指令 的章节。cmp 指令测试三元运算符的条件。setle 函数是一组 x86 函数之一,它们像条件移动一样工作:如果 argc <= 1,则 al 获取值 1。这不是我们想要的完全相反吗?在这种情况下,是的。让我们看看当 argc = 0 时会发生什么:al 获取值 1。al 被递减(al = 0),然后 eax 与 5 进行逻辑与运算。5 & 0 = 0。当 argc == 2(大于 1)时,setle 指令不会做任何事情,eax 仍然为零。然后 eax 被递减,这意味着 eax == -1。什么是 -1?
在 x86 处理器中,负数以二进制补码形式存储。例如,让我们看看以下 C 代码
BYTE x;
x = -1;
在这段 C 代码的最后,x 将具有值 11111111:全是 1!
当 argc 大于 1 时,setle 将 al 设置为零。递减此值会将 eax 中的每个位设置为逻辑 1。现在,当我们执行逻辑与运算时,我们得到
...11111111 &...00000101 ;101 is 5 in binary ------------ ...00000101
eax 获取值 5。在这种情况下,这是一种间接的方法,但作为逆向分析师,你需要注意这些东西。
为了参考,以下是上面相同三元运算符的 GCC 汇编输出
_main:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
xorl %eax, %eax
andl $-16, %esp
call __alloca
call ___main
xorl %edx, %edx
cmpl $2, 8(%ebp)
setge %dl
leal (%edx,%edx,4), %eax
leave
ret
请注意,GCC 生成的代码与 cl.exe 生成的代码略有不同。但是,堆栈帧的设置方式相同。还要注意,GCC 没有给我们行号或代码中的其他提示。三元运算符行发生在指令“call __main”之后。让我们在此处突出显示该部分
xorl %edx, %edx
cmpl $2, 8(%ebp)
setge %dl
leal (%edx,%edx,4), %eax
同样,xor 用于快速将 edx 设置为 0。Argc 与 2(而不是 1)进行比较,如果 argc 大于或等于,则 dl 被设置。如果 dl 被设置为 1,则其后的leal 指令将直接将值 5 移动到 eax(因为 lea (edx,edx,4) 表示 edx + edx * 4,即 edx * 5)。