CTF实战:格式化字符串漏洞五大利用手法详解 1. 项目概述从一道CTF题看格式化字符串漏洞的实战价值最近在带新人打CTF发现很多朋友一遇到格式化字符串漏洞Format String Vulnerability的Pwn题就有点发怵。这玩意儿原理听起来不复杂但真到了实战利用面对一堆%x、%p和内存地址往往不知道从何下手。正好CTFshow平台上有不少经典的格式化字符串漏洞题目从入门到进阶覆盖了各种利用场景。我打算结合这些实战题目把格式化字符串漏洞最常见的五种利用手法掰开揉碎了讲清楚。这不仅仅是解题更是理解漏洞利用链路的绝佳机会。无论你是刚接触二进制安全的新手还是想系统梳理一下利用技巧的老兵相信这篇从实战中总结出来的经验都能让你有所收获。格式化字符串漏洞的本质是程序将用户输入直接作为格式化字符串参数如printf(user_input)而没有使用正确的格式化占位符如printf(“%s”, user_input)。攻击者通过精心构造的输入可以达成读取栈内存、覆写内存数据、甚至执行任意代码的目的。接下来我们就以CTFshow的题目为战场逐一拆解这五种核心手法。2. 漏洞原理与利用基础栈布局与格式化符探秘在深入利用手法之前我们必须把地基打牢。格式化字符串漏洞的利用高度依赖于对函数调用时栈内存布局的理解。2.1 栈帧结构与参数传递当调用printf(format)时参数format的地址会被压入栈中。如果format是用户可控的字符串那么该字符串本身通常存储在栈的更高地址如main函数的局部变量区或堆上。但关键在于printf会将其第一个参数之后的栈内容视为它要打印的变量。在32位系统中参数通过栈传递在64位系统中前六个参数通过寄存器rdi, rsi, rdx, rcx, r8, r9传递多出的参数才用栈。这对于我们的利用姿势有决定性影响。以一个简单的漏洞函数为例void vuln() { char buf[100]; read(0, buf, 99); printf(buf); // 漏洞点 }当printf执行时它期望buf的内容是一个格式化字符串。如果我们输入%pprintf会将其解释为“打印一个指针”。由于我们没有提供对应的变量参数printf就会去“预期”的栈位置上取数据——这个位置就是调用printf时栈顶指针ESP或RSP所指向的下一个位置。简单来说我们输入的格式化字符串本身在栈上的位置决定了我们能用%n$pn为整数这样的格式访问到哪些内存数据。注意这里的n$是POSIX规范的扩展表示直接指定使用第n个参数。例如%3$p表示打印第三个参数从格式化字符串本身算起处的值。并非所有环境都支持但在CTF中常见。2.2 核心格式化占位符详解我们的武器库就是这些格式化占位符每个都有其独特作用%p、%x、%d用于泄露内存数据。%p以指针格式带0x前缀输出%x以十六进制无前缀输出%d以十进制输出。通过它们我们可以窥探栈上的内容寻找返回地址、libc地址、canary等关键信息。%s用于泄露任意地址的字符串。它会把对应参数解释为一个指针并打印该指针指向的字符串直到遇到空字节。这是获取.got.plt表中函数真实地址从而计算libc基址的关键。%n、%hn、%hhn用于向任意地址写入数据。这是漏洞利用从“读”到“写”的质变点。%n将截至目前已成功输出的字符数量写入到对应参数所指向的地址该参数应是一个指针。写入的数据类型是int4字节。%hn写入short类型2字节。%hhn写入char类型1字节。 通过组合使用%hhn和精确控制输出字符数我们可以逐字节地写入任意数据例如将一个函数的GOT表项修改为system函数的地址。%c用于精确控制输出字符的数量。%c会打印一个字符我们可以通过%Nc来输出N个字符N为整数从而增加%n系列写入器的计数。这是实现精确写入的关键。%a有时用于泄露浮点数或特定格式数据在格式化字符串漏洞中较少作为主要利用手段。理解这些之后我们面对一堆%p打印出的十六进制数就不再是天文数字了。它们可能是栈地址、代码地址、libc地址甚至是我们的输入字符串本身。下一步就是如何组织这些“武器”达成攻击目标。3. 手法一信息泄露Memory Leak—— 窥探内存的奥秘信息泄露是几乎所有格式化字符串漏洞利用的第一步目的是获取关键的内存地址为后续的利用铺平道路。CTFshow的入门题常常从这里开始。3.1 确定偏移与栈空间测绘我们的第一个任务是找到我们的输入字符串在栈上的“参数位置”。通常采用“探针”法。假设我们输入AAAA%p,%p,%p,%p,%p,%p,%p程序输出可能类似0xffffd580, 0x64, 0xf7fa4580, 0xffffd5a4, 0x41414141, 0x2c70252c, 0x252c7025看到0x41414141了吗那就是AAAA的十六进制表示‘A’的ASCII码是0x41。它出现在第5个%p对应的位置。这意味着在这个调用上下文中我们的格式化字符串是printf的第5个参数。那么我们可以用%5$p来直接读取这个位置的值。更一般地如果我们输入AAAA%n$p并递增n当输出变为0x41414141时就找到了偏移n。实操心得在本地调试时由于环境差异ASLR、栈布局偏移可能与远程服务器不同。一个可靠的方法是先发送一串类似%p.*20的payload通过分隔符如.清晰地看到所有输出再寻找规律。有时题目会直接给出偏移或通过多次尝试可以确定。3.2 泄露关键数据Canary、PIE与Libc找到偏移后我们就可以有目的地泄露数据了。泄露栈地址与CanaryCanary栈保护通常位于栈上某个固定偏移处。通过泄露栈地址我们可以计算出canary的位置然后用%偏移$p将其读出来。在后续覆盖返回地址时需要保持canary的值不变否则程序会因检测到栈破坏而崩溃。泄露程序基址PIE如果程序开启了PIE位置无关可执行文件那么代码段的地址是随机的但偏移是固定的。我们可以泄露一个来自.text段的地址例如某个函数的返回地址或main的地址然后减去其在二进制文件中的偏移就得到了程序的基址。有了基址我们就能计算出任意got表或plt表的运行时地址。泄露Libc基址这是获取shell的关键。printf、puts、read等库函数在程序全局偏移表.got.plt中的地址在程序运行时会被解析为libc中的实际地址。我们可以用%偏移$s来打印这些地址。例如如果printf的GOT表项地址位于我们可控的某个偏移处使用%s打印就能得到libc中的printf地址。然后减去libc中printf的偏移就得到了libc的基址。进而可以计算出system、/bin/sh字符串等关键符号的地址。CTFshow例题解析在一道入门题中题目可能只给了简单的格式化字符串漏洞。我们的payload可能像这样# 假设通过探针确定格式化字符串本身是第6个参数 # 1. 泄露puts的got表地址内容即libc中的puts地址 payload p32(puts_got) b%6$s # 注意这里puts_got的地址会被放在栈上%6$s会将其作为指针打印指向的字符串直到NULL。由于puts_got地址处存放的是一个地址即libc puts所以会打印出4或8字节的地址数据。 send(payload) leak u64(recv(6).ljust(8, b\x00)) # 接收并解包 libc_base leak - libc.symbols[puts]这里有一个关键点%s需要其对应的参数是一个指针。所以我们需要先把目标地址如puts_got写入栈中然后让%n$s去读这个地址指向的内容。4. 手法二任意地址读Arbitrary Read—— 用%s撬开内存之门任意地址读是信息泄露的进阶形式它意味着我们可以读取程序内存空间中任意指定地址的内容而不仅仅是栈上相邻的数据。这通常通过组合使用%s和栈上可控的地址来实现。4.1 原理与Payload构造核心思路是将我们想要读取的目标地址写入到栈上某个我们已知偏移的位置然后使用%偏移$s去打印它。假设我们通过探针发现我们输入的字符串的前4个字节32位正好对应printf的第5个参数。那么我们构造payload[目标地址] “%5$s”。[目标地址]这4个字节会被放在栈上占据第5个参数的位置。printf执行时遇到%5$s它会将第5个参数的值即我们写入的目标地址作为一个指针去打印该指针指向的字符串直到遇到空字节\x00。这就完成了一次任意地址读。如果想读取多个地址可以在栈上连续放置多个地址并用%5$s%6$s...依次读取。但要注意栈对齐问题64位下地址是8字节。4.2 实战应用dump内存与寻找敏感信息在CTF中任意地址读可以用于泄露整个GOT表获取多个libc函数地址用于双重验证libc基址或应对未知libc版本。搜索程序中的敏感字符串如/bin/sh、flag、admin等。通过遍历.data段或堆上的地址可能发现隐藏的线索。泄露程序代码段.text在某些题目中可能需要绕过一些检查泄露部分代码逻辑。注意事项使用%s进行任意地址读时必须确保目标地址是可读的并且以空字节结尾。如果目标地址不可读如未映射的内存程序会崩溃Segmentation Fault。如果目标地址没有空字节printf会一直打印下去直到遇到空字节或崩溃这可能泄露大量内存但也可能破坏输出缓冲区。CTFshow例题场景一道题目可能隐藏了一个admin_password在全局变量中。我们通过逆向工程找到了这个变量的地址0x804c060。利用格式化字符串漏洞我们可以构造payload p32(0x804c060) b%5$s如果输出不是乱码而是一串可读字符那很可能就是密码。这比盲目的栈扫描要高效得多。5. 手法三任意地址写Arbitrary Write—— %n家族的魔法如果说任意地址读是“侦察”那么任意地址写就是“攻城”。通过%n及其变体我们可以向内存中写入数据这是实现控制流劫持的关键。5.1 %n写入原理与宽度控制%n的神奇之处在于它写入的值是到它出现时printf已经成功输出到屏幕或缓冲区的字符总数。例如int bytes_written; printf(Hello%n, bytes_written); // bytes_written 的值将是 5 (Hello的长度)我们可以通过控制输出字符的数量来控制写入的值。最直接的方法是利用格式说明符中的宽度字段。例如%100c会输出100个字符前面填充空格。所以%100c%n会向指定地址写入数字100。5.2 单次写入与逐字节写入%hhn然而我们通常需要写入的是一个具体的地址值比如0xdeadbeef或system的函数地址0xf7e13660。这个值可能很大对应十进制数巨大一次性输出这么多字符不现实且可能受缓冲区限制。这时就需要用到%hhn写1字节和分次写入的策略。思路是将一个4字节32位或8字节64位的地址拆分成多个1字节的写入操作分别写入目标地址的不同字节位。例如我们要向地址0x804c014GOT表中printf项写入值0xf7e13660system地址。假设0x804c014处原值为0xf7e4c6a0printf的地址。我们需要修改的四个字节分别是0x60低字节,0x36,0xe1,0xf7高字节。我们可以安排四次%hhn写入向0x804c014写入0x60向0x804c015写入0x36向0x804c016写入0xe1向0x804c017写入0xf7如何控制每次写入的值呢我们需要精确计算每次%hhn执行前已输出的字符总数。这通常通过排列组合%c的宽度和静态字符串长度来实现并注意写入顺序一般从低字节开始写因为写入低字节时所需输出的字符数较少。构造示例概念性# 假设我们需要向addr1, addr2, addr3, addr4写入value1, value2, value3, value4 (1字节 each) # 并且我们可以控制栈上从偏移5开始放置地址 payload p32(addr1) p32(addr2) p32(addr3) p32(addr4) written len(payload) # 目前已经输出的字节数 # 计算每个%hhn前需要输出的总字符数 target1 value1 target2 value2 target3 value3 target4 value4 # 注意如果target小于当前已输出数可以通过溢出写入target256来解决因为只写1字节。 payload f%{(target1 - written) 0xff}c%5$hhn.encode() written target1 payload f%{(target2 - written) 0xff}c%6$hhn.encode() written target2 payload f%{(target3 - written) 0xff}c%7$hhn.encode() written target3 payload f%{(target4 - written) 0xff}c%8$hhn.encode()这个过程非常繁琐通常需要脚本辅助计算。在CTF中我们常使用pwntools库的fmtstr_payload函数来自动生成此类payload它封装了所有的偏移计算和宽度控制。6. 手法四覆写GOT表劫持控制流这是格式化字符串漏洞利用的经典目标也是CTFshow中高级题目的常见考点。全局偏移表GOT在程序运行时存储着外部函数如libc中的函数的实际地址。如果我们能修改某个GOT表项比如将printf的GOT项改为system的地址那么下次程序调用printf时实际上就会跳转到system去执行。6.1 利用链设计一个完整的利用链通常如下信息泄露利用漏洞泄露至少一个libc函数的地址如printf计算libc基址进而得到system和/bin/sh的地址。确定写入目标选择要覆盖的GOT表项。通常选择printf、strlen、atoi等接下来很快会被调用的函数或者选择exit、__stack_chk_fail等即使程序即将结束也会调用的函数。构造写入Payload使用上述任意地址写手法通常是%hhn组合将system的地址写入目标GOT表项。触发函数调用确保程序在执行我们的payload后会调用被我们覆盖的函数。如果覆盖的是printf本身那么本次printf调用结束后才会使用新的GOT项所以可能需要第二次触发漏洞。更常见的做法是覆盖另一个函数然后让程序正常执行流去调用它。传递参数如果劫持的是system我们需要确保在调用时其参数RDI寄存器或栈上指向字符串/bin/sh的地址。这可能需要额外的栈布局操作或ROP链配合但在简单的格式化字符串漏洞中有时可以通过巧妙的输入让原本传递给printf的格式化字符串参数恰好成为system的参数。6.2 CTFshow例题实战拆解假设有一题程序循环读取用户输入并用printf输出存在明显的格式化字符串漏洞并且之后会调用exit函数。泄露libc发送%p或%spayload泄露出printf或puts的地址计算出libc基址和system地址。选择目标我们选择覆盖exit的GOT表项。因为程序结束后一定会调用exit。自动生成payload使用pwntools。from pwn import * context.arch i386 # 32位程序 # 假设 offset 为 6即我们的输入是第6个参数 # exit_got 是 exit函数GOT表地址 # system_addr 是计算出的system函数地址 payload fmtstr_payload(6, {exit_got: system_addr})fmtstr_payload会自动帮我们计算如何分字节写入system_addr到exit_got。发送payload将生成的payload发送给程序。获取shell当程序执行流结束调用exit时实际跳转到system。但此时system的参数在32位下是栈上的下一个值需要是/bin/sh的地址。如果exit调用时栈上下一个值不可控这种方法可能失败。因此更稳妥的方案是同时覆盖exit的GOT和栈上的参数或者寻找其他调用链。更高级的题目可能会结合栈溢出或者需要先泄露canary和程序基址PIE再利用格式化字符串写GOT。这就需要我们将多种漏洞利用技术结合起来。7. 手法五覆写栈上返回地址或函数指针除了GOT表栈上的数据也是重要的攻击目标。尤其是函数的返回地址和函数指针。7.1 覆盖返回地址实现ROP在栈溢出保护如NX, Canary齐全的情况下直接覆盖返回地址可能困难。但格式化字符串漏洞的写操作是“精确制导”的它不依赖于连续的缓冲区溢出因此可以绕过canary检查。我们可以直接计算返回地址在栈上的位置然后用%n或%hn将其覆盖为我们想要的地址例如一个one_gadgetlibc中一段能直接启动execve(‘/bin/sh’)的短序列的地址或者一个ROP链的起始地址。步骤泄露栈地址计算出当前函数返回地址的确切位置相对于某个泄露点的偏移。使用任意地址写将返回地址覆盖为one_gadget地址。当函数返回时直接跳转到one_gadget获取shell。实操心得one_gadget对执行时的环境寄存器状态、栈状态有严格要求。在格式化字符串漏洞利用后环境可能不满足其条件导致利用失败。需要多尝试几个不同的one_gadget或者通过部分ROP链来设置寄存器。7.2 覆盖函数指针或HOOK程序中可能包含一些函数指针比如FILE结构体中的vtable、动态注册的信号处理函数、atexit处理程序等。如果这些指针存储在栈或全局变量中并且其地址可知那么也可以通过格式化字符串漏洞进行覆盖从而在特定事件发生时劫持控制流。例如覆盖__malloc_hook或__free_hook是堆利用中的常见技术。虽然它们通常不在栈上但如果题目中存在全局变量存储用户输入且能通过格式化字符串泄露其地址并写入也有可能实现。7.3 综合案例绕过保护获取Shell考虑一道综合题程序开启NX、PIE、Canary有格式化字符串漏洞但没有明显的栈溢出。我们的利用思路可能是利用格式化字符串泄露泄露栈地址计算canary位置和返回地址位置。泄露程序基址通过.text段地址计算GOT表地址。泄露libc基址通过GOT表计算system、/bin/sh、one_gadget。利用格式化字符串写入方案A覆盖printf的GOT为system并确保下一次printf的参数是/bin/sh。这可能需要精心布局栈上的数据让/bin/sh字符串地址出现在合适的位置。方案B覆盖返回地址为one_gadget。需要检查one_gadget的约束条件。方案C覆盖某个即将被调用的函数指针如果存在。触发让程序执行流按照我们修改的路径走。8. 常见问题与排查技巧实录在实际操作和CTF比赛中利用格式化字符串漏洞时总会遇到各种“坑”。这里记录一些典型问题和解决思路。8.1 Payload发送后程序崩溃或无输出问题构造的payload发送后程序直接崩溃收到SIGSEGV信号或没有任何输出。排查检查地址对齐在64位系统中栈上参数要求16字节对齐。如果payload中嵌入的地址没有正确对齐可能导致printf解析参数时访问错误地址。确保地址放置在8字节边界上地址前可能需填充若干字节。检查%s读取的地址确保用%s读取的地址是有效的、可读的。尝试先用%p打印该地址的内容看看是不是一个合理的指针。检查%n写入的地址确保用%n写入的地址是可写的。尝试写入一个已知的可写地址如.bss段进行测试。检查格式化字符串本身过多的%c可能会产生巨大的输出填满缓冲区导致异常。可以尝试减小写入的值或使用%hhn分多次写入。本地与远程差异偏移可能不同。重新探测远程偏移。8.2 泄露的地址看起来不对问题泄露出的libc地址计算出的基址看起来无效例如不是以0x7f开头且后跟00的64位地址。排查字节序问题确保接收数据后正确解包。32位用p32/u3264位用p64/u64。注意地址可能打印不全需要接收足够字节并补齐。泄露错了目标确认你泄露的确实是GOT表项的内容。有时栈上可能有多级指针。用调试器gdb在漏洞点下断点查看栈布局确认你使用的偏移指向的是正确的内存单元。Libc版本不对CTF题目可能使用特定版本的libc。用泄露出的地址的后12位最后3个十六进制数字去libc数据库如https://libc.blukat.me搜索确定准确的libc版本。8.3 使用pwntools的fmtstr_payload失败问题fmtstr_payload(offset, writes)生成的payload执行后没有达到预期效果。排查偏移offset错误这是最常见的原因。务必通过%p探针法或题目提示准确确认偏移。offset参数指的是我们输入的格式化字符串本身在printf参数列表中的位置。写入大小write_sizefmtstr_payload默认使用‘byte’即%hhn进行单字节写入。如果环境不支持%hhn需要指定write_sizeshort%hn或write_sizeint%n。自动计算的长度问题自动生成的payload可能因为长度问题导致地址在栈上的位置发生偏移。可以尝试在payload前添加或减少一些填充字符如‘A’然后重新计算偏移。手动验证将生成的payload在本地调试环境中单步执行观察每一步%n写入的内存值是否符合预期。8.4 如何应对不支持%n$格式的环境问题有些环境如某些嵌入式设备或严格编译选项可能不支持POSIX的%n$直接定位参数。解决方案采用“堆叠”参数的方式。通过输入大量的%p或%x来遍历栈直到找到我们能控制的内存区域通常是我们输入的长字符串本身。然后通过精确控制输入字符串中特定位置的内容作为地址并用连续的格式化符如%c%c...%s来定位到该处进行读写。这需要更精细的栈布局计算。格式化字符串漏洞的利用就像一场精密的记忆手术需要对内存布局有清晰的认知对格式化符的行为有精准的控制。从简单的信息泄露到复杂的任意地址写每一步都充满了挑战和乐趣。在CTFshow的题目中反复练习这些手法是掌握这门艺术的最佳途径。记住调试器gdb-peda/pwndbg是你最好的朋友多下断点多观察栈和寄存器的变化每一个成功的exploit背后都是无数次调试和逻辑推理的结果。