by falcon wuzhangjin@gmail.com of TinyLab.org
2008-2-13
最初发表:泰晓科技 – 聚焦嵌入式 Linux,追本溯源,见微知著!
原文链接:缓冲区溢出与注入分析
评论说明:为更好地聚合大家的讨论,请到上面原文的评论区回复。
【注】这是开源书籍《C语言编程透视》第五章,如果您喜欢该书,请关注我们的新浪微博@泰晓科技。
缓冲区溢出与注入分析
前言
虽然程序加载以及动态符号链接都已经很理解了,但是这伙却被进程的内存映像给”纠缠"住。看着看着就一发不可收拾——很有趣。
下面一起来探究“缓冲区溢出和注入”问题(主要是关心程序的内存映像)。
进程的内存映像
永远的Hello World,太熟悉了吧,
#include <stdio.h>
int main(void)
{
printf("Hello Worldn");
return 0;
}
如果要用内联汇编(inline assembly)来写呢?
1 /* shellcode.c */
2 void main()
3 {
4 __asm__ __volatile__("jmp forward;"
5 "backward:"
6 "popl %esi;"
7 "movl $4, %eax;"
8 "movl $2, %ebx;"
9 "movl %esi, %ecx;"
10 "movl $12, %edx;"
11 "int $0x80;" /* system call 1 */
12 "movl $1, %eax;"
13 "movl $0, %ebx;"
14 "int $0x80;" /* system call 2 */
15 "forward:"
16 "call backward;"
17 ".string "Hello World\n";");
18 }
看起来很复杂,实际上就做了一个事情,往终端上写了个Hello World。不过这个非常有意思。先简单分析一下流程:
- 第4行指令的作用是跳转到第15行(即forward标记处),接着执行第16行。
- 第16行调用backward,跳转到第5行,接着执行6到14行。
- 第6行到第11行负责在终端打印出Hello World字符串(等一下详细介绍)。
- 第12行到第14行退出程序(等一下详细介绍)。
为了更好的理解上面的代码和后续的分析,先来介绍几个比较重要的内容。
常用寄存器初识
X86处理器平台有三个常用寄存器:程序指令指针、程序堆栈指针与程序基指针:
<th align="left">
名称
</th>
<th align="left">
注释
</th>
寄存器 |
---|
<td align="left">
程序指令指针
</td>
<td align="left">
通常指向下一条指令的位置
</td>
ESP
<td align="left">
程序堆栈指针
</td>
<td align="left">
通常指向当前堆栈的当前位置
</td>
EBP
<td align="left">
程序基指针
</td>
<td align="left">
通常指向函数使用的堆栈顶端
</td>
当然,上面都是扩展的寄存器,用于32位系统,对应的16系统为ip,sp,bp。
call,ret指令的作用分析
- call指令
跳转到某个位置,并在之前把下一条指令的地址(EIP)入栈(为了方便”程序“返回以后能够接着执行)。这样的话就有:
call backward ==> push eip
jmp backward
- ret 指令
通常call指令和ret是配合使用的,前者压入跳转前的下一条指令地址,后者弹出call指令压入的那条指令,从而可以在函数调用结束以后接着执行后面的指令。
ret ==> pop eip
通常在函数调用后,还需要恢复esp和ebp,恢复esp即恢复当前栈指针,以便释放调用函数时为存储函数的局部变量而自动分配的空间;恢复ebp是从栈中弹出一个数据项(通常函数调用过后的第一条语句就是push ebp),从而恢复当前的函数指针为函数调用者本身。这两个动作可以通过一条leave指令完成。
这三个指令对我们后续的解释会很有帮助。更多关于Intel的指令集,请参考:Intel 386 Manual, x86 Assembly Language FAQ:part1, part2, part3.
什么是系统调用(以Linux 2.6.21版本和x86平台为例)
系统调用是用户和内核之间的接口,用户如果想写程序,很多时候直接调用了C库,并没有关心系统调用,而实际上C库也是基于系统调用的。这样应用程序和内核之间就可以通过系统调用联系起来。它们分别处于操作系统的用户空间和内核空间(主要是内存地址空间的隔离)。
用户空间 应用程序(Applications)
| |
| C库(如glibc)
| |
系统调用(System Calls,如sys_read, sys_write, sys_exit)
|
内核空间 内核(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查到),其他寄存器如ebx,ecx,edx,esi,edi一次存放系统调用的参数。而系统调用的返回值存放在eax寄存器中。
下面我们就很容易解释前面的shellcode.c程序流程的2,3两部分了。因为都用了int 0x80中断,所以都用到了系统调用。
第3部分很简单,用到的系统调用号是1,通过查表(查/usr/include/asm-i386/unistd.h或arch/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的内容来自esi,esi来自刚弹出栈的值(见第6行
popl %esi;
),而之前刚好有call指令引起了最近一次压栈操作,入栈的内容刚好是call指令的下一条指令的地址,即.string
所在行的地址,这样ecx刚好引用了"Hello Worldn"字符串的地址。 - 第三个参数是edx,刚好是12,即"Hello Worldn"字符串的长度(包括一个空字符)。这样,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文件的大体结构:
ELF Header #程序头,有该文件的Magic number(参考man magic),类型等
Program Header Table #对可执行文件和共享库有效,它描述下面各个节(section)组成的段
Section1
Section2
Section3
.....
Program Section Table #仅对可重定位目标文件和静态库有效,用于描述各个Section的重定位信息等。
对于可执行文件,文件最后的Program Section Table(节区表)和一些非重定位的Section,比如.comment,.note.XXX.debug等信息都可以删除掉,不过如果用strip,objcopy等工具删除掉以后,就不可恢复了。因为这些信息对程序的运行一般没有任何用处。
ELF文件的主要节区(section)有.data,.text,.bss,.interp等,而主要段(segment)有LOAD,INTERP等。它们之间(节区和段)的主要对应关系如下:
<th align="left">
解释
</th>
<th align="left">
实例
</th>
Section |
---|
<td align="left">
初始化的数据
</td>
<td align="left">
比如int a=10
</td>
.bss
<td align="left">
未初始化的数据
</td>
<td align="left">
比如char sum[100];这个在程序执行之前,内核将初始化为0
</td>
.text
<td align="left">
程序代码正文
</td>
<td align="left">
即可执行指令集
</td>
.interp
<td align="left">
描述程序需要的解释器(动态连接和装载程序) 存
</td>
<td align="left">
有解释器的全路径,如/lib/ld-linux.so
</td>
而程序在执行以后,.data, .bss,.text等一些节区会被Program header table映射到LOAD段,.interp则被映射到了INTERP段。
对于ELF文件的分析,建议使用file, size, readelf,objdump,strip,objcopy,gdb,nm等工具。
这里简单地演示这几个工具:
$ gcc -g -o shellcode shellcode.c #如果要用gdb调试,编译时加上-g是必须的
shellcode.c: In function ‘main’:
shellcode.c:3: warning: return type of ‘main’ is not ‘int’
f$ file shellcode #file命令查看文件类型,想了解工作原理,可man magic,man file
shellcode: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV),
dynamically linked (uses shared libs), not stripped
$ readelf -l shellcode #列出ELF文件前面的program head table,后面是它描
#述了各个段(segment)和节区(section)的关系,即各个段包含哪些节区。
Elf file type is EXEC (Executable file)
Entry point 0x8048280
There are 7 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x08048034 0x08048034 0x000e0 0x000e0 R E 0x4
INTERP 0x000114 0x08048114 0x08048114 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]
LOAD 0x000000 0x08048000 0x08048000 0x0044c 0x0044c R E 0x1000
LOAD 0x00044c 0x0804944c 0x0804944c 0x00100 0x00104 RW 0x1000
DYNAMIC 0x000460 0x08049460 0x08049460 0x000c8 0x000c8 RW 0x4
NOTE 0x000128 0x08048128 0x08048128 0x00020 0x00020 R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .hash .dynsym .dynstr .gnu.version .gnu.version_r
.rel.dyn .rel.plt .init .plt .text .fini .rodata .eh_frame
03 .ctors .dtors .jcr .dynam