PWN指北
写到这里,前面的 PWN 基础内容已经零零散散写了不少。
从最开始的:
PWN环境配置
到:
栈溢出
初阶ROP链
中阶ROP链
高阶ROP链
一路下来,其实一直在研究同一件事情:
一个程序到底是怎么运行起来的,以及怎样利用程序本身存在的错误重新控制它。
所以这篇不准备继续介绍某一种具体利用方式,而是整理一下 PWN 到底是什么、应该学什么,以及整个学习路线大概是什么样。
PWN是什么
PWN 是 CTF 中的二进制安全方向。
最常见的题目形式是:
一个 ELF 可执行文件
+
一个 libc / ld
+
一个远程 IP 和端口
需要分析程序中的漏洞,然后构造输入改变程序原本的执行流程。
最终目标可能是:
getshell
也可能只是:
读取 flag
任意地址读写
泄露敏感数据
绕过某种限制
所以如果一定要概括:
PWN
=
程序分析
+
漏洞发现
+
漏洞利用
其中每一部分背后其实都对应一大堆计算机基础知识。
PWN为什么难入门
PWN 的入门门槛相比很多 CTF 方向确实比较高。
因为第一次看到一道 PWN 题时,往往会同时碰到:
Linux
ELF
C
汇编
寄存器
栈
堆
GDB
IDA
libc
动态链接
系统调用
对于刚开始的人来说,这些东西全都是陌生的。
所以经常出现一种情况:
为了学栈溢出,先去学汇编;学汇编发现不知道寄存器;学寄存器发现不知道函数调用;学函数调用又发现不知道栈。
最后学了一圈,还没开始做题。
实际上没有必要等所有前置知识全部学完。
更合理的方式是:
知道最基本原理
↓
做一道题
↓
遇到不懂的东西
↓
补知识
↓
继续调试
PWN 很多知识必须在调试程序的时候才能真正理解。
基础知识
C语言
PWN 中遇到的大量程序都是 C/C++ 编译出来的。
不要求一开始就精通 C,但是至少需要理解:
变量
数组
指针
结构体
函数
内存
字符串
以及常见函数:
printf()
puts()
scanf()
read()
write()
gets()
fgets()
memcpy()
strcpy()
malloc()
free()
system()
尤其需要理解:
指针保存的不是“数据”,而是地址。
很多二进制漏洞最后都离不开:
地址
+
长度
这两个东西。
汇编
至少需要掌握 x86 和 x86-64 的基本汇编。
例如:
mov
lea
push
pop
call
ret
cmp
test
jmp
je
jne
以及常见寄存器:
32 位:
eax
ebx
ecx
edx
esp
ebp
eip
64 位:
rax
rbx
rcx
rdx
rsp
rbp
rip
rdi
rsi
不用一开始背完整指令集。
能够做到:
看见一段普通汇编,知道数据从哪里来、去了哪里、最后跳到哪里。
基本就够开始做题了。
函数调用
这一部分很重要。
例如执行:
func(a, b, c);
程序必须知道:
参数放在哪里
返回地址放在哪里
局部变量放在哪里
函数执行完回哪里
32 位和 64 位的调用约定并不完全相同。
Linux x86-64 中常见的前几个参数:
第1参数 -> rdi
第2参数 -> rsi
第3参数 -> rdx
第4参数 -> rcx
第5参数 -> r8
第6参数 -> r9
这也是为什么做 64 位 ROP 时经常寻找:
pop rdi ; ret
因为我们需要控制函数参数。
Linux
常规 PWN 大部分都是:
Linux 用户态 PWN
所以最好至少熟悉一些 Linux 基础操作:
ls
cd
pwd
chmod
file
ldd
readelf
objdump
strings
grep
cat
ps
以及:
文件权限
进程
虚拟内存
动态库
环境变量
管道
重定向
没有必要为了学 PWN 先变成 Linux 运维工程师。
但是如果连:
chmod +x ./pwn
都不知道是什么意思,做题会非常痛苦。
环境
现在我更推荐直接准备一个 Linux 做题环境。
可以使用:
Ubuntu
WSL
VMware
Docker
如果只是开始学习:
Ubuntu / WSL
已经完全够用。
不同题目的:
libc
ld
版本经常不同。
到了后面,为了复现远程环境,可以考虑使用 Docker 或专门准备对应版本环境。
常用工具
PWN 最核心的工具其实一直没有怎么变化。
基本就是:
IDA
GDB
pwndbg
pwntools
checksec
再加上一些辅助工具:
ROPgadget
patchelf
one_gadget
readelf
objdump
strings
seccomp-tools
工具很多,但不要变成:
装了一百个工具,却不知道每个工具到底在帮你解决什么问题。
IDA
IDA 主要负责:
静态分析
把 ELF 放进去以后,我们希望知道:
main 在哪里
输入在哪里
有什么危险函数
程序有哪些分支
有没有隐藏函数
数据保存在哪里
PWN 中最常用的几个操作:
F5 查看伪代码
Space 图形/文本切换
X 查看交叉引用
G 跳转地址
N 重命名
不要只盯着 F5 的伪代码。
伪代码只是 IDA 根据汇编推出来的结果。
真正决定 CPU 行为的还是:
assembly
GDB
IDA 告诉我们:
程序可能是什么样。
GDB 告诉我们:
程序运行起来以后实际上是什么样。
经常需要观察:
寄存器
栈
内存
指令
断点
例如 pwndbg:
gdb ./pwn
常见命令:
b
r
c
ni
si
x
vmmap
stack
context
当你怀疑:
这里应该已经覆盖 RIP 了
不要继续靠脑补。
直接:
GDB 跑起来看
通常是最快的。
pwntools
pwntools 的作用主要是把 exploit 自动化。
例如:
from pwn import *
p = process('./pwn')
本地运行程序。
远程:
p = remote('127.0.0.1', 9999)
数据打包:
p64(0xdeadbeef)
p32(0xdeadbeef)
接收:
p.recv()
p.recvline()
p.recvuntil()
发送:
p.send()
p.sendline()
p.sendafter()
p.sendlineafter()
最后:
p.interactive()
一个 exploit 最终经常就是:
连接
↓
接收数据
↓
计算地址
↓
构造 payload
↓
发送
↓
继续接收
↓
第二阶段 payload
↓
getshell
checksec
拿到题目以后,第一件事情之一就是:
checksec ./pwn
通常会看到:
RELRO
Canary
NX
PIE
这些保护机制直接决定很多利用方法是否成立。
NX
NX:
No-eXecute
核心是:
数据所在内存区域不能直接作为代码执行。
如果栈:
RW-
那么:
往栈写 shellcode
↓
直接 ret 到 shellcode
就不能用了。
这也是 ROP 出现的重要原因之一。
Canary
Canary 用来检测栈是否被破坏。
大致结构:
buffer
↓
canary
↓
saved rbp
↓
return address
函数返回前会检查:
canary 有没有变化
如果被覆盖:
*** stack smashing detected ***
程序直接退出。
因此需要考虑:
泄露 Canary
绕过 Canary
爆破 Canary
不覆盖 Canary
等方法。
PIE
PIE:
Position Independent Executable
开启以后,程序每次运行时:
.text
.plt
.got
等位置的绝对地址都可能发生变化。
因此:
0x401234
这种固定地址不能再直接写死。
一般需要:
泄露某个程序地址
↓
计算 PIE base
↓
base + offset
得到真实地址。
ASLR
ASLR 是操作系统级的地址空间随机化。
可能影响:
stack
heap
libc
ld
mmap
VDSO
所以很多 PWN 利用过程实际上都围绕:
信息泄露
展开。
因为只要获得一个可靠地址:
leak
往往就可以进一步计算:
base address
RELRO
RELRO 主要影响 GOT。
可以简单分成:
No RELRO
Partial RELRO
Full RELRO
Partial RELRO 下:
GOT
通常仍然可以写。
Full RELRO 后 GOT 会变成只读,因此:
GOT overwrite
这一类利用就受到限制。
栈
栈是 PWN 最经典的起点。
最基本的是:
Stack Buffer Overflow
如果程序:
char buf[64];
gets(buf);
而输入:
200 bytes
那么数据就会继续往后覆盖:
buffer
↓
saved rbp
↓
return address
只要能够修改:
return address
就获得了:
程序执行流控制能力
前面的:
《栈溢出》
已经介绍过最基本原理。
ROP
当:
NX enabled
以后,我们不一定能够执行自己写进去的 shellcode。
于是可以使用程序中已经存在的指令。
例如:
pop rdi
ret
这些短小代码片段称为:
gadget
通过不断:
gadget
↓
ret
↓
gadget
↓
ret
可以重新组织出一条新的执行流程。
这就是:
Return-Oriented Programming
前面已经分成:
初阶ROP链
中阶ROP链
高阶ROP链
分别介绍过。
ret2libc
这是实际比赛里非常常见的一类方法。
如果程序开启 NX,又没有:
system("/bin/sh")
可以直接返回,那么可以利用程序加载的:
libc
典型流程:
泄露 puts@got
↓
得到 puts 实际地址
↓
计算 libc base
↓
计算 system
↓
寻找 /bin/sh
↓
system("/bin/sh")
核心公式:
libc_base = puts_addr - libc.symbols['puts']
然后:
system = libc_base + libc.symbols['system']
这也是为什么题目提供的:
libc.so.6
非常重要。
格式化字符串
PWN 不只有栈溢出。
例如:
printf(buf);
如果:
buf
完全由用户控制,那么格式化字符串本身就可能变成漏洞。
例如:
%p
%x
%s
%n
这些格式符可以产生:
信息泄露
任意地址读取
任意地址写入
最终甚至可以:
修改 GOT
修改返回地址
修改函数指针
因此 Format String 是用户态 PWN 的另一大基础类型。
Heap
当栈题做得差不多以后,就会开始进入:
Heap Pwn
也就是堆利用。
这里开始研究:
malloc()
free()
背后的 glibc 内存管理机制。
需要逐渐理解:
chunk
bin
tcache
fastbin
unsorted bin
small bin
large bin
以及:
UAF
Double Free
Heap Overflow
Off-by-One
Off-by-Null
等漏洞。
栈题很多时候关注:
return address
而堆题更多是在思考:
怎样通过破坏 allocator 的内部状态获得任意地址读写。
UAF
UAF:
Use After Free
例如:
free(ptr);
以后:
ptr
没有被清空。
程序后面又继续:
ptr->something
这时原来的内存可能已经:
被重新分配
攻击者就可能控制这里的数据。
Double Free
Double Free:
同一个 chunk 被 free 两次
可能破坏 allocator 的链表结构。
早期 glibc 上经常可以利用它实现:
任意地址分配
当然随着 glibc 版本不断更新,各种检查越来越多,具体利用方式也一直变化。
这也是 Heap PWN 最麻烦的一点:
glibc 版本非常重要。
IO_FILE
到了更后面,还会遇到:
_IO_FILE
FSOP
也就是利用 glibc 的文件流结构。
例如:
stdin
stdout
stderr
背后实际上都是:
FILE
结构。
如果能够修改这些结构内部的:
pointer
vtable
buffer
就可能进一步改变程序行为。
这部分相比基础栈溢出已经复杂很多。
Seccomp
还有一些题会开启:
seccomp
限制程序可以执行哪些系统调用。
比如禁止:
execve
那么:
system("/bin/sh")
或者:
execve("/bin/sh", 0, 0)
自然也就行不通。
这时常见思路可能变成:
open
read
write
也就是所谓:
ORW
直接:
open("flag")
↓
read()
↓
write()
把 flag 内容输出。
PWN真正的做题流程
拿到一道题以后,我通常会先:
file ./pwn
确认:
32 / 64 bit
architecture
static / dynamic
然后:
checksec ./pwn
观察保护。
接着:
IDA
看程序逻辑。
寻找:
输入点
长度
危险函数
数组边界
malloc/free
printf
函数指针
然后才进入:
GDB
动态确认。
不要一上来写EXP
很多时候刚看到:
gets(buf)
就直接开始:
payload = ...
其实容易走弯路。
应该先确认:
偏移是多少?
Canary 有没有?
PIE 有没有?
返回地址能不能控制?
NX 状态?
程序有没有 system?
有没有 /bin/sh?
libc 怎么泄露?
先把利用链想清楚,再写 exploit。
例如:
漏洞
↓
获得 RIP
↓
泄露 libc
↓
回 main
↓
第二次栈溢出
↓
system("/bin/sh")
逻辑明确以后,代码反而很短。
本地通了,远程不通
这是 PWN 中最常见的问题之一。
本地:
getshell
远程:
EOF
通常不要第一时间怀疑:
平台坏了。
先检查:
libc 是否一致
ld 是否一致
偏移是否正确
网络接收是否有遗漏
栈对齐是否正确
ASLR/PIE 是否计算错误
尤其 x86-64 的:
stack alignment
有时需要额外放一个:
ret
使栈重新按照 ABI 要求对齐。
Libc版本
同一个函数:
system
puts
malloc
free
在不同 libc 中的偏移可能完全不同。
所以:
本地 libc
和:
远程 libc
如果不是同一个版本:
libc_base = leak - offset
自然就会算错。
因此题目如果提供:
libc.so.6
ld-linux-x86-64.so.2
最好尽量还原同样的运行环境。
学习路线
如果从零开始,我会把 PWN 学习过程大概分成几层。
第一阶段
Linux基础
C语言
基本汇编
GDB
IDA
pwntools
目标不是“全部学完”。
而是能够:
独立读懂一个简单 ELF。
第二阶段
栈溢出
ret2text
ret2shellcode
ret2syscall
ret2libc
做到可以独立分析:
普通 32/64 位栈题
这一阶段是最重要的基础。
第三阶段
继续学习:
Format String
PIE
Canary
ROP
Stack Pivot
ret2csu
ret2dlresolve
SROP
这时候已经不应该只会:
照着模板改地址
而应该真正理解:
ELF
PLT
GOT
动态链接
调用约定
系统调用
第四阶段
开始 Heap:
glibc malloc
tcache
fastbin
unsorted bin
UAF
Double Free
Heap Overflow
然后继续:
House of XXX
IO_FILE
FSOP
到了这一阶段,glibc 源码本身会越来越重要。
第五阶段
再往后可以扩展:
Kernel Pwn
Browser Pwn
VM Pwn
QEMU
IoT
Embedded
Windows Pwn
这些已经逐渐离开最传统的:
Linux Userland Pwn
但是底层能力实际上还是相通的:
内存
指针
汇编
系统
漏洞
利用
不要只背题型
这是最想说的一点。
很多人学 PWN 的方式是:
ret2libc 模板
heap 模板
fmt 模板
遇到题就:
搜索 Writeup
复制 EXP
改地址
这样当然也可能做出题。
但是题目一旦稍微变化:
没有 pop rdi
没有 puts
Full RELRO
libc 不知道
Seccomp
程序逻辑改变
就不知道该怎么办。
真正需要积累的是:
为什么这个漏洞存在?
为什么这个地址能泄露?
为什么这一条 gadget 能控制寄存器?
为什么程序会跳到这里?
为什么 libc base 可以这样算?
每一次都问:
为什么
比多背十个 payload 有用得多。
不要把Writeup当答案
Writeup 最大的价值不是:
给你一份能打通的EXP
而是:
看别人是怎么发现那条利用链的。
真正值得看的通常是:
为什么想到泄露这个地址
为什么选择这个 gadget
为什么先回 main
为什么做 stack pivot
为什么选择这个 bin
如果只复制最后:
payload = ...
其实最重要的东西一点都没有学到。
调试比猜测重要
PWN 很容易出现:
我觉得这里应该……
最好少一点“觉得”。
直接:
break
run
ni
si
x
context
看看 CPU 到底干了什么。
比如 payload 崩溃:
SIGSEGV
不要重新随机改 payload。
先检查:
RIP 在哪里?
RSP 指向哪里?
返回地址是什么?
寄存器是什么?
栈上是什么?
程序已经把答案摆在那里了。
一条比较完整的学习路线
最终可以整理成:
Linux
↓
C
↓
Assembly
↓
ELF
↓
GDB / IDA
↓
Stack
↓
Stack Overflow
↓
ROP
↓
Format String
↓
Libc / Dynamic Linker
↓
Heap
↓
glibc malloc
↓
IO_FILE
↓
Kernel / Browser / VM
这个过程不可能很快。
但它最大的特点就是:
前面学过的东西不会浪费。
学栈的时候需要汇编。
学 ROP 继续需要汇编。
学 Heap 需要 C 和内存。
学 Kernel 又需要更深入理解内存和 CPU。
整个知识体系实际上是一层一层叠上去的。
最后
PWN 有时候确实很折磨。
可能一整天都在:
Segmentation fault
可能算错一个:
0x8
就调几个小时。
也可能:
本地通
远程死
然后最后发现只是 libc 不一样。
但是当程序真的按照你构造的执行链:
pop
ret
leak
pivot
system
一路执行下去的时候,会有一种很特别的感觉。
你不是在“使用这个程序”。
而是在:
利用它原本存在的代码和机制,重新告诉 CPU 这个程序应该怎样运行。
PWN 真正有意思的地方可能就在这里。
最后总结成一句:
不要背 payload。
去理解程序。 















这一切,似未曾拥有