栈溢出的基本原理以及shellcode利用可以查看我的上一篇文章——栈溢出之shellcode。
栈溢出缓解措施介绍
栈地址随机化(攻击者不知道shellcode的地址,无法构造return address):ASLR(地址空间随机化)、PIE(地址无关可执行文件)
检测栈溢出,并报错退出:Stack Canary/Cookie
栈不可执行:NX(不执行)/DEP(数据执行保护)
ASLR 和 PIE
ASLR是地址空间随机化。开启后,堆、栈、共享库都会有一个随机偏移,堆和共享库往上(高地址处)偏移一个随机量,栈往下(低地址处)偏移。随机粒度为0x1000,一个内存页的大小。
单独开启ASLR,虽然堆、栈、共享库都会被随机化,但是程序的入口代码加载还是在固定地址0x804800。
PIE(地址无关代码)开启后,编译器会在编译时生成地址无关代码,编译选项-fPIC -pie
,这样程序就可以在随机地址处被加载。也就是程序的代码以及数据段会往上(高地址处)偏移一个随机量。如下图所示。
使用ldd
命令查看加载库的地址,可以看到两次都不一样。
1 | $ ldd a.out |
查看程序加载地址:
1 | $ gcc test.c -m32 -fPIC -pie -o a2.out |
- 只是开启了ASLR,没有开启PIE的话,可以通过
Return to PLT
,绕过共享库随机化。(比如将shellcode放置在.data段,这部分是不会随机化的) - x86_32架构下爆破(因为随机化粒度为0x1000,所以只有几千种可能)
- info leak(信息泄露,比如在我的(译)Analysis and exploitation of Pegasus kernel vulnerabilities (CVE-2016-4655 / CVE-2016-4656)文章中介绍的CVE-2016-4655漏洞)
- nop sled(在shellcode之前添加大段nop指令,在上一篇栈溢出之shellcode的文章中,使用了这种方法)
- heap spray(堆喷)
- 在本地环境中,可以使用
ulimit -s unilimited
命令(程序崩溃后会生成core dump文件,可以在这个文件中查看shellcode地址)
Stack Canary/Cookie
这个机制是用来检测栈溢出的。对于需要保护的函数,在执行之前,将一个随机值放在栈上,这个值被称为Canary。在64位上一般在rbp-0x8的位置,32位在ebp-0x4的位置,也就是ebp保存地址往下(低地址)的部分。然后在函数返回之前,会先检测这个值是否发生改变。stack_chk_fail的错误信息就是由于栈溢出。通过编译选项gcc -fstack-protector
可以开启。
可以通过泄露Canary的方式绕过(同一进程不同线程的函数的Canary是一样的,比如read函数)
控制覆盖长度(只覆盖局部变量,不覆盖返回地址)
修改Canary(Canary保存在TLS(Thread-local Storage)寄存器中,一般是偏移0x14的位置stack_guard)
劫持stack_chk_fail(canary被覆盖时,就会调用这个函数,劫持后可跳转到自己的shellcode,比如GOT表劫持)
利用stack_chk_fail的报错信息(在报错信息中,会打印栈溢出的程序名,即argv[0],可以覆盖这个地址为其他地址,输出我们想知道的内容)
NX/DEP
栈不可执行主要是对程序的内存块权限进行更进一步的划分。堆、栈以及数据区可写,但不可执行。代码区、只读数据区不可写,但可执行。也就是说我们要找一块内存区域是既可写也可以执行的,来写入shellcode,是找不到了。
绕过NX/DEP所需要的方法有一个基本思想,就是Return to Libc。因为我们不能执行写入的代码,那我们就跳转到程序已经加载了的代码中执行,比如libc中的大量函数,system()等等。
如上图所示,我们将函数的return地址覆盖为libc中的system函数地址,当进程跳转到这个地址时,会执行system函数内部的指令,首先是所有函数都有的,push ebp;
跟 mov ebp, esp;
这两条指令,即将exit
的地址push到。需要读取栈上的参数,即”/bin/sh”,
Return to libc
shellcode以及含有栈溢出漏洞的代码都采用的是跟我上一篇文章一样的代码。
含有栈溢出漏洞的代码:
1 |
|
编译命令gcc test.c -m32
需要关闭ASLR:echo 0 > /proc/sys/kernel/randomize_va_space
跟前文不同的是,由于没有关闭NP/DEP,所以栈上的shellcode是无法执行的,所以我们要用已经加载的库中的函数来执行我们想要的代码。
首先,使用gdb查看system以及exit函数地址,在libc中查找字符串"/bin/sh"
地址:
1 | $ gdb ./a.out |
这一步,也可以使用reaelf
strings
等命令查看system、exit函数地址,以及/bin/sh字符串地址:
1 | $ ldd a.out #查看libc库起始地址 |
然后对应着前面的图,填充地址就可以执行了:
1 | $ ./a.out $(python -c 'print "A" * 140 + "\x70\x6f\xe3\xf7" + "\xa0\xa7\xe2\xf7" + "\x15\x80\xf7\xf7" + "\0\0\0\0"') |
这里的A*140,是因为从buffer的起始地址到ret返回地址有140个字节的长度(具体计算可参考上一篇有关shellcode的文章)。
这里需要注意的是,如果地址中出现0a,比如0xf7f70aa0,为\xa0\x0a\xf7\xf7,在这里\x0a在命令行参数中代表换行符\n,所以会截断字符,这是就需要替换掉这个地址,找其他函数或者字符串。如果是\x20,代表回车,需要用双引号括起来。
Return to libc
方法的不足之处在于,如果开启了ASLR,那么我们就没法事先确定动态库的加载位置了。
另外还有一种方法叫Return to PLT
,PLT在我的GOT表劫持这篇文章中有介绍,是在懒加载的时候使用的,如果程序已经调用过了这个动态库函数,那么可以直接通过PLT调用,不需要知道它的实际地址。
ROP (Return Oriented Programming)
在Return to libc
中我们直接找的函数地址,我们也可以将函数拆成几条指令组成的以ret
指令结尾的小代码片段的集合,来执行,这就是ROP。(Return to libc其实就是ROP中的一个特例)
这些小的以ret
指令结尾的代码片段,被称之为ROP gadget
。(之所以是ret结尾,是因为ret之后会从栈中读取下一个返回地址(gadget),然后继续执行,这样才可以把gadget拼接到一起)
多个ROP gadget
拼接成的可执行片段称为ROP链。
在栈上填充的用于执行ROP链的数据,成为ROP Payload
。
ROP是使用以ret结尾的片段。
使用以jmp结尾的片段叫做JOP,
pop esi; jmp dword[esi+0x10]
以call结尾的片段叫做COP,
mov eas, dword[esp+0x20]; call dwork[eax+0x20]
(JOP和COP有时也被合并到ROP中)
ROP Gadget现在已经有很多成熟的搜索工具,比较常用的是ROPGadget。
关于ROP的具体利用方式请查看下一篇文章栈溢出之 ROP(二)