第 2 章 栈溢出
文章目录
1. 系统栈的工作原理
1.1 内存的不同用途
不管什么样的操作系统、什么样的计算机架构,进程使用的内存都可以按照功能大致分成以下 4 个部分。
- 代码区:这个区域存储着被装入执行的二进制机器代码,处理器会到这个区域取指并执行。
- 数据区:用于存储全局变量等。
- 堆区:进程可以在堆区动态地请求一定大小的内存,并在用完之后归还给堆区。动态分配和回收是堆区的特点。
- 栈区:用于动态地存储函数之间的调用关系,以保证被调用函数在返回时恢复到母函数中继续执行。
PE 文件代码段中包含的二进制级别的机器代码会被装入内存的代码区(.text),处理器将到内存的这个区域一条一条地取出指令和操作数,并送入算术逻辑单元进行运算;如果代码中请求开辟动态内存,则会在内存的堆区分配一块大小合适的区域返回给代码区的代码使用;当函数调用发生时,函数的调用关系等信息会动态地保存在内存的栈区,以供处理器在执行完被调用函数的代码时,返回母函数。
CPU 是完成工作的工人; 数据区、堆区、栈区等则是用来存放原料;代码区的指令则告诉 CPU 要做什么。栈除了存放数据,还是“车间调度主任的办公室”。
缓冲区可以是堆区、栈区和存放静态变量的数据区。缓冲区溢出的利用方法和缓冲区到底属于上面哪个内存区域密不可分。
1.2 栈与系统栈
对栈这种数据结构的介绍略。
系统栈在其他文献中可能曾被叫做运行栈、调用栈等。
1.3 函数调用时发生了什么
根据操作系统的不同、编译器和编译选项的不同,同一文件不同函数的代码在内存代码区中的分布可能相邻,也可能相离甚远,可能先后有序,也可能无序,;但它们都在同一个 PE 文件的代码所映射的一个“节”里。我们可以简单地把它们在内存代码区中的分布位置理解成是散乱无关的。
函数调用介绍略。
在实际运行中,main 函数并不是第一个被调用的函数,程序被装入内存前还有一些其他操作。
1.4 寄存器与函数栈帧
ESP 和 EBP 之间的内存空间为当前栈帧,EBP 标识了当前栈帧的底部,ESP标识了当前栈帧的顶部。
函数栈帧中,一般包含:
- 局部变量:为函数局部变量开辟的内存空间。
- 栈帧状态值:保存前栈帧的底部,前栈帧的顶部可以通过堆栈平衡计算得到),用于在本帧被弹出后恢复出上一个栈帧。
- 函数返回地址
函数栈帧的大小并不固定,一般与其对应函数的局部变量多少有关。
另一个至关重要的寄存器是eip。[本章第4节](#4. 代码植入 )中我们会介绍控制 EIP 劫持进程的原理及实验。
1.5 函数调用约定与相关指令
函数调用约定描述了函数传递参数方式和栈协同工作的技术细节。不同的操作系统、不同的语言、不同的编译器在实现函数调用时的原理虽然基本相同,但具体的调用约定还是有差别的。包括参数传递方式,参数入栈顺序是从右向左还是从左向右,函数返回时恢复堆栈平衡的操作在子函数中进行还是在母函数中进行。
C | SysCall | StdCall | Basic | Fortran | Pascal | |
---|---|---|---|---|---|---|
参数入栈顺序 | 右→左 | 右→左 | 右→左 | 左→右 | 左→右 | 左→右 |
恢复栈平衡操作的位置 | 母函数 | 子函数 | 子函数 | 子函数 | 子函数 | 子函数 |
对于 Visual C++来说,可支持以下 3 种函数调用约定
调用约定的声明 | 参数入栈顺序 | 恢复栈平衡的位置 |
---|---|---|
__cdecl | 右→左 | 母函数 |
__fastcall | 右→左 | 子函数 |
__stdcall | 右→左 | 子函数 |
如果要明确使用某一种调用约定,只需要在函数前加上调用约定的声明即可,否则默认情况下,VC 会使用__stdcall 的调用方式。
除了上边的参数入栈方向和恢复栈平衡操作位置的不同之外,参数传递有时也会有所不同。例如,每一个 C++类成员函数都有一个this
指针,在 Windows 平台中,这个指针一般是用ECX
寄存器来传递的,但如果用GCC
编译器编译,这个指针会作为最后一个参数压入栈中。
同一段代码用不同的编译选项、不同的编译器编译链接后,得到的可执行文件会有很多不同。
函数调用大致包括以下几个步骤。
- 参数入栈:将参数从右向左依次压入系统栈中。
- 返回地址入栈:将当前代码区调用指令的下一条指令地址压入栈中,供函数返回时继续执行。
- 代码区跳转:处理器从当前代码区跳转到被调用函数的入口处。
- 栈帧调整:具体包括
- 保存当前栈帧状态值,
push ebp
- 更新栈帧底部,将当前栈帧切换到新栈帧,
mov ebp,esp
- 给新栈帧分配空间,
sub esp,XXX
- 保存当前栈帧状态值,
对于__stdcall
调用约定,函数调用时用到的指令序列大致如下。
;调用前
push 参数 3 ;假设该函数有 3 个参数,将从右向左依次入栈
push 参数 2
push 参数 1
call 函数地址;call 指令将同时完成两项工作:a)向栈中压入当前指令在内存
;中的位置,即保存返回地址。b)跳转到所调用函数的入口地址函
;数入口处
push ebp ;保存旧栈帧的底部
mov ebp,esp ;设置新栈帧的底部(栈帧切换)
sub esp,xxx ;设置新栈帧的顶部(抬高栈顶,为新栈帧开辟空间)
关于栈帧的划分,不同参考书中有不同的约定。
OllyDbg前栈帧 EBP 值既属于上一个栈帧,也属于下一个栈帧,这样划分栈帧后,返回地址就成为了栈帧顶部的数据。
我们按照 EBP 与 ESP 之间的部分做为一个栈帧的原则进行划分就好了。
函数返回的步骤如下:
- 保存返回值:通常将函数的返回值保存在寄存器
EAX
中。 - 弹出当前栈帧,恢复上一个栈帧。具体包括:
- 在堆栈平衡的基础上,
add esp,XXX
,降低栈顶,回收当前栈帧的空间; pop ebp
,恢复出上一个栈帧;- 弹出返回地址。
- 在堆栈平衡的基础上,
- 跳转:按照函数返回地址跳回母函数中继续执行,恢复调用前的代码区。
add esp XXX
pop ebp
retn ; 这条指令有两个功能:
;a)弹出当前栈顶元素,即弹出栈帧中的返回地址。 至此;栈帧恢复工作完成。
;b)让处理器跳转到弹出的返回地址,恢复调用前的代码区。
2. 修改邻接变量
2.1 修改邻接变量的原理
局部变量在栈中一个挨着一个排列。如果这些局部变量中有数组之类的缓冲区,并且程序中存在数组越界的缺陷,那么越界的数组元素就有可能破坏栈中相邻变量的值,甚至破坏栈帧中所保存的EBP
值、返回地址等重要数据。
大多数情况下,局部变量在栈中的分布是相邻的,但也有可能出于编译优化等需要要而有所例外。
#include <stdio.h>
#include <string.h>
#define PASSWORD "1234567"
int verify_password (char *password)
{
int authenticated;
char buffer[8];// add local buffto be overflowed
authenticated=strcmp(password,PASSWORD);
strcpy(buffer,password);//over flowed here!
return authenticated;
}
根据书中的例子,栈帧中先定义的局部变量authenticated
地址要比buffer
高,可通过buffer
覆盖这个变量为验证成功。
另外分析的时候要适应小端存储。
3. 修改函数返回地址
更通用、更强大的攻击通过缓冲区溢出改写的目标往往不是某一个变量,而是瞄准栈帧最下方的EBP
和函数返回地址等栈帧状态值。
构造地址时,可以使用UltraEdit
十六进制编辑模式,按照小端存储逆序输入4个字节。
4. 代码植入
4.1 代码植入的原理
通过栈溢出可以让进程执行输入数据中植入的代码。
在 buffer 里包含我们自己想要执行的代码,然后通过返回地址让程序跳转到系统栈里执行,就可以让进程去执行本来没有的代码。
我们的shellcode
一般会执行一次MessageBox()
.
其实系统中并不存在真正的
MessagBox
函数,对MessageBox
这类 API 的调用最终都将由系统按照参数中字符串的类型选择“A”类函数(ASCII)或者“W”类函数(UNICODE)调用。因此,我们在汇编语言中调用的函数应该是MessageBoxA
。其实 MessageBoxA 的实现只是在设置了几个不常用参数后直接调用MessageBoxExA
。
用汇编语言调用MessageboxA
需要 3 个步骤。:
- 装载
user32.dll
。MessageBoxA
是user32.dll
的导出函数。大多数有图形化操作界面的程序都已经装载了这个库. - 在汇编语言中调用这个函数需要获得这个函数的入口地址。
- 向栈中按从右向左的顺序压入
MessageBoxA
的 4 个参数。
MessageBoxA
的入口参数可以通过user32.dll
在系统中加载的基址和MessageBoxA
在库中的偏移相加得到。可以使用 VC6.0 自带的小工具 Dependency Walker,将PE文件拖进去即可。
从汇编指令中提取出二进制机器代码的方法将在第 5 章集中讨论。