格式化字符串基础:printf家族函数详解

说实话,很多搞Pwn的朋友一上来就盯着栈溢出、堆溢出,觉得格式化字符串漏洞太“基础”了。但我做了这么多年二进制安全,见过太多人在格式化字符串上翻车——明明漏洞就在眼前,就是不知道怎么精准利用。这一章,咱们就把printf家族函数彻底讲透。

printf家族:不只是打印

先问个问题:你平时写代码用printf,真的了解它背后干了什么吗?

printf家族的核心成员就这几个:

函数 原型 特点
printf int printf(const char *format, ...) 标准输出,最常用
sprintf int sprintf(char *str, const char *format, ...) 输出到字符串缓冲区
fprintf int fprintf(FILE *stream, const char *format, ...) 输出到文件流
snprintf int snprintf(char *str, size_t size, const char *format, ...) 带长度限制的sprintf
vprintf/vsprintf/vsnprintf ... (va_list版本) 接受va_list参数

你可能会说:“这不就是C语言基础吗?”别急,关键不在于它们怎么用,而在于它们怎么处理参数。我当年第一次逆向一个格式化字符串漏洞时,盯着反汇编看了半天才明白——原来printf根本不检查参数个数!

核心要点:printf家族函数是可变参数函数。它们通过格式字符串中的格式符个数来推断参数数量,而不是通过参数列表本身。这意味着——你给多少个格式符,它就尝试取多少个参数,哪怕栈上根本没有那么多参数。

格式符深度解析:%d、%s、%x、%n

每个格式符都对应一种参数解析方式。理解这个,你就理解了格式化字符串漏洞的本质。

%d — 有符号十进制整数

这个最简单。printf从栈上取一个int,打印成十进制。我在项目中遇到过有人用%d打印指针,结果符号扩展搞出负数地址,排查了半天才发现是格式符用错了。

%s — 字符串

这个就有意思了。%s把参数当作指针,然后解引用,打印指针指向的字符串。如果这个指针是无效的,或者指向了不可读的内存——直接崩。嗯,这里要注意:%s是格式化字符串漏洞中读取任意地址内存的核心工具。

%x — 无符号十六进制

打印无符号整数,十六进制格式。我习惯用%08x来对齐输出,这样在调试时一眼就能看出栈布局。%x在信息泄露中特别好用——它能直接暴露栈上的数据,包括返回地址、canary、甚至libc地址。

%n — 写入利器

这个必须单独拿出来说。%n不打印任何东西,它把当前已输出的字符数写入到参数指向的int变量中。说白了,就是写内存。你想想看,如果能控制%n的参数指向任意地址,那不就是任意地址写吗?

%n的写入机制:
printf("AAAA%n", &count); // 输出"AAAA",count被写入4
printf("%100d%n", 1, &count); // 输出100个字符(1前面补99个空格),count被写入100

格式符完整对照表

格式符 参数类型 作用 漏洞利用价值
%d / %i int 有符号十进制 低(信息泄露)
%u unsigned int 无符号十进制 低
%x / %X unsigned int 十六进制 中(栈数据泄露)
%s char* 解引用打印字符串 高(任意地址读)
%p void* 打印指针地址 中(地址泄露)
%n int* 写入已输出字符数 极高(任意地址写)
%hn short* 写入2字节 极高(精准写)
%hhn char* 写入1字节 极高(逐字节写)
%ln long* 写入8字节(64位) 高

参数传递与栈布局

这里有个关键问题:printf到底怎么拿到这些参数的?

在x86-64架构下,前6个参数通过寄存器传递(rdi, rsi, rdx, rcx, r8, r9),多余的参数压栈。但格式化字符串的参数是可变参数,编译器没法提前知道有多少个。所以printf内部维护了一个指针,从第一个可变参数开始,每处理一个格式符就移动指针到下一个参数。

我建议你记住这个模型:

printf("格式字符串", arg1, arg2, arg3, ...);
// 内部处理时,printf从arg1开始,按格式符顺序依次取参数
// 如果格式符比参数多,printf会继续从栈上取数据

为什么会这样?因为printf不知道参数列表的结束位置。它只根据格式字符串中的格式符个数来决定取多少次参数。这就是漏洞的根源。

实战技巧:在调试格式化字符串漏洞时,我习惯先用%p或%x把栈上的数据dump出来。比如用"%p.%p.%p.%p.%p.%p.%p.%p"连续打印8个指针,看看栈上都有什么。这能帮你快速定位偏移量。

宽度与精度控制

除了基本格式符,还有几个修饰符在漏洞利用中特别重要:

  • %N$ — 直接参数访问(N是参数位置编号)。比如%2$x直接访问第2个参数。这个在漏洞利用中极其重要,可以跳过前面的参数直接访问目标位置。
  • %Nd — 最小宽度。比如%10d输出至少10个字符,不足补空格。这能控制%n写入的值。
  • %.Ns — 最大字符数。比如%.5s只打印字符串的前5个字符。
  • %0Nd — 用0填充。比如%010d输出10位,不足补0。

我曾经在CTF比赛中遇到一个题目,需要把某个地址的值改成0x1025544。直接用%n写入这么大的值几乎不可能(要输出一千多万个字符)。但用%hn分两次写入,每次只写2字节,就轻松搞定了。这就是宽度控制配合%hn的威力。

知识结构图

下面这张图总结了格式化字符串的核心知识体系,我建议你把它记在脑子里:

格式化字符串核心知识体系 printf家族函数 格式符类型 参数传递机制 宽度/精度控制 %d/%i %s %x/%p %n/%hn 寄存器传参 栈上取参 参数不匹配 %Nd %N$ 控制%n写入值 漏洞本质:格式符数量 > 实际参数数量 → 栈上数据泄露/写入 利用手段:%s任意读 + %n任意写 + %N$直接访问

避坑指南

最后分享几个我踩过的坑:

  • 我曾经在64位系统上直接用%x打印指针,结果只看到低32位,高32位完全没显示。后来改用%p才拿到完整地址。记住:64位下用%p,别用%x。
  • 我曾经用%n写入时没算好已输出字符数,结果写入了错误的值。后来我养成了习惯:先用%Nd精确控制宽度,再用%n写入。
  • 我曾经在调试时忘了格式化字符串本身也在栈上,导致偏移量算错。记住:格式化字符串本身也是参数,它的地址也在栈上。

嗯,这一章的内容就到这。printf家族函数看似简单,但每个格式符背后都对应着一种参数解析方式。理解了这些,你就掌握了格式化字符串漏洞的底层原理。下一层,咱们要开始实战了——在真实程序中定位和利用这些漏洞。