又是一道从userspace pwn到hypervisor的题目,虽然漏洞较为容易,但在利用和调试方面还是需要对虚拟化和kernel有一定的了解。在做题的过程中,也可以增进对KVM API,OS Kernel的内存管理和中断处理,以及x86-64的分页机制的理解,还是相当有趣的。

之前在TWCTF中,曾经出现过类似的题目EscapeMe. 受它的影响,本次Hitcon也出现了模式几乎一样的题目,玩了1天多感觉很有意思,在大佬的指导下体验了一回VM Escape.

赛后作者也放出了相关源码和利用,可以拿来复现一下
https://github.com/david942j/ctf-writeups/tree/master/hitcon-2018/abyss

Challenge Intro

整个题目由3个binary,hypervisor.elfkernel.binuser.elf,还有3个flag组成。

hypervisor.elf是一个利用KVM API来做虚拟化的程序,它会加载一个小型的内核kernel.bin,这个kernel就只实现了内存管理和中断处理的功能,提供了loader启动和libc加载需要的一些常见syscall,然后解析ELF启动一个用户态程序。这里直接加载ld.so.2来装载用户态程序user.elf.

user.elf就是一个标准的x86-64 ELF文件,也可以直接在host上启动。kernel.bin在处理syscall时,将一些与IO有关的例如read/write等通过 I/O Port (CPU的in/out指令) 交给hypervisor来处理。例如open这个syscall,kernel在做检查之后,直接通过hypercall传给hypervisor处理,然后hypervisor会在host上打开一个文件,并将其fd做一个映射返回给kernel. 所以实际上VM内做的open是可以打开host的文件的。

本题的3个flag都位于host上,flag1在pwn掉user.elf之后可以通过shellcode来读,flag2需要pwn掉内核,第3个flag由于不知道文件名,需要pwn掉hypervisor.

KVM Basis

在使用KVM来做虚拟化时,需要给VM分配VCPU和memory,通过ioctl的KVM_SET_USER_MEMORY_REGION request来给VM插上一块物理内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void *mem = mmap(0,
MEM_SIZE,
PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS,
-1, 0);
...
struct kvm_userspace_memory_region region = {
.slot = 0,
.flags = 0,
.guest_phys_addr = 0,
.memory_size = MEM_SIZE,
.userspace_addr = (size_t) mem
};
if(ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, &region) < 0) {
pexit("ioctl(KVM_SET_USER_MEMORY_REGION)");
}

这样一来hypervisor的mem这块位置就被作为VM的物理内存来使用了

当VCPU和memory都设置好之后,就可以使用ioctl的KVM_RUN request来启动VM,当遇到中断或者异常时,ioctl就会返回,这时可以通过检查exit_reason来得知中断或者异常的类型并做相应处理,例如I/O的请求,就可以这时进行处理。

User.elf

user.elf是一个传统的用户态pwnable,漏洞也比较容易发现,就是swap的时候没有检查可以直接改掉stack_pointer。需要注意的是程序内是没有NX的,所以可以直接修改GOT表来跳到bss的shellcode上,之后常规open flag/read/write即可拿到第1个flag.

Kernel.bin

kernel逆起来要稍稍复杂一点,但本身功能不多,结合与hypervisor之间约定的I/O Port相关的信息,可以找到它的系统调用表,然后逐个分析syscall即可。hypervisor给kernel的物理内存有0x2000000大小,kernel被加载到0的位置,0-0x200000为内核地址空间,高地址0x200000-0x2000000为userspace.

第2个flag的名字叫flag2,但是kernel在做open syscall的时候,对打开的参数做了一个白名单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int sys_open(const char *path) {
if(!access_string_ok(path)) return -EFAULT;
void *dst = copy_str_from_user(path);
if(dst == 0) return -ENOMEM;

/* do whitelist here */
if(!(
#define OK(str) strcmp(dst, #str) == 0
OK(ld.so.2) ||
OK(/lib/x86_64-linux-gnu/libc.so.6) ||
OK(/proc/sys/kernel/osrelease) ||
OK(/etc/ld.so.cache) ||
OK(./user.elf) ||
OK(flag)
#undef OK
)) return -ENOENT;

int fd = hp_open(physical(dst));
kfree(dst);
return fd;
}

想要打开flag2,就只有exploit kernel了,在处理read syscall时,可以发现其没有检查kmalloc返回0的情况

