缓冲区溢出攻击研究∗
2019-05-07袁连海李湘文
袁连海 李湘文 徐 晶
(成都理工大学工程技术学院 乐山 614000)
1 引言
应用程序在运行时,操作系统将为其分配一片连续的内存区域来存储各种各样的数据,这片内存区域叫做缓冲区。缓冲区溢出是用户向缓冲区中写入的数据超过缓冲区所能容纳的最大容量,使得写入的数据超过了定义的内存边界,从而将数据写进其他区域,在向已经分配的同定存储空间中存储多于申请大小的数据时,会发生缓冲区溢出。缓冲区溢出攻击是攻击者故意将大于缓冲区定义长度的数据写入到缓冲区,覆盖其他区域的数据,达到破坏性目的的操作。程序中存在缓冲区溢出漏洞是十分严重的安全问题。缓冲区溢出已经成为一种十分普遍和危险的安全漏洞,存在于各种操作系统和应用软件中。入侵者可以利用此漏洞攻击用户,从而造成了极大的经济损失和危害。攻击者可以通过缓冲区溢出攻击更改缓冲区的数据、注入恶意代码、改变程序的控制权、使未授权的用户获得管理员权限,以致可以非法执行任意代码。攻击者可以利用缓冲区溢出漏洞攻击用户,严重时可以导致程序不能正常运行、计算机关机和系统重启等结果。攻击者还可以利用该漏洞运行非法指令,获得系统超级用户权限,进行各种各样的非法操作。利用缓冲区溢出攻击,可以导致程序运行失败、重新启动、执行恶意代码等后果。缓冲区溢出中最危险的是堆栈溢出,入侵者可以利用堆栈溢出,在函数返回时改变返回程序的地址,让其跳转到任意地址,更为严重的是,它可被利用来执行非授权指令,甚至可以取得系统特权,进而进行各种非法操作[1]。
缓冲区溢出攻击在各种操作系统和应用软件中广泛存在,缓冲区溢出漏洞是网络信息安全中最危险的安全漏洞之一。在目前网络与操作系统安全领域,有50%以上的安全问题都是由于存在缓冲区溢出漏洞造成。从缓冲区溢出攻击第一次出现到现在,大量信息安全的研究者致力于如何尽量避免缓冲区溢出的产生,及时发现软件中缓冲区溢出的漏洞,有效防御缓冲溢出攻击的研究,产生了不少有用、有效的缓冲区溢出防御的方法和技术[2]。
2 进程内存分配
缓冲区溢出通常在采用C和C++语言编写的应用程序中存在,原因是这类编译器着重强调程序的运行效率,而缺乏检查内存是否超越边界的机制,这样应用程序就可能存在十分严重的安全问题。为更好地理解缓冲区溢出攻击是如何实现的,先让我们来了解C程序编写的进程在执行时内存分布状况。程序运行时,程序是分别加载到内存中几个内存分区的,包括代码段、数据段、BSS段、堆、和栈。上述内存区分别有对应的功能,编译器在编译程序时要将程序载入相应的内存区域。
图1 操作系统进程内存分配区
代码段又叫做文本段,在这个内存区域,主要存放可执行的进程的指令,包括用户程序代码和编译器生成的相关辅助代码,其大小取决于具体程序,代码段的存放起始位置一般是固定的,这要根据系统是32位或者64位,代码段通常是不可写入,只能读取,这是为了防止程序代码被意外修改。数据段紧挨着代码段,数据段用来存放已经初始化,但初始化值不为0的变量,程序中定义的已经初始化的静态变量、已经初始化的全局变量以及常量都保存在数据段区域。BSS段存放没有初始化的全局变量和静态变量,这些变量运行时初始化为0。堆内存区用来存放在程序中动态申请的内存区域,在C语言中,调用malloc()函数返回的内存地址就存放在堆里面,释放堆里面的内存需要调用函数free()来释放内存。栈内存区存放函数的局部变量、函数的参数及编译器自身产生的不可见的信息,如从被调用函数返回到调用函数的地址和一些状态寄存器的值。图1显示在Linux操作系统中典型的C程序的内存分布情况,在其他操作系统如UNIX和Windows操作系统中的进程的内存分配情况基本类似。
为了更进一步进行描述,由C或者C++编译器编译的程序内存占用包括以下几个部分:栈由编译器自动分配释放,通常存放函数的参数值、局部变量等,操作方式和数据结构中的栈相似。堆内存区经常由程序员分配和释放,如果程序员不释放,程序结束时操作系统进行回收。全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,静态区包括未初始化的全局变量和未初始化的静态变量在相邻的另一块区域,程序结束后由操作系统释放;字符串常量区存放常量,程序结束后由系统释放;程序代码段存放二进制代码。例如,下面一段C程序的各种变量的内存分配区域如下。
3 缓冲区溢出攻击原理
进程的内存分配情况在逻辑上基本分为三部分,分别是代码区域、动态数据区域以及静态数据区域。进程的每个线程都有私有的栈,所以每个线程虽然代码一样,但本地变量的数据都是互不干扰。动态数据区一般就是堆栈。全局变量和静态变量分配在静态数据区,本地变量分配在动态数据区,即堆栈中。程序通过堆栈的基地址和偏移量来访问本地变量。缓冲区溢出主要发生在上述内存不同区域中,具体可分为以下3种:1)基于数据段的缓冲区溢出;2)基于Heap的缓冲区溢出;3)基于Stack的缓冲区溢出。还有一些比较特别的缓冲区溢出,如BSS溢出,通常情况下,针对缓冲区溢出漏洞的利用大多数是栈溢出,堆溢出的利用相当困难[3]。
缓冲区溢出攻击是利用程序不对输入的数据进行边界检查的缺陷,向一个给定缓冲区中写入过量的数据,当数据量超过给定缓冲区的大小时,输入数据溢出并覆盖缓冲区邻近的数据。黑客利用这种方法,精心设计写入的数据,可导致程序去执行恶意代码、让系统死机或非法获得系统访问权。在C或者C++语言编写的程序中,系统分配的缓冲区允许位于数据段、堆、栈以及BSS段,而与程序执行过程相关的一些数据变量,包括函数参数指针、函数的返回内存地址等数据也是可以存放在这些内存区域。因此,当控制程序执行过程的一些关键的数据很容易受到缓冲区溢出攻击,这时,其值就有可能发生改变,结果是令正在执行的指令转向另外的代码,例如可以去运行非法的程序代码,造成用户的损失。
栈是一种先进后出的数据结构,是操作系统在建立某个进程时或者线程(在支持多线程的操作系统中是线程)为这个进程分配的内存区域,该存储区域具有先进后出的特点,系统在编译的时候可以指定栈的大小。本质上栈是由寄存器EBP和ESP指向的一片内存空间(前者指向栈底,后者指向栈顶),通常由高地址向低地址增长的内存空间,栈中保存一些临时的数据,例如一个函数中的临时变量以及函数的返回地址。
在编程中,例如C/C++中,所有的局部变量都是从栈中分配内存空间,实际上也不是什么分配,只是从栈顶向上用就行,在退出函数的时候,只是修改栈指针就可以把栈中的内容销毁,所以速度最快。自动开辟空间,用来分配局部变量、类的引用(指向堆空间段),栈使用的是一级缓存,通常在被调用时处于存储空间中,调用完毕立即释放。栈是由操作系统分配为程序运行过程中的进程分配的内存区域。当程序中执行函数调用的时候,执行流程是:第一是将函数的参数入栈,接着将指令寄存器中的内容入栈,作为返回地址(RET),再接着将基址寄存器(EBP)入栈,再把当前的栈指针(ESP)的内容拷贝到EBP,做为新的基地址;最后把ESP减去恰当的数值,为本地变量留出一定的存储空间[3]。
因为函数内的局部变量的内存分配是发生在栈里的,因此,如果某一函数内部声明缓冲区变量,那么,该变量所占用的内存空间是在该函数被调用时所建立的栈。由于对缓冲区变量的一些操作(例如,字符串复制STRCPY)是从低内存地址向高内存地址,而内存中所保存的函数调用的返回地址(RET)通常就位于该缓冲区的上面(高地址),这样就可能覆盖函数的返回地址。当用户复制的内容大于目标缓冲区大小限制时,会出现改写函数保存在函数栈中的返回地址,这样就可能使程序的执行流程按照攻击者的目的执行。下面通过对一个简单的缓冲区溢出代码来叙述缓冲区溢出原理[5]。
程序中包含一个个字节的缓冲区,编译运行以上代码,将命令行的第一个参数复制进缓冲区,由于程序没有进行边界检查,所以当argv[1]的字符数目超过5时,就会造成缓冲区溢出。在理论上程序的输入参数超过5个字节就会出现缓冲区溢出,但因为编译器不同版本原因,实际上复制的字符数目超过9个字节才能真正覆盖缓冲区。通过实际运行结果可以看到:当输入5个“B”的时候,程序会正常退出,8个字符也正常输出,当字符数目达到9个时,运行程序会出现段错误SEGMENT FAUIT。
缓冲区溢出攻击的类型包括以下几种[3]:栈溢出攻击、本地指针溢出攻击、本地函数指针溢出攻击和BSS函数指针溢出攻击。栈溢出攻击是最常见、最主要的缓冲区溢出攻击形式,这种攻击方式通过改写栈中的返回地址、前帧基址指针、函数指针以及在栈中植入攻击代码等来实施攻击。堆BSS的溢出攻击相对较难,攻击的数量也少一些。wOOw00是一种堆溢出攻击,通过溢出改写存在堆或BSS中的函数指针来获得控制。Matt Conover等做了深入的分析和研究[3]。
造成缓冲区溢出的因素通常包括以下几种情况[6]:第一是编程时采用不带类型安全检测的程序设计语言。虽然C和C++语言允许编程人员直接访问内存和寄存器,可以开发接近硬件运行能力性能、运行速度快的应用程序,但是这两种语言不进行类型安全检查以及检测数组安全边界,在包含字符串以及数组操作时,很容易造成缓冲区溢出,所以大部分出现缓冲区溢出漏洞的程序都是采用C和C++语言。第二种情况是编译器把程序缓冲区分配在内存中关键数据结构附件或者相邻的位置。最后是开发者采用了不安全的方式访问缓冲区。假设应用程序需要访问数据,而用户在将数据复制到指定的缓冲区位置却没有考虑目标缓冲区大小时,可能会出现缓冲区溢出漏洞。
4 缓冲区溢出防御
对于缓冲区溢出检测和防御,众多软硬件生产厂家做了很多措施来预防缓冲区溢出漏洞。例如微软公司在开发工具中增加编译选项来检测程序是否存在栈溢出,在操作系统中增加了结构化异常处理覆盖保护机制,通过阻止修改结构化异常处理增强系统的安全性,在硬件方面,64位CPU引入了NX(No-eXecute)机制,在内存中区分数据区与代码区,当攻击者利用溢出使CPU跳转到数据区去执行时,就会异常终止[6]。
造成缓冲区溢出的主要原因是编程人员存在不好的编程习惯,因此,防御程序存在缓冲区溢出漏洞的最主要的措施是提高程序员代码编写规范、养成良好的编程习惯。在人的因素解决后,可以从技术方面进行缓冲区溢出防御,包括编译器、操作系统等方面。是传统上系统级防范缓冲区溢出漏洞攻击的三种最常用的方法。它们都不需要程序员对自己的代码做任何修改,也基本不会带来程序性能的降低。单独一种机制可以在一定程度上降低缓冲区溢出漏洞所带来的风险,多种机制结合可使防范效果更加有效。比较经典的缓冲区溢出漏洞防御措施如下[11]。
1)不可执行栈
一些程序为了提高执行效率,有时程序会动态生成和执行代码。如JAVA采用just-in-time编译技术动态产生的代码可以提高性能。在Linux操作系统中在发送信号时,发送进程要往栈中插入代码并且触发中断,从而执行栈中包含的代码,并往接收进程发送信号,这时操作系统会修改栈的可执行属性,使其变成可执行。编译器也会在其栈中存储可执行的代码以便复用。所以,可执行的栈虽然具有一定的优点,但也留下了一定的安全隐患。为了减少可执行的栈带来的安全漏洞,可以让栈改变为不可执行。将栈改变为不可执行通常有两种方法:链接器和操作系统的保护。链接器的保护是在编译器编译源代码时,可以采用execstack选项来控制编译生成的目标文件为堆栈段不可执行。链接器链接目标文件时,如果某个目标文件的堆栈段被标记了堆栈段可执行,则生成的库或可执行文件的堆栈段会标记为可执行;如果所有目标文件都标记了堆栈段不可执行,则链接生成的库或可执行文件的堆栈段就会标记为不可执行。Linux系统提供了栈不可执行的措施,以防止攻击者将攻击代码通过栈溢出存储到进入栈,进而防止执行攻击代码。
2)随机化进程地址
由于计算机系统具有一定的相似性,这样就存在安全隐患,攻击者可以通过一台计算机上分析程序运行行为,设计攻击方法来攻击另一台计算机,所以,可以采用随机分配进程地址空间来避免不同计算机系统的相似性,从而预防缓冲区溢出漏洞,常见的方法包括:栈地址随机化以及局部变量地址随机化等方法。攻击者为了在栈中插入可运行的攻击代码,不仅需要插入攻击代码,还要插入攻击代码所在的地址值作为函数的返回地址。如果应用程序在每次执行时分配的栈地址都是相同的,攻击者就很容易获得函数的返回地址在栈上的存储地址和插入栈中在栈上的存储地址,也就很容易将固定的攻击代码改为在栈上的地址。随机化栈地址的另外作用是攻击者需要利用栈上某个变量时,由于该变量的地址不是固定的,所以攻击者获得需要的变量就比较困难。编译器在编译程序时,给每个局部变量预留大小随机的一段存储空间,因此,每个局部变量的地址也是随机的;另外,随机分配栈地址的作用是有限的,因为攻击者可以进行多次尝试,从而得到正确的地址[11]。
3)编译器引进安全检查机制
编译器为了检测程序执行时是否存在缓冲区溢出,一些编译器提供了验证码检测机制。在编译时,编译器在调用函数后和退出函数前都要插入一些代码。在调用函数后,在栈顶部和函数返回地址之间放入随机的验证码,退出函数前,插入的代码检查该验证码是否被修改,如果被修改,则报告异常。通常缓冲区溢出时,会从缓冲区的低地址到高地址依次覆盖,因此如果攻击者要覆盖写返回地址,则必须覆盖随机验证码,从而可以通过检查缓冲区被写前后的验证码是否一致判断是否发生了溢出。
5 研究热点与展望
缓冲区溢出攻击在程序中普遍存在以及具有较大的破坏性,许多研究者对如何有效地防御缓冲区溢出攻击进行了长期的研究。通过缓冲区溢出攻击原理我们知道,攻击者要利用缓冲区溢出进行攻击,攻击者经常要在一开始就通过缓冲区溢出植入攻击执行代码,接着会改变程序的正常执行流程,最终让自己的攻击代码得以执行。研究者通常对上述攻击环节中的几个步骤进行研究,阻断任何一个环节从而达到防止缓冲区溢出攻击的发生。
缓冲区溢出攻击防御分为两大类:静态防御和动态防御。静态防御方法是通过源代码找到程序的漏洞并进行修改,虽然要找出所有的源代码漏洞几乎是不可能的,但是找到已知攻击漏洞特征再进行修改,就可以大大降低程序被攻击的可能性。不足之处是需要程序源代码。获取源代码有时对用户来说是不可能的,另一个缺点是需要不断升级已知攻击数据库,一旦发现攻击漏洞后,用户需要知道怎么去修补漏洞。而动态防御是运行有漏洞的程序时,自动生成一个保护环境,让利用漏洞进行攻击的行为不能发生。
动态防御研究方面,由Crispin Cowan等开发了一种编译器技术,该技术不必修改程序源代码,可以有效预防针对栈的返回地址的缓冲区溢出漏洞攻击。在这类攻击中,攻击者通常先对栈的局域变量区写入,再从高地址向低地址重写基址指针和返回地址。通过在前帧基址指针与返回地址之间加入一个称为侦探字段的虚构域,在返回地址前,需要检查侦探字段是否和以前一致,如果不一致则报警并终止程序的执行。Stack Shield属于GCC编译器的补丁,它能防止针对返回地址和函数指针的溢出攻击。采用的策略如下:当调用函数时,返回的地址在压入栈的同时复制到一个受保护的全局数组里面,当函数返回时,通过比较栈中的返回地址是否和全局数组中的一致,来判断是否受到攻击。
在静态防御研究方面,文献[13]采用一种通过对编译以后的可执行源代码静态分析的策略,通过对函数名的检测得出哪些属于危险函数调用,经过对可执行代码反汇编,获得汇编代码,最后判断危险函数是否引起缓冲区溢出。文献[14]通过建模源程序代码,获得抽象的语法树、符号表、控制流图、函数调用图,并使用区间运算技术来分析和计算程序变量及表达式的取值范围,而且在函数分析过程中引入函数摘要概念来替换实际的函数调用。文献[15]也是通过静态语法树检查恶意攻击代码。文献[16]提出了使用静态特征匹配来查找被检测程序源代码中的攻击代码,实现的一种工具能够有效判断许多种常见的恶意代码。文献[17]通过把基于源代码分析的缓冲区溢出攻击问题转化成为一个和危险函数约束条件相关的不等式组求解的数学问题,设计出了基于不等式方程组来建立缓冲区溢出检测模型。文献[18]得出源代码中经常引起缓冲区溢出的代码的典型特征,例如缓冲区的坐标变量值、危险函数调用情况、指针增减操作、使用指针的循环语句等。通过以上特征,把缓冲区溢出归类成5种典型的基本类型,还提出了各自的预防策略。文献[12]提出一种漏洞挖掘方法,该方法基于遗传算法,根据缓冲区溢出攻击的典型特征,结合静态分析的控制流思想,该方法同模拟退火算法相比,具有更高的完备性和更快的收敛速度。但是它并没有去除程序中的漏洞,在平常环境下漏洞依然存在。动态防御不需要程序的源代码。可以防御新的恶意代码对已受保护的目标的攻击,但是对未保护的新目标攻击则无能为力。
6 结语
在网络和信息技术日益发达的当今社会,网络攻防技术不断发展,为了提高系统软件和应用软件的安全性能,研究者需要进一步在软件安全漏洞的系统分析、漏洞检测、问题发现以及安全防范等各种关键技术进行深入研究。论文对进程的内存分布、缓冲区溢出攻击的基本原理、攻击种类以及预防方法进行了详细介绍,对当前主流的缓冲区溢出攻击防御方法进行了叙述,归纳总结了缓冲区溢出防御的基本方法。有效地防范软件系统的缓冲区溢出漏洞攻击涉及各种复杂的技术和方法,开发者需要综合运用各种防御方法,从多个层面和多种角度出发,实现缓冲区溢出漏洞攻击的有效防御。但是到目前为止还没有一个完整的针对缓冲区溢出攻击的解决方法,软件开发者需要清晰地意识到缓冲区溢出攻击的普遍性和危害性,在编程时增强安全意识,养成良好的编写安全应用程序的思想,写出高质量的软件,切除缓冲区溢出的根源。测试软件时要全面和充分地进行漏洞测试和分析。