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)必须占用偶数寄存器对,且一旦某个参数落到栈上,后续全部走栈,哪怕寄存器还有空的——保证线性序列不被打断。

对比

i386x86-64 SysVRISC-V
参数位置全在栈6 GP + 8 XMM寄存器,溢出走栈a0-a7寄存器,溢出走栈
浮点参数栈上(提升为double)独立的XMM通道也走整数寄存器
va_listchar *四字段结构体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就把输出长度变成写入原语

参考资料

Tags:
recent-work

并行, 异步和并发

TL;DR 这三个词经常被混着用,但它们回答的是三个不同的问题:并发问的是「同时有几个任务在飞」,并行问的是「有几个核真的在同时执行」,异步问的是「等一个慢操作的时候,要不要把线程也一起停住」。 本文先用手写一个有栈协程把「让出」这件事拆到底层(切换到 rsp 和几个 …

Read more →

ELF格式解析

TL;DR 一个 ELF 文件同时被四个角色读写:编译器产出它、链接器改造它、内核加载器消费它、工具与攻击者审视它。这四个读者看的是同一坨字节,但需求完全不同。ELF 的解法是把文件切成两套正交的索引表: Program Header Table(段视图)——给加载器看,描述“运 …

Read more →