购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

3.1 内存漏洞

本节首先介绍程序运行时的内存结构及缓冲区溢出漏洞的基本原理,接着分别介绍栈溢出、堆溢出,以及格式化字符串漏洞原理及利用技术。

3.1.1 内存结构及缓冲区溢出

为了清晰了解缓冲区溢出的工作原理,首先介绍Win32系统内存结构及函数调用时内存的工作原理。

在Win32环境下,由高级语言编写的程序经过编译、链接,最终生成可执行文件,即PE(Portable Executable)文件。在运行PE文件时,操作系统会自动加载该文件到内存,并为其映射出4GB的虚拟存储空间,然后继续运行,这就形成了所谓的进程空间。

Win32系统中,进程使用的内存按功能可以分为4个区域,如图3-1所示。

1)代码区:存放程序汇编后的机器代码和只读数据,这个段在内存中一般被标记为只读,任何企图修改这个段中数据的指令都将引发一个Segmentation Violation错误。当计算机运行程序时,会到这个区域读取指令并执行。

图3-1 进程的内存使用划分

2)数据区:用于存储全局变量和静态变量。

3)堆区:该区域内存由进程利用相关函数或运算符动态申请,用完后释放并归还给堆区。例如,C语言中用malloc/free函数、C++语言中用new/delete运算符申请的空间就在堆区。

4)栈区:该区域内存由系统自动分配,用于动态存储函数之间的调用关系。在函数调用时存储函数的入口参数(即形参)、返回地址和局部变量等信息,以保证被调用函数在返回时能恢复到主调函数中继续执行。

程序中所使用的缓冲区既可以是堆区和栈区,也可以是存放静态变量的数据区。由于进程中各个区域都有自己的用途,根据缓冲区利用的方法和缓冲区在内存中的所属区域,其可分为栈溢出和堆溢出。

缓冲区溢出漏洞就是在向缓冲区写入数据时,由于没有做边界检查,导致写入缓冲区的数据超过预先分配的边界,从而使溢出数据覆盖在合法数据上而引起系统异常的一种现象。

目前,缓冲区溢出漏洞普遍存在于各种操作系统(Windows、Linux、Solaris、Free BSD、HP-UX及IBM AIX),以及运行在操作系统上的各类应用程序中。著名的Morris蠕虫病毒,就是利用了VAX机上BSD UNIX的finger程序的缓冲区溢出错误。

3.1.2 栈溢出漏洞及利用分析

在介绍栈溢出之前,先对栈在程序运行期间的重要作用做一简单介绍。

1.函数的栈帧

(1)栈帧的概念

在程序设计中,栈通常是指一种后进先出(Last In First Out,LIFO)的数据结构,而入栈(Push)和出栈(Pop)则是进行栈操作的两种常见方法。为了标识栈的空间大小,同时为了更方便地访问栈中数据,栈通常还包括栈顶(Top)和栈底(Base)两个栈指针。栈顶指针随入栈和出栈操作而动态变化,但始终指向栈中最后入栈的数据;栈底指针指向先入栈的数据,栈顶和栈底之间的空间存储的就是当前栈中的数据。

相对于广义的栈而言,栈帧是操作系统为进程中的每个函数调用划分的一个空间,每个栈帧都是一个独立的栈结构,而系统栈则是这些函数调用栈帧的集合。

系统栈同样遵守后进先出的栈操作原则,它与一般的栈不同的地方如下。

●系统栈由系统自动维护,用于实现高级语言中函数的调用。当函数被调用时,系统会为这个函数开辟一个新的栈帧,并把它压入栈区中,所以正在运行的函数总是在系统栈区的栈顶(本书称为“当前栈帧”)。当函数返回时,系统会弹出该函数所对应的栈帧空间。

●对于类似C语言这样的高级语言,系统栈的Push和Pop等堆栈平衡的细节对用户是透明的。

●栈帧的生长方向是从高地址向低地址增长的。

(2)栈帧的标识

Win32系统提供了两个特殊的寄存器来标识当前栈帧。

●ESP:扩展栈指针(Extended Stack Pointer)寄存器,其存放的指针指向当前栈帧的栈顶。

●EBP:扩展基址指针(Extended Base Pointer)寄存器,其存放的指针指向当前栈帧的栈底。

显然,ESP与EBP之间的空间即为当前栈帧空间。

执行如图3-2a所示的代码段时,栈区中各函数栈帧的分布状态如图3-2b所示。

