第7章 栈帧

栈帧 (Stack Frame) ,在程序中用于声明局部变量/调用函数.

1. 栈帧

栈帧就是利用EBP (栈帧指针) 寄存器访问栈内局部变量,参数,函数返回地址等的手段.ESP寄存器承担着栈顶指针的作用,而EBP寄存器则负责栈帧指针的职能.程序运行时,ESP寄存器的值随时变化,访问栈中函数的局部变量,参数时,若以ESP值为基准编写程序会十分困难,并且以很难使CPU引用到准确的地址.所以,调用某函数时,先要把用作基准点 (函数起始地址) 的ESP值保存到EBP,并维持在函数内部.这样,无论ESP的值如何变量,以EBP的为基准 (base) 能够安全访问到相关的函数的局部变量,参数,返回地址,这就是EBP寄存器作为栈帧指针的作用.

栈帧结构

2. 调试示例: StackFrame.exe

2.1 StackFrame.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "stdio.h"
long add(long a, long b)
{
long x = a, y = b;
return (x + y);
}
int main(int argc, char* argv[])
{
long a = 1, b = 2;
printf("%d\n", add(a, b));
return 0;
}

使用OllyDbg调试工具打开StackFrame.exe文件,按CTRL+G快捷键 (Go to命令) 转到401000地址处.
调试器界面

2.2 开始执行 main() 函数&生成栈帧

