第7章 栈帧
本文最后更新于:2022年5月19日 晚上
本章我们将学习栈帧(Stack Frame)相关知识,栈帧在程序中用于声明局部变量、调用函数。理解了栈帧,就能轻松掌握保存在其中的函数参数和局部变量,这对我们调试代码也是很有帮助的。
目标
- 理解栈帧的运行原理。
- 编写简单的程序,通过调试观察栈侦情况。
- 详细讲解几个简单的汇编指令。
7.1栈帧
简言之,栈帧就是利用EBP (栈帧指针,请注意不是ESP)寄存器访问栈内局部变量、参数、函数返回地址等的手段。通过前面关于IA-32寄存器的学习我们知道,ESP寄存器承担着栈顶指针的作用,而EBP寄存器则负责行使栈帧指针的职能。程序运行中,ESP寄存器的值随时变化,访问栈中函数的局部变量、参数时,若以ESP值为基准编写程序会十分困难,并且也很难使CPU引用到准确的地址。所以,调用某函数时,先要把用作基准点(函数起始地址)的ESP值保存到EBP,并维持在函数内部。这样,无论ESP的值如何变化,以EBP的值为基准(base)能够安全访问到相关函数的局部变量、参数、返回地址,这就是EBP寄存器作为栈帧指针的作用。
接下来看看栈帧对应的汇编代码(重点)
1 |
|
借助栈帧技术管理函数调用时,无论函数调用的深度有多深、多复杂,调用栈都能得到很好的管理与维护。
最新的编译器中都带有一个“优化”(Optimization)选项,使用该选项编译简单的函数将不会生成栈帧。
在栈中保存函数返回地址是系统安全隐患之一,攻击者使用缓冲区溢出技术能够把保存在栈内存的返回地址更改为其他地址。
7.2 调试示例:stackframe.exe
下面调试一个非常简单的程序来进一步了解栈帧相关知识。
7.2.1 StackFrame.cpp
1 |
|
在进行实验时使用的是Visual Studio,为了更好地适用栈帧,必须使用Release版本来编译程序,并且关闭Visual Studio的优化选项(/Od)后再编译程序。
使用x32dbg调试工具打幵StackFrame.exe文件,找到main函数所在位置,如下图所示。
对于尚不熟悉汇编语言的朋友来说,上图中的代码可能有些复杂,下面我们会详细讲解。通过与C语言源代码的对照讲解,分析代码执行各阶段中栈内数据的变化,帮助大家更好地理解。
7.2.2开始执行main()函数&生成栈帧
VS中的反汇编界面已经非常贴心地给出了汇编代码和C语言的对照表,照搬如下。其中由于VS装在Win10上,反汇编界面对应的地址是使用了ASLR的,我们将以x32dbg(win7环境下的地址为准)。
add函数:
1 |
|
main函数:
1 |
|
首先从StackFrame.cpp源程序的主函数开始分析,代码如下。
1 |
|
函数main()是程序开始执行的地方,在main()函数的起始地址( 42F430)处,设置一个断点,然后按运行程序,程序运行到main()函数的断点处暂停。
开始执行main()函数时栈的状态如下图所示。从现在开始要密切关注栈的变化,这是我们要重点学习的内容。
当前ESP的值为12FF44,EBP的值为12FF88。切记地址4012A0保存在ESP(12FF44)中,它是main()函数执行完毕后要返回的地址。
大家的运行环境不同,这意味着看到的地址可能会与上图中的不一样。
main()函数一开始运行就生成与其对应的函数栈帧。
1 |
|
PUSH是一条压栈指令,上面这条PUSH语句的含义是“把EBP值压入栈”。main()函数中,EBP为栈帧指针,用来把EBP之前的值备份到栈中(main()函数执行完毕,返回之前,该值会再次恢复)。
1 |
|
MOV是一条数据传送命令,上面这条MOV语句的命令是“把ESP值传送到EBP”。换言之,从这条命令开始,EBP就持有与当前ESP相同的值,并且直到main()函数执行完毕,EBP的值始终保持不变。也就是说,我们通过EBP可以安全访问到存储在栈中的函数参数与局部变量。执行完00401040与00401041地址处的两条命令后,函数main()的栈帧就生成了(设置好EBP了)。
x32dbg的栈窗口默认是ESP寻址,下拉如下菜单选择头两个即可在ESP寻址和EBP寻址之间来回切换,右侧的数字是显示偏移的最大长度。
接下来,在x32dbg的栈窗口中确认EBP的位置。程序调试到现在的栈内情况如下图所示,把地址转换为相对于EBP的偏移后,能更直观地观察到栈内情况。
如上图所示,当前EBP值为12FF40,与ESP值一致,12FF40地址处保存着12FF88,它是main()函数开始执行时EBP持有的初始值。
7.2.3 设置局部变量
下面开始分析源文件StackFrame.cpp中的变量声明及赋值语句。
1 |
|
main()函数中,上述语句用于在栈中为局部变量(a,b)分配空间,并赋初始值。main()函数中声明的变量a、b是如何在函数栈中生成的,又是如何管理的呢?下面一起来揭晓其中的秘密。
1 |
|
SUB是汇编语言中的一条减法指令,上面这条语句用来将ESP的值减去8个字节。如图7-4所 示,执行该条命令之前,ESP的值为12FF40,减去8个字节后,变为12FF38。那么为什么要将ESP减去8个字节呢?从ESP减去8个字节,其实质是为函数的局部变量(a与b,请参考代码7-2)开辟空间,以便将它们保存在栈中。由于局部变量a与b都是long型(长整型),它们分别占据4个字节大小,所以需要在栈中开辟8个字节的空间来保存这2个变量。
使用SUB指令从ESP中减去8个字节,为2个函数变量开辟好栈空间后,在main()内部,无论ESP的值如何变化,变量a与b的栈空间都不会受到损坏。由于EBP的值在main()函数内部是固定不变的,我们就能以它为基准来访问函数的局部变量了。继续看如下代码
1 |
|
对于刚刚接触汇编语言的朋友来说,上面两条命令中的“DWORD PTRSS:[EBP-4]” 部分可能略显陌生。其实没什么难的,只粟把它们看作类似于C语言中的指针就可以了。
上面这些指针命令很难用简洁明了的语言描述出来,简单翻译一下就是,地址EBP-4处有一个4字节大小的内存空间。
DWORD PTR SS:[EBP-4]语句中,SS是Stack Segment的缩写,表示栈段。由于Windows中使用的是段内存模型(Segment Memory Model),使用时需要指出相关内存属于哪一个区段。其实,32位的Windows OS中,SS、DS、ES的值皆为0,所以采用 这种方式附上区段并没有什么意义。(这里存疑)因EBP与ESP是指向栈的寄存器,所以添加上了SS寄存器。请注意,“DWORD PTR”与“SS:”等字符串可以通过设置x32dbg的相应选项来隐藏。
再次分析上面的2条MOV命令,它们的含义是“把数据1与2分别保存到[EBP-4]与[EBP-8]中”, 即[EBP-4]代表局部变量a,[EBP-8]代表局部变量b。执行完上面两条语句后,函数栈内的情况如下图所示。
7.2.4 add()函数参数传递与调用
StackFrame.cpp源代码中使用如下语句调用add()函数,执行加法运算并输出函数返回值。
1 |
|
1 |
|
请看上面5行汇编代码,它描述了调用add()函数的整个过程。地址40103C处为”Call 401080”命令,该命令用于调用401080处的函数,而401080处的函数即为add()函数。函数add()接收a、b这2个长整型参数,所以调用add()之前需要先把2个参数压入栈,地址004010B4-004010BB之间的代码即用于此。这一过程中需要注意的是,参数入栈的顺序与C语言源码中的参数顺序恰好相反(我们把这称为函数参数的逆向存储)。换言之,变量b ( [EBP-8])首先入栈,接着变量a ([EBP-4])再入栈。执行完地址004010B4-004010BB之间的代码后,栈内情况如下图所示。
P.S. 这里和书上结果不太一样,原因是开辟局部变量的时候编译器选择先填充了低地址,原因未明。
相比之下书上的栈内空间更能体现出逆序的感觉:
接下来进入add()函数(401080)内部,分析整个函数调用过程
返回地址
执行CALL命令进入被调用的函数之前,CPU会先把函数的返回地址压入栈,用作函数执行完毕后的返回地址。在地址004010BC处调用了add()函数,它的下一条命令的地址为4010C1。函数add()执行完毕后,程序执行流应该返回到4010C1地址处,该地址即被称为add()函数的返回地址。执行完004010BC地址处的CALL命令后进入函数,栈内情况如下图所示。
从这里也不难看出,call指令的等价命令是push call指令下一条指令的地址+jmp call调用的函数地址
7.2.5开始执行add()函数&生成栈帧
StackFrame.cpp源代码中,函数add()的前2行代码如下:
1 |
|
函数幵始执行时,栈中会单独生成与其对应的栈帧
1 |
|
上面2行代码与开始执行main()函数时的代码完全相同,先把EBP值(main()函数的基址指针)保存到栈中,再把当前ESP存储到EBP中,这样函数add()的栈帧就生成了。如此一来,add()函数内部的EBP值始终不变。执行完以上2行代码后,栈内情况如下图所示。
可以看到,main()函数使用的EBP值(12FF40 )被备份到栈中,然后EBP的值被设置为一个新值12FF28。
7.2.6设置add()函数的局部变量(x,y)
StackFrame.cpp源代码中有如下代码。
1 |
|
上面一行语句声明了2个长整型的局部变量(x,y),并使用2个形式参数(a,b)分别为它们赋初始值。希望大家密切关注形式参数与局部变量在函数内部以何种方式表示。
1 |
|
上面这条语句的含义为,在栈内存中为局部变量x、y开辟8个字节的空间。
1 |
|
add函数的栈帧生成之后,EBP的值发生了变化,[EBP+8]与[EBP+C]分别指向参数a与b,如 图7-8所示,而[EBP-8]与[EBP-4]则分别指向add()函数的2个局部变量x、y。执行完上述语句后,栈内情况如下图所示。
7.2.7 ADD 运算
StackFrame.cpp源代码中,下面这条语句用于返回2个局部变量之和。
1 |
|
1 |
|
上述MOV语句中,变量x的值被传送到EAX。
1 |
|
ADD指令为加法指令,上面这条语句中,变量y ([EBP-8] = 2 )与EAX原值(x)相加,且运 算结果被存储在EAX中,运算完成后EAX中的值为3。 第14章中我们将详细学习EAX寄存器,它是一种通用寄存器,在算术运算中存储输入输岀数据,为函数提供返回值。如上所示,函数即将返回时,若向EAX输入某个值,该值就会原封不动地返回。执行运算的过程中栈内情况保持不变。
7.2.8删除函数add()的栈帧&函数执行完毕(返回)
“删除函数栈帧与函数执行完毕返回”对应于StackFrame.cpp文件中的如下代码。
1 |
|
执行完加法运算后,要返回函数add(),在此之前先删除函数add()的栈帧。
1 |
|
上面这条命令把当前EBP的值赋给ESP,与地址00401081处的MOV EBP, ESP命令相对应。在地址00401081处,MOV EBP, ESP命令把函数add()开始执行时的ESP值(12FF44)放入EBP,函数执行完毕时,使用00401098处的MOV ESP,EBP命令再把存储到EBP中的值恢复到ESP中。
执行完上面的命令后,地址00401083处的SUB ESP,8命令就会失效,即函数add()的2个局部变量x、y不再有效。
1 |
|
上面这条命令用于恢复函数add()开始执行时备份到栈中的EBP值,它与00401080地址处的PUSH EBP命令对应。EBP值恢复为12FF40,它是main()函数的EBP值。到此,add()函数的栈帧就被删除了。
执行完上述命令后,栈内情形如下图所示
可以看到,ESP的值为12FF2C,该地址的值为4010C1,它是执行CALL 401080命令时CPU存储到栈中的返回地址。
1 |
|
执行上述RETN命令,存储在栈中的返回地址即被返回,此时栈内情形如图7-11所示。
从下图中可以看到,调用栈已经完全返回到调用add()函数之前的状态,大家可以比较一下调用前后的两张堆栈图。
应用程序采用上述方式管理栈,不论有多少函数嵌套调用,栈都能得到比较好的维护,不会崩溃。但是由于函数的局部变量、参数、返回地址等是一次性保存到栈中的,利用字符串函数的漏洞等很容易引起栈缓冲区溢出,最终导致程序或系统崩溃。
7.2.9从栈中删除函数add()的参数(整理栈)
现在,程序执行流已经重新返回main()函数中。
1 |
|
上面语句使用ADD命令将ESP加上8,为什么突然要把ESP加上8呢?请看图7-11中的栈窗口,地址12FF30与12FF34处存储的是传递给函数add()的参数a与b。函数add()执行完毕后,就不再需要参数a与b了,所以要把ESP加上8,将它们从栈中清理掉(参数a与b都是长整型,各占4个字节,合起来共8个字节)。
请记住,调用add()函数之前先使用PUSH命令把参数a、b压入栈。
执行完上述命令后,栈内情况如下图所示。
被调函数执行完毕后,函数的调用者(Caller)负责清理存储在栈中的参数,这种 方式称为cdecl方式;反之,被调用者(Callee)负责清理保存在栈中的参数,这种方 式称为stdcall方式。这些函数调用规则统称为调用约定(Calling Convention),这在程序开发与分析中是一个非常重要的概念,第10章将进一步讲解相关内容。
7.2.10调用 printf()函数
StackFrame.cpp源文件中用于打印输出运算结果的语句如下所示。
1 |
|
调用printf()函数的汇编代码如下所示。
1 |
|
地址004010C4处的EAX寄存器中存储着函数add()的返回值,它是执行加法运算后的结果值3。地址004010CA处的CALL 401040命令中调用的是401040地址处的函数,它是一个C标准库函数printf(),所有C标准库函数都由Visual C++编写而成(其中包含着数量庞大的函数,在此不详细介绍)。由于上面的printf()函数有2个参数,大小为8个字节( 32位寄存器+32位常量=64位=8字节),所以在40104F地址处使用ADD命令,将ESP加上8个字节,把函数的参数从栈中删除。函数printf()执行完毕并通过ADD命令删除参数后,栈内情形如下图所示。
7.2.11设置返回值
StackFrame.cpp中设置返回值的语句如下。
1 |
|
main()函数使用该语句设置返回值(0)。
1 |
|
XOR命令用来进行Exclusive OR bit (异或)运算,其特点为 “2个相同的值进行XOR运算,结果为0”。XOR命令比MOV EAX, 0命令执行速度快,常用于寄存器的初始化操作。
利用相同的值连续执行2次XOR运算即变为原值,这个特征被大量应用于编码与解码。后面的代码分析中我们会经常看到XOR命令。
7.2.12删除栈帧&main()函数终止
本节内容对应StackFrame.cpp中的如下代码。
1 |
|
最终主函数终止执行,同add()函数一样,其返回前要先从栈中删除与其对应的栈帧。
1 |
|
执行完上面2条命令后,main()函数的栈帧即被删除,且其局部变量a、b也不再有效。执行至此,栈内情形如下图所示。
上图与main()函数开始执行时栈内情形是完全一样的。
1 |
|
执行完上面命令后,主函数执行完毕并返回,程序执行流跳转到返回地址处(4012A5),该 地址指向Visual C++的启动函数区域。随后执行进程终止代码。请各位自行查看该过程。请大家阅读上面内容的同时动手调试,认真观察栈的行为动作,相信各位的调试水平会得到很大提高
参考
《逆向工程核心原理》 第7章