除了上述两个标识栈帧位置的寄存器外,在函数调用过程中,还有一个非常重要的寄存器——EIP,即扩展指令指针(Extended Instruction Pointer)寄存器,该寄存器存放的是指向下一条将要执行的指令。EIP控制了进程的执行流程,EIP指向哪里,CPU就会执行哪里的指令。

图3-2 栈区中各函数栈帧的分布状态

(3)栈帧中的内容

一个函数栈帧中主要包含以下信息。

1)前一个栈帧的栈底位置,即前栈帧EBP,用于在函数调用结束后恢复主调函数的栈帧(前栈帧的栈顶可计算得到)。

2)该函数的局部变量。

3)函数调用的参数。

4)函数的返回地址RET,用于保存函数调用前指令的位置,以便函数返回时能恢复到调用前的代码区中继续执行指令。

下面结合函数的调用,进一步介绍栈帧中这些信息是如何产生和使用的。

2.函数的调用

(1)函数调用的步骤

假设函数func_A调用函数func_B,这里称func_A函数为“主调函数”,func_B函数为“被调用函数”,函数调用的步骤如下。

1)参数入栈。将被调用函数(func_B)的实际参数从右到左依次压入主调函数(func_A)的函数栈帧中。

说明:

通常,不同的操作系统、不同的程序语言、不同的编译器在实现函数调用时,其对栈的基本操作都是一致的,但在函数调用约定上仍存在差异,这主要体现在函数参数的传递顺序和恢复堆栈平衡的方式,即参数入栈顺序是从左向右还是从右向左,函数返回时恢复堆栈的操作由被调用函数进行还是由主调函数进行。具体地,对于Windows平台下的Visual C++编译器而言,一般按照默认的stdcall方式对函数进行调用,即参数是按照从右向左的顺序入栈,堆栈平衡由主调函数完成。若无特殊说明,本章的函数调用均为stdcall调用方式。

2)返回地址RET入栈。将当前指令的下一条指令地址压入主调函数(func_A)的函数栈帧中。

3)代码区跳转。CPU从当前代码区跳转到被调用函数的入口,EIP指向被调用函数的入口处。

4)将当前栈帧调整为被调用函数的栈帧,具体方法如下。

●将主调函数(func_A)的栈帧底部指针EBP入栈,以便被调用函数返回时恢复主调函数的栈帧。

●更新当前栈帧底部:将主调函数(func_A)的栈帧顶部指针ESP的值赋给EBP,作为新的当前栈帧(即被调用函数func_B的栈帧)底部。

●为新栈帧分配空间:ESP减去适当的值,作为新的当前栈帧的栈顶。

(2)返回主调函数的步骤

被调用函数执行结束后,返回主调函数的步骤如下。

1)保存返回值。将函数的返回值保存在寄存器EAX中。

2)弹出当前栈帧,将前一个栈帧(即主调函数栈帧)恢复为当前栈帧,具体方法如下。

●降低栈顶,回收当前栈帧的空间。

●弹出当前EBP指向的值(即主调函数的栈帧EBP),并存入EBP寄存器,使得EBP指向主调函数栈帧的栈底。

●弹出返回地址RET,并存入EIP寄存器,使进程跳转到新的EIP所指执行指令处(即返回主调函数)。

需要注意的是,内存栈区由高地址向低地址增长,因此当4字节压入栈帧时,ESP=ESP-4;弹出栈帧时,ESP=ESP+4。

下面通过一个简单的程序来分析函数调用时栈帧中的内容变化情况,程序的运行环境为Windows 7操作系统和Visual C++ 6.0编译器。

【例3-1】函数调用时的栈帧分析

在Visual C++ 6.0中输入程序完成后,按〈F10〉键单步执行一次,进入Visual C++ 6.0的调试环境,黄色箭头指向当前要运行的代码位置。选择Debug(调试)工具栏中的Step Over(单步执行)程序,选择Debug工具栏按钮中的Memery(内存)和Registers(寄存器),可看到内存中栈的内容变化情况。

(1)main函数的栈帧情况

图3-3a所示为在fun()函数被调用之前main函数执行时内存和寄存器的情况,图3-3b所示为图3-3a对应的main函数栈帧的情况。图中阴影部分表示当前栈帧。

在查看图3-3时,需要特别注意以下3点。

1)main函数栈帧中EBP值的由来。用C语言编写的控制台程序都是以一个main函数作为用户代码的入口。但实际上,程序的执行过程如图3-4所示,首先,操作系统加载系统动态链接库kernel32.dll,由kernel32.dll中的一个间接调用函数来运行用户程序。这里还要说明的是,用户程序在编译时已经由编译器把C运行时库(C Run-time Library)的代码合并到一起了,所编译出的程序里肯定有一个mainCRTStartup函数,然后由其调用用户程序中的main函数。因此,main函数栈帧中EBP的值就是调用main函数的栈帧底部指针的EBP。

