printf 函数原型解析
c
int __cdecl printf(const char *format, ...);
- 返回值:int,代表成功输出的字符个数
- 调用约定 (x86):__cdecl,调用者负责堆栈平衡;x64 下 Windows 统一 ABI,__cdecl关键字失效
- 参数结构
○ 固定参数:const char *format 格式化字符串
○ 可变参数:...,必须放在末尾 - 工作原理 遍历 format 字符串,读到%时,读取后面的格式符(%d/%f/%s),依靠格式符来告诉 va_arg 该取什么类型的数据
⚠️重点:C 语言本身不会自动检测可变参数的数量与类型,完全靠格式化字符串,类型不匹配直接触发未定义行为
va_list 系列宏使用流程
c
va_list args; // 定义可变参数迭代指针
va_start(args, format); // 绑定到最后一个固定参数,args指向第一个可变参数
va_arg(args, type); // 取出当前参数,指针自动向后偏移
va_end(args); // 清理
内存布局区分
● x86(cdecl):参数从右向左全部压栈,所有可变参数都在栈上连续存放,va_arg本质就是指针 + 类型宽度偏移
● x64 Windows ABI:前 4 个参数优先放在rcx,rdx,r8,r9寄存器,剩余参数入栈;va_start 会处理寄存器里存放的参数,不再单纯只操作栈
坑:x64 下可变参数不是全部在栈上,逆向调试的时候容易踩坑
格式符对应 va_arg 取值
| 格式符 | va_arg 取类型 | 说明 |
|---|---|---|
%d |
int |
整型 |
%f |
double |
注意:float 传可变参数会自动提升为 double,不能写 float |
%s |
char* |
字符串指针 |
两种可变参数设计方案对比
- 显式计数法
c
int Sum(int count, ...);
第一个参数 count,直接写明后面一共有多少个参数;适合参数类型全部相同场景
示例:Sum(3,10,20,30),count=3 代表 3 个可变参数
- 特征匹配法(printf 采用) 靠格式化字符串format里面的%占位符,统计参数数量、识别参数类型;适合参数类型混杂场景
核心注意事项
- 类型严格匹配 格式符和真实参数类型不一致,会读取错误内存,程序崩溃或者乱输出
比如%d读取 double,直接取 4 字节,解析结果完全错误 - 读取顺序 可变参数读取顺序和传入顺序一致;x86 虽然参数从右往左压栈,但是栈增长向下,遍历的时候依然从左到右取参数
- 缺参问题 格式化字符串写了 3 个%,但是只传 2 个实参 → va_arg 继续向后读取栈上随机内存,属于未定义行为(格式化字符串漏洞原理基础)
- 整型提升规则 可变参数中char、short会自动提升为int;float自动提升为double,va_arg不能用char/short/float
底层逆向视角
● x86:va_start就是获取栈上固定参数的地址,计算偏移找到第一个可变参数
va_arg = 指针 + sizeof (类型)
● x64:va_list不再是单纯指针,是一个结构体,内部保存寄存器中存放参数的备份地址 + 栈参数地址,所以 x64 的 va 系列宏实现比 x86 复杂很多
格式化字符串漏洞关联拓展(拓展)
printf 第一个参数可控,用户可以自己写入%p %x %n,就能读取栈上内存甚至写入内存,就是经典的格式化字符串漏洞,根源就是 printf 靠字符串解析参数,没有边界校验