预备知识
缓冲区溢出
之前我们讲过一些缓冲区溢出的内容。简单的说,缓冲区溢出就是超长的数据向小缓冲区复制,导致数据超出了小缓冲区,导致缓冲区其他的数据遭到破坏,这就是缓冲区溢出。而栈溢出是缓冲区溢出的一种,也是最常见的。只不过栈溢出发生在栈,堆溢出发生在堆,其实都是一样的。
无论什么计算机架构,进程使用的内存都可以按照功能大致分为4个部分:
(1)代码区:这个区域存储着被装入的执行的二进制代码,处理器会到这个区域取指并执行。
(2)数据区:用于存储局部变量。
(3)堆区:进程可以在堆区中动态的请求一定大小的内存,并在用完之后归还个堆区。动态分配和回收是堆区的特点。
(4)栈区:用于动态的存储函数之间的调用关系。以保证被调用函数在返回时恢复到母函数中继续执行。
栈
栈,即堆栈,是一种具有一定规则的数据结构,它按照先进后出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶。
堆栈数据结构的两种基本操作:
- PUSH:将数据压入栈顶。
- POP :将栈顶数据弹出。
栈顶:常用寄存器ESP,ESP是栈指针寄存器,其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。
栈底:常用寄存器EBP,EBP是基址指针寄存器,其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。
系统栈:指的是内存中的栈,由系统自动维护,它用于实现高级语言中的函数调用。
函数调用栈
背景知识
栈增长方向:高地址->低地址
ESP:栈指针寄存器,指向栈顶的低地址
EBP:基址指针寄存器,指向栈底的高地址
EIP:指令指针,存储即将执行的程序指令的地址
函数调用约定:
调用方式 | cdecl | stdcall | fastcall |
---|---|---|---|
参数传递 | 从右到左压栈 | 从右到左压栈 | 左边两个参数分别放在ECX和EDX寄存器,其余的参数从右到左压栈 |
栈清理 | 调用者 | 函数自身 | 函数自身 |
函数调用开始
在调用一个函数时,系统会为这个函数分配一个栈帧,栈帧空间为该函数所独有。
调用者调用一个函数的过程大致如下:
- 函数参数从右到左入栈
- 返回地址入栈
- 上一函数ebp入栈
- …
在上一函数ebp入栈后,就开辟了被调函数的新栈帧,接下来便是被调函数临时变量入栈等操作,如果被调函数里有继续调用新函数的操作,将继续开始上述的一系列操作,不断循环嵌套下去。下图表示函数调用过程中栈的布局情况。
函数调用结束
函数调用结束时的变化,主要就是按相反的顺序将数据弹出栈:
- 弹出临时变量
- 弹出调用函数的ebp值,存到ebp寄存器中
- 弹出返回地址,存到eip寄存器中
返回地址即是用call指令调用函数时下一条指令的地址,存到eip中,程序就知道在调用完后继续执行下一条指令。
我们会有一个疑惑,调用函数时将函数参数从右到左入栈,调用结束时怎么没有将它们弹出?
在这里,系统并不是用POP指令将它们弹出,而是通常通过ADD ESP让它们从栈中“消失”(降低栈顶,回收当前栈帧)。
栈溢出原理
栈溢出是指向向栈中写入了超出限定长度的数据,溢出的数据会覆盖栈中其它数据,从而影响程序的运行。
如果我们计算好溢出的长度,编写好溢出数据,让我们想要的地址数据正好覆盖到函数返回地址,那么被调函数调用完返回主函数时,就会跳转到我们覆盖的地址上。通过这样改变程序流程,接下来我们就可以做一些事情了。
例子如下:
1 |
|
之前讲过gets等一些函数,它不进行边界检查,是危险函数。在gets((char *)&a); 中,a为int类型是占4字节空间。当输入字符大于4字节,就会溢出。我们需要让溢出数据覆盖fun1函数的返回地址,覆盖的数据为fun2函数的地址,使执行fun2函数的程序。
编译程序:
1 | gcc -z execstack -fno-stack-protector -no-pie -o stackflow ./stackflow.c |
(其中-z execstack开启堆栈可执行机制,-fno-stack-protector关闭堆栈保护机制,-no-pie关闭地址随机化)
用gdb进行调试:
flie <filename>为载入可执行目标文件,b <fun>为在该函数下断点,run是开始运行程序。
之后使用next、step指令快速调试到gets()函数,输入AAA后使用next、step指令进行到该处:
在执行完gets()函数并输入AAA后,程序的栈分布情况如下所示,0x7fffffffddd0即是上一函数(调用者main函数)的ebp, 0x400590 是fun1函数的返回地址。
我们通过输入指令可以看到0x00414141为输入的值‘AAA’,其后面就是上一个函数的ebp与返回地址。
(x /16xw addr :x表示打印内存的值,/16表示从addr开始输出单元的个数,x是以16进制形式输出,w标明一个单元的长度为4字节)
如果输入AAAAA后,溢出的数据就会存在0x7fffffffddb0开始的栈上。
所以,我们只需要输入AAAA+AAAAAAAA(覆盖上一函数ebp)+fun2地址(覆盖返回地址),就可以达到我们的目标。
所以我们需要找到fun2函数的起始地址,来完成我们对程序流程的劫持,如下:
(disass <fun>为反编译函数)
可以看到fun2函数地址为0x400557。
最后完成栈溢出,改变程序执行流程(注意地址的小端字节序)
gets(),strcpy,strcat,sprintf等危险函数都会发生缓冲区溢出
栈溢出例题
以buuctf上的warmup_csaw_2016为例。
没有什么保护措施:
拖进ida查看:
而且直接把函数地址给了:
可以看出,gets绝对有问题,v5的长度为0x40,同时加上上一函数的ebp为8个字节,所以要溢出总长度为0x48,在运行程序时可以看到sub_40060D的地址就为0x40060D(没有地址随机化措施)。
编写脚本:
1 | from pwn import * |