缓冲区溢出:十年来攻击和防卫的弱点
by dspman<dspman@antionline.org><http://www.nsfocus.com/>
来源:<crispin@cse.ogi.edu> http://www.cse.ogi.edu/DISC/peojects/immunix
一、前言
在过去的十年中,以缓冲区溢出为类型的安全漏洞占是最为常见的一种形式了。更为严重的是,缓冲区溢出漏洞占了远程网络攻击的绝大多数,这种攻击可以使得一个匿名的Internet用户有机会获得一台主机的部分或全部的控制权!由于这类攻击使任何人都有可能取得主机的控制权,所以它代表了一类极其严重的安全威胁。
缓冲区溢出攻击之所以成为一种常见安全攻击手段其原因在于缓冲区溢出漏洞太普通了,并且易于实现。而且,缓冲区溢出成为远程攻击的主要手段其原因在于缓冲区溢出漏洞给予了攻击者他所想要的一切:殖入并且执行攻击代码。被殖入的攻击代码以一定的权限运行有缓冲区溢出漏洞的程序,从而得到被攻击主机的控制权。
比如,在1998年Lincoln实验室用来评估入侵检测的的5种远程攻击中,有3种是基于社会工程学的信任关系,2种是缓冲区溢出。而在1998年CERT的13份建议中,有9份是是与缓冲区溢出有关的,在1999年,至少有半数的建议是和缓冲区溢出有关的。在Bugtraq的调查中,有2/3的被调查者认为缓冲区溢出漏洞是一个很严重的安全问题。
缓冲区溢出漏洞和攻击有很多种形式,我们会在第二部分对他们进行描述和分类。相应地防卫手段也随者攻击方法的不同而不同,我们会放在第三部分描述,它的内容包括针对每种攻击类型的有效的防卫手段。我们还要要介绍堆栈保护方法,这种方法在解决缓冲区溢出的漏洞方面很有效果,并且没有牺牲系统的兼容性和性能。在第四部分,我们要讨论各种防卫方法的综合使用。最后在第五部分是我们的结论。
二、缓冲区溢出的漏洞和攻击
缓冲区溢出攻击的目的在于扰乱具有某些特权运行的程序的功能,这样可以使得攻击者取得程序的控制权,如果该程序具有足够的权限,那么整个主机就被控制了。一般而言,攻击者攻击root程序,然后执行类似“exec(sh)”的执行代码来获得root的shell,但不一直是这样的。为了达到这个目的,攻击者必须达到如下的两个目标:
1. 在程序的地址空间里安排适当的代码。
2. 通过适当地初始化寄存器和存储器,让程序跳转到我们安排的地址空间执行。
我们根据这两个目标来对缓冲区溢出攻击进行分类。在2.1部分,我们将描述攻击代码是如何放入被攻击程序的地址空间的(这个就是“缓冲区”名字的的由来)。在2.2部分,我们介绍攻击者如何使一个程序的缓冲区溢出,并且执行转移到攻击代码(这个就是“溢出”的由来)。在2.3部分,我们介绍综合在2.1和2.2部分所讨论的代码安排和控制程序执行流程的技术。
2.1 在程序的地址空间里安排适当的代码的方法
有两种在被攻击程序地址空间里安排攻击代码的方法:
殖入法:
攻击者向被攻击的程序输入一个字符串,程序会把这个字符串放到缓冲区里。这个字符串包含的数据是可以在这个被攻击的硬件平台上运行的指令序列。在这里攻击者用被攻击程序的缓冲区来存放攻击代码。具体的方式有以下两种差别:
1. 攻击者不必为达到此目的而溢出任何缓冲区,可以找到足够的空间来放置攻击代码
2. 缓冲区可以设在任何地方:堆栈(自动变量)、堆(动态分配的)和静态数据区(初始化或者未初始化的数据)
利用已经存在的代码:
有时候,攻击者想要的代码已经在被攻击的程序中了,攻击者所要做的只是对代码传递一些参数,然后使程序跳转到我们的目标。比如,攻击代码要求执行“exec("/bin/sh")”,而在libc库中的代码执行“exec(arg)”,其中arg使一个指向一个字符串的指针参数,那么攻击者只要把传入的参数指针改向指向"/bin/sh",然后调转到libc库中的相应的指令序列。
2.2 控制程序转移到攻击代码的方法
所有的这些方法都是在寻求改变程序的执行流程,使之跳转到攻击代码。最基本的就是溢出一个没有边界检查或者其他弱点的缓冲区,这样就扰乱了程序的正常的执行顺序。通过溢出一个缓冲区,攻击者可以用近乎暴力的方法改写相邻的程序空间而直接跳过了系统的检查。
这里分类的基准是攻击者所寻求的缓冲区溢出的程序空间类型。原则上是可以任意的空间。比如,最初的Morris Worm使用了fingerd程序的缓冲区溢出,扰乱fingerd要执行的文件的名字。实际上,许多的缓冲区溢出是用暴力的方法来寻求改变程序指针的。这类程序的不同的地方就是程序空间的突破和内存空间的定位不同。
(图1)
激活纪录(Activation Records):
每当一个函数调用发生时,调用者会在堆栈中留下一个激活纪录,它包含了函数结束时
返回的地址。攻击者通过溢出这些自动变量,使这个返回地址指向攻击代码,如图1所示。通过改变程序的返回地址,当函数调用结束时,程序就跳转到攻击者设定的地址,而不是原先的地址。这类的缓冲区溢出被称为“stack smashing attack”,使目前常用的缓冲区溢出攻击方式。
函数指针(Function Pointers):
“void (* foo)()”声明了一个返回值为void函数指针的变量foo。函数指针可以用来定
位任何地址空间,所以攻击者只需在任何空间内的函数指针附近找到一个能够溢出的缓冲区,然后溢出这个缓冲区来改变函数指针。在某一时刻,当程序通过函数指针调用函数时,程序的流程就按攻击者的意图实现了!它的一个攻击范例就是在Linux系统下的superprobe程序。
长跳转缓冲区(Longjmp buffers):
在C语言中包含了一个简单的检验/恢复系统,称为setjmp/longjmp。意思是在检验点设
定“setjmp(buffer)”,用“longjmp(buffer)”来恢复检验点。然而,如果攻击者能够进入缓冲区的空间,那么“longjmp(buffer)”实际上是跳转到攻击者的代码。象函数指针一样,longjmp缓冲区能够指向任何地方,所以攻击者所要做的就是找到一个可供溢出的缓冲区。一个典型的例子就是Perl 5.003,攻击者首先进入用来恢复缓冲区溢出的的longjmp缓冲区,然后诱导进入恢复模式,这样就使Perl的解释器跳转到攻击代码上了!
2.3 综合代码殖入和流程控制技术
现在我们研究综合代码殖入和流程控制的技术。
最简单和常见的缓冲区溢出攻击类型就是在一个字符串里综合了代码殖入和激活纪录。攻击者定位一个可供溢出的自动变量,然后向程序传递一个很大的字符串,在引发缓冲区溢出改变激活纪录的同时殖入了代码。这个是由Levy指出的攻击的模板。因为C在习惯上只为用户和参数开辟很小的缓冲区,因此这种漏洞攻击的实例不在少数。
代码殖入和缓冲区溢出不一定要在在一次动作内完成。攻击者可以在一个缓冲区内放置代码,这是不能溢出缓冲区。然后,攻击者通过溢出另外一个缓冲区来转移程序的指针。这种方法一般用来解决可供溢出的缓冲区不够大(不能放下全部的代码)的情况。
如果攻击者试图使用已经常驻的代码而不是从外部殖入代码,他们通常有必须把代码作为参数化。举例来说,在libc(几乎所有的C程序都要它来连接)中的部分代码段会执行“exec(something)”,其中somthing就是参数。攻击者然后使用缓冲区溢出改变程序的参数,然后利用另一个缓冲区溢出使程序指针指向libc中的特定的代码段。
3. 缓冲区溢出的保护方法
目前有四种基本的方法保护缓冲区免受缓冲区溢出的攻击和影响。在3.1中介绍了强制写正确的代码的方法。在3.2中介绍了通过操作系统使得缓冲区不可执行,从而阻止攻击者殖入攻击代码。这种方法有效地阻止了很多缓冲区溢出的攻击,但是攻击者并不一定要殖入攻击代码来实现缓冲区溢出的攻击(参见2.1节),所以这种方法还是存在很弱点的。在3.3中,我们介绍了利用编译器的边界检查来实现缓冲区的保护。这个方法使得缓冲区溢出不可能出现,从而完全消除了缓冲区溢出的威胁,但是相对而言代价比较大。在3.4中我们介绍一种间接的方法,这个方法在程序指针失效前进行完整性检查。这样虽然这种方法不能使得所有的缓冲区溢出失效,但它的的确确阻止了绝大多数的缓冲区溢出攻击,而能够逃脱这种方法保护的缓冲区溢出也很难实现。然后在3.5,我们要分析这种保护方法的兼容性和性能优势(与数组边界检查)。
3.1 编写正确的代码
编写正确的代码是一件非常有意义但耗时的工作,特别象编写C语言那种具有容易出错倾向的程序(如:字符串的零结尾),这种风格是由于追求性能而忽视正确性的传统引起的。尽管花了很长的时间使得人们知道了如何编写安全的程序,具有安全漏洞的程序依旧出现。因此人们开发了一些工具和技术来帮助经验不足的程序员编写安全正确的程序。
最简单的方法就是用grep来搜索源代码中容易产生漏洞的库的调用,比如对strcpy和sprintf的调用,这两个函数都没有检查输入参数的长度。事实上,各个版本C的标准库均有这样的问题存在。
为了寻找一些常见的诸如缓冲区溢出和操作系统竞争条件等漏洞,代码检查小组检查了很多的代码。然而依然有漏网之鱼存在。尽管采用了strncpy和snprintf这些替代函数来防止缓冲区溢出的发生,但是由于编写代码的问题,仍旧会有这种情况发生。比如lprm程序就是最好的例子,虽然它通过了代码的安全检查,但仍然有缓冲区溢出的问题存在。
为了对付这些问题,人们开发了一些高级的查错工具,如fault injection等。这些工具的目的在于通过人为随机地产生一些缓冲区溢出来寻找代码的安全漏洞。还有一些静态分析工具用于侦测缓冲区溢出的存在。
虽然这些工具帮助程序员开发更安全的程序,但是由于C语言的特点,这些工具不可能找出所有的缓冲区溢出漏洞。所以,侦错技术只能用来减少缓冲区溢出的可能,并不能完全地消除它的存在。除非程序员能保证他的程序万无一失,否则还是要用到以下3.2到3.4部分的内容来保证程序的可靠性能。
3.2 非执行的缓冲区
通过使被攻击程序的数据段地址空间不可执行,从而使得攻击者不可能执行被殖入被攻击程序输入缓冲区的代码,这种技术被称为非执行的缓冲区技术。事实上,很多老的Unix系统都是这样设计的,但是近来的Unix和MS Windows系统由于实现更好的性能和功能,往往在在数据段中动态地放入可执行的代码。所以为了保持程序的兼容性不可能使得所有程序的数据段不可执行。
但是我们可以设定堆栈数据段不可执行,这样就可以最大限度地保证了程序的兼容性。Linux和Solaris都发布了有关这方面的内核补丁。因为几乎没有任何合法的程序会在堆栈中存放代码,这种做法几乎不产生任何兼容性问题,除了在Linux中的两个特例,这时可执行的代码必须被放入堆栈中:
信号传递:
Linux通过向进程堆栈释放代码然后引发中断来执行在堆栈中的代码来实现向进程发送Unix信号。非执行缓冲区的补丁在发送信号的时候是允许缓冲区可执行的。
GCC的在线重用:
研究发现gcc在堆栈区里放置了可执行的代码作为在线重用之用。然而,关闭这个功能并不产生任何问题,只有部分功能似乎不能使用。
非执行堆栈的保护可以有效地对付把代码殖入自动变量的缓冲区溢出攻击,而对于其他形式的攻击则没有效果(参见2.1)。通过引用一个驻留的程序的指针,就可以跳过这种保护措施。其他的攻击可以采用把代码殖入堆或者静态数据段中来跳过保护。
3.3 数组边界检查
殖入代码引起缓冲区溢出是一个方面,扰乱程序的执行流程是另一个方面。不象非执行缓冲区保护,数组边界检查完全放置了缓冲区溢出的产生和攻击。这样,只要数组不能被溢出,溢出攻击也就无从谈起。为了实现数组边界检查,则所有的对数组的读写操作都应当被检查以确保对数组的操作在正确的范围内。最直接的方法是检查所有的数组操作,但是通常可以采用一些优化的技术来减少检查的次数。目前有以下的几种检查方法:
3.3.1 Compaq C 编译器
Compaq公司为Alpha CPU开发的C编译器(在Tru64的Unix平台上是cc,在Alpha Linux平台上是ccc)支持有限度的边界检查(使用-check_bounds参数)。这些限制是:
·只有显示的数组引用才被检查,比如“a[3]”会被检查,而“*(a+3)”则不会。
·由于所有的C数组在传送的时候是指针传递的,所以传递给函数的的数组不会被检查。
·带有危险性的库函数如strcpy不会在编译的时候进行边界检查,即便是指定了边界检查。
由于在C语言中利用指针进行数组操作和传递是如此的频繁,因此这种局限性是非常严重的。通常这种边界检查用来程序的查错,而且不能保证不发生缓冲区溢出的漏洞。
3.3.2 Jones & Kelly: C的数组边界检查
Richard Jones和Paul Kelly开发了一个gcc的补丁,用来实现对C程序完全的数组边界检查。由于没有改变指针的含义所以被编译的程序和其他的gcc模块具有很好的兼容性。更进一步的是,他们由此从没有指针的表达式中导出了一个“基”指针,然后通过检查这个基指针来侦测表达式的结果是否在容许的范围之内。
当然,这样付出的性能上的代价是巨大的:对于一个频繁使用指针的程序如向量乘法,将由于指针的频繁使用而使速度比本来慢30倍。
这个编译器目前还很不成熟;一些复杂的程序(如elm)还不能在这个上面编译,执行通过。然而在它的一个更新版本之下,它至少能编译执行ssh软件的加密软件包。其实现的性能要下降12倍。
3.3.3 Purify:存储器存取检查
Purify是C程序调试时查看存储器使用的工具而不是专用的安全工具。Purify使用“目标代码插入”技术来检查所有的存储器存取。通过用Purify连接工具连接,可执行代码在执行的时候数组的所有引用来保证其合法性。这样带来的性能上的损失要下降3-5倍。
3.3.4 类型-安全语言
所有的缓冲区溢出漏洞都源于C语言缺乏类型安全。如果只有类型-安全的操作才可以被允许执行,这样就不可能出现对变量的强制操作。如果作为新手,可以推荐使用具有类型-安全的语言如Java和ML。
但是作为Java执行平台的Java虚拟机是C程序,因此通过攻击JVM的一条途径是使JVM的缓冲区溢出。因此在系统中采用缓冲区溢出防卫技术来使用强制类型-安全的语言可以收到意想不到的效果。
3.4 程序指针完整性检查
程序指针完整性检查和边界检查由略微的不同。与防止程序指针被改变不同,程序指针完整性检查在程序指针被引用之前检测到它的改变。因此,即便一个攻击者成功地改变了程序的指针,由于系统事先检测到了指针的改变,因此这个指针将不会被使用。
与数组边界检查相比,这种方法不能解决所有的缓冲区溢出问题;采用其他的缓冲区溢出方法就可以避免这种检测。但是这种方法在性能上有很大的优势,而且在兼容性也很好。
程序完整性检查大体上有三个研究方向。在3.4.1中会介绍Snarskii为FreeBSD开发了一套定制的能通过监测cpu堆栈来确定缓冲区溢出的libc。在3.4.2中会介绍我们自己的堆栈保护方法所开发的一个编译器,它能够在函数调用的时候自动生成完整性检测代码。最后在3.4.3,我们介绍正在开发中的指针保护方法,这种方法类似于堆栈保护,它提供对所有程序指针的完整性的保护。
3.4.1 手写的堆栈监测
Snarskii为FreeBSD开发了一套定制的能通过监测cpu堆栈来确定缓冲区溢出的libc。这个应用完全用手工汇编写的,而且只保护libc中的当前有效纪录函数。这个应用达到了设计要求,对于基于libc库函数的攻击具有很好的防卫,但是不能防卫其它方式的攻击。
3.4.2 堆栈保护:编译器生成的有效纪录完整性检测
堆栈保护是一种提供程序指针完整性检查的编译器技术,通过检查函数活动纪录中的返回地址来实现。堆栈保护作为gcc的一个小的补丁,在每个函数中,加入了函数建立和销毁的代码。加入的函数建立代码实际上在堆栈中函数返回地址后面加了一些附加的字节,如图2示。而在函数返回时,首先检查这个附加的字节是否被改动过。如果发生过缓冲区溢出的攻击,那么这种攻击很容易在函数返回前被检测到。
(图2)
但是,如果攻击者预见到这些附加字节的存在,并且能在溢出过程中同样地制造他们,那么他就能成功地跳过堆栈保护的检测。通常,我们有如下的两种方案对付这种欺骗:
终止符号:
利用在C语言中的终止符号如0(null),CR,LF,-1(EOF)等不能在常用的字符串函数中使用,因为这些函数一旦遇到这些终止符号,就结束函数过程了。
随机符号:
利用一个在函数调用时产生的一个32位的随机数来实现保密,使得攻击者不可能猜测到附加字节的内容。而且,每次调用,附加字节的内容都在改变,也无法预测。
通过检查堆栈的完整性的堆栈保护法是从Synthetix方法演变来的。Synthetix方法通过使用准不变量来确保特定变量的正确性。这些特定的变量的改变是程序实现能预知的,而且只能在满足一定的条件才能可以改变。这种变量我们称为准不变量。Synthetix开发了一些工具用来保护这些变量。
攻击者通过缓冲区溢出而产生的改变可以被系统当做非法的动作。在某些极端的情况下,这些准不变量有可能被非法改变,这是就需要堆栈保护来提供更完善的保护了。
(表一)
实验的数据表明,堆栈保护对于各种系统的缓冲区溢出攻击都有很好的保护作用,并能保持较好的兼容性和系统性能。早先我们报告的堆栈保护所能抑制的漏洞都在表一中列出。随后,我们用堆栈保护的方法重新构造了一个完整的Linux系统(Red Hat 5.1)。然后我们用XFree86-3.3.2-5和lsof的漏洞对此进行了攻击,结果表明,这个系统有效地抵御了这些攻击。这些分析表明,堆栈保护能有效抵御现在的和将来的基于堆栈的攻击。
堆栈保护版本的Red Hat Linux 5.1已经在各种系统上运行了多年,包括个人的笔记本电脑和工作组文件服务器。从我们的Web服务器上可以得到这个版本,而且在我们的邮件列表里已经有了55个成员。出了仅有的一次例外,这个系统和本来的系统工作完全一样,这表明堆栈保护并不对系统的兼容性构成很大的影响。
我们已经用各种性能测试来评测堆栈保护的性能。Mircobenchmarks的结果表明在函数的调用,堆栈保护中增加了系统的开销。而在网络的测试中(需要用到堆栈保护的地方),则表明这种开销不是很大。
我们的第一个测试对象是SSH,它提供了极强的加密和认证,用来替代Berkeley的r系列指令。SSH使用了软件加密,因此系统的占用的带宽不大,我们用网络间复制一个大的文件来测试带宽:
scp bigsource localhost:bigdest
测试结果表明:堆栈保护几乎不影响SSH的网络吞吐性能。
第二个测试使用了Apache Web服务器。如果这种服务器存在基于堆栈的攻击,那么攻击者就可以轻易地取得Web服务器的控制权,允许攻击者阅读隐秘的内容和肆意篡改主页的内容。同时,Web服务器也是对性能和带宽要求较高的一个服务器部件。
我们用WebStone对带有和不带堆栈保护的Apache Web服务器进行了测试,测试的结果在表二中列出。
和SSH一样,他们的性能几乎没有区别。在客户数目较少的情况下,带有保护的服务器性能比不带保护的略微好些,在客户端数目多的时候,不带保护的性能好些。在最坏的情况下,带保护的服务器比不带保护的要差8%的连接性能,而在平均延时上保持优势。象以前一样,我们把这些归结为噪声的影响。因此,我们的结论是:堆栈保护对Web服务器系统性能没有重大的影响。
3.4.3 指针保护:编译器生成程序指针完整性检查
在堆栈保护设计的时候,冲击堆栈构成了缓冲区溢出攻击的常见的一种形式。有人推测存在一种模板来构成这些攻击(在1996年的时候)。从此,很多简单的漏洞被发现,实施和补丁了,很多攻击者开始用在第二部分中描述的更一般的方法实施缓冲区溢出攻击。
指针保护是堆栈保护针对这种情况的一个推广。通过在所有的代码指针之后放置附加字节来检验指针在被调用之前的合法性。如果检验失败,会发出报警信号和退出程序的执行,就如同在堆栈保护中的行为一样。这种方案有两点需要注意:
附加字节的定位:
附加字节的空间是在被保护的变量被分配的时候分配的,同时在被保护字节初始化过程中被初始化。这样就带来了问题;为了保持兼容性,我们不想改变被保护变量的大小,因此我们不能简单地在变量的结构定义中加入附加字。还有,对各种类型也有不同附加字节数目。
检查附加字节:
每次程序指针被引用的时候都要检查附加字节的完整性。这个也存在问题;因为“从存取器读”在编译器中没有语义;编译器更关心指针的使用,而各种的优化算法倾向于从存储器中读入变量。
还有随着不同类型的变量,读入的方法也各自不同。
我们已经开发了指针保护的一个原型(还是基于gcc的),通过附加字节来保护静态分配的函数指针,但不适用于结构和数组类型。这个计划还远没有完成。一旦这个项目完成了,那么用它和堆栈保护构成的可执行代码将不会受到缓冲区溢出的攻击了。
目前为止,只有很少一部分使用非指针变量的攻击能逃脱指针保护的检测。但是,可以通过在编译器上强制对某一变量加入附加字节来实现检测,这时需要程序员自己手工加入相应的保护了。
3.5 兼容性和性能的考虑
程序指针完整性检查与边界检查相比,并不能防止所有的缓冲区溢出问题。然而在执行的性能和兼容性上具有相当的优势:
性能:
边界检查必须在每个数组元素操作时完成一次检查。相比之下,程序指针检查只在被引用的时候实现检查。无论在C还是在C++中,这种花在程序指针引用上的开销始终比数组的指针引用小。
应用效能:
边界检查最难实现之处在于在C语言中,很能确定数组的边界。这是由于在C中,数组的概念和通用指针的混用造成的。由于一个指针是一个独立的对象,没有与特定的边界条件关联,只有一个系统的机器字来存储它,而标识边界信息的数据却没有存放。因此需要特殊的方法来恢复这些信息;数组的引用将不在是一个简单的指针,而是一个对缓冲区描述的指针组。
与现有代码的兼容性:
一些边界检查方法为了与现有的代码保持兼容而在系统的性能上得到了损失。而另一些则用别的方法达到目的。这样就打破的传统的C的转换规则,转而产生了一类新的C编译器,只能编译C的一个子集,有的还不能使用指针或者需要别的改变。
4. 有效的组合
在这里我们研究、比较在第二部分描述的各种漏洞攻击和在第三部分描述的防卫方法,以此来确定何种组合能完全消除缓冲区溢出问题。我们把关于缓冲区溢出的攻击和防卫的方法都列在表3中,但是我们没有把边界检查计算在内,因为它能有效地防止所有的缓冲区溢出,但是所需的开销也是惊人的。
最普通的缓冲区溢出形式是攻击活动纪录然后在堆栈中殖入代码。这种类型的攻击在1996年中有很多纪录。而非执行堆栈和堆栈保护的方法都可以有效防卫这种攻击。非执行堆栈可以防卫所有把代码殖入堆栈的攻击方法,堆栈保护可以防卫所有改变活动纪录的方法。这两种方法相互兼容,可以同时防卫多种可能的攻击。
剩下的攻击基本上可以用指针保护的方法来防卫,但是在某些特殊的场合需要用手工来实现指针保护。全自动的指针保护需要对每个变量加入附加字节,这样使得指针边界检查在某些情况下具有优势。
最为有趣的是,第一个缓冲区溢出漏洞--Morris蠕虫使用了现今所有方法都无法有效防卫的方法,但是却很少有人用到,也许是这种方法过于复杂的缘故吧。
5. 结论
在本文中,我们详细描述和分析了缓冲区溢出的攻击和防卫方法。由于这种攻击是目前常见的攻击手段,所以进行这个方面的研究工作是有意义和成效的。研究的结果表明,堆栈保护方法和非执行缓冲区方法对于当前绝大多数的攻击都能有效地防御,指针保护的方法可以对剩下的攻击进行有效的防御。最后声明的是对于Morris蠕虫的攻击,迄今还没有有效的防御手段。
来源:<crispin@cse.ogi.edu> http://www.cse.ogi.edu/DISC/peojects/immunix
一、前言
在过去的十年中,以缓冲区溢出为类型的安全漏洞占是最为常见的一种形式了。更为严重的是,缓冲区溢出漏洞占了远程网络攻击的绝大多数,这种攻击可以使得一个匿名的Internet用户有机会获得一台主机的部分或全部的控制权!由于这类攻击使任何人都有可能取得主机的控制权,所以它代表了一类极其严重的安全威胁。
缓冲区溢出攻击之所以成为一种常见安全攻击手段其原因在于缓冲区溢出漏洞太普通了,并且易于实现。而且,缓冲区溢出成为远程攻击的主要手段其原因在于缓冲区溢出漏洞给予了攻击者他所想要的一切:殖入并且执行攻击代码。被殖入的攻击代码以一定的权限运行有缓冲区溢出漏洞的程序,从而得到被攻击主机的控制权。
比如,在1998年Lincoln实验室用来评估入侵检测的的5种远程攻击中,有3种是基于社会工程学的信任关系,2种是缓冲区溢出。而在1998年CERT的13份建议中,有9份是是与缓冲区溢出有关的,在1999年,至少有半数的建议是和缓冲区溢出有关的。在Bugtraq的调查中,有2/3的被调查者认为缓冲区溢出漏洞是一个很严重的安全问题。
缓冲区溢出漏洞和攻击有很多种形式,我们会在第二部分对他们进行描述和分类。相应地防卫手段也随者攻击方法的不同而不同,我们会放在第三部分描述,它的内容包括针对每种攻击类型的有效的防卫手段。我们还要要介绍堆栈保护方法,这种方法在解决缓冲区溢出的漏洞方面很有效果,并且没有牺牲系统的兼容性和性能。在第四部分,我们要讨论各种防卫方法的综合使用。最后在第五部分是我们的结论。
二、缓冲区溢出的漏洞和攻击
缓冲区溢出攻击的目的在于扰乱具有某些特权运行的程序的功能,这样可以使得攻击者取得程序的控制权,如果该程序具有足够的权限,那么整个主机就被控制了。一般而言,攻击者攻击root程序,然后执行类似“exec(sh)”的执行代码来获得root的shell,但不一直是这样的。为了达到这个目的,攻击者必须达到如下的两个目标:
1. 在程序的地址空间里安排适当的代码。
2. 通过适当地初始化寄存器和存储器,让程序跳转到我们安排的地址空间执行。
我们根据这两个目标来对缓冲区溢出攻击进行分类。在2.1部分,我们将描述攻击代码是如何放入被攻击程序的地址空间的(这个就是“缓冲区”名字的的由来)。在2.2部分,我们介绍攻击者如何使一个程序的缓冲区溢出,并且执行转移到攻击代码(这个就是“溢出”的由来)。在2.3部分,我们介绍综合在2.1和2.2部分所讨论的代码安排和控制程序执行流程的技术。
2.1 在程序的地址空间里安排适当的代码的方法
有两种在被攻击程序地址空间里安排攻击代码的方法:
殖入法:
攻击者向被攻击的程序输入一个字符串,程序会把这个字符串放到缓冲区里。这个字符串包含的数据是可以在这个被攻击的硬件平台上运行的指令序列。在这里攻击者用被攻击程序的缓冲区来存放攻击代码。具体的方式有以下两种差别:
1. 攻击者不必为达到此目的而溢出任何缓冲区,可以找到足够的空间来放置攻击代码
2. 缓冲区可以设在任何地方:堆栈(自动变量)、堆(动态分配的)和静态数据区(初始化或者未初始化的数据)
利用已经存在的代码:
有时候,攻击者想要的代码已经在被攻击的程序中了,攻击者所要做的只是对代码传递一些参数,然后使程序跳转到我们的目标。比如,攻击代码要求执行“exec("/bin/sh")”,而在libc库中的代码执行“exec(arg)”,其中arg使一个指向一个字符串的指针参数,那么攻击者只要把传入的参数指针改向指向"/bin/sh",然后调转到libc库中的相应的指令序列。
2.2 控制程序转移到攻击代码的方法
所有的这些方法都是在寻求改变程序的执行流程,使之跳转到攻击代码。最基本的就是溢出一个没有边界检查或者其他弱点的缓冲区,这样就扰乱了程序的正常的执行顺序。通过溢出一个缓冲区,攻击者可以用近乎暴力的方法改写相邻的程序空间而直接跳过了系统的检查。
这里分类的基准是攻击者所寻求的缓冲区溢出的程序空间类型。原则上是可以任意的空间。比如,最初的Morris Worm使用了fingerd程序的缓冲区溢出,扰乱fingerd要执行的文件的名字。实际上,许多的缓冲区溢出是用暴力的方法来寻求改变程序指针的。这类程序的不同的地方就是程序空间的突破和内存空间的定位不同。
(图1)
激活纪录(Activation Records):
每当一个函数调用发生时,调用者会在堆栈中留下一个激活纪录,它包含了函数结束时
返回的地址。攻击者通过溢出这些自动变量,使这个返回地址指向攻击代码,如图1所示。通过改变程序的返回地址,当函数调用结束时,程序就跳转到攻击者设定的地址,而不是原先的地址。这类的缓冲区溢出被称为“stack smashing attack”,使目前常用的缓冲区溢出攻击方式。
函数指针(Function Pointers):
“void (* foo)()”声明了一个返回值为void函数指针的变量foo。函数指针可以用来定
位任何地址空间,所以攻击者只需在任何空间内的函数指针附近找到一个能够溢出的缓冲区,然后溢出这个缓冲区来改变函数指针。在某一时刻,当程序通过函数指针调用函数时,程序的流程就按攻击者的意图实现了!它的一个攻击范例就是在Linux系统下的superprobe程序。
长跳转缓冲区(Longjmp buffers):
在C语言中包含了一个简单的检验/恢复系统,称为setjmp/longjmp。意思是在检验点设
定“setjmp(buffer)”,用“longjmp(buffer)”来恢复检验点。然而,如果攻击者能够进入缓冲区的空间,那么“longjmp(buffer)”实际上是跳转到攻击者的代码。象函数指针一样,longjmp缓冲区能够指向任何地方,所以攻击者所要做的就是找到一个可供溢出的缓冲区。一个典型的例子就是Perl 5.003,攻击者首先进入用来恢复缓冲区溢出的的longjmp缓冲区,然后诱导进入恢复模式,这样就使Perl的解释器跳转到攻击代码上了!
2.3 综合代码殖入和流程控制技术
现在我们研究综合代码殖入和流程控制的技术。
最简单和常见的缓冲区溢出攻击类型就是在一个字符串里综合了代码殖入和激活纪录。攻击者定位一个可供溢出的自动变量,然后向程序传递一个很大的字符串,在引发缓冲区溢出改变激活纪录的同时殖入了代码。这个是由Levy指出的攻击的模板。因为C在习惯上只为用户和参数开辟很小的缓冲区,因此这种漏洞攻击的实例不在少数。
代码殖入和缓冲区溢出不一定要在在一次动作内完成。攻击者可以在一个缓冲区内放置代码,这是不能溢出缓冲区。然后,攻击者通过溢出另外一个缓冲区来转移程序的指针。这种方法一般用来解决可供溢出的缓冲区不够大(不能放下全部的代码)的情况。
如果攻击者试图使用已经常驻的代码而不是从外部殖入代码,他们通常有必须把代码作为参数化。举例来说,在libc(几乎所有的C程序都要它来连接)中的部分代码段会执行“exec(something)”,其中somthing就是参数。攻击者然后使用缓冲区溢出改变程序的参数,然后利用另一个缓冲区溢出使程序指针指向libc中的特定的代码段。
3. 缓冲区溢出的保护方法
目前有四种基本的方法保护缓冲区免受缓冲区溢出的攻击和影响。在3.1中介绍了强制写正确的代码的方法。在3.2中介绍了通过操作系统使得缓冲区不可执行,从而阻止攻击者殖入攻击代码。这种方法有效地阻止了很多缓冲区溢出的攻击,但是攻击者并不一定要殖入攻击代码来实现缓冲区溢出的攻击(参见2.1节),所以这种方法还是存在很弱点的。在3.3中,我们介绍了利用编译器的边界检查来实现缓冲区的保护。这个方法使得缓冲区溢出不可能出现,从而完全消除了缓冲区溢出的威胁,但是相对而言代价比较大。在3.4中我们介绍一种间接的方法,这个方法在程序指针失效前进行完整性检查。这样虽然这种方法不能使得所有的缓冲区溢出失效,但它的的确确阻止了绝大多数的缓冲区溢出攻击,而能够逃脱这种方法保护的缓冲区溢出也很难实现。然后在3.5,我们要分析这种保护方法的兼容性和性能优势(与数组边界检查)。
3.1 编写正确的代码
编写正确的代码是一件非常有意义但耗时的工作,特别象编写C语言那种具有容易出错倾向的程序(如:字符串的零结尾),这种风格是由于追求性能而忽视正确性的传统引起的。尽管花了很长的时间使得人们知道了如何编写安全的程序,具有安全漏洞的程序依旧出现。因此人们开发了一些工具和技术来帮助经验不足的程序员编写安全正确的程序。
最简单的方法就是用grep来搜索源代码中容易产生漏洞的库的调用,比如对strcpy和sprintf的调用,这两个函数都没有检查输入参数的长度。事实上,各个版本C的标准库均有这样的问题存在。
为了寻找一些常见的诸如缓冲区溢出和操作系统竞争条件等漏洞,代码检查小组检查了很多的代码。然而依然有漏网之鱼存在。尽管采用了strncpy和snprintf这些替代函数来防止缓冲区溢出的发生,但是由于编写代码的问题,仍旧会有这种情况发生。比如lprm程序就是最好的例子,虽然它通过了代码的安全检查,但仍然有缓冲区溢出的问题存在。
为了对付这些问题,人们开发了一些高级的查错工具,如fault injection等。这些工具的目的在于通过人为随机地产生一些缓冲区溢出来寻找代码的安全漏洞。还有一些静态分析工具用于侦测缓冲区溢出的存在。
虽然这些工具帮助程序员开发更安全的程序,但是由于C语言的特点,这些工具不可能找出所有的缓冲区溢出漏洞。所以,侦错技术只能用来减少缓冲区溢出的可能,并不能完全地消除它的存在。除非程序员能保证他的程序万无一失,否则还是要用到以下3.2到3.4部分的内容来保证程序的可靠性能。
3.2 非执行的缓冲区
通过使被攻击程序的数据段地址空间不可执行,从而使得攻击者不可能执行被殖入被攻击程序输入缓冲区的代码,这种技术被称为非执行的缓冲区技术。事实上,很多老的Unix系统都是这样设计的,但是近来的Unix和MS Windows系统由于实现更好的性能和功能,往往在在数据段中动态地放入可执行的代码。所以为了保持程序的兼容性不可能使得所有程序的数据段不可执行。
但是我们可以设定堆栈数据段不可执行,这样就可以最大限度地保证了程序的兼容性。Linux和Solaris都发布了有关这方面的内核补丁。因为几乎没有任何合法的程序会在堆栈中存放代码,这种做法几乎不产生任何兼容性问题,除了在Linux中的两个特例,这时可执行的代码必须被放入堆栈中:
信号传递:
Linux通过向进程堆栈释放代码然后引发中断来执行在堆栈中的代码来实现向进程发送Unix信号。非执行缓冲区的补丁在发送信号的时候是允许缓冲区可执行的。
GCC的在线重用:
研究发现gcc在堆栈区里放置了可执行的代码作为在线重用之用。然而,关闭这个功能并不产生任何问题,只有部分功能似乎不能使用。
非执行堆栈的保护可以有效地对付把代码殖入自动变量的缓冲区溢出攻击,而对于其他形式的攻击则没有效果(参见2.1)。通过引用一个驻留的程序的指针,就可以跳过这种保护措施。其他的攻击可以采用把代码殖入堆或者静态数据段中来跳过保护。
3.3 数组边界检查
殖入代码引起缓冲区溢出是一个方面,扰乱程序的执行流程是另一个方面。不象非执行缓冲区保护,数组边界检查完全放置了缓冲区溢出的产生和攻击。这样,只要数组不能被溢出,溢出攻击也就无从谈起。为了实现数组边界检查,则所有的对数组的读写操作都应当被检查以确保对数组的操作在正确的范围内。最直接的方法是检查所有的数组操作,但是通常可以采用一些优化的技术来减少检查的次数。目前有以下的几种检查方法:
3.3.1 Compaq C 编译器
Compaq公司为Alpha CPU开发的C编译器(在Tru64的Unix平台上是cc,在Alpha Linux平台上是ccc)支持有限度的边界检查(使用-check_bounds参数)。这些限制是:
·只有显示的数组引用才被检查,比如“a[3]”会被检查,而“*(a+3)”则不会。
·由于所有的C数组在传送的时候是指针传递的,所以传递给函数的的数组不会被检查。
·带有危险性的库函数如strcpy不会在编译的时候进行边界检查,即便是指定了边界检查。
由于在C语言中利用指针进行数组操作和传递是如此的频繁,因此这种局限性是非常严重的。通常这种边界检查用来程序的查错,而且不能保证不发生缓冲区溢出的漏洞。
3.3.2 Jones & Kelly: C的数组边界检查
Richard Jones和Paul Kelly开发了一个gcc的补丁,用来实现对C程序完全的数组边界检查。由于没有改变指针的含义所以被编译的程序和其他的gcc模块具有很好的兼容性。更进一步的是,他们由此从没有指针的表达式中导出了一个“基”指针,然后通过检查这个基指针来侦测表达式的结果是否在容许的范围之内。
当然,这样付出的性能上的代价是巨大的:对于一个频繁使用指针的程序如向量乘法,将由于指针的频繁使用而使速度比本来慢30倍。
这个编译器目前还很不成熟;一些复杂的程序(如elm)还不能在这个上面编译,执行通过。然而在它的一个更新版本之下,它至少能编译执行ssh软件的加密软件包。其实现的性能要下降12倍。
3.3.3 Purify:存储器存取检查
Purify是C程序调试时查看存储器使用的工具而不是专用的安全工具。Purify使用“目标代码插入”技术来检查所有的存储器存取。通过用Purify连接工具连接,可执行代码在执行的时候数组的所有引用来保证其合法性。这样带来的性能上的损失要下降3-5倍。
3.3.4 类型-安全语言
所有的缓冲区溢出漏洞都源于C语言缺乏类型安全。如果只有类型-安全的操作才可以被允许执行,这样就不可能出现对变量的强制操作。如果作为新手,可以推荐使用具有类型-安全的语言如Java和ML。
但是作为Java执行平台的Java虚拟机是C程序,因此通过攻击JVM的一条途径是使JVM的缓冲区溢出。因此在系统中采用缓冲区溢出防卫技术来使用强制类型-安全的语言可以收到意想不到的效果。
3.4 程序指针完整性检查
程序指针完整性检查和边界检查由略微的不同。与防止程序指针被改变不同,程序指针完整性检查在程序指针被引用之前检测到它的改变。因此,即便一个攻击者成功地改变了程序的指针,由于系统事先检测到了指针的改变,因此这个指针将不会被使用。
与数组边界检查相比,这种方法不能解决所有的缓冲区溢出问题;采用其他的缓冲区溢出方法就可以避免这种检测。但是这种方法在性能上有很大的优势,而且在兼容性也很好。
程序完整性检查大体上有三个研究方向。在3.4.1中会介绍Snarskii为FreeBSD开发了一套定制的能通过监测cpu堆栈来确定缓冲区溢出的libc。在3.4.2中会介绍我们自己的堆栈保护方法所开发的一个编译器,它能够在函数调用的时候自动生成完整性检测代码。最后在3.4.3,我们介绍正在开发中的指针保护方法,这种方法类似于堆栈保护,它提供对所有程序指针的完整性的保护。
3.4.1 手写的堆栈监测
Snarskii为FreeBSD开发了一套定制的能通过监测cpu堆栈来确定缓冲区溢出的libc。这个应用完全用手工汇编写的,而且只保护libc中的当前有效纪录函数。这个应用达到了设计要求,对于基于libc库函数的攻击具有很好的防卫,但是不能防卫其它方式的攻击。
3.4.2 堆栈保护:编译器生成的有效纪录完整性检测
堆栈保护是一种提供程序指针完整性检查的编译器技术,通过检查函数活动纪录中的返回地址来实现。堆栈保护作为gcc的一个小的补丁,在每个函数中,加入了函数建立和销毁的代码。加入的函数建立代码实际上在堆栈中函数返回地址后面加了一些附加的字节,如图2示。而在函数返回时,首先检查这个附加的字节是否被改动过。如果发生过缓冲区溢出的攻击,那么这种攻击很容易在函数返回前被检测到。
(图2)
但是,如果攻击者预见到这些附加字节的存在,并且能在溢出过程中同样地制造他们,那么他就能成功地跳过堆栈保护的检测。通常,我们有如下的两种方案对付这种欺骗:
终止符号:
利用在C语言中的终止符号如0(null),CR,LF,-1(EOF)等不能在常用的字符串函数中使用,因为这些函数一旦遇到这些终止符号,就结束函数过程了。
随机符号:
利用一个在函数调用时产生的一个32位的随机数来实现保密,使得攻击者不可能猜测到附加字节的内容。而且,每次调用,附加字节的内容都在改变,也无法预测。
通过检查堆栈的完整性的堆栈保护法是从Synthetix方法演变来的。Synthetix方法通过使用准不变量来确保特定变量的正确性。这些特定的变量的改变是程序实现能预知的,而且只能在满足一定的条件才能可以改变。这种变量我们称为准不变量。Synthetix开发了一些工具用来保护这些变量。
攻击者通过缓冲区溢出而产生的改变可以被系统当做非法的动作。在某些极端的情况下,这些准不变量有可能被非法改变,这是就需要堆栈保护来提供更完善的保护了。
(表一)
实验的数据表明,堆栈保护对于各种系统的缓冲区溢出攻击都有很好的保护作用,并能保持较好的兼容性和系统性能。早先我们报告的堆栈保护所能抑制的漏洞都在表一中列出。随后,我们用堆栈保护的方法重新构造了一个完整的Linux系统(Red Hat 5.1)。然后我们用XFree86-3.3.2-5和lsof的漏洞对此进行了攻击,结果表明,这个系统有效地抵御了这些攻击。这些分析表明,堆栈保护能有效抵御现在的和将来的基于堆栈的攻击。
堆栈保护版本的Red Hat Linux 5.1已经在各种系统上运行了多年,包括个人的笔记本电脑和工作组文件服务器。从我们的Web服务器上可以得到这个版本,而且在我们的邮件列表里已经有了55个成员。出了仅有的一次例外,这个系统和本来的系统工作完全一样,这表明堆栈保护并不对系统的兼容性构成很大的影响。
我们已经用各种性能测试来评测堆栈保护的性能。Mircobenchmarks的结果表明在函数的调用,堆栈保护中增加了系统的开销。而在网络的测试中(需要用到堆栈保护的地方),则表明这种开销不是很大。
我们的第一个测试对象是SSH,它提供了极强的加密和认证,用来替代Berkeley的r系列指令。SSH使用了软件加密,因此系统的占用的带宽不大,我们用网络间复制一个大的文件来测试带宽:
scp bigsource localhost:bigdest
测试结果表明:堆栈保护几乎不影响SSH的网络吞吐性能。
第二个测试使用了Apache Web服务器。如果这种服务器存在基于堆栈的攻击,那么攻击者就可以轻易地取得Web服务器的控制权,允许攻击者阅读隐秘的内容和肆意篡改主页的内容。同时,Web服务器也是对性能和带宽要求较高的一个服务器部件。
我们用WebStone对带有和不带堆栈保护的Apache Web服务器进行了测试,测试的结果在表二中列出。
和SSH一样,他们的性能几乎没有区别。在客户数目较少的情况下,带有保护的服务器性能比不带保护的略微好些,在客户端数目多的时候,不带保护的性能好些。在最坏的情况下,带保护的服务器比不带保护的要差8%的连接性能,而在平均延时上保持优势。象以前一样,我们把这些归结为噪声的影响。因此,我们的结论是:堆栈保护对Web服务器系统性能没有重大的影响。
3.4.3 指针保护:编译器生成程序指针完整性检查
在堆栈保护设计的时候,冲击堆栈构成了缓冲区溢出攻击的常见的一种形式。有人推测存在一种模板来构成这些攻击(在1996年的时候)。从此,很多简单的漏洞被发现,实施和补丁了,很多攻击者开始用在第二部分中描述的更一般的方法实施缓冲区溢出攻击。
指针保护是堆栈保护针对这种情况的一个推广。通过在所有的代码指针之后放置附加字节来检验指针在被调用之前的合法性。如果检验失败,会发出报警信号和退出程序的执行,就如同在堆栈保护中的行为一样。这种方案有两点需要注意:
附加字节的定位:
附加字节的空间是在被保护的变量被分配的时候分配的,同时在被保护字节初始化过程中被初始化。这样就带来了问题;为了保持兼容性,我们不想改变被保护变量的大小,因此我们不能简单地在变量的结构定义中加入附加字。还有,对各种类型也有不同附加字节数目。
检查附加字节:
每次程序指针被引用的时候都要检查附加字节的完整性。这个也存在问题;因为“从存取器读”在编译器中没有语义;编译器更关心指针的使用,而各种的优化算法倾向于从存储器中读入变量。
还有随着不同类型的变量,读入的方法也各自不同。
我们已经开发了指针保护的一个原型(还是基于gcc的),通过附加字节来保护静态分配的函数指针,但不适用于结构和数组类型。这个计划还远没有完成。一旦这个项目完成了,那么用它和堆栈保护构成的可执行代码将不会受到缓冲区溢出的攻击了。
目前为止,只有很少一部分使用非指针变量的攻击能逃脱指针保护的检测。但是,可以通过在编译器上强制对某一变量加入附加字节来实现检测,这时需要程序员自己手工加入相应的保护了。
3.5 兼容性和性能的考虑
程序指针完整性检查与边界检查相比,并不能防止所有的缓冲区溢出问题。然而在执行的性能和兼容性上具有相当的优势:
性能:
边界检查必须在每个数组元素操作时完成一次检查。相比之下,程序指针检查只在被引用的时候实现检查。无论在C还是在C++中,这种花在程序指针引用上的开销始终比数组的指针引用小。
应用效能:
边界检查最难实现之处在于在C语言中,很能确定数组的边界。这是由于在C中,数组的概念和通用指针的混用造成的。由于一个指针是一个独立的对象,没有与特定的边界条件关联,只有一个系统的机器字来存储它,而标识边界信息的数据却没有存放。因此需要特殊的方法来恢复这些信息;数组的引用将不在是一个简单的指针,而是一个对缓冲区描述的指针组。
与现有代码的兼容性:
一些边界检查方法为了与现有的代码保持兼容而在系统的性能上得到了损失。而另一些则用别的方法达到目的。这样就打破的传统的C的转换规则,转而产生了一类新的C编译器,只能编译C的一个子集,有的还不能使用指针或者需要别的改变。
4. 有效的组合
在这里我们研究、比较在第二部分描述的各种漏洞攻击和在第三部分描述的防卫方法,以此来确定何种组合能完全消除缓冲区溢出问题。我们把关于缓冲区溢出的攻击和防卫的方法都列在表3中,但是我们没有把边界检查计算在内,因为它能有效地防止所有的缓冲区溢出,但是所需的开销也是惊人的。
最普通的缓冲区溢出形式是攻击活动纪录然后在堆栈中殖入代码。这种类型的攻击在1996年中有很多纪录。而非执行堆栈和堆栈保护的方法都可以有效防卫这种攻击。非执行堆栈可以防卫所有把代码殖入堆栈的攻击方法,堆栈保护可以防卫所有改变活动纪录的方法。这两种方法相互兼容,可以同时防卫多种可能的攻击。
剩下的攻击基本上可以用指针保护的方法来防卫,但是在某些特殊的场合需要用手工来实现指针保护。全自动的指针保护需要对每个变量加入附加字节,这样使得指针边界检查在某些情况下具有优势。
最为有趣的是,第一个缓冲区溢出漏洞--Morris蠕虫使用了现今所有方法都无法有效防卫的方法,但是却很少有人用到,也许是这种方法过于复杂的缘故吧。
5. 结论
在本文中,我们详细描述和分析了缓冲区溢出的攻击和防卫方法。由于这种攻击是目前常见的攻击手段,所以进行这个方面的研究工作是有意义和成效的。研究的结果表明,堆栈保护方法和非执行缓冲区方法对于当前绝大多数的攻击都能有效地防御,指针保护的方法可以对剩下的攻击进行有效的防御。最后声明的是对于Morris蠕虫的攻击,迄今还没有有效的防御手段。