栈的特点:
1,先进后出 (好比高高的蒸笼一层一层的,放在最下面的蒸笼的包子最后才能拿出来),比如进123,出321 2,具有记忆功能,栈的特点是先进栈的后出栈,后进栈的先出栈,所以你对一个栈进行出栈操作,出来的元素肯定是你最后存入栈中的元素,所以栈有记忆功能。
理解调用栈最重要的两点是:栈的结构,EBP寄存器的作用。
首先要认识到这样两个事实:
1、一个函数调用动作可分解为:零到多个PUSH指令(用于参数入栈),一个CALL指令。CALL指令内部其实还暗含了一个将返回地址(即CALL指令下一条指令的地址)压栈的动作。
2、几乎所有本地编译器都会在每个函数体之前插入类似如下指令:PUSH EBP; MOV EBP ESP;
即,在程式执行到一个函数的真正函数体时,已有以下数据顺序入栈:参数,返回地址,EBP。
由此得到类似如下的栈结构(参数入栈顺序跟调用方式有关,这里以C语言默认的CDECL为例):
+| (栈底方向,高位地址) |
| .................... |
| .................... |
| 参数3 |
| 参数2 |
| 参数1 |
| 返回地址 |
-| 上一层[EBP] |
“PUSH EBP”“MOV EBP ESP”这两条指令实在大有深意:首先将EBP入栈,然后将栈顶指针ESP赋值给EBP。“MOV EBP ESP”这条指令表面上看是用ESP把EBP原来的值覆盖了,其实不然??因为给EBP赋值之前,原EBP值已被压栈(位于栈顶),而新的EBP又恰恰指向栈顶。
此时EBP寄存器就已处于一个非常重要的地位,该寄存器中存储着栈中的一个地址(原EBP入栈后的栈顶),从该地址为基准,向上(栈底方向)能获取返回地址、参数值,向下(栈顶方向)能获取函数局部变量值,而该地址处又存储着上一层函数调用时的EBP值!
一般而言,ss:[ebp+4]处为返回地址,ss:[ebp+8]处为第一个参数值(最后一个入栈的参数值,此处假设其占用4字节内存),ss:[ebp-4]处为第一个局部变量,ss:[ebp]处为上一层EBP值。
由于EBP中的地址处总是“上一层函数调用时的EBP值”,而在每一层函数调用中,都能通过当时的EBP值“向上(栈底方向)能获取返回地址、参数值,向下(栈顶方向)能获取函数局部变量值”。
如此形成递归,直至到达栈底。这就是函数调用栈。
编译器对EBP的使用实在太精妙了。
从当前EBP出发,逐层向上找到所有的EBP是非常容易的:
unsigned int _ebp;
__asm _ebp, ebp;
while (not stack bottom)
{
//...
_ebp = *(unsigned int*)_ebp;
}
如果要写一个简单的调试器的话,注意需在被调试进程(而非当前进程??调试器进程)中读取内存数据。
首先看如下图-1演示的用c语言编写的例子程序。
┌————————————————————┐
│ 1. void fun() │
│ 2. { │
│ 3. printf(“Hello World\n”); │
│ 4. } │
│ 5. int main() │
│ 6. { │
│ 7. int *i; │
│ 8. i = (int*)&i + 2; │
│ 9. *i = fun; │
│ 10. } │
│ 图-1 │
└————————————————————┘
如果我跟你说程序运行的结果(我们以下的讨论将在IA32-linux-gcc3.4环境中进行)是将打印出“Helllo World”,你可能会不相信。那么这一个“诡异版HelloWorld程序”究竟是如何做到的呢?
这就涉及到了栈的问题,不过首先得补充一些理论。
我们知道:函数中的局部变量是存放于栈中的。栈中除了局部变量外,还包括函数参数、返回地址等一系列内容。其存储过程如下:当执行到某一函数时,首先将函数的各个参数依次压入栈,随后是函数的返回地址(即程序中产生该函数调用处的下一行代码的地址),最后压入栈内的是函数的局部变量。综上所述,一个函数运行时的栈理论上具有如下结构:
┌—————————————————————┐
│高地址 │ │ │
│ │ …… │ │
│ ├——————┤ │
│ │ 参数 │ │
│ ├——————┤ │
│ │ 返回地址 │ │
│ ├——————┤ │
│ │ 局部变量 │ │
│ ├——————┤←—— 栈顶 │
│ │ │ │
│低地址 │ │ │
│ │ │ │
│ 图-2 │
└—————————————————————┘
但是现实中栈的结构还受到编译器的限制,从而出现了一些细节上的变化,以IA32体系结构下的例子补充一下我们的理论,就得到了实际应用中的栈结构:
┌—————————————————————┐
│高地址 │ │ │
│ │ │ │
│ ├——————┤ │
│ │ 局部变量n │ │ │
│ ├——————┤ │ │
│ │ … │ │栈 │
│ ├——————┤ │增 │
│ │ 参数1 │ │长 │
│ ├——————┤ │方 │
│ │ 返回地址 │ │向 │
│ ├——————┤ │ │
│ │ OLD Ebp │ │ │
│ ├——————┤ │ │
│ │ 局部变量1 │ │ │
│ ├——————┤ ▼ │
│ │ … │ │
│ ├——————┤ │
│ │ 局部变量n │ │
│ ├——————┤←—— 栈顶 │
│ │ │ │
│低地址 │ │ │
│ │ │ │
│ 图-3 │
└—————————————————————┘
作为补充,我们就图-3给出如下的一些解释:
1、我们不用理解“Old Ebp”项是什么,这也不会影响我们的分析,只需要知道在IA32架构中它是一个四字节的数值就够了。
2、函数的各个参数入栈顺序与具体的编译时约定有关,对于标准C而言参数是从右到左依次入栈,至于局部变量的入栈顺序在gcc3.4中是按照声明顺序一次入栈。
3、进入函数之后,栈顶位置指向的就是最后声明的局部变量的位置。
4、结合c语言的语法,我们可以知道函数可以没有参数和局部变量,所以图-3中的“参数”和“局部变量”不是函数栈结构的必需部分。
好了,我们现在已经可以开始结合图-1的例子程序进行叙述了。
程序中定义了一个全局函数fun,没有参数,简简单单一个函数。主函数中定义了一个int*型变量i,然后是做了一系列赋值操作。所以刚刚进入主函数的一刻对应的栈就有如下结构:
┌—————————————————————┐
│高地址 │ │ │
│ │ …… │ │
│ ├——————┤ │
│ │ 返回地址 │ │
│ ├——————┤ │
│ │ OLD Ebp │ │
│ ├——————┤ │
│ │ (int*)i │ │
│ ├——————┤←—— 栈顶 │
│ │ │ │
│低地址 │ │ │
│ │ │ │
│ 图-4 │
└—————————————————————┘
准确地说这是主函数的栈结构,fun函数只是我们想为看结果而补充的一段代码而已。主函数有着与普通函数一致的理论上的栈结构,不同的是它的栈结构中有些内容是系统给它的,即图-4中用省略号代替的部分,而具体细节就与体系结构相关了,还得有心人自己去研究。
我们从图-4中可以看到,此时栈顶元素就是指针i,紧接着它的就是“Old Ebp”和“返回地址”,而“返回地址”就是主函数结束后系统规定执行的指令地址。我想说到这有聪明的读者就应该想到程序肯定是将“返回地址”做了修改,从而执行了fun函数。果真如此!请看:
i = (int*)&i + 2;
它将i的地址(就本程序而言i的地址相当于是栈顶位置)加2之后再赋给i。不过这里加2可不是简简单单的一个“地址+2”,而是一个(int*)类型的加法,实际将按照字节大小换算成“地址+2×sizeof(int*)”,再加上(int*)类型的大小是4所以最终编译的结果将是加8(或者说是两个“int*”大小)。怎么样?很拗口吧,不单单你这样想,我也是。这是高级语言的优势,也是它的劣势:我们节省了大量繁琐的计算,可同时也丧失了对程序底层的深入理解和自由控制(如果你不深究个中原委的话)。那么此时指针i控制的这个地址指向了哪里呢?我们还是从图上看来得明白:
┌—————————————————————┐
│高地址 │ │ │
│ │ …… │ │
│ ├——————┤ │
│ 4字节{ │ 返回地址 │ │
│ ├——————┤←—— │
│ 4字节{ │ OLD Ebp │ 栈顶+8 │
│ ├——————┤ │
│ 4字节{ │ (int*)i │ │
│ ├——————┤←—— 栈顶 │
│ │ │ │
│低地址 │ │ │
│ │ │ │
│ 图-5 │
└—————————————————————┘
如图-5,指针i的地址(也相当于栈顶)加上八个字节,我们一查,不就是“返回地址”的地址吗?这是指针i刚好就指向了返回地址。好啊,既然得到了,还等什么,直接:
*i = fun;
一下子我们就把“‘返回地址’的地址中的内容”(也就是返回地址)修改成了函数fun的地址。这样我们完成了所有的准备工作,剩下的内容自然是:
Main函数结束后,转而取出自己栈内的“返回地址”(当然是已经被我们修改过的了),跳到那里去执行,这时就进入了函数fun中,一切顺理成章,我们的分析结束。
不过我在这里刻意隐瞒了一个情况:即运行例子程序时,除了正常输出之外,还包含了错误提示。因为我们修改的是main函数的返回地址,这是系统的东西,自然要有纰漏从而出现错误。至于如何能正常退出,还是看看本文开头介绍的陈硕的文章好了。
事实上本文的目的有两个:一来为今年选修“unix高级技术”的同学们介绍一点先行知识,这有助于为解决某些上机实验提供思路;二者也是想说一些关于代码安全的问题。
我们从上可以看到基于栈行为我们甚至可以改变程序的正常执行过程。当然没有那个人会傻到写一个如我们的例子程序这样的东西被别人攻击,可实际上类似的漏洞是隐蔽的,并且大量存在着。“基于缓冲区的溢出攻击”一直以来就是包括微软公司在内的软件公司及个人在研究和防范的要点。鉴于这个课题的广泛,我不想展开说,只是想给出两条用得上,简单有效的经验以资借鉴:
(1)不要为接受的数据预留相对过小的缓冲区,同时对传入的数据进行越界检查。
(2)不要定义过大的局部缓冲区,这里说的“大”是“很大”的意思,一般是以超过系统页大小为限。解决之道是定义一个全局的缓冲区来代替之。
与本文相关的论题应该说是已经有了很成熟的理论,只不过我们还有很长大的路要走
才能和时代的最前沿齐头并进,希望能与有兴趣的同学有所交流,共同进步。