你真的了解栈溢出么?
记得之前看过一篇文章说,最好查的bug是语法错误,因为编译器会告诉你,最不好查的bug是栈溢出,因为啥,因为不仅编译器不会告诉你,连你自己有可能都找不到原因出在哪。
经过了一段时间的摸索,算是基本搞清楚了栈溢出的原理,写下来以防日后出现问题无从下手。
前言
开发过单片机的同学应该不陌生这个名词,一般我们也说堆栈,其实这里有两个意思:一般我们说堆栈其实指的就是帧本身,而说堆指的就是堆。这是两个不同的分区。便于理解给出一张典型的C语言在linux系统下的占区图:
可以看出,对于Linux系统下的,存储空间的分配有着较为层次清晰的分层。单片机大概也遵循这个分区架构。
二进制代码以及常量(CONST修饰)以及全局变量在最底层,存储空间最靠前的部分
然后是堆区,堆区向上增长,我们常用到的molloc()、free()等函数操作的就是这个区,这也是芯片系统中唯一可以让程序员通过代码操作的一片存储空间
再然后是动态链接库
在往上(高地址)便是栈区。 最高地址一般为操作系统内核,用户无法访问
了解了这个之后我们开始详解何为栈、栈为什么会溢出以及在代码级如何预防栈溢出,最后说一下栈溢出攻击的事情。
那么什么是栈呢
在计算机中,栈可以理解为一个特殊的容器,用户可以将数据依次放入栈中,然后再将数据按照相反的顺序从栈中取出。也就是说,先放入的数据最后才能取出,而最后放入的数据必须先取出。这称为先进后出(First In Last Out)原则。
放入数据常称为入栈或压栈(Push),取出数据常称为出栈或弹出(Pop)。
可以发现,栈底始终不动,出栈入栈只是在移动栈顶,当栈中没有数据时,栈顶和栈底重合。
这里需要注意标识栈顶和栈底的两个寄存器: ebp寄存器指向栈底,esp寄存器指向栈顶。从本质上来讲,栈是一段连续的内存,需要同时记录栈底和栈顶,才能对当前的栈进行定位。
栈溢出是怎么回事
了解了栈实际上也是一块内存后,栈溢出就好理解了。
当我们定义的数据所需要占用的内存超过了栈的大小时,就会发生栈溢出。编译器会报栈溢出错误。
如一块芯片的内存RAM大小为4k,当我们定义了一个大数组,如下:
int buf[1024*5] = {0};
很明显定义的数组超过了内存大小,这就导致了栈溢出。
预防栈溢出需要我们在编程时了解内存使用,尽可能不要定义特别大的数组,尽可能不要定义特别复杂的函数,如多个形参等。
函数调用栈
定义的数组会占用栈空间,同样,定义的函数也会占用栈空间,一个简单的例子便是函数的入栈和出栈。
举个例子:
void func(int a, int b)
{
int p =12, q = 345;
}
int main()
{
func(90, 26);
return 0;
}
函数的进栈出栈过程如下图所示:
函数进栈
1) main() 是主函数,也需要进栈,如步骤①所示。
2) 在步骤②中,执行语句func(90, 26);,先将实参 90、26 压入栈中,再将返回地址压入栈中,这些工作都由 main() 函数(调用方)完成。这个时候 ebp 的值并没有变,仅仅是改变 esp 的指向。
3) 到了步骤③,就开始执行 func() 的函数体了。首先将原来 ebp 寄存器的值压入栈中(也即图中的 old ebp),并将 esp 的值赋给 ebp,这样 ebp 就从 main() 函数的栈底指向了 func() 函数的栈底,完成了函数栈的切换。由于此时 esp 和ebp 的值相等,所以它们也就指向了同一个位置。
4) 为局部变量、返回值等预留足够的内存,如步骤④所示。由于栈内存在函数调用之前就已经分配好了,所以这里并不是真的分配内存,而是将 esp 的值减去一个整数,例如 esp - 0XC0,就是预留 0XC0 字节的内存。
5) 将 ebp、esi、edi 寄存器的值依次压入栈中。
6) 将局部变量的值放入预留好的内存中。
至此,func() 函数的活动记录就构造完成了。可以发现,在函数的实际调用过程中,形参是不存在的,不会占用内存空间,内存中只有实参,而且是在执行函数体代码之前、由调用方压入栈中的。
未初始化的局部变量的值为什么是垃圾值
为局部变量分配内存时,仅仅是将 esp 的值减去一个整数,预留出足够的空白内存,不同的编译器在不同的模式下会对这片空白内存进行不同的处理,可能会初始化为一个固定的值,也可能不进行初始化。
函数出栈
步骤⑦到⑨是函数 func() 出栈过程:
7) 函数 func() 执行完成后开始出栈,首先将 edi、esi、ebx 寄存器的值出栈。
8) 将局部变量、返回值等数据出栈时,直接将 ebp 的值赋给 esp,这样 ebp 和 esp 就指向了同一个位置。
9) 接下来将 old ebp 出栈,并赋值给现在的 ebp,此时 ebp 就指向了 func() 调用之前的位置,即 main() 活动记录的 old ebp 位置,如步骤⑨所示。
这一步很关键,保证了还原到函数调用之前的情况,这也是每次调用函数时都必须将 old ebp 压入栈中的原因。
最后根据返回地址找到下一条指令的位置,并将返回地址和实参都出栈,此时 esp 就指向了 main() 活动记录的栈顶, 这意味着 func() 完全出栈了,栈被还原到了 func() 被调用之前的情况。
函数执行完局部变量的值真的不存在了?
经过上面的分析可以发现,函数出栈只是在增加 esp 寄存器的值,使它指向上一个数据,并没有销毁之前的数据。
栈上的数据只有在后续函数继续入栈时才能被覆盖掉,这就意味着,只要时机合适,在函数外部依然能够取得局部变量的值。请看下面的代码:
#include <stdio.h>
int *p;
void func(int m, int n)
{
int a = 18, b = 100;
p = &a;
}
int main()
{
int n;
func(10, 20);
n = *p;
printf("n = %d\n", n);
return 0;
}
运行结果:
n = 18
在 func() 中,将局部变量 a 的地址赋给 p,在 main() 函数中调用 func(),函数刚刚调用结束,还没有其他函数入栈,局部变量 a 所在的内存没有被覆盖掉,所以通过语句n = *p;能够取得它的值。
参考网址:C语言中文网