本节书摘来自异步社区《逆向工程权威指南》一书中的第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”之类的指令。