Learning KVM

主要参考的文章是david942j@217的这篇博客和这篇介绍

在做abyss这个题目的时候涉及到了很多hypervisor, kvm的内容。虚拟化这一层一般也是我们在逃逸的最后一层了。

Basic Intro

KVM的全称是Kernel Base Virtual Machine,基于内核的虚拟机,是基于x86架构的免费的开源的全虚拟化解决方案。云计算的发展让虚拟化技术变得火热,KVM作为一种结构简单的方案,使得很多云服务提供者使用其作为hypervisor,包括RedHat和Ubuntu等等。

每次说到Hypervisor可能会有点模棱两可,这里把hyperv说一下好了。称作虚拟机监视器,virtual machine monitor,缩写VMM。

首先我们有一个host,即主体机器。在host运行hypervisor,hypervisor提供虚拟的平台给客体操作系统guest,负责管理guest的执行。这些guest共享虚拟化后的硬件资源。

总的来说,hypervisor是运行在服务器和操作系统之间的中间软件层,允许多个操作系统共享硬件。

hypervisor的类型主要分为三种:

  • (1)直接运行在系统硬件上,称作“裸机”型
  • (2)运行在传统操作系统上,称作“托管(宿主)”型。调用资源时通过vm内核->hypervisor->主机内核,性能比较差。
  • (3)运行在传统操作系统上,创建独立的虚拟化容器,指向底层托管操作系统,称作操作系统虚拟化。

hypervisor

KVM提供分隔的独立的运行环境,KVM创建的每一个虚拟机都都有自己的虚拟化硬件,包括网卡,硬盘,图形适配器等等。

我们接下来的学习目标:

  • 如何使用KVM来执行简单的汇编代码
  • 通过KVM运行内核的关键点

需要的环境:

  • 开启了KVM

Get started

我们现在的位置在Host宿主机。想要和kvm交互需要通过ioctl,这个在内核我们已经见过很多次了。

Create a vm

通过kvm创建vm的7个步骤如下:

  • 通过open打开/dev/kvm kvmfd=open("/dev/kvm", O_RDWR|O_CLOEXEC)
  • 通过ioctl创建vm vmfd=ioctl(kvmfd, KVM_CREATE_VM, 0)
  • 为vm guest设置内存范围 ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, &region)
  • 为vm创建一个虚拟cpu vcpufd=ioctl(vmfd, KVM_CREATE_VCPU, 0) vcpu可以有多个
  • 给vcpu分配内存
    • vcpu_size=ioctl(kvmfd, KVM_GET_VCPU_MMAP_SIZE, NULL)
    • run=(struct kvm_run*)mmap(NULL, mmap_size, PROT_READ|PROT_WRITE, MAP_SHARED, vcpufd, 0)
  • 把汇编代码放入到用户空间,设置vcpu的寄存器的值,例如rip等等
  • 运行vm并根据退出状态做好处理 while(1) { ioctl(vcpufd, KVM_RUN, 0); ... }

丰富一下结构图:

hypervisor2

Create a vcpu

  • 通过ioctl vmfd创建vcpu拿到vcpufd
  • 通过ioctl kvmfd拿到vcpu的大小并mmap这么大的空间给vcpu
  • 设置vcpu的regs和sregs寄存器

Excute 16-bit assembled code

就是一个while 1的大循环。通过ioctl vcpufd run来运行,直到遇到需要退出vm的指令如hlt out等。需要对退出的原因进行处理,是结束运行还是IO中断还是出了问题shutdown等等。其中比较重要的是IO,inout指令会触发KVM_EXIT_IO。这要是hypervisor和host通信的手段。

需要注意的是现在没有页表所以不能运行32位/64位程序,现在处于实模式(Real Mode),只能运行16-bit的汇编代码。

关于IO需要用到INOUT指令:

image-20200531154232732

image-20200531154243233

也就是读写端口。我们现在还没有内核,自然也就没有syscall之类的,现在也没有hypercall。

