7.1栈帧
简言之,栈帧就是利用EBP(栈帧指针,请注意不是ESP)寄存器访问栈内局部变量、参数、函数返回地址等的手段。通过前面关于IA-32寄存器的学习我们知道,ESP寄存器承担着栈顶指针的作用,而EBP寄存器则负责行使栈帧指针的职能。程序运行中,ESP寄存器的值随时变化,访问栈中函数的局部变量、参数时,若以ESP值为基准编写程序会十分困难,并且也很难使CPU引用到准确的地址。所以,调用某函数时,先要把用作基准点(函数起始地址)的ESP值保存到EBP,并维持在函数内部。这样,无论ESP的值如何变化,以EBP的值为基准( base)能够安全访问到相关函数的局部变量、参数、返回地址,这就是EBP寄存器作为栈帧指针的作用。
接下来看看栈帧对应的汇编代码。
代码7-1 栈帧结构
PUSH EBP ;函数开始(使用EBP前先把已有值保存到栈中)
MOV EBP, ESP ;保存当前ESP到EBP中
;函数体
;无论ESP值如何变化,EBP都保持不变,可以安全访问函数的局部变量、参数
MOV ESP,EBP ;将函数的起始地址返回到ESP中
POP EBP ;函数返回前弹出保存在栈中的EBP值
RETN ;函数终止
借助栈帧技术管理函数调用时,无论函数调用的深度有多深、多复杂,调用栈都能得到很好的管理与维护。
提示
·最新的编译器中都带有一个“优化”( Optimization)选项,使用该选项编译简单的函数将不会生成栈帧。
·在栈中保存函数返回地址是系统安全隐患之一,攻击者使用缓冲区溢出技术能够把保存在栈内存的返回地址更改为其他地址。
7.2调试示例:stackframe.exe
下面调试一个非常简单的程序来进一步了解栈帧相关知识。
7.2.1 StackFrame.cpp
#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;
}
提示
为了更好地适用栈帧,必须先关闭Visual C++的优化选项(/Od)后再编译程序。
使用OllyDbg调试工具打开StackFrame.exe文件,按Ctrl+G快捷键( Go to命令)转到401000地址处,如图7-1所示。
对于尚不熟悉汇编语言的朋友来说,图7-1中的代码可能有些复杂,下面我们会详细讲解。通过与C语言源代码的对照讲解,分析代码执行各阶段中栈内数据的变化,帮助大家更好地理解。
7.2.2开始执行main()函数&生成栈帧
首先从StackFrame.cpp源程序的主函数开始分析,代码如下:
int main(int argc,char* argv[])
{
函数main()是程序开始执行的地方,在main()函数的起始地址( 401020)处,按F2键设置一个断点,然后按F9运行程序,程序运行到main()函数的断点处暂停。
开始执行main()函数时栈的状态如图7-2所示。从现在开始要密切关注栈的变化,这是我们要重点学习的内容。
当前ESP的值为19FF2C,EBP的值为19FF70。切记地址401250保存在ESP (19FF2C)中,它是main()函数执行完毕后要返回的地址。
提示
大家的运行环境不同,这意味着看到的地址可能会与图7-2中的不一样。
main()函数一开始运行就生成与其对应的函数栈帧。
00401020 PUSH EBP ;#main()
PUSH是一条压栈指令,上面这条PUSH语句的含义是“把EBP值压入栈”。main()函数中,EBP为栈帧指针,用来把EBP之前的值备份到栈中 ( main()函数执行完毕,返回之前,该值会再次恢复)。
00401021 MOV EBP,ESP
MOV是一条数据传送命令,上面这条MOV语句的命令是“把ESP值传送到EBP”。换言之,从这条命令开始,EBP就持有与当前ESP相同的值,并且直到main()函数执行完毕,EBP的值始终保持不变。也就是说,我们通过EBP可以安全访问到存储在栈中的函数参数与局部变量。执行完401020与401021地址处的两条命令后,函数main()的栈帧就生成了(设置好EBP了)。
进人OllyDbg的栈窗口,单击鼠标右键,在弹出菜单中依次选择Address-Relative to EBP,如图7-3所示。
接下来,在OllyDbg的栈窗口中确认EBP的位置。程序调试到现在的栈内情况如图7-4所示,把地址转换为相对于EBP的偏移后,能更直观地观察到栈内情况:
如图7-4所示,当前EBP值为19FF28,与ESP值一致,19FF28地址处保存着19FF70,它是main()函数开始执行时EBP持有的初始值。
7.2.3 设置局部变量
下面开始分析源文件StackFrame.cpp中的变量声明及赋值语句。long a = 1, b = 2;
main()函数中,上述语句用于在栈中为局部变量(a,b)分配空间,并赋初始值。代码7-2main()函数中声明的变量a,b是如何在函数栈中生成的,又是如何管理的呢?下面一起来揭晓其中的秘密。
00401023 SUB ESP,8
SUB是汇编语言中的一条减法指令,上面这条语句用来将ESP的值减去8个字节。如图7-4所示,执行该条命令之前,ESP的值为19FF28,减去8个字节后,变为19FF20。那么为什么要将ESP减去8个字节呢?从ESP减去8个字节,其实质是为函数的局部变量( a与b,请参考代码7-2)开辟空间,以便将它们保存在栈中。由于局部变量a与b都是long型(长整型),它们分别占据4个字节大小,所以需要在栈中开辟8个字节的空间来保存这2个变量。
使用SUB指令从ESP中减去8个字节,为2个函数变量开辟好栈空间后,在main()内部,无论ESP的值如何变化,变量a与b的栈空间都不会受到损坏。由于EBP的值在main()函数内部是固定不变的,我们就能以它为基准来访问函数的局部变量了。继续看如下代码。
00401026 MOV DwORD PTR SS: [EBP-4],1 ; [EBP-4] =local 'a'
0040102D MOV DwORD PTR SS: [EBP-8],2 ; [EBP-8] = local 'b'
对于刚刚接触汇编语言的朋友来说,上面两条命令中的“DWORD PTR SS:[EBP-4]”部分可能略显陌生。其实没什么难的,只要把它们看作类似于C语言中的指针就可以了。
表7-1 汇编语言与c语言的指针语句格式
| | |
| | |
汇编语言 | C语言 | 类型转换 |
---|---|---|
DWORD PTR SS:[EBP-4] | (DWORD)(EBP-4) | DwORD(4个字节) |
WORD PTR SS:[EBP-4] | (WORD)(EBP-4) | WORD(2个字节) |
BYTE PTR SS:[EBP-4] | (BYTE)(EBP-4) | BYTE(1个字节) |
上面这些指针命令很难用简洁明了的语言描述出来,简单翻译一下就是,地址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:”等字符串可以通过设置OllyDbg的相应选项来隐藏。
再次分析上面的2条MOV命令,它们的含义是“把数据1与2分别保存到[EBP-4]与[EBP-8]中”,即[EBP-4]代表局部变量a,[EBP-8]代表局部变量b。执行完上面两条语句后,函数栈内的情况如图7-5所示:
7.2.4 add()函数参数传递与调用
StackFrame.cpp源代码中使用如下语句调用add()函数,执行加法运算并输出函数返回值。printf(“%d\n", add(a,b));
00401034 |. 8B45 F8 mov eax,[local.2] ;[EBP-8] =b
00401037 |. 50 push eax ;Arg2 =00000002
00401038 |. 8B4D FC mov ecx,[local.1] ;[EBP-4]=a
0040103B |. 51 push ecx ;Arg1 =00000001
0040103C |. E8 BFFFFFFF call StackFra.00401000 ;add()
请看上面5行汇编代码,它描述了调用add()函数的整个过程。地址40103C处为“Call 401000”命令,该命令用于调用401000处的函数,而401000处的函数即为add()函数。代码7-2中,函数add()接收a、b这2个长整型参数,所以调用add()之前需要先把2个参数压入栈,地址401034~40103B之间的代码即用于此。这一-过 程中需要注意的是,参数人栈的顺序与C语言源码中的参数顺序恰好相反(我们把这称为函数参数的逆向存储)。换言之,变量b ([EBP-8])首先人栈,接着变量a([EBP-4])再人栈。执行完地址401034- 40103B之间的代码后,栈内情况如图7-6所示。
接下来进人add()函数(401000)内部,分析整个函数调用过程。
返回地址
执行CALL命令进人被调用的函数之前,CPU会先把函数的返回地址压人栈,用作函数执行完毕后的返回地址。从图7-1中可知,在地址40103C处调用了add()函数,它的下一条命令的地址为401041。函数add()执行完毕后,程序执行流应该返回到401041地址处,该地址即被称为add()函数的返回地址。执行完40103C地址处的CALL命令后进人函数,栈内情况如图7-7所示。
7.2.5 开始执行add()函数&生成栈帧
StackFrame.cpp源代码中,函数add()的前2行代码如下:
long add(long a, Long b)
{
函数开始执行时,栈中会单独生成与其对应的栈帧。
00401000 /$ 55 push ebp
00401001 |. 8BEC mov ebp,esp
上面2行代码与开始执行main()函数时的代码完全相同,先把EBP值( main()函数的基址指针)保存到栈中,再把当前ESP存储到EBP中,这样函数add0的栈帧就生成了。如此-来, add()函数内部的EBP值始终不变。执行完以上2行代码后,栈内情况如图7-8所示。
可以看到,main(函数使用的EBP值(12FF28)被备份到栈中,然后EBP的值被设置为一个新值12FF28。
7.2.6设置 add()函数的局部变量(x, y)
StackFrame.cpp源代码中有如下代码:
longx=a,y=b;
上面一行语句声明了2个长整型的局部变量(x, y),并使用2个形式参数(a, b)分别为它们赋初始值。希望大家密切关注形式参数与局部变量在函数内部以何种方式表示。
00401003 SUB ESP,8
上面这条语句的含义为,在栈内存中为局部变量x、y开辟8个字节的空间。
00401006 |. 8B45 08 mov eax,[arg.1]
00401009 |. 8945 F8 mov [local.2],eax
0040100C |. 8B4D 0C mov ecx,[arg.2]
0040100F |. 894D FC mov [local.1],ecx
add函数的栈帧生成之后,EBP的值发生了变化,[EBP+8]与[EBP+C]分别指向参数a与b,如图7-8所示,而[EBP-8]与[EBP-4]则分 别指向add()函数的2个局部变量x、y。执行完上述语句后,栈内情况如图7-9所示。
7.2.7 ADD 运算
StackFrame.cpp源代码中,下面这条语句用于返回2个局部变量之和。
return (x + y);
00401012 MOV EAX, DWORD PTR SS:[EBP-8] ; [EBP-8] = local x
上述MOV语句中,变量x的值被传送到EAX。
00401015 ADD EAX, DWORD PTR SS:[EBP-4] ; [EBP-4] = local y
ADD指令为加法指令,上面这 条语句中,变量y ([EBP-4]=2)与EAX原值(x)相加,且运算结果被存储在EAX中,运算完成后EAX中的值为3。
第14章中我们将详细学习EAX寄存器,它是一-种通用寄存器,在算术运算中存储输人输出数据,为函数提供返回值。如上所示,函数即将返回时,若向EAX输人某个值,该值就会原封不动地返回。执行运算的过程中栈内情况保持不变,如图7-9所示。
7.2.8删除 函数add()的栈帧&函数执行完毕(返回)
删除函数栈帧与函数执行完毕返回”对应于StackFrame.cpp文件中的如下代码。
return (x + y);
执行完加法运算后,要返回函数add(),在此之前先删除函数add()的栈帧。
00401018 MOV ESP, EBP
上面这条命令把当前EBP的值赋给ESP,与地址401001处的MOV EBP, ESP命令相对应。在地址401001处,MOV EBP, ESP命令把函数add()开始执行时的ESP值(19FF28)放人EBP,函数执行完毕时,使用401018处的MOV ESP,EBP命令再把存储到EBP中的值恢复到ESP中。
提示
执行完上面的命令后,地址401003处的SUB ESP,8命令就会失效,即函数add()的2个局部变量x、y不再有效。
0040101A POP EBP
上面这条命令用于恢复函数add()开始执行时备份到栈中的EBP值,它与401000地址处的PUSH EBP命令对应。EBP值恢复为19FF28,它是main()函数的EBP值。到此,add()函数的栈帧就被删除了。
执行完上述命令后,栈内情形如图7-10所示。
可以看到,ESP的值为19FF14,该地址的值为401041,它是执行CALL 401000命令时CPU存储到栈中的返回地址。
0040101B RETN
执行上述RETN命令,存储在栈中的返回地址即被返回,此时栈内情形如图7-11所示。从图7-11中可以看到,调用栈已经完全返回到调用add)函数之前的状态,大家可以比较一下图7-11与图7-6。
应用程序采用上述方式管理栈,不论有多少函数嵌套调用,栈都能得到比较好的维护,不会崩溃。但是由于函数的局部变量、参数、返回地址等是-一次性保存到栈中的,利用字符串函数的漏洞等很容易引起栈缓冲区溢出,最终导致程序或系统崩溃。
7.2.9从栈中删除函数add()的参数(整理栈)
现在,程序执行流已经重新返回main(函数中。
00401041 ADD ESP,8
上面语句使用ADD命令将ESP加上8,为什么突然要把ESP加上8呢?请看图17-11中的栈窗口,地址12FF30与12FF34处存储的是传递给函数add()的参数a与b。函数add(执行完毕后,就不再需要参数a与b了,所以要把ESP加上8,将它们从栈中清理掉(参数a与b都是长整型,各占4个字节,合起来共8个字节)。
提示
请记住,调用add()函数之前先使用PUSH命令把参数a、b压入栈。
执行完上述命令后,栈内情况如图7-12所示。
提示
被调函数执行完毕后,函数的调用者(Caller) 负责清理存储在栈中的参数,这种方式称为cdecl方式;反之,被调用者(Callee)负责清理保存在栈中的参数,这种方式称为stdcall 方式。这些函数调用规则统称为调用约定( Calling Convention),这在程序开发与分析中是一个非常重要的概念,第10章将进一步讲解相关内容。
7.2.10调用printf()函数
StackFrame.cpp源文件中用于打印输出运算结果的语句如下所示。
printf("%d\n", add(a, b));
调用print()函数的汇编代码如下所示。
00401044 PUSH EAX
函数add()的返回值
00401044 PUSH EAX ;函数add()的返回值
00401045 PUSH 0040B384 ;"%d\n"
0040104A CALL 00401067 ;printf()
0040104F ADD ESP,8
地址401044处的EAX寄存器中存储着函数add()的返回值,它是执行加法运算后的结果值3。地址40104A处的CALL 401067命令中调用的是401067地址处的函数,它是一-个C标准库函数printf(), 所有C标准库函数都由Visual C++编写而成(其中包含着数量庞大的函数,在此不详细介绍)。由于上面的printf()函数有2个参数,大小为8个字节( 32位寄存器+32位常量=64位=8字节),所以在40104F地址处使用ADD命令,将ESP加上8个字节,把函数的参数从栈中删除。函数printf()执行完毕并通过ADD命令删除参数后,栈内情形如图7-12所示。
7.2.11设置返回值
StackFrame.cpp中设置返回值的语句如下。
return 0;
main()函数使用该语句设置返回值(0)。
00401052 XOR EAX, EAX
XOR命令用来进行Exclusive OR bit (异或)运算,其特点为“2 个相同的值进行XOR运算,结果为0”。XOR命令比MOV EAX,0命令执行速度快,常用于寄存器的初始化操作。
提示
利用相同的值连续执行2次XOR运算即变为原值,这个特征被大量应用于编码与解码。后面的代码分析中我们会经常看到XOR命令。
7.2.12删除栈帧&main()函数终止
本节内容对应StackFrame.cpp中的如下代码。
return 0;
}
最终主函数终止执行,同add()函数一样,其返回前要先从栈中删除与其对应的栈帧。
00401054 MOV ESP , EBP
00401056 POP EBP
执行完上面2条命令后, main()函数的栈帧即被删除,且其局部变量a、b也不再有效。执行至此,栈内情形如图7-13所示。
图7-13与main(函数开始执行时栈内情形( 请参考图7-2)是完全-样的。
00401057 RETN
执行完上面命令后,主函数执行完毕并返回,程序执行流跳转到返回地址处( 401250),该地址指向Visual C++的启动函数区域。随后执行进程终止代码。请各位自行查看该过程。
请大家阅读上面内容的同时动手调试,认真观察栈的行为动作,相信各位的调试水平会得到很大提高。
7.3设 置OllyDbg选项
OllyDbg提供了丰富多样的选项,是个名副其实的动态调试工具。下面看一下其中显示代码窗口反汇编代码的选项。
7.3.1 Disasm 选项
打开OllyDbg的Debugging options对话框(快捷键Alt+O ),如图7-14所示。
图7-14OllyDbg的Debugging options对话框-Disasm选项卡
在Debugging options对话框中选择Disasm选项卡后,分别点击“Show default segments”与“Always show size of memory operands”左侧的复选框,取消选择。观察代码窗口可以发现,原来代码中显示的默认段与内存大小都不再显示了,如图7-15所示。
提示——
如图7-15所示,401026与40102D地址处的命令中仍然保留着“DWORD PTR”。若将其删除,解析后面常量1、2的尺寸时就会产生歧义,无法确认它们是BYTE,还是WORD、DWORD。而地址401034处的命令中,原来显示的“DWORD PTR”字符串被省略了,这是因为参与运算的寄存器EAX大小明确,为4个字节。
7.3.2 Analysis1选项
再介绍另一个比较有用的选项。
选择Analysis 1选项卡,点击“SHOW ARGs and LOCALs in procedures”左侧的复选框,启用该选项,如图7-16所示。
如图7-17所示,原来以EBP表示的函数局部变量、参数分别表示成了LOCAL.1、ARG.1的形式。该选项为代码提供了非常好的可读性,有助于调试代码。
启用该选项后,OllyDbg会直接分析函数的栈帧,然后把局部变量的个数、参数的个数等显示在代码窗口。启用该选项后,虽然偶尔会出现显示错误,但它的显示非常直观,故常常能为代码调试提供帮助。现在我们连它的运行原理也学习了,真可谓锦上添花。