图3-3 示例程序main函数栈帧的情况

2)接着存储的是main函数的局部变量。这里要注意内存中数据的存储方式与数据值的关系。Win32系统内存中,由低位向高位存储一个4字节的双字,但作为实际数值时,是按由高向低字节进行解释的。例如,变量t1在内存中的存储为0x11110000,其实际数值应为0x00001111。

图3-4 C语言控制台程序调用过程

3)栈帧中ESP值的确定。ESP的值会随着局部变量、被调用函数参数等值的压入而减少,系统还会将ESP减去一定的值,为被调用函数执行时的栈帧预留一定大小的空间。

知识拓展:C运行时库(C Run-time Library)

运行时库是程序在运行时所需要的库文件,通常运行时库是以LIB或DLL形式提供的。C运行时库诞生于20世纪70年代。

C运行时库除了为人们提供必要的库函数调用(如memcpy、printf、malloc等)外,另一个重要的功能是为应用程序添加启动函数。

C运行时库启动函数的主要功能是进行程序的初始化,对全局变量赋初值,加载用户程序的入口函数,如控制台程序的入口点为mainCRTStartup(void)。

到了C++里,有了另外一个概念——C++标准库(C++ Standard Library),它包括了C运行时库和标准模板库(Standard Template Library,STL)。

(2)调用函数fun时main函数栈帧的变化情况

当调用函数fun时,根据上述步骤,首先将被调函数参数n和m的值压入主调函数main的栈帧(ESP=ESP-8),再将主调函数返回地址RET(0x00401093)压入main函数栈帧(ESP=ESP-4),如图3-5所示,此时当前栈帧仍为main函数栈帧,图中阴影部分表示当前栈帧。

(3)函数fun执行被调用时的栈帧

进程跳转至被调用函数的入口,同时将当前EBP(0x0018FF48)入栈(ESP=ESP-4),再将当前ESP的值赋给EBP,使当前栈帧调整为被调用函数fun的栈帧,如图3-6所示,图中阴影部分表示当前函数fun的栈帧。系统自动设置新的ESP值,为被调用函数的执行预留一定大小的空间。

图3-5 调用函数fun时main函数栈帧的情况

图3-6 当前栈帧为被调用函数fun栈帧的情况

(4)被调用函数fun执行结束时栈帧的变化

被调用函数fun执行结束时,函数返回值存入寄存器EAX中(EAX=0x00003333),同时弹出该函数的栈帧,将EBP指向的值(0x0018FF48)弹出存入EBP寄存器,以恢复主调函数栈帧底部。然后弹出返回地址RET(0x00401093),存入EIP中,如图3-7所示,图中阴影部分表示当前栈帧。

最后弹出被调函数的两个参数(ESP=ESP+8),将当前栈帧恢复到调用fun函数前主调函数main的栈帧状态,如图3-8所示,图中阴影部分表示当前栈帧。

通过【例3-1】了解了在函数调用时内存栈区的数据分布情况。在对这些知识理解的基础上,下面开始介绍栈溢出漏洞的原理和利用方法。

图3-7 被调用函数fun执行结束时栈帧的情况

图3-8 被调用函数执行结束后栈帧的情况

3.栈溢出漏洞基本原理

在函数的栈帧中,局部变量是顺序排列的,局部变量下面紧跟着的是前栈帧EBP及函数返回地址RET。如果这些局部变量为数组,由于存在越界的漏洞,那么越界的数组元素将会覆盖相邻的局部变量,甚至覆盖前栈帧EBP及函数返回地址RET,从而造成程序的异常。

下面通过两个例子来说明缓冲区溢出的两种情况:修改相邻变量和修改返回地址。

(1)栈溢出修改相邻变量

【例3-2】修改相邻变量

fun()函数实现了一个基于口令认证的功能:用户输入的口令存放在局部变量str数组中,然后程序将其与预设在局部变量password中的口令进行比较,以得出是否通过认证的判断(此处仅为示例,并非实际采用的方法)。图3-9所示为程序执行时,用户输入了正确口令ABCDE后内存的状态。注意:数组大小为6,字符串结束字符“\0”占1个字节,因此口令应当为5个字节,图中阴影部分表示当前栈帧。

图3-9 程序执行时用户输入正确口令后内存的状态

从图3-9中可以看出,内存分配是按字节对齐的,因此根据变量定义的顺序,在函数栈帧中首先分配2个字节给password数组,然后再分配2个字节给str数组。

