C和C++安全编码(原书第2版)
上QQ阅读APP看书,第一时间看更新

2.3.5 栈管理

栈通过维护自动的进程状态数据来支持程序的执行。例如,如果一个程序的主例程(main routine)调用了a()函数,a()又调用了b()函数,则b()函数最终会将控制权返回给a(),a()则会接着将控制权返回给main()函数,如图2.6所示。

图2.6 栈管理

要做到将程序控制返回到正确的位置,就需要将返回地址的序列存储起来。栈很适合做这项工作,因为动态的LIFO数据结构在内存限制允许的情况下可以支持任意层数的嵌套。当调用一个子例程时,调用例程中的下一条将要执行的指令地址被压入栈中。当被调用的子例程返回时,预先存储的返回地址从栈中弹出,程序的执行点就跳到该指定位置上,如图2.7所示。栈维护的这些信息反映了任何时刻进程的执行状态。

图2.7 调用一个子例程

除了返回地址以外,栈还被用来保存子例程的参数以及局部(或自动)变量。帧(frame)指由函数调用引发的压入栈的数据。当前帧的地址被存储到帧或者基址寄存器中。在x86-32架构上,扩展基址指针(extended base pointer,ebp)寄存器就是用作此目的。帧指针在栈中是一个定点的引用。当调用一个子例程时,调用端函数的帧指针同样被压入栈,这样当被调用子例程退出时,帧指针能被重新恢复。

Intel指令有两种符号,微软使用Intel符号。


mov eax, 4 # Intel Notation

GCC使用AT&T符号


mov $4, %eax # AT&T Notation

这两种指令都把直接数4移动到eax寄存器。例2.4展示了调用foo(MyInt.MyStrPtr)所得的使用Intel符号表示的x86-32反汇编形式。

例2.4 使用Intel符号表示的反汇编


01  void foo(int, char *); // 
函数原形 
02 
03  int main(void) { 
04    int MyInt=1; // 
栈变量位于 ebp-8 
05    char *MyStrPtr="MyString"; //
栈变量位于ebp-4 
06    /* ... */ 
07    foo(MyInt, MyStrPtr); // 
调用 foo 
函数 
08      mov  eax, [ebp-4] 
09      push eax            # 
把第2
个参数压入栈 
10      mov  ecx, [ebp-8] 
11      push ecx            # 
把第1
个参数压入栈 
12      call foo            # 
把返回地址压入栈 
13                          # 
并跳到那个地址 
14      add  esp, 8 
15    /* ... */ 
16  } 

调用由三个步骤组成,如下所示。

1.第二个参数被移到eax寄存器中,接着被压入栈(第8行和第9行)。注意mov指令是如何利用ebp寄存器来引用参数以及栈中的局部变量的。

2.第一个参数被移到ecx寄存器中,接着被压入栈(第10行和第11行)。

3.call指令将一个返回地址(call指令下一条指令的地址)压入栈,然后将控制转移到foo()函数(第12行)。

指令指针(eip)指向将要执行的下一条指令。当执行连续指令时,它会按照每个指令的大小自动递增,从而使CPU按顺序执行序列中的下一条指令。通常情况下,不能直接修改eip,相反,它必须通过诸如跳转(jump),调用(call)和返回(return)指令间接修改。

当控制返回到返回地址时,栈指针(SP)被递增8个字节(第14行)。(在x86-32中,栈指针被命名为esp,e前缀代表“扩展”,用来区分32位栈指针与16位栈指针)。栈指针指向栈的顶端。栈增长的方向取决于具体架构上的pop和push指令的实现(换言之,取决于对栈指针是递增还是递减操作)。对于很多流行的架构,包括x86、SPARC以及MIPS处理器在内,栈向低地址方向增长。在具有这些架构的机器上,递增栈指针就意味着从栈中弹出数据。

foo()函数开头。函数开头中包含一个函数调用后所执行的指令。foo()函数的函数开头如下所示:


1  void foo(int i, char *name) {
2    char LocalChar[24];
3    int LocalInt;
4      push ebp       # 
保存帧指针
5      mov ebp, esp   # 
子例程的帧指针被设置为
6                     # 
当前栈指针.
7      sub esp, 28    # 
为局部变量分配空间.
8    /* ... */ 

push指令将保存有指向调用者栈帧指针的ebp寄存器压入栈。mov指令将函数的帧指针(ebp寄存器)指向当前栈指针。最后,函数在栈上为局部变量分配了总共28个字节的空间(Local Char占24字节,Local Int占4字节)。

函数开头部分执行之后,foo()的栈帧如表2.2所示。在x86上,栈向内存低地址增长。

foo()的函数结尾。函数结尾包含将一个函数返回给调用者所执行的指令。下面是从foo()函数返回的函数结尾:


1  /* ... */
2   return;
3     mov  esp, ebp   # 
恢复栈指针
4     pop  ebp        # 
恢复帧指针  
5     ret             # 
将返回地址从栈弹出
6                     # 
并把控制移交给那个位置
7  }

表2.2 函数开头部分执行之后,foo()的栈帧

这些返回序列可以看成是前面的函数开头的逆序执行形式。mov指令将栈指针(esp)从帧指针(ebp)中恢复。pop指令将调用者的帧指针从栈中恢复。ret指令从栈中弹出调用函数中的返回地址,并且将控制转移到该位置。