开始接触更难的知识,不要怕,冲就对了。
KVM 基础知识
kvm是一个内核模块,它实现了一个/dev/kvm的字符设备来与用户进行交互,通过调用一系列ioctl函数可以实现qemu和kvm之间的切换。
KVM结构体
KVM结构体在 KVM的系统架构中代表一个具体的虚拟机,当通过 VM_CREATE_KVM 指令创建一个新的 KVM结构体对象。
struct kvm结构体如下:
1 | struct kvm { |
KVM结构体对象包含了 vCPU、内存、APIC、IRQ、MMU、Event时间管理等信息,该结构体中的信息主要在 KVM虚拟机内部使用,用于跟踪虚拟机的状态。
在 KVM中,连接了如下几个重要的结构体成员,他们对虚拟机的运行有重要作用。
struct kvm_memslots *memslots;KVM虚拟机所分配到的内存slot,以数组形式存储这些slot的地址信息。
由于客户机物理地址不能直接用于宿主机物理MMU进行寻址,所以需要把客户机物理地址转换成宿主机虚拟地址(Host Virtual Address, HVA),为此,KVM用一个kvm_memory_slot数据结构来记录每一个地址区间的映射关系,此数据结构包含了对应此映射区间的起始客户机页帧号(Guest Frame Number, GFN),映射的内存页数目以及起始宿主机虚拟地址。于是KVM就可以实现对客户机物理地址到宿主机虚拟地址之间的转换,也即首先根据客户机物理地址找到对应的映射区间,然后根据此客户机物理地址在此映射区间的偏移量就可以得到其对应的宿主机虚拟地址。进而再通过宿主机的页表也可实现客户机物理地址到宿主机物理地址之间的转换,也即 GPA 到 HPA 的转换。struct kvm_vcpu *vcpus[KVM_MAX_VCPUS];KVM虚拟机中包含的vCPU结构体,一个虚拟机CPU对应一个vCPU结构体。struct kvm_io_bus *buses[KVM_NR_BUSES];KVM虚拟机中的I/O总线,一条总线对应一个kvm_io_bus结构体,如ISA总线、PCI总线。struct kvm_vm_stat stat;KVM虚拟机中的页表、MMU等运行时的状态信息。struct kvm_arch arch;KVM的软件arch方面所需要的一些参数。
KVM初始化过程
1 | // 获取 kvm 句柄 |
源码分析
参考网上有的 Hitcon 2018 abyss 题目的源码,整体分析一下这类题目的大致逻辑。
整个题目由3个binary,hypervisor.elf、kernel.bin与user.elf组成:
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的文件的。
hypervisor
首先是 main函数,主要重点有 kvm_init、copy_argv和 execute函数
1 | int main(int argc, char *argv[]) { |
kvm_init
kvm_init函数的整体逻辑和上面说的 KVM初始过程差不多,主要实现了初始化和创建 kvm,创建了KVM内存和CPU, 然后拷贝了用户代码。
1 | VM* kvm_init(uint8_t code[], size_t len) { |
setup_regs
主要设置了 KVM运行时的寄存器,包括代码运行点,内存大小等
1 | /* set rip = entry point |
设置寄存器的值时,有一个很重要的点是我们要关注的,即是否设置了 ERREF寄存器,如果这个寄存器的值为 0x800(1<<11),那么意味着 hypervisor开了 NEX即数据执行保护,这对于我们后续的 EXP的编写影响很大。
此处我们可以看到未设置 ERREF寄存器,也就是未 开启 NEX,我们后续可以直接执行 shellcode.
setup_long_mode
主要是设置了段页的各项属性,包括pml4、pdp、pd、cr3等指定页表映射等关系的内存和寄存器。这一块页表映射还有点不太懂。
1 | /* Maps: |
copy_argv
copy_argv函数将一些参数拷贝到内核栈上
1 | /* copy argv onto kernel's stack */ |
execute
最后会调用execute函数,我们可以看到开始循环运行KVM虚拟机,如果发生了中断会进入中断处理流程。其中我们重点关注 KVM_EXIT_IO,该流程会根据 io.port去调用 hp_handler来处理,如果处理失败才会退出虚拟机。
1 | void __attribute__((noreturn)) execute(VM* vm) { |
hp_handle
hp_handle定义了hypervisor接受内核发出的 IO中断时的处理函数,可以看到主要处理了 open、read、 write、 lseek、 fclose、 fstat、 exit、 acces、 ioctl、 panic等函数。而其中 ioctl函数为对参数做检查,可以在host上以任意参数来调用一个ioctl函数。
1 | int hp_handler(uint16_t nr, VM* vm) { |
kernel
首先是 entry.s,取出参数,然后调用 kernel_main函数,此外就会一直循环 hlt
1 | .globl _start, hlt |
kernel_main
先初始化了页表,然后初始化了内存分配器,根据源码得到 KERNEL_BASE_OFFSET=0x8000000000,然后注册了系统调用,最终切换用户。
1 | int kernel_main(void* addr, uint64_t len, uint64_t argc, char *argv[]) { |
init_pagetable
主要完成的也是页表映射功能,是做一个0x8000000000 ~ 0x8002000000到0 ~ 0x2000000的地址映射
1 | /* Maps |
init_allocator
设置内存分配,调用时init_allocator((const char *)(addr | 0x8000000000i64), len);。所以arena.top就是0x8000000000。现在应该可以得出结论,内存映射:0x8000000000~0x8002000000到0x0~0x2000000。
1 | void init_allocator(void *addr, uint64_t len) { |
register_syscall
使用 wrmsr写模式定义寄存器,对于WRMSR 指令,把要写入的信息存入(EDX:EAX)中,执行写指令后,即可将相应的信息存入ECX 指定的MSR 中。MSR 总体来是为了设置CPU 的工作环境和标示CPU 的工作状态,包括温度控制,性能监控等。而此部分代码,主要是注册了syscall,包括 syscall的入口等。
1 |
|
switch_user
switch_user 函数先利用 add_trans_user转到用户态,然后调用 sys_execve执行了用户态程序。
1 | void switch_user(uint64_t argc, char *argv[]) { |
syscall系统调用表分析
由于 Kernel 都会对 系统调用进行一些特殊处理,所以我们对各个 syscall系统函数进行分析,首先就要找到 syscall_table
首先有一个 标志函数 syscall_entry,其特点是开始和结尾进行了很长的 push操作,这可以作为我们很快找到 syscall_entry 的标志。
1 | syscall_entry: |
然后再进入 sycall_handler函数,可以看到这里就出现了 syscall_tabe,然后通过 swich_case 跳转。
1 | static const void* syscall_table[MAX_SYS_NR + 1] = { |
最终,我们就可以在 Kernel中找到 系统调用表。
User
用户态的程序就和正常的 pwn题一样可以直接在我们本机上运行,在此不做过多分析。
Hellouser
程序分析
Hellouser的总体逻辑和 helloheap很相似,但是更为简单。
但是,由于是在自己的 kernel 里跑的,所以其 syscall 或者 系统调用都做了新的处理。但是 Kernel 里有一个 很重要的点是,没有开启 NEX,也就是数据执行保护,我们可以执行任意shellcode
利用分析
首先分析单独一个用户态程序的利用:
- 修改
edit_flag和backdoor_flag
首先执行 后门函数,泄露 plt地址,得到edit_flag的地址。由于修改 第10个块name时,存在一个 一字节 null 溢出,通过这个溢出可以修改第9块的 slogan_addr,我们将 slogan_addr修改为 edit_flag的地址。最后通过第9块即可修改 edit_flag 和 backdoor_flag 的值。自此,我们即可依次泄露 stack 和 libc 的地址,同时实现任意地址读写。
此时,我们在本机执行ORW是可以成功的,但是加上虚拟化后执行会失败。因此我们就需要分析内核对于orw 等各类系统调用是否做了修改
- Virtual分析

可以看到未设置 ERREF寄存器,所以 可以直接执行 shellcode。
- Kernel分析
首先我们按照上面所讲的标志,可以找到 Kernel中的 syscall_table

然后,我们即可找到对应的 ORW函数了,我们可以看到 sys_open函数:

当检测到 打开的文件名是 flag时,其会执行一个 hook函数,而不是执行正常的流程。

hook的主要流程是使用 hp_read函数读取 flag到内存中,然后调用 mprotect函数修改了该内存为只写。所以,我们的方法应该是先调用 mprotect将这块内存的保护方式改为 可读可写,然后使用 ORW将这块内存的内容读取出来。这里由于未开启 NEX,可以直接执行shellcode。
EXP
1 | from pwn import * |
参考
Escape from Stack VM: HITCON 2018 Abyss I
- 本文作者: A1ex
- 本文链接: http://yoursite.com/2020/10/22/TSCTF-helloUser/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!