摘要:
本文主要演示linux平台下的栈溢出,首先根据理论对示例代码进行溢出攻击;结果是溢出攻击成立,但是与设想的有差别;然后采用GDB调试工具对发生的意外,进行深入的分析。
测试的平台:
1. ubuntu 9; gcc 4.4.1; Gdb 7.0-ubuntu
2. ubuntu系统安装在virtual box 3.2.8虚拟机上;
示例代码如下:
#include<string.h> void overflow(char* arg) { char buf[12]; strcpy(buf, arg); } int main(int argc, char *argv[]) { if(argc > 1) overflow(argv[1]); return 0; }
如果按照一般的方式编译:
gcc –o * *.c
linux系统能够探测到程序中的stack overflow,从而终止程序,如下图所示:
那有没有办法让系统不探测到stack overflow,此处可以在编译时,禁用堆栈保护,具体命令如下:
gcc –fno-stack-protector –o * *.c
然后采用gdb调试*,
这里的输出跟设想的存在很大的差别,因为设想中的函数栈如下:
前面的12个字节填充buf,然后接下来的4个字节填充ebp,最后的4个字节填充RET地址,那么照理说,这里的eip应该是0x65656565,那为什么此处是0x61616161,刚好是aaaa的值呢?
根据单步调试的结果,发现eip变为0x61616161是在main函数退出后达到的,按照设想应该是在overflow退出时,eip变为0x65656565。
为什么overflow退出后还能回到main函数?可能的原因:输入的字符串没有覆盖掉ret地址,但是字符串却意外地将main的返回地址给覆盖掉了?
但就算是覆盖,为什么覆盖的值没有采用e的值,而是采用的a的值?要知道a是在字符串的起始处?这点的确让人奇怪。
1. 我们还是采用一步步调试的方式来观察问题所在,先看下gcc编译后的反汇编代码:
使用到的命令:
set disassembly-flavor intel //将汇编设定为intel风格;
disassemble main //反汇编main函数;
Main函数:
1. push ebp 2. mvo ebp, esp 3. and esp, 0xfffffff0 4. sub esp, 0x10 5. cmp DWORD PTR [ebp+0x8], 0x1 6. jle 0x804841d <main+31> 7. mov eax, DWORD PTR [ebp+0xc] 8. add eax, 0x4 9. mov eax, DWORD PTR [eax] 10. mov DWORD PTR [esp], eax 11. call 0x80483e4 <overflow> 12. mov eax, 0x0 13. leave 14. ret
然后再来看下,overflow的反汇编代码,命令:disassemble overflow
Overflow函数:
1. push ebp 2. mov ebp, esp 3. sub esp, 0x28 4. mov eax, DWORD PTR[ebp+0x8] 5. mov DWORD PTR[esp+0x4], eax 6. lea eax, [ebp-0x14] 7. mov DWORD PTR [esp], eax 8. call 0x804831c <strcpy@plt> 9. leave 10. ret
我们单步调试上述的指令,关注其中esp值的变化。总图如下,后面是对其中每一步的分析:
在完成main.1后,命令p $esp后,esp的值变为:Esp = 0xbffff438
Main.3后,esp的值变为0xbffff430,估计是用于对齐;
Main.4后,esp的值为0xbffff420;
Main.7-10,这里主要将argv[]的arg[1]字符串的首地址取出来,并且将其放置在esp中,此时esp的值为0xbffff420;
执行overflow.1后,esp的值变为:0xbffff41c,其中存放着main的下一条语句的地址,
通过命令:x $esp可以看到overflow返回的地址:
0xbffff41c 0x0804841d
Overflow.1执行后,esp的值变为0xbffff418,用于存放ebp的值;
Overflow.3执行后,esp的值变为0xbffff3f0,
Overflow.5-7执行后,将字符串的地址放置在3f0+0x4地址处,然后再将临时字符串也即buf的首地址[ebp-0x14]放置在3f0地址处(当前的esp指向处);
执行完overflow.8后,我们查看buf起始处后,发现的确完成了内容的赋值,命令如下:
X 地址;
执行完leave指令后,发现esp的值变为:0xbffff41c,刚好指向的返回main的地址;然后再执行ret指令后,发现esp变为0xbffff420;然后,程序跳回到main函数;
然后再执行main函数中的leave指令,按照设想中leave指令执行后,esp的值应该变为43c,指向main的返回地址;但是实际我们执行后,esp的值变为0xbffff404,其中的内容刚好是0x61616161,也就是aaaa的值;这里我们测试时使用的参数的头四个字符刚好是aaaa;
到这里为止,就明白整个问题的症结:
Main函数中调用leave指令时,esp的值并没有调整到位。本来应该指向43c(前面的地址忽略)的,此处却指向的404?
那么为什么会产生这样的状况?照理说这是编译器该干的事,为什么此处编译器没有尽责呢?
奇怪的是:
不发生栈溢出时,也即我们输入的字符串长度不超过12时,main中的leave指令执行后,程序可以正常的返回到43c的位置,顺利退出;
那这里的问题就很奇怪了:栈溢出是发生在overflow中,程序可以从overflow中顺利返回到main中,然后main的leave指令就不正常了;如果overflow中没有栈溢出,程序也顺利返回到main函数,然后main的leave指令可以正常工作。
上述的问题的症结在于搞清楚leave指令本身;猜想其可能会依赖某些寄存器,或者依赖特定的存储单元来达到恢复目的。
首先看看能不能stepi进入leave;答案是leave是单条指令,不是一个处理函数;
所以我们试试能不能从寄存器上发现点什么,发现其实对照vistual studio中的操作,
Leave指令应该等效为:
Mov esp, ebp
Pop ebp
之前关注的都是esp,那按照上面的等效的话,接下来应该关注ebp寄存器的变化。所以,接下来要做的1. 首先验证上述的的等效成立,2. 要盯着ebp在执行过程中的变化。
对于问题1的验证,我们偷个懒,直接baidu之,发现的确符合我们的猜想,leave指令主要就是恢复esp和ebp之前的存储值。(后面顺带地测试了下,的确也主要做了对应的操作。)
如此我们就回到问题2,主要查看进入overflow和退出overflow时,以及进入main和退出main时,寄存器ebp值的变化。
会溢出的版本下,我们查看call strcpy前和后的ebp的值,如下图所示,我们发现调用strcpy函数的前与后过程中,ebp的值都是418(省略前面的),也就是说,调用strcpy函数过程前后ebp的值是正常的。
那么后续的执行leave指令,按照正常的版本(经过正常版本的验证),那么esp = 418,然后再经过pop ebp后,esp的值变为41c;而ebp的值应该恢复为438;
实际的执行后,esp如预期的发生变化,但是其中ebp的值却没有按照预想中的变化;那么问题只可能出在pop ebp这个语句,也只有一个原因:就是ebp存储的栈中的地址的内容被修改了,也就意味着418地址处的值在函数调用过程中被修改为400,原来应该是438。
那可能出现这种情况的也只有在overflow函数调用中,可能发生这样的情况。根据上述的分析,那我们在栈溢出版本中,在strcpy调用前后查看418处的值,即可证明这点。果然下面的图示正好说明该问题:
执行完strcpy函数后,看倒数最后一行,0xbffff418处的内容被修改为0xbffff400;作为对比,我们来看看非溢出版本的情况:
可以发现最后一行的ebp值没有被修改,因此,不会发生错误。
现在可以确定:ebp的值在溢出的strcpy调用中被修改了,从而导致主函数退出时的问题。那下面的问题是:strcpy是怎么样修改ebp值的?这个问题的解答要深入到strcpy的汇编代码里面,才能得到答案,本文不作探讨,有兴趣的可以再深入研究。
结论与启发:
1. 虽然函数是否可以顺利返回取决于栈上的返回地址,但是此例也让我们看到通过间接地修改ebp的值,也可以达到控制返回地址的目的;只不过这里的修改ebp值不会影响到当前函数的返回,但会影响上一级函数的返回;
2. 虽然对linux下的汇编不熟,但是借鉴visual studio下的代码的熟悉,还是容易读通linux下的汇编的;由此,可以体会的借鉴的价值,而借鉴的前提在于对某些技术的深入;
3. 找问题过程中,采用了对比的手法,比如对比BSD下的上述代码版本,可以按照设想的执行;让我确认上述代码的确存在问题;由此,可以体会对比的价值;