PWN指北
写到这里,前面的 PWN 内容已经零零散散写了不少。
从最早的环境配置,到栈溢出,再到初阶、中阶和高阶 ROP,一路下来其实一直在研究同一件事情:
一个程序到底是怎么运行起来的,以及怎样利用程序本身存在的错误重新控制它。
所以这一篇不准备再单独介绍某一种利用方法,而是把前面的东西收回来,整理一下:
PWN 到底是什么?
刚开始应该学什么?
一道题拿到手以后应该怎么看?
这些知识最后又会一路走到哪里?
PWN 看起来东西特别多,但如果真的一层一层拆开,其实没有那么乱。
PWN到底是什么
PWN 是 CTF 里面的二进制安全方向。
最常见的题目形式,就是给一个 ELF 可执行文件,有时还会附带:
libc
ld
以及一个远程 IP 和端口。
需要做的事情一般是分析程序中的漏洞,再构造输入改变程序原本的行为,最后实现:
getshell
读取 flag
任意地址读
任意地址写
泄露敏感数据
绕过程序限制
如果一定要简单概括:
PWN
=
程序分析
+
漏洞发现
+
漏洞利用
真正麻烦的是,这三个词背后会一路牵出来一整套计算机基础。
第一次看一道 PWN 题,很可能会同时碰到:
Linux
ELF
C
汇编
寄存器
栈
堆
GDB
IDA
libc
动态链接
系统调用
如果这些东西以前都没有碰过,很容易产生一种感觉:
我是不是得先把计算机底层从头学一遍,才能开始做题?
实际上没必要。
PWN 更适合这样学:
知道一点基本原理
↓
开始做题
↓
遇到看不懂的东西
↓
补这一块知识
↓
继续调试
↓
再遇到新的问题
很多概念脱离程序本身,很难真正理解。
尤其是:
栈
寄存器
调用约定
内存布局
光看书的时候可能觉得自己知道了,真正进 GDB,看着程序一条一条跑以后,才会突然变得具体。
先补够用的基础
PWN 里面大量程序都是 C/C++ 编译出来的,所以 C 语言肯定是第一层。
不用一开始就学到特别深,但至少应该知道:
变量
数组
指针
结构体
函数
字符串
内存
以及一些经常出现的函数:
printf()
puts()
scanf()
read()
write()
gets()
fgets()
memcpy()
strcpy()
malloc()
free()
system()
其中我觉得最应该早点搞明白的还是指针。
指针里面装的不是某种神秘东西。
就是:
地址。
很多二进制漏洞最后拆到底,其实一直都绕不开:
地址
+
长度
程序从哪个地址读?
往哪个地址写?
一次允许读多少?
一次允许写多少?
这个长度是谁控制的?
这个地址又是谁控制的?
很多看起来完全不同的漏洞,最后都能继续往这些问题上拆。
汇编不用全背
汇编也一样。
没有必要刚开始就背完整个 x86 指令集,但至少应该能够认出常见的:
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
r8
r9
真正需要达到的不是:
把每条指令定义背下来
而是看到一小段普通汇编以后,能够大概判断:
数据从哪里来?
被放去了哪里?
这里是不是在调用函数?
这里比较了什么?
函数最后往哪里返回?
PWN 里面最终真的发生了什么,还是以 CPU 执行的指令为准。
函数不是凭空调用的
例如:
func(a, b, c);
C 里面看起来只是一行。
但真正执行的时候,程序必须解决:
参数放在哪里?
返回地址放在哪里?
局部变量放在哪里?
函数结束以后回哪里?
Linux x86-64 下,常见整数或者指针参数前六个依次通过:
rdi
rsi
rdx
rcx
r8
r9
传递。
所以做 64 位 ROP 的时候总能看到:
pop rdi ; ret
并不是因为这两条指令本身有什么特殊魔法。
而是因为如果想调用:
system("/bin/sh");
那么首先要让:
rdi = "/bin/sh" 的地址
ROP 里面很多 gadget 的意义,实际上都来自调用约定。
Linux也得会一点
大部分基础 PWN 题都在 Linux 用户态环境里面。
所以至少应该正常使用:
ls
cd
pwd
chmod
file
ldd
readelf
objdump
strings
grep
ps
也应该知道:
文件权限
进程
虚拟内存
动态库
环境变量
管道
重定向
大概是什么东西。
当然没必要为了学 PWN,先变成 Linux 运维。
但是如果:
chmod +x ./pwn
都不知道在干什么,后面肯定会很痛苦。
环境可以先用:
Ubuntu
WSL
虚拟机
都行。
到了后面开始遇到不同:
libc
ld
版本,再考虑 Docker 或者单独还原题目运行环境。
工具不是越多越好
PWN 里面真正天天用的核心工具,其实一直就那些:
IDA
GDB
pwndbg
pwntools
checksec
再加上一些:
ROPgadget
patchelf
one_gadget
readelf
objdump
strings
seccomp-tools
已经能覆盖大量题目。
工具当然可以继续装。
但是不要最后变成:
装了一百个工具,却不知道每一个工具到底帮自己解决什么问题。
IDA看什么
IDA 主要负责静态分析。
一个 ELF 丢进去以后,我通常先想知道:
main 在哪里?
程序从哪里读取输入?
输入长度是多少?
有没有危险函数?
有哪些重要分支?
有没有没被正常调用的函数?
全局数据放在哪里?
F5 看伪代码确实很好用。
但不能只看伪代码。
因为伪代码毕竟还是 IDA 根据:
assembly
猜出来的一种高层表达。
真正 CPU 执行的,仍然是汇编。
有时候一个变量在伪代码里面看起来很正常,切到栈布局和汇编以后才发现:
原来它离返回地址只有这么远。
GDB看什么
GDB 则负责动态分析。
IDA 告诉我:
程序大概是什么样。
GDB 则告诉我:
程序真正运行起来以后,现在到底发生了什么。
例如我怀疑:
这里应该已经覆盖 RIP 了。
那就不要继续坐在那里脑补。
直接跑起来看。
RIP 是多少?
RSP 指向哪里?
当前栈里面有什么?
寄存器是什么状态?
真正崩在哪里?
PWN 里面很多时候,调试比继续猜快得多。
我现在越来越不喜欢一句话:
“这里应该是……”
既然程序就在面前,能断下来,就直接看。
pwntools只是把利用自动化
pwntools 主要负责写 EXP。
例如:
from pwn import *
p = process('./pwn')
# p = remote('127.0.0.1', 9999)
payload = b'A' * 72
p.sendline(payload)
p.interactive()
真正复杂以后,一个 EXP 往往只是把已经想明白的利用过程自动跑一遍:
连接
↓
接收数据
↓
构造 payload
↓
发送
↓
拿到泄露
↓
计算地址
↓
构造第二阶段
↓
发送
↓
getshell
所以:
pwntools 不是利用原理。
它只是把利用过程稳定地写下来。
如果不知道为什么:
payload += p64(addr)
那换一个题,模板很快就会失效。
拿到程序先看保护
拿到一道 PWN,第一批操作一般都是:
file ./pwn
checksec ./pwn
先看:
什么架构?
32位还是64位?
静态还是动态链接?
然后再看:
RELRO
Canary
NX
PIE
这些东西。
保护机制不应该只背名字。
真正应该问的是:
这个保护打开以后,它具体阻止了我原来准备干的哪一步?
NX
NX,也就是 No-eXecute。
最直接的理解就是:
某些本来用于保存数据的内存区域,不允许直接作为代码执行。
例如栈如果是:
RW-
那么即使把 shellcode 写进栈里,也不能直接把 RIP 指过去执行。
早期很直接的:
栈溢出
↓
写 shellcode
↓
跳 shellcode
于是就会被挡住。
ROP 之所以重要,一个很大的背景就是:
既然不能自己在数据区放代码,那就复用程序原本已经存在的可执行代码。
Canary
Canary 用来检测栈是否被破坏。
一个很粗略的栈结构可以看成:
buffer
↓
canary
↓
saved rbp
↓
return address
如果栈溢出一路往后写,先把 Canary 改了,函数返回前检查发现不一致,就可能直接:
stack smashing detected
所以遇到 Canary,就要继续考虑:
能不能泄露?
能不能绕过?
能不能爆破?
有没有办法根本不覆盖它?
保护本身只是把最直接的路堵掉。
不代表漏洞就自动消失了。
PIE和ASLR
PIE 开启以后,程序本身的加载基址会变化。
以前可能直接写:
0x401234
现在绝对地址就不能直接假设固定。
于是经常需要先泄露程序中的某个地址:
然后再:
ASLR 则是更大的地址空间随机化。
会影响:
stack
heap
libc
ld
mmap
VDSO
等位置。
所以做到后面会发现,大量 PWN 题都离不开:
信息泄露。
只要能够稳定泄露某一个模块里的地址,经常就可以继续反推出整个模块的 base。
RELRO
RELRO 主要和 GOT 等重定位相关区域的保护有关。
Partial RELRO 下,一些 GOT 项通常仍然可写。
Full RELRO 则会让相关区域在重定位完成以后变成只读。
所以以前某些:
GOT overwrite
思路,到了 Full RELRO 下就不能直接用了。
还是那句话:
不要只背:
Full RELRO 很强
Partial RELRO 可以改 GOT
而是想:
我原本准备修改哪里?现在为什么改不了了?有没有别的控制数据?
从栈开始理解执行流
栈溢出一直是最经典的 PWN 起点。
例如:
char buf[64];
gets(buf);
如果程序允许向一个 64 字节数组里面输入远大于 64 字节的数据,多出来的内容就会继续覆盖后面的栈数据。
包括:
saved rbp
return address
一旦能够控制返回地址,本质上就拿到了一种非常关键的能力:
控制程序执行流。
后面的很多技术,其实都在扩大这项能力。
ROP
现代题目里如果 NX 开启,不能直接往栈上放 shellcode 执行,那么可以利用程序和共享库里已经存在的代码。
例如:
pop rdi
ret
这种可以利用的短指令序列,一般就叫 gadget。
再通过:
gadget
↓
ret
↓
gadget
↓
ret
↓
gadget
把它们串起来。
于是可以拼出一条原程序本来没有的执行流程。
这就是:
Return-Oriented Programming
ROP 真正让我觉得有意思的地方,不是:
找到很多 ret
而是:
程序里面原本属于不同位置、不同函数的代码,被重新排列成了另一段程序。
ret2libc
ROP 再往下,一个很典型的方向就是:
ret2libc
例如程序:
NX 开启
没有现成 system("/bin/sh")
但它运行的时候已经加载了 libc。
那么可以先泄露某个 libc 函数真实地址,例如:
puts
然后:
泄露 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
通常就值得特别关注。
Format String
栈溢出之外,格式化字符串也是很重要的基础漏洞。
例如:
printf(buf);
如果:
buf
完全由用户控制,那么:
%p
%x
%s
%n
这些格式符就可能被滥用。
进一步形成:
信息泄露
任意地址读
任意地址写
再去修改:
GOT
返回地址
函数指针
其他控制数据
所以我现在看一道题,更愿意先问:
这个漏洞到底给了我什么能力?
而不是急着问:
它属于哪一种模板?
比如:
能不能控制执行流?
能不能泄露地址?
能不能任意读?
能不能任意写?
题目外壳一直会变。
这些 primitive 反而比较稳定。
再往后就是Heap
栈题熟一些以后,通常就会碰 Heap Pwn。
这时候研究的不再只是:
return address
而是:
malloc
free
背后的 allocator 到底怎么管理内存。
会逐渐接触:
chunk
tcache
fastbin
unsorted bin
small bin
large bin
以及:
UAF
Double Free
Heap Overflow
Off-by-One
Off-by-Null
这些漏洞。
UAF和Double Free
UAF:
Use After Free
例如:
free(ptr);
以后程序仍然继续使用:
ptr
但是这块内存可能已经被 allocator 分配给别的数据。
这样攻击者就可能让旧指针访问重新布置过的内容。
Double Free 则是同一个 chunk 被重复释放。
不同版本的 glibc 对这种情况的检查一直在变化,所以 Heap Pwn 有一个非常明显的特点:
glibc 版本非常重要。
很多以前可以直接利用的东西,到新版本里面可能已经加了检查。
同时新的 allocator 结构又会带来新的玩法。
所以 Heap 比基础栈题更明显地需要结合具体版本。
IO_FILE和FSOP
继续往后还会碰:
_IO_FILE
FSOP
例如:
stdin
stdout
stderr
背后实际上都有对应的 FILE 结构。
如果能够控制这些结构里面的一些关键字段,例如:
pointer
buffer
vtable
或者其他内部状态,就可能继续改变程序行为。
到了这里,单纯背某一种利用已经越来越困难。
因为真正需要看的开始变成:
glibc 自己到底是怎么实现这些东西的?
所以 Heap Pwn 学到后面,直接去翻 glibc 源码几乎是绕不开的。
Seccomp和ORW
有些题还会开:
seccomp
限制程序能够使用哪些系统调用。
如果:
execve
被禁掉,那么:
system("/bin/sh");
或者:
execve("/bin/sh", 0, 0);
自然就不适合作为最终目标。
这时候常见思路可能变成:
open
↓
read
↓
write
也就是:
ORW
直接打开 flag,读取,再输出。
这也是一个很重要的变化:
PWN 的目标不一定是 getshell。
真正的目标是:
利用当前已经拥有的能力,完成题目要求。
Shell 只是很多情况下最方便的一种结果。
一道PWN题应该怎么开始
拿到一道题,我一般先:
file ./pwn
checksec ./pwn
确认:
架构
位数
链接方式
保护
然后进 IDA 看逻辑。
重点找:
输入点
输入长度
危险函数
数组边界
malloc / free
printf
函数指针
用户可控数据
隐藏功能
再进 GDB 动态确认。
这个过程里很重要的一点是:
不要刚看到 gets(buf) 就立刻开始写 EXP。
先把几个问题想清楚:
偏移是多少?
Canary 有没有?
PIE 有没有?
返回地址到底能不能控制?
NX 什么状态?
程序有没有 system?
有没有 /bin/sh?
libc 怎么泄露?
先把利用链画出来。
比如:
栈溢出
↓
控制 RIP
↓
泄露 libc
↓
返回 main
↓
第二次溢出
↓
system("/bin/sh")
利用链一旦清楚,真正的 pwntools 代码往往反而没有多长。
本地能打,远程为什么死
这个情况太常见了。
本地:
getshell
远程:
EOF
这个时候第一反应最好不要永远都是:
平台坏了。
先检查:
libc 一不一样?
ld 一不一样?
偏移对不对?
网络数据有没有收完整?
PIE / ASLR 地址有没有算错?
还有 x86-64 经常碰到的:
stack alignment
问题。
有时候调用 libc 函数前多放一个:
ret
程序就正常了。
libc 版本尤其容易坑人。
同一个:
puts
system
malloc
free
在不同 libc 里面偏移可能完全不一样。
如果:
libc_base = leak - offset
这里的 offset 错了,那么后面:
system
/bin/sh
gadget
全部一起错。
所以题目如果给:
libc.so.6
ld-linux-x86-64.so.2
最好尽量把本地环境还原成相同版本。
学习路线应该怎么走
如果现在让我重新从零安排,大概会分成几个阶段。
第一阶段先补:
Linux
C
基本汇编
ELF
GDB
IDA
pwntools
目标不是学完。
而是能够独立看懂一个简单 ELF,知道:
输入在哪里
函数怎么调用
栈大概怎么变化
程序大概在干什么
然后就开始做题。
第一阶段:栈
先集中做:
Stack Overflow
ret2text
ret2shellcode
ret2syscall
ret2libc
ROP
把:
栈
寄存器
调用约定
返回地址
执行流
真正搞熟。
这一步非常重要。
后面的很多东西看起来越来越复杂,但这些基础一直都还在。
第二阶段:保护和更复杂的ROP
再继续加:
Format String
PIE
Canary
Stack Pivot
ret2csu
ret2dlresolve
SROP
这时候已经不能只是照着模板改几个地址了。
需要开始真正理解:
ELF
PLT
GOT
动态链接
调用约定
系统调用
前面《初阶ROP链》《中阶ROP链》《高阶ROP链》里面写的东西,基本也就在这一条线上。
第三阶段:Heap
再进入:
glibc malloc
chunk
tcache
fastbin
unsorted bin
UAF
Double Free
Heap Overflow
然后继续往:
House of XXX
IO_FILE
FSOP
这些东西走。
到了这里,开始越来越频繁地需要直接看:
glibc source
而不是只看别人总结好的攻击模板。
再往后
传统 Linux Userland Pwn 往后,还可以继续碰:
Kernel Pwn
Browser Pwn
VM Pwn
QEMU
IoT
Embedded
Windows Pwn
这些方向已经不再是最基础的 CTF Linux PWN 了。
但是底层很多东西仍然相通:
内存
指针
汇编
CPU
操作系统
漏洞
利用
所以整个路线如果硬压成一条线,大概就是:
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、指针和内存。
再往系统层走,又会继续碰 CPU、虚拟内存和操作系统。
整个知识体系其实一直在往下面叠。
不要把PWN学成模板题
这是整篇最想说的一件事情。
PWN 很容易学成:
ret2libc 模板
fmt 模板
heap 模板
看到一道题:
搜索 Writeup
↓
找到 EXP
↓
复制
↓
改几个地址
↓
跑
有时候当然真的能把题打通。
但只要题目稍微变化:
没有 pop rdi
没有 puts
Full RELRO
不知道 libc
有 Seccomp
程序逻辑不同
马上就不知道下一步应该怎么办。
真正应该积累的是:
为什么这个漏洞存在?
这个漏洞给了什么 primitive?
为什么这个地址能泄露?
为什么这个 gadget 能控制寄存器?
为什么程序最后会走到这里?
为什么 libc base 能这样计算?
每次多问一句:
为什么?
比再背十份 payload 有用。
Writeup应该看什么
Writeup 最大的价值,也不是最后那份:
payload = ...
真正值得看的应该是:
别人为什么会想到这条利用链?
例如:
为什么先泄露这个地址?
为什么选这个 gadget?
为什么第一次打完还要回 main?
为什么需要 stack pivot?
为什么选择这个 bin?
为什么这里不用 getshell,而是 ORW?
EXP 只是最后的结果。
前面怎么从:
漏洞
一步一步推到:
利用链
才是最值得学的部分。
少一点“应该”
调试也是一样。
PWN 很容易出现:
我觉得这里应该已经……
最好少一点这种“应该”。
直接:
break
run
ni
si
x
context
看看 CPU 到底干了什么。
如果 payload 最后:
SIGSEGV
也不要马上重新随机改 payload。
先看:
RIP 在哪里?
RSP 在哪里?
返回地址是什么?
寄存器是什么?
栈上是什么?
程序为什么死在这里?
很多时候,答案已经在崩溃现场里面了。
只是需要把它看出来。
最后
PWN 有时候确实很折磨。
可能一整天都在:
Segmentation fault
也可能只算错一个:
0x8
就调几个小时。
还可能本地完全打通,远程一连:
EOF
最后发现:
libc 不一样。
这种事情多得很。
但是当程序真的按照自己构造出来的执行链:
pop
ret
leak
pivot
system
一路跑下去的时候,感觉还是很特别。
因为这个时候已经不是在:
使用这个程序。
而是在:
利用它原本存在的代码、数据和机制,重新告诉 CPU,这个程序接下来应该怎么运行。
PWN 真正有意思的地方,大概就在这里。
前面学了那么多:
栈
汇编
ELF
ROP
libc
动态链接
堆
系统调用
最后其实一直在回答一个问题:
程序究竟是怎样运行起来的?
理解这一点以后,很多所谓的“漏洞利用技巧”才不会只剩下一份模板。
最后还是一句:
不要背 payload。去理解程序。

















这一切,似未曾拥有