目前的效果:

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
#include <fcntl.h>
#include <linux/kvm.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mman.h>

void kvm_init(uint8_t code[], size_t code_len)
{
int kvmfd = open("/dev/kvm", O_RDWR|O_CLOEXEC);
if(kvmfd < 0){
printf("kvmfd = %d, failed to open kvm\n", kvmfd);
exit(0);
}
printf("kvmfd = %d\n", kvmfd);
int api_ver = ioctl(kvmfd, KVM_GET_API_VERSION, 0);
if(api_ver < 0){
printf("api_ver = %d, faild to get api version\n", api_ver);
exit(0);
}
if(api_ver != 12){
printf("api_ver = %d, please use api ver 12\n", api_ver);
exit(0);
}
printf("api_ver = %d\n", api_ver);
int vmfd = ioctl(kvmfd, KVM_CREATE_VM, 0);
if(vmfd < 0){
printf("vmfd = %d, failed to create vm\n", vmfd);
exit(-1);
}
printf("vmfd = %d\n", vmfd);
size_t memsize = 0x40000000; // 1G
void *mem = mmap(0, memsize, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS,-1, 0);
printf("mem = %p\n", mem);
int user_entry = 0;
memcpy(mem+user_entry, code, code_len); // assemble code on the first page, address = 0
struct kvm_userspace_memory_region region =
{
.slot = 0,
.flags = 0,
.guest_phys_addr = 0,
.memory_size = memsize,
.userspace_addr = mem
};
ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, &region);

int vcpufd = ioctl(vmfd, KVM_CREATE_VCPU, 0);
printf("vcpufd = %d\n", vcpufd);
size_t vcpu_mmap_size = ioctl(kvmfd, KVM_GET_VCPU_MMAP_SIZE, 0);
printf("vcpu_mmap_size = 0x%x\n", vcpu_mmap_size);
struct kvm_run* run = (struct kvm_run*) mmap(0, vcpu_mmap_size, PROT_READ|PROT_WRITE, MAP_SHARED, vcpufd, 0);
printf("run = %p\n", run);

struct kvm_regs regs;
ioctl(vcpufd, KVM_GET_REGS, &regs);
regs.rip = user_entry; // zero address
regs.rsp = 0x200000; // stack address
regs.rflags = 0x2;
ioctl(vcpufd, KVM_SET_REGS, &regs);

struct kvm_sregs sregs;
ioctl(vcpufd, KVM_GET_SREGS, &sregs);
sregs.cs.base = 0;
sregs.cs.selector = 0;
ioctl(vcpufd, KVM_SET_SREGS, &sregs);

while(1){
ioctl(vcpufd, KVM_RUN, NULL);
switch(run->exit_reason){
case KVM_EXIT_HLT:
printf("KVM_EXIT_HLT\n");
return 0;
case KVM_EXIT_IO:
putchar(*((char *)run + run->io.data_offset));
break;
case KVM_EXIT_FAIL_ENTRY:
printf("KVM_EXIT_FAIL_ENTRY\n");
return 0;
case KVM_EXIT_INTERNAL_ERROR:
printf("KVM_EXIT_INTERNAL_ERROR\n");
return 0;
case KVM_EXIT_SHUTDOWN:
printf("KVM_EXIT_SHUTDOWN\n");
default:
printf("exit reason: %d\n", run->exit_reason);
}
}

}

int main()
{
// default: 16bit mode assemble code
uint8_t *code = "\xB0\x61\xBA\x17\x02\xEE\xB0\n\xEE\xF4";
size_t code_len = 35;
printf("Supposed to output a char 'a'\n");
kvm_init(code, 10);
return 0;
}

看起来还不是很规范,之后会把异常处理和一些工具类的函数整理一下。运行效果:

1
2
3
4
5
6
7
8
9
10
11
➜  kvm ./a.out 
Supposed to output a char 'a'
kvmfd = 3
api_ver = 12
vmfd = 4
mem = 0x7ffa2394d000
vcpufd = 5
vcpu_mmap_size = 0x3000
run = 0x7ffa63f62000
a
KVM_EXIT_HLT

