导语:毫不夸张地说,学习完本文,你完全可以创建自己的虚拟环境,并且可以了解VMWare,VirtualBox,KVM和其他虚拟化软件如何使用处理器的函数来创建虚拟环境。
接下来,我将解释如何拦截EPT中的网页。
// Setup PT by allocating two pages Continuously // We allocate two pages because we need 1 page for our RIP to start and 1 page for RSP 1 + 1 and other paages for paging const int PagesToAllocate = 10; UINT64 Guest_Memory = ExAllocatePoolWithTag(NonPagedPool, PagesToAllocate * PAGE_SIZE, POOLTAG); RtlZeroMemory(Guest_Memory, PagesToAllocate * PAGE_SIZE); for (size_t i = 0; i < PagesToAllocate; i++) { EPT_PT[i].Fields.AccessedFlag = 0; EPT_PT[i].Fields.DirtyFlag = 0; EPT_PT[i].Fields.EPTMemoryType = 6; EPT_PT[i].Fields.Execute = 1; EPT_PT[i].Fields.ExecuteForUserMode = 0; EPT_PT[i].Fields.IgnorePAT = 0; EPT_PT[i].Fields.PhysicalAddress = (VirtualAddress_to_PhysicalAddress( Guest_Memory + ( i * PAGE_SIZE ))/ PAGE_SIZE ); EPT_PT[i].Fields.Read = 1; EPT_PT[i].Fields.SuppressVE = 0; EPT_PT[i].Fields.Write = 1; }
注意:EPTMemoryType可以是0(对于未缓存的内存)或6(写回)内存,因为我们希望我们的内存是可缓存的,所以把6放在它上面。
下表是PDE,PDE应该指向PTE基址,因此我们只需将EPT PTE中第一个项的地址作为页面目录项的物理地址即可。
// Setting up PDE EPT_PD->Fields.Accessed = 0; EPT_PD->Fields.Execute = 1; EPT_PD->Fields.ExecuteForUserMode = 0; EPT_PD->Fields.Ignored1 = 0; EPT_PD->Fields.Ignored2 = 0; EPT_PD->Fields.Ignored3 = 0; EPT_PD->Fields.PhysicalAddress = (VirtualAddress_to_PhysicalAddress(EPT_PT) / PAGE_SIZE); EPT_PD->Fields.Read = 1; EPT_PD->Fields.Reserved1 = 0; EPT_PD->Fields.Reserved2 = 0; EPT_PD->Fields.Write = 1;
下一步是映射PDPT,PDPT项应指向页面目录的第一个项。
// Setting up PDPTE EPT_PDPT->Fields.Accessed = 0; EPT_PDPT->Fields.Execute = 1; EPT_PDPT->Fields.ExecuteForUserMode = 0; EPT_PDPT->Fields.Ignored1 = 0; EPT_PDPT->Fields.Ignored2 = 0; EPT_PDPT->Fields.Ignored3 = 0; EPT_PDPT->Fields.PhysicalAddress = (VirtualAddress_to_PhysicalAddress(EPT_PD) / PAGE_SIZE); EPT_PDPT->Fields.Read = 1; EPT_PDPT->Fields.Reserved1 = 0; EPT_PDPT->Fields.Reserved2 = 0; EPT_PDPT->Fields.Write = 1;
最后一步是配置PML4E,它指向PTPT的第一个项。
// Setting up PML4E EPT_PML4->Fields.Accessed = 0; EPT_PML4->Fields.Execute = 1; EPT_PML4->Fields.ExecuteForUserMode = 0; EPT_PML4->Fields.Ignored1 = 0; EPT_PML4->Fields.Ignored2 = 0; EPT_PML4->Fields.Ignored3 = 0; EPT_PML4->Fields.PhysicalAddress = (VirtualAddress_to_PhysicalAddress(EPT_PDPT) / PAGE_SIZE); EPT_PML4->Fields.Read = 1; EPT_PML4->Fields.Reserved1 = 0; EPT_PML4->Fields.Reserved2 = 0; EPT_PML4->Fields.Write = 1;
现在,只需为我们的VMCS设置EPTP,只需将0x6设置为内存类型(即回写)即可,我们执行了4次,因此页面遍历长度为4-1 = 3,而PML4地址是其中第一个项的物理地址。
稍后,我将解释DirtyAndAcessEnabled字段。
// Setting up EPTP EPTPointer->Fields.DirtyAndAceessEnabled = 1; EPTPointer->Fields.MemoryType = 6; // 6 = Write-back (WB) EPTPointer->Fields.PageWalkLength = 3; // 4 (tables walked) - 1 = 3 EPTPointer->Fields.PML4Address = (VirtualAddress_to_PhysicalAddress(EPT_PML4) / PAGE_SIZE); EPTPointer->Fields.Reserved1 = 0; EPTPointer->Fields.Reserved2 = 0;
最后一步。
DbgPrint("[*] Extended Page Table Pointer allocated at %llx",EPTPointer); return EPTPointer;
以上所有页表均应对齐4KB边界,但只要我们分配> = PAGE_SIZE(一个PFN记录),它就会自动对齐4kb。
我们的实现由4个表组成,因此,整个布局如下所示:
EPTP中的已访问标志和Dirty Flag
在EPTP中,你将使用扩展页表指针(EPTP)的第6位来决定是否启用EPT的已访问标志和Dirty Flag。设置此标志会导致处理器对客户分页结构项的访问被视为写操作。
对于在客户物理地址转换期间使用的任何EPT分页结构项,位8是已访问标志。对于映射页面的EPT分页结构项(与引用另一个EPT分页结构相对),位9是Dirty Flag。
每当处理器使用EPT分页结构项作为客户物理地址转换的一部分时,它都会在该项中设置访问标志(如果尚未设置)。
每当写入客户物理地址时,处理器都会在EPT分页结构项中设置Dirty Flag(如果尚未设置),该Dirty Flag标识客户物理地址的最终物理地址(可以是EPT PTE或7位为1的EPT分页结构项)。
这些标记是“粘性的”,这意味着一旦设置,处理器就不会清除它们,只有软件可以清除它们。
5级EPT转换
Intel建议在转换层次结构中增加一个名为PML5的新表,它将EPT扩展为5层表,客户操作系统可以使用最多57位的虚拟地址,而传统的4级EPT仅限于转换48位客户地址,尚未有现代操作系统使用此函数。
PML5也同时应用于EPT和常规分页机制:
转换首先要确定一个4 KB自然对齐的EPT PML5表。它位于EPTP的位51:12中指定的物理地址。 EPT PML5表包含512个64位项(EPT PML5E)。使用如下定义的物理地址选择一个EPT PML5E。
1.位63:52均为0;
2.位51:12来自EPTP;
3.位11:3 是客户物理地址的位56:48;
4.位2:0 均为0;
5.因为EPT PML5E是使用客户物理地址的位56:48标识的,所以它控制对线性地址空间的256 TByte区域的访问。
唯一的区别是你应在EPTP中放置PML5物理地址而不是PML4地址。
有关5层分页的更多信息,请参阅此Intel文件。
无效的缓存(INVEPT)
INVEPT:使处理器中的高速缓存扩展页表(EPT)映射无效,以使虚拟机中的地址转换与驻留内存的EPT页面同步。
INVVPID :使基于虚拟处理器ID(VPID)的地址转换的缓存映射无效。
假设我们访问客户物理地址0x1000,它将被转换为主机物理地址0x5000。下次,如果我们访问0x1000,CPU不会将请求发送到内存总线,而是使用缓存的内存,它更快。现在假设我们将EPT_PDPT-> PhysicalAddress更改为指向不同的EPT PD或更改其中一个EPT表的属性,现在我们必须告诉处理器你的缓存无效,这正是INVEPT所执行的。
现在我们在这里有两个术语,单上下文和全上下文。
单上下文(Single-Context)意味着你使基于一个EPTP的所有EPT派生的转换无效(简而言之:对于单个VM)。
全上下文(All-Context)意味着你使所有EPT派生的转换无效。
因此,如果你在更改EPT的结构后仍不执行INVEPT,则可能会冒CPU会重用旧转换的风险。
基本上,对EPT结构的任何更改都需要INVEPT,但切换EPT(或VMCS)则不需要INVEPT,因为该转换将与更改后的缓存中的EPTP进行“标记”。
以下汇编函数负责INVEPT:
INVEPT_Instruction PROC PUBLIC invept rcx, oword ptr [rdx] jz @jz jc @jc xor rax, rax ret @jz: mov rax, VMX_ERROR_CODE_FAILED_WITH_STATUS ret @jc: mov rax, VMX_ERROR_CODE_FAILED ret INVEPT_Instruction ENDP
请注意,VMX_ERROR_CODE_FAILED_WITH_STATUS和VMX_ERROR_CODE_FAILED定义如下。
VMX_ERROR_CODE_SUCCESS = 0 VMX_ERROR_CODE_FAILED_WITH_STATUS = 1 VMX_ERROR_CODE_FAILED = 2
现在,我们实现INVEPT。
unsigned char INVEPT(UINT32 type, INVEPT_DESC* descriptor) { if (!descriptor) { static INVEPT_DESC zero_descriptor = { 0 }; descriptor = &zero_descriptor; } return INVEPT_Instruction(type, descriptor); }
要使所有上下文无效,请使用以下函数。
unsigned char INVEPT_ALL_CONTEXTS() { return INVEPT(all_contexts ,NULL); }
最后一步是需要EPTP的单上下文INVEPT。
unsigned char INVEPT_SINGLE_CONTEXT(EPTP ept_pointer) { INVEPT_DESC descriptor = { ept_pointer, 0 }; return INVEPT(single_context, &descriptor); }
在修改状态下使用上述函数,告诉处理器使其缓存无效。
综上所述,我们将看到如何初始化扩展页表,以及如何将客户物理地址映射到主机物理地址,然后根据分配的地址构建EPTP。下面,我们来看看如何构建VMCS和实现其他VMX指令。
首先,我们配置先前分配的虚拟机控制结构(VMCS),最后,我们执行VMLAUNCH并进行硬件虚拟化,该部分的完整源代码可在GitHub上找到。
我们已经在前面实现了VMXOFF函数,现在让我们实现其他VMX指令函数。我还对调用VMXON和VMPTRLD函数进行了一些更改,以使其更具模块化。
VMPTRST
VMPTRST将当前VMCS指针存储到指定的内存地址中,该指令的操作数始终为64位,并且始终位于内存中。
以下函数是VMPTRST的实现:
UINT64 VMPTRST() { PHYSICAL_ADDRESS vmcspa; vmcspa.QuadPart = 0; __vmx_vmptrst((unsigned __int64 *)&vmcspa); DbgPrint("[*] VMPTRST %llx\n", vmcspa); return 0; }
VMCLEAR
该指令适用于VMCS,其VMCS区域位于指令操作数中包含的物理地址处。该指令确保将该VMCS的VMCS数据(其中一些当前可能在处理器上维护)复制到内存中的VMCS区域。它还会初始化VMCS区域的某些部分(例如,它将VMCS的启动状态设置为clear)。
BOOLEAN Clear_VMCS_State(IN PVirtualMachineState vmState) { // Clear the state of the VMCS to inactive int status = __vmx_vmclear(&vmState->VMCS_REGION); DbgPrint("[*] VMCS VMCLAEAR Status is : %d\n", status); if (status) { // Otherwise terminate the VMX DbgPrint("[*] VMCS failed to clear with status %d\n", status); __vmx_off(); return FALSE; } return TRUE; }
VMPTRLD
它标记当前-VMCS指针有效,并在指令操作数中将其与物理地址一起加载。如果指令的操作数未正确对齐,设置了不受支持的物理地址位或等于VMXON指针,则该指令将失败。此外,如果操作数引用的内存中的32位与该处理器支持的VMCS版本标识符不匹配,则指令将失败。
BOOLEAN Load_VMCS(IN PVirtualMachineState vmState) { int status = __vmx_vmptrld(&vmState->VMCS_REGION); if (status) { DbgPrint("[*] VMCS failed with status %d\n", status); return FALSE; } return TRUE; }
为了实现VMRESUME,你需要了解一些VMCS字段,因此VMRESUME的实现是在实现VMLAUNCH之后。
增强虚拟机状态结构
正如我在前面的部分中告诉你的那样,我们需要一种结构来分别保存每个内核中虚拟机的状态,在我们的hypervisor的最新版本中使用以下结构:
typedef struct _VirtualMachineState { UINT64 VMXON_REGION; // VMXON region UINT64 VMCS_REGION; // VMCS region UINT64 EPTP; // Extended-Page-Table Pointer UINT64 VMM_Stack; // Stack for VMM in VM-Exit State UINT64 MSRBitMap; // MSRBitMap Virtual Address UINT64 MSRBitMapPhysical; // MSRBitMap Physical Address } VirtualMachineState, *PVirtualMachineState;
请注意,它不是最终的_VirtualMachineState结构,我们将在以后的部分中对其进行增强。
准备启动VM
在这一部分中,我们只是尝试在驱动程序中测试hypervisor,在未来,我们将与驱动程序进行一些用户模式交互,因此让我们从修改DriverEntry开始,因为它是加载驱动程序时执行的第一个函数。
我们添加了以下几行以来使用第4部分(EPT)的结构:
// Initiating EPTP and VMX PEPTP EPTP = Initialize_EPTP(); Initiate_VMX();
我将导出添加到一个名为“VirtualGuestMemoryAddress”的全局变量中,该变量保存客户代码开始的地址。
现在,用\ xf4填充分配的页面,该\ xf4代表HLT指令。我之所以选择HLT,是因为采用某些特殊配置(如下所述)会导致VM-Exit并将代码返回给Host处理程序。
现在,让我们创建一个函数,该函数负责在特定内核上运行我们的虚拟机。
void LaunchVM(int ProcessorID , PEPTP EPTP);
我将ProcessorID设置为0,因此我们位于第0个逻辑处理器。
请记住,每个逻辑内核都有其自己的VMCS,并且如果你希望客户代码在其他逻辑处理器中运行,则应单独配置它们。
现在,我们应该使用Windows KeSetSystemAffinityThread函数将关联性设置为特定的逻辑处理器,并确保选择特定内核的vmState,因为每个内核都有其自己的单独的VMXON和VMCS区域。
KAFFINITY kAffinityMask; kAffinityMask = ipow(2, ProcessorID); KeSetSystemAffinityThread(kAffinityMask); DbgPrint("[*]\t\tCurrent thread is executing in %d th logical processor.\n", ProcessorID); PAGED_CODE();
现在,我们应该分配一个特定的堆栈,以便每次发生VM退出时,我们都可以保存寄存器并调用其他Host函数。
我更愿意为堆栈分配一个单独的位置,而不是使用驱动程序的当前RSP,但是你也可以使用当前堆栈(RSP)。
下面几行用于分配和调零VM-Exit处理程序的堆栈:
// Allocate stack for the VM Exit Handler. UINT64 VMM_STACK_VA = ExAllocatePoolWithTag(NonPagedPool, VMM_STACK_SIZE, POOLTAG); vmState[ProcessorID].VMM_Stack = VMM_STACK_VA; if (vmState[ProcessorID].VMM_Stack == NULL) { DbgPrint("[*] Error in allocating VMM Stack.\n"); return; } RtlZeroMemory(vmState[ProcessorID].VMM_Stack, VMM_STACK_SIZE);
与上面一样,为MSR位图分配一个页面并将其添加到vmState中:
// Allocate memory for MSRBitMap vmState[ProcessorID].MSRBitMap = MmAllocateNonCachedMemory(PAGE_SIZE); // should be aligned if (vmState[ProcessorID].MSRBitMap == NULL) { DbgPrint("[*] Error in allocating MSRBitMap.\n"); return; } RtlZeroMemory(vmState[ProcessorID].MSRBitMap, PAGE_SIZE); vmState[ProcessorID].MSRBitMapPhysical = VirtualAddress_to_PhysicalAddress(vmState[ProcessorID].MSRBitMap);
现在是时候清除我们的VMCS状态并将其作为当前VMCS加载到特定处理器(在我们的示例中为第0个逻辑处理器)中。
上面描述了Clear_VMCS_State和Load_VMCS:
// Clear the VMCS State if (!Clear_VMCS_State(&vmState[ProcessorID])) { goto ErrorReturn; } // Load VMCS (Set the Current VMCS) if (!Load_VMCS(&vmState[ProcessorID])) { goto ErrorReturn; }
现在是时候设置VMCS了,本主题后面的内容将提供有关VMCS设置的详细说明。
DbgPrint("[*] Setting up VMCS.\n"); Setup_VMCS(&vmState[ProcessorID], EPTP);
最后一步是执行VMLAUNCH,但我们不应忘记保存堆栈的当前状态(RSP和RBP),因为在执行客户代码期间以及从VM-Exit返回之后,我们必须知道当前状态并从中返回。这是因为,如果你为驱动程序提供了错误的RSP和RBP,那么你肯定会看到BSOD。
Save_VMXOFF_State();
保存返回点
对于Save_VMXOFF_State(),我声明了两个全局变量,分别称为g_StackPointerForReturning和g_BasePointerForReturning。无需保存RIP,因为返回地址始终在堆栈中。只需在汇编文件中EXTERN:
EXTERN g_StackPointerForReturning:QWORD EXTERN g_BasePointerForReturning:QWORD
Save_VMXOFF_State的实现:
Save_VMXOFF_State PROC PUBLIC MOV g_StackPointerForReturning,rsp MOV g_BasePointerForReturning,rbp ret Save_VMXOFF_State ENDP
返回到以前的状态
保存当前状态时,如果要返回到先前状态,则必须还原RSP和RBP并清除堆栈位置,最后清除RET指令。我还添加了一个VMXOFF,因为它应该在返回之前执行。
Restore_To_VMXOFF_State PROC PUBLIC VMXOFF ; turn it off before existing MOV rsp, g_StackPointerForReturning MOV rbp, g_BasePointerForReturning ; make rsp point to a correct return point ADD rsp,8 ; return True xor rax,rax mov rax,1 ; return section mov rbx, [rsp+28h+8h] mov rsi, [rsp+28h+10h] add rsp, 020h pop rdi ret Restore_To_VMXOFF_State ENDP
之所以这样定义“返回部分”,是因为我在IDA Pro中看到了LaunchVM的返回部分。
LaunchVM返回框架
VMLAUNCH
现在是执行VMLAUNCH的时候了。
__vmx_vmlaunch(); // if VMLAUNCH succeed will never be here ! ULONG64 ErrorCode = 0; __vmx_vmread(VM_INSTRUCTION_ERROR, &ErrorCode); __vmx_off(); DbgPrint("[*] VMLAUNCH Error : 0x%llx\n", ErrorCode); DbgBreakPoint();
如果VMLAUNCH成功了,我们将永远不会执行其他行。如果VMCS状态存在错误(这是一个常见问题),则我们必须运行VMREAD并从VMCS的VM_INSTRUCTION_ERROR字段读取错误代码,还要从VMXOFF读取并打印错误。 DbgBreakPoint只是一个调试断点(int 3),仅当你使用远程内核Windbg Debugger时,它才有用。很明显,你无法在系统中对其进行测试,因为只要没有调试器可以在内核中执行cc就会冻结系统,因此强烈建议你创建一台远程内核调试机并测试你的代码。
另外,无法在远程VMWare调试和其他虚拟机调试工具上对其进行测试,因为当前的Intel处理器不支持嵌套VMX。
请记住,我们仍处于LaunchVM函数中,__vmx_vmlaunch()是VMLAUNCH的固有函数,而__vmx_vmread是VMREAD指令的固有函数。
现在是时候配置VMCS之前阅读一些理论了。
VMX控制
VM执行控制
为了控制客户函数,我们必须在VMCS中设置一些字段。下表表示基于主处理器的VM执行控件和基于辅助处理器的VM执行控件。
我们像这样定义上表:
#define CPU_BASED_VIRTUAL_INTR_PENDING 0x00000004 #define CPU_BASED_USE_TSC_OFFSETING 0x00000008 #define CPU_BASED_HLT_EXITING 0x00000080 #define CPU_BASED_INVLPG_EXITING 0x00000200 #define CPU_BASED_MWAIT_EXITING 0x00000400 #define CPU_BASED_RDPMC_EXITING 0x00000800 #define CPU_BASED_RDTSC_EXITING 0x00001000 #define CPU_BASED_CR3_LOAD_EXITING 0x00008000 #define CPU_BASED_CR3_STORE_EXITING 0x00010000 #define CPU_BASED_CR8_LOAD_EXITING 0x00080000 #define CPU_BASED_CR8_STORE_EXITING 0x00100000 #define CPU_BASED_TPR_SHADOW 0x00200000 #define CPU_BASED_VIRTUAL_NMI_PENDING 0x00400000 #define CPU_BASED_MOV_DR_EXITING 0x00800000 #define CPU_BASED_UNCOND_IO_EXITING 0x01000000 #define CPU_BASED_ACTIVATE_IO_BITMAP 0x02000000 #define CPU_BASED_MONITOR_TRAP_FLAG 0x08000000 #define CPU_BASED_ACTIVATE_MSR_BITMAP 0x10000000 #define CPU_BASED_MONITOR_EXITING 0x20000000 #define CPU_BASED_PAUSE_EXITING 0x40000000 #define CPU_BASED_ACTIVATE_SECONDARY_CONTROLS 0x80000000
在VMX的早期版本中,没有任何类似于基于二级处理器的vm执行控件的东西。如果你想使用辅助表你必须设置第一个表的第31位否则它就像带有0的辅助表字段。
上表的定义是这样的(我们忽略了一些位,如果要在hypervisor中使用它们,可以定义它们):
#define CPU_BASED_CTL2_ENABLE_EPT 0x2 #define CPU_BASED_CTL2_RDTSCP 0x8 #define CPU_BASED_CTL2_ENABLE_VPID 0x20 #define CPU_BASED_CTL2_UNRESTRICTED_GUEST 0x80 #define CPU_BASED_CTL2_ENABLE_VMFUNC 0x2000
VM-entry控制位
VM项控件构成一个32位向量,用于控制VM项的基本操作。
// VM-entry Control Bits #define VM_ENTRY_IA32E_MODE 0x00000200 #define VM_ENTRY_SMM 0x00000400 #define VM_ENTRY_DEACT_DUAL_MONITOR 0x00000800 #define VM_ENTRY_LOAD_GUEST_PAT 0x00004000
VM-exit控制位
VM退出控件构成一个32位向量,用于控制VM退出的基本操作。
// VM-entry Control Bits #define VM_ENTRY_IA32E_MODE 0x00000200 #define VM_ENTRY_SMM 0x00000400 #define VM_ENTRY_DEACT_DUAL_MONITOR 0x00000800 #define VM_ENTRY_LOAD_GUEST_PAT 0x00004000
基于PIN的执行控制
基于引脚的VM执行控件构成一个32位向量,用于控制异步事件(例如:中断)的处理。我们将在以后的部分中使用它,但是现在让我们在Hypervisor中对其进行定义。
// PIN-Based Execution #define PIN_BASED_VM_EXECUTION_CONTROLS_EXTERNAL_INTERRUPT 0x00000001 #define PIN_BASED_VM_EXECUTION_CONTROLS_NMI_EXITING 0x00000004 #define PIN_BASED_VM_EXECUTION_CONTROLS_VIRTUAL_NMI 0x00000010 #define PIN_BASED_VM_EXECUTION_CONTROLS_ACTIVE_VMX_TIMER 0x00000020 #define PIN_BASED_VM_EXECUTION_CONTROLS_PROCESS_POSTED_INTERRUPTS 0x00000040
中断状态
客户状态区域包括以下字段,这些字段描述客户状态,但不对应于处理器寄存器:
活动状态(32位:此字段标识逻辑处理器的活动状态,当逻辑处理器正常执行指令时,它处于活动状态。某些指令的执行和某些事件的发生可能导致逻辑处理器转换为非活动状态,在该状态中逻辑处理器停止执行指令。
定义了以下活动状态:
— 0:活跃,逻辑处理器正常执行指令。
— 1:停止,逻辑处理器处于非活动状态,因为它执行了HLT指令。
— 2:关闭,逻辑处理器处于非活动状态,因为它招致了三重fault1或其他一些严重错误。
— 3:Wait-for-SIPI,逻辑处理器处于非活动状态,因为它正在等待startup-IPI (SIPI)。
中断状态(32位):IA-32体系结构包括允许某些事件在一段时间内被阻止的函数,该字段包含有关此类阻止的信息。下表中给出了该字段的详细信息和格式:
本文翻译自:https://rayanfam.com/topics/hypervisor-from-scratch-part-4/ 与 https://rayanfam.com/topics/hypervisor-from-scratch-part-5/如若转载,请注明原文地址: https://www.4hou.com/web/22107.html