由于C语言中没有数组越界检查,因此,当用户输入的口令超过2个字节时,将会覆盖紧邻的password数组。如图3-10所示,当用户输入13个字符“aaaaaaaaaaaaa”时,password数组中的内容将被覆盖。此时,password数组和str数组的内容就是同一个字符串“aaaaa”,从而比较结果为二者相等。因此,在不知道正确口令的情况下,只要输入13个字符,其中前5个字符与后5个字符相同,就可以绕过口令的验证了。

图3-10 用户输入13个字符“aaaaaaaaaaaaa”后内存的状态

如果用户增加输入字符串的长度,将会超过password数组的边界,从而覆盖前栈帧EBP,甚至是覆盖返回地址RET。当返回地址RET被覆盖后,将会造成进程执行跳转的异常。图3-11给出了当用户输入23个字符“aaaabbbbccccddddeeeefff”后内存的状态,出于对4字节对齐的考虑,输入时按4个相同字符一组进行组织,图中阴影部分表示当前栈帧。

图3-11 用户输入23个字符“aaaabbbbccccddddeeeefff”后内存的状态

显然,在EBP+4所指空间本应该存放返回地址RET,但现在已经被覆盖成了字符串“fff”。当程序进一步执行,返回主调函数main时,弹出该空间的值,作为返回地址存入EIP中,即EIP=0x00666666。此时,CPU将按照EIP给出的地址去取指令,由于内存0x00666666处没有合法指令可执行,因此程序报错,如图3-12所示。

图3-12 程序报错

(2)栈溢出后修改返回地址RET

栈溢出后修改相邻变量这种漏洞利用对代码环境的要求比较苛刻。更常用的栈溢出修改的目标往往不是某个变量,而是栈帧中的EBP和函数返回地址RET等值。

接下来【例3-3】演示的是,将一个有效指令地址写入返回地址区域中,这样就可以让CPU跳转到所希望执行的指令处,从而达到控制程序执行流程的目的。

【例3-3】修改返回地址

由于从键盘上输入的字符必须是可打印字符,而将一个有效地址作为ASCII对应的字符不一定能打印,如0x011。因此,将【例3-2】程序稍作修改,程序输入改为读取密码文件password.txt。

如果在password.txt文件中,存入23个字符“aaaabbbbccccddddeeeefff”,程序运行的效果将与从键盘输入一致。为了让程序在调用fun()函数返回后,去执行所希望的函数Attack(),必须将password.txt文件中的最后4个字节改为Attack()函数的入口地址0x0040100F(得到函数的入口地址的方法有很多,可以利用OllyDbg工具查看)。利用十六进制编辑软件UltraEdit打开password.txt文件,将最后4个字节改为相应的地址即可,如图3-13所示。

图3-13 利用UltraEdit修改password.txt文件中最后4个字节

函数调用返回时,从栈帧中弹出返回地址,存入EIP中。从图3-14a中可以看到,此时EIP=0x0040100F,CPU按该地址去获取指令,跳转到Attack()函数。图3-14b显示的程序运行结果表明,函数Attack()被正常执行了,说明溢出修改返回地址成功。

图3-14 溢出覆盖返回地址

4.栈溢出攻击

上面介绍了用户进程能够修改相邻变量和修改返回地址两种缓冲区溢出漏洞。实际攻击中,攻击者通过缓冲区溢出改写的目标往往不是某一个变量,而是栈帧高地址的EBP和函数的返回地址等值。通过覆盖程序中的函数返回地址和函数指针等值,攻击者可以直接将程序跳转到其预先设定或已经注入到目标程序的代码上去执行。

栈溢出攻击是一种利用栈溢出漏洞所进行的攻击行动,目的在于扰乱具有某些特权运行的程序的功能,使得攻击者取得程序的控制权,如果该程序具有足够的权限,那么整个主机就被控制了。

下面介绍两种栈溢出攻击的基本原理。

(1)JMP ESP覆盖方法

上述【例3-3】演示了代码植入攻击的基本方法。实际攻击者会向被攻击的程序中输入一个包含Shellcode的字符串,让被攻击程序转而执行Shellcode。不过,在实际的漏洞利用中,由于动态链接库的装入和卸载等原因,Windows进程的函数栈帧可能发生移位,即Shellcode在内存中的地址是动态变化的,所以这种采用直接赋地址值的简单方式在以后的运行过程中会出现跳转异常。

