1. 项目概述为什么格式化字符串漏洞是CTF中的“常青树”在CTFCapture The Flag竞赛尤其是Web安全或Pwn二进制漏洞利用类题目中格式化字符串漏洞Format String Vulnerability绝对是一个绕不开的经典考点。它不像栈溢出那样直观也不像SQL注入那样广为人知但它却像一把精巧的瑞士军刀一旦掌握就能在看似固若金汤的程序防御上撬开一道缝隙。CTFshow平台上的相关题目从入门到进阶系统地覆盖了这个漏洞的方方面面成为了无数安全爱好者实战演练的绝佳沙盒。简单来说格式化字符串漏洞源于程序员的一个常见疏忽误用了像printf、sprintf、fprintf这类C语言格式化输出函数。这些函数的设计本意是接收一个格式字符串如%s,%d和一系列对应的参数然后按照格式输出。但如果程序允许用户输入直接作为格式字符串参数攻击者就可以注入特殊的格式化占位符如%x,%p,%n从而读取内存、修改数据甚至执行任意代码。在CTFshow的题目里你可能会遇到一个简单的、回显你输入的服务而你的任务就是利用这个回显一步步窥探和操控程序内部。为什么它如此重要首先它揭示了“信任边界”的脆弱性——程序过于信任了用户的输入。其次它的利用链条非常有趣从信息泄露内存读取到任意地址写%n逻辑递进清晰是学习漏洞利用思维的绝佳范例。最后即使在现代编译器和操作系统拥有诸多保护机制如ASLR, NX, Stack Canary的今天格式化字符串漏洞的某些利用技巧依然有效或者能与其他漏洞形成组合拳。因此无论你是刚接触二进制安全的萌新还是想深化利用技巧的老手系统性地攻克CTFshow上的格式化字符串题目都能让你对程序内存布局、函数调用约定和漏洞利用艺术有质的理解。2. 漏洞原理深度拆解不只是“%s”和“%n”那么简单要利用一个漏洞必须先吃透它的原理。格式化字符串漏洞的核心在于对C语言可变参数函数variadic functions机制的滥用。我们以最常用的printf为例。2.1 格式化函数如何工作当一个正常的printf被调用时例如printf(Number: %d, String: %s\n, 123, hello);函数的执行流程是这样的函数从栈上或特定的寄存器取决于调用约定如x86-64下前几个参数用寄存器获取格式字符串地址。解析格式字符串遇到%d时它知道要去栈上或下一个寄存器取一个4字节的数据解释为整数输出遇到%s时它知道要去取一个指针然后解引用该指针指向的内存地址将其作为字符串输出直到遇到空字节。关键点在于函数本身并不知道你传入了多少个参数。它完全“信任”格式字符串的指示。格式字符串说有几个占位符它就“认为”你在栈上准备了几个对应的参数。2.2 漏洞如何产生漏洞产生的典型代码模式如下char user_input[100]; fgets(user_input, sizeof(user_input), stdin); printf(user_input); // 危险用户输入直接作为格式字符串或者更隐蔽的printf(%s, user_input);这是安全的而printf(user_input);这是危险的。当攻击者输入的不是普通字符串而是包含格式化占位符的字符串时例如输入%p.%p.%p.%pprintf会忠实地执行解析格式字符串发现第一个%p于是它去栈上“本该”是第一个参数的位置实际上在printf(user_input)的调用中user_input本身就是第一个参数所以第一个%p会读取栈上user_input之后的数据读取一个指针大小的数据并输出。接着解析第二个%p再去栈上更深处读取数据……如此循环。这样一来攻击者就像拥有了一台“内存望远镜”可以逐字逐句地读取栈上的内容。这些内容可能包括函数的返回地址栈上的局部变量库函数的地址可用于绕过ASLR甚至是程序本身的代码段地址2.3 关键格式化符的利用含义%x/%p: 内存泄露的起点。%x以十六进制输出一个unsigned int%p输出一个指针值。这是探测内存布局、寻找有用信息如canary值、libc地址的主要工具。%s: 更危险的信息泄露。它试图将对应参数视为指针并输出该指针指向的字符串。如果我们可以控制这个参数的值通过栈布局或后续技巧就能读取任意地址的内存如got表中的函数地址这是计算libc基址的关键。%n: 漏洞利用的“质变点”。这是最特殊的占位符。它不输出内容而是将截至目前已成功输出的字符数写入到对应参数所指向的内存地址中。这个参数必须是一个指针int *。%n实现了任意地址写。你可以写入一个数字通过控制输出字符的数量就能控制写入的值。%hn写入2字节、%hhn写入1字节可以用于更精确的写入。%c: 用于精确控制输出字符数量从而配合%n控制写入的数值。%[num]d/%[num]x: 用于控制输出字段宽度同样是为了配合%n精确控制写入值。理解这些占位符就拿到了格式化字符串漏洞的“武器库清单”。3. 实战环境搭建与基础利用从“Hello, Memory”开始理论讲再多不如动手调一调。我们从一个最简单的CTFshow风格题目开始搭建实验环境。3.1 编写漏洞程序与编译选项创建一个名为fmt_vuln.c的文件#include stdio.h #include string.h void vuln_func() { char buffer[100]; printf(Enter your payload: ); fgets(buffer, sizeof(buffer), stdin); // 移除末尾的换行符 buffer[strcspn(buffer, \n)] 0; printf(buffer); // 漏洞点 printf(\n); } int main() { vuln_func(); return 0; }为了模拟CTF题目环境我们关闭一些现代保护机制进行编译但保留部分以学习绕过gcc -m32 -no-pie -fno-stack-protector -z execstack -o fmt_vuln fmt_vuln.c-m32: 编译为32位程序。32位环境下栈操作更直观参数传递全在栈上便于初学者理解。-no-pie: 关闭位置无关可执行文件。这样程序的代码段、数据段地址是固定的便于计算偏移。-fno-stack-protector: 关闭栈溢出保护Canary。这样栈结构更干净我们暂时不处理canary。-z execstack: 使栈可执行。这为后续可能的shellcode执行提供便利虽然高级利用中不常用。注意在实际CTF比赛中保护机制通常是全开的Canary, NX, ASLR, PIE。我们的学习路径是从简单到复杂先理解核心原理再学习如何绕过这些保护。3.2 基础信息泄露窥探栈内存运行程序并输入一串%p$ ./fmt_vuln Enter your payload: %p.%p.%p.%p.%p.%p 0xff8b1a10.0x1.0xf7e3e5a0.0x1.0xff8b1a9c.0xff8b1aa4你看到的是一串十六进制数。它们是什么第一个%p0xff8b1a10: 这通常是buffer数组本身在栈上的地址或附近。因为buffer是printf的参数在32位下第一个参数之后就是栈上的后续内容。后续的%p依次是栈上更高地址处的内容。其中可能包含局部变量调用者的栈帧基址ebp函数返回地址main函数的参数argc,argv以及最重要的libc函数的地址如上面输出中的0xf7e3e5a0很可能是一个libc地址。如何定位关键数据你需要多次尝试结合调试器如gdb或pwndbg来分析。在gdb中运行程序在printf处下断点查看栈布局与你输出的%p序列进行比对。你会发现第n个%p对应的是栈上从格式字符串指针地址向高地址方向的第(n-1)个参数位置因为第一个参数是格式字符串本身。3.3 利用%s进行任意地址读仅仅泄露地址还不够我们需要读取特定地址的内容。假设我们通过之前的泄露知道了栈上某个位置存放着一个指向Hello字符串的指针我们想读取它。 如果这个指针位于第6个参数的位置即第6个%p输出的值是一个指针我们可以构造payloadAAAA%6$s。AAAA是四个字符的填充也可以是任意字符用于在内存中形成一个可识别的模式有时在调试中更方便。%6$s是直接参数访问语法。6$表示使用第6个参数从1开始计数。printf会去栈上找到第6个参数的值将其作为指针然后输出该指针指向的字符串直到遇到\x00。如果第6个参数的值确实是一个可读地址的指针程序就会输出该地址处的字符串。如果该值不是合法指针或指向不可读内存程序会崩溃段错误。这是信息泄露更强大的形式可以用来读取GOT表中的函数地址从而计算libc基址。实操心得在CTF中题目往往会将某个关键字符串如/bin/sh或函数的GOT表地址放在栈上。你的任务就是用%p遍历找到它然后用%s读出来。使用类似%p,%p,%p,...或%{i}$pi从1递增的payload进行“爆破”式探测是常用手段。4. 高级利用技巧利用%n实现任意地址写与控制流劫持信息泄露读只是铺垫真正的攻击目标是获得控制权写和执行。%n家族格式化符是实现“写”的关键。4.1%n的基本原理与危险%n会将当前已输出的字符数量写入到对应参数指向的地址。例如int bytes_written; printf(Hello%n, bytes_written); // 执行后bytes_written的值将是5Hello的长度。在漏洞利用中我们无法直接传递一个地址作为参数bytes_written。但是我们可以利用栈布局让某个栈上的位置恰好存放着我们想要写入的目标地址然后通过直接参数访问如%7$n告诉printf“把输出的字符数写入到第7个参数所指向的地址”。如果第7个参数的值恰好是我们想要修改的地址比如某个函数的GOT表项那么我们就实现了向该地址写入数据。4.2 利用步骤详解以修改GOT表为例假设我们的目标是修改printf的GOT表项使其指向我们注入的shellcode在栈可执行的情况下或者指向system函数。大致的利用链条如下信息泄露利用%p或%s泄露栈地址和libc地址。泄露一个栈地址用于计算我们输入的buffer在内存中的位置。泄露一个libc函数如__libc_start_main的地址通过查询libc数据库如libc-database计算出libc基址和system函数的真实地址。构造写目标我们需要将目标地址如printfgot.plt放到栈上并确保printf在解析格式化字符串时能将其作为一个参数找到。在32位下我们可以直接将目标地址作为payload的一部分写入buffer。因为buffer在栈上它的内容本身就是参数的一部分。例如payload可以是addr_printf_got%offset$n。这里addr_printf_got是4字节的地址它会占据buffer的开头。我们需要计算这个地址在栈上作为第几个参数被printf看到这个位置就是offset。计算偏移Offset这是最关键的一步。你需要确定你输入的buffer起始地址在printf的栈帧中是第几个参数。通过泄露栈地址和gdb调试可以精确计算。假设buffer地址是0xffffd010printf的格式字符串指针指向buffer在栈上的位置是0xffffd02c。在32位下参数是连续4字节存放的。那么从0xffffd02c第一个参数即格式字符串地址到0xffffd010我们输入的buffer起始的偏移是(0xffffd02c - 0xffffd010) / 4 7字节。这意味着buffer起始处的内容会被printf当作第8个参数因为第一个参数是0xffffd02c本身指向的地址即buffer的地址所以从buffer内容开始算是第2个参数这里容易混淆需要仔细计算。更可靠的方法是使用模式字符串。输入AAAA%p.%p.%p.%p.%p.%p.%p...观察输出中AAAA即0x41414141出现在第几个%p的位置。假设出现在第7个那么偏移就是7。那么%7$n就会将输出字符数写入到AAAA这个地址0x41414141指向的内存这会导致崩溃。我们将AAAA替换成真正的目标地址如0x0804c014printf的GOT地址%7$n就会向0x0804c014写入数据。控制写入的值%n写入的是已输出的字符数。如果我们想写入一个特定的值比如system函数的地址就需要精确控制输出字符的数量。直接输出一个巨大的字符串不现实。这里用到%[num]d或%[num]x。例如%100d会输出一个宽度为100的整数用空格填充。这样就能快速增加输出字符数。但system的地址可能是一个很大的数如0xf7e3c850。我们无法一次性输出数亿个字符。解决方案是分字节写入。利用%hn写2字节或%hhn写1字节将目标地址拆分成高16位、低16位或者甚至4个字节分别写入目标地址、目标地址1、目标地址2、目标地址3。构造链式写入这是格式化字符串利用中最精妙的部分。例如我们要向地址0x0804c014写入值0xdeadbeef。将地址拆分0xdead高16位0xbeef低16位。假设我们需要先写低16位0xbeef48879十进制到0x0804c014再写高16位0xdead57005十进制到0x0804c016。Payload结构[addr_low][addr_high]%[value_low]d%[offset]$hn%[value_high_diff]d%[offset1]$hn具体计算value_low 0xbeef 48879。value_high需要写入0xdead 57005但此时已输出了len(addr_lowaddr_high) 48879个字符。所以第二部分需要输出的字符数是57005 - (已输出字符数)。通过精心计算填充可以精确控制。劫持控制流成功将GOT表中的printf地址覆盖为system地址后下一次程序调用printf时实际上会跳转到system。如果我们能同时控制printf的参数在漏洞利用中我们通常可以比如让这个参数是字符串/bin/sh的地址那么system(/bin/sh)就会被执行从而获得一个shell。4.3 自动化利用与工具手动计算偏移、构造payload非常繁琐且容易出错。在实际CTF解题中我们通常使用Python脚本配合pwntools库来自动化这一过程。pwntools提供了强大的格式化字符串漏洞利用功能fmtstr_payload(offset, writes, numbwritten0, write_sizebyte)这个函数可以自动生成复杂的格式化字符串payload。你只需要提供offset: 你控制的第一个格式化字符串参数在栈上的偏移。writes: 一个字典{target_address: value_to_write}。numbwritten: 在生成payload前已经输出的字符数通常为0。write_size: 写入大小可选byte%hhn、short%hn、int%n。例如要利用我们上面的漏洞程序假设偏移是7想将0x0804c014printf_got改为0xf7e3c850system_addr脚本大致如下from pwn import * context.arch i386 context.log_level debug # 假设本地运行 p process(./fmt_vuln) # 先泄露信息计算偏移和地址这里省略泄露过程 offset 7 printf_got 0x0804c014 system_addr 0xf7e3c850 # 构造payload payload fmtstr_payload(offset, {printf_got: system_addr}) p.sendline(payload) p.interactive()pwntools会帮你处理好所有复杂的地址拆分、字节序和长度计算。5. 现代保护机制下的利用挑战与绕过技巧真实的CTF题目和现实世界的程序都开启了各种保护机制让利用变得困难。CTFshow的中高级题目正是训练这些绕过技巧的战场。5.1 地址空间布局随机化ASLR与位置无关可执行文件PIE挑战ASLR使得栈、堆、libc的基址在每次程序运行时都随机变化。PIE使得程序本身的代码段、数据段包括GOT地址也随机化。你之前硬编码的地址如0x0804c014全部失效。绕过格式化字符串漏洞本身是信息泄露的利器而这正是绕过ASLR/PIE的关键。泄露程序基址通过格式化字符串泄露栈上或.got.plt中的一个已知函数地址如__libc_start_main或某个库函数。因为程序内部的相对偏移是固定的用泄露的地址减去该函数在libc中的已知偏移就能得到libc的基址。得到libc基址后就可以计算出system、/bin/sh等所有libc中符号的运行时地址。泄露栈地址同样可以泄露栈上的一个指针通过计算与目标缓冲区如我们输入的字符串的固定偏移可以定位到缓冲区在内存中的确切位置从而将地址写入栈上正确的位置。泄露PIE基址如果程序是PIE的可以通过泄露程序代码段中的一个地址例如main函数的返回地址它指向__libc_start_main内的某个位置但这个位置相对于程序基址的偏移是固定的来计算程序的基址。有了程序基址就能算出GOT表的运行时地址。注意事项在64位系统下地址空间巨大且地址通常带有高位0x00字节如0x00007ffff7a523c0。这些空字节会被C字符串函数如strcpy,fgets视为字符串终止符导致payload截断。因此在构造payload时需要将地址放在payload的末尾或者利用格式化字符串本身的特性如%*c配合%n来避免在内存中直接出现空字节。这也是64位下格式化字符串利用的一个常见难点。5.2 栈保护Stack Canary挑战Canary是一个放在栈上的随机值位于返回地址之前。函数返回前会检查这个值是否被修改如果被修改则立即终止程序防止栈溢出覆盖返回地址。格式化字符串如何绕过Canary本身也存放在栈上。我们可以利用格式化字符串漏洞读取Canary的值。因为Canary在每次运行中是随机的但在同一次运行中对于同一个函数它的值是不变的。一旦我们泄露了Canary在后续真正的栈溢出攻击中如果存在就可以在覆盖缓冲区时将正确的Canary值写回原处从而通过检查。格式化字符串在这里扮演了“情报员”的角色。5.3 数据执行保护NX/DEP挑战NXNo-eXecute位将数据区如栈、堆标记为不可执行。即使你在栈上注入了shellcode跳转过去也会引发段错误。绕过格式化字符串漏洞的经典利用方式写GOT表本身不依赖于执行栈上的代码。我们通过%n覆盖GOT表项将其指向已有的代码片段比如libc中的system函数。这是一种面向返回编程ROP的雏形不涉及执行自定义的shellcode因此NX防护对此无效。这也是为什么格式化字符串漏洞在现代保护下依然强大的原因之一——它主要利用的是程序已有的代码。5.4 完整利用链示例带ASLR/PIE假设一个开启了ASLR、PIE、NX的64位程序存在格式化字符串漏洞。利用步骤高度概括如下多次交互通常需要至少两次printf调用。第一次用于信息泄露第二次用于写入。第一次泄露发送%p大礼包泄露多个栈地址和libc地址。通过偏移计算得到一个libc函数的地址 - 计算libc基址 - 得到system和/bin/sh地址。一个指向栈的地址 - 计算输入缓冲区在栈上的绝对地址。程序代码段中的一个地址 - 计算程序基址 - 得到GOT表地址。构造写payload使用pwntools的fmtstr_payload传入计算出的偏移、目标GOT地址如printf_got和要写入的值system_addr。注意64位地址的空字节问题pwntools会自动处理通常将地址放在payload末尾。触发执行发送写payload。当下一次程序调用printf或其他被覆盖的函数时控制流就被劫持到system同时要确保此时该函数的参数可能在栈上指向/bin/sh字符串的地址。这可能需要通过之前的泄露和计算精心布局栈上的数据。6. CTFshow典型题目实战解析与排错指南结合CTFshow平台的热门关键词我们来分析几种典型的出题套路和解题思路。6.1 场景一无字母数字代码执行结合格式化字符串这通常是Web类题目的考点但原理相通。题目限制payload不能包含字母和数字却要执行命令。格式化字符串中的%p、%x、%n等符号本身不是字母数字可以用来构造攻击。思路利用格式化字符串泄露内存中的地址这些地址是十六进制数字0-9, a-f。虽然不能直接输入字母数字但泄露出的地址信息中可能包含有用的数据。更进一步可以通过%n向某个可写内存如栈上的变量写入一个值这个值可能被后续代码当作函数指针或跳转地址。挑战在于如何用纯格式化字符串符号构造出有用的地址或数据。有时需要结合多次泄露和写入进行“数据锻造”。6.2 场景二日志文件包含题目可能模拟一个Web应用将用户输入记录到日志文件然后另一个功能点会读取并printf这个日志文件。这就间接造成了格式化字符串漏洞。解题关键找到注入点确定哪个用户输入会被最终放入日志并被printf。考虑过滤题目可能会过滤一些字符如%、\n、\x00等。需要尝试绕过例如使用%25%的URL编码如果后端解码或者使用%c%c组合来输出%字符本身%的ASCII码是37可以用%37c输出。利用链可能较长因为需要先写入日志再触发读取所以可能需要精心构造一个payload使其在日志文件中保存时是安全的在被printf时才会被解析为格式化字符串。6.3 场景三多层嵌套解码题目可能对输入进行多次解码如base64、URL、html实体等然后再传递给printf。解题关键理清解码顺序你需要构造一个payload使其在经过所有解码步骤后变成有效的格式化字符串攻击载荷。例如原始输入是%2570%70的URL编码。第一次URL解码后变成%70第二次如果被printf解析%70可能被解释为输出70个字符的%c如果%70c是合法格式或者引发其他行为。这需要仔细测试和理解每一层解码的效果。利用编码绕过过滤如果直接输入%n被过滤可以尝试输入%6En的ASCII码十六进制配合%c或%x来动态生成字符n但这非常复杂通常需要结合其他漏洞。6.4 常见问题与调试技巧程序崩溃Segmentation Fault原因最常见。可能是%s读取了不可读地址%n写入了不可写地址或者构造的地址不对齐如在64位下写入非8字节对齐地址。排查使用gdb调试在printf处下断点单步执行观察栈内容和寄存器。检查你计算的偏移是否正确。检查你试图读写地址的合法性vmmap命令查看内存映射。输出乱码或无输出原因%s读取的地址可能包含空字节导致字符串提前终止或者输出的字符是非打印字符。排查尝试用%p代替%s先看看地址值是什么。使用hexdump或pwntools的hexdump功能查看原始输出。写入的值不对原因%n写入的是已输出的字符数计算错误。特别是使用%[num]d时num是输出字段的最小宽度如果实际数字位数小于num会用空格或0填充这会影响字符计数。排查在脚本中精确计算payload长度。使用len(payload)或在构造payload时打印中间结果。pwntools的fmtstr_payload能极大减少这类错误。偏移计算不准原因本地环境和远程环境可能存在差异如环境变量导致栈偏移微调。或者对于64位程序参数传递前几个使用寄存器rdi, rsi, rdx, rcx, r8, r9格式化字符串参数超过6个后才用栈计算方式与32位不同。技巧使用“试探法”。发送AAAA%p-%p-%p...或AAAA%1$p-%2$p...观察0x41414141出现在第几个%p。这个位置就是偏移。编写脚本时可以写一个循环来自动探测偏移。应对过滤过滤%n尝试使用%hn或%hhn有时过滤不完整。或者尝试使用%c和%*c等组合来间接实现写入但这非常复杂。过滤$直接参数访问这是最麻烦的。你只能使用顺序访问的%p、%x等。你需要通过大量填充字符如%c来移动格式化字符串的“指针”使其对齐到你想要读写的栈位置。这需要精确计算已输出的字符数极其繁琐通常需要自动化脚本。格式化字符串漏洞的学习曲线较陡但一旦掌握你对程序内存和漏洞利用的理解会上一个大台阶。CTFshow的题目梯度设置得很好从简单的内存读到复杂的组合利用建议按照平台题目顺序逐一攻克每一步都配合调试器理解内存变化积累下来的经验会成为你安全技能库中一件强大的武器。记住关键不是记住payload而是理解其背后的“为什么”——为什么这个偏移有效为什么这个地址要这样写理解了原理万变不离其宗。