缓冲区溢出与注入分析

前言

虽然程序加载以及动态符号链接都已经很理解了,但是这伙却被进程的内存映像给”纠缠”住。看着看着就一发不可收拾——很有趣。

下面一起来探究“缓冲区溢出和注入”问题(主要是关心程序的内存映像)。

进程的内存映像

永远的 Hello World,太熟悉了吧,

  1. #include <stdio.h>
  2. int main(void)
  3. {
  4. printf("Hello World\n");
  5. return 0;
  6. }

如果要用内联汇编(inline assembly)来写呢?

  1. 1 /* shellcode.c */
  2. 2 void main()
  3. 3 {
  4. 4 __asm__ __volatile__("jmp forward;"
  5. 5 "backward:"
  6. 6 "popl %esi;"
  7. 7 "movl $4, %eax;"
  8. 8 "movl $2, %ebx;"
  9. 9 "movl %esi, %ecx;"
  10. 10 "movl $12, %edx;"
  11. 11 "int $0x80;" /* system call 1 */
  12. 12 "movl $1, %eax;"
  13. 13 "movl $0, %ebx;"
  14. 14 "int $0x80;" /* system call 2 */
  15. 15 "forward:"
  16. 16 "call backward;"
  17. 17 ".string \"Hello World\\n\";");
  18. 18 }

看起来很复杂,实际上就做了一个事情,往终端上写了个 Hello World 。不过这个非常有意思。先简单分析一下流程:

  • 第 4 行指令的作用是跳转到第 15 行(即 forward 标记处),接着执行第 16 行。
  • 第 16 行调用 backward,跳转到第 5 行,接着执行 6 到 14 行。
  • 第 6 行到第 11 行负责在终端打印出 Hello World 字符串(等一下详细介绍)。
  • 第 12 行到第 14 行退出程序(等一下详细介绍)。

为了更好的理解上面的代码和后续的分析,先来介绍几个比较重要的内容。

常用寄存器初识

X86 处理器平台有三个常用寄存器:程序指令指针、程序堆栈指针与程序基指针:

寄存器 名称 注释
EIP 程序指令指针 通常指向下一条指令的位置
ESP 程序堆栈指针 通常指向当前堆栈的当前位置
EBP 程序基指针 通常指向函数使用的堆栈顶端

当然,上面都是扩展的寄存器,用于 32 位系统,对应的 16 系统为 ipspbp

call,ret 指令的作用分析

  • call 指令

    跳转到某个位置,并在之前把下一条指令的地址(EIP)入栈(为了方便”程序“返回以后能够接着执行)。这样的话就有:

    1. call backward ==> push eip
    2. jmp backward
  • ret 指令

    通常 call 指令和 ret 是配合使用的,前者压入跳转前的下一条指令地址,后者弹出 call 指令压入的那条指令,从而可以在函数调用结束以后接着执行后面的指令。

    1. ret ==> pop eip

通常在函数调用后,还需要恢复 espebp,恢复 esp 即恢复当前栈指针,以便释放调用函数时为存储函数的局部变量而自动分配的空间;恢复 ebp 是从栈中弹出一个数据项(通常函数调用过后的第一条语句就是 push ebp),从而恢复当前的函数指针为函数调用者本身。这两个动作可以通过一条 leave 指令完成。

这三个指令对我们后续的解释会很有帮助。更多关于 Intel 的指令集,请参考:Intel 386 Manual, x86 Assembly Language FAQ:part1, part2, part3.

什么是系统调用(以 Linux 2.6.21 版本和 x86 平台为例)

系统调用是用户和内核之间的接口,用户如果想写程序,很多时候直接调用了 C 库,并没有关心系统调用,而实际上 C 库也是基于系统调用的。这样应用程序和内核之间就可以通过系统调用联系起来。它们分别处于操作系统的用户空间和内核空间(主要是内存地址空间的隔离)。

  1. 用户空间 应用程序(Applications)
  2. | |
  3. | C库(如glibc
  4. | |
  5. 系统调用(System Calls,如sys_read, sys_write, sys_exit)
  6. |
  7. 内核空间 内核(Kernel)

系统调用实际上也是一些函数,它们被定义在 arch/i386/kernel/sys_i386.c (老的在 arch/i386/kernel/sys.c)文件中,并且通过一张系统调用表组织,该表在内核启动时就已经加载了,这个表的入口在内核源代码的 arch/i386/kernel/syscall_table.S 里头(老的在 arch/i386/kernel/entry.S)。这样,如果想添加一个新的系统调用,修改上面两个内核中的文件,并重新编译内核就可以。当然,如果要在应用程序中使用它们,还得把它写到 include/asm/unistd.h 中。

如果要在 C 语言中使用某个系统调用,需要包含头文件 /usr/include/asm/unistd.h,里头有各个系统调用的声明以及系统调用号(对应于调用表的入口,即在调用表中的索引,为方便查找调用表而设立的)。如果是自己定义的新系统调用,可能还要在开头用宏 _syscall(type, name, type1, name1...)来声明好参数。

如果要在汇编语言中使用,需要用到 int 0x80 调用,这个是系统调用的中断入口。涉及到传送参数的寄存器有这么几个,eax 是系统调用号(可以到 /usr/include/asm-i386/unistd.h 或者直接到 arch/i386/kernel/syscall_table.S 查到),其他寄存器如 ebxecxedxesiedi 一次存放系统调用的参数。而系统调用的返回值存放在 eax 寄存器中。

下面我们就很容易解释前面的 Shellcode.c 程序流程的 2,3 两部分了。因为都用了 int 0x80 中断,所以都用到了系统调用。

第 3 部分很简单,用到的系统调用号是 1,通过查表(查 /usr/include/asm-i386/unistd.harch/i386/kernel/syscall_table.S)可以发现这里是 sys_exit 调用,再从 /usr/include/unistd.h 文件看这个系统调用的声明,发现参数 ebx 是程序退出状态。

第 2 部分比较有趣,而且复杂一点。我们依次来看各个寄存器,首先根据 eax 为 4 确定(同样查表)系统调用为 sys_write,而查看它的声明(从 /usr/include/unistd.h),我们找到了参数依次为文件描述符、字符串指针和字符串长度。

  • 第一个参数是 ebx,正好是 2,即标准错误输出,默认为终端。
  • 第二个参数是 ecx,而 ecx 的内容来自 esiesi 来自刚弹出栈的值(见第 6 行 popl %esi;),而之前刚好有 call 指令引起了最近一次压栈操作,入栈的内容刚好是 call 指令的下一条指令的地址,即 .string 所在行的地址,这样 ecx 刚好引用了 Hello World\\n 字符串的地址。
  • 第三个参数是 edx,刚好是 12,即 Hello World\\n 字符串的长度(包括一个空字符)。这样,Shellcode.c 的执行流程就很清楚了,第 4,5,15,16 行指令的巧妙之处也就容易理解了(把 .string 存放在 call 指令之后,并用 popl 指令把 eip 弹出当作字符串的入口)。

什么是 ELF 文件

这里的 ELF 不是“精灵”,而是 Executable and Linking Format 文件,是 Linux 下用来做目标文件、可执行文件和共享库的一种文件格式,它有专门的标准,例如:X86 ELF format and ABI中文版

下面简单描述 ELF 的格式。

ELF 文件主要有三种,分别是:

  • 可重定位的目标文件,在编译时用 gcc-c 参数时产生。
  • 可执行文件,这类文件就是我们后面要讨论的可以执行的文件。
  • 共享库,这里主要是动态共享库,而静态共享库则是可重定位的目标文件通过 ar 命令组织的。

ELF 文件的大体结构:

  1. ELF Header #程序头,有该文件的Magic number(参考man magic),类型等
  2. Program Header Table #对可执行文件和共享库有效,它描述下面各个节(section)组成的段
  3. Section1
  4. Section2
  5. Section3
  6. .....
  7. Program Section Table #仅对可重定位目标文件和静态库有效,用于描述各个Section的重定位信息等。

对于可执行文件,文件最后的 Program Section Table (节区表)和一些非重定位的 Section,比如 .comment.note.XXX.debug 等信息都可以删除掉,不过如果用 stripobjcopy 等工具删除掉以后,就不可恢复了。因为这些信息对程序的运行一般没有任何用处。

ELF 文件的主要节区(section)有 .data.text.bss.interp 等,而主要段(segment)有 LOADINTERP 等。它们之间(节区和段)的主要对应关系如下:

Section 解释 实例
.data 初始化的数据 比如 int a=10
.bss 未初始化的数据 比如 char sum[100]; 这个在程序执行之前,内核将初始化为 0
.text 程序代码正文 即可执行指令集
.interp 描述程序需要的解释器(动态连接和装载程序) 存有解释器的全路径,如 /lib/ld-linux.so

而程序在执行以后,.data.bss.text 等一些节区会被 Program header table 映射到 LOAD 段,.interp 则被映射到了 INTERP 段。

对于 ELF 文件的分析,建议使用 filesizereadelfobjdumpstripobjcopygdbnm 等工具。

这里简单地演示这几个工具:

  1. $ gcc -g -o shellcode shellcode.c #如果要用gdb调试,编译时加上-g是必须的
  2. shellcode.c: In function main’:
  3. shellcode.c:3: warning: return type of main is not int
  4. f$ file shellcode #file命令查看文件类型,想了解工作原理,可man magic,man file
  5. shellcode: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV),
  6. dynamically linked (uses shared libs), not stripped
  7. $ readelf -l shellcode #列出ELF文件前面的program head table,后面是它描
  8. #述了各个段(segment)和节区(section)的关系,即各个段包含哪些节区。
  9. Elf file type is EXEC (Executable file)
  10. Entry point 0x8048280
  11. There are 7 program headers, starting at offset 52
  12. Program Headers:
  13. Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
  14. PHDR 0x000034 0x08048034 0x08048034 0x000e0 0x000e0 R E 0x4
  15. INTERP 0x000114 0x08048114 0x08048114 0x00013 0x00013 R 0x1
  16. [Requesting program interpreter: /lib/ld-linux.so.2]
  17. LOAD 0x000000 0x08048000 0x08048000 0x0044c 0x0044c R E 0x1000
  18. LOAD 0x00044c 0x0804944c 0x0804944c 0x00100 0x00104 RW 0x1000
  19. DYNAMIC 0x000460 0x08049460 0x08049460 0x000c8 0x000c8 RW 0x4
  20. NOTE 0x000128 0x08048128 0x08048128 0x00020 0x00020 R 0x4
  21. GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
  22. Section to Segment mapping:
  23. Segment Sections...
  24. 00
  25. 01 .interp
  26. 02 .interp .note.ABI-tag .hash .dynsym .dynstr .gnu.version .gnu.version_r
  27. .rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame
  28. 03 .ctors .dtors .jcr .dynamic .got .got.plt .data .bss
  29. 04 .dynamic
  30. 05 .note.ABI-tag
  31. 06
  32. $ size shellcode #可用size命令查看各个段(对应后面将分析的进程内存映像)的大小
  33. text data bss dec hex filename
  34. 815 256 4 1075 433 shellcode
  35. $ strip -R .note.ABI-tag shellcode #可用strip来给可执行文件“减肥”,删除无用信息
  36. $ size shellcode #“减肥”后效果“明显”,对于嵌入式系统应该有很大的作用
  37. text data bss dec hex filename
  38. 783 256 4 1043 413 shellcode
  39. $ objdump -s -j .interp shellcode #这个主要工作是反编译,不过用来查看各个节区也很厉害
  40. shellcode: file format elf32-i386
  41. Contents of section .interp:
  42. 8048114 2f6c6962 2f6c642d 6c696e75 782e736f /lib/ld-linux.so
  43. 8048124 2e3200 .2.