首先在StackFrame.cpp源程序的主函数分析,代码如下.
int main(int argc, char* argv[])
{

函数main()是程序开始执行的地方,在main()函数的起始地址(401020)处,按F2键设置一个断点,然后按F9运行程序,程序运行到main()函数的断点处暂停.

栈初始

当前ESP的初始值为0019FF3C,EBP的值为0019FF80.切记地址401250保存在ESP(0019FF3C)中,它是main()函数执行完毕后返回的地址.

main()函数一开始运行就生成与其对应的函数栈帧.

00401020 push ebp ; # main()

PUSH是一条压栈指令,上面这条PUSH语句的含义是”把EBP值压入栈”.main()函数中,EBP为栈帧指针,用来把EBP之前的值备份到栈中 (main()函数执行完毕,返回之前,该值会再次恢复).

00401021 mov ebp,esp

MOV是一条数据传送命令,上面这条MOV语句的命令是”把esp值传送到ebp”.换原之,就是两值相等,并且直到main()函数执行完毕,EBP的值始终保持不变.也就是说,我们通过EBP可以安全访问到存储在栈中的函数参数与局部变量.执行完401020与401021地址处后的两条命令后,函数main()的栈帧就生成了.

进入OllyDbg的栈窗口,单击鼠标右键,在弹出的菜单中依次选择Address-Relative to EBP,把地址转换为相对于EBP的偏移后,能够直接观察到栈内情况.

备份到栈中的EBP初始值

如图,当前EBP值为0019FF38,与ESP的值一致,19FF80地址处保存着19FF80,它是main()函数开始执行时EBP持有的初始值.

2.3 设置局部变量

下面开始分析源文件StackFrame.cpp中的变量声明与赋值语句.
long a = 1, b = 2;

main()函数中,上述语句用于在栈中为局部变量(a,b)分配空间,并赋初始值.

00401023 sub esp,0x8

SUB是汇编语言中的一条减法指令,上面这条语句用来将ESP的值减去8个字节.执行该指令前ESP的值为:0019FF38,执行后变为:0019FF30.为什么要减去8个字节,其实质为函数的局部变量 (a与b)开辟空间,以便将它们保存在栈中.由于局部变量a与b都是long型,它们分别占据4个字节大小,所以需要在栈中开辟8个字节的空间来保存这2个变量.

使用SUB指令从ESP中减去8个字节,为2个函数变量开辟栈空间后,在main()内部,无论ESP的值如何变化,变量a与b的栈空间都不会受到损坏.由于EBP的值在main()函数内部是固定不变的,我们就能以它作为基准来访问函数的局部变量了.

1
2
00401026 mov [EBP-4],1 ; [EBP-4] = local 'a'
0040102D mov [EBP-8],2 ; [EBP-8] = local 'b'

分析上面的2条MOV命令,它们的含义是”把数据1与2分别保存到[local.1]与[local2]中”.

变量a与变量b

2.4 add()函数参数传递与调用

StackFrame.cpp源代码中使用如下语句调用add()函数,执行加法运算并输出函数返回值.
printf(“%d\n”, add(a, b));

5行汇编代码

请看上面5行汇编代码,它描述了调用add()函数的整个过程.地址40103C处为”CALL 401000”命令,该命令用于调用401000处的函数,而401000处的函数即为add()函数.函数add()接收a,b这2个长整型参数,所以调用add()之前先把2个参数压入栈, 地址401034~40103B之间的代码即用于此.参数入栈的顺序与C语言源码中的参数顺序刚好相反.换言之,变量b首先入栈,接着变量a再入栈.执行完地址401034~40103B之间的代码后,栈内变化情况所示.

传递add()函数的参数

接下来进入add()函数(401000)内部,分析整个函数调用过程.

返回地址
执行CALL命令进入被调用的函数之前,CPU会先把函数的返回地址压入栈,用作函数执行完毕后的返回地址.在地址处调用了40103C处调用了add()函数,它的下 一条命令的地址为401041.函数add()执行完毕后,程序执行完返回到401041地址处,该地址即被称为add()函数的返回地址.执行完40103C地址处的CALL命令后进入函数,栈内情况如图:
函数add()返回地址

2.5 开始执行add()函数&生成栈帧

StackFrame.cpp源代码中,函数add()的前2行代码如下:
long add(long a, long b)
{

函数开始执行时,栈中会单独生成与其对应的栈帧.

1
2
0040100 push ebp
00401001 mov ebp,esp

上述2行代码与开始执行main()函数时的代码完全相同,先把EBP值保存到栈中,再把当前的ESP存储到EBP,这样函数add()的栈帧就生成了.

函数add()的栈帧

可以看到,main()函数使用的EBP值(19FF38)被备份到栈中,然后EBP的值被设置为一个新值19FF20.

2.6 设置add()函数的局部变量(X,Y)

StackFrame.cpp源代码中.
long x = a, y = b;

上面一行语句声明了2个长整型的局部变量(X,Y),并使用2个形式参数(a,b)分别为它们进行赋值初始.

00401003 sub esp,0x8

在栈内存中为局部变量X,Y开辟8个字节的空间.

1
2
3
4
5
6
7
00401006 mov eax,[arg.1] //a
00401009 mov [local.2],eax //x
0040100C mov ecx,[arg.2] //b
0040100F mov [local.1],ecx //y

函数add()的局部变量X/Y

2.7 ADD 运算

StackFrame.cpp源代码中,下面这条语句用于返回2个局部变量之和.
return (x + y);

1
2
3
00401012 mov eax,[local.2] //语句执行完毕,变量X的值会被传送到EAX
00401015 add eax,[local.1] //变量Y

上述这条语句中,变量Y与EAX的原值(X)相加,且运算结果被存储到EAX中,运算完后EAX的值为3.

2.8 删除函数add()的栈帧&函数执行完毕(返回)

“删除函数栈帧与函数执行完毕返回”对应于StackFrame.app文件中的如下代码.
return (x + y);
}

执行完加法运算后,要返回函数add(),在此之前要先删除函数add()的栈帧.

00401018 mov esp,ebp

上面这条命令将当前的EBP赋值给ESP,等价于将EBP中的值恢复到ESP中.

提示
执行完上面的命令后,地址401003处的SUB ESP,8命令失效,即是函数里面的局部变量不再有效.

0040101A pop ebp

上面这条命令用于恢复函数add()开始执行时备份到栈中的EBP值,它与401000地址处的PUSH EBP 命令对应.EBP值恢复为0019FF38,它是main()函数的EBP值.到此,add()函数的栈帧就被删除了.

删除函数add()的栈帧

可以看到ESP的值为:0019FF24,该地址值位:401041,它是执行CALL 401000命令时CPU存储到栈中的返回地址.

0040101B retn

执行上述语句,存储在栈中的返回地址被返回.

2.9 从栈中删除函数add()的参数 (整理栈)

00401041 add esp,0x8

函数add()执行完毕后,就不需要参数a与b了,所以要把esp加上8,将它们从栈中清理掉.

2.10 调用 printf() 函数

StackFrame.cpp源代码中用于打印运算结果的语句如下所示.
printf(“%d\n”, add(a, b));

调用printf()函数的汇编代码如下.

1
2
3
4
5
6
7
00401044 push eax
00401045 push StackFra.0040B384 ; ASCII "%d\n"
0040104A call StackFra.00401067
0040104F add esp,0x8

地址401044处的EAX寄存器中存储函数add()的返回值,它是执行加法运算后的结果3.地址40104A处的CALL 401067命令中调用的是401067地址处的函数.它是一个C标准函数printf(),由于上面的printf()函数有两个参数,大小为8个字节,所以在40104F地址处使用ADD命令,将ESP加上8个字节,把函数的参数从栈中删除,函数printf()执行完毕后通过ADD命令删除参数.

2.11 设置返回值

StackFrame.cpp中设置返回值的语句如下:
return 0;

main()函数使用该语句设置返回值 (0).

00401052 xor eax,eax

XOR命令用来进行异或运算,起特点为 “2个相同的值进行XOR运算,结果为0”.XOR命令比MOV EAX ,0命令执行速度快,常用语寄存器的初始化操作.

2.12 删除栈帧%main()函数终止

StackFrame.cpp中对于代码如下:
return 0;
}
最终主函数终止,同add()函数一样,其返回值要先从栈中删除与其对应的栈帧.

1
2
3
00401054 mov esp,ebp
00401056 pop ebp

执行完上面2条命令后,main()函数的栈帧即将被删除,其局部变量a,b也不再有效.执行完毕.

00401057 RETN

执行完上面命令后,主函数执行完毕之后,程序执行跳转到 (401250),该地址指向Visual C++的启动函数区域,随后执行进程终止代码.

3. 设置 OllyDbg的选项

3.1 打开OllyDbg的Debugging options对话框 (快捷键Alt+O).

OllyDbg的Debugging  options对话框

可以设置一些适合自己的选项.

4. 总结

栈帧技术使用EBP寄存器(而非ESP寄存器)管理局部变量/参数/返回地址等.