为了避免这种情况的发生,可以在覆盖返回地址时用系统动态链接库中某条处于高地址且位置固定的跳转指令所在的地址进行覆盖,然后再通过这条跳转指令指向动态变化的Shellcode地址。这样便能够确保程序执行流程在目标系统运行时可以被如期进行。

JMP ESP覆盖方法是覆盖函数返回地址的一种攻击方式。考虑到函数返回时ESP总是指向函数返回后的下一条指令,根据这一特点,如果用指令JMP ESP的地址覆盖返回地址,则函数也可以跳转到函数返回后的下一条指令,而从函数返回后的下一条指令开始都已经被Shellcode所覆盖,那么程序就可以跳转到该Shellcode上并执行,从而实现了程序流程的控制。

在这种方法中,输入被攻击程序的字符串格式为:NN…NNRSS…SS,其中N(Nop)表示可填充一些无关的数据,R表示JMP ESP的地址,S表示Shellcode。这一格式的字符串输入后,栈帧的状态如图3-15所示,图中阴影部分表示当前栈帧。

当函数返回时,取出JMP ESP后,ESP向高地址移4个字节,正好指向Shellcode,而此时EIP指向JMP ESP指令,所以程序就转去执行Shellcode了。

为了能成功地进行JMP ESP覆盖法攻击,攻击者需要做如下工作。

1)分析调试有漏洞的被攻击程序,获得栈帧的状态。

2)获得本机JMP ESP指令的地址。

3)写出希望运行的Shellcode。

4)根据栈帧状态,构造输入字符串。

在内存中搜索JMP ESP指令是比较容易的(可以通过Ollydbg软件在内存中搜索)。一般选择kernel32.dll或者user32.dll中的地址。

(2)SEH覆盖方法

Windows平台下,操作系统或应用程序运行时,为了保证在出现除零、非法内存访问等错误时,系统也能正常运行而不至于崩溃或宕机,Windows会对运行在其中的程序提供一次补救的机会来处理错误,这种机制就是Windows下的异常处理机制。异常处理就是在程序出错后,系统关闭之前,让程序转去执行一个预设的回调函数(异常处理函数)。

异常处理机制的一个重要数据结构是位于系统栈中的异常处理结构体(Struct Exception Handler,SHE),它包含两个DWORD指针。

●SHE链表指针prev,用于指向下一个SEH结构。

●异常处理函数句柄handler,用于指向异常处理函数的指针。

当线程初始化时,会自动向栈中安装一个异常处理结构,作为线程默认的异常处理。这样,多个异常处理程序就连接成了一个由栈顶向栈底延伸的单链表,链表头部位置通过TEB(Thread Environment Block,线程控制块)0字节偏移处的指针标识。如图3-16所示,当发生异常时,操作系统会中断程序,并首先从TEB的0字节偏移处取出最顶端的SEH结构地址,使用异常处理函数句柄所指向的代码来处理异常。如果该异常处理函数运行失败,则顺着SEH链表依次尝试其他的异常处理函数。如果程序预先安装的所有异常处理函数均无法处理,系统将采用默认的异常处理函数,弹出错误对话框并强制关闭程序。

图3-15 使用JMP ESP覆盖方法跳转到Shellcode执行

图3-16 SEM链表结构

SEH覆盖方法就是覆盖异常处理程序地址的一种攻击方式。由于SHE结构存放在栈中,因此攻击者可以利用栈溢出漏洞,设计特定的溢出数据,将SEH中异常函数的入口地址覆盖为Shellcode的起始地址或可以跳转到Shellcode的跳转指令地址,从而导致程序发生异常时,Windows异常处理机制执行的不是预设的异常处理函数,而是Shellcode。

3.1.3 堆溢出漏洞及利用分析

上一节介绍了栈溢出的原理及其利用技术。另一种基于堆溢出的攻击逐渐成为主流,相对于栈溢出攻击,这类攻击更难防范。本节首先介绍堆的相关知识,然后介绍堆溢出漏洞及利用技术。

1.堆的基本知识

(1)堆与栈的区别

程序在执行时需要两种不同类型的内存来协同配合,即如图3-1中所示的栈和堆。

典型的栈变量包括函数内部的普通变量、数组等。栈变量在使用时不需要额外的申请操作,系统栈会根据函数中的变量声明自动在函数栈帧中给其预留空间。栈空间由系统维护,它的分配和回收都是由系统来完成的,最终达到栈平衡,所有这些对程序员来说都是透明的。

另一种类型的内存结构是堆,主要具备以下特性。

●堆是一种在程序运行时动态分配的内存。所谓动态,是指所需内存的大小在程序设计时不能预先确定或内存过大无法在栈中进行分配,需要在程序运行时参考用户的反馈。