汇编代码是

1
2
3
4
5
6
7
.code16
mov al, 0x61
mov dx, 0x217
out dx, al
mov al, 10
out dx, al
hlt

目前只能输出一个字节。

Switch to long mode

为了能够运行64位的程序,我们需要将vcpu从实模式切换到长模式。切换的方法可以参考这里:Enter long mode。

image-20200531124421606

其中主要是关于各种页表结构让人眼花缭乱。在x86-64中使用的内存管理是PAE模式,也就是物理地址扩展。这就涉及到四个管理的结构体:

  • PML4T (page-map level-4 table) 页映射级4表
  • PDPT (page-directory pointer table) 页目录指针表
  • PDT (page-directory table) 页目录
  • PT (page table) 页表

具体的关系:

  • PML4T中的每一项是PDPT的地址
  • PDT的每一项是PDT的地址
  • PDT的每一项是PT的地址
  • PT的每一项是一个物理地址

X86_Paging_64bit.svg

具体的关系看这个图就清楚很多了,图片来源在这里,内容是4KB的分页机制。除了4K的分页我们还有2M的分页,这种情况不需要PT,PDT直接就指向了物理地址,如图:

image-20200531142709227

控制寄存器(CR*)一般用来存储分页的属性。例如CR3寄存器用来保存PML4的物理地址。在寻址的过程中最开始就是通过CR3来找到PML4的。

Setup page table

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
void set_pagetable(void* mem, struct kvm_sregs *sregs){
uint64_t pml4_addr = 0x1000;
uint64_t *pml4 = (void *)(mem + pml4_addr);

uint64_t pdpt_addr = 0x2000;
uint64_t *pdpt = (void *)(mem + pdpt_addr);

uint64_t pd_addr = 0x3000;
uint64_t *pd = (void *)(mem + pd_addr);

pml4[0] = pdpt_addr | 3; // PDE64_PRESENT | PDE64_RW
pdpt[0] = pd_addr | 3;
pd[0] = 0x80 | 3; // 2M pagging
printf("pml4[0] = %p\n", pml4[0]);
printf("pdpt[0] = %p\n", pdpt[0]);
printf("pd = %p\n", pd[0]);

sregs->cr3 = pml4_addr;
sregs->cr4 = 1 << 5; // PAE
sregs->cr4 |= 0x600; // CR4_OSFXSR | CR4_OSXMMEXCPT enable sse
sregs->cr0 = 0x80050033; // CR0_PE | CR0_MP | CR0_ET | CR0_NE | CR0_WP | CR0_AM | CR0_PG
sregs->efer = 0x500; // EFER_LME | EFER_LMA

}
void set_segment_regs(struct kvm_sregs *sregs){
struct kvm_segment seg = {
.base = 0,
.limit = 0xffffffff,
.selector = 1 << 3,
.present = 1,
.type = 11, /* execute, read, accessed */
.dpl = 0,
.db = 0,
.s = 1,
.l = 1,
.g = 1,
};
sregs->cs = seg;
seg.type = 3; /* read/write, accessed */
seg.selector = 2 << 3;
sregs->ds = sregs->es = sregs->fs = sregs->gs = sregs->ss = seg;

}

修改

1
2
3
4
//sregs.cs.base = 0;
//sregs.cs.selector = 0;
set_pagetable(mem, &sregs);
set_segment_regs(&sregs);

这里做的地址映射是

1
/* Maps: 0 ~ 0x200000 -> 0 ~ 0x200000 */

可以参考一下这本书,里面关于哥哥标志位的内容。

因为我们只给pml4 pdpt pd的第0项做了映射,所以我们只能找到第一个2M的页(2^21),也就是0~0x200000,所以目前我们只能使用0~0x200000的地址。

address

现在我们可以运行64bit的汇编代码了。

