CTF PWN实战:格式化字符串与栈溢出组合利用Mary_Morton 1. 从一道经典PWN题说起为什么Mary_Morton值得反复琢磨如果你刚开始接触CTF的PWN方向或者已经刷了一些基础题但遇到格式化字符串和栈溢出结合的题目就卡壳那Mary_Morton这道题大概率会成为你绕不过去的一个坎。这道题同时包含格式化字符串漏洞和缓冲区溢出漏洞两个考点而且不是简单地让你分别利用而是需要把两个漏洞串起来打一套组合拳。我第一次做这道题的时候光理解为什么要先泄露出栈上的某个地址、再去做栈溢出跳转就花了大半天时间。但搞懂之后这类“信息泄露控制流劫持”的题目基本就有了通用的解题框架。这篇文章我会从零开始把这道题的每一个环节拆开讲清楚。包括怎么判断题目类型、怎么定位漏洞点、格式化字符串怎么用来泄露信息、栈溢出怎么计算偏移、怎么构造最终的payload。每一步我都会解释“为什么这么做”而不是只给一个脚本让你抄。适合已经了解PWN基本概念比如栈、寄存器、调用约定但还没有完整独立做过一道综合题的朋友。如果你连栈是什么都还不清楚建议先去补一下x86-64汇编基础和函数调用过程不然看下去会比较吃力。整道题的核心逻辑其实可以用一句话概括先用格式化字符串漏洞泄露一个已知函数的真实地址计算出libc基址然后再用栈溢出漏洞跳转到libc中的system函数执行“/bin/sh”拿到shell。听起来不复杂但中间有很多细节容易踩坑比如偏移量的计算、payload的对齐、libc版本的匹配等等。下面我会逐一展开。2. 题目整体设计与漏洞点定位2.1 拿到二进制文件后的第一轮检查拿到题目给的可执行文件第一步永远是做基础信息收集。我习惯用checksec先看一眼保护机制checksec Mary_Morton输出大概是这样Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)这几个信息非常关键。Canary found说明栈上有金丝雀保护直接栈溢出覆盖返回地址会触发canary检查导致程序崩溃。NX enabled说明栈不可执行不能往栈上写shellcode然后跳过去执行。No PIE说明程序本身的代码段地址固定方便我们做ROP或者跳转到程序内部的函数。Partial RELRO说明GOT表可写但这里我们主要利用的是libc中的函数所以RELRO的影响不大。看到Canary和NX同时存在就要意识到单纯的栈溢出不可行必须想办法绕过canary。而题目名字里提到了格式化字符串漏洞这通常就是用来泄露canary或者泄露某个地址的。所以整体思路就清晰了——用格式化字符串泄露信息再用栈溢出构造ROP链。2.2 用IDA或objdump分析程序逻辑接下来用IDA打开或者用objdump反汇编看主函数逻辑。程序一般不长大概结构是void main() { // 初始化 puts(Welcome to Mary Morton!); // 某个循环或菜单 while(1) { puts(1. Trigger the format string vulnerability); puts(2. Trigger the buffer overflow vulnerability); puts(3. Exit); // 读取用户选择 // 根据选择进入不同分支 } }两个功能分支分别对应两个漏洞功能1读取用户输入然后直接作为printf的第一个参数输出。典型的格式化字符串漏洞因为我们能控制格式串。功能2读取用户输入到一个栈上的缓冲区但没有检查长度。典型的栈溢出漏洞但因为有canary直接覆盖返回地址会失败。这里的关键在于功能1可以用来泄露canary的值因为canary通常位于栈上某个固定偏移处而格式化字符串可以通过%p或者%n$p的方式读取栈上的数据。一旦拿到canary功能2的栈溢出就可以放心地覆盖返回地址了。2.3 为什么选择“先泄露再溢出”而不是其他方案有人可能会问既然有格式化字符串漏洞能不能直接用它来写内存比如用%n改写返回地址理论上可以但实际操作中限制很多。首先%n写入的值是已经输出的字符数要写入一个64位地址需要输出非常多的字符效率极低且容易失败。其次格式化字符串写内存需要把目标地址放在栈上而这道题中我们控制的是格式串本身格式串在栈上的位置和返回地址的位置关系需要精确计算比较麻烦。相比之下用格式化字符串泄露canary再用栈溢出做ROP是更稳妥的方案。因为canary一旦泄露栈溢出就变成了常规操作偏移量计算也相对直接。而且No PIE意味着我们可以直接用程序中的gadget不需要额外泄露程序基址。所以这个方案在效率和可靠性上都更优。3. 格式化字符串漏洞泄露canary的实操细节3.1 格式化字符串漏洞的基本原理格式化字符串漏洞的本质是当printf的第一个参数格式串可以被用户控制时用户可以通过%p、%x、%s等格式说明符读取或写入栈上的数据。因为printf会根据格式串中的说明符去栈上取对应的参数而我们没有提供足够的参数它就会把栈上已有的数据当作参数来用。举个例子如果栈上某个位置存放着canary的值而我们能通过%n$pn是偏移量读到它那就成功泄露了。关键在于找到正确的偏移量n。3.2 确定canary在栈上的偏移实际操作中我一般先用一个简单的输入来探测输入: %p.%p.%p.%p.%p.%p.%p.%p.%p.%p然后观察输出中哪些值看起来像canary。canary在x86-64下有一个特点最低字节是0x00。所以如果看到某个输出以0x开头且末尾是00比如0x123456789abcdef00那大概率就是canary。但更高效的方法是用%n$p逐个尝试。比如输入: %1$p 输入: %2$p ...直到找到那个末尾为00的值。根据我的经验在这道题中canary通常出现在偏移量17或者18的位置具体取决于程序编译时的栈布局。假设我们测试发现%17$p输出的是canary那么就可以用这个偏移量来泄露。注意不同环境、不同编译选项下偏移量可能不同。一定要自己在本地跑一遍确认不要直接抄网上的偏移量。3.3 泄露canary的payload构造假设确认偏移量为17那么泄露canary的输入就是%17$p程序会输出类似0x123456789abcdef00这就是canary的值。但要注意canary的最低位是\x00这是设计上为了防止字符串溢出泄露canary而故意设置的。但在格式化字符串漏洞中我们可以直接读到完整的8字节值包括那个\x00因为%p是按指针宽度输出的。拿到这个值后在后续的栈溢出payload中我们需要把canary原样填回去注意最低字节要覆盖为\x00因为canary在栈上存储时最低字节就是\x00但如果我们从%p读到的值已经包含了\x00那直接使用即可。3.4 实操中容易踩的坑第一个坑是偏移量找错。有时候%17$p输出的并不是canary而是一个看起来很像但实际不是的值。我的做法是连续泄露多个偏移量的值然后对比哪个值在每次运行中保持不变canary每次运行会变但同一进程内不变而某些地址如栈地址每次运行会变。实际上canary在同一进程内是固定的但不同进程会变。所以更好的判断方法是看哪个值末尾是00且看起来随机。第二个坑是输出格式处理。%17$p输出的字符串是0x开头的十六进制我们需要用int()或者eval()把它转成整数然后再用p64()打包成字节。如果直接把这个字符串拼接到payload里肯定会出错。第三个坑是canary的最低字节。虽然%p输出的是完整的8字节但canary在栈上实际存储时最低字节是\x00。如果我们读到的值最低字节不是\x00那可能读错了位置。正常情况下%p读到的canary最低字节应该是00。4. 缓冲区溢出漏洞计算偏移与构造ROP链4.1 确定缓冲区到返回地址的偏移有了canary之后下一步就是利用栈溢出。首先需要确定从缓冲区起始位置到返回地址的偏移量。方法有两种方法一用cyclic模式。生成一个长字符串运行程序触发溢出看崩溃时RIP的值然后用cyclic工具找到偏移。但因为有canary保护直接溢出会先触发canary检查失败所以这个方法需要先绕过canary比如先泄露canary再在payload中填回正确的canary。方法二用IDA静态分析。直接看反汇编中缓冲区的大小和栈布局。比如sub rsp, 0x90 lea rax, [rbp-0x80] mov edi, 0 call read这里缓冲区在rbp-0x80那么从缓冲区到rbp的偏移就是0x80。返回地址在rbp8所以从缓冲区到返回地址的偏移是0x8080x88。再加上canary的位置通常在rbp-8所以payload结构是[0x80字节填充] [8字节canary] [8字节rbp] [8字节返回地址]但要注意canary在rbp-8所以缓冲区到canary的偏移是0x80-80x78。也就是说前0x78字节填充然后8字节canary然后8字节rbp然后返回地址。我一般会两种方法都做一遍互相验证。静态分析给出理论值动态调试确认实际值。4.2 确定要跳转的目标地址因为NX开启我们不能执行栈上的代码。但No PIE意味着程序本身的代码段地址固定而且程序中有system的PLT条目吗不一定。通常这类题目不会直接给你system的PLT而是需要你泄露libc基址然后跳转到libc中的system。所以我们需要泄露一个libc函数的真实地址比如puts或printf的GOT表项。根据泄露的地址和已知的libc版本计算libc基址。计算system函数和/bin/sh字符串在libc中的地址。构造ROP链调用system(/bin/sh)。但这里有个问题格式化字符串漏洞只能泄露栈上的数据而GOT表项在程序的数据段不在栈上。所以不能直接用格式化字符串泄露GOT表。那怎么办实际上我们可以用格式化字符串泄露栈上保存的返回地址或者某个libc函数的地址。因为程序在调用printf时栈上会保存调用者的返回地址这个返回地址位于libc中。通过泄露这个返回地址我们就能计算出libc基址。具体来说在功能1中调用printf时栈上某个位置保存着printf的返回地址即调用printf之后的下一条指令的地址这个地址在libc中。如果我们能泄露这个地址就能算出libc基址。所以偏移量的选择很关键既要能泄露canary又要能泄露libc地址。有时候需要泄露两个不同的偏移量。4.3 计算libc基址与system地址假设我们泄露了printf的返回地址记为leaked_addr。然后我们需要知道这个地址在libc中的偏移。这需要知道目标系统使用的libc版本。通常题目会提供一个libc文件或者我们可以根据泄露地址的低12位来推断。拿到libc文件后用readelf -s libc.so.6 | grep printf或者用objdump -T libc.so.6 | grep printf找到printf的偏移记为printf_offset。那么libc基址就是libc_base leaked_addr - printf_offset然后system的地址就是system_addr libc_base system_offset/bin/sh字符串的地址就是binsh_addr libc_base binsh_offset这些偏移量都可以从libc文件中查到。4.4 构造最终的ROP链在x86-64下函数调用的第一个参数放在rdi寄存器中。所以调用system(/bin/sh)需要先把/bin/sh的地址放到rdi然后调用system。我们需要一个pop rdi; ret的gadget。这个gadget可以在程序中找No PIE地址固定也可以在libc中找。用ROPgadget工具ROPgadget --binary Mary_Morton | grep pop rdi假设找到的gadget地址是pop_rdi_addr。那么最终的payload结构是[0x78字节填充] [canary] [8字节rbp填充] [pop_rdi_addr] [binsh_addr] [system_addr]但要注意栈对齐问题。在x86-64下调用system时栈需要16字节对齐否则可能会在system内部崩溃。如果遇到对齐问题可以在pop_rdi_addr之前加一个ret指令来调整栈。4.5 完整利用脚本示例下面是一个完整的利用脚本框架用pwntoolsfrom pwn import * # 连接 p process(./Mary_Morton) # p remote(target, port) # 泄露canary p.recvuntil(bExit) p.sendline(b1) p.recvuntil(binput) p.sendline(b%17$p) canary int(p.recvline().strip(), 16) log.info(fcanary: {hex(canary)}) # 泄露libc地址假设偏移量为某个值 p.recvuntil(bExit) p.sendline(b1) p.recvuntil(binput) p.sendline(b%19$p) # 假设19是保存返回地址的偏移 leaked_addr int(p.recvline().strip(), 16) log.info(fleaked: {hex(leaked_addr)}) # 计算libc基址 libc ELF(./libc.so.6) printf_offset libc.symbols[printf] libc_base leaked_addr - printf_offset system_addr libc_base libc.symbols[system] binsh_addr libc_base next(libc.search(b/bin/sh)) # 找gadget pop_rdi 0x400xxx # 根据实际找到的地址填写 # 构造payload payload bA * 0x78 payload p64(canary) payload bB * 8 payload p64(pop_rdi) payload p64(binsh_addr) payload p64(system_addr) # 触发栈溢出 p.recvuntil(bExit) p.sendline(b2) p.recvuntil(binput) p.sendline(payload) # 拿shell p.interactive()这个脚本是框架性的实际偏移量和地址需要根据具体题目调整。5. 常见问题与排查技巧实录5.1 泄露的canary不对导致程序崩溃这是最常见的问题。表现是填了canary之后程序还是崩溃提示stack smashing detected。原因可能有几个偏移量找错了%17$p读到的不是canary而是其他值。解决办法是逐个尝试偏移量找到那个末尾为00且看起来随机的值。canary的最低字节处理错误虽然%p输出的是完整8字节但canary在栈上最低字节是\x00。如果读到的值最低字节不是00那可能读错了。正常情况下应该以00结尾。payload中canary的位置放错了canary在rbp-8所以填充长度应该是缓冲区到canary的距离而不是到rbp的距离。需要仔细计算。5.2 libc版本不匹配导致system地址错误如果泄露的地址算出的libc基址不对或者跳转到system后没有拿到shell很可能是libc版本不对。解决办法题目如果提供了libc文件一定要用题目给的。如果没有提供可以用泄露地址的低12位去libc database网站查询但这里不能提具体网站只能说用工具查询。本地调试时可以用ldd查看程序链接的libc或者用pwntools的ELF加载本地libc。5.3 栈对齐问题导致system崩溃在x86-64下调用system时如果栈没有16字节对齐可能会在system内部的movaps指令处崩溃。表现是成功跳转到system但程序直接segfault。解决办法是在ROP链中加一个ret指令payload p64(pop_rdi) payload p64(binsh_addr) payload p64(ret_addr) # 加一个ret调整对齐 payload p64(system_addr)或者调整填充长度使栈对齐。5.4 格式化字符串泄露时程序输出被截断有时候%17$p输出的字符串可能被截断导致int()转换失败。原因是printf输出后可能跟了其他字符或者recvline读到了不完整的数据。解决办法是用recvuntil精确读取或者用正则表达式提取十六进制部分。5.5 常见问题速查表问题现象可能原因解决办法程序提示stack smashingcanary错误重新确认偏移量检查canary最低字节跳转后segfault栈对齐问题加ret指令调整对齐system地址错误libc版本不匹配使用题目提供的libc或查询正确版本泄露地址为0或异常偏移量错误逐个尝试偏移量确认泄露的是返回地址payload长度不对偏移计算错误用cyclic或IDA确认缓冲区大小程序无输出输入格式不对检查是否发送了换行符检查recvuntil的匹配字符串6. 个人实操体会与进阶建议这道题我反复做了好几遍每次都能发现之前忽略的细节。最大的体会是PWN题的核心不是写脚本而是理解程序的执行流程和内存布局。脚本只是最后一步前面的分析才是关键。如果你能清楚地画出程序运行时的栈布局图标出每个变量的位置、canary的位置、返回地址的位置那偏移量计算就是水到渠成的事。另外我强烈建议在本地用gdb配合pwndbg或者peda调试。在泄露canary的那一步可以在printf调用处下断点查看栈上的实际内容确认canary的位置。在栈溢出那一步可以在read返回后查看栈布局确认填充长度是否正确。动态调试看到的东西比静态分析直观得多。还有一个进阶方向这道题用的是格式化字符串泄露栈溢出ROP。如果题目开启了Full RELRO和PIE难度会更大需要泄露程序基址和libc基址可能还需要用one_gadget来简化利用。但核心思路是一样的——先泄露信息再控制流劫持。把这道题吃透后面遇到更复杂的题目就有了分析框架。最后分享一个小技巧在写利用脚本时我习惯把每个步骤的中间结果都打印出来canary值、泄露地址、libc基址、system地址等这样一旦出错可以快速定位是哪一步的问题。不要等到最后拿不到shell才去排查那时候信息太少很难判断。