11 Mar 2013
关于栈空间的思考一则
年前把 BLOG 搭好后,过了个年,就把这事给搁置了。从今天重新捡起来,准备坚持每周至少写一篇。写作主题是自己的所思所想,遇到问题的解决办法及思考。不转贴,不拾人牙慧,不做网上资料汇总。
如果文章有表述不正确的地方,请不吝指教。
一时半会没有想到写什么好。就写最近遇到的一个问题的思考。
前几天,有个人问我,他在 RHEL 5.8 32bit 上写了一个很简单的 C 程序,结果很奇怪。代码如下,为了说明问题,我稍做了改变。
--------------------------------------main.c
#include <sdtio.h>
int main()
{
int a = 1, b = 2;
char s[5] = "test";
printf("a=%d b=%d s=%s\n", a, b, s);
scanf("%s", s);
printf("a=%d b=%d s=%s\n", a, b, s);
return 0;
}
--------------------------------------main.c
RHEL 5.8 上的 GCC 版本是 4.1.2,编译后运行,如果输入的是 abcd,输出结果正常,如果输入 abcde,a 的值会变成 0。如下所示。
$ ./a.out
a=1 b=2 s=test
abcd
a=1 b=2 s=abcd
$ ./a.out
a=1 b=2 s=test
abcde
a=0 b=2 s=abcde
于是我跟他说,你再多输几个字符的话,至少要输上 9 个字符,b 的值也会变的。证实一下。
$ ./a.out
a=1 b=2 s=test
abcdexxxxy
a=2021161080 b=121 s=abcdexxxxy
如果我们看一下程序运行后栈中数据的分布,一切就都恍然大悟了。
输入 abcde 后 输入 adbcdexxxxy 后
----------------------------------------------------------------
高地址| EBP | ==> EBP | EBP | | EBP |
----------- ----------- -----------
| | | | | |
----------- ----------- -----------
... ... ...
----------- ----------- -----------
| 0x00 | | 0x00 | | 0x00 |
----------- ----------- -----------
| 0x00 | | 0x00 | | 0x00 |
----------- ----------- -----------
| 0x00 | | 0x00 | | '\0' |
----------- ----------- -----------
| 0x02 | ==> b | 0x02 | | 'y' |
----------- ----------- -----------
| 0x00 | | 0x00 | | 'x' |
----------- ----------- -----------
| 0x00 | | 0x00 | | 'x' |
----------- ----------- -----------
| 0x00 | | 0x00 | | 'x' |
----------- ----------- -----------
| 0x01 | ==> a | '\0' | | 'x' |
----------- ----------- -----------
| '\0' | | 'e' | | 'e' |
----------- ----------- -----------
| 't' | | 'd' | | 'd' |
----------- ----------- -----------
| 's' | | 'c' | | 'c' |
----------- ----------- -----------
| 'e' | | 'b' | | 'b' |
----------- ----------- -----------
| 't' | ==> s | 'a' | | 'a' |
--------------- --------------- ---------------
... ... ...
----------- ----------- -----------
低地址| | ==> ESP | | | |
----------- ----------- -----------
- 当输入 abcde 时,最后的结束符 ‘\0’ 把类型为 int 的变量 a 的最低字节 (因为此时数据存储是小端模式,低字节存在低地址) 所在的内存空间覆盖,导致 a 值变为 0;
- 当输入 abcdexxxxy 时,a 的 4 个字节全部被覆盖,b 的最低两个字节被 ‘y’ 和 ‘\0’ 覆盖,而 ‘y’ 对应的 ascii 码值是 121,这也就解释了为什么 b 值变成了 121。
(2015-1-18 补,今日重新回来看这篇 blog,发现当时写这篇 blog 的时候还是没有很深刻地理解所谓的栈溢出漏洞攻击。上面这种情况不正是栈溢出的攻击手段吗?如果我再输入更多的字符,使字符的长度长到能够覆盖栈中存放返回地址的内存空间,而且使覆盖返回地址内存空间的值是一个精心构造的值,比如是一个指向恶意代码的地址,不就实现攻击吗)。
以上栈的数据的分布情况可以从上面的程序的运行结果推断出来,更直接的办法是查看汇编代码 (gcc -S main.c 生成汇编代码 main.s):
...
.LC0:
.string "test"
...
pushl %ebp ==> 常规操作,保存原来的 ebp
movl %esp, %ebp ==> ebp = esp
pushl %ecx
subl $36, %esp ==> 分配了 36 字节的栈空间,esp = ebp - 4(ecx) - 36
movl $1, -12(%ebp) ==> &a = ebp - 12
movl $2, -8(%ebp) ==> &b = ebp - 8
movl .LC0, %eax
movl %eax, -17(%ebp) ==> &s[0] = ebp - 17
movzbl .LC0+4, %eax ==> 这两步是将 "test" 的值从静态存储区拷贝到栈中
movb %al, -13(%ebp)
leal -17(%ebp), %eax
movl %eax, 12(%esp) ==> 开始为 printf 函数传参,参数 4 地址:esp + 12
movl -8(%ebp), %eax
movl %eax, 8(%esp) ==> 参数 3 地址:esp + 8
movl -12(%ebp), %eax
movl %eax, 4(%esp) ==> 参数 2 址址:esp + 4
movl $.LC1, (%esp) ==> 参数 1 地址:esp
call printf
...
从汇编代码可以看出,程序分配了 36 字节的栈空间,高地址的 13 字节 (a,b,s 共 13 字节) 用于存储自动变量,低地址的 23 字节用于函数传参。而实际传参只用了 16 个字节,两片空间并未连续,中间产生了 7 字节的空隙 (应该是为了对齐吧)。
(2013/04/22补:实际上这一栈帧的空间还应包括 返回地址的 4 字节,pushl %ebp 中的 %ebp 的 4 字节,pushl %ecx 中的 %ecx 的 4 字节,因此总的空间是 36+12=48 字节。此处我一直有一个疑问,就是为什么 GCC 会分配多余的空间。《深入了解计算机系统》第一版的修订版在第三章(第 151 页处)的脚注中也写道:”不清楚为什么 C 编译器会为这个函数在栈中分配这么多的未使用存储”,后来又看第二版的时候,就有答案了,在第二版的第 154 页处增加了一段话,专门解释了这个问题:”为什么 GCC 分配从不使用的空间…GCC 坚持一个 x86 编程指导方针,也就是一个函数使用的所有栈空间必须是 16 字节的整数倍。包括保存 ebp 的 4 字节和返回值的 4 个字节…严格对齐”,恍然大悟。)
我以前知道自动变量是分配在栈上,至于内部的分配细节没有探究过。而这次的探究,可以说是推翻了以前我对栈空间的认识。以前我认为这个栈空间,顾名思议嘛,栈,就跟数据结构里的栈一样,只允许 push 和 pop 两种操作,一切都只能在栈顶操作。而从上面的汇编代码来看,函数内部完全是把这部分空间当作线性数组使用,进行随机存取。
所以,是不是可以这么理解栈空间,宏观上它还是先进后出的栈,但微观上,它是可以进行随机存取的一片顺序存储空间。
另外还有一个疑问是,我明明定义的顺序是 a,b,s,程序实际分配的内存空间顺序 (地址由低到高) 却是 s,a,b 呢? 我只能理解为取决于编译器的自己的策略。我在 Ubuntu 12.10 上使用 GCC 4.6.3 编译的结果是,无论 a,b,s 的定义顺序是什么,最终分配的空间顺序是 a,b 在低地址,s 在高地址。在这种情况下,如果 s 输入时越界,就会使程序马上崩溃,这样容易使问题暴露得更早。
(看了《深入理解计算机系统》,我的理解是,这应该是编译器对于栈溢出的一种保护机制)
Baurine