3.1.6 弥补及防御措施
1.预防
预防策略可以根据其如何分配空间,进一步分为静态预防策略和动态预防策略。
静态分配的缓冲区会假设一个固定的大小,这就意味着一旦缓冲区被填满,就无法再向其中添加任何数据。这方面的例子有标准C的strncpy()和strncat()函数以及Openbsd的strcpy()和strcat()函数。因为静态方法会丢弃多余的数据,因此必然存在实际程序数据丢失的可能性。这就导致必须对得到的字符串进行有效性验证。
对于动态分配的缓冲区而言,当有追加内存的需求时,会动态地调整其大小。动态的方式伸缩性更好且不会丢弃多余的数据。最大的缺点在于,如果对输入不加限制,会耗尽一台机器的内存,因而这种方式会被拒绝服务攻击(DoS)所利用。
(1)输入验证
缓冲区溢出通常是由无界字符串或内存复制造成的。缓冲区溢出可以通过确保输入的数据不超过其存储的最小缓冲区大小进行预防。图3.22展示了一个简单的函数示例,该函数对输入数据进行了验证。任何跨越安全边界传送到程序接口的数据都需要验证。这些数据的例子包括:argv、环境变量、套接字、管道、文件、信号、共享内存以及硬件设备等。输入验证虽然对所有类型的缓冲区利用都有效,但这需要开发者对可能引起缓冲区溢出的所有外部输入进行正确地识别和验证。由于这种方式易于出错,因此通常将其和其他策略谨慎地组合使用(如替换可疑的函数)。
图3.22 输入验证
(2)一致的内存管理约定
最有效地防止出现内存问题的方法是在编写内存管理代码时严守纪律。开发团队应该采用一个标准的途径并始终如一地应用它。有如下一些良好的实践可供遵循。
使用同样的模式分配和释放内存。在C++程序中,在构造函数中进行所有的内存分配,在析构函数中进行所有的内存释放。在C程序中,定义具有相同功能的create()和destroy()函数。
在同一个模块中,在同一个抽象层次中,分配和释放内存。在子例程中释放内存会导致混乱:会存在究竟内存是否被释放、何时释放、在哪儿释放的问题。
让分配和释放配对。如果有多个构造函数,那么要确保在所有可能的情况下都会进行正确地析构。
坚定不移地采取一致的内存管理是避免内存错误的最佳方式。MIT的krb52004-002e号安全报告提供了一个生动的例子,说明不一致的内存管理实践会如何导致软件漏洞。在MIT的krb5库中,krb5-1.3.4及所有以前版本中的ASN.1解码器函数及其调用者未采用一致的内存管理约定。调用者期待解码器分配内存。一般情况下,调用方也有错误处理代码,在指向已分配的内存的指针不为NULL的时候,会将ASN.1解码器分配的内存释放掉。但是当遇到错误情况的时候,ASN.1解码器自身将会释放它自己分配的内存,但却并未将对应的指针置为NULL。当一些库函数从ASN.1解码器中接收到错误时,它们会试图将该非空指针(指向已释放的内存)传递给free(),从而导致双重释放。这个例子同样也展示了将空悬指针置为NULL的价值。
(3)空指针
一个可以减少C和C++程序中漏洞数量的明显技术就是在完成对free()的调用后,将指针置为NULL。空悬指针(指向已释放内存的指针)可能导致涂写已释放内存和双重释放漏洞。将指针置为空后,任何企图解引用(dereference)指针的操作都会导致致命的错误,这样就增加了在编码和测试过程中发现问题的概率。并且,如果指针被置为NULL,内存可以被释放多次而不会导致糟糕的后果。
虽然将指针置空可以显著地减少因涂写已释放内存和双重释放而导致的漏洞,但如果多个指针指向同一个数据结构的话这种方式就失效了。
2.检测和恢复
检测和恢复的缓解策略通常要求对运行时的环境做出一定的改变,以便可以在缓冲区溢出发生时对其进行检测,从而应用程序或操作系统可以从错误中恢复(或者至少“安全地”失效)。在受到威胁时,如果最外层的防线被攻击突破(即预防性策略不奏效),那么检测和恢复的策略通常能够形成第二道防线。由于在发生缓冲区溢出后攻击者有很多种方式控制程序的执行,因此和预防性策略相比,检测和恢复的策略并不算有效,而且也不应该被作为系统唯一可依赖的缓解策略。
(1)编译器生成的运行时检测
Visual C++为捕获诸如栈指针破坏和局部数组越界之类的常见运行时错误提供了基本的运行时检测。Visual C++还提供了一个runtime_checks pragma指令,可以用来禁用或启用/RTC设置。
栈指针破坏:栈指针校验可以检测栈指针破坏情况。不匹配的调用约定是导致栈指针破坏的原因之一。
局部数组界:通过设置RTC[4]选项,可以为超出数组之类的局部变量边界的写入操作启用栈帧运行时错误检测,但它不能检测到因编译器对结构内部进行填充而产生的内存越界访问。
(2)不可执行的栈
不可执行栈是一种针对缓冲区溢出的运行时解决方案,被设计用于防止在栈段(stack segment)内运行可执行代码。很多操作系统都可以被配置成具有不可执行栈的能力。不可执行栈常常被描绘成防范缓冲区溢出漏洞的“万能药”,但实际上它并不能阻止栈段、堆段或数据段的缓冲区溢出。它阻止不了攻击者利用缓冲区溢出修改返回地址、变量值、数据指针或函数指针。它对在堆段或数据段内的弧注入或可执行代码注入也无能为力。禁止攻击者在栈中执行代码能够阻止对某些漏洞的利用,但对攻击者而言这种方式往往形同虚设。在不同的实现机制下,不可执行栈对性能有着不同的影响。它还有可能使得一些依赖于在栈段内执行代码的程序(包括Linux signal delivery和gcc trampolines等)无法工作。
(3)Stackgap
很多基于栈的缓冲区溢出的利用都依赖于一个内存中已知位置的缓冲区。如果攻击者能够覆盖位于溢出缓冲区内一个固定位置的函数返回地址,就能执行攻击者提供的代码。如果在栈中分配栈内存时加入随机大小的空隙,则可以使得攻击者更难定位栈上的返回地址(针对仅消耗一页物理存储的情形)。这种缓解措施可以比较容易地加入操作系统中。图3.23展示了为了实现Stackgap,Linux内核所需做出的修改。虽然Stackgap使得对漏洞的利用变得更加困难,但它并不能阻止攻击者利用相对地址而非绝对地址发起攻击。
图3.23 Stackgap实现
(4)运行时边界检查器
如果不能使用一种类似于Java的类型安全语言,我们还是有可能使用编译器对C程序执行数组边界检查。
Jones和Kelley的边界检测方法基于以下原则:一个根据边界内指针计算出来的地址必定与原始指针指向相同的对象。遗憾的是,现存有数目惊人的程序生成并存储边界外地址,然后在计算中又取得这些地址处的值,而这些操作并未造成缓冲区溢出,这就使得这些程序不适宜采用前述的边界检测方式。这种运行时边界检查方式还要付出显著的性能代价,尤其在某些指针密集型的程序中,性能下降至原来的1/30。
Ruwase和Lam在他们的C范围错误侦测器(Crange Error Detector,CRED)中改进了Jones和Kelley的方法。根据他们的说法,CRED执行一种宽松的正确性标准,这是通过允许程序操作不会引起缓冲区溢出的边界外地址而做到的。这个宽松的正确性标准为现有的软件提供了较高的兼容性。
CRED可以被配置为检测所有数据的边界或者仅检测字符串数据的边界。完全边界检测,比如Jones和Kelley的方法,会产生显著的性能开销。将对边界的检查局限于字符串则可改善大多数程序的性能。视程序中对字符串的使用情况,这种性能开销范围为1%~130%。
边界检测可以有效地阻止大多数溢出情况,但这种方式并非完美。以CRED方案为例,它无法检测出一个边界外指针首先利用算术操作转型为整数然后转型回指针的情况。这种方案可以保护栈堆和数据段的溢出,甚至优化到仅检测字符串溢出的情况时,CRED也可以有效地检测出Wilander和Kamkar开发的用于评价动态缓冲区溢出检测器的20种不同的缓冲区溢出攻击[Wilander 03]。CRED已经合并到最新的(针对GCC 3.3.1的)Jones和Kelley检测器中,目前由Herman ten Brugge维护。
(5)canaries
canaries是另一种用来检测和阻止栈粉碎攻击的机制,不是执行一般化的边界检查,canaries用于保护栈上的返回地址免遭连续的写操作(如strcpy()所导致的结果)。canaries由一个被写入被保护栈节的下面的“难以插入”或“难以伪造”的值构成。为了进入受保护区域,一个连续的写操作将需要覆盖这个值。canary在返回地址被保存后立即被初始化,并且在返回地址被存取之前立即被检测。“难以插入”的canary(或终止符canary)由4种不同的字符串终止符组成(CR、LF、NULL和-1)。这些哨位(guard)可以保护由于字符串操作所产生的缓冲区溢出,但不能保护内存复制操作造成的缓冲区溢出。“难以伪造”的噪声(或随机canary)由一个32位的秘密随机数组成,随着程序每次执行它都会发生改变。在canary确实保密的情况下,这种方式能够很好地工作。
canaries仅用来阻止那些对“溢出栈中的缓冲区并企图覆盖栈指针或其他受保护区域”的利用。canaries无法保护修改变量、数据指针或函数指针的利用,也不能阻止缓冲区溢出发生于任何位置(包括栈段在内)。不管是终止符canary还是随机canary都无法完全阻止通过覆盖返回地址的利用。直接覆盖栈中的4字节返回地址可以使得这两种方法都失效[Bulba00]。为了解决这些直接存取的利用,Stack guard加入随机异或canaries(Random XOR canaries)[Wagle 03],将返回地址与该canary作异或计算。当然,这种方法也只有在canary保持秘密的情况下有效。
(6)栈粉碎保护器(Propolice)
从Stack guard发展而来的一个流行缓解方法是GCC的栈粉碎保护器(Stack Smashing Protector,SSP),也称为Propolice。SSP是GCC的一个扩展,可以保护C应用免遭大多数常见形式的栈缓冲区溢出利用,它是作为GCC的中间语言翻译器的形式实现的。SSP提供了缓冲区溢出检测和变量重排技术来防止对指针的破坏。特别地,SSP重排局部变量,将缓冲区放到指针后面,并且将函数参数中的指针复制到局部缓冲区变量之前的区域,从而防止对指针的破坏(这些指针可被用于进一步破坏任意内存位置)。SSP特性通过GCC的选项提供,-fstack-protector和-fno-stack-protector选项可以打开或关闭栈粉碎保护。-fstackprotector-all和-fno-stack-protector-all选项可以打开或关闭对每一个函数的保护,而不仅仅局限于对具有字符数组的函数的保护。