1
2
3
4
5
6
7
int main()
{
uint8_t *code = "H\xB8\x41\x42\x43\x44\x31\x32\x33\nj\bY\xBA\x17\x02\x00\x00\xEEH\xC1\xE8\b\xE2\xF9\xF4";
size_t code_len = 100;
kvm_init(code, code_len);
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
➜  kvm ./a.out 
kvmfd = 3
api_ver = 12
vmfd = 4
mem = 0x7f629fee3000
vcpufd = 5
vcpu_mmap_size = 0x3000
run = 0x7f62e04f8000
pml4[0] = 0x2003
pdpt[0] = 0x3003
pd = 0x83
ABCD123
KVM_EXIT_HLT
1
2
3
4
5
6
7
8
9
  movabs rax, 0x0a33323144434241
push 8
pop rcx
mov edx, 0x217
OUT:
out dx, al
shr rax, 8
loop OUT
hlt

现在关于KVM的部分基本结束了。

Implement a simple kernel

一些需要思考的问题:

  1. CPU如何区分内核态和用户态?
  2. 用户调用syscall时CPU如何陷入内核?
  3. CPU如何用户态和内核态中切换?

kernel mode vs. user mode

内核态和用户态的一个很大的区别在于,有一些指令是只能在内核态执行的,例如hltwrmsr。这两种模式主要通过cs.dpl来区别。用户态为3,内核态为0,这似乎和ring3 ring0是一致的。

另外对于页表的权限需要做一些修改。在之前的代码中我们设置的是用户不可访问。

1
2
3
pml4[0] = pdpt_addr | 3; // PDE64_PRESENT | PDE64_RW
pdpt[0] = pd_addr | 3;
pd[0] = 0x80 | 3; // 2M pagging

如果想要用户可以访问的话,页表的3rd bit要设置为1,即设置PDE64_USER标志位,在代码中就是| 7了。这步骤需要内核来完成,在hypervisor中不应该设置用户可访问。否则用户可以直接修改内核的页面了。可以出个题。

syscall

在进入长模式之前将EFER寄存器设置好,从而可以使用syscall/sysenter。

1
sregs->efer = 0x500; // EFER_LME | EFER_LMA (Long Mode Enable and Long Mode Active)

开启syscall

1
sregs->efer |= 0x1; // EFER_SCE

另外还需要设置syscall_handler,让CPU知道对应的syscall需要跳转到哪里执行。这部分是内核需要做的事情。注册syscall handler需要通过MSR来完成。通过ioctl和vcpufd可以完成MSR的get/set操作。或者在内核中通过rdmsrwrmsr指令完成。

注册syscall handler:

1
2
3
4
5
6
7
8
9
10
11
12
  lea rdi, [rip+syscall_handler]
call set_handler
syscall_handler:
// handle syscalls!
set_handler:
mov eax, edi
mov rdx, rdi
shr rdx, 32
/* input of msr is edx:eax */
mov ecx, 0xc0000082 /* MSR_LSTAR, Long Syscall TARget */
wrmsr
ret

switch between kernel and user

需要注册cs.selector,包括内核和用户,通过MSR可以完成这一步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
register_syscall:
xor rax, rax
mov rdx, 0x00200008
mov ecx, 0xc0000081 /* MSR_STAR */
wrmsr

mov eax, 0x3f7fd5
xor rdx, rdx
mov ecx, 0xc0000084 /* MSR_SYSCALL_MASK */
wrmsr

lea rdi, [rip + syscall_handler]
mov eax, edi
mov rdx, rdi
shr rdx, 32
mov ecx, 0xc0000082 /* MSR_LSTAR */
wrmsr

之后用户态就可以正常使用syscall了。

syscall_handler:

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
.globl syscall_handler, kernel_stack
.extern do_handle_syscall
.intel_syntax noprefix

kernel_stack: .quad 0 /* initialize it before the first time switching into user-mode */
user_stack: .quad 0

syscall_handler:
mov [rip + user_stack], rsp
mov rsp, [rip + kernel_stack]
/* save non-callee-saved registers */
push rdi
push rsi
push rdx
push rcx
push r8
push r9
push r10
push r11

/* the forth argument */
mov rcx, r10
call do_handle_syscall

pop r11
pop r10
pop r9
pop r8
pop rcx
pop rdx
pop rsi
pop rdi

mov rsp, [rip + user_stack]
.byte 0x48 /* REX.W prefix, to indicate sysret is a 64-bit instruction */
sysret

hypercall

syscall是用户和内核之间的交互,hypercall是内核和hypervisor之间的交互。

calls

在之前的代码中,用out指令来输出一个字节。还可以做很多其他的事情。在in out指令中用到了两个参数,16-bit的dx和32-bit的eax,可以做如下设计:

  • dx用来区分是哪一种hypercall
  • eax是hypercall的参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
while (1) {
ioctl(vm->vcpufd, KVM_RUN, NULL);
switch (vm->run->exit_reason) {
/* other cases omitted */
case KVM_EXIT_IO:
// putchar(*(((char *)vm->run) + vm->run->io.data_offset));
if(vm->run->io.port & HP_NR_MARK) {
switch(vm->run->io.port) {
case NR_HP_open: hp_handle_open(vm); break;
/* other cases omitted */
default: errx(1, "Invalid hypercall");
}
else errx(1, "Unhandled I/O port: 0x%x", vm->run->io.port);
break;
}
}

可以根据IO的端口来进行不同的操作,例如open等等。

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
/* hypervisor/hypercall.c */
static void hp_handle_open(VM *vm) {
static int ret = 0;
if(vm->run->io.direction == KVM_EXIT_IO_OUT) { // out instruction
uint32_t offset = *(uint32_t*)((uint8_t*)vm->run + vm->run->io.data_offset);
const char *filename = (char*) vm->mem + offset;

MAY_INIT_FD_MAP(); // initialize fd_map if it's not initialized
int min_fd;
for(min_fd = 0; min_fd <= MAX_FD; min_fd++)
if(fd_map[min_fd].opening == 0) break;
if(min_fd > MAX_FD) ret = -ENFILE;
else {
int fd = open(filename, O_RDONLY, 0);
if(fd < 0) ret = -errno;
else {
fd_map[min_fd].real_fd = fd;
fd_map[min_fd].opening = 1;
ret = min_fd;
}
}
} else { // in instruction
*(uint32_t*)((uint8_t*)vm->run + vm->run->io.data_offset) = ret;
}
}

在内核中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#define HP_NR_MARK 0x8000
#define NR_HP_open (HP_NR_MARK | 0)
...
/* kernel/hypercalls/hp_open.c */
int hp_open(uint32_t filename_paddr) {
int ret = 0;
asm(
"mov dx, %[port];" /* hypercall number */
"mov eax, %[data];"
"out dx, eax;" /* trigger hypervisor to handle the hypercall */
"in eax, dx;" /* get return value of the hypercall */
"mov %[ret], eax;"
: [ret] "=r"(ret)
: [port] "r"(NR_HP_open), [data] "r"(filename_paddr)
: "rax", "rdx"
);
return ret;
}

Almost done

还需要的工作

  • execve运行ELF文件,这需要一些对ELF文件格式的处理
  • 内存分配,实现自己的malloc&free
  • paging 维护页表,需要注意有些地址只有内核可以访问,有些是用户能够访问
  • 权限检查 所有从用户传入的参数都需要做好检查,否则用户可能会对内核进行任意地址读写

Conclusion

感觉看了一遍基本是在翻译博客..?关于KVM的一些基础应该是差不多了。下面的计划:

  • 进一步完善hypervisor
  • 开始写kernel的部分
  • 完成user的部分

因为已经有代码可以做参考所以问题应该不大。顺便做一下这种稍微大工程一点的代码(太久没写量大一点的代码了: p)。