关于函数栈空间
假设函数A调用函数B,我们称A函数为"调用者",B函数为“被调用者”则函数调用过程可以这么描述:
(1)先将调用者(A)的堆栈的基址(ebp)入栈,以保存之前任务的信息。
(2)然后将调用者(A)的栈顶指针(esp)的值赋给ebp,作为新的基址(即被调用者B的栈底)。
(3)然后在这个基址(被调用者B的栈底)上开辟(一般用sub指令)相应的空间用作被调用者B的栈空间。
(4) 函数B返回后,从当前栈帧的ebp即恢复为调用者A的栈顶(esp),然后调用者A再从恢复后的栈顶可弹出之前的ebp值。
在这里,我们写一个具体的例子,演示一下函数调用过程中,堆栈及寄存器ebp、esp的变化情况
void B(int a)
{
int b = a;
}
void A()
{
int a[10] = {0};
B(a[1]);
}
void main()
{
A();
}
使用vc6.0编译得到如下汇编代码
函数A:
00000 55 push ebp
00001 8b ec mov ebp, esp
00003 83 ec 68 sub esp, 104
00006 53 push ebx
00007 56 push esi
00008 57 push edi
00009 8d 7d 98 lea edi, DWORD PTR [ebp-104]
0000c b9 1a 00 00 00 mov ecx, 26
00011 b8 cc cc cc cc mov eax, -858993460
00016 f3 ab rep stosd
00018 c7 45 d8 00 00
00 00 mov DWORD PTR _a$[ebp], 0
0001f b9 09 00 00 00 mov ecx, 9
00024 33 c0 xor eax, eax
00026 8d 7d dc lea edi, DWORD PTR _a$[ebp+4]
00029 f3 ab rep stosd
0002b 8b 45 dc mov eax, DWORD PTR _a$[ebp+4]
0002e 50 push eax
0002f e8 00 00 00 00 call ?B@@YAXH@Z
00034 83 c4 04 add esp, 4
00037 5f pop edi
00038 5e pop esi
00039 5b pop ebx
0003a 83 c4 68 add esp, 104
0003d 3b ec cmp ebp, esp
0003f e8 00 00 00 00 call __chkesp
00044 8b e5 mov esp, ebp
00046 5d pop ebp
00047 c3 ret 0
函数B:
00000 55 push ebp
00001 8b ec mov ebp, esp
00003 83 ec 44 sub esp, 68
00006 53 push ebx
00007 56 push esi
00008 57 push edi
00009 8d 7d bc lea edi, DWORD PTR [ebp-68]
0000c b9 11 00 00 00 mov ecx, 17
00011 b8 cc cc cc cc mov eax, -858993460
00016 f3 ab rep stosd
00018 8b 45 08 mov eax, DWORD PTR _a$[ebp]
0001b 89 45 fc mov DWORD PTR _b$[ebp], eax
0001e 5f pop edi
0001f 5e pop esi
00020 5b pop ebx
00021 8b e5 mov esp, ebp
00023 5d pop ebp
00024 c3 ret 0
由上面的汇编代码可以看出,函数A和函数B在调用之前都做了如下处理:
00000 55 push ebp
00001 8b ec mov ebp, esp
这两句代码分别对应函数调用过程的(1)、(2),即保存当前的栈底地址(edp),将当前的栈顶地址(esp)作为该函数的栈底。在保持完这些状态之后,开始开辟函数的栈空间,代码如下
00003 83 ec 44 sub esp, 68 ;开辟68字节的栈空间: 44H = 68D
而在函数执行完之后,又做了如下处理:
00021 8b e5 mov esp, ebp
00023 5d pop ebp
这两句代码对应的是
调用过程的(4),即还原调用函数前的栈状态。
单步跟踪运行,在调用B之前,查看此时的寄存器状态如下图:
根据EBP的值,我们可看到此时函数栈的状态如下图:
单步执行 call @ILT+10(B)(0040100f) 后,堆栈状态如下:
我们注意到Call指令已经将函数B的返回地址(0x401094)压入了栈,在函数返回时,将该地址赋于寄存器EIP,使得程序能正确返回并继续往下执行。如果我们修改这个地址的值,那么在函数返回时,程序将跳到你指定的地址继续执行。缓冲区溢出攻击的原理就是如此,通过越界访问缓冲区以修改函数返回地址,从而获得程序控制权。
当程序进入函数B之后,寄存器的状态如下:
栈空间状态如下:
可以看出,函数B的栈底地址指向的值(4字节)是函数A的栈底地址,函数B的栈底地址指向的下一个值(4字节)是函数B的返回地址,我们只要能定位到这个返回地址并将之修改为我们自己的函数地址,那我们就掌握了程序的控制权。
在下一章,将给出一个例子,讨论如何准备一些特殊的数据,将这些数据传给没有做溢出检测的函数,从而获得程序的控制权。