●堆在使用时需要程序员使用专有的函数进行申请,如C语言中的malloc等函数,C++中的new运算符等都是最常见的分配堆内存的方法。堆内存申请有可能成功,也有可能失败,这与申请内存的大小、机器性能和当前运行环境有关。

●一般用一个堆指针来使用申请得到的内存,读、写、释放都通过这个指针来完成。

●堆使用完毕后需要将堆指针传给堆释放函数以回收这片内存,否则会造成内存泄露。典型的释放方法包括free、delete等。

堆内存和栈内存的特点比较如表3-1所示。

表3-1 堆内存和栈内存的比较

(2)堆的结构

现代操作系统中堆的数据结构一般包括堆块和堆表两类。

1)堆块:出于性能的考虑,堆区的内存按不同大小组织成块,以堆块为单位进行标识,而不是传统的按字节标识。一个堆块包括两个部分:块首和块身。块首是一个堆块头部的几个字节,用来标识这个堆块自身的信息,例如,本块的大小,本块是空闲还是占用等信息;块身是紧跟在块首后面的部分,也是最终分配给用户使用的数据区。

堆的内存组织如图3-17所示。

堆区和堆块的分配都由程序员来完成。对一个堆块而言,被分配之后,如果不被合并,那么会有两种状态:占有态和空闲态。其中,空闲态的堆块会被链入空链表中,由系统管理。而占有态的堆块会返回一个由程序员定义的句柄,由程序员管理。

堆块被分为两部分:块首和块身。其中块首是一个8字节的数据,存放着堆块的信息。指向堆块的指针或者句柄指向的是块身的首地址。

图3-17 堆在内存中的组织

2)堆表:堆表一般位于堆区的起始位置,用于索引堆区中所有堆块的重要信息,包括堆块的位置、堆块的大小、空闲还是占用等。堆表的数据结构决定了整个堆区的组织方式,是快速检索空闲块、保证堆分配效率的关键。堆表在设计时可能会考虑采用平衡二叉树等高级数据结构来优化查找效率。现代操作系统的堆表往往不止一种数据结构。

在Windows中,占用态的堆块被使用它的程序索引,而堆表只索引所有空闲块的堆块。其中最重要的堆表有两种:空闲双向链表freelist(简称空表)和快速单向链表lookaside(简称快表)。

①空表。

空闲堆的块首中包含一对重要的指针:前向指针(flink)和后向指针(blink),用于将空闲堆块组织成双向链表。空闲双向表中的结点结构如图3-18所示。

图3-18 空闲双向表结点结构

堆区一开始的堆表区中有一个128项的指针数组,称为空表索引(freelistarray)。该数组的每一项包括两个指针,用于表示一个空表。

图3-19中,空表索引的第二项free[1]标识了堆中所有大小为8字节的空闲堆块,之后每个索引项指示的空闲块递增8字节,例如,free[2]标识大小为16字节的空闲堆块,free[3]标识大小为24字节的空闲堆块,free[127]标识大小为1016字节的空闲堆块。因此有:

空闲堆块的大小=索引项×8(字节)

其中,空表索引项的第一项free[0]标识的空表比较特殊。这条双向链表链入了所有大于等于1024字节并且小于512K字节的堆块。这些堆块按照各自的大小在零号空表中按升序依次排列。

图3-19 空闲双向链表

②快表。

快表是Windows用来加速堆块分配而采用的一种堆表。之所以把它称为“快表”,是因为这类单向链表中从来不会发生堆块合并(其中的空闲块块首被设置为占有态,用来防止堆块合并)。

快表也有128条,组织结构与空表类似,只是其中的堆块按照单链表组织。快表总是被初始化为空,而且每条快表最多只有4个结点,故很快就会被填满。由于在堆溢出中一般不利用快表,故不作详述。

2.堆溢出漏洞及利用

堆管理系统的3类操作:堆块分配、堆块释放和堆块合并,归根到底都是对空链表的修改。分配就是将堆块从空表中“卸下”;释放就是把堆块“链入”空表;合并可以看成是把若干块先从空表中“卸下”,修改块首信息,然后把更新后的块“链入”空表。所有“卸下”和“链入”堆块的工作都发生在链表中,如果能够修改链表结点的指针,在“卸下”和“链入”的过程中就有可能获得一次读写内存的机会。

(1)DWORD Shoot

