本文在amd64平台使用以下编译器进行分析

gcc version 9.3.0 (Ubuntu 9.3.0-10ubuntu2)

在c中,定义一个函数,往往是确定的参数类型和个数,并返回一个确定的类型 但每个程序员都写过的 printf(“hello world”); 却调用了一个非常异端的函数

int printf(const char* format, ...);

printf作为一个格式化字符串函数,除了接收一个format串,还可以接收任意数量的任意类型的参数用来格式化,是个非常典型的变参函数

type VarArgFunc(type FixedArg1, type FixedArg2,...);

要了解格式化字符串的工作原理,绕不开变参这个点,而这也是格式化字符串漏洞的核心机制

如何在c中定义一个像printf一样的变参函数:

#include <stdarg.h>
int VarArgFunc(int dwFixedArg, ...){
    va_list pArgs = NULL;
    va_start(pArgs, dwFixedArg);
    int dwVarArg = va_arg(pArgs, int);
    va_end(pArgs);
}
//可在头文件中声明函数为extern int VarArgFunc(int dwFixedArg, ...);,调用时用VarArgFunc(FixedArg, VarArg);

其实熟悉c函数调用约定(Calling convention)的话我们不难知道,函数的参数传递是约定好的,因此即使我们不定义显式定义一个经过编译器检查分配的形参,对应的寄存器和栈空间仍然可以被视作实参 首先以i386下__cdecl的典型栈传参为例,所有的参数按照从右到左的顺序依次压入栈,返回值在EAX中 并且由调用者清理栈

register int i=1;
int j=2;
calc(i,j,3);

主调函数(caller)

 push EAX            ; 压入寄存器中的值
 push byte[EBP+20]   ; 压入栈上局部变量的值 (FASM/TASM syntax)
 push 3              ; 压入立即数
 call calc           ; 

被调函数(callee)

calc:
  push EBP            ; 保存旧的栈帧
  mov EBP,ESP         ; 将现有栈顶作为新栈帧起点
  sub ESP,localsize   ; 为本地变量开辟栈空间
  .
  .                   ; 做对应的计算,结果存在eax
  .
  mov ESP,EBP         ; 释放当前栈帧的空间
  pop EBP             ; 恢复旧的栈帧
  ret paramsize       ; 返回到主调函数

由于参数的传递全部依靠栈完成,从被调函数的角度考虑,只要获取到在栈上的第一个参数(最后被压入栈),就可以向上找到任意个数的参数

               high addr
          + +-------------+ old ebp
          | |             | <-----+
          | |             |       |
          | |  arg...     |       |
          | +-------------+       |
          | |  arg3       |       |
          | +-------------+       |
          | |  arg2       |       |
          | +-------------+       |
          | |  arg1       |       |
          | +-------------+       |
 old frame| |  arg0       |       |
       +--+ +-------------+       |
            |  saved eip  |       |
          + +-------------+ +-----+
          | |  saved ebp  |    ebp
          | +-------------+ <-----+
          | |  local args | old esp
          | |  ...        |
 new frame| |             |    esp
       +--+ +-------------+ <-----+
               low addr

写一个越界获取任意数量参数的例子

#include<stdio.h>
int a(int num){
  void * base=NULL;
  for(int i=0;i<num;i++){
    printf("%p\n",*(&base+i));
  }
}
int main(int argc,char *argv[],char *envp[]){
  a(10);
}

output

(nil)
0x12c6a360e922cf00
0x7fffe9fb5590
0x7ffe7d11c212
(nil)
0x7fffe9fb5698
0x7fffe9fb5688
0x100000000
(nil)
0x7ffe7cf070b3

btw,我们发现main函数其实也是可变参的,程序启动时我们可以从cli指定任意多个参数,显然,这里的argc和argv是由libc处理过的变参