补充:如果要删除可执行文件的 Program Section Table,可以用 A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux 一文的作者写的 elf kicker 工具链中的 sstrip 工具。

程序执行基本过程

在命令行下,敲入程序的名字或者是全路径,然后按下回车就可以启动程序,这个具体是怎么工作的呢?

首先要再认识一下我们的命令行,命令行是内核和用户之间的接口,它本身也是一个程序。在 Linux 系统启动以后会为每个终端用户建立一个进程执行一个 Shell 解释程序,这个程序解释并执行用户输入的命令,以实现用户和内核之间的接口。这类解释程序有哪些呢?目前 Linux 下比较常用的有 /bin/bash 。那么该程序接收并执行命令的过程是怎么样的呢?

先简单描述一下这个过程:

  • 读取用户由键盘输入的命令行。
  • 分析命令,以命令名作为文件名,并将其它参数改为系统调用 execve 内部处理所要求的形式。
  • 终端进程调用 fork 建立一个子进程。
  • 终端进程本身用系统调用 wait4 来等待子进程完成(如果是后台命令,则不等待)。当子进程运行时调用 execve,子进程根据文件名(即命令名)到目录中查找有关文件(这是命令解释程序构成的文件),将它调入内存,执行这个程序(解释这条命令)。
  • 如果命令末尾有 & 号(后台命令符号),则终端进程不用系统调用 wait4 等待,立即发提示符,让用户输入下一个命令,转 1)。如果命令末尾没有 & 号,则终端进程要一直等待,当子进程(即运行命令的进程)完成处理后终止,向父进程(终端进程)报告,此时终端进程醒来,在做必要的判别等工作后,终端进程发提示符,让用户输入新的命令,重复上述处理过程。

现在用 strace 来跟踪一下程序执行过程中用到的系统调用。

  1. $ strace -f -o strace.out test
  2. $ cat strace.out | grep \(.*\) | sed -e "s#[0-9]* \([a-zA-Z0-9_]*\)(.*).*#\1#g"
  3. execve
  4. brk
  5. access
  6. open
  7. fstat64
  8. mmap2
  9. close
  10. open
  11. read
  12. fstat64
  13. mmap2
  14. mmap2
  15. mmap2
  16. mmap2
  17. close
  18. mmap2
  19. set_thread_area
  20. mprotect
  21. munmap
  22. brk
  23. brk
  24. open
  25. fstat64
  26. mmap2
  27. close
  28. close
  29. close
  30. exit_group

相关的系统调用基本体现了上面的执行过程,需要注意的是,里头还涉及到内存映射(mmap2)等。

下面再罗嗦一些比较有意思的内容,参考《深入理解 Linux 内核》的程序的执行(P681)。

Linux 支持很多不同的可执行文件格式,这些不同的格式是如何解释的呢?平时我们在命令行下敲入一个命令就完了,也没有去管这些细节。实际上 Linux 下有一个 struct linux_binfmt 结构来管理不同的可执行文件类型,这个结构中有对应的可执行文件的处理函数。大概的过程如下:

  • 在用户态执行了 execve 后,引发 int 0x80 中断,进入内核态,执行内核态的相应函数 do_sys_execve,该函数又调用 do_execve 函数。 do_execve 函数读入可执行文件,检查权限,如果没问题,继续读入可执行文件需要的相关信息(struct linux_binprm 描述的)。

  • 接着执行 search_binary_handler,根据可执行文件的类型(由上一步的最后确定),在 linux_binfmt 结构链表(formats,这个链表可以通过 register_binfmtunregister_binfmt 注册和删除某些可执行文件的信息,因此注册新的可执行文件成为可能,后面再介绍)上查找,找到相应的结构,然后执行相应的 load_binary 函数开始加载可执行文件。在该链表的最后一个元素总是对解释脚本(interpreted script)的可执行文件格式进行描述的一个对象。这种格式只定义了 load_binary 方法,其相应的 load_script 函数检查这种可执行文件是否以两个 #! 字符开始,如果是,这个函数就以另一个可执行文件的路径名作为参数解释第一行的其余部分,并把脚本文件名作为参数传递以执行这个脚本(实际上脚本程序把自身的内容当作一个参数传递给了解释程序(如 /bin/bash),而这个解释程序通常在脚本文件的开头用 #! 标记,如果没有标记,那么默认解释程序为当前 SHELL)。

  • 对于 ELF 类型文件,其处理函数是 load_elf_binary,它先读入 ELF 文件的头部,根据头部信息读入各种数据,再次扫描程序段描述表(Program Header Table),找到类型为 PT_LOAD 的段(即 .text.data.bss 等节区),将其映射(elf_map)到内存的固定地址上,如果没有动态连接器的描述段,把返回的入口地址设置成应用程序入口。完成这个功能的是 start_thread,它不启动一个线程,而只是用来修改了 pt_regs 中保存的 PC 等寄存器的值,使其指向加载的应用程序的入口。当内核操作结束,返回用户态时接着就执行应用程序本身了。

  • 如果应用程序使用了动态连接库,内核除了加载指定的可执行文件外,还要把控制权交给动态连接器(ld-linux.so)以便处理动态连接的程序。内核搜寻段表(Program Header Table),找到标记为 PT_INTERP 段中所对应的动态连接器的名称,并使用 load_elf_interp 加载其映像,并把返回的入口地址设置成 load_elf_interp 的返回值,即动态链接器的入口。当 execve 系统调用退出时,动态连接器接着运行,它检查应用程序对共享链接库的依赖性,并在需要时对其加载,对程序的外部引用进行重定位(具体过程见《进程和进程的基本操作》)。然后把控制权交给应用程序,从 ELF 文件头部中定义的程序进入点(用 readelf -h 可以出看到,Entry point address 即是)开始执行。(不过对于非 LIB_BIND_NOW 的共享库装载是在有外部引用请求时才执行的)。

