BUFFER OVERFLOW 6-The Function Stack
THE PROCESSOR’S STACK FRAME LAYOUT
处理器的栈帧布局 文章的简单翻译
一个典型的栈帧布局如下所示,在不同的操作系统中它可能会有不同的组织方式:

图1:一个函数调用的典型栈布局结构
函数参数
函数的返回地址
栈帧指针
错误处理框架
本地声明的变量
缓冲区
callee save registers
从图中我们可以看出,如果出现了缓冲区溢出漏洞,它就有可能覆盖其他被分配在内存地址高于 Buffer 的变量,比如本地变量、错误处理框架、EBP、返回地址和函数参数。
以 windows\intel 为例,当函数调用发生,数据元素会按照以下的方式存储在栈中:
- 在函数被调用之前,函数参数按照从右往左的顺序被 push 入栈中
- 函数返回地址通过 x86 的 CALL 指令被放在栈上,其值是 EIP 寄存器当前值
- 代表了 EBP 寄存器先前的值的帧指针被放在栈中
- 如果函数包括了 try/catch 或者其他的错误处理结构,编译器会在栈中含有这些框架信息
- 接下来,是声明的本地变量
- 之后 BUFFER 为临时变量存储分配空间
- 最后,the callee save registers 例如 ESI,EDI 和 EBX 如果在函数执行阶段被任何指针使用了就会被存储。对于 Linux/intel 这一步会在第 4 步之后发生。
THE PROCESSOR’S STACK OPERATION
有两个 CPU 寄存器对栈的功能性很重要,它们保存调用驻留在内存中的数据时所需的信息。在 32 位系统中被称为 ESP and EBP。
ESP(Extend Stack Pointer) 保存了栈顶地址。ESP 可以被直接或间接的修改:
直接:
1 | add esp, 0Ch |
这条指令会使当前栈缩小 12 bytes(注意图1的栈结构,add 指令使得 ESP 往高地址增加,因此栈变小)
1 | sub esp, 0Ch |
这条指令会使的当前栈增长 12 bytes(实际上,ESP 的值越大,栈越小,反之亦然)
间接:
1 | push ebp ; Save ebp, put it on the stack |
间接方式就是通过向栈中通过 PUSH 增加数据元素或者通过 POP 移除数据元素。
除了堆栈指针,它指向堆栈的顶部(较低的数字地址)以外; 拥有一个堆栈帧指针 (FP) 通常很方便,它保存指向帧内固定位置的地址。查看栈帧发现,我们可以通过提供局部变量与 ESP 的偏移量(offests)来引用它们。
但是,随着数据入栈和出栈,这些偏移量会发生变化,造成局部变量的引用不一致。因此,许多编译器使用另一个寄存器(通常称为帧指针 FP),用于引用局部变量和参数,因为参数与 FP 的距离不会随 PUSH(递减ESP指针) 和 POP(递增ESP指针) 改变。
在 Intel CPUs 中,EBP 充当了 FP 这个角色。由于栈的增长方式,实参有对于 EBP 的正偏移,本地变量有对于 EBP 的负偏移。
1 |
|
如上是一个简单的 C 语言程序,其内存布局将会如下所示:

