开始接触更难的知识,不要怕,冲就对了。
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 许可协议。转载请注明出处!