跳转到内容

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)。

华夏公益教科书