概述
缓存溢出(或译为缓冲溢出)为黑客最为常用的攻击手段之一,蠕虫病毒对操作系统高危漏洞的溢出高速与大规模传播均是利用此技术。缓存溢出攻击从理论上来讲可以用于攻击任何有缺陷不完美的程序,包括对杀毒软件、防火墙等安全产品的攻击以及对银行程序的攻击。
缓存区溢出存在于各种电脑程序中,特别是广泛存在于用C、C++等这些本身不提供内存越界检测功能的语言编写的程序中。现在C、C++作为程序设计基础语言的地位还没发生改变,它们仍然被广泛应用于操作系统、商业软件的编写中,每年都会有很多缓存区溢出漏洞被人们从已发布和还在开发的软件中发现出来。从CERT漏洞数据库和国家漏洞数据库NVD中统计2001—2012年每一年发现的缓存区溢出漏洞数如图所示。虽然从图上看出缓存区溢出数相比2007年已经大幅减少,但在2011年的CWE/SANS最危险的软件漏洞排行榜上,“没进行输入大小检测的缓存区复制”漏洞排名第三。可见,如何检测和预防缓存区溢出漏洞仍然是一个非常棘手的问题1。
缓存溢出攻击方式为实现缓存区溢出攻击,攻击者必须在程序的地址空间里安排适当的代码及进行适当的初始化寄存器和内存,让程序跳转到入侵者安排的地址空间执行。控制程序转移到攻击代码的方法有如下几种:
破坏活动记录函数调用发生时,调用者会在栈中留下函数的活动记录,包含当前被调函数的参数、返回地址、前栈指针、变量缓存区等值,它们在栈中的存放顺序如图所示。由它们在栈中的存放顺序可知,返回地址、栈指针与变量缓存区紧邻,且返回地址指向函数结束后要执行的下一条指令。栈指针指向上一个函数的活动记录,这样攻击者可以利用变量缓存区溢出来修改返回地址值和栈指针,从而改变程序的执行流。
破坏堆数据程序运行时,用户用C、C++内存操作库函数如malloc、free等在堆内存空间分配存储和释放删除用户数据,对内存的使用情况如内存块的大小、它前后指向的内存块用一个链接类的数据结构予以记录管理,管理数据同样存放于堆中,且管理数据与用户数据是相邻的。这样,攻击者可以像破坏活动记录一样来溢出堆内存中分配的用户数据空间,从而破坏管理数据。因为堆内存数据中没有指针信息,所以即使破坏了管理数据也不会改变程序的执行流,但它还是会使正常的堆操作出错,导致不可预知的结果。
更改函数指针指针在C、C++等程序语言中使用得非常频繁,空指针可以指向任何对象的特性使得指针的使用更加灵活,但同时也需要人们对指针的使用更加谨慎小心,特别是空的函数指针,它可以使程序执行转移到任何地方。攻击者充分利用了指针的这些特性,千方百计地溢出与指针相邻的变量、缓存区,从而修改函数指针指向达到转移程序执行流的目的。溢出的具体方法可以参考文献,本文不再详述。
溢出固定缓存区C标准库函数中提供了一对长跳转函数setjmp/longjmp来进行程序执行流的非局部跳转,意思是在某一个检查点设置setjmp(buffer),在程序执行过程中用longjmp(buffer)使程序执行流跳到先前设置的检查点。它们跟函数指针有些相似,在给用户提供了方便性的同时也带来了安全隐患,攻击者同样只需找一个与longjmp(buffer)相邻的缓存区并使它溢出,这样就能跳转到攻击者要运行的代码空间。典型的例子有Perl5.003的缓冲区溢出漏洞,攻击者首先进入用来恢复缓冲区溢出的longjmp缓冲区,然后诱导进入恢复模式,这样就使Perl的解释器跳转到攻击代码上了。
检测和预防技术缓存区溢出首次进入公共视野是在1988年的第一种缓存区溢出攻击---Morris蠕虫,被罗伯特等人制造出来造成全世界6000多台网络服务器瘫痪后,人们开始关注缓存区溢出,并相继提出了各种各样的缓存区溢出检测与预防技术和工具。本文参考李毅超、夏一民等人的分类,根据技术是作用于源码还是二进制代码、是只静态分析代码还是要重新编译运行代码,把所有的缓存区溢出检测和预防技术进行分类,如图所示。随后介绍了主流的检测和预防技术的原理、发展历程和优缺点。
基于源码的静态分析技术模型化
模型化方法是静态分析里一种常用的方法,它可以用在程序分析的很多方面,如判断可执行路径中正确属性是否发生的nevertrace方法,能以较低花费支持安全需求进化过程的模型监测方法;同时它还能把缓存区的定义与使用信息及其之间的关系与缓存区有关的语句和函数都予以建模,把缓存区溢出检测问题等价地转换为模型验证问题。将缓存区溢出检测转换为整数限制求解问题,把C字符串作为一个抽象数据类型,每一个缓存区用两个整数来描述,一个是整数表示缓存区被分配的大小,另一个表示缓存区使用的当前位置。标准库函数被模型化为单纯的长度赋值语句,判断每次缓存区操作时缓存区使用的当前位置是否超过它所占内存的范围,这样即可把缓存区溢出检测问题转换为整数限制求解问题。但由于整数范围分析得不够精确,使得该方法存在误报,且报告缓存区溢出时不能提供导致该溢出的相关信息,因而人工验证的成本很高。CodeAuditor是上述技术的另一种实现方式,所不同的是缓存区溢出检测不再转换为整数限制求解问题,而是转换为模型验证问题。CodeAuditor把与缓存区有关的语句和函数转换为整数转移函数和约束(这些函数与约束就是判断缓存区是否溢出),在源代码的AST树上插入函数和约束的验证语句,用模型验证方法来判断这些函数和约束是否能得到满足。该方法有效地解决了因为范围分析不够精确而导致的误报率过高问题。从上面分析可知,模型化的方法对模型的质量要求很高,模型不够全面的话会漏掉很多错误;由于是把原问题转换为其他问题求解,丢失了原来的一些信息,也容易导致误报;当产生错误报告时不能提供为改正该错误有用的信息,因而错误修改亦不方便。
路径分析
路径分析是根据程序的控制流图对每条执行路径进行分析,判断沿着路径的执行是否导致缓存区溢出。对路径既可以正向遍历亦可以反向遍历。正向遍历是在原程序的缓存区操作前加入检测语句,程序顺序执行并运行检测语句来判断缓存区是否溢出;反向遍历是当遇到一个缓存区操作时,逆向遍历经过该缓存区的所有路径,得到缓存区的分配使用信息,比较分配使用值的大小关系即能判断缓存区是否溢出。SecTAC是一个路径再测试工具,它重用以前的测试用例来产生执行路径,每条路径符号执行后都会生成程序约束和安全约束,当程序变量满足程序约束但是不满足安全约束时,错误报告就会产生。该方法受产生执行路径的测试用例的限制,如果测试用例不够全面,漏报就会很多;当程序大且复杂时,执行路径太多,SecTACSL的处理时间就会很长。Marple把路径分为infeasible、safe、vulnerable、overflow-input-independent和don’t-know五类,它首先用分析器分析程序剔除不可能存在缓存区溢出漏洞的路径,从可能存在缓存区溢出漏洞的语句上提出查询;然后反向遍历控制流程图,并从源码中提取出缓存区相关信息;再建立漏洞模型来构造、更新、求解该查询。当遇到进入infeasible路径块、收集的信息已够解决该查询和到函数入口时查询还未解决这三种情况时,分析结束。如由收集的信息判断出现了缓存区溢出,Marple会返回可能导致产生该溢出的路径信息。Marple的缺陷是它把很多库函数调用、循环语句、共享的全局变量等作为don’t-know类型来处理,这样就漏掉了很多漏洞。除了上面这两种方法外,还有其他的路径分析方法,如已有方法把可能导致缓存区溢出的路径分为三种模式:语法使用、元素访问和块移动。先用语法分析器把所有的语法使用错误都剔除,然后用受限的符号评价系统对后两种模式进行分析,找出存在的缓存区溢出漏洞。该方法对用户自定义的操作不兼容,因而存在误报与漏报。虽然路径分析方法能综合上下文对缓存区溢出进行检测,减少了缓存区溢出检测的误报率,能对检测语句进行优化,减少了处理时间,但从上面的叙述可以看出,它存在很明显的漏报。对于大型的复杂的程序,因为路径过多,检测效果就更加不理想了。
基于源码的动态分析技术扩展编译器
1)值验证
为预防破坏活动记录的攻击,可以在返回地址和局部变量之间插入一个固定值,如果缓存区溢出了必然会使该固定值发生改变,在访问返回地址前先判断该值是否发生了变化就可以预防这种攻击。Cowan等人提出了函数调用时在缓存区与返回地址之前插入一个canary值,在函数结束返回前判断这个值是否被修改,如果被修改了就说明出现了栈溢出。随后他们又提出了优化方法PointGuard,对函数指针和longjmp缓存区之后也增加了canary值保护,防止被缓存区溢出操作改写。ProPolice继承了Cowan的方法,所不同的是ProPolice重排了局部变量,使得攻击者很难找到要溢出的缓存区;复制函数参数内的指针到某一个安全区域使得这些指针不会被污染;同时对插桩代码进行了优化。Hasabnis等人根据canary值原理提出了另一种边界值检测方法(LBC)。他们把每个对象的前后都插入一个guardzone,如果程序运行时访问到任一个guardzone就提示出现了访问错误。他们对guardzone的取值大小和如何插入这些guardzone进行了详细的研究,但是没有考虑到当guardzone与源程序代码片段相同时该怎样解决;当偏移使得指针从一个对象跳到另一个对象时,该方法也处理不了。为使攻击者不能猜到canary值,canary取值必须随机化。又因为该方法只是通过检测canary值是否改变了来进行缓存区溢出的判断,不能对只读上溢和下溢进行判断,也不能对堆溢出进行检测等。
2)参考对象
早期的参考对象技术是在数组越界检测里用一个包含指针基地址、指针所指向的空间大小、指针在文件中所处的位置等作为一个多元组来扩展指针的表现形式。在程序中把所有的原指针都以新的指针访问形式替换,这样在程序运行时就能根据多元组里的信息判断指针访问是否正确,如CCured、Cyclone等。因为该方法改变了指针的表现形式,导致处理后代码与未处理的代码不兼容。Jones等人提出了一种不改变指针表现形式的方法,它把单个有效的内存块(变量、数组、结构、联合等所被分配的内存)作为一个参考对象,用一个全局的对象表收集所有参考对象的基地址、空间大小等信息。在程序运行过程中,当对象创建时,把该对象的相关信息添加到对象表中,当程序运行超出了该对象的作用域或是对象被销毁时,则从表中删除该对象信息。在访问该对象时,先查找表,判断当前访问是否超出了基地址加大小的范围,如果超出了,这提示存在缓存区溢出并结束程序。该方法当一个指针访问出错时,指向该指针的指针也不能再被检测,即如果指针p访问越界,则认为指向p的指针q也是越界的,即使q没有越界。CRED是对Jones方法的优化,它以ILLEGAL表示一个越界指针,用一个宽松的越界判断标准解决了上述问题。Dhurjati等人进一步优化了参考对象方法,他们用自动的池分配技术(automatic pool allocation)把一棵扩展树分解成多棵树,极大地减小了指示对象的查找时间,同时从扩展树上删除了一些多余的对象,使树整体上变小了。但是把整数强制转换为指针等问题没有很好地被解决。虽然Jones的系列方法解决了程序兼容性问题,但他们把数据类型为数组、结构、联合等的变量分配的空间单独作为一个参考,故不能对其中的成员元素进行越界检测;它只能对在编译时就已经明确的操作命令进行检测,如*p,s.a等这些命令,当对它们取别名用别名来访问时,就不能被处理。
3)影子内存
影子内存技术是把程序所占的内存所处的状态映射到一个安全的区域(影子内存)中去,如Dr.Memory就把程序所占内存每一比特的状态分为不可访问、未初始化、已经定义的三种状态。当访问内存时先查找影子内存中对应比特的状态,如果是不可访问的话就说明出现访问错误。典型的映射方法还有直接把地址空间的数值范围和偏移映射到一个单一的影子地址空间或映射到表结构的影子空间。如TaintTrace用一块与原地址空间同样大小的影子空间;Dr.Memory用一个表结构作为影子空间的存储结构,在访问时要进行表的查找。为使地址空间的排列更加灵活,一些工具用了多层的映射机制,如Valgrind,在64位的平台上用额外的表来存储高于32G的地址,LBC用两级影子内存存放相应的信息。还有其他优化方法如Umbra,凭借non-uniform避免了表查找操作,同时动态地调整了数值范围与偏移机制;AddressSanitizer用了128-1的映射关系来进行影子状态的编码使得内存更加紧凑等。影子内存技术是在位层位对内存进行操作,故映射机制的好坏直接决定了所需影子内存的大小和查找所需要时间的长短。
另写分析工具
Frama-C提出了一般规格语言ACSL和旨在消除静态分析和动态分析工具之间区别的ACSL子语言E-ACSL,这两种语言引入的“注释”概念是指程序运行中要满足的一些性质,如检查指针访问是否有效、变量有没有溢出、数组访问是否越界等。Frama-C在检测程序时先用“注释”生成插件,如RTE,把“注释”插入到原代码中满足E-ACSL规范的插件;把这些“注释”翻译成C语言,编译运行翻译后的代码,当“注释”在程序运行过程中不被满足时,程序终止执行并给出“注释”所在行列号等信息。如果所有“注释”在程序运行中都被满足了,程序不会产生额外的信息,程序所做的功能与未插入“注释”前是完全一样的。Frama-C本质上仍然是一种运行时验证技术,所不同的是它引入了产生“注释”这一中间过程,把程序的静态分析与动态分析有机地结合在一起。Frama-C用一个内存监测机制来监测内存的使用情况,在程序执行过程中记录内存块的有效性和初始化信息到一个专门的内存区域(该内存区域的存储结构可以是表、树等),在内存访问时执行查询语句来查找该区域以确定内存的访问是否正确。虽然现在ASCS、E-ACSL语言已能处理大部分C语言错误,但是它对原程序的性能影响很大,最近Kosmatov等人优化了记录存储内容,比较了以哈希表、链表、伸展树、Patricia树等作为存储结构对记录查询效率的影响,同时还改进了查询算法,用静态分析优化了插桩过程,进一步提高了该类语言的可用性。虽然以插件的形式来编写其他功能代码使得Frama-C的扩展非常容易,但是从另一方面来说,该系列的语言过于复杂,导致最后插桩生成的可执行的C文件代码膨胀过大,程序执行效率降低很多;同时它们对C源文件的处理过程是要先插入“注释”,然后再把“注释”翻译成C类型语言,处理过程复杂,所需时间也长。
基于二进制代码的分析技术本文把基于二进制代码的分析技术分为基于二进制代码的静态分析技术和动态分析技术。基于二进制代码的静态分析技术是在可执行的二进制代码上进行静态分析,找出感兴趣的点(如函数调用、缓存区访问等),在这些点中插入一些语句来检测程序的运行情况;然后重新生成新的二进制代码,运行该代码,如果运行过程中有错误发生(如数组越界),则停止程序的执行并报告错误。基于二进制代码的动态分析技术不需要源代码,在可执行的二进制代码上修改或添加新的二进制代码,程序运行过程中调用这些代码来进行检测。是否要重新生成新的可执行二进制代码是基于二进制代码的静态分析技术与动态分析技术的不同点。
基于二进制代码的静态插桩技术
1)库函数替换
该技术是在程序链接时用重新编写过的能进行边界值检查的函数来替换C、C++标准库函数中不进行边界值检查的函数,这样当程序运行时,如果边界检查未通过,则报告出现缓存区溢出并终止运行。Valgrind是一个开源的直接在二进制上进行插桩的缓存区溢出检测工具。插入相应的代码拦截malloc和free函数的运行,然后在一个虚拟的x86处理器上模拟插桩之后代码的执行,当模拟执行崩溃时,说明缓存区操作出错,Valgrind会产生相应的错误报告。处理相同的程序,Valgrind会比gcc多花25~50倍的时间。Seward等人还扩展了Valgrind的使用,在位精度层面对使用未定义的变量错误进行了检测;在2007年把Valgrind正式扩展为一个动态二进制插桩工具。Chaperon同样在可执行的二进制代码上插入代码来拦截替换malloc、free等函数的执行,通过新的函数来进行堆访问有效性的验证、内存泄露、访问未定义变量错误的检测。Chaperon能对堆缓存区进行有效的检测,但对栈缓存区的检测效果不行;当程序因为其他问题而崩溃时,Chaperon也不能进行有效的工作;Chaperon是一个不开源的商业工具,很难对它进行扩展。虽然库函数替换的方法是在二进制上进行操作,不需要重新编译源码,但每调用一次库函数都要进行边界值的检测:对原程序的执行效率影响很大;对静态连接库无效;不能预防基于堆或BSS数据段的攻击等。
2)地址随机化
地址随机化技术是另一种二进制代码静态分析方法,它把程序代码段、数据段等所在内存空间的地址打乱,使攻击者很难知道某一段代码、某一个数据在内存空间的地址位置,如随机化变量和函数的顺序、随机化系统调用映射和改变库的入口地址等。一般是用改变传统的内存分配算法,在程序加载时将内存布局打乱。打乱的原则有随机化代码与数据的绝对地址和数据地址之间相对的距离。例如地址空间随机化就是把进程空间的地址随机化,使得用绝对地址空间进行攻击的方法找不到它要攻击的内存地址,这样即使被攻击,程序也只是崩溃而不会被控制。还有其他随机化方法。例如:PaXASLR能在操作系统的支持下随机化栈、堆、全局空间,但是不能随机化代码段和静态数据段的地址;Addressobfuscation扩展了PaxASLR的方法,在编译器的帮助下能随机化静态代码段和数据段的地址,但是导致了11%的性能损耗;PIE能对全局对象进行处理,该对象基地址的数据段都能被重定位,它也会导致14%的性能损耗。可见这些方法共有的缺陷是随机化不充分,能被暴力破解,对原程序的性能影响很大。Addressspacelayoutpermutation(ASLP)能对所有的空间地址进行随机化,它用一个二进制重写工具能随机定位静态数据段和代码段,重排列代码段的函数、数据段的数据对象。又因为是在二进制代码上进行操作,故不需要源代码;修改Linux内核使得它能够随机化栈、堆、内存映射区域。在二进制重写工具和内核的支持下,ASLP能对32位结构下的29位进行随机化,并且只损失了1%的性能。但是ASLP不能对栈框架进行随机化,如它不能预防重定位到库函数的攻击;如果缺失重定位的信息,ASLP必须重新链接或是重新编译。
基于二进制代码的动态插桩技术(程序执行流更改)
该技术是在二进制代码上替换要检测的函数调用的入口、出口地址,在程序执行过程中,如果遇到函数调用语句,程序执行流直接跳到额外添加的代码并执行,执行完再跳回原程序执行。Libverify利用_init()函数进行代码插桩,插桩代码的功能包括如何确定用户代码的位置和大小,如何确定在用户代码中函数的开始地址和对每一个函数的操作(复制函数到堆内存,在原函数调用前添加调用函数wrapper_entry和在原函数返回前添加调用函数wrapper_exit),这样,在每个函数调用前把函数的返回地址通过wrapper_entry函数保存,在函数返回前用wrapper_exit函数来判断返回地址是否被修改,如果被修改了就说明出现了栈溢出。Gupta等人优化了Libverify方法,他们没有作函数代码的复制,只是在二进制代码上更改了函数的调用代码,但是他们只能对5Byte的指令进行安全的跳转,当编译生成的二进制代码中没有5Byte的代码可用来放置跳转指令时,程序就会出现错误。而不同的编译器对函数调用语句生成的二进制代码的格式一般是不同的,可见该方法不能通用。虽然程序执行流更改技术现在只能对栈缓存区溢出漏洞进行检测,但是它在修改原来的二进制代码后,不必重新编译链接生成新的可执行二进制代码,也就是说它不需要源代码,这对那些未开源的程序测试是非常有意义的。同时还可以扩展该技术使得在不中断服务的基础上对那些提供服务的程序进行检测。
其他检测和预防技术缓存区溢出漏洞自动检测与修复技术是近几年才出现的,Arcuri于2008年首次提出了软件bug自动修复的方法,他用合适的函数(自己设计或是从软件说明书中自动生成)来进化程序(如用遗传算法编写的程序),使之能够通过一系列单元测试用例,在进化过程中程序得到了修复与完善。虽然该方法存在修复的程序(遗传程序GP)计算复杂、GP的搜索空间太大、对完整复杂的程序测试不了、复杂的bug也不能被修复等问题,作者还是在一个冒泡排序程序上进行了实验,说明了其方法是切实可行的。相比Arcuri的方法可以对各种不同的错误进行自动修复,SafeStack只能对栈缓存区溢出漏洞进行修复。以往的自动修复技术需要中断原程序的执行,修复完之后再重新运行程序,SafeStack是当原程序运行到可能存在缓存区溢出的代码片段时,跳到SafeStack执行,修复完成后返回原程序执行,因此它不会中断原程序的执行。它通过内存访问虚拟化技术把可能存在缓存区溢出的代码片段移到一个受保护的内存区域中,在该内存中对漏洞进行检测、补丁生成、补丁测试、补丁运用等操作。SafeStack每一步实现借鉴了其他的技术,如检测是否存在缓存区溢出的canary方法,补丁生成和评测的First-Aid技术等。可见,缓存区溢出的自动检测与修复技术可以用协同进化的方法,也可以把现有的检测技术与修复技术有机地结合在一起。结合的方式一般是先用检测模块找出程序中的缓存区溢出漏洞,然后传递给修复模块进行修复,修复完成后再输入检测模块进行检测,直到不再有漏洞被检测出来为止。流程如图所示。还有很多其他的检测和预防技术如增加硬件支持技术、增加一个新的堆栈来存放返回地址。在函数返回前判断该堆栈里的地址与原堆栈中的返回地址是否一致来防止返回地址被修改,该类技术对源码影响小、速度快,但操作系统和编译器都需要相应的修改;使堆栈不可执行技术,该类技术能有效地预防堆栈缓存区溢出攻击,同样需要修改操作系统和编译器等等1。
黑客如何搅乱缓存下面让我们了解一下缓存溢出的原理。众说周知,c语言不进行数组的边界检查,在许多运用c语言实现的应用程序中,都假定缓冲区的大小是足够的,其容量肯定大于要拷贝的字符串的长度。然而事实并不总是这样,当程序出错或者恶意的用户故意送入一过长的字符串时,便有许多意想不到的事情发生,超过的那部分字符将会覆盖与数组相邻的其他变量的空间,使变量出现不可预料的值。如果碰巧,数组与子程序的返回地址邻近时,便有可能由于超出的一部分字符串覆盖了子程序的返回地址,而使得子程序执行完毕返回时转向了另一个无法预料的地址,使程序的执行流程发生了错误。甚至,由于应用程序访问了不在进程地址空间范围的地址,而使进程发生违例的故障。这种错误其实是编程中常犯的。
组成部分一个利用缓冲区溢出而企图破坏或非法进入系统的程序通常由如下几个部分组成:
1. 准备一段可以调出一个shell的机器码形成的字符串,在下面我们将它称为shellcode。
2. 申请一个缓冲区,并将机器码填入缓冲区的低端。
3. 估算机器码在堆栈中可能的起始位置,并将这个位置写入缓冲区的高端。这个起始的位置也是我们执行这一程序时需要反复调用的一个参数。
4. 将这个缓冲区作为系统一个有缓冲区溢出错误程序的入口参数,并执行这个有错误的程序。
通过以上的分析和实例,我们可以看到缓存溢出对系统的安全带来的巨大威胁。在unix系统中,使用一类精心编写的程序,利用suid程序中存在的这种错误可以很轻易地取得系统的超级用户的权限。当服务程序在端口提供服务时,缓冲区溢出程序可以轻易地将这个服务关闭,使得系统的服务在一定的时间内瘫痪,严重的可能使系统立刻宕机,从而变成一种拒绝服务的攻击。这种错误不仅是程序员的错误,系统本身在实现的时候出现的这种错误更多。如今,缓冲区溢出的错误正源源不断地从unix、windows、路由器、网关以及其他的网络设备中被发现,并构成了对系统安全威胁数量最大、程度较大的一类。
防范缓冲区溢出缓冲区溢出2是代码中固有的漏洞,除了在开发阶段要注意编写正确的代码之外,对于用户而言,一般的防范错误为
–关闭端口或服务。管理员应该知道自己的系统上安装了什么,并且哪些服务正在运行
–安装软件厂商的补丁,漏洞一公布,大的厂商就会及时提供补丁
–在防火墙上过滤特殊的流量,无法阻止内部人员的溢出攻击
–自己检查关键的服务程序,看看是否有可怕的漏洞
-以所需要的最小权限运行软件