本文为看雪论坛优秀文章
看雪论坛作者ID:顾忧
栈,通常用于存储局部变量、传递函数参数、保存函数返回地址等。
一
栈内存在进程中的作用
1、暂时保存函数内的局部变量
2、调用函数时传递参数
3、保存函数返回后的地址
二
栈的特征
一个进程中,栈顶指针(ESP)初始状态指向栈底端、执行push命令将数据压入栈时,栈顶指针就会上移到栈顶端。执行pop命令从栈中弹出数据时,若栈为空,则栈顶指针就会重新移动到栈底端。
栈是由下往上扩展的,栈顶顶针在初始状态下指向栈底,这就是栈的特征。
三
栈操作示例
1、od打开stack.exe文件,从中看到寄存器窗口中栈顶指针ESP的值为0012FFC4,栈窗口中最上面的地址(即栈顶地址)也为0012FFC4,右边为0012FFC4对应的值。
2、在代码窗口按下F7(Step into),自己执行地址00401000处的PUSH 100指令。
可以看到ESP的值减少了四个字节,变成了0012FFC0,并且栈窗口中的栈顶指定也指向了0012FFC0地址,且值也变成100。
再次执行pop EAX,ESP的值增加4个字节,变为0012FFC4。OD状态变成最开始的状态。
栈帧在程序中用于声明局部变量、调用函数。理解栈帧主要用来掌握保存在其中的函数参数与局部变量。
简而言之,栈帧就是利用EBP(栈帧指针,注意不是ESP)寄存器访问栈内局部变量、参数、函数返回地址等的手段。ESP寄存器承担着栈顶指针的作用,而EBP寄存器则负责行驶栈帧指针的职能。
在程序运行中,ESP寄存器的值随时会变化,访问栈中函数的局部变量、参数时,若以ESP的值为基准编写程序会十分困难,并且也很难使CPU引用到准确的地址。
所以,调用某函数时,先要把用作基准点(函数起始地址)的ESP值保存到EBP,并维持在函数内部。这样,无论ESP的值如何变化,以EBP的值为基准能够安全访问到相关函数的局部变量、参数、返回地址。
栈帧对应的汇编代码:
示例:stackframe.cpp源码
在main()函数的起始地址(40120)处下一个断点,然后F9运行程序,程序运行到断点处停止。
在开始调试前,记录一下栈的状态。如下,此时ESP的值为0012FF7C,EBP的值为0012FFC0。
注意,地址00401250保存在ESP(0012FF7C)中,它是main()函数执行完毕后要返回的地址。
main()函数一开始运行就生成与其对应的函数。
PUSH是一条压栈指令,下面这一句的意思是将EBP的值压入栈。main()函数中,EBP为栈帧指针,用来把EBP之前的值备份到栈中(main()函数执行完毕后,在返回之前,该值会再次恢复)。同时执行,这一条PUSH指令后,ESP的值变成12 FF78(0012FF7C-4)
同时执行这一条PUSH指令后,ESP的值变成12 FF78(0012FF7C-4)。
MOV是一条数据传输指令,这条语句的意思是将ESP的值传送到EBP。也就是说,从这条指令开始,EBP就有了与现在ESP相同的值,并且直到main()函数执行完毕,EBP的值始终保持不变。
换言之,可以通过EBP安全的访问到存储在栈中的函数参数与局部变量。执行完PUSH与MOV两条指令,栈帧就生成了,即EBP被设置好了。
在OD的栈窗口,右击依次选择Address-Relative to EBP。
现在的栈内情况被转换成相对于EBP的偏移后,能更直观地观察到栈内情况。如下,在栈窗口中可以看到EBP的位置。
2、设置局部变量
开始分析源文件中的变量声明与赋值语句。下面的语句用于在栈中为局部变量(a,b)分配空间
执行该指令之后,ESP由原来的12 FF78变成12 FF70。减去这8个字节,是为局部变量(a和b)开辟空间,以便将它们保存在栈中。由于局部变量a与b都是long型(长整型),它们分别占据4个字节,因此需要开辟8个字节的空间。
为函数变量开辟好栈空间后,在main()函数内部,无论ESP的值如何变化,变量a与b的栈空间都不会收到损坏。由于EBP的值在函数内部是固定不变的,所以可以以它为基准来访问函数的局部变量了。
接下来看如下代码,dword ptr ss:[ebp-0x4]可以理解为c语言中的指针。
汇编语言与C语言的指针语句格式:
提示:DWORD PTR SS:[EBP-4]语句中,SS是Stack Segment的缩写,表示栈段。由于Windows中使用的段内存模型,使用时需要指出相关内存属于哪一个区段。起始,32位的Windows OS中,SS、DS、ES的值皆为0,所以采用这种方式附上区段并没有什么意义。
因为EBP与ESP是指向栈的寄存器,所以添加上了SS寄存器。DWORD PTR SS:等字符串可以通过OD的相关选项来隐藏。
再次分析一下刚刚的两条MOV指令,它们的含义是把数据1与数据2分别保存在[EBP-4]与[EBP-8]中,即[EBP-4]代表局部变量a,[EBP-8]代表局部变量b。执行完两条指令后,栈内情况如下图,可以看到其对应的值分别为1,2。
3、add()函数参数传递与调用
代码如下:
请看下面五行汇编代码,它描述了add()函数的整个过程。
地址0040103C处为"CALL 40100" 命令,用于调用40100处的函数,而40100处的函数为add()函数。
函数add()接收a、b两个长整型参数,所以在调用add()之前需要把两个参数压入栈,地址401034—40103B之间的指令就是这个作用。这一过程中需要注意的是,参数入栈的顺利与C语言源码中的参数顺序相反(这种现象被称为函数参数的逆向存储)。
换言之,变量B首先入栈,接着变量a再入栈。执行完401034—40103B的指令后,栈内情况如下:
执行CALL命令进入被调用函数之前,CPU会把函数的返回值压入栈,用做函数执行完毕后的返回地址。
从上图可以看出调用add()函数之后,下一条命令的地址为401041。函数执行完毕后就会跳转到这个地址。这个地址被称为函数的返回地址。执行CALL命令后的栈窗口如下:
接下来执行add()函数的汇编指令。指令如下:
指令先把EBP值(main()函数的基址指针)保存到栈中,再把当前的ESP的值存储到EBP当中。执行完,栈窗口中的情况如下:
4、设置add()函数的局部变量(x,y)
源代码如下:
声明了两个长整型的局部变量(x,y),并用两个形式参数(a,b)分别为它们赋初始值。
执行完下面的指令后,[EBP+8]与[EBP+C]分别执行参数a与b,而[EBP-8]与[EBP-4]分别指向add()函数的两个局部变量x,y。栈窗口如下:
5、ADD运算
源码:
汇编指令:
小知识:
EAX是一种通用寄存器,在算术运算中存储输入输出数据,为函数提供返回值。若向EAX输入某个值,该值就会原封不动地返回。执行过程中,栈内状态不变。
6、删除函数add()的栈帧以及函数执行完成返回
把EBP值赋给ESP,与前文的MOV EBP,ESP指令对应。把存储到EBP的值恢复到ESP中。
提示:
执行完MOV ESP,EBP之后,地址401003处的指令不在有效。POP EBP用于恢复函数add()开始执行时备份到栈中的EBP值,可以看到ESP的值为0012FF64,对应的值为401041是执行call 401000命令时cpu存储到栈中的返回地址。
EBP恢复为0012FF78,是当初main()函数的EBP值。
执行下面的语句后,存储在栈中的返回地址被调用。
栈中地址如下:此时栈中的地址与返回到调用add()函数之前的状态。
7、从栈中删除函数add()的参数
执行如下命令,将ESP+8。
ESP加8的原因可以从上一张图看出,地址12FF68与12FF6C处存储的是传递给函数add()的参数a与b。函数add()执行后,不需要参数a与b了,所以ESP+8将它们从栈中清理掉。
提示:
1、调用add()函数之前先使用push指令把参数a,b压入栈;
2、被调函数执行完毕后,函数的调用者(Caller)负责清理掉存储在栈中的参数,这种方式称为cdecl方式;
反之,被调用者(Caller)负责清理保存在栈中的参数,这种方式被称为stdcall方式。
这些函数调用规则统称为调用约定。
8、调用printf()函数
汇编指令如下:
地址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 命令删除参数后。栈内情况如下:
执行如下指令来设置返回值:
常用于寄存器的初始化操作,执行下面两条命令来对栈帧进行删除以及函数终止。
执行后栈内地址如下图:
执行完下面的命令,主函数执行完毕并返回,程序执行跳转到返回地址(401250)。该地址执行visual C++的启动函数区域。
00401056 retn
参考:《逆向工程核心原理》
看雪ID:顾忧
https://bbs.pediy.com/user-home-890008.htm
# 往期推荐
球分享
球点赞
球在看
点击“阅读原文”,了解更多!