1
2
3
4
5
6
7
8
9
int64_t sys_read(int fildes, void *buf, uint64_t nbyte) {
if(fildes < 0) return -EBADF;
if(!access_ok(VERIFY_WRITE, buf, nbyte)) return -EFAULT;
void *dst = kmalloc(nbyte, MALLOC_NO_ALIGN);
int64_t ret = hp_read(fildes, physical(dst), nbyte);
if(ret >= 0) memcpy(buf, dst, ret);
kfree(dst);
return ret;
}

这里作者使用的方式是将内存耗尽,首先mmap一块0x1000000的内存,之后再read 0x1000000 bytes,这样kmalloc就会因为空间不足而返回0,这样就直接从0开始read,可以直接改掉内核代码,执行内核shellcode了。在hypervisor处理完read再次进到VM时,实际是从in/out那里开始执行,一直覆盖到那里就可以了。之后执行内核shellcode,利用hypercall open flag2/read/write就可以拿到flag2.

Hypervisor.elf

现在我们已经可以调用任意hypercall了,在hypervisor里有一个0x8008 port可以处理ioctl请求,但整个kernel并没有使用这个port,也不提供ioctl这个syscall. 看一下ioctl hypercall的处理就会发现,没有做任何检查。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* I'm sorry.. this is a backdoor.. */
static int hp_handle_ioctl(VM *vm) {
static int ret = UNUSED_VAR;
PROCESS {
uint32_t offset = FETCH_U32;
const uint64_t *kbuf = (uint64_t*) MEM_AT(offset);
int fd = kbuf[0];
unsigned long request = kbuf[1];
uint32_t paddr = kbuf[2];
if(paddr == 0) ret = ioctl(fd, request, 0);
else ret = ioctl(fd, request, MEM_AT(paddr));
if(ret < 0) ret = -errno;
} THEN_RETURN(ret);
return 0;
}

这就意味着我们可以在host上以任意参数来调用一个ioctl,回想之前KVM的memory分配,我们其实就可以直接用KVM_SET_USER_MEMORY_REGION来将hypervisor其他的可写内存给插到VM里面。首先需要读一下/proc/self/maps文件leak hypervisor的地址,之后构造一个memory region.

1
2
3
4
5
6
7
struct kvm_userspace_memory_region region = {
.slot = 1,
.flags = 0,
.guest_phys_addr = 0x2000000,
.memory_size = 0x21000,
.userspace_addr = [stack_addr]
};

这样就可以把hypervisor的栈直接映射到VM里0x2000000的位置,也就是说在VM里操作这块的物理内存就相当于操作hypervisor的stack. 但现在我们还不能直接在kernel中访问这块地址,因为它没有页表项。看一下x86-64的页表结构

Page Translation

图片来源:https://www.cs.uaf.edu/2012/fall/cs301/lecture/11_05_mmap.html

在64位的long mode下,所有的访存都需要经过页表,而hypervisor和kernel并没有为高于0x2000000设置页映射,所以我们需要手动为它加上一个页表项。kernel在刚启动时对page table做了下面的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
/* Maps
* 0x8000000000 ~ 0x8002000000 -> 0 ~ 0x2000000
*/
void init_pagetable() {
uint64_t* pml4;
asm("mov %[pml4], cr3" : [pml4]"=r"(pml4));
uint64_t* pdp = (uint64_t*) ((uint64_t) pml4 + 0x3000);
pml4[1] = PDE64_PRESENT | PDE64_RW | (uint64_t) pdp; // 0x8000000000
uint64_t* pd = (uint64_t*) ((uint64_t) pdp + 0x1000);
pdp[0] = PDE64_PRESENT | PDE64_RW | (uint64_t) pd;
for(uint64_t i = 0; i < 0x10; i++)
pd[i] = PDE64_PRESENT | PDE64_RW | PDE64_PS | (i * KERNEL_PAGING_SIZE);
}

在PML4的第1项上放了1个PDP,在PDP中初始化了16个page,这些Page都是带有PDE64_PS标志位的,这代表着它们不是下一级PD的地址,而是1个0x200000(2MB)的page的直接映射。因为PML4的第1项对应的Virtual Address的39-47位为1,实际上就是一组0x8000000000 ~ 0x8002000000到0 ~ 0x2000000的映射。

我们可以如法炮制,在PDP的末尾加上1项,将0x8002000000 ~ 0x8002200000映射到0x2000000 ~ 0x2200000,这样就可以通过0x8002000000来访问物理地址0x2000000,也就是hypervisor栈的底部了。

之后就是在这段内存中搜索KVM_RUN这个ioctl调用的返回地址,并把ROP链布置上去,再用hlt触发中断就可以了。