1. 从一道经典PWN题说起:为什么Mary_Morton值得反复琢磨
如果你刚开始接触CTF的PWN方向,或者刷题刷到某个阶段觉得遇到了瓶颈,那Mary_Morton这个名字你大概率不会陌生。它是攻防世界平台上PWN类目里一道非常经典的入门级综合题,经典到什么程度呢?几乎每个PWN新手的学习路径上都会遇到它,而且很多人在这道题上卡了不止一次。
这道题的核心价值在于,它同时考察了两个PWN领域最基础也最重要的漏洞类型:格式化字符串漏洞和缓冲区溢出漏洞。更关键的是,它不是让你分别用这两种漏洞各打一次就完事,而是要求你在同一个二进制程序里,先利用格式化字符串漏洞泄露关键信息,再利用缓冲区溢出漏洞完成最终的控制流劫持。这种组合利用的思路,在真实场景的漏洞挖掘中非常常见。
我第一次做这道题的时候,格式化字符串部分还算顺利,但到了栈溢出环节,因为对栈布局的理解不够深入,调了很久才打通。后来回头复盘,发现其实每一步都有清晰的逻辑链条,只是当时对底层机制的理解还不够扎实。所以这篇文章,我打算把这道题从头到尾拆开揉碎,不仅告诉你每一步怎么做,更要讲清楚每一步为什么要这么做。适合的读者是:已经了解基本的栈溢出原理、会用IDA和GDB、但对综合漏洞利用还不太熟练的PWN初学者。如果你已经能独立完成大部分基础ROP题,这篇文章也能帮你梳理一遍格式化字符串和栈溢出组合利用的完整思路。
2. 题目整体设计与漏洞点定位
2.1 程序的基本结构与保护机制
拿到一个PWN题,第一步永远是先看保护机制。这不是走流程,而是直接决定了你后续的利用策略。用checksec跑一下,典型的Mary_Morton输出大概是这样:
[*] '/home/ctf/mary_morton' Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)逐条解读一下这些保护意味着什么。Canary found说明栈上有金丝雀值,直接栈溢出覆盖返回地址会触发__stack_chk_fail导致程序崩溃,所以不能硬来。NX enabled意味着栈上不可执行,不能往栈上写shellcode然后跳过去执行。No PIE是个好消息,说明代码段的加载地址固定,我们可以直接在IDA里看到函数地址,不需要额外泄露程序基址。Partial RELRO说明GOT表可写,但这里我们大概率用不到GOT覆写。
把这些信息串起来,利用思路的轮廓就出来了:因为Canary的存在,我们需要先泄露Canary的值;因为NX的存在,我们需要用ROP的方式调用程序里已有的函数(比如system)或者泄露libc地址后调用libc中的函数。而题目给了格式化字符串漏洞,正好可以用来泄露Canary和libc地址。
2.2 两个漏洞点的定位与分析
用IDA打开程序,主函数逻辑并不复杂。程序会打印一段菜单,让你选择功能。通常有两个选项:一个是格式化字符串相关的功能,另一个是栈溢出相关的功能。这正是这道题设计的精妙之处——两个漏洞分别对应两个功能入口,你需要先通过功能一泄露信息,再通过功能二完成利用。
格式化字符串漏洞通常出现在类似printf(buf)这样的代码中,其中buf是用户可控的输入。正常的写法应该是printf("%s", buf),但程序员漏掉了格式字符串参数,导致用户可以传入%p、%x、%s等格式说明符来读取栈上的数据。在这道题里,你可以通过输入特定的偏移量来读取栈上特定位置的值,包括Canary。
栈溢出漏洞则出现在另一个读取输入的函数中,通常是用read或gets读入一个固定大小的缓冲区,但没有检查输入长度。当你输入超过缓冲区大小的数据时,就会覆盖栈上的Canary、保存的RBP和返回地址。但因为有Canary保护,你必须把正确的Canary值原样写回去,才能安全地覆盖返回地址。
注意:很多新手在这里会犯一个错误——以为泄露了Canary就万事大吉了。实际上,你不仅要泄露Canary,还要确保在溢出时把Canary放在正确的位置上。Canary通常位于缓冲区末尾和保存的RBP之间,具体偏移量需要结合栈布局来确定。
2.3 组合利用的整体思路
整道题的利用链条可以概括为三个步骤:
- 泄露Canary:通过格式化字符串漏洞,读取栈上Canary的值。
- 泄露libc基址:同样通过格式化字符串漏洞,读取某个libc函数的GOT表项或者栈上的返回地址,计算出libc的加载基址。
- 构造ROP链:通过栈溢出漏洞,在保留正确Canary的前提下,覆盖返回地址为
system("/bin/sh")或者执行execve的ROP链。
这三步环环相扣,缺一不可。没有Canary,栈溢出就会被检测到;没有libc基址,就不知道system函数在内存中的实际地址;没有栈溢出,就无法劫持控制流。所以这道题虽然难度不算高,但非常考验你对整个利用链条的理解是否完整。
3. 核心细节解析与实操要点
3.1 格式化字符串漏洞的原理与偏移计算
格式化字符串漏洞的本质是printf函数会根据格式字符串中的说明符,从栈上依次读取参数。正常情况下,如果你写了printf("%p %p %p", a, b, c),它会打印a、b、c的值。但如果你只写了printf("%p %p %p"),它仍然会从栈上读取三个值打印出来,只不过这些值不是你传入的参数,而是栈上恰好存在的其他数据。
在64位程序中,前六个参数通过寄存器传递(RDI、RSI、RDX、RCX、R8、R9),从第七个参数开始才通过栈传递。但printf的格式字符串本身是第一个参数,放在RDI中。所以当你输入%p时,它实际上读取的是RSI中的值;输入第二个%p读取RDX,以此类推。当寄存器用完后,才会从栈上读取。
这就引出了一个关键问题:Canary在栈上的位置对应第几个%p?这个偏移量不是固定的,需要根据具体的程序来确定。常用的方法是在GDB中下断点,观察栈上的布局,或者直接用%p逐个试探,看哪个输出值以00结尾(Canary的典型特征)。
在实际操作中,我通常会用这样的payload来定位:
payload = "%p." * 20然后观察输出,找到那个看起来像Canary的值。在Mary_Morton中,Canary通常出现在第17个%p左右的位置(具体偏移因环境而异,需要实测)。一旦确定了偏移,就可以用%17$p这样的格式直接读取指定位置的值。
实操心得:不同版本的libc和不同的程序编译选项可能会影响栈布局,所以偏移量一定要自己实测。不要直接抄别人的偏移量,否则很可能打不通。我一般会写一个小脚本,循环打印不同偏移的值,然后人工识别Canary。
3.2 Canary的识别与泄露技巧
Canary值有一个非常明显的特征:它的最低字节是\x00。这是设计上的要求,因为Canary的作用是检测栈溢出,而字符串操作通常以\x00结尾,如果Canary的最低字节不是\x00,那么字符串函数在读取时就会把Canary也读进去,导致误报。所以Canary的最低字节被强制设为\x00,这样字符串函数遇到它就会停止。
在泄露Canary时,如果你用%p读取,打印出来的值通常不会显示前导的\x00。比如Canary的实际值是0x00a1b2c3d4e5f600,但%p打印出来可能是0xa1b2c3d4e5f600。你需要手动在最高位补上\x00,或者在构造payload时注意这一点。
另外,Canary在每次程序运行时都会变化,所以你不能硬编码一个值。必须在同一次程序运行中,先泄露Canary,再立即进行栈溢出。如果程序重新启动,Canary就会变,之前的泄露就白费了。这也是为什么这类题目通常要求你在一次连接中完成所有操作。
3.3 栈溢出的偏移计算与ROP链构造
栈溢出的偏移计算是另一个容易出错的环节。你需要知道从缓冲区起始位置到Canary的偏移量,以及从Canary到返回地址的偏移量。这两个偏移量加起来,才是你覆盖返回地址所需的填充长度。
在64位程序中,栈布局通常是这样的:
[缓冲区起始位置] ... [Canary] <- 缓冲区末尾 + 8字节对齐 [保存的RBP] <- Canary + 8 [返回地址] <- 保存的RBP + 8所以,如果你知道了缓冲区的大小和Canary的位置,就可以计算出填充长度。在Mary_Morton中,缓冲区通常是0x80字节(128字节),Canary位于缓冲区之后,所以填充长度是0x80 + 8(Canary本身占8字节),然后再加上保存的RBP的8字节,才是返回地址的偏移。
构造ROP链时,因为NX开启,我们不能执行栈上的代码,所以需要利用程序已有的代码片段(gadgets)或者调用libc中的函数。常见的做法是:
- 泄露libc基址后,计算
system函数和/bin/sh字符串的地址。 - 构造ROP链:
pop rdi; ret+/bin/sh地址 +system地址。 - 将ROP链写入栈溢出payload中,覆盖返回地址。
注意:在64位程序中,函数调用的第一个参数通过RDI传递,所以你需要一个
pop rdi; ret的gadget来设置RDI的值。这个gadget可以用ROPgadget或者ropper工具在程序中查找。
3.4 工具链的选择与使用要点
做PWN题离不开几个核心工具:pwntools用于编写exp,GDB配合pwndbg或peda用于调试,IDA用于静态分析,ROPgadget用于查找gadgets。这几个工具的组合基本能覆盖大部分PWN题的需求。
pwntools的remote和process函数可以方便地连接远程或本地程序,sendlineafter和recvuntil可以精确控制输入输出。在编写exp时,我习惯先用process在本地调试,确认无误后再切换到remote打远程。
GDB配合pwndbg是我调试时的首选。stack命令可以直观地看到栈布局,telescope命令可以查看指定地址附近的内存内容,canary命令可以直接显示Canary的值。这些功能在定位偏移量和验证泄露结果时非常有用。
ROPgadget的用法很简单:
ROPgadget --binary mary_morton --only "pop|ret"这条命令会列出所有pop和ret相关的gadget。找到pop rdi; ret后,记下它的地址,在构造ROP链时使用。
4. 完整实操过程与核心环节实现
4.1 环境准备与初始侦察
先把题目文件下载到本地,赋予执行权限,然后用checksec查看保护机制。这一步前面已经讲过,不再赘述。接下来用IDA打开,找到主函数和两个关键功能函数。
在IDA中,我通常会先看函数的调用图(View -> Graphs -> Function calls),快速了解程序的控制流。然后逐个查看关键函数,重点关注printf、read、gets等危险函数的调用。在Mary_Morton中,你会看到两个明显的功能入口:一个调用了printf且格式字符串可控,另一个调用了read且缓冲区大小固定但没有长度检查。
用GDB加载程序,在printf调用处下断点,运行程序,输入一串%p,观察栈上的输出。这一步的目的是确定Canary的偏移量。我一般会输入%p.重复20次,然后数输出中第几个值看起来像Canary。
4.2 泄露Canary与libc基址
确定偏移量后,编写泄露脚本。假设Canary在第17个位置,payload就是%17$p。发送这个payload后,程序会打印出Canary的值。用pwntools的recvline接收输出,然后用int解析出这个值。
from pwn import * p = process('./mary_morton') # 选择功能一 p.recvuntil(b'Exit the battle') p.sendline(b'2') # 发送格式化字符串payload p.recvuntil(b'0x') canary = int(p.recvline().strip(), 16) # 补上最低字节的\x00 canary = canary & ~0xff # 或者 canary = canary | 0x00 log.info(f'Canary: {hex(canary)}')泄露libc基址的思路类似,但需要找到一个指向libc的指针。常见的目标是GOT表中的某个函数地址,比如printf的GOT表项。你可以用%s来读取GOT表项指向的地址,或者用%p读取栈上的返回地址。在Mary_Morton中,栈上通常会有__libc_start_main的返回地址,通过泄露这个地址,再减去已知的偏移量,就能得到libc基址。
# 泄露__libc_start_main的地址 p.recvuntil(b'Exit the battle') p.sendline(b'2') p.sendline(b'%p') # 假设偏移量对应__libc_start_main libc_addr = int(p.recvline().strip(), 16) libc_base = libc_addr - libc.symbols['__libc_start_main'] log.info(f'libc base: {hex(libc_base)}')实操心得:泄露libc地址时,偏移量可能和Canary的偏移量不同,需要单独确定。我通常会在GDB中查看栈上的返回地址,找到那个指向libc的值,然后计算它在格式化字符串中的位置。另外,libc的版本也很重要,不同版本的libc中函数偏移量不同,需要根据题目提供的libc文件或者通过泄露多个地址来确定版本。
4.3 构造ROP链并完成利用
有了Canary和libc基址,就可以构造最终的栈溢出payload了。payload的结构是:
[填充字节] + [Canary] + [保存的RBP] + [ROP链]填充字节的长度等于缓冲区大小减去Canary的偏移量。在Mary_Morton中,缓冲区通常是0x80字节,Canary紧跟在缓冲区之后,所以填充长度是0x80。然后写入8字节的Canary值,再写入8字节的假RBP(随便填),最后是ROP链。
ROP链的构造:
pop_rdi = 0x4008da # 假设的gadget地址,需要实际查找 bin_sh = libc_base + next(libc.search(b'/bin/sh')) system = libc_base + libc.symbols['system'] payload = b'A' * 0x80 payload += p64(canary) payload += p64(0) # 假RBP payload += p64(pop_rdi) payload += p64(bin_sh) payload += p64(system)发送payload后,如果一切顺利,就能拿到shell。用p.interactive()进入交互模式,执行cat flag即可。
p.recvuntil(b'Exit the battle') p.sendline(b'1') # 选择栈溢出功能 p.sendline(payload) p.interactive()4.4 本地调试与远程验证
本地调试时,我习惯在GDB中下断点,逐步执行,观察每一步的寄存器和栈变化。特别是在发送payload之前,确认Canary的值是否正确,ROP链的地址是否计算正确。如果程序崩溃,用GDB查看崩溃时的栈回溯,通常能快速定位问题。
远程验证时,把process换成remote,填入题目提供的IP和端口。注意远程环境的libc版本可能与本地不同,需要用题目提供的libc文件或者通过泄露多个函数地址来确定版本。如果远程打不通,先检查libc版本是否匹配,再检查偏移量是否正确。
注意:远程利用时,网络延迟可能会影响
recvuntil的匹配。我通常会在recvuntil中加上超时参数,或者用recvregex来匹配更灵活的模式。另外,如果远程程序有ASLR,每次运行的地址都会变,所以必须在同一次连接中完成泄露和利用。
5. 常见问题与排查技巧实录
5.1 格式化字符串偏移量定位失败
这是新手最常遇到的问题。你输入%p,但输出的值看起来都不像Canary。可能的原因有几个:一是偏移量不对,Canary可能在更靠后的位置;二是程序可能对输入做了过滤,比如限制了输入长度,导致你的payload被截断;三是你选择的格式化字符串位置不对,可能读到了其他无关的值。
排查方法:先用%p.重复足够多次(比如30次),把所有输出都打印出来,然后人工寻找以00结尾的值。如果找不到,尝试用%x代替%p,或者用%1$p、%2$p这样的格式逐个试探。另外,检查程序的输入函数是否有长度限制,如果有,可能需要调整payload的长度。
5.2 Canary泄露后程序崩溃
有时候你成功泄露了Canary,但在进行栈溢出时程序却崩溃了。这通常是因为Canary的值没有正确写回,或者写回的位置不对。检查一下你的payload中Canary的位置是否正确,以及Canary的值是否完整(包括最低字节的\x00)。
另一个可能的原因是,程序在栈溢出之前可能还有其他检查,比如检查某个变量的值是否被修改。在Mary_Morton中,通常没有这类检查,但其他题目可能会有。用GDB在栈溢出函数返回处下断点,观察Canary是否被正确恢复。
5.3 libc基址计算错误
libc基址计算错误通常是因为泄露的地址不是你以为的那个函数的地址,或者libc版本不匹配。排查方法:在GDB中查看泄露的地址对应的是哪个函数,确认它的偏移量。如果用的是题目提供的libc文件,用readelf -s libc.so.6 | grep 函数名查看偏移量。如果题目没有提供libc,可以用libc-database等工具通过泄露的多个地址来识别版本。
5.4 ROP链构造后无法getshell
ROP链构造正确但无法getshell,可能的原因有:system函数的地址计算错误、/bin/sh字符串的地址计算错误、pop rdi; ret的gadget地址错误、或者栈对齐问题。在64位程序中,system函数要求栈16字节对齐,如果不对齐可能会导致崩溃。解决方法是在ROP链中插入一个额外的retgadget来调整栈对齐。
payload += p64(pop_rdi) payload += p64(bin_sh) payload += p64(ret) # 额外的ret用于栈对齐 payload += p64(system)5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 找不到Canary | 偏移量错误 | 用%p.打印30个值逐个检查 | 调整偏移量,用%N$p精确定位 |
| 泄露Canary后崩溃 | Canary写回位置错误 | GDB断点观察栈布局 | 确认Canary偏移和写入位置 |
| libc基址错误 | 泄露地址对应函数错误 | GDB查看泄露地址对应函数 | 重新确定泄露目标和偏移量 |
| ROP链不执行 | 栈对齐问题 | GDB观察崩溃时的RSP | 插入额外ret gadget调整对齐 |
| 远程打不通 | libc版本不匹配 | 对比本地和远程libc | 使用题目提供的libc或识别版本 |
实操心得:我在做这类题时,习惯把每一步的中间结果都打印出来,比如Canary的值、libc基址、各个gadget的地址。这样即使某一步出错,也能快速定位是哪个环节的问题。另外,本地调试通过后,不要急着打远程,先用本地libc验证一遍,确认exp的鲁棒性。
6. 从这道题延伸出的学习路径
Mary_Morton这道题虽然基础,但它涵盖的知识点非常典型。格式化字符串漏洞和栈溢出漏洞是PWN领域的两大基石,几乎所有的进阶题目都是在这两个基础上做变化和组合。掌握了这道题,你就可以进一步学习更复杂的利用技巧,比如:
- 格式化字符串的任意地址写:不仅读取数据,还可以向任意地址写入数据,用于覆写GOT表或函数指针。
- ROP链的高级构造:当程序中没有合适的gadget时,如何利用libc中的gadget,或者使用ret2csu等技术。
- 堆漏洞利用:从栈溢出延伸到堆溢出,学习UAF、double free、fastbin attack等堆利用技术。
- 绕过更复杂的保护:比如Full RELRO、PIE、ASLR的组合绕过。
我个人建议的学习路径是:先把Mary_Morton这类基础题刷熟,确保每一步都能独立完成,然后再逐步挑战更难的题目。每道题做完后,不要急着看下一题,而是回头复盘,把利用链条画出来,把每个偏移量的计算过程写清楚。这样积累下来,你的PWN能力会有质的提升。
最后分享一个小技巧:在做题时,养成写exp模板的习惯。把常用的函数封装好,比如leak_canary()、leak_libc()、build_rop()等。这样遇到新题时,只需要修改偏移量和gadget地址,就能快速构造出exp。这个习惯能帮你节省大量时间,也能减少低级错误。