堆溢出利用的精髓就是用精心构造的数据去溢出覆盖下一个堆块的块首,使其改写块首中的前向指针(flink)和后向指针(blink),然后在分配、释放和合并等操作发生时伺机获得一次向内存任意地址写入任意数据的机会。这种能够向内存任意位置写任意数据的机会称为Arbitrary Dword Reset(又称Dword Shoot)。Arbitrary Dword Reset发生时,不但可以控制射击的目标(任意地址),还可以选用适当的目标数据(4字节恶意数据)。通过Arbitrary Dword Reset,攻击者可以进而劫持进程,运行shellcode。

下面先简单分析一下空表中结点的正常拆卸操作。

根据链表操作的常识,可以了解到,拆卸时发生如下操作。

图3-20所示为“卸掉”空闲双向链表中阴影结点的操作。

图3-20 空闲双向链表中结点的拆卸

如果用精心构造的数据淹没该结点块身的前8个字节,即该堆块的前向指针和后向指针时,在flink里面放入4字节的任意恶意数据内容,在blink里面放入目标地址;若该结点被拆卸,执行node->blink->flink=node->flink操作时,对于node->blink->flink,系统会认为node->blink指向的是一个堆块的块身,而flink正是这个块身的第一个4字节单元,而node->flink为node的前4字节,因此该拆卸操作导致目标地址的内容被修改为该4字节的恶意数据。通过这种构造可以实现对任意地址的4字节(DWORD)数据的任意操作。

图3-21所示为上述过程的图示。

图3-21 DWORD Shoot攻击原理

(2)Heap Spray

Heap spray技术是使用栈溢出和堆结合的一种技术,这种技术可以在很大程度上解决溢出攻击在不同版本上的不兼容问题,并且可以减少对栈的破坏。缺陷在于只能在浏览器相关溢出中使用,但是相关思想却被广泛应用于其他类型攻击中,如JIT spray、ActivexSpray等。

这种技术的关键在于,首先将shellcode放置到堆中,然后在栈溢出时,控制函数执行流程,跳转到堆中执行shellcode。

在一次漏洞利用过程中,关键是用传入的shellcode所在的位置去覆盖EIP。在实际攻击中,用什么值覆盖EIP是可控的,但是这个值指向的地址是否有shellcode就很关键了。假设“地址A”表示shellcode的起始地址,“地址B”表示在缓冲区溢出中用于覆盖的函数返回地址或者函数指针的值。因此如果B<A,而地址B到地址A之间如果有诸如nop这样的不改变程序状态的指令,那么在执行完B到A间的这些指令后,就可以继续执行shellcode。

Heap spray应用环境一般是浏览器,因为在这种环境下,内存布局比较困难,想要跳转到某个固定位置几乎不可能,即使使用jmp esp等间接跳转有时也不太可靠。因此,Heap Spray技术应运而生。

3.1.4 格式化字符串漏洞及利用分析

格式化字符串(简称格式化串)的漏洞本身并不算缓冲区溢出漏洞,这里作为一类比较典型的系统函数存在的漏洞做一介绍。

1.格式化串漏洞

格式化串漏洞的产生源于数据输出函数中对输出格式解析的缺陷,其根源也是C语言中不对数组边界进行检查的缓冲区错误。

下面以printf函数为例进行介绍。

格式控制format中的内容可能为“%s,%d,%p,%x,%n,…”等格式控制符,用于将数据格式化后输出。

printf函数进行格式化输出时,会根据格式化串中的格式化控制符在栈上取相应的参数,然后按照所需格式输出。因此,这里存在的漏洞是,如果函数调用给出的输出数据列表少于格式控制符个数,甚至没有给出输出数据列表,系统仍然会按照格式化串中格式化控制符的个数输出栈中的数据。

【例3-4】分析下面的程序

对于上述代码,第一个printf函数调用时,3个参数按照从右到左的顺序,即b、a、"a=%d,b=%d\n"的顺序入栈,栈中状态如图3-22所示,输出结果正常,显示:

第二次调用printf函数没有引起编译错误,程序正常执行,只是输出的数据出乎预料。一种可能的运行结果为:

这是因为,第二次调用的printf函数参数中缺少了输出数据列表部分,故只压入格式控制符参数,这时栈中状态如图3-23所示。当在栈上取与格式化控制符%d和%d对应的两个变量输出时,错误地把栈上其他数据当作a、b的值进行了输出。

格式符除了常见的d、f、u、o、x之外,还有一些指针型的格式符。

●s:参数对应的是指向字符串的指针。

●n:这个参数对应的是一个整数型指针,将这个参数之前输出的字符的个数写入该格式符对应参数指向的地址中。例如,对于如下代码:

图3-22 printf函数调用时的内存布局

图3-23 格式化漏洞原理

