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$p(n是偏移量)读到它,那就成功泄露了。关键在于找到正确的偏移量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。返回地址在rbp+8,所以从缓冲区到返回地址的偏移是0x80+8=0x88。再加上canary的位置(通常在rbp-8),所以payload结构是:[0x80字节填充] + [8字节canary] + [8字节rbp] + [8字节返回地址]但要注意,canary在rbp-8,所以缓冲区到canary的偏移是0x80-8=0x78。也就是说,前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 完整利用脚本示例下面是一个完整的利用脚本框架(用pwntools):from pwn import * # 连接 p = process('./Mary_Morton') # p = remote('target', port) # 泄露canary p.recvuntil(b'Exit') p.sendline(b'1') p.recvuntil(b'input') p.sendline(b'%17$p') canary = int(p.recvline().strip(), 16) log.info(f'canary: {hex(canary)}') # 泄露libc地址(假设偏移量为某个值) p.recvuntil(b'Exit') p.sendline(b'1') p.recvuntil(b'input') p.sendline(b'%19$p') # 假设19是保存返回地址的偏移 leaked_addr = int(p.recvline().strip(), 16) log.info(f'leaked: {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 = b'A' * 0x78 payload += p64(canary) payload += b'B' * 8 payload += p64(pop_rdi) payload += p64(binsh_addr) payload += p64(system_addr) # 触发栈溢出 p.recvuntil(b'Exit') p.sendline(b'2') p.recvuntil(b'input') 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才去排查,那时候信息太少,很难判断。
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$p(n是偏移量)读到它,那就成功泄露了。关键在于找到正确的偏移量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。返回地址在rbp+8,所以从缓冲区到返回地址的偏移是0x80+8=0x88。再加上canary的位置(通常在rbp-8),所以payload结构是:[0x80字节填充] + [8字节canary] + [8字节rbp] + [8字节返回地址]但要注意,canary在rbp-8,所以缓冲区到canary的偏移是0x80-8=0x78。也就是说,前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 完整利用脚本示例下面是一个完整的利用脚本框架(用pwntools):from pwn import * # 连接 p = process('./Mary_Morton') # p = remote('target', port) # 泄露canary p.recvuntil(b'Exit') p.sendline(b'1') p.recvuntil(b'input') p.sendline(b'%17$p') canary = int(p.recvline().strip(), 16) log.info(f'canary: {hex(canary)}') # 泄露libc地址(假设偏移量为某个值) p.recvuntil(b'Exit') p.sendline(b'1') p.recvuntil(b'input') p.sendline(b'%19$p') # 假设19是保存返回地址的偏移 leaked_addr = int(p.recvline().strip(), 16) log.info(f'leaked: {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 = b'A' * 0x78 payload += p64(canary) payload += b'B' * 8 payload += p64(pop_rdi) payload += p64(binsh_addr) payload += p64(system_addr) # 触发栈溢出 p.recvuntil(b'Exit') p.sendline(b'2') p.recvuntil(b'input') 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才去排查,那时候信息太少,很难判断。