做PWN题经常遇到栈溢出,有时一些栈的基础知识总是记不清楚,脑子卡顿,所以整理一番,让自己彻底记住它!
1. 什么是栈?
栈,即堆栈,是一种具有一定规则的数据结构,它按照先进后出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶。
堆栈数据结构的两种基本操作:
- PUSH:将数据压入栈顶
- POP :将栈顶数据弹出
知道了什么是栈,我们看看栈在内存中是怎样一个分布情况。
以Linux 32位平台为例,进程有4GB大小的虚拟地址空间,其中1GB留给系统内核,3GB是进程自身拥有。一个进程大致的内存布局如下图所示。
简单说明一下
代码段:存放可执行程序的代码,可读不可写
数据段:存放程序中已经初始化的静态(全局)变量,可读写
bss段:存放程序中未初始化的静态(全局)变量,可读写
堆(heap):存放动态分配的内容,需要程序猿手动分配和释放
栈(stack):存放局部变量,如函数的参数、返回地址、局部变量等,有系统自动分配和释放
2. 函数调用栈
栈的基本概念和进程内存布局get,让我们继续看看函数调用栈,C语言函数调用过程中栈的PUSH和POP了解一下。
2.1 背景知识
栈增长方向:高地址->低地址
ESP:栈指针寄存器,指向栈顶的低地址
EBP:基址指针寄存器,指向栈底的高地址
EIP:指令指针,存储即将执行的程序指令的地址
函数调用约定:
调用方式 | cdecl | stdcall | fastcall |
参数传递 | 从右到左压栈 | 从右到左压栈 | 左边两个参数 分别放在ECX 和EDX寄存器, 其余的参数从 右到左压栈 |
栈清理 | 调用者 | 函数自身 | 函数自身 |
(C语言默认的函数调用方式为cdecl)
2.2 函数调用开始
在调用一个函数时,系统会为这个函数分配一个栈帧,栈帧空间为该函数所独有。
调用者调用一个函数的过程大致如下:
- 函数参数从右到左入栈
- 返回地址入栈
- 上一函数ebp入栈
- ...
在上一函数ebp入栈后,就开辟了被调函数的新栈帧,接下来便是被调函数临时变量入栈等操作,如果被调函数里有继续调用新函数的操作,将继续开始上述的一系列操作,不断循环嵌套下去。下图表示函数调用过程中栈的布局情况。
2.3 函数调用结束
函数调用结束时的变化,主要就是按相反的顺序将数据弹出栈:
- 弹出临时变量
- 弹出调用函数的ebp值,存到ebp寄存器中
- 弹出返回地址,存到eip寄存器中
返回地址即是用call指令调用函数时下一条指令的地址,存到eip中,程序就知道在调用完后继续执行下一条指令。
我们会有一个疑惑,调用函数时将函数参数从右到左入栈,调用结束时怎么没有将它们弹出?
在这里,系统并不是用POP指令将它们弹出,而是通常通过ADD ESP让它们从栈中“消失”。
3. 栈溢出原理
那么,什么是栈溢出呢?栈溢出是指向向栈中写入了超出限定长度的数据,溢出的数据会覆盖栈中其它数据,从而影响程序的运行。
如果我们计算好溢出的长度,编写好溢出数据,让我们想要的地址数据正好覆盖到函数返回地址,那么被调函数调用完返回主函数时,就会跳转到我们覆盖的地址上。通过这样改变程序流程,接下来我们就可以干很多坏事了!
我们以一个实例(64位程序)对上述栈溢出原理和利用做一个说明。
#include<stdio.h>
int fun1()
{
int a;
gets((char *)&a);
return 0;
}
int fun2()
{
printf("stackflow success!\n");
return 0;
}
int main(int argc, char *argv[])
{
fun1();
return 0;
}
gets()是C中的危险函数之一,它不进行边界检查。在我们的例子中,a是int型只有4字节大小的空间,所以当输入的字符大于4字节时,就会发生溢出。而我们的目标就是,让我们的溢出数据覆盖fun1函数的返回地址,具体就是覆盖为fun2函数的地址,使程序的流程跳转到fun2函数去执行。
首先,我们用gcc对这段代码进行编译:
gcc -z execstack -fno-stack-protector -o stackflow-example ./stackflow-example.c
(其中-z execstack开启堆栈可执行机制,-fno-stack-protector关闭堆栈保护机制)
用gdb进行调试,可以直接在gets()函数下断点,也可以使用next、step指令快速调试到gets()函数这,在输入AAA后,查看堆栈数据。
在执行完gets()函数并输入AAA后,程序的栈分布情况如下所示,0x00007fffffffe110即是上一函数(调用者main函数)的ebp,0x4005b4是fun1函数的返回地址。
在输入AAAAA后呢,溢出的数据就会存在0x00007fffffffe0f0开始的栈上
所以,我们只需要输入AAAA+AAAAAAAA(覆盖上一函数ebp)+fun2地址(覆盖返回地址),就可以达到我们的目标。
紧接着,我们需要找到fun2函数的起始地址,来完成我们对程序流程的劫持。这个程序比较简单,可以直接在调试的时候快速找到fun2函数的地址,正常我们可以使用如下命令查找。
fun2函数地址==0x400586
最后完成栈溢出,改变程序执行流程!(注意地址的小端字节序)
当然,在实际的栈溢出中,我们劫持程序的流程,一般会修改返回地址到shellcode或者内存中已经有的一段指令(ROP),一些具体的攻击利用技术,这里暂时先不做详细介绍了。
最后列一些C中会发生缓冲区溢出的危险函数:
- gets()
- strcpy
- strcat
- sprintf
- ......