现在被调函数可以从栈上获取任意个数的参数,主调函数如何传入任意个数的参数呢,这里要两部分讨论 传入和传出 传入时,按照正常的书写习惯,func(a,b,c,…),实际就是caller中逐个压入不定数量的参数,退出时再通过add esp的操作一步清栈 这是cdecl下由主调函数清栈的好处,实参的压入和退出是统一的,形参和实参不一致不会导致问题,通过gcc中对变参定义的...语法很容易就可以解决传参的问题,但在stdcall之类又被调函数清栈的约定下,形参和实参不一致会很容易产生堆栈错误,因此,在一些使用stdcall约定的编译环境下,如果要编译诸如printf的可变参函数,往往还得使用__cdecl

相信只要明白了Calling convention的过程,在i386下的变参实现原理就很简单明了

接下来我们讨论下amd64下的__cdecl,我们知道,在64位下默认的calling convention为了优化传参,降低压栈弹栈开销,前6个参数是使用寄存器进行传参的(rdi,rsi,rdx,rcx,r8d,r9d),这么一来似乎从栈上连续获取前6个参数就成了无稽之谈,那变参该如何实现呢,我们修改一下前面的例子

int b(int num,...){
  void * base=NULL;
  for(int i=0;i<num;i++){
    printf("%p: %p\n",&base+i,*(&base+i));
  }
}
int main(int argc,char *argv[],char *envp[]){
  b(36,1,2,3,4,5,6,7,8,9,10);
}

output

0x7ffe5c1c28a0: (nil)              ;loacl var
0x7ffe5c1c28a8: 0x3f5eff2fb4a7c900 ;stack cookie
0x7ffe5c1c28b0: 0x34000000340
0x7ffe5c1c28b8: 0x1                ;arg2~arg6
0x7ffe5c1c28c0: 0x2
0x7ffe5c1c28c8: 0x3
0x7ffe5c1c28d0: 0x4
0x7ffe5c1c28d8: 0x5
0x7ffe5c1c28e0: 0x34000000340
0x7ffe5c1c28e8: 0x34000000340
...
0x7ffe5c1c2950: (nil)
0x7ffe5c1c2958: 0x2001005
0x7ffe5c1c2960: 0x7ffe5c1c29c0     ;saved rbp
0x7ffe5c1c2968: 0x55723d0fc350     ;saved rip
0x7ffe5c1c2970: 0x6                ;arg7~
0x7ffe5c1c2978: 0x7
0x7ffe5c1c2980: 0x8
0x7ffe5c1c2988: 0x9
0x7ffe5c1c2990: 0xa
0x7ffe5c1c2998: 0x55723d0fc360
0x7ffe5c1c29a0: (nil)
0x7ffe5c1c29a8: 0x7ffe5c1c2ac8
0x7ffe5c1c29b0: 0x7ffe5c1c2ab8
0x7ffe5c1c29b8: 0x100000000

我们可以发现即使寄存器传参的变量也出现在了栈上,2-6的的参数出现在了新栈帧却在stack cookie之后,像个小夹层

分析一下反汇编

main函数(caller视角)

...
sub     rsp, 8
push    0Ah
push    9
push    8
push    7
push    6
mov     r9d, 5
mov     r8d, 4
mov     ecx, 3
mov     edx, 2
mov     esi, 1
mov     edi, 24h ; '$' 36
mov     eax, 0
call    b
...

b函数 (callee视角)

endbr64
push    rbp
mov     rbp, rsp
sub     rsp, 0E0h
mov     [rbp+var_D4], edi
mov     [rbp+var_A8], rsi
mov     [rbp+var_A0], rdx
mov     [rbp+var_98], rcx
mov     [rbp+var_90], r8
mov     [rbp+var_88], r9
test    al, al
jz      short loc_125A
...
loc_125A:
mov     rax, fs:28h
mov     [rbp+var_B8], rax
xor     eax, eax
mov     [rbp+var_C0], 0
mov     [rbp+var_C4], 0
jmp     short loc_12D9
....
mov     rsi, [rbp+var_B8]
xor     rsi, fs:28h
jz      short locret_12FF
...
locret_12FF:
leave
retn

可以看到,在被调变参函数内部,开辟新的stack frame后的第一件事居然不是设置stack cookie,而是先把寄存器传参的部分全部都放栈上了,如果设置rax的话浮点数寄存器也一一保存,这里rsi~r9是挨着放的,唯独edi放在了稍低一点的位置,这也解释了刚刚我们为什么没有看到第一个参数,让我们稍作修改