对于内核态的函数调用过程,没有办法通过 strace(它只能跟踪到系统调用层)来做的,因此要想跟踪内核中各个系统调用的执行细节,需要用其他工具。比如可以通过 Ftrace 来跟踪内核具体调用了哪些函数。当然,也可以通过 ctags/cscope/LXR 等工具分析内核的源代码。

Linux 允许自己注册我们自己定义的可执行格式,主要接口是 /procy/sys/fs/binfmt_misc/register,可以往里头写入特定格式的字符串来实现。该字符串格式如下:
:name:type:offset:string:mask:interpreter:

  • name 新格式的标示符
  • type 识别类型(M 表示魔数,E 表示扩展)
  • offset 魔数(magic number,请参考 man magicman file)在文件中的启始偏移量
  • string 以魔数或者以扩展名匹配的字节序列
  • mask 用来屏蔽掉 string 的一些位
  • interpreter 程序解释器的完整路径名

Linux 下程序的内存映像

Linux 下是如何给进程分配内存(这里仅讨论虚拟内存的分配)的呢?可以从 /proc/<pid>/maps 文件中看到个大概。这里的 pid 是进程号。

/proc 下有一个文件比较特殊,是 self,它链接到当前进程的进程号,例如:

  1. $ ls /proc/self -l
  2. lrwxrwxrwx 1 root root 64 2000-01-10 18:26 /proc/self -> 11291/
  3. $ ls /proc/self -l
  4. lrwxrwxrwx 1 root root 64 2000-01-10 18:26 /proc/self -> 11292/

看到没?每次都不一样,这样我们通过 cat /proc/self/maps 就可以看到 cat 程序执行时的内存映像了。

  1. $ cat -n /proc/self/maps
  2. 1 08048000-0804c000 r-xp 00000000 03:01 273716 /bin/cat
  3. 2 0804c000-0804d000 rw-p 00003000 03:01 273716 /bin/cat
  4. 3 0804d000-0806e000 rw-p 0804d000 00:00 0 [heap]
  5. 4 b7b90000-b7d90000 r--p 00000000 03:01 87528 /usr/lib/locale/locale-archive
  6. 5 b7d90000-b7d91000 rw-p b7d90000 00:00 0
  7. 6 b7d91000-b7ecd000 r-xp 00000000 03:01 466875 /lib/libc-2.5.so
  8. 7 b7ecd000-b7ece000 r--p 0013c000 03:01 466875 /lib/libc-2.5.so
  9. 8 b7ece000-b7ed0000 rw-p 0013d000 03:01 466875 /lib/libc-2.5.so
  10. 9 b7ed0000-b7ed4000 rw-p b7ed0000 00:00 0
  11. 10 b7eeb000-b7f06000 r-xp 00000000 03:01 402817 /lib/ld-2.5.so
  12. 11 b7f06000-b7f08000 rw-p 0001b000 03:01 402817 /lib/ld-2.5.so
  13. 12 bfbe3000-bfbf8000 rw-p bfbe3000 00:00 0 [stack]
  14. 13 ffffe000-fffff000 r-xp 00000000 00:00 0 [vdso]

编号是原文件里头没有的,为了说明方便,用 -n 参数加上去的。我们从中可以得到如下信息:

  • 第 1,2 行对应的内存区是我们的程序(包括指令,数据等)
  • 第 3 到 12 行对应的内存区是堆栈段,里头也映像了程序引用的动态连接库
  • 第 13 行是内核空间

总结一下:

  • 前两部分是用户空间,可以从 0x000000000xbfffffff (在测试的 2.6.21.5-smp 上只到 bfbf8000),而内核空间从 0xC00000000xffffffff,分别是 3G1G,所以对于每一个进程来说,共占用 4G 的虚拟内存空间
  • 从程序本身占用的内存,到堆栈段(动态获取内存或者是函数运行过程中用来存储局部变量、参数的空间,前者是 heap,后者是 stack),再到内核空间,地址是从低到高的
  • 栈顶并非 0xC0000000 下的一个固定数值

结合相关资料,可以得到这么一个比较详细的进程内存映像表(以 Linux 2.6.21.5-smp 为例):

地址 内核空间 描述
0xC0000000
(program flie) 程序名 execve 的第一个参数
(environment) 环境变量 execve 的第三个参数,main 的第三个参数
(arguments) 参数 execve 的第二个参数,main 的形参
(stack) 栈 自动变量以及每次函数调用时所需保存的信息都
存放在此,包括函数返回地址、调用者的
环境信息等,函数的参数,局部变量都存放在此
(shared memory) 共享内存 共享内存的大概位置
(heap) 堆 主要在这里进行动态存储分配,比如 malloc,new 等。
.bss (uninitilized data) 没有初始化的数据(全局变量哦)
.data (initilized global data) 已经初始化的全局数据(全局变量)
.text (Executable Instructions) 通常是可执行指令
0x08048000
0x00000000

