应该感谢那些指出你错误的人

借我三千虎骑,复我泱泱中华!

博客园 首页 新随笔 联系 订阅 管理
本文讨论 Linux/UNIX 系统中最常见的缺陷:缓冲区溢出。本文首先解释什么是缓冲区溢出,以及它们为何如此常见和如此危险。然后讨论广泛用于解决缓冲区溢出的新 Linux 和 UNIX 方法 ―― 以及为什么这些方法还不足够。随后将展示 C/C++ 程序中防止缓冲区溢出的各种方法,同时包括静态调整大小的方法(比如标准的 C 库和 OpenBSD/strlcpy 解决方案)和动态调整大小的解决方案,以及一些将为您提供帮助的工具。最后,本文以一些关于缓冲区溢出缺陷的未来发展形势的预测来结束全文的讨论。

1988 年 11 月,许多组织不得不因为“Morris 蠕虫”而切断 Internet 连接,“Morris 蠕虫”是 23 岁的程序员 Robert Tappan Morris 编写的用于攻击 VAX 和 Sun 机器的程序。 据有关方面估计,这个程序大约使得整个 Internet 的 10% 崩溃。 2001 年 7 月,另一个名为“Code Red”的蠕虫病毒最终导致了全球运行微软的 IIS Web Server 的 300,000 多台计算机受到攻击。2003 年 1 月,“Slammer”(也称为“Sapphire”)蠕虫利用 Microsoft SQL Server 2000 中的一个缺陷,使得南韩和日本的部分 Internet 崩溃,中断了芬兰的电话服务,并且使得美国航空订票系统、信用卡网络和自动出纳机运行缓慢。所有这些攻击 ―― 以及其他许多攻击,都利用了一个称做为 缓冲区溢出 的程序缺陷。

1999 年 Bugtraq(一个讨论安全缺陷的邮件列表)进行的一次非正式调查发现,三分之二的参与者认为第一号的缺陷就是缓冲区溢出(要了解相关背景,请参阅本文后面 参考资料部 分列出的“Buffer Overflows: Attacks and Defenses for the Vulnerability of the Decade”一文)。从 1997 年到 2002 年 3 月,CERT/CC 发出的半数安全警报都基于缓冲区缺陷。

如果希望自己的程序是安全的,您需要知道什么是缓冲区溢出,如何防止它们,可以采用哪些最新的自动化工具来防止它们(以及为什么这些工具还不足够),还有如何在您自己的程序中防止它们。

什么是缓冲区溢出?

缓冲区以前可能被定义为“包含相同数据类型的实例的一个连续计算机内存块”。在 C 和 C++ 中,缓冲区通常是使用数组和诸如 malloc() new 这样的内存分配例程来实现的。极其常见的缓冲区种类是简单的字符数组。 溢出 是指数据被添加到分配给该缓冲区的内存块之外。

如果攻击者能够导致缓冲区溢出,那么它就能控制程序中的其他值。虽然存在许多利用缓冲区溢出的方法,不过最常见的方法还是“stack- smashing”攻击。Elias Levy (又名为 Aleph One)的一篇经典文章“Smashing the Stack for Fun and Profit”解释了 stack-smashing 攻击,Elias Levy 是 Bugtraq 邮件列表(请参阅 参考资料 以获得相关链接)的前任主持人。

为了理解 stack-smashing 攻击(或其他任何缓冲区攻击)是如何进行的,您需要了解一些关于计算机在机器语言级实际如何工作的知识。在类 UNIX 系统上,每个进程都可以划分为三个主要区域:文本、数据和堆栈。 文本区域包括代码和只读数据,通常不能对它执行写入操作。 数据区域同时包括静态分配的内存(比如全局和静态数据)和动态分配的内存(通常称为 )。 堆栈区域用于允许函数/方法调用;它用于记录函数完成之后的返回位置,存储函数中使用的本地变量,向函数传递参数,以及从函数返回值。每当调用一个函数,就会使用一个新的 堆栈帧来支持该调用。了解这些之后,让我们来考察一个简单的程序。


