缓冲区溢出:十年来攻击和防卫的弱点
摘要: 在过去的十年中,以缓冲区溢出为类型的安全漏洞占是最为 常见的一种形式了。更为严重的是,缓冲区溢出漏洞占了远程网 络攻击的绝大多数,这种攻击可以使得一个匿名的Internet用户 有机会获得一台主机的部分或全部的控制权!如果能有效地消除 缓冲区溢出的漏洞,则很大一部分的安全威胁可以得到缓解。在 本文中,我们研究了各种类型的缓冲区溢出漏洞和攻击手段,同 时我们也研究了各种的防御手段,这些手段用来消除这些漏洞所 造成的影响,其中包括我们自己的堆栈保护方法。然后我们要考 虑如何在保证现有系统功能和性能不变的情况下,如何使用这些 方法来消除这些安全漏洞。
一、前言 在过去的十年中,以缓冲区溢出为类型的安全漏洞占是最为 常见的一种形式了。更为严重的是,缓冲区溢出漏洞占了远程网 络攻击的绝大多数,这种攻击可以使得一个匿名的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语言中利用指针进行数组操作和传递是如此的频 繁,因此这种局限性是非常严重的。通常这种边界检查用来程序 的查错,而且不能保证不发生缓冲区溢出的漏洞。