最近在CTF中QEMU逃逸题目出现的频率也越来越高了,在这里记录一下常见的攻击面和进行交互的方式。对于虚拟化逃逸来说,其实就是打hypervisor这个用户态的程序,所以在明白交互的原理之后流程就和一般的pwn差不多了。

Device

这应该是最常见的一种形式了,在qemu中新实现一种设备并引入漏洞,guest通过与设备进行交互进行漏洞利用。

在host上使用下面的命令来查看qemu支持的设备

1
./qemu-system-x86_64 -device help

也可以boot到guest之后使用lspci -nvv来查看pci设备的具体信息。

Linux kernel与设备通信有PIO, MMIO, DMA等形式,通常是kernel会实现一套driver在内核态与device交互,再开放API供用户态程序使用。
但其实在用户态也是可以直接访问到设备的,这就意味着我们可以直接编写用户态的程序来进行交互了。

Port I/O

PIO可以算是最原始的一种模式了,CPU执行in/out指令来向I/O端口发送数据。
cat /proc/ioports能够看到目前port的一些信息和它们对应的设备。

in/out指令是特权指令,一般只有在ring 0时才能执行,linux内核提供了 inb / outb / inw / outw / inl / outl,用于向port发送不同长度的数据。
不过在linux中可以通过 iopl ioperm 这两个系统调用对port的权能进行设置。

1
2
#include <sys/io.h>
int ioperm(unsigned long from, unsigned long num, int turn_on);

ioperm可以将from之后的num个port打开供用户态程序使用,当在用户态执行in/out对这些port进行读写时不会触发fault.
这个调用需要进程是root权限或者具有CAP_SYS_RAWIO权能。

另外,还可以通过打开/dev/port这个文件,再lseek到对应port的offset进行读写,同样可以实现PIO.

具体的使用可以看这两个例子inp.c,outp.c.

如果没有/dev/port文件,可以使用 mknod -m 660 /dev/port c 1 4 来创建。

CTF中的例子可以参见SECCON 2018的q-escape

MMIO (Memory-mapping I/O)

另一种进行交互的方式是使用MMIO,CPU将一部分设备的寄存器映射到一段特殊的物理内存地址,当进行访存时就相当于对这些寄存器进行操作。

cat /proc/iomem能够看到与I/O相关的一些内存地址信息。

通过kernel提供的sysfs,我们可以直接映射出设备对应的内存,具体方法是打开类似 /sys/devices/pci0000:00/0000:00:04.0/resource0 的文件,并用mmap将其映射到进程的地址空间,就可以对其进行读写了。这里的设备号0000:00:04.0是需要事先在/proc/iomem中看好的。当映射完成后,就可以对这块内存进行读写操作了,内存读写会触发到qemu内设备的mmio处理函数(一般会叫xxxx_mmio_read/xxxx_mmio_write),传入的参数是写入的地址偏移和具体的值。

在qemu这边,需要对这块内存区域用memory_region_init_io进行注册,传入MemoryRegionOps结构,提供相应的读写处理函数。

另外,还可以通过打开/dev/mem这个文件直接操作物理内存,mmap设备对应的地址进行读写,也是相同的效果。

如果没有/dev/mem文件,可以使用 mknod -m 660 /dev/mem c 1 1 来创建。

这里有一个我经常使用的交互模板

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
#include <assert.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <unistd.h>

unsigned char* iomem;

void die(const char* msg)
{
perror(msg);
exit(-1);
}

void iowrite(uint64_t addr, uint64_t value)
{
*((uint64_t*)(iomem + addr)) = value;
}

uint64_t ioread(uint64_t addr)
{
return *((uint64_t*)(iomem + addr));
}

void iowrite32(uint64_t addr, uint32_t value)
{
*((uint32_t*)(iomem + addr)) = value;
}

uint32_t ioread32(uint64_t addr)
{
return *((uint32_t*)(iomem + addr));
}

int main(int argc, char *argv[])
{
// Open and map I/O memory
int fd = open("/sys/devices/pci0000:00/0000:00:04.0/resource0", O_RDWR | O_SYNC);
if (fd == -1)
die("open");

iomem = mmap(0, 0x10000, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (iomem == MAP_FAILED)
die("mmap");

printf("iomem @ %p\n", iomem);
// Do something
return 0;
}

DMA

8会:)

Hypercall

hypercall是guest kernel向hypervisor发出的一种调用形式,广义上说像vmtools的RPCI机制使用特定I/O端口实现的也可以称之为hypercall. 还有一种方式是通过一些特权指令,例如 vmmcall.

即使不在kvm模式(KVM的hypercall实现可以参考我之前的一篇abyss writeup),QEMU也仍然可以通过TCG实现hypercall,具体来说就是在进行binary translation时,为vmmcall定制helper函数,完成特定的功能就可以了。
此次XCTF Final中的QTCG就是这样实现的,但这样一来我们需要直接执行vmmcall指令,这如果在用户态执行会产生fault,所以我们就在内核态用kernel module来完成利用。

编写一个可以进行简单交互的kernel module pwn.c

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
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/module.h>

MODULE_DESCRIPTION("My kernel module");
MODULE_AUTHOR("Me");
MODULE_LICENSE("GPL");

static int hcall(int nr, uint64_t arg1, uint64_t arg2, uint64_t arg3)
{
int ret;
asm volatile ("movl %1, %%eax\n"
"movq %2, %%rdi\n"
"movq %3, %%rsi\n"
"movq %4, %%rdx\n"
"vmmcall\n"
"movl %%eax, %0"
: "=r"(ret)
: "r"(nr), "r"(arg1), "r"(arg2), "r"(arg3)
: "%eax", "%rdi", "%rsi", "%rdx"
);
return ret;
}

static int pwn_init(void)
{
printk(KERN_INFO "Hi\n");
return hcall(0x1, 0x1337, 0xdead, 0x1234);
}

static void pwn_exit(void)
{
printk(KERN_INFO "Bye\n");
}

module_init(pwn_init);
module_exit(pwn_exit);

再编写一个对应的makefile,因为kernel module与linux的内核版本是紧密相关的,根据kernel的文档,在有kernel源码的时候,可以直接使用Kbuild的Makefile来build. 当然kernel源码也需要事先build一些东西,例如 make modules_prepare,具体可以参看文档。

1
2
3
4
5
6
7
8
obj-m := pwn.o

all: build

build:
make -C ./linux-5.0.5/ M=`pwd`
clean:
make -C ./linux-5.0.5/ M=`pwd` clean

通过指定 obj-m 变量告诉Kbuild我们build的文件名pwn,在执行make之后,会生成对应的pwn.ko.

对于当前运行的内核进行编译,makefile可以这样写(需要安装kernel headers包).

1
2
3
4
5
6
7
8
obj-m := pwn.o

all: build

build:
make -C /lib/modules/$(shell uname -r)/build M=`pwd`
clean:
make -C /lib/modules/$(shell uname -r)/build M=`pwd` clean

总结

完成hypervisor的漏洞利用对只做过用户态pwn的同学来说是一个比较大的考验,不仅要掌握hypervisor这边的一些实现原理,更需要对guest内部的kernel工作机制有所了解,而这其实也正是计算机系统最底层的知识。
如果在这方面基础薄弱(计原,操统没好好学)的话,还是应该补补课,算是系统安全的基本素养之一。

将虚拟化逃逸和kernel pwn放在一起来学习,也是一个不错的选择。

Reference