int c(int *num,...){
  void * base=NULL;
  for(int i=-6;i<30;i++){
    printf("%p: %p\n",&base+i,*(&base+i));
  }
}
int main(int argc,char *argv[],char *envp[]){
  c(0xdeadbeef,1,2,3,4,5,6,7,8,9,10);
}

output

0x7ffdc6938d50: 0x3936313433613265
0x7ffdc6938d58: 0x564be2a342d3
0x7ffdc6938d60: (nil)
0x7ffdc6938d68: 0xdeadbeef
0x7ffdc6938d70: 0x564be2a34080
0x7ffdc6938d78: 0xffffffffc6938f90
0x7ffdc6938d80: (nil)
0x7ffdc6938d88: 0x4bbe10d4c0571b00
0x7ffdc6938d90: 0x7ffdc6938ea0
0x7ffdc6938d98: 0x1
0x7ffdc6938da0: 0x2
...

果然在local 区域里看见了第一个参数

这样一来,我们只要知道某个参数的位置作为参照,也是可以根据固定的偏移来获取到其他参数了,由于实践中第一个参数往往是控制参数,因此对于第一个参数的获取其实不是那么要紧,只要获取2~6的区域并且根据偏移得到7个以上参数压栈区的偏移即可

int d(int num,...){
  void * base=NULL;
  printf("arg%d: %p\n",1,(long int)(*(&base-3))>>32);
  for(int i=2;i<7;i++){
    printf("arg%d: %p\n",i,*(&base+i+1));
  }
  for(int i=7;i<12;i++){
    printf("arg%d: %p\n",i,*(&base+i+0x13));
  }
}
int main(int argc,char *argv[],char *envp[]){
  c(0x233,1,2,3,4,5,6,7,8,9,10);
}

output

arg1: 0x233
arg2: 0x1
arg3: 0x2
arg4: 0x3
arg5: 0x4
arg6: 0x5
arg7: 0x6
arg8: 0x7
arg9: 0x8
arg10: 0x9
arg11: 0xa

但是这样实现的变参是不具有可移植性的,不同的架构需要不同的实现,为了C的良好移植性,需要封装成统一的接口

ANSI C标准就制定了可移植的可变参函数实现方法,提供了一个专门用于处理可变参数的头文件stdarg.h

也就有了一开始我们用到的那几个宏

void va_start(va_list ap, last);
type va_arg(va_list ap, type);
void va_end(va_list ap);
void va_copy(va_list dest,va_list src);

隐式数据类型 va_list 作为获取参数的追踪指针

通过 va_start 来初始化ap指针 并在va_end释放

通过 va_arg 从ap获取对应类型的参数

虽然理想的va_list实现是void* 但若要拷贝va_list必须使用va_copy,这是因为有些平台下的va_list不是简单的指针

amd64下的va_list

typedef struct __va_list_tag{
	unsigned int gp_offset;   //通用寄存器偏移
	unsigned int fp_offset;   //浮点寄存器偏移
	void *overflow_arg_area;  //指向栈传参区(7~)
	void *reg_save_area;      //指向参数保存区(2~6)
}va_lsit

gcc的变参宏是内建的,在对应版本gcc/builtins.c:5047可见源码,但其中的tree和rtx等编译相关的细节较为复杂,暂且略过

整个变参通过几个标准的宏来提供,而由于实现和cpu的一些特性密切相关,考虑到跨平台的处理的复杂性,gcc下使用内置宏来间接实现变参宏,在硬件与软件,编译器和标准库间翩翩起舞

但同时,可变参带来灵活性的同时,也放弃了很大一部分编译时对函数参数的检查,在运行时对栈进行自由度极高的读写,这一特性的滥用讲使得攻击者可以很容易对栈进行修改以劫持控制流,比如经典的格式化字符串漏洞,在设计变参函数的过程中,必须明确声明一个控制条件,并在使用该函数过程中写死控制条件,不可将对参数的控制权交给用户