图2:程序的内存布局
EBP 是一个指向栈底的静态指针。栈底是一个固定地址,更准确地说,EBP 寄存器包含堆栈底部的地址,作为相对于执行函数的偏移量。根据函数的任务,堆栈大小在运行时由内核动态调整。 每次调用新函数时,EBP 的旧值首先被压入堆栈,然后将 ESP 的新值移至 EBP。EBP 持有的 ESP 的这个新值成为局部变量的引用基础,这些变量需要检索分配给新函数调用的堆栈部分。
如前所述,堆栈向下增长到较低的内存地址。 这是堆栈在包括 Intel、Motorola、SPARC 和 MIPS 处理器在内的许多计算机上增长的方式。堆栈指针 (ESP) 指向堆栈上的最后一个地址,而不是堆栈顶部之后的下一个空闲可用地址。
一个函数在被调用时必须做的第一件事是保存之前的 EBP(便于它可以通过在函数退出时复制到 EIP 来恢复)。 然后它将 ESP 复制到 EBP 中以创建新的堆栈帧指针,并推进 ESP 为局部变量保留空间。这些代码被称为 procedure prolog。函数退出时必须再次清理栈,有时这被称为 procedure epilog。您可能会发现 Intel 提供了 ENTER 和 LEAVE 指令以及 Motorola 的 LINK 和 UNLINK 指令来高效地执行大部分 procedure prolog 和 procedure epilog 工作。
THE FUNCTION CALL AND STACK FRAME: SOME ANALYSIS
测试代码仍然是上文提到的简单代码,反编译后的结果如下:
1 | --- e:\test\testproga\winprocess.cpp ------------------------ |
将参数从右到左 PUSH 入堆栈。
参数被 PUSH 入堆栈,从右到左一次一个。 调用代码必须跟踪有多少字节的参数被压入堆栈,以便稍后清理它。
1
2
300401078 push 38h;character 8 is pushed on the stack at [ebp+12]
0040107A push 7 ;integer 7 is pushed on the stack at [ebp+8]函数调用
处理器将 EIP 的内容压入堆栈,它指向 CALL 指令后的第一个字节,即函数的返回地址。 完成后,调用者失去控制,被调用者负责。 这一步不会改变 EBP 寄存器,即当前堆栈帧指针。
1
0040107C call @ILT+5(MyFunc) (0040100a) ;call MyFunc(), return address:00401081, is pushed on the stack at [ebp+4]
保存并且更新 EBP
现在我们在新函数中,我们需要一个新的 EBP 指向的本地堆栈帧,所以这是通过保存当前的 EBP(属于前一个函数的帧,可能包括 main())并使 其指向栈顶。
1
200401020 push ebp ;save the previous frame pointer at [ebp+0]
00401021 mov ebp, esp ;the esp (top of the stack) becomes new ebp.The esp and ebp now are pointing to the same address. 一但 EBP 被更改,我们可以直接引用函数的参数(在第 1 步中PUSH)为 [ebp + 8]、[ebp +12] 等。注意 [ebp+0] 是旧的基指针(帧指针),而 [ebp+4] 是旧指令指针(EIP),即函数的返回地址。
为本地变量和 BUFFER 分配空间
只需将堆栈栈顶指针递减所需的空间量即可。 这总是在四字节块(32 位系统)中完成。
1
00401023 sub esp, 48h ;subtract 72 bytes for local variables & buffer,where is the esp? [ebp-72]
保存用于临时的处理寄存器(Save processor registers used for temporaries)
如果此函数将使用任何处理器寄存器,则必须先保存旧值,以免破坏调用者或其他程序使用的数据。 每个要使用的寄存器一次一个地压入堆栈,编译器必须记住它做了什么,以便以后可以展开它。
1
2
300401026 push ebx ;save, push ebx register, [ebp-76]
00401027 push esi ;save, push esi register, [ebp-80]
00401028 push edi ;save, push edi register, [ebp-84]PUSH本地变量
现在,局部变量位于作为基址的 EBP 和作为堆栈顶部的 ESP 寄存器之间的堆栈上。 如前所述,按照惯例,EBP 寄存器用作堆栈帧引用上数据的偏移量。 这意味着 [ebp-4] 指的是第一个局部变量。
1
2
3
46: int local1 = 9;
00401038 mov dword ptr [ebp-4], 9 ;move the local variable, integer 9 by pointer at [ebp-4]
7: char local2 = 'Z';
0040103F mov byte ptr [ebp-8], 5Ah ;move local variable character Z by pointer at [ebp-8],no buffer usage in this program so can start dismantling the stack函数执行的任务
此时,堆栈帧已正确设置,如图 3 所示。所有参数和局部变量引用都是 EBP 寄存器的偏移量。 在我们的程序中,没有对该函数进行操作。 所以可以开始拆解函数的堆栈了。
该函数可以自由使用在进入时已保存到堆栈中的任何 ebx、esi 和 edi 寄存器,但它不得更改堆栈指针 (EBP)。
图3:程序栈帧布局
恢复保存的寄存器
函数操作完成后,对于每个进入堆栈时保存到堆栈中的寄存器,必须以相反的顺序从堆栈中恢复。 如果保存和恢复阶段不完全匹配,堆栈将被破坏。
1
2
300401045 pop edi ;restore, pop edi register, [ebp-84]
00401046 pop esi ;restore, pop esi register, [ebp-80]
00401047 pop ebx ;restore, pop ebx register, [ebp-76]恢复旧的基指针
这个函数在进入时做的第一件事是保存调用者的 EBP 基指针,通过现在恢复它(从堆栈中弹出),我们有效地丢弃了整个本地堆栈帧并将调用者的帧放回之前的状态 .
1
200401048 mov esp, ebp ;move ebp into esp, [ebp+0]. At this moment the esp and ebp are pointing at the same address
0040104A pop ebp ;then pop the saved ebp, [ebp+0] so the ebp is back pointing at the previous stack frame从函数中返回
这是被调用函数的最后一步,RET指令从堆栈中弹出保存的旧的EIP(返回地址)并跳转到该位置。 这将控制权交还给调用者。 只有堆栈指针 (EBP) 和指令指针 (EIP) 被子程序返回修改。
1
0040104B ret ;load the saved eip, the return address: 00401081 into the eip and start executing the instruction, the address is [ebp+4]
清理被 PUSH 的参数
在 __cdecl 约定中,调用者必须清除压入堆栈的参数,这可以通过将堆栈弹出到函数参数的无关寄存器或直接将参数块大小添加到堆栈指针来完成 .
1
00401081 add esp, 8 ;clear the parameters, 8 bytes for integer 7 and character 8 at [ebp+8] and [ebp+12] after this cleanup by the caller, main(),the MyFunc()’s stack is totally dismantled.
你也可以从汇编的角度看到,在使用堆栈时,它必须在 PUSH 和 POP 的字节数方面对称。 如前所述,函数的堆栈构造前后必须有平衡。 显然,如果堆栈在退出函数时不平衡,则程序执行将从错误的地址开始,这几乎只会使程序崩溃。 在大多数情况下,如果您将给定的数据大小压入堆栈,请确保您必须弹出相同的数据大小。