[Virtualization] Peach VM - 基于Intel VMX的简易虚拟机实例分析
前言
之前在看VMX相关的东西的时候基本都是从比较抽象的文档入手,对于概念的理解还是比较模糊的。而且像kvm这种项目太大了,硬看下去会花很多时间在边边角角的点上。偶然看到Github上有个阿里云大佬开源了一个非常小巧的虚拟机实现—— Peach,虽然没有什么实际作用(指VM Monitor特别简单,而且完全没有实现外围设备),但是可以让人迅速对Intel VMX技术有清晰的概念。作者还同时在自己的微信公众号发布了讲解如何实现该实例的文章,但是99块的门槛有点夸张了😂。读完源码后我Fork了一份,并在关键代码都加了注释 放在这。本文行文比较仓促,可能错误有点多,一切解释以 Intel® 64 and IA-32 Architectures Software Developer's Manual Volume 3C: System Programming Guide, Part 3 手册为准。
基本概念
还是简单从抽象层面了解下使用了VMX技术的虚拟机是如何工作的。这部分放在前面,不想看代码的看完这部分就可以溜了。
架构
先借用《QEMU/KVM源码解析与应用》中的一幅图:
这幅图详细描述的QEMU-KVM模型的协作关系,比较复杂。而Peach VM的实现方式与该模型类似,但是少了很多东西,我们可以进行简化处理,只看VMX相关的部分。
工作关系
图中蓝色部分表示虚拟机的软件实现,由用户态程序(如qemu-system
)和内核模块(如kvm
)两部分组成,分别工作在ring3和ring0。两部分之间的通信通过Linux的文件操作接口完成,如open
, ioctl
等。灰色部分为宿主机(Host)的操作系统和应用软件。橙色和黄色部分为虚拟机(Guest)的操作系统和应用软件,它们的整体运行在一个虚拟化环境中,从他们视角上看和正常的操作系统并没有区别。紫色部分为VMXON Region和VMCS Region,其中VMXON Region在VMX操作模式开启后将一直存在,而VMCS Region则与创建的虚拟机实例有关,负责保存虚拟机运行期间Host和Guest的上下文信息。
这里有个奇怪的点,那就是为什么要同时保存Host和Guest的上下文信息?朴素思维下,实现一个虚拟机通常只需要关注虚拟机状态的维护即可。但是仔细观察可以发现Host和Guest的工作环境被区分成了root
和non-root
模式,所有的客户机都运行在non-root
模式下运行,并且这两种模式的切换由VM Exit
和VM Entry
接口完成。顾名思义这两个接口的主要功能就是将执行流在虚拟化环境和宿主机环境中来回切换。由于VMX直接使用了逻辑CPU模拟出vCPU去运行虚拟机上的代码,所以不存在软件层面的指令转译,这就意味着无论是从Host切换到Guest还是从Guest切换到Host,都需要保存当前的上下文,以便执行流的恢复。
还有一个傻瓜问题,我姑且自问自答一下:问什么虚拟机跑起来之后需要频繁调用VM Exit
?这个原因说简单也简单,说复杂了那就要从微机原理开始扯了(x。虚拟机运行期间少不了很多的硬件IO访问操作,或者调用VMCALL指令,或者调用了HLT
指令,或者产生了一个page fault
,又或者访问了特殊设备的寄存器等等,这其中IO操作是最频繁的。这些操作无法被VMX本身处理,需要交还执行流到VM Monitor中,然后由VM Monitor选择一个处理方案:
- 直接忽略,跳过该指令并调用
VM Entry
- 在Host的内核模块中处理,处理完后同样
VM Entry
- 返回到用户态程序中(如
qemu-system
),由用户态程序处理。这种情况比较常见,因为大部分的虚拟设备(如RAM
,PCI Bus
及相关设备,ISA Bus
及相关设备,南北桥,VGA
设备等等)都被实现在用户态中,这么做也是便于开发和移植。但是在Peach VM中省略了这些内容,如果想了解的话之后可以单独做个 Qemu设备虚拟化 相关的专题。 - 直接结束Guest虚拟机的运行
QEMU 模拟的 Intel 440FX 框架
MSR Register
MSR(Model Specific Register)是x86架构中的概念,指的是在x86架构处理器中,一系列用于控制CPU运行、功能开关、调试、跟踪程序执行、监测CPU性能等方面的寄存器。每个MSR寄存器都会有一个相应的ID,即MSR Index,或者也叫作MSR寄存器索引,当执行RDMSR或者WRMSR指令的时候,只要提供MSR Index就能让CPU知道目标MSR寄存器。这些MSR寄存器的索引(MSR Index)、名字及其各个数据区域的定义可以在Intel x86架构手册”Intel 64 and IA-32 Architectures Software Developer's Manual"的Volume 4中找到。
之所以介绍这个概念是因为Peach VM的代码中有大量读MSR寄存器来获取一些常量的汇编代码。
读MSR寄存器的指令是rdmsr
,这条指令使用eax
,edx
,ecx
作为参数,ecx
用于保存MSR寄存器相关值的索引,而edx
,eax
分别保存结果的高32位和低32位。该指令必须在ring0权限或者实地址模式下执行;否则会触发#GP(0)异常。在ecx
中指定一个保留的或者未实现的MSR地址也会引发异常。
Peach VM中一个从MSR中读取IA32_VMX_BASIC值的样例
VMXON Region
对于Intel x86处理器,在打开VMX(Virtual Machine Extension),即执行VMXON指令的时候需要提供一个4KB对齐的内存区间,称作VMXON Region,该区域的物理地址作为vmxon
指令的操作数。该内存区间用于支持逻辑CPU的VMX功能,该区域在VMXON
和VMXOFF
之间一直都会被VMX硬件所使用。
对于每个支持VMX功能的逻辑CPU而言,都需要一个相应的VMXON Region。Peach VM为了避免多CPU带来的的麻烦,在初始化时绑定到了其中一个CPU上。
VMCS Region
这是事关虚拟机运行最为重要的一个对象,Peach VM的内核模块部分大部分(几百行)的代码都在操作VMCS
对象,操作的方式主要是读(vmread
)和写(vmwrite
)。由于VMCS中有大量的Guest和Host状态,所以在运行前需要进行冗长的设置。
下图是VMCS Region的所有字段,大体上分为了GUEST STATE AREA
和HOST STATE AREA
两部分:
Peach VM中对VMCS Region读的代码:
Peach VM中对VMCS Region写的代码:
注意:VMXON Region和VMCS Region是不一样的两个内存区域,VMXON是针对逻辑CPU的,每个逻辑CPU都会有一份,并且在整个VMX功能使用期间硬件都会使用;而VMCS Region则是针对vCPU的,每个vCPU都会有一份VMCS Region,用于辅助硬件对vCPU的模拟。
技术
Intel EPT
在解释EPT(Extended Page Table)之前需要明白一个基本概念,在最初的设计中,虚拟机中的APP在进行访存的时候,实际上需要穿透三层地址空间——也就是需要进行三次地址转换:
- 客户机虚拟地址(GVA)到客户机物理地址(GPA)的转换——借助客户机页表(GPT)
- 虚拟机物理地址(GPA)到宿主机虚拟地址(HVA)的转换——借助类似
kvm_memory_slot
的映射结构 - 宿主机虚拟地址(HVA)到宿主机物理地址(HPA)的转换——借助宿主机页表(HPT)
GVA -> GPA -> HVA -> HPA
影子页表
这样繁琐的转换方式效率比较低,于是首先出现了影子页表这种技术。影子页表简单来说就是,可以直接把客户机的虚拟地址(GVA)映射成宿主端的物理地址(HPA)。客户机想把客户机的页表基地址写入cr3寄存器的时候,由于读写cr3寄存器的指令都是特权指令,在读写 cr3的过程中都会陷入到VMM(之前说的VM Exit
),VMM会首先截获到此指令:
- 在客户机写cr3寄存器的时候,VMM首先保存好写入的值,然后填入的是宿主机端针对客户机生成的一张页表(也就是影子页表)的基地址
- 当客户机读cr3值的时候,VMM会把之前保存的cr3的值返回给客户机
这样做的目的是,在客户机内核态中虽然有一张页表,但是客户机在访问内存的时候,虚拟机MMU机制不会走这张页表,MMU走的是以填入到cr3寄存器上的真实的值为基地址(这个值是VMM写的主机端的物理地址)的影子页表,经过影子页表找到宿主机的物理地址,最终实现了GVA直通HPA的转换。但是影子页表也有缺陷,需要对客户端的每一个进程维护一张表,后来出现了EPT页表。
GVA -> HPA
EPT
EPT 页表机制是一个四级的页表,与影子页表不同,EPT机制并不干扰客户机使用cr3完成GVA到GPA的转换,它主要的作用是直接完成GPA到HPA的转换。注意EPT本身由VMM维护,但其转换过程由硬件完成,所以其比影子页表有更高的效率。下面是EPT的工作方式:
GVA -> GPA -> HPA
EPTP
-> PML4 Table
-> EPT page-directory pointer Table
-> EPT page-directory Table
-> EPT Page Table
-> Page
EPT表借助VMCS结构与客户机实例相关联,在VMCS Region中有一个EPTP的指针,其中的12-51位指向EPT页表的一级目录即PML4 Table。这样根据客户机物理地址的首个9位就可以定位一个PML4 entry,一个PML4 entry理论上可以控制512GB的区域。这对于一个简单的样例来说完全够用了,所以Peach VM只初始化了一个PML4表项和16个页。注意不管是32位客户机还是64位客户机,这里统一按照64位物理地址来寻址。
关于各级页表表项比特位的作用(权限位,索引位,保留位...),可以参考Intel手册,这里不再赘述。
关于地址转换的细节不用细究,只需要记得虚拟机运行前需要初始化的各级页表有那些即可
Intel VMX 指令集
完整内容依然建议参考前文的Intel手册,这里列出Peach VM会涉及到的(以及最常用的)部分指令,以便读者速查:
指令 | 作用 |
---|---|
VMPTRLD | 加载一个VMCS结构体指针作为当前操作对象 |
VMPTRST | 保存当前VMCS结构体指针 |
VMCLEAR | 清除当前VMCS结构体 |
VMREAD | 读VMCS结构体指定域 |
VMWRITE | 写VMCS结构体指定域 |
VMCALL | 引发一个VMExit事件,返回到VMM |
VMLAUNCH | 启动一个虚拟机 |
VMRESUME | 从VMM返回到虚拟机继续运行 |
VMXOFF | 退出VMX操作模式 |
VMXON | 进入VMX操作模式 |
指令的使用细节会在代码分析一节指出
测试环境
随机,不用参考
宿主机
硬件平台:较新的 Intel CPU 都支持
操作系统:Windows 10/11
虚拟机软件:Vmware Workstation 16
相关设置:勾选Vmware客户机CPU的下面几个选项,以便支持嵌套虚拟化
虚拟机
操作系统:Ubuntu 20.04 LTS
编译样例:
git clone https://github.com/pandengyang/peach
make && cd module;make
sudo ./mkdev.sh
启动用户态程序然后查看内核log:
cd ../ && ./peach
sudo dmesg
代码分析
目录
目录结构比较简单,根目录的main.c
是用户态程序,它会通过ioctl
调用内核模块相关功能;module目录下是内核模块源代码,peach_intel.c
完成虚拟机的初始化、客户机的创建&销毁。vmexit_handler.S
完成VM Exit & VM Entry
时的上下文保存和恢复工作;guest目录下是GuestOS的代码,由于不是分析的重点,直接忽略。
用户态部分
该部分的工作位置类似于qemu-system
,如果有过使用/dev/kvm
提供的接口来完成客户机创建的同学应该一眼就知道是在干嘛。
首先完成CPU的绑定,避免处理多核问题
拿到Peach VM设备的fd,该fd相当于一个handle,是下面一切操作的作用对象
客户机创建前的环境检查
此处
ioctl
的指令为PEACH_PROBE
创建客户机,启动,并等待其运行完毕
此处
ioctl
的指令为PEACH_RUN
可以发现Peach VM实在太精简了,以至于只提供了PEACH_PROBE
和PEACH_RUN
两个操作接口,所以下文对于内核模块的分析也是围绕PEACH_PROBE
和PEACH_RUN
展开。
内核模块
一些数据结构
模块初始化
查看static int peach_init(void)
,该函数初始化了Peach VM内核模块,完成了字符设备的注册,属于内核模块初始化的常规流程:
ioctl - PROBE
该接口主要完成一系列的rdmsr
命令,将读取到的内容使用printk输出。rdmsr
命令在前文介绍过:
...这条指令使用eax
,edx
,ecx
作为参数,ecx
用于保存MSR寄存器相关值的索引,而edx
,eax
分别保存结果的高32位和低32位...
读出来的这些值可以用于判断当前平台是否能够使用VMX技术进行虚拟化,显然Peach VM并没有做判断,只是简单打印了一下:
ioctl - PEACH_RUN
首先通过kmalloc拿一块内存作为GuestOS的运行内存,大小为16个页(绰绰有余):
之所以已经有了guest_memory
还要通过__pa
宏计算guest_memory_pa
是因为EPT的目的是帮助GPA直通HPA,所以要保证写进EPT页表表项的每个值都来自HPA。但是程序中的读写操作依然用的是HVA的指针的值(即:guest_memory
)。往下涉及到的所有xx
和xx_pa
基本上都是这么一个关系。
从Guest运行内存的起始处写入GuestOS的镜像,由于是一个测试用的mini OS,不考虑使用Loader等方式,直接写内存里就完事了:
调用init_ept()
初始化EPT各级页表,传入全局变量ept_pointer
的引用和刚刚计算出的guest_memory_pa
:
init_ept
再次使用kmalloc拿到一块内存,用于存放EPT页表本身:
初始化EPTP:
可以看到初始化EPTP就是把ept_pa
指针低位做一些处理后写入全局变量ept_pointer
中,这些位的含义可以参考:
查表可知:1<<6
是访问许可,3<<3
是EPE page-walk length,6
表示Write-back
往下初始化各级页表表项,每个表的大小都是4K,并且在连续内存上分布
下面代码中的entry
都是一个临时变量,作为各级页表的入口点
设置PML4表首个表项:
设置EPT page-directory pointer表首个表项:
设置EPT page-directory表首个表项:
设置EPT Page表前16个Page,并分别指向
guest_memory_pa + 页大小*n
的位置:
init_ept
函数结束
接下来是一个小重点,初始化VMXON Region和本客户机实例对应的VMCS Region:
依然是前面提到过的,vmxon
在虚拟机启动虚拟化之后将一直存在,而vmcs
则与单个客户机实例绑定,这里之所以放在一起初始化是因为实例较为简单,并且并不打算支持多实例,所以干脆耦合着。
接下来,从Host CR4中取出第13位放入CF中并将该位设为1,再更新回cr4,这一步的目的是打开CR4寄存器中的虚拟化开关:
vmxon
指令通过传入VMXON Region的“物理地址”作为操作数,表示进入VMX操作模式,setna
指令借助EFLAGS.CF
的值判断执行是否成功:
这里可以留意一下,VMX的虚拟化开启需要打开两个“开关”,一个是Host CR4寄存器的第13位,一个是
vmxon
指令顺便补充一点关于GCC内联汇编的概念:在clobbered list(第三行冒号)中加入cc和memory会告诉编译器内联汇编会修改cc(状态寄存器标志位)和memory(内存)中的值,于是编译器不会再假设这段内联汇编后对应的值依然是合法的
在开始设置VMCS Region之前,先用vmclear
清空即将使用的VMCS中的字段:
加载一个VMCS结构体指针作为当前操作对象:
asm volatile (
"vmptrld %[pa]\n\t"
"setna %[ret]"
: [ret] "=rm" (ret1)
: [pa] "m" (vmcs_pa)
: "cc", "memory"
);
VMCS被加载到逻辑CPU上后,处理器并没法通过普通的内存访问指令去访问它, 如果那样做的话,会引起“处理器报错”,唯一可用的方法就是通过vmread
和vmwrite
指令去访问。可以理解为逻辑CPU为当前正在使用的VMCS对象添加了一层“访问保护”。
恶心的阶段开始了!
接下来就是vmread
和vmwrite
的主场——为了规范对当前实例的VMCS Region的访问,intel提供了vmwrite
,vmread
指令。这两个指令接受两个操作数,第一个操作数表示字段索引(不是偏移),第二个操作数表示要写入的值或者要保存值的寄存器。
由于Peach VM中所有的索引值都用的16进制常数,所以这里先把访问VMCS对应字段所需常量的宏定义放出来:
我猜你可能以及记不清VMCS里面都有哪些字段了,所以再次祭出这张图:
再留意一个点,vmread/vmwrite
对CS,SS,GS等段寄存器都不是采取整个索引的策略,也就是说,你不必浪费精力一次性构造整个段寄存器的值再更新,只需要索引到其中的XX->Selector
,XX->BaseAddress
,XX->SegmentLimit
,XX->AccessRight
等字段单独修改即可。好处是灵活性增加了,坏处是比较繁琐。
下面开始初始化GUEST STATE AREA的部分段寄存器,RIP寄存器和EFLAGS寄存器:
省去了大同小异的部分,关注一下索引为0x0000681E
的部分,这里写的是GuestOS的执行起点。Peach VM里面写了0x0000000000000000
,因为之前的mini OS镜像直接写入到运存的起始位置了。
然后初始化HOST STATE AREA的部分段寄存器:
下面的设置的IA32_SYSENTER_EIP
用于标识用户进行快速系统调用时,直接跳转到的ring0代码段的地址。SYSENTER进行的系统调用可以避免普通中断产生的较大开销。
来到一个关键点,下面的两步设置了HOST STATE AREA中的RSP
和RIP
:
之前说过,因为客户机和VMM之间会通过VM Exit
和VM Entry
发生频繁的切换,所以VMCS就承担起了记录Host和Guest上下文的责任。这里设置的Host RIP和Host RSP就是在客户机通过VM Exit
返回到VMM时自动设置的RSP和RIP值。RSP的值被设置为了stack + 0x8000
,这是一段kmalloc开辟出来的栈空间,因为返回到VMM时不可能再去复用内核模块此时的RSP,所以单独开辟一个栈空间显然是最合理的选择,同时也便于多个实例情况下的处理。而RIP被设置成了_vmexit_handler
函数的地址,顾名思义这是专门用来处理VM Exit
的一个函数。该函数的实现在vmexit_handler.S
中:
可以发现,该函数主要的任务是:保存上下文 -> 调用handle_vmexit(rsp)
-> 恢复上下文 -> vmresume
重启客户机 -> ret
返回。这个函数开始一定要保存所有的寄存器,并在返回虚拟机之前恢复所有的寄存器。否则退出虚拟机之前寄存器中的内容和返回虚拟机之后寄存器中的内容不一样的话一定会导致不可预知的结果。因此这个函数一定得是汇编写的裸函数。
这里暂且把handle_vmexit
的内容放一放,先看完客户机的完整创建过程再回过头来看handle_vmexit
会更顺理成章。
往下设置vCPU的ID:
由于只有一个vCPU,直接写1就行
将之前辛辛苦苦准备的EPT表的ept_pointer
的物理地址(PA)写进VMCS Region中:
注意ept_pointer指针指向一个保存了EPT表地址的内存位置(而不是直接指向EPT表)
通过设置PIN_BASED_VM_EXEC_CONTROL
控制pin与INTR和NMI是否产生VM-Exit:
设置CPU_BASED_VM_EXEC_CONTROL
,SECONDARY_VM_EXEC_CONTROL
:
这两个字段同样是启用或禁用一些重要功能,对于Peach VM而言,最主要的是使GuestOS在执行HLT
指令时会发生VM Exit
,这是README.md里特别强调的。
下表是CPU_BASED_VM_EXEC_CONTROL
各个位的意义,大部分都是中断虚拟化相关的东西:
接下来设置VM_ENTRY_CONTROLS
和VM_EXIT_CONTROLS
的值:
这两者正好相反,一个是控制VM Entry
时的行为,一个是控制VM Exit
时的行为。下表分别是VM_ENTRY_CONTROLS
和VM_EXIT_CONTROLS
各个位的意义。例如通过查表可得,VM_ENTRY_CONTROLS
设置为:
顺带一提,不用宏赋值真的有点无语,查表都难查
在正式启动客户机前,把当前的RSP和RBP保存下来:
这是因为在GuestOS发生HLT
时handle_vmexit
会跳转回该函数的尾部,借助函数尾部的流程关闭客户机,结束VMX操作模式。只有把栈给恢复了才能确保函数正常退出。虽然我不确定Peach VM这种奇怪的控制流是不是很容易出问题...感觉稍微设计一下就是一道绝佳的CTF题。
经历了千辛万苦地前期准备,终于到了启动客户机的时候,实际上只需要一条vmlaunch
就可以进入GuestOS
:
在这条指令后需要通过VMM判断vmlunch
的返回结果,以确定vCPU是否真正被执行,还是因为某些逻辑冲突导致vCPU没有被执行就返回。只需要通过vmread
读出VMCS中的VM_EXIT_REASON
值即可:
繁华落幕,往下就是虚拟机的关闭流程了。
先通过内联汇编添加一个shutdown
标签:
这么做的原因前面已经提到,handle_vmexit
遇到HLT
指令最后会跳回这里,这样才能将执行流正常从peach_ioctl
返回到用户态部分。
虚拟机的关闭和开启相互对应,同样是两个步骤,先使用vmxoff
关闭VMX操作模式,再设置Host CR4中的第13位关闭虚拟化开关。
最后的最后来看看之前被我们暂时搁置handle_vmexit
函数。
handle_vmexit
之前已经说过,每次VM Exit
都会进入该函数,所以为了调试方便可以把客户机寄存器信息给打印一下:
首先用vmread
读出EXIT_REASON
:
从读出的EXIT_REASON进入不同的处理逻辑,比如用户可以自定义对于某些PMIO,MMIO以及xx中断的处理逻辑。但是Peach VM只象征性的实现了CPUID
和HLT
的处理:
- 遇到
EXIT_REASON_HLT
时,恢复先前保存的peach_ioctl
的栈寄存器,跳转到shutdown标签,完成虚拟机的关闭和ioctl的返回 - 遇到
EXIT_REASON_CPUID
时直接设置客户机中的寄存器值
顺便补充一下各种EXIT_REASON
的宏定义:
往下看,下面的部分主要在为vmresume
做准备。每次重新进入guest VM之前都要重新设置一下Guest RIP,否则再次进入时又会碰到导致VM Exit
发生的指令。VMCS提供了VM_EXIT_INSTRUCTION_LEN
这个索引,该索引对应的值正好是导致客户机退出的指令的长度,Guest RIP只需要自增对应值即可跳过该指令:
handle_vmexit
函数结束
总结
关于Peach VM和Intel VMX入门的分析就这么多,如果可以的话建议上手调试一下。虚拟化能研究的方向还有好多好多,比如QEMU源码的分析,KVM开发,虚拟化安全等等。如果有兴趣的话可以私聊交流,相互学习!
🐂,很通俗了