printf的实现:可变参数、ABI差异与格式化字符串漏洞
TL;DR
在libc中的printf的实现已经有一套完整的抽象流程。
但直接读源码容易迷失在细节里。其实整条链路是三个连环问题:参数在哪、怎么按格式串把参数取出来、取出来之后怎么变成字符串,外加一层printf/vprintf/vsnprintf的API设计。以musl 1.2.0为参考逐个拆,最后会发现这条链路最脆弱的一环恰恰藏在第一个问题里。
问题一:参数在哪
printf的签名是固定的:
int printf(const char *fmt, ...);
编译器在调用点清楚每个参数的类型,为什么运行时还要费劲?因为被调用者手里只有格式串和一串无名参数,...本身不含任何类型信息。C用可变参数函数把"从哪开始、按什么走"推迟到运行时,工具是stdarg.h:
va_list ap;
va_start(ap, fmt); // 起点:第一个可变参数在哪
int x = va_arg(ap, int); // 步长:按int走一步
va_end(ap);
所以va_arg的本质是一个游标加一个步长。步长由类型决定,而printf里类型只存在于格式串:printf("%d", 1, 2, 3)的后两个参数不会被读,printf("%d")缺参数也不会报错,游标照样往前走,读到什么算什么。
两条规则是这套设计的副作用:
- 默认实参提升(
float→double,char/short→int):调用点先把参数规整成少数几种规格,游标的步长种类就可控了 - 没有边界检查:游标走多远全凭格式串说了算
游标往哪指:32位与64位的分歧
起点确定了,但"起点在哪"取决于调用约定。这里的核心矛盾是:va_arg要按地址取值,参数却不一定在内存里。
i386:全压栈
x86 32位下可变参数函数的所有参数(含具名参数)都压栈,内存连续,游标直接走:取值、按4字节对齐前进,不需要任何寄存器搬运。问题到此非常简单。
但64位把参数搬进寄存器之后,事情变了。
x86-64:寄存器无法被指针索引
System V AMD64 ABI规定前6个整数参数走rdi rsi rdx rcx r8 r9,前8个浮点走xmm0-7(XMM寄存器),溢出才压栈。摆在面前的有两条路:让va_arg直接读寄存器,或者把寄存器搬到栈上。
前者不可行——va_arg是宏展开的代码,它无法预知该读哪个寄存器、以什么顺序读。ABI只能选后者:被调用者在序言里把参数寄存器dump到栈上,造出一块可以被指针索引的内存:
typedef struct {
unsigned int gp_offset; // 下一个整数参数的偏移,耗尽后为48 (6*8)
unsigned int fp_offset; // 下一个浮点参数的偏移,耗尽后为176 (6*8+8*16)
void *overflow_arg_area; // 栈上溢出参数
void *reg_save_area; // 寄存器保存区基址
} va_list[1];
gp_offset < 48就从reg_save_area + gp_offset取,否则切到overflow_arg_area走栈。注意整数和浮点是两条独立游标:它们来自两组互不相干的寄存器,各自dump到保存区的不同段落,没法交错排成一条线。
fp_offset耗尽值在旧版ABI文档里写作304(按16个XMM算),2001年改成实际只用8个XMM后的176,文档没同步,2021年才勘误。编译器实现的一直是176。
RISC-V:不解决复杂度,直接消掉它
站在x86-64的四字段结构体面前,值得反过来问一句:这套复杂度是必需的吗?它全部来自"浮点走独立寄存器组"这一条设计。
RISC-V的psABI干脆绕开了这个问题:可变参数一律按整数调用约定传递,即使double也进a0-a7,fa0-fa7只服务具名参数。所有参数因此排成一条线性序列,va_list退化为void *:
typedef void *va_list;
代价是浮点可变参数放弃了寄存器传参的速度,收益是实现复杂度回到i386同级。被调用者只需把没被具名参数用掉的a0-a7拷到entry sp正下方的varargs save area,且这块区域必须与栈上参数连续,游标线性前进就能先读完寄存器参数、再读栈参数。
一个细节:2×XLEN对齐的可变参数(RV64上如long double)必须占用偶数寄存器对,且一旦某个参数落到栈上,后续全部走栈,哪怕寄存器还有空的——保证线性序列不被打断。
对比
| i386 | x86-64 SysV | RISC-V | |
|---|---|---|---|
| 参数位置 | 全在栈 | 6 GP + 8 XMM寄存器,溢出走栈 | a0-a7寄存器,溢出走栈 |
| 浮点参数 | 栈上(提升为double) | 独立的XMM通道 | 也走整数寄存器 |
va_list | char * | 四字段结构体 | void * |
| 取参复杂度 | 线性前进 | GP/FP双游标 + 溢出区切换 | 线性前进 |
x86-64的结构体是"浮点独立寄存器组"逼出来的;RISC-V用一条约定把这个前提抹掉了。
问题二:格式串怎么变成字符流
游标能用了,下一个问题:printf("abc%08x", 42)里,abc原样输出,%08x却要翻译成一段定宽补零的十六进制。本质是一次遍历——普通字符直接吐,遇到%就进入一次"取参 + 格式化"。难点在%后面可以跟flags、width、precision、length、转换符的任意组合。
musl 1.2.0的调用链是 printf → vfprintf → printf_core,全部逻辑收在printf_core一个函数里。但这个链条本身就藏着一个设计问题:为什么中间层是 vfprintf,而不是 printf 直接调 printf_core?
解耦:参数来源与输出去向是两回事
先看一个绕不开的困境。你想写一个自己的日志函数,把格式化转发给标准库:
void logit(const char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
printf(fmt, ap); // ✗ ap 只是一个指针/结构体,不会被printf再次展开
va_end(ap);
}
...只能在函数自己的序言里展开,没法原样转手。而va_list是普通对象(x86-64上就是上一节那个四字段结构体),可以当参数传递。于是标准库把每个格式化函数拆成一对:printf/vprintf、fprintf/vfprintf、snprintf/vsnprintf。带v的收va_list,内外的包装函数统一转发过去,格式化逻辑永远只有一份。
musl里这层是薄壳叠薄壳:
int vprintf(const char *fmt, va_list ap)
{
return vfprintf(stdout, fmt, ap); // printf同理:vfprintf(stdout, ...)
}
int vsprintf(char *s, const char *fmt, va_list ap)
{
return vsnprintf(s, INT_MAX, fmt, ap); // vsprintf不实现任何东西
}
vsnprintf同样不碰格式化。它构造一个1字节缓冲的假FILE,把write回调指向sn_write(memcpy进目标缓冲并计数,超长部分丢弃但照常返回总长度),然后:
return vfprintf(&f, fmt, ap); // 核心引擎面对的永远是FILE *
至此两轴解耦完成:
| 层 | 职责 | 变化点 |
|---|---|---|
printf/fprintf/snprintf | ... → va_list,转发 | 参数来源 |
vfprintf + printf_core | 解析、取参、生成、填充 | 输出目标:stdout / 文件 / 内存缓冲 |
FILE.write 回调 | 字节的最终去向 | 换任何输出后端,引擎不动 |
要新增一个输出目标(比如格式化进环形缓冲),只需实现write回调,不必复制一行解析代码;要给所有入口统一加功能(如位置参数的两遍扫描),只改printf_core一处。这就是printf_core敢把所有逻辑收进一个函数的底气:它只面向FILE *,其他维度都被推到了边界上。
先定类型,再取参
面对%hd这种组合,musl没有层层嵌套switch,而是把长度修饰符 × 转换符做成二维状态表,查表直接得到参数类型:
static const unsigned char states[]['z'-'A'+1] = {
/* 0: bare */ S('d')=INT, S('x')=UINT, S('s')=PTR, S('l')=LPRE, ...,
/* 1: l- */ S('d')=LONG, S('l')=LLPRE, ...,
};
do { ps = st; st = states[st]S(*s++); } while (st - 1 < STOP);
类型定了,pop_arg就用与之匹配的va_arg调用把值取出,塞进同一个union。后面所有格式化逻辑只面对这个union,不再关心参数是怎么来的:
union arg { uintmax_t i; long double f; void *p; };
case SHORT: arg->i = (short)va_arg(*ap, int); // 提升后按int取
case PTR: arg->p = va_arg(*ap, void *);
位置参数逼出两遍扫描
到这里都是线性消费:格式串从左到右,游标同步前进。但POSIX位置参数printf("%2$d %1$d", a, b)允许打乱顺序,线性模型直接失效——你不能先取b再回头取a,游标不会倒退。
musl的解法是扫两遍。第一遍f == NULL不输出,只识别位置参数并把每个位置的类型记入nl_type[]:
// %1$d 这类位置参数
if (isdigit(s[1]) && s[2]=='$') { l10n = 1; argpos = s[1]-'0'; }
第一遍结束时按序pop_arg把参数缓存进nl_arg[],第二遍直接按位置查表取值,游标问题被"先全部取出再按需分配"化解了。没有位置参数时第一遍只是廉价的合法性检查。
生成与填充
类型和值都就位了,剩下"怎么变成字符串"。整数转换有个小技巧:从缓冲区末尾倒着填,天然省去反转:
static char *fmt_u(uintmax_t x, char *s) {
for (; x; x /= 10) *--s = '0' + x % 10;
return s;
}
最后是固定的填充序列,-(左对齐)和0(补零)两个flag在这里分道扬镳:
pad(f, ' ', w, pl+p, fl); // 左侧空格
out(f, prefix, pl); // 符号 / 0x前缀
pad(f, '0', w, pl+p, fl^ZERO_PAD); // 宽度补零
pad(f, '0', p, z-a, 0); // 精度补零
out(f, a, z-a); // 数字本体
pad(f, ' ', w, pl+p, fl^LEFT_ADJ); // 右侧空格
%d %s %p三个代表分别走:有符号整数经fmt_u倒序生成、strnlen受精度限制后直接out、以及p = MAX(p, 2*sizeof(void*))后走ALT_FORM十六进制(%p本质是强制#flag的%x)。浮点的fmt_fp是另一套大数运算,不在此展开。
问题三:谁在控制游标
链路走完一遍,回头看控制权:解析格式串决定类型,类型决定va_arg怎么走,转换符决定输出什么、输出多少。每一步的指令都来自格式串。那么格式串如果是用户输入的呢?
printf(user_input); // user_input = "%p.%p.%p.%s%n"
- 读:连续的
%p/%x把游标之后的栈上数据逐个打出来,泄露地址(绕过ASLR的第一步) - 写:
%n把当前已输出字符数写入指针参数,配合%hn/%hhn可做任意地址写
// musl printf_core 中 %n 的实现
case 'n': *(int *)arg.p = cnt; break; // cnt 是截至此刻的输出长度
输出长度由格式串控制,目标地址来自栈上参数,读写两条链条都闭合了。
最小PoC
// vuln.c
#include <stdio.h>
int main(int argc, char **argv) {
printf(argv[1]); // 格式串来自命令行参数
return 0;
}
$ gcc -o vuln vuln.c
$ ./vuln '%p.%p.%p.%p.%p'
0x7ffd3c0a1a1c.(nil).0x401136.0x7f...
前几个%p依次吐出栈上的返回地址、libc地址。真实利用中再叠加上面的位置参数(%7$n直接指定第7个参数作为写入地址),就能跳过顺序限制精确落到目标槽位。
防御只有一条:格式串永远写死为字面量,用户数据一律作实参传入(printf("%s", input))。
总结
| 问题 | 答案 |
|---|---|
| 参数在哪 | i386在栈上,x86-64把寄存器物化到save area,RISC-V用约定保住了线性 |
| API怎么分层 | v*系列收va_list解决转手问题,vfprintf + FILE.write把输出目标和引擎解耦 |
| 怎么取 | 格式串即类型:状态表定类型 → pop_arg取参;位置参数靠两遍扫描化解乱序 |
| 怎么变字符串 | 倒序生成 + 固定六步填充序列 |
| 谁控制一切 | 格式串。它一旦来自输入,%n就把输出长度变成写入原语 |