格式化串中指定了%n,此前输出了1~0这10个字符,因此会将10写入变量a中。

类似地,利用%p、%s和%n等格式符精心构造格式化串即可实现对程序内数据的任意读、任意写,从而造成信息泄露、数据篡改和程序流程的非法控制等威胁。

除了printf函数之外,该系列中的其他函数也可能产生格式化串漏洞,如fprintf、sprintf、snprintf、vprintf、vfprintf、vsprintf和wprintf等。

2.格式化串漏洞利用

格式化串漏洞的利用可以通过以下方法实现。

1)通过改变格式化串中输出参数的个数实现修改指定地址的值:可以修改填充字符串长度实现;也可以通过改变输出的宽度实现,如%8d。

2)通过改变格式化串中格式符的个数,调整格式符对应参数在栈中的位置,从而实现对栈中特定位置数据的修改。如果恰当地修改栈中函数的返回地址,那么就有可能实现程序执行流程的控制。也可以修改其他函数指针,改变执行流程。相对于修改返回地址,改写指向异常处理程序的指针,然后引起异常,这种方法猜测地址的难度比较小,成功率较高。

下面通过两个例子说明格式化串漏洞利用的基本原理。

【例3-5】利用格式化串漏洞读内存数据

在Windows XP SP2操作系统下,运行Visual C++6.0编译器,生成release版本的可执行文件。当向程序中传入普通字符串(如“Buffer Overflow”)时,将输出该字符串中的第一个单词。但如果传入的字符串中带有格式控制符时,printf就会打印出栈中的数据。例如,输入“%p,%p,%p,…”可以读出栈中的数据(%p控制以十六进制整数方式输出指针的值),如图3-24所示。

【例3-5】演示的利用格式化串读内存数据还不算很糟糕,如果配合修改内存数据,就有可能引起进程劫持和shellcode植入了。

图3-24 利用格式化串漏洞读内存

在格式化控制符中,有一种较少用到的控制符%n。这个控制符用于把当前输出的所有字符的个数值写回一个变量中去。下面【例3-6】中的代码展示了这种方法。

【例3-6】用printf向内存写数据

上述代码在Windows 7操作系统下,运行Visual C++6.0编译器。当程序执行第1条语句后,内存布局如图3-25所示,注意变量num的地址为0x0018FF44。

图3-25 执行第1条语句后的内存布局

程序执行第2条语句(第1条printf语句)后,内存布局如图3-26所示。注意,参数从右向左依次压栈。

图3-26 执行第1条printf语句后的内存布局示意图

执行第3条语句(第2条printf语句)后,参数压栈之后,内存布局如图3-27所示。

当执行第3条printf语句后,变量num的值已经变成了0x00000014(对应十进制值为20),如图3-28所示。这是因为程序中将变量num的地址压入栈,作为第2条printf()的第2个参数,“%n”会将打印总长度保存到对应参数的地址中去,打印结果如图3-29所示。0x61616161的十进制值为1633771873,按照“%.20d”格式输出,其长度为20。

图3-27 执行第2条printf语句后的内存布局示意图

图3-28 执行第3条printf语句后的内存布局

图3-29 输出结果

如果不将num的地址压入堆栈,如下面的程序所示。

运行结果如图3-30所示。

图3-30 运行结果

程序在执行第2条printf()语句时发生错误,printf()将堆栈中main()函数的变量num当作了%n所对应的参数,而0x61616161肯定是不能访问的。

格式化串漏洞是一类真实存在、危害较大的漏洞,但是相对于栈溢出等漏洞而言,实际案例并不多。并且格式化串漏洞的形成原因较为简单,只要通过静态扫描等方法,就可以发现这类漏洞。此外,在Visual Studio 2005以上版本中的编译级别对参数进行了检查,且默认情况下关闭了对%n控制符的使用。

拓展阅读

读者要想了解更多缓冲区溢出攻击方法,可以阅读以下书籍资料。

[1]王清.0 day安全:软件漏洞分析技术[M].2版.北京:电子工业出版社,2011.

[2]ChrisAnley,等.黑客攻防技术宝典:系统实战篇[M].2版.北京:人民邮电出版社,2010.

[3]林桠泉.漏洞战争:软件漏洞分析精要[M].北京:电子工业出版社,2016.

[4]吴世忠,郭涛,董国伟.软件漏洞分析技术[M].北京:科学出版社,2014. BrpeBTj1J6q8ucKvHnmwjws3837vQZBQs+8PTVDFSOftCVsmyCLaqxWDsr3xmuY8

点击中间区域
呼出菜单
上一章
目录
下一章
×