清单 1. 一个简单的程序

void function1(int a, int b, int c) {
char buffer1[5];
gets(buffer1); /* DON'T DO THIS */
}

void main() {
function(1,2,3);
}

假设使用 gcc 来编译清单 1 中的简单程序,在 X86 上的 Linux 中运行,并且紧跟在对 gets() 的调用之后中止。此时的内存内容看起来像什么样子呢?答案是它看起来类似图 1,其中展示了从左边的低位地址到右边的高位地址排序的内存布局。

图 1. 堆栈视图

内存的底部





内存的顶部

buffer1 sfp ret a b c
<--- 增长 --- [ ] [ ] [ ] [ ] [ ] [ ] ...
堆栈的顶部





堆栈的底部

许多计算机处理器,包括所有 x86 处理器,都支持从高位地址向低位地址“倒”增长堆栈。因此,每当一个函数调用另一个函数,更多的数据将被添加到左边(低位地址),直至系统的堆栈空间耗尽。在这个例子中,当 main() 调用 function1() 时,它将 c 的值压入堆栈,然后压入 b 的值,最后压入 a 的值。之后它压入 return (ret) 值,这个值在 function1() 完成时告诉 function1() 返回到 main() 中的何处。它还把所谓的“已保存的帧指针(saved frame pointer,sfp)”记录到堆栈上;这并不是必须保存的内容,此处我们不需要理解它。在任何情况下, function1() 在启动以后,它会为 buffer1() 预留空间,这在图 1 中显示为具有一个低地址位置。

现在假设攻击者发送了超过 buffer1() 所能处理的数据。接下来会发生什么情况呢?当然,C 和 C++ 程序员不会自动检查这个问题,因此除非程序员明确地阻止它,否则下一个值将进入内存中的“下一个”位置。那意味着攻击者能够改写 sfp (即已保存的帧指针),然后改写 ret (返回地址)。之后,当 function1() 完成时,它将“返回”―― 不过不是返回到 main() ,而是返回到攻击者想要运行的任何代码。

通常攻击者会使用它想要运行的恶意代码来使缓冲区溢出,然后攻击者会更改返回值以指向它们已发送的恶意代码。这意味着攻击者本质上能够在一个操作中完成整个攻击!Aleph On 的文章(请参阅 参考资料)详细介绍了这样的攻击代码是如何创建的。例如,将一个 ASCII 0 字符压入缓冲区通常是很困难的,而该文介绍了攻击者一般如何能够解决这个问题。

除了 smashing-stack 和更改返回地址外,还存在利用缓冲区溢出缺陷的其他途径。与改写返回地址不同,攻击者可以 smashing-stack(使堆栈上的缓冲区溢出),然后改写局部变量以利用缓冲区溢出缺陷。缓冲区根本就不必在堆栈上 ―― 它可以是堆中动态分配的内存(也称为“malloc”或“new”区域),或者在某些静态分配的内存中(比如“global”或“static”内存)。 基本上,如果攻击者能够溢出缓冲区的边界,麻烦或许就会找上你了。 然而,最危险的缓冲区溢出攻击就是 stack-smashing 攻击,因为如果程序对攻击者很脆弱,攻击者获得整个机器的控制权就特别容易。

为什么缓冲区溢出如此常见?

在几乎所有计算机语言中,不管是新的语言还是旧的语言,使缓冲区溢出的任何尝试通常都会被该语言本身自动检测并阻止(比如通过引发一个异常或根据需 要给缓冲区添加更多空间)。但是有两种语言不是这样:C 和 C++ 语言。C 和 C++ 语言通常只是让额外的数据乱写到其余内存的任何位置,而这种情况可能被利用从而导致恐怖的结果。更糟糕的是,用 C 和 C++ 编写正确的代码来始终如一地处理缓冲区溢出则更为困难;很容易就会意外地导致缓冲区溢出。除了 C 和 C++ 使用得 非常广泛外,上述这些可能都是不相关的事实;例如,Red Hat Linux 7.1 中 86% 的代码行都是用 C 或 C ++ 编写的。因此,大量的代码对这个问题都是脆弱的,因为实现语言无法保护代码避免这个问题。

