c语言如何配置可变参数(C语言可变参数及其原理)
c语言如何配置可变参数(C语言可变参数及其原理)我的balloon OS中,printk是这样运行的:好像看起来还是很混乱,所以我从网上又查到了一个相同原理的更简单粗暴的实现(这段代码同时也在我的balloon OS的vsprintf.c中被使用):所幸我找到了一份linux 0.01的代码,代码里有printk的实现,printk在某种意义上来说相当于是内核中的printf,其使用方法和printf基本没有区别。而且最重要的是,在这份代码里我们能看到可变参数能够被使用的关键代码,并且不用被windows平台的头文件内容给搅浑了思路……下面的内嵌汇编我们就暂时忽视了吧,只看我们需要看的三条语句,一个是va_start,一个是vsprintf调用,一个是va_end。va_start,va_end都被定义在include/stdarg.h中,定义如下:
在刚学C没多久的时候,我一直有这么个疑问:
printf这个函数是怎么做到可以处理不同数量的参数的?在初学C的时候,根本就没有遇到过(事实上程序设计课也没讲过)。
我们追踪一下printf的实现:
这里用到了__builtin_va_list __builtin_va_start __builtin_va_end,在中间调用了__mingw_vprintf函数,把类型为__builtin_va_list的__local_argv和第一个参数__format传了进去……然后就不明所以了。
所幸我找到了一份linux 0.01的代码,代码里有printk的实现,printk在某种意义上来说相当于是内核中的printf,其使用方法和printf基本没有区别。而且最重要的是,在这份代码里我们能看到可变参数能够被使用的关键代码,并且不用被windows平台的头文件内容给搅浑了思路……
下面的内嵌汇编我们就暂时忽视了吧,只看我们需要看的三条语句,一个是va_start,一个是vsprintf调用,一个是va_end。
va_start,va_end都被定义在include/stdarg.h中,定义如下:
好像看起来还是很混乱,所以我从网上又查到了一个相同原理的更简单粗暴的实现(这段代码同时也在我的balloon OS的vsprintf.c中被使用):
我的balloon OS中,printk是这样运行的:
typedef char* va_list;
#define _INTSIZEOF(n) ((sizeof(n) sizeof(int)-1)&~(sizeof(int)-1))
#define va_start(ap v) (ap=(va_list)&v _INTSIZEOF(v))
#define va_arg(ap t) (*(t*)((ap =_INTSIZEOF(t))-_INTSIZEOF(t)))
#define va_end(ap) (ap=(va_list)0)
第一句typedef char* va_list;
意思就是 va_list其实本质是char* 字符指针,只不过给它换了个名字而已。
第二句 _INTSIZEOF(n)定义为((sizeof(n) sizeof(int)-1)&~(sizeof(int)-1))
这个宏的目的是根据各种变量类型,取得他们的变量存在栈中的长度。实际使用中,每个传参存储的空间都必须是int类型长度的整数倍,即4 8 12 16……
我们拿char short int double来举例子
_INTSIZEOF(char) 会被替换为((sizeof(char) sizeof(int)-1)&~(sizeof(int)-1))
这时算出的结果为((1 4-1)&~3),即4和非3进行与操作,非3执行后,该数据的低2位都为0,高位全为1,和4相与,结果为4
_INTSIZEOF(short)结果为((2 4-1)&~3),5和非3相与,结果为4
_INTSIZEOF(int)结果为((4 4-1)&~3),7和非3相与,结果为4
_INTSIZEOF(double)结果为((8 4-1)&~3),11和非3相与,结果为8
不得不说这个宏的设计非常巧妙。
第三句 va_start(ap v)定义为(ap=(va_list)&v _INTSIZEOF(v))
这个宏的目的就是根据传入参数的第一个参数,获取可变参数列表的第一个参数的地址。printk在调用时,第一个传入的参数是const char* fmt,那么va_start(ap fmt)就被展开为(ap=(char*)&fmt _INTSIZEOF(fmt)),即获取fmt的指针之后,跳到fmt指针指向的空间存储的内容的下一个内容处。
最后一句话可能有点晦涩,这时候就需要来点图……
在现代计算机中,栈是一个地址从大往小增长的数据结构,即栈里面填入的内容越多,栈顶的地址越小。
拿开头的那个printf调用做例子,参数是从右到左依次入栈的,所以栈顶的fmt,对应的地址是最小的。那么我在执行va_start(ap fmt)的时候,(char*)&fmt获得了fmt存储的地址0x40007924,再加上sizeof(const char*)的4个字节,就到了0x40007924。
注意,在32位机中,指针类型的长度是4字节,64位机中则长度是8字节,那么在64位机中,fmt存储的位置就要从0x0000000040007920开始,一直跨越到0x0000000040007927……那么ap就获得了可变参数列表的入口地址了。
第四句va_arg(ap t)定义为(*(t*)((ap =_INTSIZEOF(t))-_INTSIZEOF(t)))
这个宏的目的是从列表中取出一个参数,然后让ap跳到下一个参数的地址处。很明显,ap先执行了内部的 =操作更新了自己,然后通过后面的-操作获得ap之前指向的数据的地址,然后用*操作取出内容。
第五句 va_end简单粗暴,就是把ap设置为空指针。
通过这几个宏,我们可以让vsprintf这个函数通过格式字符串,一个个对应获取数据,转换成字符,放入buf中,跳转回printf/printk,然后调用系统的接口,把buf内的内容展现出来……
上图为vsprintf内部的一小段,其中的va_arg就是在从参数列表中获取内容。
那么既然我们能通过格式化字符串来获得栈上内容,我可不可以故意写错格式化字符串,导致printf/printk崩溃呢?其实是可以的……而且,在格式化字符串中有个非常有趣的控制格式符%n,这个格式符并不会进行任何输出,但是会在获取的地址位置填入之前输出的字符的个数。
上图为vsprintf中对%n格式符的操作,ip为int*类型,最后我们在ip指向的位置存入了str-buf,即已经输出的字符的个数。
在无canary等栈保护机制的条件下,格式化字符串会把栈直接暴露给用户,如果字符串写法不当,很容易造成漏洞。而函数在调用时,会将ret指令需要使用的指令地址存在栈上的某一位置,如下图:
图中0x4000791c位置是调用vsprintf留下的返回地址,vsprintf执行完之后会通过这个回到printf/printk中。printf/printk执行完之后,会根据0x40007930处的地址返回到之前调用他们的函数中(这里假设是main函数)。
那么我要是把格式化字符串写成"%d %c %n"会怎样?答案是vsprintf照样会在遇到%n时选择访问0x40007930,并且把已经输出的字符数量存入这个位置,而这个位置本来存的是外部函数调用printf时,call printf指令的下一条指令的地址。
如果我们控制输出的字符数量为一个非常特殊的值会怎么样?那么vsprintf仍然会在该处填入这个值。如果这个值指向一个后门函数中的一条关键指令。那么恭喜你,在运行完vsprintf和它的上层函数(printf/printk,或其他任意调用了vsprintf的,有可变参数的函数)之后,上层函数返回时,你的程序就中招了……