源代码
int main()
{
return 0;
}
VC环境
winxpsp3+vc2010_release
首先以vc2010(Microsoft Visual C++ 2010 Express)release模式生成的可执行文件为例,上图为main函数的汇编码,可以看到,内容十分简单。
第一条指令xor eax,eax是对eax进行异或运算,这是对寄存器赋0值的一种常见形式,通常约定把函数的返回值放在eax中返回(32位,16位放在ax中),因此可见这是在为return 0;语句做准备;第二条指令retn是过程近(near)返回指令,从堆栈弹出返回地址压到eip中,与之对应的还有远(far)返回指令retf,首先弹出eip,然后弹出cs(其实,对于现代操作系统来讲,每个进程都有其单独的相同的逻辑地址空间,段寄存器的值由操作系统设定且固定,与之相关的汇编指令也就很少再使用了),而指令ret根据PROC伪指令,自动判断是近返回还是远返回(当然,从可执行文件是看不到伪指令的)。
仅看main函数的汇编指令,可以说和C语言的源代码是一样的简单。
接着观察vc2010debug模式生成的可执行文件的汇编码,见上图,可以看到相比release模式要复杂许多,之所以会有这样的区别,是因为在debug模式包含有调试信息,且未进行优化,release模式把一些执行过程优化掉了。
下面简单解释一下代码的含义:
因为main函数也是函数,所以它与函数的执行过程相同:调用前传递函数参数(本例中没有参数),进入时为函数的局部变量分配空间,并在退出时释放这些空间。这里要介绍一下栈帧(stack frame)的概念,栈帧,也称为活动记录(activation record),它是为传递的参数、子例程的返回地址、局部变量和保存的寄存器保留的堆栈空间。栈帧的两端是以两个指针定界的,寄存器ebp作为帧指针,表示栈帧的底部,等于函数调用前运行栈的栈顶指针的值,在函数调用过程中不改变其值,当函数调用结束时可以通过帧指针的值将栈帧空间释放掉;寄存器esp是运行栈的栈顶指针,同时也表示栈帧的顶部,在运行时是可以改变的(见下图)。
第一条汇编码push ebp首先保存ebp的值,因为马上将使用它作为帧指针;第二条汇编码mov ebp,esp将ebp赋为当前的栈顶指针,也就是帧指针,从这时开始,ebp就被作为寻址所有子例程参数的基址指针使用了;第三条汇编码sub esp,0C0h是将栈(也是栈帧)扩大0C0h大小,但此时并没有在其中填充内容,这样做通常是为了给局部变量留出空间,这里明明没有任何局部变量,那0C0h大小是如何跑出来的呢,稍后将解释这个问题。
根据惯例,eax、edx、ecx的值由调用方负责保存,即在函数内部这3个寄存器可以随便使用;而ebx、esi、edi的值由被调用方负责保存,使用之前必须将原先的值保存到栈中,这也是为什么接下来的3条代码将这3个寄存器分别压入栈中的原因。
接下来的几条指令是专门用作调试。lea edi,[ebp+var_C0]实际上就是把地址存入到edi中,地址的值就是刚才留出0C0h大小的区域的最低位置;接着对ecx赋值为30h;对eax赋值为0CCCCCCCCh;最后执行指令rep stosd,这条指令的含义是将stosd这条指令重复ecx(即30h)次,而stosd指令的含义是将eax的值(0CCCCCCCCh)复制到内存中,内存的地址为es:edi,每次执行后edi改变,这条指令合在一起的意思就是将es:edi为起始地址,大小为ecx*4的内存的所有字节均设为0CCh,就是把刚才留出的0C0h(30h * 4 = 0C0h)的空间全部填为0CCh。
只所以这样做是为了便于调试:0CCh是汇编指令int 3的二进制码,这条指令的意思是调用3号中断服务程序,会产生一个断点,如果想感受一下实际的运行效果可以用下面的代码:
int main()
{
__asmint 3;
return 0;
}
而将一大片区域都设置成为断点的意义在于:若程序存在漏洞,执行时可能会误执行这片区域中的内容,因为这片区域内容都是0CCh,运行时立刻报错,便于发现漏洞,说白了就是在栈中有用的数据旁边附着陷阱,一个正确的程序执行是绝不会踏入陷阱中的。
这个过程结束之后,就是之前介绍过的xor eax,eax,如果这个main函数有其他语句,那汇编出来的代码就会在rep stosd与xoe eax,eax之间。接着还原edi、esi、ebx寄存器的值。
此时函数的执行已经基本结束,之前开辟出的栈帧的使命已经结束,mov esp,ebp将esp恢复到函数调用前的状态,接着恢复ebp,最后返回,整个过程结束。
此外,由于栈帧的创建与释放十分普遍,intel提供了两条简化的汇编指令enter和leave。其中,enter imm,0与push ebp; mov ebp,esp; sub esp,imm相等价;leave与mov esp,ebp; pop ebp相等价。
winxpsp3+vc2008_debug
情况与vc2010下完全相同,没有看出编译器的变化。
win7+vc2010_release
情况与winxp下的相同,但虽然找到了main函数,却不知为何没有对其进行命名。虽然vc的版本是VS2010pro,但按说编译器应该是相同的。
win7+vc2010_debug
与release模式相同,仅是main名称没有识别出来的问题。
GCC模式
以下实验使用的编译命令均为gcc -o test test.c。
Ubuntu10.04+linux2.6.32+gcc4.4.3
汇编码见上图,结合上面讲述的栈帧概念很好理解。如果仔细留意的话,会发现与vcdebug的汇编码相比,这里没有对edi、esi、ebx的保存与回复操作,而且因为没有用到esp,所以最后也没有mov esp,ebp的操作。
我又设优先级为O0(gcc默认的优先级是O1)重新编译了一遍,发现结果是相同的,看来gcc编译时会记录寄存器的使用情况。
我又以优先级O2、O3重新编译,结果如下图(两个结果相同):发现首先mov eax,0变成xor eax,eax,异或运算执行速度要快于传送运算;接着发现这条优化后的指令位置向上移动,跑到了mov ebp,esp指令的上面,这个原因我就不清楚了。
Ubuntu9.04+linux2.6.28+gcc4.3.3
再来看一个较低版本的,先是O0(O1相同)的(见左下图):
可以看到比上一个版本情况复杂了不少,而且还有错误信息提示(见最后一行),说是栈指针(stack pointer)分析失败(sp-analysis failed),造成这个错误的原因是因为堆栈不平衡而导致的,在IDA中点击Options->General->Disassembly,将选项stack pointer打勾,汇编码就会显示栈指针的情况,如右上图,最后一条指令retn前为“004”而不是“000”,因此会报错,接下来我们分析一下这段汇编码的含义:
汇编指令 | 解释 |
lea ecx, [esp+arg_0] | 将esp加4的得到的地址存入ecx,即ecx指向第1个参数 |
and esp, 0FFFFFFF0h | 这里调整esp的值,使之末4位0(即按16字节对齐) |
push dword ptr [ecx-4] | 将最开始esp所指的地址的值(即函数返回地址)入栈 |
push ebp和mov ebp, esp | 建立栈帧 |
push ecx | 压入ecx(即old esp+4)保存,这条指令之后进入main函数 |
mov eax,0 | 由于我们的main函数没有内容,所以直接开始返回操作 |
pop ecx和pop ebp | 恢复ecx(即old esp+4)与ebp |
lea esp, [ecx-4]和retn | 恢复esp然后返回 |
看到这里,你会发现esp在执行前后是相同的,其实当main函数中出现了跳转指令后,错误提示就消失了,也许这只是IDA的一个bug吧。见左下图:
可以看到,同一条指令lea esp, [ecx-4]在不同的函数中其栈指针的变化居然不同,因此大家不必为这个错误提示而在意。
这段代码多出来一个push操作:push dword ptr [ecx-4]。它是做什么用的,我只能做如下解释:
首先先了解一下call指令的操作(前面已经提到过,即使是main函数,实际也是被另一个函数使用call调用),call指令将下一条指令的地址(即eip寄存器中的值,就是函数返回地址)压入栈中(即push eip),然后将控制转移到目的地址(即eip=目的地址)。
回忆栈帧的示意图可以得知,栈中函数返回地址标志着调用帧的结束,而创建栈帧第一步压入栈的ebp标志着被调用帧的开始,它们之间形成一条分界线。如右上图黑色的粗线:
而现在在push ebp之前要进行16字节的对齐操作,栈中压入的eip与ebp就可能存在一个空隙,也许是为了保证栈帧格式的完整性,在对齐操作之后,push ebp之前,重新压入一次返回地址,因此增加了一条push dword ptr [ecx-4],ecx-4指向的就是返回地址。注意,我说的只是也许,因为我几次测试也没有发现为什么要压入这个返回地址,只能做这样的猜测。
Mac OS X 10.6.4+x64+gcc4.2.1
最后分析mac下的情况,如左上图所示(优先级为O0,O1),出现了没有见过的rbp,rsp;其实这是64位的寄存器,它们的作用分别与32位的寄存器ebp,esp相对应;其实原先的通用e系列通用寄存器都有与之对应的r系列寄存器,此外intel64位模式还新增了8个通用寄存器(r8-r15),以后有机会我会测试mac下的gcc有没有针对64位作出专门的优化,使用这些新增的寄存器。
右上图为优先级O2、O3的结果,用异或操作取代了传送操作,很简单。
总结
这篇文章展示了一些常见环境下main函数的汇编码,并简单的进行分析,内容比较粗浅。其实只要能完全理解栈帧的概念,不管将来遇到什么样的函数汇编码,都能轻松突破各种混乱的操作,找到其关键内容,这也正是本文的初衷。今后经过进一步的学习,我还将尝试完整地解析各个操作系统的可执行文件的内容,而不仅仅只是一个空的main函数。