且构网

分享程序员开发的那些事...
且构网 - 分享程序员编程开发的那些事

《逆向工程权威指南》—第3章3.节x86

更新时间:2022-10-12 10:38:39

本节书摘来自异步社区《逆向工程权威指南》一书中的第3章3.节x86,作者【乌克兰】Dennis Yurichev(丹尼斯),更多章节内容可以访问云栖社区“异步社区”公众号查看。

第3章 Hello,world!
逆向工程权威指南
现在,我们开始演示《C语言编程》一书[1]中著名的程序:

#include <stdio.h>

int main() 
{
    printf("hello, world\n");
    return 0;
};

3.1 x86
3.1.1 MSVC
接下来我们将通过下述指令,使用MSVC 2010编译下面这个程序。

cl 1.cpp /Fa1.asm
其中/Fa选项将使编译器生成汇编指令清单文件(assembly listing file),并指定汇编列表文件的文件名称是1.asm。

上述命令生成的1.asm内容如下。

指令清单3.1 MSVC 2010

CONST   SEGMENT
$SG3830 DB       'hello, world', 0AH, 00H
CONST   ENDS
PUBLIC  _main
EXTRN   _printf:PROC
; Function compile flags: /Odtp
_TEXT   SEGMENT
_main   PROC
        push    ebp
        mov     ebp, esp
        push    OFFSET $SG3830
        call    _printf
        add     esp, 4
        xor     eax, eax
        pop     ebp
        ret     0
_main   ENDP
_TEXT   ENDS

MSVC生成的汇编清单文件都采用了Intel语体。汇编语言存在两种主流语体,即Intel语体和AT&T语体。本书将在3.1.3节中讨论它们之间的区别。

在生成1.asm之后,编译器会生成1.obj再将之链接为可执行文件1.exe。

在hello world这个例子中,文件分为两个代码段,即CONST和_TEXT段,它们分别代表数据段和代码段。在本例中,C/C++程序为字符串常量“Hello,world”分配了一个指针(const char[]),只是在代码中这个指针的名称并不明显(参照下列Bjarne Stroustrup. The C++ Programming Language, 4th Edition. 2013的第176页,7.3.2节)。

接下来,编译器进行了自己的处理,并在内部把字符串常量命名为$SG3830。

因此,上述程序的源代码等效于:

#include <stdio.h>

const char *$SG3830[]="hello, world\n";

int main() 
{
    printf($SG3830);
    return 0; 
}

在回顾1.asm文件时,我们会发现编译器在字符串常量的尾部添加了十六进制的数字0,即00h。依据C/C++字符串的标准规范,编译器要为这个字符串常量添加结束标志(即数值为零的单个字节)。有关标准请参照本书的57.1.1节。

在代码段_TEXT只有1个函数,即主函数main()。在汇编指令清单里,主函数的函数体有标志性的函数序言(function prologue)和函数尾声(function epilogue)。实际上所有的函数都有这样的序言和尾声。在函数的序言标志之后,我们能够看到调用printf()函数的指令: CALL _printf。

通过PUSH指令,程序把字符串的指针推送入栈。这样,printf()函数就可以调用栈里的指针,即字符串“hello, world!”的地址。

在printf()函数结束以后,程序的控制流会返回到main()函数之中。此时,字符串地址(即指针)仍残留在数据栈之中。这个时候就需要调整栈指针(ESP寄存器里的值)来释放这个指针。

下一条语句是“add ESP,4”,把ESP寄存器(栈指针/Stack Pointer)里的数值加4。

为什么要加上“4”?这是因为x86平台的内存地址使用32位(即4字节)数据描述。同理,在x64系统上释放这个指针时,ESP就要加上8。

因此,这条指令可以理解为“POP某寄存器”。只是本例的指令直接舍弃了栈里的数据而POP指令还要把寄存器里的值存储到既定寄存器[2]。

某些编译器(如Intel C++编辑器)不会使用ADD指令来释放数据栈,它们可能会用POP ECX指令。例如,Oracle RDBMS(由Intel C++编译器编译)就会用POP ECX指令,而不会用ADD指令。虽然POP ECX命令确实会修改ECX寄存器的值,但是它也同样释放了栈空间。

Intel C++编译器使用POP ECX指令的另外一个理由就是,POP ECX对应的OPCODE(1字节)比ADD ESP的OPCODE(3字节)要短。

指令清单3.2 Oracle RDBMS 10.2 Linux (摘自app.o)

.text:0800029A      push    ebx
.text:0800029B      call    qksfroChild
.text:080002A0      pop     ecx

本书将在讨论操作系统的部分详细介绍数据栈。

在上述C/C++程序里,printf()函数结束之后,main()函数会返回0(函数正常退出的返回码)。即main()函数的运算结果是0。

这个返回值是由指令“XOR EAX, EAX”计算出来的。

顾名思义,XOR就是“异或” [3]。编译器通常采用异或运算指令,而不会使用“MOV EAX,0”指令。主要是因为异或运算的opcode较短(2字节:5字节)。

也有一些编译器会使用“SUB EAX,EAX”指令把EAX寄存器置零,其中SUB代表减法运算。总之,main()函数的最后一项任务是使EAX的值为零。

汇编列表中最后的操作指令是RET,将控制权交给调用程序。通常它起到的作用就是将控制权交给操作系统,这部分功能由C/C++的CRT[4]实现。

3.1.2 GCC
接下来,我们使用GCC 4.4.1编译器编译这个hello world程序。

gcc 1.c -o 1
我们使用反汇编工具IDA(Interactive Disassembler)查看main()函数的具体情况。IDA所输出的汇编指令的格式,与MSVC生成的汇编指令的格式相同,它们都采用Intel语体显示汇编指令。