在 C 和 C++ 语言本身中,这个问题是不容易解决的。该问题基于 C 语言的根本设计决定(特别是 C 语言中指针和数组的处理方式)。由于 C++ 是最兼容的 C 语言超集,它也具有相同的问题。存在一些能防止这个问题的 C/C++ 兼容版本,但是它们存在极其严重的性能问题。而且一旦改变 C 语言来防止这个问题,它就不再是 C 语言了。许多语言(比如 Java 和 C#)在语法上类似 C,但它们实际上是不同的语言,将现有 C 或 C++ 程序改为使用那些语言是一项艰巨的任务。

然而,其他语言的用户也不应该沾沾自喜。有些语言存在允许缓冲区溢出发生的&ldquo;转义&rdquo;子句。Ada 一般会检测和防止缓冲区溢出(即针对这样的尝试引发一个异常),但是不同的程序可能会禁用这个特性。C# 一般会检测和防止缓冲区溢出,但是它允许程序员将某些例程定义为“不安全的”,而这样的代码 可能 会导致缓冲区溢出。因此如果您使用那些转义机制,就需要使用 C/C++ 程序所必须使用的相同种类的保护机制。许多语言都是用 C 语言来实现的(至少部分是用 C 语言来实现的 ),并且用任何语言编写的所有程序本质上都依赖用 C 或 C++ 编写的库。因此,所有程序都会继承那些问题,所以了解这些问题是很重要的。



回页首


导致缓冲区溢出的常见 C 和 C++ 错误

从根本上讲,在程序将数据读入或复制到缓冲区中的任何时候,它需要在复制 之前检查是否有足够的空间。能够容易看出来的异常就不可能会发生 ―― 但是程序通常会随时间而变更,从而使得不可能成为可能。

遗憾的是,C 和 C++ 附带的大量危险函数(或普遍使用的库)甚至连这点(指检查空间)也无法做到。程序对这些函数的任何使用都是一个警告信号,因为除非慎重地使用它们,否则它 们就会成为程序缺陷。您不需要记住这些函数的列表;我的真正目的是说明这个问题是多么普遍。这些函数包括 strcpy(3)、strcat(3)、sprintf(3) (及其同类 vsprintf(3) )和 gets(3)scanf() 函数集( scanf(3)、fscanf(3)、sscanf(3)、vscanf(3)、vsscanf(3)vfscanf(3) )可能会导致问题,因为使用一个没有定义最大长度的格式是很容易的(当读取不受信任的输入时,使用格式“%s”总是一个错误)。

其他危险的函数包括 realpath(3)、getopt(3)、getpass(3)、streadd(3)、strecpy(3)strtrns(3) 。 从理论上讲, snprintf() 应该是相对安全的 ―― 在现代 GNU/Linux 系统中的确是这样。但是非常老的 UNIX 和 Linux 系统没有实现 snprintf() 所应该实现的保护机制。

Microsoft 的库中还有在相应平台上导致同类问题的其他函数(这些函数包括 wcscpy()、_tcscpy()、_mbscpy()、wcscat()、_tcscat()、_mbscat()CopyMemory() )。注意,如果使用 Microsoft 的 MultiByteToWideChar() 函数,还存在一个常见的危险错误 ―― 该函数需要一个最大尺寸作为字符数目,但是程序员经常将该尺寸以字节计(更普遍的需要),结果导致缓冲区溢出缺陷。

另一个问题是 C 和 C++ 对整数具有非常弱的类型检查,一般不会检测操作这些整数的问题。由于它们要求程序员手工做所有的问题检测工作,因此以某种可被利用的方式不正确地操作那些 整数是很容易的。特别是,当您需要跟踪缓冲区长度或读取某个内容的长度时,通常就是这种情况。但是如果使用一个有符号的值来存储这个长度值会发生什么情况 呢 ―― 攻击者会使它“成为负值”,然后把该数据解释为一个实际上很大的正值吗?当数字值在不同的尺寸之间转换时,攻击者会利用这个操作吗?数值溢出可被利用吗? 有时处理整数的方式会导致程序缺陷。



回页首


防止缓冲区溢出的新技术

当然,要让程序员 犯常见错误是很难的,而让程序(以及程序员)改为使用另一种语言通常更为困难。那么为何不让底层系统自动保护程序避免这些问题呢?最起码,避免 stack-smashing 攻击是一件好事,因为 stack-smashing 攻击是特别容易做到的。

一般来说,更改底层系统以避免常见的安全问题是一个极好的想法,我们在本文后面也会遇到这个主题。事实证明存在许多可用的防御措施,而一些最受欢迎的措施可分组为以下类别:

  • 基于探测方法(canary)的防御。这包括 StackGuard(由 Immunix 所使用)、ProPolice(由 OpenBSD 所使用)和 Microsoft 的 /GS 选项。
  • 非执行的堆栈防御。这包括 Solar Designer 的 non-exec 补丁(由 OpenWall 所使用)和 exec shield(由 Red Hat/Fedora 所使用)。
  • 其他方法。这包括 libsafe(由 Mandrake 所使用)和堆栈分割方法。

遗憾的是,迄今所见的所有方法都具有弱点,因此它们不是万能药,但是它们会提供一些帮助。

基于探测方法的防御

研究人员 Crispen Cowan 创建了一个称为 StackGuard 的有趣方法。Stackguard 修改 C 编译器(gcc),以便将一个“探测”值插入到返回地址的前面。“探测仪”就像煤矿中的探测仪:它在某个地方出故障时发出警告。在任何函数返回之前,它执 行检查以确保探测值没有改变。如果攻击者改写返回地址(作为 stack-smashing 攻击的一部分),探测仪的值或许就会改变,系统内就会相应地中止。这是一种有用的方法,不过要注意这种方法无法防止缓冲区溢出改写其他值(攻击者仍然能够 利用这些值来攻击系统)。人们也曾扩展这种方法来保护其他值(比如堆上的值)。Stackguard(以及其他防御措施)由 Immunix 所使用。

IBM 的 stack-smashing 保护程序(ssp,起初名为 ProPolice)是 StackGuard 的方法的一种变化形式。像 StackGuard 一样,ssp 使用一个修改过的编译器在函数调用中插入一个探测仪以检测堆栈溢出。然而,它给这种基本的思路添加了一些有趣的变化。 它对存储局部变量的位置进行重新排序,并复制函数参数中的指针,以便它们也在任何数组之前。这样增强了ssp 的保护能力;它意味着缓冲区溢出不会修改指针值(否则能够控制指针的攻击者就能使用指针来控制程序保存数据的位置)。默认情况下,它不会检测所有函数,而 只是检测确实需要保护的函数(主要是使用字符数组的函数)。从理论上讲,这样会稍微削弱保护能力,但是这种默认行为改进了性能,同时仍然能够防止大多数问 题。考虑到实用的因素,它们以独立于体系结构的方式使用 gcc 来实现它们的方法,从而使其更易于运用。从 2003 年 5 月的发布版本开始,广受赞誉的 OpenBSD(它重点关注安全性)在他们的整个发行套件中使用了 ssp(也称为 ProPolice)。

Microsoft 基于 StackGuard 的成果,添加了一个编译器标记(/GS)来实现其 C 编译器中的探测仪。

非执行的堆栈防御

另一种方法首先使得在堆栈上执行代码变得不可能。 遗憾的是,x86 处理器(最常见的处理器)的内存保护机制无法容易地支持这点;通常,如果一个内存页是可读的,它就是可执行的。一个名叫 Solar Designer 的开发人员想出了一种内核和处理器机制的聪明组合,为 Linux 内核创建了一个“非执行的堆栈补丁”;有了这个补丁,堆栈上的程序就不再能够像通常的那样在 x86 上运行。 事实证明在有些情况下,可执行程序 需要在 堆栈上;这包括信号处理和跳板代码(trampoline)处理。trampoline 是有时由编译器(比如 GNAT Ada 编译器)生成的奇妙结构,用以支持像嵌套子例程之类的结构。Solar Designer 还解决了如何在防止攻击的同时使这些特殊情况不受影响的问题。

Linux 中实现这个目的的最初补丁在 1998 年被 Linus Torvalds 拒绝,这是因为一个有趣的原因。即使不能将代码放到堆栈上,攻击者也可以利用缓冲区溢出来使程序“返回”某个现有的子例程(比如 C 库中的某个子例程),从而进行攻击。简而言之,仅只是拥有非可执行的堆栈是不足够的。

一段时间之后,人们又想出了一种防止该问题的新思路:将所有可执行代码转移到一个称为“ASCII 保护(ASCII armor)”区域的内存区。要理解这是如何工作的,就必须知道攻击者通常不能使用一般的缓冲区溢出攻击来插入 ASCII NUL 字符(0)这个事实。 这意味着攻击者会发现,要使一个程序返回包含 0 的地址是很困难的。由于这个事实,将所有可执行代码转移到包含 0 的地址就会使得攻击该程序困难多了。

具有这个属性的最大连续内存范围是从 0 到 0x01010100 的一组内存地址,因此它们就被命名为 ASCII 保护区域(还有具有此属性的其他地址,但它们是分散的)。与非可执行的堆栈相结合,这种方法就相当有价值了:非可执行的堆栈阻止攻击者发送可执行代码,而 ASCII 保护内存使得攻击者难于通过利用现有代码来绕过非可执行堆栈。这样将保护程序代码避免堆栈、缓冲区和函数指针溢出,而且全都不需重新编译。

然而,ASCII 保护内存并不适用于所有程序;大程序也许无法装入 ASCII 保护内存区域(因此这种保护是不完美的),而且有时攻击者 能够将 0 插入目的地址。 此外,有些实现不支持跳板代码,因此可能必须对需要这种保护的程序禁用该特性。Red Hat 的 Ingo Molnar 在他的“exec-shield”补丁中实现了这种思想,该补丁由 Fedora 核心(可从 Red Hat 获得它的免费版本)所使用。最新版本的 OpenWall GNU/Linux (OWL)使用了 Solar Designer 提供的这种方法的实现(请参阅 参考资料 以获得指向这些版本的链接)。

其他方法

还有其他许多方法。一种方法就是使标准库对攻击更具抵抗力。Lucent Technologies 开发了 Libsafe,这是多个标准 C 库函数的包装,也就是像 strcpy() 这样已知的对 stack-smashing 攻击很脆弱的函数。Libsafe 是在 LGPL 下授予许可证的开放源代码软件。那些函数的 libsafe 版本执行相关的检查,确保数组改写不会超出堆栈桢。然而,这种方法仅保护那些特定的函数,而不是从总体上防止堆栈溢出缺陷,并且它仅保护堆栈,而不保护堆 栈中的局部变量。它们的最初实现使用了 LD_PRELOAD ,而这可能与其他程序产生冲突。Linux 的 Mandrake 发行套件(从 7.1 版开始)包括了 libsafe。

另一种方法称为“分割控制和数据堆栈”―― 基本的思路是将堆栈分割为两个堆栈,一个用于存储控制信息(比如“返回”地址),另一个用于控制其他所有数据。Xu et al. 在 gcc 中实现了这种方法,StackShield 在汇编程序中实现了这种方法。这样使得操纵返回地址困难多了,但它不会阻止改变调用函数的数据的缓冲区溢出攻击。

事实上还有其他方法,包括随机化可执行程序的位置;Crispen 的“PointGuard”将这种探测仪思想引申到了堆中,等等。如何保护当今的计算机现在已成了一项活跃的研究任务。

posted on 2006-04-25 09:41  落拓孤鸿  阅读(1203)  评论(0编辑  收藏  举报