黑客中高级技术----溢出
溢出是黑客利用操作系统的漏洞,专门开发了一种程序,加相应的参数运行后,就可以得到你电
脑具有管理员资格的控制权,你在你自己电脑上能够运行的东西他可以全部做到,等于你的电脑
就是他的了。在黑客频频攻击、在系统漏洞层出不穷的今天,作为网络管理员、系统管理员的我
们虽然在服务器的安全上都下了不少功夫:诸如,及时的打上系统安全补丁、进行一些常规的安
全配置,但是仍然不太可能每台服务器都会在第一时间内给系统打上全新补丁。因此我们必需要
在还未被入侵之前,通过一些系列安全设置,来将入侵者们挡在“安全门”之外。
【内存溢出】
内存溢出已经是软件开发历史上存在了近40年的“老大难”问题,象在“红色代码”病毒事件中
表现的那样,它已经成为黑客攻击企业网络的“罪魁祸首”。
如在一个域中输入的数据超过了它的要求就会引发数据溢出问题,多余的数据就可以作为指令在
计算机上运行。据有关安全小组称,操作系统中超过50%的安全漏洞都是由内存溢出引起的,其
中大多数与微软的技术有关。
微软的软件是针对台式机开发的,内存溢出不会带来严重的问题。但现在台式机一般都连上了互
联网,内存溢出就为黑客的入侵提供了便利条件。
为什么会出现内存溢出问题
导致内存溢出问题的原因有很多,比如:
(1) 使用非类型安全(non-type-safe)的语言如 C/C++ 等。
(2) 以不可靠的方式存取或者复制内存缓冲区。
(3) 编译器设置的内存缓冲区太靠近关键数据结构。
下面来分析这些因素:
1. 内存溢出问题是 C 语言或者 C++ 语言所固有的缺陷,它们既不检查数组边界,又不检查类
型可靠性(type-safety)。众所周知,用 C/C++ 语言开发的程序由于目标代码非常接近机器内核
,因而能够直接访问内存和寄存器,这种特性大大提升了 C/C++ 语言代码的性能。只要合理编
码,C/C++ 应用程序在执行效率上必然优于其它高级语言。然而,C/C++ 语言导致内存溢出问题
的可能性也要大许多。其他语言也存在内容溢出问题,但它往往不是程序员的失误,而是应用程
序的运行时环境出错所致。
2. 当应用程序读取用户(也可能是恶意攻击者)数据,试图复制到应用程序开辟的内存缓冲区中
,却无法保证缓冲区的空间足够时(换言之,假设代码申请了 N 字节大小的内存缓冲区,随后又
向其中复制超过 N 字节的数据)。内存缓冲区就可能会溢出。想一想,如果你向 12 盎司的玻璃
杯中倒入 16 盎司水,那么多出来的 4 盎司水怎么办?当然会满到玻璃杯外面了!
3. 最重要的是,C/C++ 编译器开辟的内存缓冲区常常邻近重要的数据结构。现在假设某个函数
的堆栈紧接在在内存缓冲区后面时,其中保存的函数返回地址就会与内存缓冲区相邻。此时,恶
意攻击者就可以向内存缓冲区复制大量数据,从而使得内存缓冲区溢出并覆盖原先保存于堆栈中
的函数返回地址。这样,函数的返回地址就被攻击者换成了他指定的数值;一旦函数调用完毕,
就会继续执行“函数返回地址”处的代码。非但如此,C++ 的某些其它数据结构,比如 v-table
、例外事件处理程序、函数指针等,也可能受到类似的攻击。
好,闲话少说,现在来看一个具体的例子。
请思考:以下代码有何不妥之处?
void CopyData(char *szData) {
char cDest[32];
strcpy(cDest,szData);
// 处理 cDest
...
}
奇怪,这段代码好像没什么不对劲啊!确实,只有调用上述 CopyData() 才会出问题。例如:这
样使用 CopyData() 是安全的:
char *szNames[] = {"Michael","Cheryl","Blake"};
CopyData(szName[1]);
为什么呢?因为数组中的姓名("Michael"、"Cheryl"、"Blake")都是字符串常量,而且长度都不
超过 32 个字符,用它们做 strcpy() 的参数总是安全的。再假设 CopyData 的唯一参数
szData 来自 socket 套接字或者文件等不可靠的数据源。由于 strcpy 并不在乎数据来源,只
要没遇上空字符,它就会一个字符一个字符地复制 szData 的内容。此时,复制到 cDest 的字
符串就可能超过 32 字符,进而导致内存缓冲区 cDest 的溢出;溢出的字符就会取代内存缓冲
区后面的数据。不幸的是,CopyData 函数的返回地址也在其中!于是,当 CopyData 函数调用
完毕以后,程序就会转入攻击者给出的“返回地址”,从而落入攻击者的圈套!授人以柄,惨!
前面提到的其它数据结构也可能受到类似的攻击。假设有人利用内存溢出漏洞覆盖了下列 C++
类中的 v-table :
void CopyData(char *szData) {
char cDest[32];
CFoo foo;
strcpy(cDest,szData);
foo.Init();
}
与其它 C++ 类一样,这里的 CFoo 类也对应一个所谓的 v-table,即用于保存一个类的全部方
法地址的列表。若攻击者利用内存溢出漏洞偷换了 v-table 的内容,则 CFoo 类中的所有方法
,包括上述 Init() 方法,都会指向攻击者给出的地址,而不是原先 v-table 中的方法地址。
顺便说一句,即使你在某个 C++ 类的源代码中没有调用任何方法,也不能认为这个类是安全的
,因为它在运行时至少需要调用一个内部方法——析构器(destructor)!当然,如果真有一个类
没有调用任何方法,那么它的存在意义也就值得怀疑了。
如何解决溢出内存问题
下面讨论内存溢出问题的解决和预防措施。
1、改用受控代码
2002 年 2 月和 3 月,微软公司展开了 Microsoft Windows Security Push 活动。在此期间,
我所在的小组一共培训了超过 8500 人,教授他们如何在设计、测试和文档编制过程中解决安全
问题。在我们向所有程序设计人员提出的建议中,有一条就是:紧跟微软公司软件开发策略的步
伐,将某些应用程序和工具软件由原先基于本地 Win32 的 C++ 代码改造成基于 .NET 的受控代
码。我们的理由很多,但其中最根本的一条,就是为了解决内存溢出问题。基于受控代码的软件
发生内存溢出问题的机率要小得多,因为受控代码无法直接存取系统指针、寄存器或者内存。作
为开发人员,你应该考虑(至少是打算)用受控代码改写某些应用程序或工具软件。例如:企业管
理工具就是很好的改写对象之一。当然,你也应该很清楚,不可能在一夜之间把所有用 C++ 开
发的软件用 C# 之类的受控代码语言改写。
2、遵守黄金规则
当你用 C/C++ 书写代码时,应该处处留意如何处理来自用户的数据。如果一个函数的数据来源
不可靠,又用到内存缓冲区,那么它就必须严格遵守下列规则:
必须知道内存缓冲区的总长度。
检验内存缓冲区。
提高警惕。
让我们来具体看看这些“黄金规则”:
(1)必须知道内存缓冲区的总长度。
类似这样的代码就有可能导致 bug :
void Function(char *szName) {
char szBuff[MAX_NAME];
// 复制并使用 szName
strcpy(szBuff,szName);
}
它的问题出在函数并不知道 szName 的长度是多少,此时复制数据是不安全的。正确的做法是,
在复制数据前首先获取 szName 的长度:
void Function(char *szName, DWORD cbName) {
char szBuff[MAX_NAME];
// 复制并使用 szName
if (cbName < MAX_NAME)
strcpy(szBuff,szName);
}
这样虽然有所改进,可它似乎又过于信任 cbName 了。攻击者仍然有机会伪造 czName 和
szName 两个参数以谎报数据长度和内存缓冲区长度。因此,你还得检检这两个参数!
(2)检验内存缓冲区
如何知道由参数传来的内存缓冲区长度是否真实呢?你会完全信任来自用户的数据吗?通常,答
案是否定的。其实,有一种简单的办法可以检验内存缓冲区是否溢出。请看如下代码片断:
void Function(char *szName, DWORD cbName) {
char szBuff[MAX_NAME];
// 检测内存
szBuff[cbName] = 0x42;
// 复制并使用 szName
if (cbName < MAX_NAME)
strcpy(szBuff,szName);
}
这段代码试图向欲检测的内存缓冲区末尾写入数据 0x42 。你也许会想:真是多此一举,何不直
接复制内存缓冲区呢?事实上,当内存缓冲区已经溢出时,一旦再向其中写入常量值,就会导致
程序代码出错并中止运行。这样在开发早期就能及时发现代码中的 bug 。试想,与其让攻击者
得手,不如及时中止程序;你大概也不愿看到攻击者随心所欲地向内存缓冲区复制数据吧。
(3)提高警惕
虽然检验内存缓冲区能够有效地减小内存溢出问题的危害,却不能从根本上避免内存溢出攻击。
只有从源代码开始提高警惕,才能真正免除内存溢出攻击的危胁。不错,上一段代码已经很对用
户数据相当警惕了。它能确保复制到内存缓冲区的数据总长度不会超过 szBuff 的长度。然而,
某些函数在使用或复制不可靠的数据时也可能潜伏着内存溢出漏洞。为了检查你的代码是否存在
内存溢出漏洞,你必须尽量追踪传入数据的流向,向代码中的每一个假设提出质疑。一旦这么做
了,你将会意识到其中某些假设是错误的;然后你还会惊讶地叫道:好多 bug 呀!
在调用 strcpy、strcat、gets 等经典函数时当然要保持警惕;可对于那些所谓的第 n 版 (n-
versions) strcpy 或 strcat 函数 —— 比如 strncpy 或 strncat (其中 n = 1,2,3 ……)
—— 也不可轻信。的确,这些改良版本的函数是安全一些、可靠一些,因为它们限制了进入内
存缓冲区的数据长度;然而,它们也可能导致内存溢出问题!请看下列代码,你能指出其中的错
误吗?
#define SIZE(b) (sizeof(b))
char buff[128];
strncpy(buff,szSomeData,SIZE(buff));
strncat(buff,szMoreData,SIZE(buff));
strncat(buff,szEvenMoreData,SIZE(buff));
给点提示:请注意这些字符串函数的最后一个参数。怎么,弃权?我说啊,如果你是执意要放弃
那些“不安全”的经典字符串函数,并且一律改用“相对安全”的第 n 版函数的话,恐怕你这
下半辈子都要为了修复这些新函数带来的新 bug 而疲于奔命了。呵呵,开个玩笑而已。为何这
么说?首先,它们的最后一个参数并不代表内存缓冲区的总长度,它们只是其剩余空间的长度;
不难看出,每执行完一个 strn... 函数,内存缓冲区 buff 的长度就减少一些。其次,传递给
函数的内存缓冲区长度难免存在“大小差一”(off-by-one)的误差。你认为在计算 buff 的长度
时包括了字符串末尾的空字符吗?当我向听众提出这个问题时,得到的肯定答复和否定答复通常
是 50 比 50 ,对半开。也就是说,大约一半人认为计算了末尾的空字符,而另一半人认为忽略
了该字符。最后,那些第 n 版函数所返回的字符串不见得以空字符结束,所以在使用前务必要
仔细阅读它们的说明文档。
3、编译选项 /GS
“/GS”是 Visual C++ .NET 新引入的一个编译选项。它指示编译器在某些函数的堆栈帧
(stack-frames) 中插入特定数据,以帮助消除针对堆栈的内存溢出问题隐患。切记,使用该选
项并不能替你清除代码中的内存溢出漏洞,也不可能消灭任何 bug 。它只是亡羊补牢,让某些
内存溢出问题隐患无法演变成真正的内存溢出问题;也就是说,它能防止攻击者在发生内存溢出
时向进程中插入和运行恶意代码。无论如何,这也算是小小的安全保障吧。请注意,在新版的本
地 Win32 C++ 中使用 Win32 应用程序向导创建新项目时,默认设置已经打开了此选项。同样,
Windows .NET Server 环境也默认打开了此选项。关于 /GS 选项的更多信息,请参考 Brandon
Bray 的《Compiler Security Checks In Depth》一书。
所谓定点数溢出是指定点数的运算结果的绝对值大于计算机能表示的最大数的绝对值。浮点数的
溢出又可分为“上溢出”和“下溢出”两种,“上溢出”与整数、定点数的含义相同,“下溢出
”是指浮点数的运算结果的绝对值小于机器所能表示的最小数绝对值,此时将该运算结果处理成
机器零。若发现溢出(上溢出),运算器将产生溢出标志或发出中断请求,当溢出中断未被屏蔽时
,溢出中断信号的出现可中止程序的执行而转入溢出中断处理程序。<BR><BR>
例如:有两个数0.1001111和0.1101011相加,其结果应为1.0111010。由于定点数计算机只能表
示小于1的数,如果字长只有8位,那么小数点前面的1,会因没有触发器存放而丢失。这样,上
述两个数在计算机中相加,其结果变为0.0111010。又如,有两个数0.0001001和0.00001111相乘
,其结果应为0.00000000111111,若字长只有8位,则结果显示为0.0000000,后面的1个0和6个1
全部丢失,显然这个结果有误差。计算机的任何运算都不允许溢出,除非专门利用溢出做判断,
而不使用所得的结果。所以,当发生和不允许出现的溢出时,就要停机或转入检查程序,以找出
产生溢出的原因,做出相应的处理。
【缓冲区溢出】
缓冲区是用户为程序运行时在计算机中申请的一段连续的内存,它保存了给定类型的数据。缓冲
区溢出指的是一种常见且危害很大的系统攻击手段,通过向程序的缓冲区写入超出其长度的内容
,造成缓冲区的溢出,从而破坏程序的堆栈,使程序转而执行其他的指令,以达到攻击的目的。
更为严重的是,缓冲区溢出攻击占了远程网络攻击的绝大多数,这种攻击可以使得一个匿名的
Internet用户有机会获得一台主机的部分或全部的控制权!由于这类攻击使任何人都有可能取得
主机的控制权,所以它代表了一类极其严重的安全威胁。
缓冲区溢出攻击的目的在于扰乱具有某些特权运行的程序的功能,这样可以使得攻击者取得程序
的控制权,如果该程序具有足够的权限,那么整个主机就被控制了。一般而言,攻击者攻击root
程序,然后执行类似“exec(sh)”的执行代码来获得root的shell。为了达到这个目的,攻击者
必须达到如下的两个目标:在程序的地址空间里安排适当的代码;通过适当地初始化寄存器和存
储器,让程序跳转到事先安排的地址空间执行。根据这两个目标,可以将缓冲区溢出攻击分为以
下3类。
缓冲区溢出分类
1.1在程序的地址空间里安排适当的代码
1.1.1殖入法
攻击者用被攻击程序的缓冲区来存放攻击代码。 攻击者向被攻击的程序输入一个字符串,程序
会把这个字符串放到缓冲区里。这个字符串包含的数据是可以在这个被攻击的硬件平台上运行的
指令序列。
1.1.2利用已经存在的代码
有时候,攻击者想要的代码已经在被攻击的程序中了,攻击者所要做的只是对代码传递一些参数
,然后使程序跳转到指定目标。比如,在C语言中,攻击代码要求执行“exec("/bin/sh")”,而
在libc库中的代码执行“exec(arg)”,其中arg是指向一个字符串的指针参数,那么攻击者只要
把传入的参数指针指向"/bin/sh",就可以调转到libc库中的相应的指令序列。
1.2控制程序转移到攻击代码
这种方法旨在改变程序的执行流程,使之跳转到攻击代码。最基本方法的就是溢出一个没有边界
检查或者其他弱点的缓冲区,这样就扰乱了程序的正常的执行顺序。通过溢出一个缓冲区,攻击
者可以用近乎暴力的方法改写相邻的程序空间而直接跳过了系统的检查。
1.2.1激活纪录(Activation Records)
每当一个函数调用发生时,调用者会在堆栈中留下一个激活纪录,它包含了函数结束时返回的地
址。攻击者通过溢出这些自动变量,使这个返回地址指向攻击代码。通过改变程序的返回地址,
当函数调用结束时,程序就跳转到攻击者设定的地址,而不是原先的地址。这类的缓冲区溢出被
称为“stack smashing attack”,是目前常用的缓冲区溢出攻击方式。
1.2.2函数指针(Function Pointers)
C语言中,“void (* foo)()”声明了一个返回值为void函数指针的变量foo。函数指针可以用来
定位任何地址空间,所以攻击者只需在任何空间内的函数指针附近找到一个能够溢出的缓冲区,
然后溢出这个缓冲区来改变函数指针。在某一时刻,当程序通过函数指针调用函数时,程序的流
程就按攻击者的意图实现了!它的一个攻击范例就是在Linux系统下的super probe程序。
1.2.3长跳转缓冲区(Longjmp buffers)
在C语言中包含了一个简单的检验/恢复系统,称为setjmp/longjmp。意思是在检验点设定
“setjmp(buffer)”,用“longjmp(buffer)”来恢复检验点。然而,如果攻击者能够进入缓冲
区的空间,那么“longjmp(buffer)”实际上是跳转到攻击者的代码。象函数指针一样,longjmp
缓冲区能够指向任何地方,所以攻击者所要做的就是找到一个可供溢出的缓冲区。一个典型的例
子就是Perl 5.003,攻击者首先进入用来恢复缓冲区溢出的的longjmp缓冲区,然后诱导进入恢
复模式,这样就使Perl的解释器跳转到攻击代码上了!
最简单和常见的缓冲区溢出攻击类型就是在一个字符串里综合了代码殖入和激活纪录。攻击者定
位一个可供溢出的自动变量,然后向程序传递一个很大的字符串,在引发缓冲区溢出改变激活纪
录的同时殖入了代码。这个是由Levy指出的攻击的模板。因为C语言在习惯上只为用户和参数开
辟很小的缓冲区,因此这种漏洞攻击的实例不在少数。
代码殖入和缓冲区溢出不一定要在一次动作内完成。攻击者可以在一个缓冲区内放置代码,这是
不能溢出缓冲区。然后,攻击者通过溢出另外一个缓冲区来转移程序的指针。这种方法一般用来
解决可供溢出的缓冲区不够大的情况。
如果攻击者试图使用已经常驻的代码而不是从外部殖入代码,他们通常有必须把代码作为参数化
。举例来说,在libc中的部分代码段会执行“exec(something)”,其中something就是参数。攻
击者然后使用缓冲区溢出改变程序的参数,利用另一个缓冲区溢出使程序指针指向libc中的特定
的代码段。
为什么缓冲区溢出如此常见
在几乎所有计算机语言中,不管是新的语言还是旧的语言,使缓冲区溢出的任何尝试通常都会被
该语言本身自动检测并阻止(比如通过引发一个异常或根据需要给缓冲区添加更多空间)。但是
有两种语言不是这样: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++ 程序改为使用那些语言是一项艰巨
的任务。
然而,其他语言的用户也不应该沾沾自喜。有些语言存在允许缓冲区溢出发生的“转义
”子句。Ada 一般会检测和防止缓冲区溢出(即针对这样的尝试引发一个异常),但是不
同的程序可能会禁用这个特性。C# 一般会检测和防止缓冲区溢出,但是它允许程序员将某些例
程定义为“不安全的”,而这样的代码 可能 会导致缓冲区溢出。因此如果您使用那些转义机制
,就需要使用 C/C++ 程序所必须使用的相同种类的保护机制。许多语言都是用 C 语言来实现的
(至少部分是用 C 语言来实现的 ),并且用任何语言编写的所有程序本质上都依赖用 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”将这种探测仪
思想引申到了堆中,等等。如何保护当今的计算机现在已成了一项活跃的研究任务。
▲一般保护是不足够的
如此多不同的方法意味着什么呢?对用户来说,好的一面在于大量创新的方法正在试验之中;长
期看来,这种“竞争”会更容易看出哪种方法最好。而且,这种多样性还使得攻击者躲避所有这
些方法更加困难。然而,这种多样性也意味着开发人员需要 避免编写会干扰其中任何一种方法
的代码。这在实践上是很容易的;只要不编写对堆栈桢执行低级操作或对堆栈的布局作假设的代
码就行了。即使不存在这些方法,这也是一个很好的建议。
操作系统供应商需要参与进来就相当明显了:至少挑选一种方法,并使用它。缓冲区溢出是第一
号的问题,这些方法中最好的方法通常能够减轻发行套件中几乎半数已知缺陷的影响。可以证明
,不管是基于探测仪的方法更好,还是基于非可执行堆栈的方法更好,它们都具有各自的优点。
可以将它们结合起来使用,但是少数方法不支持这样使用,因为附加的性能损失使得这样做不值
得。我并没有其他意思,至少就这些方法本身而言是这样;libsafe 和分割控制及数据堆栈的方
法在它们所提供的保护方面都具有局限性。当然,最糟糕的解决办法就是根本不对这个第一号的
缺陷提供保护。还没有实现一种方法的软件供应商需要立即计划这样做。从 2004 年开始,用户
应该开始避免使用这样的操作系统,即它们至少没有对缓冲区溢出提供某种自动保护机制。
然而,没有哪种方法允许开发人员忽略缓冲区溢出。所有这些方法都能够被攻击者破坏。 攻击
者也许能够通过改变函数中其他数据的值来利用缓冲区溢出;没有哪种方法能够防止这点。如果
能够插入某些难于创建的值(比如 NUL 字符),那么这其中的许多方法都能被攻击者绕开;随
着多媒体和压缩数据变得更加普遍,攻击者绕开这些方法就更容易了。从根本上讲,所有这些方
法都能减轻从程序接管攻击到拒绝服务攻击的缓冲区溢出攻击所带来的破坏。遗憾的是,随着计
算机系统在更多关键场合的使用,即使拒绝服务通常也是不可接受的。因而,尽管发行套件应该
至少包括一种适当的防御方法,并且开发人员应该使用(而不是反对)那些方法,但是开发人员
仍然需要最初就编写无缺陷的软件。