Baurine's Blog

关于栈空间的思考一则

March 11, 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 输入时越界,就会使程序马上崩溃,这样容易使问题暴露得更早。
(看了《深入理解计算机系统》,我的理解是,这应该是编译器对于栈溢出的一种保护机制)


Comments