此外,如果要让GCC编译器生成Intel语体的汇编列表文件,可以使用GCC的选项“-S-masm=intel”。

指令清单3.3 在IDA中观察到的汇编指令

Main         proc near
var_10       = dword ptr -10h

             push    ebp
             mov     ebp, esp
             and     esp, 0FFFFFFF0h
             sub     esp, 10h
             mov     eax, offset aHelloWorld ; "hello, world\n"
             mov     [esp+10h+var_10], eax
             call    _printf
             mov     eax, 0
             leave
             retn
main         endp

GCC生成的汇编指令,与MSVC生成的结果基本相同。它首先把“hello, world”字符串在数据段的地址(指针)存储到EAX寄存器里,然后再把它存储在数据栈里。

其中值得注意的还有开场部分的“AND ESP, 0FFFFFFF0h”指令。它令栈地址(ESP的值)向16字节边界对齐(成为16的整数倍),属于初始化的指令。如果地址位没有对齐,那么CPU可能需要访问两次内存才能获得栈内数据。虽然在8字节边界处对齐就可以满足32位x86 CPU和64位x64 CPU的要求,但是主流编译器的编译规则规定“程序访问的地址必须向16字节对齐(被16整除)”。人们还是为了提高指令的执行效率而特意拟定了这条编译规范。[5]

“SUB ESP,10h”将在栈中分配0x10 bytes,即16字节。我们在后文看到,程序只会用到4字节空间。但是因为编译器对栈地址(ESP)进行了16字节对齐,所以每次都会分配16字节的空间。

而后,程序将字符串地址(指针的值)直接写入到数据栈。此处,GCC使用的是MOV指令;而MSVC生成的是PUSH指令。其中var_10是局部变量,用来向后面的printf()函数传递参数。

随即,程序调用printf()函数。

GCC和MSVC不同,除非人工指定优化选项,否则它会生成与源代码直接对应的“MOV EAX, 0”指令。但是,我们已经知道MOV指令的opcode肯定要比XOR指令的opcode长。

最后一条LEAVE指令,等效于“MOV ESP, EBP”和“POP EBP”两条指令。可见,这个指令调整了数据栈指针ESP,并将EBP的数值恢复到调用这个函数之前的初始状态。毕竟,程序段在开始部分就对EBP和EBP进行了操作(MOVEBP, ESP/AND ESP, ...),所以函数要在退出之前恢复这些寄存器的值。

3.1.3 GCC:AT&T语体
AT&T语体同样是汇编语言的显示风格。这种语体在UNIX之中较为常见。

接下来,我们使用GCC4.7.3编译如下所示的源程序。

指令清单3.4 使用GCC 4.7.3 编译源程序

gcc –S 1_1.c
上述指令将会得到下述文件。

指令清单3.5 GCC 4.7.3生成的汇编指令

.file   "1_1.c"
        .section       .rodata
.LC0:
        .string  "hello, world\n"
        .text
        .globl    main
        .type     main, @function
main: 
.LFB0:
        .cfi_startproc
        pushl    %ebp
        .cfi_def_cfa_offset 8
        .cfi_offset 5, -8
        movl     %esp, %ebp
        .cfi_def_cfa_register 5
        andl     $-16, %esp
        subl     $16, %esp
        movl     $.LC0, (%esp)
        call     printf
        movl     $0, %eax
        leave
        .cfi_restore 5
        .cfi_def_cfa 4, 4
        ret
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .ident  "GCC: (Ubuntu/Linaro 4.7.3-1ubuntu1) 4.7.3"
        .section        .note.GNU-stack,"",@progbits

在上述代码里,由小数点开头的指令就是宏。这种形式的汇编语体大量使用汇编宏,可读性很差。为了便于演示,我们将其中字符串以外的宏忽略不计(也可以启用GCC的编译选项-fno-asynchronous-unwind-tables,直接预处理为没有cfi宏的汇编指令),将会得到如下指令。

指令清单3.6 GCC 4.7.3生成的指令

.LC0:
        .string "hello, world\n"
main:
        pushl   %ebp
        movl    %esp, %ebp
        andl    $-16, %esp
        subl    $16, %esp
        movl    $.LC0, (%esp)
        call    printf
        movl    $0, %eax
        leave
        ret

在继续解读这个代码之前,我们先介绍一下Intel语体和AT&T语体的区别。

运算表达式(operands,即运算单元)的书写顺序相反。
Intel 格式:<指令><目标><源>。
AT&T 格式:<指令><源><目标>。
如果您认为Intel语体的指令使用等号(=)赋值,那么您可以认为AT&T语法结构使用右箭头(→)进行赋值。应当说明的是,这两种格式里,部分C标准函数的运算单元的书写格式确实是相同的,例如memcpy()、strcpy()。
AT&T语体中,在寄存器名称之前使用百分号(%)标记,在立即数之前使用美元符号($)标记。AT&T语体使用圆括号,而Intel语体则使用方括号。
AT&T语体里,每个运算操作符都需要声明操作数据的类型:
9-quad(64位)
l指代32位long型数据。
w指代16位word型数据。
b指代8位byte型数据。
其他区别请参考Sun公司发布的《x86 Assembly Language Reference Manual》。
现在再来阅读hello world的AT&T语体指令,就会发现它和IDA里看到的指令没有实质区别。有些人可能注意到,用于数据对齐的0FFFFFFF0h在这里变成了十进制的$-16——把它们按照32byte型数据进行书写后,就会发现两者完全一致。

此外,在退出main()时,处理EAX寄存器的指令是MOV指令而不是XOR指令。MOV的作用是给寄存器赋值(load)。某些硬件框架的指令集里有更为直观的“LOAD”“STORE”之类的指令。