格式化字符串基础: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根本不检查参数个数!
格式符深度解析:%d、%s、%x、%n
每个格式符都对应一种参数解析方式。理解这个,你就理解了格式化字符串漏洞的本质。
%d — 有符号十进制整数
这个最简单。printf从栈上取一个int,打印成十进制。我在项目中遇到过有人用%d打印指针,结果符号扩展搞出负数地址,排查了半天才发现是格式符用错了。
%s — 字符串
这个就有意思了。%s把参数当作指针,然后解引用,打印指针指向的字符串。如果这个指针是无效的,或者指向了不可读的内存——直接崩。嗯,这里要注意:%s是格式化字符串漏洞中读取任意地址内存的核心工具。
%x — 无符号十六进制
打印无符号整数,十六进制格式。我习惯用%08x来对齐输出,这样在调试时一眼就能看出栈布局。%x在信息泄露中特别好用——它能直接暴露栈上的数据,包括返回地址、canary、甚至libc地址。
%n — 写入利器
这个必须单独拿出来说。%n不打印任何东西,它把当前已输出的字符数写入到参数指向的int变量中。说白了,就是写内存。你想想看,如果能控制%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不知道参数列表的结束位置。它只根据格式字符串中的格式符个数来决定取多少次参数。这就是漏洞的根源。
宽度与精度控制
除了基本格式符,还有几个修饰符在漏洞利用中特别重要:
- %N$ — 直接参数访问(N是参数位置编号)。比如%2$x直接访问第2个参数。这个在漏洞利用中极其重要,可以跳过前面的参数直接访问目标位置。
- %Nd — 最小宽度。比如%10d输出至少10个字符,不足补空格。这能控制%n写入的值。
- %.Ns — 最大字符数。比如%.5s只打印字符串的前5个字符。
- %0Nd — 用0填充。比如%010d输出10位,不足补0。
我曾经在CTF比赛中遇到一个题目,需要把某个地址的值改成0x1025544。直接用%n写入这么大的值几乎不可能(要输出一千多万个字符)。但用%hn分两次写入,每次只写2字节,就轻松搞定了。这就是宽度控制配合%hn的威力。
知识结构图
下面这张图总结了格式化字符串的核心知识体系,我建议你把它记在脑子里:
避坑指南
最后分享几个我踩过的坑:
- 我曾经在64位系统上直接用%x打印指针,结果只看到低32位,高32位完全没显示。后来改用%p才拿到完整地址。记住:64位下用%p,别用%x。
- 我曾经用%n写入时没算好已输出字符数,结果写入了错误的值。后来我养成了习惯:先用%Nd精确控制宽度,再用%n写入。
- 我曾经在调试时忘了格式化字符串本身也在栈上,导致偏移量算错。记住:格式化字符串本身也是参数,它的地址也在栈上。
嗯,这一章的内容就到这。printf家族函数看似简单,但每个格式符背后都对应着一种参数解析方式。理解了这些,你就掌握了格式化字符串漏洞的底层原理。下一层,咱们要开始实战了——在真实程序中定位和利用这些漏洞。