本文将对函数栈进行解析,在此之前先对程序的内存分配有个大概了解。
程序内存逻辑分配
一个程序中内存分布如下图:
代码段:保存程序文本,可读可执行不可写,指令寄存器EIP指向代码段中将要执行的代码行首地址
数据段:保存已初始化的全局变量和静态变量,可读可写不可执行
BBS:未初始化的全局变量和静态变量
Heap(堆):动态内存,数据存放向内存高端增长,可读可写可执行
Stack(栈):存放局部变量,函数参数,函数调用信息,寄存器状态等,函数栈便是在栈区,数据存放向内存低端增长,可读可写可执行
简单介绍下几个常用的寄存器
通用寄存器:
EAX:累加寄存器。用于算术运算的主要寄存器,也常用来存放函数的返回值。在保护模式下也可作为内存偏移指针。
ECX:计数器寄存器。常用于特定指令的计数。
EBX:基地址寄存器。内存寻址时用于存放基地址。
EDX:多功能寄存器。常用于乘除法和I/0指针。
ESI:源变址寄存器。通常在内存操作指令中作为“源地址指针”使用。
EDI:目的变址寄存器。通常在内存操作指令中作为“目的地址指针”使用。
ESP:栈指针。作为指针指向当前栈的栈顶(栈顶为栈的低地址)。
EBP:帧指针。作为指针指向当前栈的栈底(栈底为栈的高地址)。
EIP:指令寄存器。作为指针指向下一条指令,该寄存器的值不能直接修改。
函数栈解析:
本文将以一个示例程序对函数栈进行解析:
#include <stdio.h>
#include <Windows.h>
int Fun(int a, int b)
{
return a + b;
}
int main()
{
int a, b;
a = 0x11111111;
b = 0x22222222;
Fun(3, 4);
system("pause > nul");
return 0;
}
函数栈初始化
下面以main函数的函数栈为例介绍函数栈的初始化:
main函数初始化在VS下的反汇编源码如下:
14: int main()
15: {
001D1410 push ebp
001D1411 mov ebp, esp
001D1413 sub esp, 0D8h
001D1419 push ebx
001D141A push esi
001D141B push edi
001D141C lea edi, [ebp – 0D8h]
001D1422 mov ecx, 36h
001D1427 mov eax, 0CCCCCCCCh
001D142C rep stos dword ptr es : [edi]
包括调用main函数,每个函数栈的初始化都是相似的。被调用函数会先将各状态寄存器和堆栈寄存器压栈,完成对原始寄存器状态的保存;并分配函数栈的大小并初始化函数栈。
接下来对每行汇编源码进行分析:
push ebp、ebx、esi、edi 分别将四个寄存器压栈,压栈的起始位置为(ESP – 1),每次压栈ESP -= 4
第二行mov ebp, esp是在完成对原始EBP寄存器的保存后,将调用函数的栈顶设为被调用函数的栈底
第三行sub esp, 0D8h即esp -= 0D8h,即将(esp – 0D8h)设为main函数的栈顶
在完成对各寄存器状态的保存和函数栈大小的分配后,最后四行汇编源码是对函数栈初始化:
lea edi, [ebp – 0D8h]将(ebp – 0F4h) 赋值给EDI寄存器,由于mov操作不支持第二个操作数是一个寄存器减去一个数值,故用lea操作。这里是将栈顶地址赋给EDI寄存器
mov ecx, 36h将数值36h赋值给ECX寄存器,0X36 = 0D8h >> 2,即为main函数栈内存的DWORD数目
mov eax, 0CCCCCCCCh将EAX赋值为0CCCCCCCCh,即为INT 3中断
rep stos dword ptr es : [edi] rep指令是stos字符串指令的前缀,rep指令使其后的字符串指令被重复直至ECX为0,故ECX即为重复次数,stos指令是一个字符串指令,是将EAX中的值拷贝到ES : EDI指向的地址,再EDI += 4,而dword ptr则是指stos指令一次拷贝双字
main函数的函数栈初始化如下图所示:
在调用Fun函数时
21: Fun(3, 4);
001D143C push 4
001D143E push 3
001D1440 call Fun (01D1127h)
001D1445 add asp, 8
Fun函数的调用方式为__cdecl,故实参从右向左压栈,并且__cdecl方式由调用函数清理栈中的实参, add esp, 8为清除实参的指令。
call Fun (01D1127h)将跳转到Fun函数的jmp跳转指令地址0x01D1127h处,注意call指令有一个隐含的压栈操作:将函数的返回地址(Fun函数结束后将执行的指令地址,本例中为0x001D1445h) 压栈。
Fun函数的函数栈初始化也和main函数相似,不再赘述。
Fun函数的函数栈初始化后如下图所示:
函数栈的清理
Fun函数结束处的汇编源码如下:
6: return a + b;
001D13EC mov eax, dword ptr [a]
001D13EF add eax, dword ptr [b]
7: }
001D13F2 pop edi
001D13F3 pop esi
001D13F4 pop ebx
001D13F5 mov esp, ebp
001D13F7 pop ebp
001D13F8 ret
Fun函数的返回值存放在EAX寄存器中,之后EDI、ESI、EBX寄存器分别出栈,出栈时不对内存操作,栈顶指针ESP += 4,EDI、ESI、EBX寄存器恢复为栈中保存的值
栈顶指针ESP和栈底指针EBP分别恢复为call指令之后的状态
如下图:
上文提到call指令隐含函数返回地址的压栈,ret指令与call指令是相对的,隐含函数返回地址的出栈,指令寄存器EIP指向出栈的返回地址所指向的指令,同时ESP+= 4。
因此不难想到:可以在被调用函数中通过帧指针EBP寻址并修改函数返回地址,从而实现跳转。
在ret指令和实参出栈后,Fun函数的函数栈清理也就完成了。
看到这里,对于函数栈的初始化和恢复也就清楚了。
扩展:
一、 函数栈的安全检查:
在一些程序中main函数的函数栈初始化完成后经常会看到如下三行:
00B113DE A1 00 80 B1 00 mov eax,dword ptr ds:[00B18000h]
00B113E3 33 C5 xor eax,ebp
00B113E5 89 45 FC mov dword ptr [ebp-4],eax
这是VS添加的基于cookie的安全检查,引用Microsoft的解释:
Theprolog contains an instruction that fetches a copy of the cookie, followed byan instruction that does a logical xor of the cookie and the return address,and then finally an instruction that stores the cookie on the stack directlybelow the return address. From this point forward, the function will execute asit does normally. When a function returns, the last thing to execute is thefunction’s epilog, which is the opposite of the prolog.Without security checks, it will reclaim the stack space and return, such asthe following instructions:
关于cookie变量安全检查这里便不细说,详细的可以看微软的MSDN文档CompilerSecurity Checks In Depth
二、 函数栈局部变量存放问题
在一些博文中看到过通用栈指针EBP来获取函数栈内的局部变量的方法,不过这里需要注意:函数栈中局部变量的存放并不一定是连续的。以上面的程序为例,main函数的函数栈中有两个局部变量a(0x11111111h)、b(0x22222222h)。VS2013下,其在函数栈中的存放如下图所示:
红色框即为两个局部变量a、b,绿色框为栈底(EBP所指处)。