光看没有任何概念,我们用 gdb 来看看刚才那个简单的程序。

  1. $ gcc -g -o shellcode shellcode.c #要用gdb调试,在编译时需要加-g参数
  2. $ gdb -q ./shellcode
  3. (gdb) set args arg1 arg2 arg3 arg4 #为了测试,设置几个参数
  4. (gdb) l #浏览代码
  5. 1 /* shellcode.c */
  6. 2 void main()
  7. 3 {
  8. 4 __asm__ __volatile__("jmp forward;"
  9. 5 "backward:"
  10. 6 "popl %esi;"
  11. 7 "movl $4, %eax;"
  12. 8 "movl $2, %ebx;"
  13. 9 "movl %esi, %ecx;"
  14. 10 "movl $12, %edx;"
  15. (gdb) break 4 #在汇编入口设置一个断点,让程序运行后停到这里
  16. Breakpoint 1 at 0x8048332: file shellcode.c, line 4.
  17. (gdb) r #运行程序
  18. Starting program: /mnt/hda8/Temp/c/program/shellcode arg1 arg2 arg3 arg4
  19. Breakpoint 1, main () at shellcode.c:4
  20. 4 __asm__ __volatile__("jmp forward;"
  21. (gdb) print $esp #打印当前堆栈指针值,用于查找整个栈的栈顶
  22. $1 = (void *) 0xbffe1584
  23. (gdb) x/100s $esp+4000 #改变后面的4000,不断往更大的空间找
  24. (gdb) x/1s 0xbffe1fd9 #在 0xbffe1fd9 找到了程序名,这里是该次运行时的栈顶
  25. 0xbffe1fd9: "/mnt/hda8/Temp/c/program/shellcode"
  26. (gdb) x/10s 0xbffe17b7 #其他环境变量信息
  27. 0xbffe17b7: "CPLUS_INCLUDE_PATH=/usr/lib/qt/include"
  28. 0xbffe17de: "MANPATH=/usr/local/man:/usr/man:/usr/X11R6/man:/usr/lib/java/man:/usr/share/texmf/man"
  29. 0xbffe1834: "HOSTNAME=falcon.lzu.edu.cn"
  30. 0xbffe184f: "TERM=xterm"
  31. 0xbffe185a: "SSH_CLIENT=219.246.50.235 3099 22"
  32. 0xbffe187c: "QTDIR=/usr/lib/qt"
  33. 0xbffe188e: "SSH_TTY=/dev/pts/0"
  34. 0xbffe18a1: "USER=falcon"
  35. ...
  36. (gdb) x/5s 0xbffe1780 #一些传递给main函数的参数,包括文件名和其他参数
  37. 0xbffe1780: "/mnt/hda8/Temp/c/program/shellcode"
  38. 0xbffe17a3: "arg1"
  39. 0xbffe17a8: "arg2"
  40. 0xbffe17ad: "arg3"
  41. 0xbffe17b2: "arg4"
  42. (gdb) print init #打印init函数的地址,这个是/usr/lib/crti.o里头的函数,做一些初始化操作
  43. $2 = {<text variable, no debug info>} 0xb7e73d00 <init>
  44. (gdb) print fini #也在/usr/lib/crti.o中定义,在程序结束时做一些处理工作
  45. $3 = {<text variable, no debug info>} 0xb7f4a380 <fini>
  46. (gdb) print _start #在/usr/lib/crt1.o,这个才是程序的入口,必须的,ld会检查这个
  47. $4 = {<text variable, no debug info>} 0x8048280 <__libc_start_main@plt+20>
  48. (gdb) print main #这里是我们的main函数
  49. $5 = {void ()} 0x8048324 <main>

补充:在进程的内存映像中可能看到诸如 initfini_start 等函数(或者是入口),这些东西并不是我们自己写的啊?为什么会跑到我们的代码里头呢?实际上这些东西是链接的时候 gcc 默认给连接进去的,主要用来做一些进程的初始化和终止的动作。更多相关的细节可以参考资料如何获取当前进程之静态影像文件和”The Linux Kernel Primer”, P234, Figure 4.11,如果想了解链接(ld)的具体过程,可以看看本节参考《Unix环境高级编程编程》第7章 “UnIx进程的环境”, P127和P13,ELF: From The Programmer’s PerspectiveGNU-ld 连接脚本 Linker Scripts

上面的操作对堆栈的操作比较少,下面我们用一个例子来演示栈在内存中的情况。

栈在内存中的组织

这一节主要介绍一个函数被调用时,参数是如何传递的,局部变量是如何存储的,它们对应的栈的位置和变化情况,从而加深对栈的理解。在操作时发现和参考资料的结果不太一样(参考资料中没有 ediesi 相关信息,再第二部分的一个小程序里头也没有),可能是 gcc 版本的问题或者是它对不同源代码的处理不同。我的版本是 4.1.2 (可以通过 gcc --version 查看)。

先来一段简单的程序,这个程序除了做一个加法操作外,还复制了一些字符串。

  1. /* testshellcode.c */
  2. #include <stdio.h> /* printf */
  3. #include <string.h> /* memset, memcpy */
  4. #define BUF_SIZE 8
  5. #ifndef STR_SRC
  6. # define STR_SRC "AAAAAAA"
  7. #endif
  8. int func(int a, int b, int c)
  9. {
  10. int sum = 0;
  11. char buffer[BUF_SIZE];
  12. sum = a + b + c;
  13. memset(buffer, '\0', BUF_SIZE);
  14. memcpy(buffer, STR_SRC, sizeof(STR_SRC)-1);
  15. return sum;
  16. }
  17. int main()
  18. {
  19. int sum;
  20. sum = func(1, 2, 3);
  21. printf("sum = %d\n", sum);
  22. return 0;
  23. }

上面这个代码没有什么问题,编译执行一下:

  1. $ make testshellcode
  2. cc testshellcode.c -o testshellcode
  3. $ ./testshellcode
  4. sum = 6

下面调试一下,看看在调用 func 后的栈的内容。

  1. $ gcc -g -o testshellcode testshellcode.c #为了调试,需要在编译时加-g选项
  2. $ gdb -q ./testshellcode #启动gdb调试
  3. ...
  4. (gdb) set logging on #如果要记录调试过程中的信息,可以把日志记录功能打开
  5. Copying output to gdb.txt.
  6. (gdb) l main #列出源代码
  7. 20
  8. 21 return sum;
  9. 22 }
  10. 23
  11. 24 int main()
  12. 25 {
  13. 26 int sum;
  14. 27
  15. 28 sum = func(1, 2, 3);
  16. 29
  17. (gdb) break 28 #在调用func函数之前让程序停一下,以便记录当时的ebp(基指针)
  18. Breakpoint 1 at 0x80483ac: file testshellcode.c, line 28.
  19. (gdb) break func #设置断点在函数入口,以便逐步记录栈信息
  20. Breakpoint 2 at 0x804835c: file testshellcode.c, line 13.
  21. (gdb) disassemble main #反编译main函数,以便记录调用func后的下一条指令地址
  22. Dump of assembler code for function main:
  23. 0x0804839b <main+0>: lea 0x4(%esp),%ecx
  24. 0x0804839f <main+4>: and $0xfffffff0,%esp
  25. 0x080483a2 <main+7>: pushl 0xfffffffc(%ecx)
  26. 0x080483a5 <main+10>: push %ebp
  27. 0x080483a6 <main+11>: mov %esp,%ebp
  28. 0x080483a8 <main+13>: push %ecx
  29. 0x080483a9 <main+14>: sub $0x14,%esp
  30. 0x080483ac <main+17>: push $0x3
  31. 0x080483ae <main+19>: push $0x2
  32. 0x080483b0 <main+21>: push $0x1
  33. 0x080483b2 <main+23>: call 0x8048354 <func>
  34. 0x080483b7 <main+28>: add $0xc,%esp
  35. 0x080483ba <main+31>: mov %eax,0xfffffff8(%ebp)
  36. 0x080483bd <main+34>: sub $0x8,%esp
  37. 0x080483c0 <main+37>: pushl 0xfffffff8(%ebp)
  38. 0x080483c3 <main+40>: push $0x80484c0
  39. 0x080483c8 <main+45>: call 0x80482a0 <printf@plt>
  40. 0x080483cd <main+50>: add $0x10,%esp
  41. 0x080483d0 <main+53>: mov $0x0,%eax
  42. 0x080483d5 <main+58>: mov 0xfffffffc(%ebp),%ecx
  43. 0x080483d8 <main+61>: leave
  44. 0x080483d9 <main+62>: lea 0xfffffffc(%ecx),%esp
  45. 0x080483dc <main+65>: ret
  46. End of assembler dump.
  47. (gdb) r #运行程序
  48. Starting program: /mnt/hda8/Temp/c/program/testshellcode
  49. Breakpoint 1, main () at testshellcode.c:28
  50. 28 sum = func(1, 2, 3);
  51. (gdb) print $ebp #打印调用func函数之前的基地址,即Previous frame pointer。
  52. $1 = (void *) 0xbf84fdd8
  53. (gdb) n #执行call指令并跳转到func函数的入口
  54. Breakpoint 2, func (a=1, b=2, c=3) at testshellcode.c:13
  55. 13 int sum = 0;
  56. (gdb) n
  57. 16 sum = a + b + c;
  58. (gdb) x/11x $esp #打印当前栈的内容,可以看出,地址从低到高,注意标记有蓝色和红色的值
  59. #它们分别是前一个栈基地址(ebp)和call调用之后的下一条指令的指针(eip)
  60. 0xbf84fd94: 0x00000000 0x00000000 0x080482e0 0x00000000
  61. 0xbf84fda4: 0xb7f2bce0 0x00000000 0xbf84fdd8 0x080483b7
  62. 0xbf84fdb4: 0x00000001 0x00000002 0x00000003
  63. (gdb) n #执行sum = a + b + c,后,比较栈内容第一行,第4列,由0变为6
  64. 18 memset(buffer, '\0', BUF_SIZE);
  65. (gdb) x/11x $esp
  66. 0xbf84fd94: 0x00000000 0x00000000 0x080482e0 0x00000006
  67. 0xbf84fda4: 0xb7f2bce0 0x00000000 0xbf84fdd8 0x080483b7
  68. 0xbf84fdb4: 0x00000001 0x00000002 0x00000003
  69. (gdb) n
  70. 19 memcpy(buffer, STR_SRC, sizeof(STR_SRC)-1);
  71. (gdb) x/11x $esp #缓冲区初始化以后变成了0
  72. 0xbf84fd94: 0x00000000 0x00000000 0x00000000 0x00000006
  73. 0xbf84fda4: 0xb7f2bce0 0x00000000 0xbf84fdd8 0x080483b7
  74. 0xbf84fdb4: 0x00000001 0x00000002 0x00000003
  75. (gdb) n
  76. 21 return sum;
  77. (gdb) x/11x $esp #进行copy以后,这两列的值变了,大小刚好是7个字节,最后一个字节为'\0'
  78. 0xbf84fd94: 0x00000000 0x41414141 0x00414141 0x00000006
  79. 0xbf84fda4: 0xb7f2bce0 0x00000000 0xbf84fdd8 0x080483b7
  80. 0xbf84fdb4: 0x00000001 0x00000002 0x00000003
  81. (gdb) c
  82. Continuing.
  83. sum = 6
  84. Program exited normally.
  85. (gdb) quit

从上面的操作过程,我们可以得出大概的栈分布(func 函数结束之前)如下:

地址 值(hex) 符号或者寄存器 注释
低地址 栈顶方向
0xbf84fd98 0x41414141 buf[0] 可以看出little endian(小端,重要的数据在前面)
0xbf84fd9c 0x00414141 buf[1]
0xbf84fda0 0x00000006 sum 可见这上面都是func函数里头的局部变量
0xbf84fda4 0xb7f2bce0 esi 源索引指针,可以通过产生中间代码查看,貌似没什么作用
0xbf84fda8 0x00000000 edi 目的索引指针
0xbf84fdac 0xbf84fdd8 ebp 调用func之前的栈的基地址,以便调用函数结束之后恢复
0xbf84fdb0 0x080483b7 eip 调用func之前的指令指针,以便调用函数结束之后继续执行
0xbf84fdb4 0x00000001 a 第一个参数
0xbf84fdb8 0x00000002 b 第二个参数
0xbf84fdbc 0x00000003 c 第三个参数,可见参数是从最后一个开始压栈的
高地址 栈底方向

先说明一下 ediesi 的由来(在上面的调试过程中我们并没有看到),是通过产生中间汇编代码分析得出的。

  1. $ gcc -S testshellcode.c

在产生的 testShellcode.s 代码里头的 func 部分看到 push ebp 之后就 pushediesi 。但是搜索了一下代码,发现就这个函数里头引用了这两个寄存器,所以保存它们没什么用,删除以后编译产生目标代码后证明是没用的。

  1. $ cat testshellcode.s
  2. ...
  3. func:
  4. pushl %ebp
  5. movl %esp, %ebp
  6. pushl %edi
  7. pushl %esi
  8. ...
  9. popl %esi
  10. popl %edi
  11. popl %ebp
  12. ...

下面就不管这两部分(ediesi)了,主要来分析和函数相关的这几部分在栈内的分布:

  • 函数局部变量,在靠近栈顶一端
  • 调用函数之前的栈的基地址(ebpPrevious Frame Pointer),在中间靠近栈顶方向
  • 调用函数指令的下一条指令地址 ` (eip`),在中间靠近栈底的方向
  • 函数参数,在靠近栈底的一端,最后一个参数最先入栈

到这里,函数调用时的相关内容在栈内的分布就比较清楚了,在具体分析缓冲区溢出问题之前,我们再来看一个和函数关系很大的问题,即函数返回值的存储问题:函数的返回值存放在寄存器 eax 中。

先来看这段代码:

  1. /**
  2. * test_return.c -- the return of a function is stored in register eax
  3. */
  4. #include <stdio.h>
  5. int func()
  6. {
  7. __asm__ ("movl $1, %eax");
  8. }
  9. int main()
  10. {
  11. printf("the return of func: %d\n", func());
  12. return 0;
  13. }

编译运行后,可以看到返回值为 1,刚好是我们在 func 函数中 moveax 中的“立即数” 1,因此很容易理解返回值存储在 eax 中的事实,如果还有疑虑,可以再看看汇编代码。在函数返回之后,eax 中的值当作了 printf 的参数压入了栈中,而在源代码中我们正是把 func 的结果作为 printf 的第二个参数的。

  1. $ make test_return
  2. cc test_return.c -o test_return
  3. $ ./test_return
  4. the return of func: 1
  5. $ gcc -S test_return.c
  6. $ cat test_return.s
  7. ...
  8. call func
  9. subl $8, %esp
  10. pushl %eax #printf的第二个参数,把func的返回值压入了栈底
  11. pushl $.LC0 #printf的第一个参数the return of func: %d\n
  12. call printf
  13. ...

对于系统调用,返回值也存储在 eax 寄存器中。

缓冲区溢出

实例分析:字符串复制

先来看一段简短的代码。

  1. /* testshellcode.c */
  2. #include <stdio.h> /* printf */
  3. #include <string.h> /* memset, memcpy */
  4. #define BUF_SIZE 8
  5. #ifdef STR1
  6. # define STR_SRC "AAAAAAA\0\1\0\0\0"
  7. #endif
  8. #ifndef STR_SRC
  9. # define STR_SRC "AAAAAAA"
  10. #endif
  11. int func(int a, int b, int c)
  12. {
  13. int sum = 0;
  14. char buffer[BUF_SIZE];
  15. sum = a + b + c;
  16. memset(buffer, '\0', BUF_SIZE);
  17. memcpy(buffer, STR_SRC, sizeof(STR_SRC)-1);
  18. return sum;
  19. }
  20. int main()
  21. {
  22. int sum;
  23. sum = func(1, 2, 3);
  24. printf("sum = %d\n", sum);
  25. return 0;
  26. }

编译一下看看结果:

  1. $ gcc -DSTR1 -o testshellcode testshellcode.c #通过-D定义宏STR1,从而采用第一个STR_SRC的值
  2. $ ./testshellcode
  3. sum = 1

不知道你有没有发现异常呢?上面用红色标记的地方,本来 sum1+2+3 即 6,但是实际返回的竟然是 1 。到底是什么原因呢?大家应该有所了解了,因为我们在复制字符串 AAAAAAA\\0\\1\\0\\0\\0buf 的时候超出 buf 本来的大小。 buf 本来的大小是 BUF_SIZE,8 个字节,而我们要复制的内容是 12 个字节,所以超出了四个字节。根据第一小节的分析,我们用栈的变化情况来表示一下这个复制过程(即执行 memcpy 的过程)。

  1. memcpy(buffer, STR_SRC, sizeof(STR_SRC)-1);
  2. (低地址)
  3. 复制之前 ====> 复制之后
  4. 0x00000000 0x41414141 #char buf[8]
  5. 0x00000000 0x00414141
  6. 0x00000006 0x00000001 #int sum
  7. (高地址)

下面通过 gdb 调试来确认一下(只摘录了一些片断)。

  1. $ gcc -DSTR1 -g -o testshellcode testshellcode.c
  2. $ gdb -q ./testshellcode
  3. ...
  4. (gdb) l
  5. 21
  6. 22 memset(buffer, '\0', BUF_SIZE);
  7. 23 memcpy(buffer, STR_SRC, sizeof(STR_SRC)-1);
  8. 24
  9. 25 return sum;
  10. ...
  11. (gdb) break 23
  12. Breakpoint 1 at 0x804837f: file testshellcode.c, line 23.
  13. (gdb) break 25
  14. Breakpoint 2 at 0x8048393: file testshellcode.c, line 25.
  15. (gdb) r
  16. Starting program: /mnt/hda8/Temp/c/program/testshellcode
  17. Breakpoint 1, func (a=1, b=2, c=3) at testshellcode.c:23
  18. 23 memcpy(buffer, STR_SRC, sizeof(STR_SRC)-1);
  19. (gdb) x/3x $esp+4
  20. 0xbfec6bd8: 0x00000000 0x00000000 0x00000006
  21. (gdb) n
  22. Breakpoint 2, func (a=1, b=2, c=3) at testshellcode.c:25
  23. 25 return sum;
  24. (gdb) x/3x $esp+4
  25. 0xbfec6bd8: 0x41414141 0x00414141 0x00000001

可以看出,因为 C 语言没有对数组的边界进行限制。我们可以往数组中存入预定义长度的字符串,从而导致缓冲区溢出。

缓冲区溢出后果

溢出之后的问题是导致覆盖栈的其他内容,从而可能改变程序原来的行为。

如果这类问题被“黑客”利用那将产生非常可怕的后果,小则让非法用户获取了系统权限,把你的服务器当成“僵尸”,用来对其他机器进行攻击,严重的则可能被人删除数据(所以备份很重要)。即使不被黑客利用,这类问题如果放在医疗领域,那将非常危险,可能那个被覆盖的数字刚好是用来控制治疗癌症的辐射量的,一旦出错,那可能导致置人死地,当然,如果在航天领域,那可能就是好多个 0 的 money 甚至航天员的损失,呵呵,“缓冲区溢出,后果很严重!”

缓冲区溢出应对策略

那这个怎么办呢?貌似Linux下缓冲区溢出攻击的原理及对策提到有一个 libsafe 库,可以至少用来检测程序中出现的类似超出数组边界的问题。对于上面那个具体问题,为了保护 sum 不被修改,有一个小技巧,可以让求和操作在字符串复制操作之后来做,以便求和操作把溢出的部分给重写。这个呆伙在下面一块看效果吧。继续看看缓冲区的溢出吧。

先来看看这个代码,还是 testShellcode.c 的改进。

  1. /* testshellcode.c */
  2. #include <stdio.h> /* printf */
  3. #include <string.h> /* memset, memcpy */
  4. #define BUF_SIZE 8
  5. #ifdef STR1
  6. # define STR_SRC "AAAAAAAa\1\0\0\0"
  7. #endif
  8. #ifdef STR2
  9. # define STR_SRC "AAAAAAAa\1\0\0\0BBBBBBBB"
  10. #endif
  11. #ifdef STR3
  12. # define STR_SRC "AAAAAAAa\1\0\0\0BBBBBBBBCCCC"
  13. #endif
  14. #ifdef STR4
  15. # define STR_SRC "AAAAAAAa\1\0\0\0BBBBBBBBCCCCDDDD"
  16. #endif
  17. #ifndef STR_SRC
  18. # define STR_SRC "AAAAAAA"
  19. #endif
  20. int func(int a, int b, int c)
  21. {
  22. int sum = 0;
  23. char buffer[BUF_SIZE] = "";
  24. memset(buffer, '\0', BUF_SIZE);
  25. memcpy(buffer, STR_SRC, sizeof(STR_SRC)-1);
  26. sum = a + b + c; //把求和操作放在复制操作之后可以在一定情况下“保护”求和结果
  27. return sum;
  28. }
  29. int main()
  30. {
  31. int sum;
  32. sum = func(1, 2, 3);
  33. printf("sum = %d\n", sum);
  34. return 0;
  35. }

看看运行情况:

  1. $ gcc -D STR2 -o testshellcode testshellcode.c #再多复制8个字节,结果和STR1时一样
  2. #原因是edi,esi这两个没什么用的,覆盖了也没关系
  3. $ ./testshellcode #看到没?这种情况下,让整数操作在字符串复制之后做可以“保护‘整数结果
  4. sum = 6
  5. $ gcc -D STR3 -o testshellcode testshellcode.c #再多复制4个字节,现在就会把ebp给覆盖
  6. #了,这样当main函数再要用ebp访问数据
  7. #时就会出现访问非法内存而导致段错误。
  8. $ ./testshellcode
  9. Segmentation fault

如果感兴趣,自己还可以用gdb类似之前一样来查看复制字符串以后栈的变化情况。

如何保护 ebp 不被修改

下面来做一个比较有趣的事情:如何设法保护我们的 ebp 不被修改。

首先要明确 ebp 这个寄存器的作用和“行为”,它是栈基地址,并且发现在调用任何一个函数时,这个 ebp 总是在第一条指令被压入栈中,并在最后一条指令(ret)之前被弹出。类似这样:

  1. func: #函数
  2. pushl %ebp #第一条指令
  3. ...
  4. popl %ebp #倒数第二条指令
  5. ret

还记得之前(第一部分)提到的函数的返回值是存储在 eax 寄存器中的么?如果我们在一个函数中仅仅做放这两条指令:

  1. popl %eax
  2. pushl %eax

那不就刚好有:

  1. func: #函数
  2. pushl %ebp #第一条指令
  3. popl %eax #把刚压入栈中的ebp弹出存放到eax中
  4. pushl %eax #又把ebp压入栈
  5. popl %ebp #倒数第二条指令
  6. ret

这样我们没有改变栈的状态,却获得了 ebp 的值,如果在调用任何一个函数之前,获取这个 ebp,并且在任何一条字符串复制语句(可能导致缓冲区溢出的语句)之后重新设置一下 ebp 的值,那么就可以保护 ebp 啦。具体怎么实现呢?看这个代码。

  1. /* testshellcode.c */
  2. #include <stdio.h> /* printf */
  3. #include <string.h> /* memset, memcpy */
  4. #define BUF_SIZE 8
  5. #ifdef STR1
  6. # define STR_SRC "AAAAAAAa\1\0\0\0"
  7. #endif
  8. #ifdef STR2
  9. # define STR_SRC "AAAAAAAa\1\0\0\0BBBBBBBB"
  10. #endif
  11. #ifdef STR3
  12. # define STR_SRC "AAAAAAAa\1\0\0\0BBBBBBBBCCCC"
  13. #endif
  14. #ifdef STR4
  15. # define STR_SRC "AAAAAAAa\1\0\0\0BBBBBBBBCCCCDDDD"
  16. #endif
  17. #ifndef STR_SRC
  18. # define STR_SRC "AAAAAAA"
  19. #endif
  20. unsigned long get_ebp()
  21. {
  22. __asm__ ("popl %eax;"
  23. "pushl %eax;");
  24. }
  25. int func(int a, int b, int c, unsigned long ebp)
  26. {
  27. int sum = 0;
  28. char buffer[BUF_SIZE] = "";
  29. sum = a + b + c;
  30. memset(buffer, '\0', BUF_SIZE);
  31. memcpy(buffer, STR_SRC, sizeof(STR_SRC)-1);
  32. *(unsigned long *)(buffer+20) = ebp;
  33. return sum;
  34. }
  35. int main()
  36. {
  37. int sum, ebp;
  38. ebp = get_ebp();
  39. sum = func(1, 2, 3, ebp);
  40. printf("sum = %d\n", sum);
  41. return 0;
  42. }

这段代码和之前的代码的不同有:

  • func 函数增加了一个参数 ebp,(其实可以用全局变量替代的)
  • 利用了刚介绍的原理定义了一个函数 get_ebp 以便获取老的 ebp
  • main 函数中调用 func 之前调用了 get_ebp,并把它作为 func 的最后一个参数
  • func 函数中调用 memcpy 函数(可能发生缓冲区溢出的地方)之后添加了一条恢复设置 ebp 的语句,这条语句先把 buffer+20 这个地址(存放 ebp 的地址,你可以类似第一部分提到的用 gdb 来查看)强制转换为指向一个 unsigned long 型的整数(4 个字节),然后把它指向的内容修改为老的 ebp

看看效果:

  1. $ gcc -D STR3 -o testshellcode testshellcode.c
  2. $ ./testshellcode #现在没有段错误了吧,因为ebp得到了“保护”
  3. sum = 6

如何保护 eip 不被修改?

如果我们复制更多的字节过去了,比如再多复制四个字节进去,那么 eip 就被覆盖了。

  1. $ gcc -D STR4 -o testshellcode testshellcode.c
  2. $ ./testshellcode
  3. Segmentation fault

同样会出现段错误,因为下一条指令的位置都被改写了,func 返回后都不知道要访问哪个”非法“地址啦。呵呵,如果是一个合法地址呢?

如果在缓冲区溢出时,eip 被覆盖了,并且被修改为了一条合法地址,那么问题就非常”有趣“了。如果这个地址刚好是调用func的那个地址,那么整个程序就成了死循环,如果这个地址指向的位置刚好有一段关机代码,那么系统正在运行的所有服务都将被关掉,如果那个地方是一段更恶意的代码,那就?你可以尽情想像哦。如果是黑客故意利用这个,那么那些代码貌似就叫做shellcode了。

有没有保护 eip 的办法呢?呵呵,应该是有的吧。不知道 gas 有没有类似 masm 汇编器中 offset 的伪操作指令(查找了一下,貌似没有),如果有的话在函数调用之前设置一个标号,在后面某个位置获取,再加上一个可能的偏移(包括 call 指令的长度和一些 push 指令等),应该可以算出来,不过貌似比较麻烦(或许你灵感大作,找到好办法了!),这里直接通过 gdb 反汇编求得它相对 main 的偏移算出来得了。求出来以后用它来”保护“栈中的值。

看看这个代码:

  1. /* testshellcode.c */
  2. #include <stdio.h> /* printf */
  3. #include <string.h> /* memset, memcpy */
  4. #define BUF_SIZE 8
  5. #ifdef STR1
  6. # define STR_SRC "AAAAAAAa\1\0\0\0"
  7. #endif
  8. #ifdef STR2
  9. # define STR_SRC "AAAAAAAa\1\0\0\0BBBBBBBB"
  10. #endif
  11. #ifdef STR3
  12. # define STR_SRC "AAAAAAAa\1\0\0\0BBBBBBBBCCCC"
  13. #endif
  14. #ifdef STR4
  15. # define STR_SRC "AAAAAAAa\1\0\0\0BBBBBBBBCCCCDDDD"
  16. #endif
  17. #ifndef STR_SRC
  18. # define STR_SRC "AAAAAAA"
  19. #endif
  20. int main();
  21. #define OFFSET 40
  22. unsigned long get_ebp()
  23. {
  24. __asm__ ("popl %eax;"
  25. "pushl %eax;");
  26. }
  27. int func(int a, int b, int c, unsigned long ebp)
  28. {
  29. int sum = 0;
  30. char buffer[BUF_SIZE] = "";
  31. memset(buffer, '\0', BUF_SIZE);
  32. memcpy(buffer, STR_SRC, sizeof(STR_SRC)-1);
  33. sum = a + b + c;
  34. *(unsigned long *)(buffer+20) = ebp;
  35. *(unsigned long *)(buffer+24) = (unsigned long)main+OFFSET;
  36. return sum;
  37. }
  38. int main()
  39. {
  40. int sum, ebp;
  41. ebp = get_ebp();
  42. sum = func(1, 2, 3, ebp);
  43. printf("sum = %d\n", sum);
  44. return 0;
  45. }

看看效果:

  1. $ gcc -D STR4 -o testshellcode testshellcode.c
  2. $ ./testshellcode
  3. sum = 6

这样,EIP 也得到了“保护”(这个方法很糟糕的,呵呵)。

类似地,如果再多复制一些内容呢?那么栈后面的内容都将被覆盖,即传递给 func 函数的参数都将被覆盖,因此上面的方法,包括所谓的对 sumebp 等值的保护都没有任何意义了(如果再对后面的参数进行进一步的保护呢?或许有点意义,呵呵)。在这里,之所以提出类似这样的保护方法,实际上只是为了讨论一些有趣的细节并加深对缓冲区溢出这一问题的理解(或许有一些实际的价值哦,算是抛砖引玉吧)。

缓冲区溢出检测

要确实解决这类问题,从主观上讲,还得程序员来做相关的工作,比如限制将要复制的字符串的长度,保证它不超过当初申请的缓冲区的大小。

例如,在上面的代码中,我们在 memcpy 之前,可以加入一个判断,并且可以对缓冲区溢出进行很好的检查。如果能够设计一些比较好的测试实例把这些判断覆盖到,那么相关的问题就可以得到比较不错的检查了。

  1. /* testshellcode.c */
  2. #include <stdio.h> /* printf */
  3. #include <string.h> /* memset, memcpy */
  4. #include <stdlib.h> /* exit */
  5. #define BUF_SIZE 8
  6. #ifdef STR4
  7. # define STR_SRC "AAAAAAAa\1\0\0\0BBBBBBBBCCCCDDDD"
  8. #endif
  9. #ifndef STR_SRC
  10. # define STR_SRC "AAAAAAA"
  11. #endif
  12. int func(int a, int b, int c)
  13. {
  14. int sum = 0;
  15. char buffer[BUF_SIZE] = "";
  16. memset(buffer, '\0', BUF_SIZE);
  17. if ( sizeof(STR_SRC)-1 > BUF_SIZE ) {
  18. printf("buffer overflow!\n");
  19. exit(-1);
  20. }
  21. memcpy(buffer, STR_SRC, sizeof(STR_SRC)-1);
  22. sum = a + b + c;
  23. return sum;
  24. }
  25. int main()
  26. {
  27. int sum;
  28. sum = func(1, 2, 3);
  29. printf("sum = %d\n", sum);
  30. return 0;
  31. }

现在的效果如下:

  1. $ gcc -DSTR4 -g -o testshellcode testshellcode.c
  2. $ ./testshellcode #如果存在溢出,那么就会得到阻止并退出,从而阻止可能的破坏
  3. buffer overflow!
  4. $ gcc -g -o testshellcode testshellcode.c
  5. $ ./testshellcode
  6. sum = 6

当然,如果能够在 C 标准里头加入对数组操作的限制可能会更好,或者在编译器中扩展对可能引起缓冲区溢出的语法检查。

缓冲区注入实例

最后给出一个利用上述缓冲区溢出来进行缓冲区注入的例子。也就是通过往某个缓冲区注入一些代码,并把eip修改为这些代码的入口从而达到破坏目标程序行为的目的。

这个例子来自Linux 下缓冲区溢出攻击的原理及对策,这里主要利用上面介绍的知识对它进行了比较详细的分析。

准备:把 C 语言函数转换为字符串序列

首先回到第一部分,看看那个 Shellcode.c 程序。我们想获取它的汇编代码,并以十六进制字节的形式输出,以便把这些指令当字符串存放起来,从而作为缓冲区注入时的输入字符串。下面通过 gdb 获取这些内容。

$ gcc -g -o shellcode shellcode.c
$ gdb -q ./shellcode
(gdb) disassemble main
Dump of assembler code for function main:
...
0x08048331 <main+13>:   push   %ecx
0x08048332 <main+14>:   jmp    0x8048354 <forward>
0x08048334 <main+16>:   pop    %esi
0x08048335 <main+17>:   mov    $0x4,%eax
0x0804833a <main+22>:   mov    $0x2,%ebx
0x0804833f <main+27>:   mov    %esi,%ecx
0x08048341 <main+29>:   mov    $0xc,%edx
0x08048346 <main+34>:   int    $0x80
0x08048348 <main+36>:   mov    $0x1,%eax
0x0804834d <main+41>:   mov    $0x0,%ebx
0x08048352 <main+46>:   int    $0x80
0x08048354 <forward+0>: call   0x8048334 <main+16>
0x08048359 <forward+5>: dec    %eax
0x0804835a <forward+6>: gs
0x0804835b <forward+7>: insb   (%dx),%es:(%edi)
0x0804835c <forward+8>: insb   (%dx),%es:(%edi)
0x0804835d <forward+9>: outsl  %ds:(%esi),(%dx)
0x0804835e <forward+10>:        and    %dl,0x6f(%edi)
0x08048361 <forward+13>:        jb     0x80483cf <__libc_csu_init+79>
0x08048363 <forward+15>:        or     %fs:(%eax),%al
...
End of assembler dump.
(gdb) set logging on   #开启日志功能,记录操作结果
Copying output to gdb.txt.
(gdb) x/52bx main+14  #以十六进制单字节(字符)方式打印出shellcode的核心代码
0x8048332 <main+14>:    0xeb    0x20    0x5e    0xb8    0x04    0x00    0x00   0x00
0x804833a <main+22>:    0xbb    0x02    0x00    0x00    0x00    0x89    0xf1   0xba
0x8048342 <main+30>:    0x0c    0x00    0x00    0x00    0xcd    0x80    0xb8   0x01
0x804834a <main+38>:    0x00    0x00    0x00    0xbb    0x00    0x00    0x00   0x00
0x8048352 <main+46>:    0xcd    0x80    0xe8    0xdb    0xff    0xff    0xff   0x48
0x804835a <forward+6>:  0x65    0x6c    0x6c    0x6f    0x20    0x57    0x6f   0x72
0x8048362 <forward+14>: 0x6c    0x64    0x0a    0x00
(gdb) quit
$ cat gdb.txt | sed -e "s/^.*://g;s/\t/\\\/g;s/^/\"/g;s/\$/\"/g"  #把日志里头的内容处理一下,得到这样一个字符串
"\0xeb\0x20\0x5e\0xb8\0x04\0x00\0x00\0x00"
"\0xbb\0x02\0x00\0x00\0x00\0x89\0xf1\0xba"
"\0x0c\0x00\0x00\0x00\0xcd\0x80\0xb8\0x01"
"\0x00\0x00\0x00\0xbb\0x00\0x00\0x00\0x00"
"\0xcd\0x80\0xe8\0xdb\0xff\0xff\0xff\0x48"
"\0x65\0x6c\0x6c\0x6f\0x20\0x57\0x6f\0x72"
"\0x6c\0x64\0x0a\0x00"

注入:在 C 语言中执行字符串化的代码

得到上面的字符串以后我们就可以设计一段下面的代码啦。

/* testshellcode.c */
char shellcode[]="\xeb\x20\x5e\xb8\x04\x00\x00\x00"
"\xbb\x02\x00\x00\x00\x89\xf1\xba"
"\x0c\x00\x00\x00\xcd\x80\xb8\x01"
"\x00\x00\x00\xbb\x00\x00\x00\x00"
"\xcd\x80\xe8\xdb\xff\xff\xff\x48"
"\x65\x6c\x6c\x6f\x20\x57\x6f\x72"
"\x6c\x64\x0a\x00";

void callshellcode(void)
{
   int *ret;
   ret = (int *)&ret + 2;
   (*ret) = (int)shellcode;
}

int main()
{
        callshellcode();

        return 0;
}

运行看看,

$ gcc -o testshellcode testshellcode.c
$ ./testshellcode
Hello World

竟然打印出了 Hello World,实际上,如果只是为了让 Shellcode 执行,有更简单的办法,直接把 Shellcode 这个字符串入口强制转换为一个函数入口,并调用就可以,具体见这段代码。

char shellcode[]="\xeb\x20\x5e\xb8\x04\x00\x00\x00"
"\xbb\x02\x00\x00\x00\x89\xf1\xba"
"\x0c\x00\x00\x00\xcd\x80\xb8\x01"
"\x00\x00\x00\xbb\x00\x00\x00\x00"
"\xcd\x80\xe8\xdb\xff\xff\xff\x48"
"\x65\x6c\x6c\x6f\x20\x57\x6f\x72"
"\x6c\x64\x0a\x00";

typedef void (* func)();            //定义一个指向函数的指针func,而函数的返回值和参数均为void

int main()
{
        (* (func)shellcode)();

        return 0;
}

注入原理分析

这里不那样做,为什么也能够执行到 Shellcode 呢?仔细分析一下 callShellcode 里头的代码就可以得到原因了。

int *ret;

这里定义了一个指向整数的指针,ret 占用 4 个字节(可以用 sizeof(int *) 算出)。

ret = (int *)&ret + 2;

这里把 ret 修改为它本身所在的地址再加上两个单位。
首先需要求出 ret 本身所在的位置,因为 ret 是函数的一个局部变量,它在栈中偏栈顶的地方。
然后呢?再增加两个单位,这个单位是 sizeof(int),即 4 个字节。这样,新的 ret 就是 ret 所在的位置加上 8 个字节,即往栈底方向偏移 8 个字节的位置。对于我们之前分析的 Shellcode,那里应该是 edi,但实际上这里并不是 edi,可能是 gcc 在编译程序时有不同的处理,这里实际上刚好是 eip,即执行这条语句之后 ret 的值变成了 eip 所在的位置。

(*ret) = (int)shellcode;

由于之前 ret 已经被修改为了 eip 所在的位置,这样对 (*ret) 赋值就会修改 eip 的值,即下一条指令的地址,这里把 eip 修改为了 Shellcode 的入口。因此,当函数返回时直接去执行 Shellcode 里头的代码,并打印了 Hello World

gdb 调试一下看看相关变量的值的情况。这里主要关心 ret 本身。 ret 本身是一个地址,首先它所在的位置变成了 EIP 所在的位置(把它自己所在的位置加上 2*4 以后赋于自己),然后,EIP 又指向了 Shellcode 处的代码。

$ gcc -g -o testshellcode testshellcode.c
$ gdb -q ./testshellcode
(gdb) l
8       void callshellcode(void)
9       {
10         int *ret;
11         ret = (int *)&ret + 2;
12         (*ret) = (int)shellcode;
13      }
14
15      int main()
16      {
17              callshellcode();
(gdb) break 17
Breakpoint 1 at 0x804834d: file testshell.c, line 17.
(gdb) break 11
Breakpoint 2 at 0x804832a: file testshell.c, line 11.
(gdb) break 12
Breakpoint 3 at 0x8048333: file testshell.c, line 12.
(gdb) break 13
Breakpoint 4 at 0x804833d: file testshell.c, line 13.
(gdb) r
Starting program: /mnt/hda8/Temp/c/program/testshell

Breakpoint 1, main () at testshell.c:17
17              callshellcode();
(gdb) print $ebp       #打印ebp寄存器里的值
$1 = (void *) 0xbfcfd2c8
(gdb) disassemble main
...
0x0804834d <main+14>:   call   0x8048324 <callshellcode>
0x08048352 <main+19>:   mov    $0x0,%eax
...
(gdb) n

Breakpoint 2, callshellcode () at testshell.c:11
11         ret = (int *)&ret + 2;
(gdb) x/6x $esp
0xbfcfd2ac:     0x08048389      0xb7f4eff4      0xbfcfd36c      0xbfcfd2d8
0xbfcfd2bc:     0xbfcfd2c8      0x08048352
(gdb) print &ret #分别打印出ret所在的地址和ret的值,刚好在ebp之上,我们发现这里并没有
       #之前的testshellcode代码中的edi和esi,可能是gcc在汇编的时候有不同处理。
$2 = (int **) 0xbfcfd2b8
(gdb) print ret
$3 = (int *) 0xbfcfd2d8 #这里的ret是个随机值
(gdb) n

Breakpoint 3, callshellcode () at testshell.c:12
12         (*ret) = (int)shellcode;
(gdb) print ret   #执行完ret = (int *)&ret + 2;后,ret变成了自己地址加上2*4,
                  #刚好是eip所在的位置。
$5 = (int *) 0xbfcfd2c0
(gdb) x/6x $esp
0xbfcfd2ac:     0x08048389      0xb7f4eff4      0xbfcfd36c      0xbfcfd2c0
0xbfcfd2bc:     0xbfcfd2c8      0x08048352
(gdb) x/4x *ret  #此时*ret刚好为eip,0x8048352
0x8048352 <main+19>:    0x000000b8      0x8d5d5900      0x90c3fc61      0x89559090
(gdb) n

Breakpoint 4, callshellcode () at testshell.c:13
13      }
(gdb) x/6x $esp #现在eip被修改为了shellcode的入口
0xbfcfd2ac:     0x08048389      0xb7f4eff4      0xbfcfd36c      0xbfcfd2c0
0xbfcfd2bc:     0xbfcfd2c8      0x8049560
(gdb) x/4x *ret  #现在修改了(*ret)的值,即修改了eip的值,使eip指向了shellcode
0x8049560 <shellcode>:  0xb85e20eb      0x00000004      0x000002bb      0xbaf18900

上面的过程很难弄,呵呵。主要是指针不大好理解,如果直接把它当地址绘出下面的图可能会容易理解一些。

callshellcode栈的初始分布:

ret=(int *)&ret+2=0xbfcfd2bc+2*4=0xbfcfd2c0
0xbfcfd2b8      ret(随机值)                     0xbfcfd2c0
0xbfcfd2bc      ebp(这里不关心)
0xbfcfd2c0      eip(0x08048352)         eip(0x8049560 )

(*ret) = (int)shellcode;即eip=0x8049560

总之,最后体现为函数调用的下一条指令指针(eip)被修改为一段注入代码的入口,从而使得函数返回时执行了注入代码。

缓冲区注入与防范

这个程序里头的注入代码和被注入程序竟然是一个程序,傻瓜才自己攻击自己(不过有些黑客有可能利用程序中一些空闲空间注入代码哦),真正的缓冲区注入程序是分开的,比如作为被注入程序的一个字符串参数。而在被注入程序中刚好没有做字符串长度的限制,从而让这段字符串中的一部分修改了 eip,另外一部分作为注入代码运行了,从而实现了注入的目的。不过这会涉及到一些技巧,即如何刚好用注入代码的入口地址来修改 eip (即新的 eip 能够指向注入代码)?如果 eip 的位置和缓冲区的位置之间的距离是确定,那么就比较好处理了,但从上面的两个例子中我们发现,有一个编译后有 ediesi,而另外一个则没有,另外,缓冲区的位置,以及被注入程序有多少个参数我们都无法预知,因此,如何计算 eip 所在的位置呢?这也会很难确定。还有,为了防止缓冲区溢出带来的注入问题,现在的操作系统采取了一些办法,比如让 esp 随机变化(比如和系统时钟关联起来),所以这些措施将导致注入更加困难。如果有兴趣,你可以接着看看最后的几篇参考资料并进行更深入的研究。

需要提到的是,因为很多程序可能使用 strcpy 来进行字符串的复制,在实际编写缓冲区注入代码时,会采取一定的办法(指令替换),把代码中可能包含的 \0 字节去掉,从而防止 strcpy 中断对注入代码的复制,进而可以复制完整的注入代码。具体的技巧可以参考 Linux下缓冲区溢出攻击的原理及对策Shellcode技术杂谈virus-writing-HOWTO

后记

实际上缓冲区溢出应该是语法和逻辑方面的双重问题,由于语法上的不严格(对数组边界没有检查)导致逻辑上可能出现严重缺陷(程序执行行为被改变)。另外,这类问题是对程序运行过程中的程序映像的栈区进行注入。实际上除此之外,程序在安全方面还有很多类似的问题。比如,虽然程序映像的正文区受到系统保护(只读),但是如果内存(硬件本身,内存条)出现故障,在程序运行的过程中,程序映像的正文区的某些字节就可能被修改了,也可能发生非常严重的后果,因此程序运行过程的正文区检查等可能的手段需要被引入。

参考资料