首页
社区
课程
招聘
Hypervisor From Scratch – 第 7 部分:使用 EPT 和页面级监控功能
2024-3-31 16:30 2837

Hypervisor From Scratch – 第 7 部分:使用 EPT 和页面级监控功能

2024-3-31 16:30
2837

目录

七、使用EPT和页面级监控功能

7.1.介绍

这是教程 Hypervisor From Scratch 的第七部分,它是关于在已运行的系统中使用扩展页表 (EPT) 的。 您可能知道,分页是现代操作系统上管理内存的重要组成部分。 虚拟机管理程序使用额外的分页表; 这为我们提供了一个绝佳的机会来监视内存的不同方面(读-写-执行),而无需修改操作系统页表。 EPT是一种硬件机制,因此速度很快,但另一方面,我们必须处理不同的缓存和同步问题。

这部分高度依赖于教程的第四部分 - 第 4 部分:使用扩展页表(EPT)进行地址转换 ,所以请再阅读一遍这部分; 因此,我避免重新描述与 EPT 表相关的基本概念。

在第七部分中,我们将了解如何通过配置 VMCS 并创建基于内存类型范围寄存器 (MTRR) 的标识表来虚拟化当前运行的系统,然后使用监视功能来检测某些 Windows 功能的执行情况。

这部分深受 Simplevisor Gbhv 的启发。

7.2.概述

这部分分为 七个 主要部分:

  1. 实施管理 Vmcall 的机制
  2. 从 MMU 虚拟化 (EPT) 开始
  3. 解释内存类型范围寄存器 (MTRR) 概念
  4. 使用 EPT 描述页面级监控功能
  5. Invalidating Translations Derived from EPT (INVEPT)
  6. 修复了一些先前有关死锁和同步问题的设计警告
  7. 讨论(在本节中我们讨论有关 EPT 的不同问题和方法)

最后,我将讨论调试虚拟机管理程序和 EPT 时需要了解的一些重要注意事项。

伙计们,如果你不理解某些部分也没关系,通过阅读这篇文章,你会得到一个想法,你可以使用 EPT,随着时间的推移你会更好地理解事情。

这部分的源码相比之前的部分有很大的改变; 命名约定得到改进,因此您会看到更加清晰和可读的代码; 代码中还添加了许多新例程,例如以 Hv 开头的例程是虚拟机管理程序例程,您必须从 IRP 主要函数调用它们,并避免直接调用带有 Vmx 前缀的方法,因为这些函数管理与 VMX 操作相关的操作, Asm 前缀是内联汇编函数,以 Ept 开头的函数是与扩展页表 (EPT) 相关的函数。 另外,带有 Vmcall 前缀的函数用于 VMCALL 服务,带有 Invept 的函数与 Invalidate EPT 缓存相关。

7.3.实现管理VMCALL的函数

我们从实现与 VMCALL 相关的功能开始本文。 Intel 将 Vmcall 描述为“通过导致 VM 退出来调用 VM 监视器”。

Vmcall 允许guest软件向底层 VM 监视器调用服务。 此类调用的编程接口的详细信息是 VMM 特定的。 该指令只不过导致虚拟机退出。

换句话说,每当您在 Vmx 非根模式下执行 Vmcall 指令时(每当发生 vm-exit 时,我们就处于 vmx root-mode,并且我们会保持在 vmx root-mode,直到我们执行 VMRESUME 或 VMXOFF,因此任何其他上下文都是vmx non-root模式意味着其他驱动程序可以在其上下文中使用 Vmcall 在 vmx root-mode下向我们的虚拟机管理程序请求服务)。

VMCALL 的执行会导致 Vm 退出 (EXIT_REASON_VMCALL)。 由于我们可以在执行 VMCALL 之前设置寄存器和堆栈,因此我们可以将参数发送到 Vmcall 处理程序,我的意思是我们需要做的就是设计一个调用约定,以便 vmcall 处理程序和请求服务的驱动程序可以完美地协同工作。

我们需要实现的第一件事是汇编中的一个函数,它执行 VMCALL 并返回。

1
2
3
4
AsmVmxVmcall PROC
    vmcall                  ; VmxVmcallHandler(UINT64 VmcallNumber, UINT64 OptionalParam1, UINT64 OptionalParam2, UINT64 OptionalParam3)
    ret                     ; Return type is NTSTATUS and it's on RAX from the previous function, no need to change anything
AsmVmxVmcall ENDP

它是这样定义的,

1
extern NTSTATUS inline AsmVmxVmcall(unsigned long long VmcallNumber, unsigned long long OptionalParam1, unsigned long long OptionalParam2, unsigned long long OptionalParam3);

与上面代码的区别在于,我们没有修改 AsmVmxVmcall 中的任何内容,这意味着如果有人将参数传递给 AsmVmxVmcall ,则参数位于 RCX、RDX、R8、R9 中,其余参数进入堆栈,这是因为x64 FAST CALL 调用约定。

请记住,如果您正在为 Linux 设计虚拟机管理程序,Linux 中的快速调用与 Windows 中的快速调用不同。

由于我们保存了 vm-exit 上的所有寄存器,因此在 vm-exit 处理程序中,我们将 GuestRegs->rcx、GuestRegs->rdx、GuestRegs->r8、GuestRegs->r9 传递给 VmxVmcallHandler,RCX 是指定的 Vmcall 编号我们希望虚拟机管理程序执行的服务,RDX、R8 和 R9 是可选参数。

1
2
3
4
5
case EXIT_REASON_VMCALL:
{
    GuestRegs->rax = VmxVmcallHandler(GuestRegs->rcx, GuestRegs->rdx, GuestRegs->r8, GuestRegs->r9);
    break;
}

例如,我们的虚拟机管理程序在这一部分有以下服务(Vmcall Numbers)。

1
2
3
4
5
#define VMCALL_TEST                     0x1         // Test VMCALL
#define VMCALL_VMXOFF                   0x2         // Call VMXOFF to turn off the hypervisor
#define VMCALL_EXEC_HOOK_PAGE           0x3         // VMCALL to Hook ExecuteAccess bit of the EPT Table
#define VMCALL_INVEPT_ALL_CONTEXT       0x4         // VMCALL to invalidate EPT (All Contexts)
#define VMCALL_INVEPT_SINGLE_CONTEXT    0x5         // VMCALL to invalidate EPT (A Single Context)

VmxVmcallHandler 没有什么特别的,它只是一个简单的 switch case。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* Main Vmcall Handler */
NTSTATUS VmxVmcallHandler(UINT64 VmcallNumber, UINT64 OptionalParam1, UINT64 OptionalParam2, UINT64 OptionalParam3)
{
    NTSTATUS VmcallStatus;
    BOOLEAN HookResult;
 
    VmcallStatus = STATUS_UNSUCCESSFUL;
    switch (VmcallNumber)
    {
    case VMCALL_TEST:
    {
        VmcallStatus = VmcallTest(OptionalParam1, OptionalParam2, OptionalParam3);
        break;
    }
    default:
    {
        LogWarning("Unsupported VMCALL");
        VmcallStatus = STATUS_UNSUCCESSFUL;
        break;
    }
    }
    return VmcallStatus;
}

为了测试它,我创建了一个名为 VmcallTest 的函数,它只是显示传递给 Vmcall 的参数。

1
2
3
4
5
6
/* Test Vmcall (VMCALL_TEST) */
NTSTATUS VmcallTest(UINT64 Param1, UINT64 Param2, UINT64 Param3) {
 
    LogInfo("VmcallTest called with @Param1 = 0x%llx , @Param2 = 0x%llx , @Param3 = 0x%llx", Param1, Param2, Param3);
    return STATUS_SUCCESS;
}

最后,我们可以使用以下代码并将 VMCALL_TEST 作为 Vmcall 编号以及其他可选参数传递。。

1
2
//  Check if everything is ok then return true otherwise false
AsmVmxVmcall(VMCALL_TEST, 0x22, 0x333, 0x4444);

不要忘记上面的代码只能在 vmx 非 root 模式下执行。

关于 VMCALL 我没什么可说的,但为了进一步阅读(与我们的虚拟机管理程序无关),如果您想知道在 vmx root 模式下执行 VMCALL 会发生什么,它会调用 SMM 监视器。 此调用将激活系统管理中断 (SMI) 和系统管理模式 (SMM) 的双监视器处理(如果尚未激活)。 换句话说,在 vmx root 模式下执行 Vmcall 会导致 SMM VM 退出!

请阅读英特尔 SDM 中的第 34.15.2 节和第 34.15.6 节以了解更多信息。

7.4.从MMU虚拟化(EPT)开始

让我从物理地址和虚拟地址之间的差异开始,

物理寻址意味着您的程序知道 RAM 的实际布局。 当您访问地址 0x8746b3 处的变量时,这就是它存储在物理 RAM 芯片中的位置。

通过虚拟寻址,所有应用程序内存访问都会进入页表,然后页表从虚拟地址映射到物理地址。 因此,每个应用程序都有自己的“私有”地址空间,并且任何程序都无法读取或写入另一个程序的内存。

EPT 是页行走长度为 4(或在较新版本中为 5)的页表。 它将访客物理地址转换为主机物理地址。

首先,您必须了解 EPT 将客户物理页映射到主机物理页,映射物理地址使虚拟机管理程序更容易理解,因为您可以忘记与虚拟内存和操作系统内存管理器相关的所有概念。 为什么? 那是因为你无法分配更多的物理内存。 当然,您可以将 RAM 直接热插入主板,但现在让我们忘记这一点 ,因此 RAM 通常从 0 开始,通常以 AMOUNT OF RAM + SOME MORE 结束,其中 SOME MORE 是一些 MMIO/设备空间。

看下图(来自 hvpp ),具有 2 GB RAM 的 VMWare VM 的内存范围。
图片描述

注意范围之间的空洞(例如,A0000 - 100000); 屏幕截图中的范围由实际物理 RAM 支持,孔是 MMIO 空间。

现在,您知道如果分配或释放内存,RAM 范围始终存在,并且 RAM 中的数据内容会发生变化。

请记住,作为一种电子电路,RAM 中当然没有漏洞,但 BIOS 是如何将某些物理内存范围映射到实际硬件 RAM 的,换句话说,RAM 通常不是一个连续的地址空间,如果您有 1 GB 的 RAM 通常不是一块 0 … 1GB 的物理地址空间,而是该空间的某些部分所属,例如网卡、声卡、USB 集线器等。

让我们看看 VMWare、Hyper-V、VirtualBox 等虚拟机管理程序如何处理物理内存。 我们没有相同的方法,但它可以帮助您更好地理解 MMU 虚拟化。

在VMWare(Hyper-v、VirtualBox等)中,VM有自己的物理内存,我们的PC(主机)也有一些物理地址空间。 EPT 的存在使您可以将客户机物理内存转换为主机物理内存。 例如,如果客户机想要从物理地址 0x1000 读取,它会查找 EPT,EPT 告诉它内存的内容位于主机的物理地址 0x5000 上。 您当然不希望让 VMWare 中的某些来宾读取主机上的物理内存,因此正确设置 EPT 并拥有一些专用于来宾的物理内存块是 VMWare 的工作。

7.4.1.存储器类型范围寄存器(MTTR)

到现在为止,您已经了解了内存 (RAM) 如何划分区域; 这些区域可以使用 MTRR 寄存器找到,仅此而已!

现在让我们更准确地解释它们。

维基百科 对 MTRR 的定义如下:

内存类型范围寄存器 (MTRR) 是一组处理器补充功能控制寄存器,它们为系统软件提供如何缓存 CPU 对内存范围的访问的控制。 它使用一组可编程模型特定寄存器 (MSR),这是大多数现代 CPU 提供的特殊寄存器。 对内存范围的可能访问模式可以是不缓存、直写、写组合、写保护和回写。 在回写模式下,写操作被写入CPU的缓存,并且缓存被标记为脏,以便其内容稍后被写入内存。
图片描述
在旧的x86架构系统中,主要是单独的芯片在CPU封装之外提供缓存,该功能由芯片组本身控制并通过BIOS设置进行配置,当CPU缓存移至CPU内部时,CPU实现固定范围的MTRR 。

通常,BIOS 软件配置 MTRR。 然后,操作系统或执行程序可以使用典型的页级高速缓存属性自由修改内存映射。

如果您对上面的句子感到困惑,让我更清楚地解释一下。 RAM 被划分为不同的区域,我们希望使用 MTRR 寄存器读取这些块的详细信息(基址、结束地址和缓存策略)。 缓存策略是 BIOS 或操作系统为特定区域设置的内容。 例如,操作系统决定将UC(未缓存)放置到RAM的从0x1000到0x2000(物理地址)开始的区域,然后选择将WB(写回)放置到从0x5000到0x7000(物理地址)开始的区域,它基于操作系统策略。 如果您不了解不同的内存类型缓存(例如 UC、WB),您可以阅读 此处

好的,让我们看看如何读取这些 MTRR。

MTRR 功能的可用性是特定于型号的,这意味着我们可以通过执行 CPUID 指令并读取功能信息寄存器 (EDX) 中的 MTRR 标志(位 12)的状态来确定处理器是否支持 MTRR。 不过,此检查并不是必需的,因为我们的流程可能支持它,因为它是一项旧功能。

对我们来说最重要的是一个名为“IA32_MTRR_DEF_TYPE”的 MSR。 以下结构代表 IA32_MTRR_DEF_TYPE :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// MSR_IA32_MTRR_DEF_TYPE
typedef union _IA32_MTRR_DEF_TYPE_REGISTER
{
    struct
    {
        /**
         * [Bits 2:0] Default Memory Type.
         */
        UINT64 DefaultMemoryType : 3;
        UINT64 Reserved1 : 7;
 
        /**
         * [Bit 10] Fixed Range MTRR Enable.
         */
        UINT64 FixedRangeMtrrEnable : 1;
 
        /**
         * [Bit 11] MTRR Enable.
         */
        UINT64 MtrrEnable : 1;
        UINT64 Reserved2 : 52;
    };
 
    UINT64 Flags;
} IA32_MTRR_DEF_TYPE_REGISTER, * PIA32_MTRR_DEF_TYPE_REGISTER;

我们实现了一个名为“EptCheckFeatures”的函数,该函数检查我们的处理器是否支持基本的 EPT 功能; 对于 MTRR,我们将检查 MTRR 是否启用。 对于我们的虚拟机管理程序来说,启用 MTRR 是必要的。 (我们将在稍后描述 EPT 时完成此功能。)

1
2
3
4
5
6
7
8
9
IA32_MTRR_DEF_TYPE_REGISTER MTRRDefType;
 
MTRRDefType.Flags = __readmsr(MSR_IA32_MTRR_DEF_TYPE);
 
if (!MTRRDefType.MtrrEnable)
{
    LogError("Mtrr Dynamic Ranges not supported");
    return FALSE;
}

7.4.2.Building MTTR Map

在从内存区域创建映射之前,最好看看 Windbg 如何使用“!mtrr”命令显示 MTRR 区域及其缓存策略。
图片描述
正如您在上图中看到的,Windows 更喜欢使用固定范围寄存器(启用固定支持)和可变范围寄存器。

我将在本文后面讨论固定范围寄存器。

为了读取 MTRR,我们首先读取 IA32_MTRRCAP MSR (0xFE) 的 VCNT 值,该值决定变量 MTRR 的数量(区域数量)。
图片描述
下一步是迭代每个 MTRR 变量; 我们读取每个范围的 MSR_IA32_MTRR_PHYSBASE0 和 MSR_IA32_MTRR_PHYSMASK0 并检查该范围是否有效(基于 IA32_MTRR_PHYSMASK_REGISTER.Valid 位)。

1
2
CurrentPhysBase.Flags = __readmsr(MSR_IA32_MTRR_PHYSBASE0 + (CurrentRegister * 2));
CurrentPhysMask.Flags = __readmsr(MSR_IA32_MTRR_PHYSMASK0 + (CurrentRegister * 2));

现在我们需要根据MSR计算起始地址和结束地址(物理)。

起始地址:

1
2
// Calculate the base address in bytes
Descriptor->PhysicalBaseAddress = CurrentPhysBase.PageFrameNumber * PAGE_SIZE;

结束地址:

1
2
3
4
5
6
// Calculate the total size of the range
// The lowest bit of the mask that is set to 1 specifies the size of the range
_BitScanForward64(&NumberOfBitsInMask, CurrentPhysMask.PageFrameNumber * PAGE_SIZE);
 
// Size of the range in bytes + Base Address
Descriptor->PhysicalEndAddress = Descriptor->PhysicalBaseAddress + ((1ULL << NumberOfBitsInMask) - 1ULL);

有关 MTRR 计算的更多信息,您可以阅读 Intel SDM Vol 3A(11.11.3 基础和掩模计算示例)。

最后,读取BIOS或操作系统设置的缓存策略。

1
2
// Memory Type (cacheability attributes)
Descriptor->MemoryType = (UCHAR)CurrentPhysBase.Type;

把它们放在一起,我们有以下函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/* Build MTRR Map of current physical addresses */
BOOLEAN EptBuildMtrrMap()
{
    IA32_MTRR_CAPABILITIES_REGISTER MTRRCap;
    IA32_MTRR_PHYSBASE_REGISTER CurrentPhysBase;
    IA32_MTRR_PHYSMASK_REGISTER CurrentPhysMask;
    PMTRR_RANGE_DESCRIPTOR Descriptor;
    ULONG CurrentRegister;
    ULONG NumberOfBitsInMask;
 
 
    MTRRCap.Flags = __readmsr(MSR_IA32_MTRR_CAPABILITIES);
 
    for (CurrentRegister = 0; CurrentRegister < MTRRCap.VariableRangeCount; CurrentRegister++)
    {
        // For each dynamic register pair
        CurrentPhysBase.Flags = __readmsr(MSR_IA32_MTRR_PHYSBASE0 + (CurrentRegister * 2));
        CurrentPhysMask.Flags = __readmsr(MSR_IA32_MTRR_PHYSMASK0 + (CurrentRegister * 2));
 
        // Is the range enabled?
        if (CurrentPhysMask.Valid)
        {
            // We only need to read these once because the ISA dictates that MTRRs are to be synchronized between all processors
            // during BIOS initialization.
            Descriptor = &EptState->MemoryRanges[EptState->NumberOfEnabledMemoryRanges++];
 
            // Calculate the base address in bytes
            Descriptor->PhysicalBaseAddress = CurrentPhysBase.PageFrameNumber * PAGE_SIZE;
 
            // Calculate the total size of the range
            // The lowest bit of the mask that is set to 1 specifies the size of the range
            _BitScanForward64(&NumberOfBitsInMask, CurrentPhysMask.PageFrameNumber * PAGE_SIZE);
 
            // Size of the range in bytes + Base Address
            Descriptor->PhysicalEndAddress = Descriptor->PhysicalBaseAddress + ((1ULL << NumberOfBitsInMask) - 1ULL);
 
            // Memory Type (cacheability attributes)
            Descriptor->MemoryType = (UCHAR)CurrentPhysBase.Type;
 
            if (Descriptor->MemoryType == MEMORY_TYPE_WRITE_BACK)
            {
                /* This is already our default, so no need to store this range.
                 * Simply 'free' the range we just wrote. */
                EptState->NumberOfEnabledMemoryRanges--;
            }
            LogInfo("MTRR Range: Base=0x%llx End=0x%llx Type=0x%x", Descriptor->PhysicalBaseAddress, Descriptor->PhysicalEndAddress, Descriptor->MemoryType);
        }
    }
 
    LogInfo("Total MTRR Ranges Committed: %d", EptState->NumberOfEnabledMemoryRanges);
 
    return TRUE;
}

7.4.3.固定范围MTRR和PAT

上面的部分足以理解 EPT 的 MTRR。 不过,我想多谈谈物理和虚拟内存布局以及缓存策略(您可以跳过本节,因为它与我们的虚拟机管理程序无关)。

还有其他 MTRR 寄存器,顾名思义,称为固定范围寄存器,这些寄存器是处理器定义的一些预定义范围(您可以在 Windbg 中的 !mtrr 命令的第一行中看到它们)。

这些范围如下表所示:
图片描述
正如您所看到的,物理 RAM 的开始是由这些固定范围寄存器定义的,这是出于性能和遗留原因。

请注意,MTRR 应连续定义; 如果您的 MTRR 不连续,则 RAM 的其余部分通常被假定为一个空洞。

请记住,每个 RAM 区域的缓存策略由物理区域的 MTRR 和虚拟区域的页面属性表 (PAT) 定义,以便每个页面都可以通过配置 IA32_PAT MSR 使用自己的缓存策略。 这意味着有时会忽略 MTRR 寄存器中指定的缓存策略,而是使用页级缓存策略。 Intel SDM 中有一个表显示了 PAT 和 MTRR 之间的优先级规则(表 11-7. Pentium III 和较新处理器系列的有效页级内存类型)。

如需进一步阅读,您可以阅读 Intel SDM(第 11 章第 3 A 卷 - 11.11 内存类型范围寄存器 (MTRRS) 和 11.12 页面属性表 (PAT))。

7.4.4.使用EPT虚拟化当前系统的内存

由于您之前从 EPT(第 4 部分)获得了一些信息,因此我们为 VM 创建一个 EPT 表。 在对当前机器内存进行全虚拟化的情况下,EPT的实现方式有多种; 我们可以为每个核心使用一个单独的 EPT 表,也可以为所有核心使用一个 EPT 表,我们的方法是为所有核心使用一个 EPT,因为它更易于实施和管理(有关优点和注意事项的更多详细信息,请参阅讨论部分)。

我们想要做的是创建一个 EPT 表,将所有可用的物理内存(我们从 MTRR 获得物理内存的详细信息)映射到物理地址。 这就像添加一个表,将以前的地址映射到以前的地址,并使用一些附加字段来控制它们。 如果您感到困惑,没关系,只需阅读本文的其余部分,事情就会变得更加清晰。

7.4.5.EPT身份映射

在我们的虚拟机管理程序或所有虚拟化已运行系统的虚拟机管理程序(不是 VMWare、VirtualBox 等)中,我们有一个术语称为“身份映射或 1:1 映射”。 这意味着如果您访问访客 PA(物理地址)0x4000,它将访问 0x4000 处的主机 PA,因此,您必须将 RAM 的空洞以及内存范围映射到访客。

与常规页表相同(也可以这样设置页表,使虚拟地址0x1234对应物理地址0x1234);

如果您没有映射某些物理内存并且来宾访问它,那么您将得到“EPT Violation”,这可以理解为虚拟机管理程序的页面错误。

为了将所有内容一一映射,我们将创建 PML4E,然后是 PDPTE,然后是 PDE,最后是 PE。 在粒度为 2 MB 的情况下,我们将跳过 PE。 当然,最好具有 4 KB 粒度,但请记住,4 GB RAM 会产生一百万个 4 KB 页面,因此具有 4 KB 粒度会占用大量内存,除此之外,设置 4 KB 粒度将需要相当多的时间。如果您频繁测试虚拟机管理程序,这会让您发疯。

hvpp, gbhv, 和大多数其他虚拟机管理程序首先为整个系统(包括 RAM 范围和 MMIO 孔)设置 2 MB,然后根据需要将一些 2 MB 页面分解为 4 KB 页面。

拆分为 4 KB 页面后,您可以再次将它们合并回 2 MB 页面。 我们对虚拟机管理程序驱动程序执行相同的操作,首先初始粒度为 2 MB,然后在需要时将其拆分为 4 KB。

为什么我们不应该关心 Windows 的新内存分配?

嗯,那是因为我们使用 2 MB 块映射了所有物理内存(物理 RAM 中的每个可能的地址),包括已分配的内存块和尚未分配的内存块,因此无论 Windows 是否分配新的内存块,我们都已经将其放在我们的 EPT 表中。

我们要做的是创建一个PML4E; 然后是PDPTE,我们将把该PDPTE添加到PML4E中,然后创建PDE并将其添加到PDPTE中,最后创建PE,它将指向物理地址0。然后我们创建另一个PE,它将指向地址0x1000(如果粒度为 4 KB)或 0x200000(如果粒度为 2 MB)并再次添加 512 次(所有分页表(包括 EPT 页表和常规页表)中的最大条目为 512),然后我们将创建另一个 PDE 并重复!

总而言之,我们的虚拟机管理程序不应该关心任何虚拟地址,而只关心物理内存。

理论已经足够了,让我们来实现吧!

7.4.6.设置PML4和PML3条目

首先,我们必须为EPT页表分配一块大内存,然后将其清零。

1
2
3
4
5
6
7
8
9
10
PageTable = MmAllocateContiguousMemory((sizeof(VMM_EPT_PAGE_TABLE) / PAGE_SIZE) * PAGE_SIZE, MaxSize);
 
if (PageTable == NULL)
{
    LogError("Failed to allocate memory for PageTable");
    return NULL;
}
 
// Zero out all entries to ensure all unused entries are marked Not Present
RtlZeroMemory(PageTable, sizeof(VMM_EPT_PAGE_TABLE));

我们有一个链表来保存每个分配的内存的踪迹; 我们必须首先对其进行初始化,以便每当我们想要关闭虚拟机管理程序时都可以取消分配已分配的页面。

1
2
// Initialize the dynamic split list which holds all dynamic page splits
InitializeListHead(&PageTable->DynamicSplitList);

现在是初始化第一个表 (EPT PML4) 的时候了。 对于初始化阶段,我们将所有 EPT 表的所有访问权限设置为 1(包括读访问权限、写访问权限、执行访问权限)。

PML4E 的物理地址(页帧号 - PFN)是 PML3 的地址,并且由于它已对齐并且每当处理器想要转换它时(它执行乘以 PAGE_SIZE),因此我们将其除以 PAGE_SIZE (4096)。

1
2
3
4
5
// Mark the first 512GB PML4 entry as present, which allows us to manage up to 512GB of discrete paging structures.
PageTable->PML4[0].PageFrameNumber = (SIZE_T)VirtualAddressToPhysicalAddress(&PageTable->PML3[0]) / PAGE_SIZE;
PageTable->PML4[0].ReadAccess = 1;
PageTable->PML4[0].WriteAccess = 1;
PageTable->PML4[0].ExecuteAccess = 1;

每个 PML4 条目占用 512 GB 内存,因此一个条目就足够了。 每个表有 512 个条目,因此我们必须用 512 个 1 GB 条目填充 PML3。 我们通过创建一个启用了 RWX 的模板并使用 __stosq 用该模板连续填充表来完成此操作。 __stosq 生成存储字符串指令 (rep stosq) 意味着连续(在我们的示例中为 VMM_EPT_PML3E_COUNT=512)将某些内容复制到特殊位置。

下一步是将我们之前分配的 PML2 条目转换为物理地址,并用这些地址填充 PML3。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Set up one 'template' RWX PML3 entry and copy it into each of the 512 PML3 entries
// Using the same method as SimpleVisor for copying each entry using intrinsics.
RWXTemplate.ReadAccess = 1;
RWXTemplate.WriteAccess = 1;
RWXTemplate.ExecuteAccess = 1;
 
// Copy the template into each of the 512 PML3 entry slots
__stosq((SIZE_T*)&PageTable->PML3[0], RWXTemplate.Flags, VMM_EPT_PML3E_COUNT);
 
// For each of the 512 PML3 entries
for (EntryIndex = 0; EntryIndex < VMM_EPT_PML3E_COUNT; EntryIndex++)
{
    // Map the 1GB PML3 entry to 512 PML2 (2MB) entries to describe each large page.
    // NOTE: We do *not* manage any PML1 (4096 byte) entries and do not allocate them.
    PageTable->PML3[EntryIndex].PageFrameNumber = (SIZE_T)VirtualAddressToPhysicalAddress(&PageTable->PML2[EntryIndex][0]) / PAGE_SIZE;
}

对于 PML2,我们有相同的方法,用 RWX 模板填充它,但这次我们将 LargePage 设置为 1(因为我上面告诉过您以 2 MB 粒度进行初始化)。 与上面完全相同,我们使用 __stosq 来填充这些条目,这次使用 512*512 条目,因为我们有 512 个条目,每个条目描述 512 个条目。

下一步是设置每个条目的 PFN 地址。 我将在下一节中描述 EptSetupPML2Entry。

请注意,我们正在填充 512*512 表的条目,因此我们必须对每个 EntryGroupIndex 执行乘以 512,然后将其添加到当前 PML2 的地址 (EntryIndex)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// All PML2 entries will be RWX and 'present'
PML2EntryTemplate.WriteAccess = 1;
PML2EntryTemplate.ReadAccess = 1;
PML2EntryTemplate.ExecuteAccess = 1;
 
// We are using 2MB large pages, so we must mark this 1 here.
PML2EntryTemplate.LargePage = 1;
 
/* For each collection of 512 PML2 entries (512 collections * 512 entries per collection), mark it RWX using the same template above.
   This marks the entries as "Present" regardless of if the actual system has memory at this region or not. We will cause a fault in our
   EPT handler if the guest access a page outside a usable range, despite the EPT frame being present here.
 */
__stosq((SIZE_T*)&PageTable->PML2[0], PML2EntryTemplate.Flags, VMM_EPT_PML3E_COUNT * VMM_EPT_PML2E_COUNT);
 
// For each of the 512 collections of 512 2MB PML2 entries
for (EntryGroupIndex = 0; EntryGroupIndex < VMM_EPT_PML3E_COUNT; EntryGroupIndex++)
{
    // For each 2MB PML2 entry in the collection
    for (EntryIndex = 0; EntryIndex < VMM_EPT_PML2E_COUNT; EntryIndex++)
    {
        // Setup the memory type and frame number of the PML2 entry.
        EptSetupPML2Entry(&PageTable->PML2[EntryGroupIndex][EntryIndex], (EntryGroupIndex * VMM_EPT_PML2E_COUNT) + EntryIndex);
    }
}

把它们放在一起我们有以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
/* Allocates page maps and create identity page table */
PVMM_EPT_PAGE_TABLE EptAllocateAndCreateIdentityPageTable()
{
    PVMM_EPT_PAGE_TABLE PageTable;
    EPT_PML3_POINTER RWXTemplate;
    EPT_PML2_ENTRY PML2EntryTemplate;
    SIZE_T EntryGroupIndex;
    SIZE_T EntryIndex;
 
    // Allocate all paging structures as 4KB aligned pages
    PHYSICAL_ADDRESS MaxSize;
    PVOID Output;
 
    // Allocate address anywhere in the OS's memory space
    MaxSize.QuadPart = MAXULONG64;
 
    PageTable = MmAllocateContiguousMemory((sizeof(VMM_EPT_PAGE_TABLE) / PAGE_SIZE) * PAGE_SIZE, MaxSize);
 
    if (PageTable == NULL)
    {
        LogError("Failed to allocate memory for PageTable");
        return NULL;
    }
 
    // Zero out all entries to ensure all unused entries are marked Not Present
    RtlZeroMemory(PageTable, sizeof(VMM_EPT_PAGE_TABLE));
 
    // Initialize the dynamic split list which holds all dynamic page splits
    InitializeListHead(&PageTable->DynamicSplitList);
 
    // Mark the first 512GB PML4 entry as present, which allows us to manage up to 512GB of discrete paging structures.
    PageTable->PML4[0].PageFrameNumber = (SIZE_T)VirtualAddressToPhysicalAddress(&PageTable->PML3[0]) / PAGE_SIZE;
    PageTable->PML4[0].ReadAccess = 1;
    PageTable->PML4[0].WriteAccess = 1;
    PageTable->PML4[0].ExecuteAccess = 1;
 
    /* Now mark each 1GB PML3 entry as RWX and map each to their PML2 entry */
 
    // Ensure stack memory is cleared
    RWXTemplate.Flags = 0;
 
    // Set up one 'template' RWX PML3 entry and copy it into each of the 512 PML3 entries
    // Using the same method as SimpleVisor for copying each entry using intrinsics.
    RWXTemplate.ReadAccess = 1;
    RWXTemplate.WriteAccess = 1;
    RWXTemplate.ExecuteAccess = 1;
 
    // Copy the template into each of the 512 PML3 entry slots
    __stosq((SIZE_T*)&PageTable->PML3[0], RWXTemplate.Flags, VMM_EPT_PML3E_COUNT);
 
    // For each of the 512 PML3 entries
    for (EntryIndex = 0; EntryIndex < VMM_EPT_PML3E_COUNT; EntryIndex++)
    {
        // Map the 1GB PML3 entry to 512 PML2 (2MB) entries to describe each large page.
        // NOTE: We do *not* manage any PML1 (4096 byte) entries and do not allocate them.
        PageTable->PML3[EntryIndex].PageFrameNumber = (SIZE_T)VirtualAddressToPhysicalAddress(&PageTable->PML2[EntryIndex][0]) / PAGE_SIZE;
    }
 
    PML2EntryTemplate.Flags = 0;
 
    // All PML2 entries will be RWX and 'present'
    PML2EntryTemplate.WriteAccess = 1;
    PML2EntryTemplate.ReadAccess = 1;
    PML2EntryTemplate.ExecuteAccess = 1;
 
    // We are using 2MB large pages, so we must mark this 1 here.
    PML2EntryTemplate.LargePage = 1;
 
    /* For each collection of 512 PML2 entries (512 collections * 512 entries per collection), mark it RWX using the same template above.
       This marks the entries as "Present" regardless of if the actual system has memory at this region or not. We will cause a fault in our
       EPT handler if the guest access a page outside a usable range, despite the EPT frame being present here.
     */
    __stosq((SIZE_T*)&PageTable->PML2[0], PML2EntryTemplate.Flags, VMM_EPT_PML3E_COUNT * VMM_EPT_PML2E_COUNT);
 
    // For each of the 512 collections of 512 2MB PML2 entries
    for (EntryGroupIndex = 0; EntryGroupIndex < VMM_EPT_PML3E_COUNT; EntryGroupIndex++)
    {
        // For each 2MB PML2 entry in the collection
        for (EntryIndex = 0; EntryIndex < VMM_EPT_PML2E_COUNT; EntryIndex++)
        {
            // Setup the memory type and frame number of the PML2 entry.
            EptSetupPML2Entry(&PageTable->PML2[EntryGroupIndex][EntryIndex], (EntryGroupIndex * VMM_EPT_PML2E_COUNT) + EntryIndex);
        }
    }
 
    return PageTable;
}

7.4.7.设置PML2条目

PML2与其他表不同; 这是因为,在我们的 2 MB 设计中,它是最后一个表,因此它必须处理 MTRR 的缓存策略。

首先,我们必须设置 PML2 条目的 PageFrameNumber。 这是因为我们正在映射所有 512 GB,没有任何漏洞,我的意思是,我们并不是试图查看 MTRR 的基地址和结束地址并基于它们进行映射,而是映射 512 GB 内的每个可能的物理地址。 再想一想。

如果您想了解有关 Windows 中 PFN 的更多信息,可以阅读我的博客文章 Inside Windows Page Frame Number (PFN) – Part 1 Part 2

1
2
3
4
5
6
  Each of the 512 collections of 512 PML2 entries is setup here.
  This will, in total, identity map every physical address from 0x0 to physical address 0x8000000000 (512GB of memory)
 
  ((EntryGroupIndex * VMM_EPT_PML2E_COUNT) + EntryIndex) * 2MB is the actual physical address we're mapping
 */
NewEntry->PageFrameNumber = PageFrameNumber;

现在是时候看看基于 MTRR 的实际缓存策略了。 MTRR 中的范围不除以 4 KB 或 2 MB,这些是精确的物理地址。 我们要做的是迭代每个 MTRR,看看特殊的 MTRR 是否描述了我们当前的物理地址。

如果都没有描述,那么我们选择Write-Back(MEMORY_TYPE_WRITE_BACK)作为默认的缓存策略; 否则,我们必须选择 MTRR 中使用的缓存策略。

这种方法将使我们的 EPT PML2 就像一个真实的系统一样。

如果我们不选择系统特定的缓存策略,那么将会导致灾难性的错误。 例如,一些使用物理内存作为命令和控制机制的设备会通过缓存,不会立即响应我们的请求,或者对于APIC设备来说,在实时中断的情况下将无法工作。

以下代码负责根据 MTRR 查找所需的缓存策略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Default memory type is always WB for performance.
TargetMemoryType = MEMORY_TYPE_WRITE_BACK;
 
// For each MTRR range
for (CurrentMtrrRange = 0; CurrentMtrrRange < EptState->NumberOfEnabledMemoryRanges; CurrentMtrrRange++)
{
    // If this page's address is below or equal to the max physical address of the range
    if (AddressOfPage <= EptState->MemoryRanges[CurrentMtrrRange].PhysicalEndAddress)
    {
        // And this page's last address is above or equal to the base physical address of the range
        if ((AddressOfPage + SIZE_2_MB - 1) >= EptState->MemoryRanges[CurrentMtrrRange].PhysicalBaseAddress)
        {
            /* If we're here, this page fell within one of the ranges specified by the variable MTRRs
               Therefore, we must mark this page as the same cache type exposed by the MTRR
             */
            TargetMemoryType = EptState->MemoryRanges[CurrentMtrrRange].MemoryType;
            // LogInfo("0x%X> Range=%llX -> %llX | Begin=%llX End=%llX", PageFrameNumber, AddressOfPage, AddressOfPage + SIZE_2_MB - 1, EptState->MemoryRanges[CurrentMtrrRange].PhysicalBaseAddress, EptState->MemoryRanges[CurrentMtrrRange].PhysicalEndAddress);
 
            // 11.11.4.1 MTRR Precedences
            if (TargetMemoryType == MEMORY_TYPE_UNCACHEABLE)
            {
                // If this is going to be marked uncacheable, then we stop the search as UC always takes precedent.
                break;
            }
        }
    }
}
 
// Finally, commit the memory type to the entry.
NewEntry->MemoryType = TargetMemoryType;

7.4.8.违反EPT规定

英特尔是这样描述 EPT 违规的:

当没有 EPT 错误配置,但 EPT 分页结构条目不允许使用来宾物理地址进行访问时,就会发生 EPT 违规。

但这很难理解,简而言之,每次一条指令尝试读取一页(读访问),或者一条指令尝试在一页上写入(写访问),或者一条指令导致从一页和 EPT 属性中获取指令(我们在该页面的上述部分中配置的页面不允许这样做,然后就会发生 EPT 违规。

让我再解释一下,假设我们的 EPT 表中有一个条目负责映射物理地址 0x1000。 在此条目中,我们将写入访问权限设置为 0(读取访问权限 = 1,执行访问权限 = 1)。 如果任何指令尝试在该页面上写入,例如使用 (Mov [0x1000], RAX),则由于分页属性不允许写入,因此会发生 EPT 违规,现在调用我们的回调,以便我们可以决定我们想对该页面做什么。

0x1000 是指物理地址。 当然,如果您有虚拟地址,那么它会被转换为物理地址。

另一个例子,假设一个 NT 函数(例如 NtCreateFile)位于 fffff801`80230540。

1
2
3
4
nt!NtCreateFile:
fffff801`80230540 4881ec88000000  sub     rsp,88h
fffff801`80230547 33c0            xor     eax,eax
fffff801`80230549 4889442478      mov     qword ptr [rsp+78h],rax

如果我们将其转换为物理地址,那么NtCreateFile在物理内存中的地址是0x3B8000,现在我们尝试在我们的EPT PTE表中找到这个物理地址。 然后我们将该条目的执行访问设置为 0。现在,每次有人尝试对该特定页面进行调用、jmp、ret 等操作时,都会发生 EPT 违规。

这就是使用 EPT 函数钩子的基本思想,我们将在第 8 部分详细讨论。

现在,首先,我们必须读取导致 EPT 违规的物理地址。 这是通过使用 Vmread 指令读取 GUEST_PHYSICAL_ADDRESS 来完成的。

1
2
3
4
// Reading guest physical address
GuestPhysicalAddr = 0;
__vmx_vmread(GUEST_PHYSICAL_ADDRESS, &GuestPhysicalAddr);
LogInfo("Guest Physical Address : 0x%llx", GuestPhysicalAddr);

我们必须阅读的第二件事是退出资格。 如果您还记得上一部分,退出资格提供了有关退出原因的更多详细信息。

我的意思是,每个退出原因可能都有一个特殊的退出资格,该资格对于该特殊的退出原因具有特殊的含义。 (我在上一句中用了多少个“特殊”?)

退出原因可以使用 Vmread 指令从 VM_EXIT_REASON 读取。

1
2
ULONG ExitReason = 0;
__vmx_vmread(VM_EXIT_REASON, &ExitReason);

如果发生 EPT 违规,退出资格会显示发生此违规的原因。 例如,它表示由于向读访问为 0 的物理页读取数据或从执行访问为 0 的物理页取指令(函数尝试执行指令)而发生 EPT 违规。

下表显示了退出资格的结构以及 EPT 违规的各个位的含义。
图片描述
现在我们已经有了所有详细信息,我们需要将它们传递给 EptHandlePageHookExit, 我们将在下一节中处理它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/*
   Handle VM exits for EPT violations. Violations are thrown whenever an operation is performed
   on an EPT entry that does not provide permissions to access that page.
*/
BOOLEAN EptHandleEptViolation(ULONG ExitQualification, UINT64 GuestPhysicalAddr)
{
 
    VMX_EXIT_QUALIFICATION_EPT_VIOLATION ViolationQualification;
 
    DbgBreakPoint();
 
    ViolationQualification.Flags = ExitQualification;
 
    if (EptHandlePageHookExit(ViolationQualification, GuestPhysicalAddr))
    {
        // Handled by page hook code.
        return TRUE;
    }
 
    LogError("Unexpected EPT violation");
    DbgBreakPoint();
 
    // Redo the instruction that caused the exception.
    return FALSE;
}

7.4.9.EPT配置错误

另一个 EPT 派生的 vm-exit 是 EPT 错误配置 ( EXIT_REASON_EPT_MICONFIG )。

当在转换物理guest地址的过程中,逻辑处理器遇到包含不受支持的值的 EPT 分页结构条目时,就会发生 EPT 错误配置。

如果您想了解更多有关 EPT Misconfiguration 发生的所有原因,您可以参阅 Intel SDM - Vol 3C 第 28.2.3.1 节。

根据我的经验,我大多数时候遇到EPT Misconfiguration是因为我清除了条目的bit 0(表示不允许数据读取),并且设置了bit 1(报告允许数据写入)。

此外,当 EPT 分页结构条目配置了为未来功能保留的设置时,也会发生 EPT 错误配置。

这是致命的错误,让我们打破看看我们做错了什么!

1
2
3
4
5
6
7
8
9
VOID EptHandleMisconfiguration(UINT64 GuestAddress)
{
    LogInfo("EPT Misconfiguration!");
    LogError("A field in the EPT paging structure was invalid, Faulting guest address : 0x%llx", GuestAddress);
 
    DbgBreakPoint();
    // We can't continue now.
    // EPT misconfiguration is a fatal exception that will probably crash the OS if we don't get out now.
}

7.4.10.将EPT添加到VMCS

我们的虚拟机管理程序通过调用 EptLogicalProcessorInitialize 开始虚拟化 MMU,这会设置一个名为 EPTP 的 64 位值。下表为EPTP的结构 。 如果你看了第 4 部分,我们在那部分也有这个表,但是这里有一个变化,在我编写第 4 部分时保留了第 7 位,现在它与影子堆栈有关。

EptLogicalProcessorInitialize 调用 EptAllocateAndCreateIdentityPageTable 来分配标识表(如上所述)。

为了提高性能,我们让处理器知道它可以缓存 EPT( MemoryTypeMEMORY_TYPE_WRITE_BACK )。

我们没有利用“ 访问 ”和“ ”标志功能( EnableAccessAndDirtyFlagsFALSE )。

正如 Intel 提到的,Page Walk 应该是我们使用的表的数量 (4) 减 1,因此 PageWalkLength = 3 表示 EPT 页遍历长度为 4。这是因为我们不只使用 3 个具有 2 MB 粒度的表,我们将 2 MB 页面拆分为 4 KB 粒度。

最后一步是将 EPTP 保存到全局变量中,以便我们稍后可以使用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/*
  Initialize EPT for an individual logical processor.
  Creates an identity mapped page table and sets up an EPTP to be applied to the VMCS later.
*/
BOOLEAN EptLogicalProcessorInitialize()
{
    PVMM_EPT_PAGE_TABLE PageTable;
    EPTP EPTP;
 
    /* Allocate the identity mapped page table*/
    PageTable = EptAllocateAndCreateIdentityPageTable();
    if (!PageTable)
    {
        LogError("Unable to allocate memory for EPT");
        return FALSE;
    }
 
    // Virtual address to the page table to keep track of it for later freeing
    EptState->EptPageTable = PageTable;
 
    EPTP.Flags = 0;
 
    // For performance, we let the processor know it can cache the EPT.
    EPTP.MemoryType = MEMORY_TYPE_WRITE_BACK;
 
    // We are not utilizing the 'access' and 'dirty' flag features.
    EPTP.EnableAccessAndDirtyFlags = FALSE;
 
    /*
      Bits 5:3 (1 less than the EPT page-walk length) must be 3, indicating an EPT page-walk length of 4;
      see Section 28.2.2
     */
    EPTP.PageWalkLength = 3;
 
    // The physical page number of the page table we will be using
    EPTP.PageFrameNumber = (SIZE_T)VirtualAddressToPhysicalAddress(&PageTable->PML4) / PAGE_SIZE;
 
    // We will write the EPTP to the VMCS later
    EptState->EptPointer = EPTP;
 
    return TRUE;
}

最后,我们需要使用 EPTP 表配置 Vmcs,因此我们使用 vmwriteEPT_POINTER 并将其设置为我们的 EPTP

1
2
// Set up EPT
__vmx_vmwrite(EPT_POINTER, EptState->EptPointer.Flags);

另外,不要忘记使用 CPU_BASED_CTL2_ENABLE_EPT 在基于辅助处理器的 VM 执行控制中启用 EPT 功能; 否则,它将无法工作。

1
2
3
4
5
6
SecondaryProcBasedVmExecControls = HvAdjustControls(CPU_BASED_CTL2_RDTSCP |
    CPU_BASED_CTL2_ENABLE_EPT | CPU_BASED_CTL2_ENABLE_INVPCID |
    CPU_BASED_CTL2_ENABLE_XSAVE_XRSTORS, MSR_IA32_VMX_PROCBASED_CTLS2);
 
__vmx_vmwrite(SECONDARY_VM_EXEC_CONTROL, SecondaryProcBasedVmExecControls);
LogInfo("Secondary Proc Based VM Exec Controls (MSR_IA32_VMX_PROCBASED_CTLS2) : 0x%x", SecondaryProcBasedVmExecControls);

7.5.监控页面的RWX活动

下一个重要主题是页面RWX的监控。 从上面的部分中,您看到我们将读取访问、写入访问和执行访问都设置为 1,但是要使用 EPT 的监控功能,我们必须将其中一些设置为 0,以便我们在每个访问上都会出现 EPT 违规。上面提到的访问。

使用这些功能(将访问设置为 0)本质上有其困难,与 IRQL、分割、无法使用 NT 功能、同步和死锁相关的问题是其中的一些问题和限制。

在本节中,我们将尝试解决这些问题。

7.5.1.为VMX-root 模式预分配缓冲区

执行VMLAUNCH后,我们不应该从VMX non-root模式修改EPT表; 这是因为如果我们这样做,那么它可能(并且将会)导致系统不一致。

这种限制以及我们无法在 VMX Root 模式下使用任何 NT 功能的事实给我们带来了新的挑战。

这些挑战之一是我们可能需要将 2 MB 页面拆分为 4 KB 页面,当然,需要另一个页表 (PML1) 来存储新 4 KB 页面的详细信息。 我的意思是,我们必须创建另一个页表(PML1),并且它需要新的内存。

我们无法在 Vmx root 模式下使用 ExAllocatePoolTag ,因为它是 NT API。 (您可以在 Vmx root 模式下使用它,您会发现它有时可以工作,有时会停止系统 - 原因在 讨论 部分中有描述)。

这个问题的解决方案是使用之前从 VMX non-root模式分配的缓冲区,并在 VMX root-mode下使用它,因此这给我们的虚拟机管理程序带来了第一个限制,即我们必须开始从 VMX non-root模式设置钩子,因为我们想要预分配一个缓冲区,然后使用特殊的 Vmcalls 将缓冲区和挂钩设置传递给 VMX root-mode。

顺便说一句,这并不是一个无法解决的限制,例如,您可以从 Vmx 非 root 模式分配 100 个页面,并在 Vmx root 模式下随时使用它们,这不一定是一个限制,但现在,让我们假设调用者应该从 Vmx 非 root 模式开始设置挂钩。

老实说,我想建立一种机制,使用 NMI 事件从 VMX root-mode运行代码到 VMX non-root模式; 使用这种方法将解决预分配缓冲区的问题,但是对于这一部分,让我们使用预分配缓冲区。

Hyperplatform Hvpp 使用预分配的缓冲区。

在本节和下一节中,我们将尝试完成一个名为“ EptPageHook ”的函数。

GuestState 中有一个名为“PreAllocationMemoryDetails”的每核全局变量,其定义如下:

1
2
3
4
5
typedef struct _VMX_NON_ROOT_MODE_MEMORY_ALLOCATOR
{
    PVOID PreAllocatedBuffer;       // As we can't use ExAllocatePoolWithTag in VMX Root mode, this holds a pre-allocated buffer address
                                    // PreAllocatedBuffer == 0 indicates that it's not previously allocated
} VMX_NON_ROOT_MODE_MEMORY_ALLOCATOR, * PVMX_NON_ROOT_MODE_MEMORY_ALLOCATOR;

现在我们正在尝试挂钩,我们将查看当前核心是否具有先前预先分配的缓冲区。 如果它没有缓冲区,那么我们使用 ExAllocatePoolWithTag 分配它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if (GuestState[LogicalCoreIndex].PreAllocatedMemoryDetails.PreAllocatedBuffer == NULL)
{
    PreAllocBuff = ExAllocatePoolWithTag(NonPagedPool, sizeof(VMM_EPT_DYNAMIC_SPLIT), POOLTAG);
 
    if (!PreAllocBuff)
    {
        LogError("Insufficient memory for pre-allocated buffer");
        return FALSE;
    }
 
    // Zero out the memory
    RtlZeroMemory(PreAllocBuff, sizeof(VMM_EPT_DYNAMIC_SPLIT));
 
    // Save the pre-allocated buffer
    GuestState[LogicalCoreIndex].PreAllocatedMemoryDetails.PreAllocatedBuffer = PreAllocBuff;
}

现在我们有两种不同的状态,如果我们之前使用 EPT 配置了 VMCS 并且我们已经在虚拟机管理程序中,那么我们必须要求 Vmx root-mode 为我们设置挂钩( 在 Vmlaunch 之后设置挂钩 ); 否则,我们可以在常规函数中修改它,因为我们还没有执行 VMLAUNCH (使用 EPT)( 在 Vmlaunch 之前设置钩子 )。

我所说的“使用 EPT”是指我们是否在虚拟机管理程序中使用此 EPT。 例如,您可能配置了没有EPTP的VMCS,然后执行VMLAUNCH,现在您决定创建EPT表,这种方式不需要VMX-root 模式来修改EPT表,我们可以从VMX non-root模式更改它因为我们还没有使用这个 EPT 表。

7.5.2.在VMLAUNCH之前设置钩子

我更喜欢在一个函数中完成所有操作,以便 EptVmxRootModePageHook 可以用于 VMX root-mode和非根模式。 不过,您不应该直接调用此函数,因为它需要一个准备阶段(相反,您可以调用 EptPageHook )。

我们要做的就是调用 EptVmxRootModePageHookHasLaunched 标志,该标志确定我们是否在 Vmx 操作中使用了 EPT。

1
2
3
4
if (EptVmxRootModePageHook(TargetFunc, HasLaunched) == TRUE) {
    LogInfo("[*] Hook applied (VM has not launched)");
    return TRUE;
}

描述 EptVmxRootModePageHook 部分中 我将在稍后的应用 Hook

7.5.3.VMLAUNCH后设置钩子

如果我们已经在 Vmx 操作中使用了这个 EPT,那么我们需要要求 Vmx root-mode 为我们修改 EPT 表; 换句话说,我们必须从VMX-root 模式调用 EptVmxRootModePageHook ,因此需要Vmcall。

我们在这里还有一些额外的事情要做,正如我告诉过你的,每个逻辑核心都有自己的一组与 EPT 相关的缓存,所以我们必须立即使所有核心的 EPT 表失效,当然这必须在 Vmx non-root 模式下完成,因为我们想要使用 NT API。

从 Vmx root-mode调用 EptVmxRootModePageHook,我们将使用 Vmcall 和 VMCALL_EXEC_HOOK_PAGE 并将函数虚拟地址 (TargetFunc) 作为第一个参数发送。

1
2
3
4
5
6
7
8
9
10
11
12
if (HasLaunched)
{
    if (AsmVmxVmcall(VMCALL_EXEC_HOOK_PAGE, TargetFunc, NULL, NULL, NULL) == STATUS_SUCCESS)
    {
        LogInfo("Hook applied from VMX Root Mode");
 
        // Now we have to notify all the core to invalidate their EPT
        HvNotifyAllToInvalidateEpt();
 
        return TRUE;
    }
}

在 Vmcall 处理程序中,我们只需调用 EptVmxRootModePageHook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
case VMCALL_EXEC_HOOK_PAGE:
{
    HookResult = EptVmxRootModePageHook(OptionalParam1, TRUE);
 
    if (HookResult)
    {
        VmcallStatus = STATUS_SUCCESS;
    }
    else
    {
        VmcallStatus = STATUS_UNSUCCESSFUL;
    }
    break;
}

让我们开始讨论失效部分,

HvNotifyAllToInvalidateEpt 使用 KeIpiGenericCall 在所有核心上广播 HvInvalidateEptByVmcall

1
2
3
4
5
6
/* Notify all core to invalidate their EPT */
VOID HvNotifyAllToInvalidateEpt()
{
    // Let's notify them all
    KeIpiGenericCall(HvInvalidateEptByVmcall, EptState->EptPointer.Flags);
}

由于失效应该在 VMX root-mode内进行( INVEPT 指令仅在 VMX root-mode下有效),因此 HvInvalidateEptByVmcall 使用带有 VMCALL_INVEPT_ALL_CONTEXTVMCALL_INVEPT_SINGLE_CONTEXT 的 Vmcall来通知 VMX root-mode有关失效的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* Invalidate EPT using Vmcall (should be called from Vmx non root mode) */
VOID HvInvalidateEptByVmcall(UINT64 Context)
{
    if (Context == NULL)
    {
        // We have to invalidate all contexts
        AsmVmxVmcall(VMCALL_INVEPT_ALL_CONTEXT, NULL, NULL, NULL);
    }
    else
    {
        // We have to invalidate all contexts
        AsmVmxVmcall(VMCALL_INVEPT_SINGLE_CONTEXT, Context, NULL, NULL);
    }
}

Vmcall 处理程序使用 InveptSingleContextInveptAllContexts 来使上下文无效; 我们将在本部分稍后详细讨论无效( 使 EPT 派生的转换无效(INVEPT) )。

1
2
3
4
5
6
7
8
9
10
11
12
case VMCALL_INVEPT_SINGLE_CONTEXT:
{
    InveptSingleContext(OptionalParam1);
    VmcallStatus = STATUS_SUCCESS;
    break;
}
case VMCALL_INVEPT_ALL_CONTEXT:
{
    InveptAllContexts();
    VmcallStatus = STATUS_SUCCESS;
    break;
}

7.5.4.在EPT表中查找页面条目

让我们看看如何在 PML1、PML2、PML3 和 PML4 中找到地址。

7.5.5.查找PML4、PML3、PML2条目

我们想要找到PML2条目,为了找到PML2,首先我们必须找到PML4和PML3。

我们使用序数方法来映射物理地址,因此所有物理地址都以相同的方式存储,因此我们需要一些定义来查找 表中条目的索引

这是定义。

1
2
3
4
5
6
7
8
9
10
11
// Index of the 1st paging structure (4096 byte)
#define ADDRMASK_EPT_PML1_INDEX(_VAR_) ((_VAR_ & 0x1FF000ULL) >> 12)
 
// Index of the 2nd paging structure (2MB)
#define ADDRMASK_EPT_PML2_INDEX(_VAR_) ((_VAR_ & 0x3FE00000ULL) >> 21)
 
// Index of the 3rd paging structure (1GB)
#define ADDRMASK_EPT_PML3_INDEX(_VAR_) ((_VAR_ & 0x7FC0000000ULL) >> 30)
 
// Index of the 4th paging structure (512GB)
#define ADDRMASK_EPT_PML4_INDEX(_VAR_) ((_VAR_ & 0xFF8000000000ULL) >> 39)

找到索引后,我们必须找到该索引的虚拟地址,以便修改页表。 这是因为在保护模式下我们无法访问物理地址。

以下代码首先查找索引,然后将 EPT 页表中的虚拟地址返回到该索引。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* Get the PML2 entry for this physical address. */
PEPT_PML2_ENTRY EptGetPml2Entry(PVMM_EPT_PAGE_TABLE EptPageTable, SIZE_T PhysicalAddress)
{
    SIZE_T Directory, DirectoryPointer, PML4Entry;
    PEPT_PML2_ENTRY PML2;
 
    Directory = ADDRMASK_EPT_PML2_INDEX(PhysicalAddress);
    DirectoryPointer = ADDRMASK_EPT_PML3_INDEX(PhysicalAddress);
    PML4Entry = ADDRMASK_EPT_PML4_INDEX(PhysicalAddress);
 
    // Addresses above 512GB are invalid because it is > physical address bus width
    if (PML4Entry > 0)
    {
        return NULL;
    }
 
    PML2 = &EptPageTable->PML2[DirectoryPointer][Directory];
    return PML2;
}

7.5.6.查找PML1条目

对于 PML1,我们有相同的方法。 首先,我们发现PML2与上面相同。 然后我们检查 PML2 是否分裂。 这是因为如果之前不进行拆分,那么我们就没有 PML1,它是 3 级分页。

找到索引,然后将虚拟地址返回到该页条目。 最后,由于我们连续保存物理地址,因此我们可以使用ADDRMASK_EPT_PML1_INDEX (如上所述)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/* Get the PML1 entry for this physical address if the page is split. Return NULL if the address is invalid or the page wasn't already split. */
PEPT_PML1_ENTRY EptGetPml1Entry(PVMM_EPT_PAGE_TABLE EptPageTable, SIZE_T PhysicalAddress)
{
    SIZE_T Directory, DirectoryPointer, PML4Entry;
    PEPT_PML2_ENTRY PML2;
    PEPT_PML1_ENTRY PML1;
    PEPT_PML2_POINTER PML2Pointer;
 
    Directory = ADDRMASK_EPT_PML2_INDEX(PhysicalAddress);
    DirectoryPointer = ADDRMASK_EPT_PML3_INDEX(PhysicalAddress);
    PML4Entry = ADDRMASK_EPT_PML4_INDEX(PhysicalAddress);
 
    // Addresses above 512GB are invalid because it is > physical address bus width
    if (PML4Entry > 0)
    {
        return NULL;
    }
 
    PML2 = &EptPageTable->PML2[DirectoryPointer][Directory];
 
    // Check to ensure the page is split
    if (PML2->LargePage)
    {
        return NULL;
    }
 
    // Conversion to get the right PageFrameNumber.
    // These pointers occupy the same place in the table and are directly convertable.
    PML2Pointer = (PEPT_PML2_POINTER)PML2;
 
    // If it is, translate to the PML1 pointer
    PML1 = (PEPT_PML1_ENTRY)PhysicalAddressToVirtualAddress((PVOID)(PML2Pointer->PageFrameNumber * PAGE_SIZE));
 
    if (!PML1)
    {
        return NULL;
    }
 
    // Index into PML1 for that address
    PML1 = &PML1[ADDRMASK_EPT_PML1_INDEX(PhysicalAddress)];
 
    return PML1;
}

7.5.7.将2MB页面拆分为4kb页面

如您所知,在我们所有的虚拟机管理程序部分中,我们使用了 3 级分页(PML4、PML3、PML2),粒度为 2 MB。 拥有 2 MB 粒度的页面不足以满足监控目的,因为我们可能会收到许多由不相关区域引起的不相关违规行为。

为了解决此类问题,我们使用 PML1 和 4 KB 粒度。

这就是我们可能需要额外缓冲区的地方,并且由于我们处于 vmx root 模式,因此我们将使用之前分配的缓冲区。

首先,我们从 PML2 获取实际条目,并检查它是否已经是一个 4 KB 定义的表,如果它之前已拆分,则无需执行任何操作,我们可以使用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Find the PML2 entry that's currently used
TargetEntry = EptGetPml2Entry(EptPageTable, PhysicalAddress);
if (!TargetEntry)
{
    LogError("An invalid physical address passed");
    return FALSE;
}
 
// If this large page is not marked a large page, that means it's a pointer already.
// That page is therefore already split.
if (!TargetEntry->LargePage)
{
    return TRUE;
}

如果没有,我们将 PreAlulatedMemoryDetailsPreAlulatedBuffer 设置为 null,以便下次预分配器为此目的分配一个新的缓冲区。

1
2
// Free previous buffer
GuestState[CoreIndex].PreAllocatedMemoryDetails.PreAllocatedBuffer = NULL;

然后,我们应该使用 RWX 模板填充 PML1,然后将 2 MB 页面拆分为 4 KB 块(计算 4 KB 物理地址并填充 PageFrameNumber

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Point back to the entry in the dynamic split for easy reference for which entry that dynamic split is for.
NewSplit->Entry = TargetEntry;
 
// Make a template for RWX
EntryTemplate.Flags = 0;
EntryTemplate.ReadAccess = 1;
EntryTemplate.WriteAccess = 1;
EntryTemplate.ExecuteAccess = 1;
 
// Copy the template into all the PML1 entries
__stosq((SIZE_T*)&NewSplit->PML1[0], EntryTemplate.Flags, VMM_EPT_PML1E_COUNT);
 
 
// Set the page frame numbers for identity mapping.
for (EntryIndex = 0; EntryIndex < VMM_EPT_PML1E_COUNT; EntryIndex++)
{
    // Convert the 2MB page frame number to the 4096 page entry number plus the offset into the frame.
    NewSplit->PML1[EntryIndex].PageFrameNumber = ((TargetEntry->PageFrameNumber * SIZE_2_MB) / PAGE_SIZE) + EntryIndex;
}

最后,创建一个新的 PML2 条目( LargePage = 0 )并将其替换为之前的 PML2 条目。

还要跟踪已分配的内存,以便在我们想要运行 vmxoff 时取消分配它。

1
2
3
4
5
6
7
8
9
10
11
12
// Allocate a new pointer which will replace the 2MB entry with a pointer to 512 4096 byte entries.
NewPointer.Flags = 0;
NewPointer.WriteAccess = 1;
NewPointer.ReadAccess = 1;
NewPointer.ExecuteAccess = 1;
NewPointer.PageFrameNumber = (SIZE_T)VirtualAddressToPhysicalAddress(&NewSplit->PML1[0]) / PAGE_SIZE;
 
// Add our allocation to the linked list of dynamic splits for later deallocation
InsertHeadList(&EptPageTable->DynamicSplitList, &NewSplit->DynamicSplitList);
 
// Now, replace the entry in the page table with our new split pointer.
RtlCopyMemory(TargetEntry, &NewPointer, sizeof(NewPointer));

以下函数表示将 2 MB 页面拆分为 4 KB 页面的完整代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
/* Split 2MB (LargePage) into 4kb pages */
BOOLEAN EptSplitLargePage(PVMM_EPT_PAGE_TABLE EptPageTable, PVOID PreAllocatedBuffer, SIZE_T PhysicalAddress, ULONG CoreIndex)
{
 
    PVMM_EPT_DYNAMIC_SPLIT NewSplit;
    EPT_PML1_ENTRY EntryTemplate;
    SIZE_T EntryIndex;
    PEPT_PML2_ENTRY TargetEntry;
    EPT_PML2_POINTER NewPointer;
 
    // Find the PML2 entry that's currently used
    TargetEntry = EptGetPml2Entry(EptPageTable, PhysicalAddress);
    if (!TargetEntry)
    {
        LogError("An invalid physical address passed");
        return FALSE;
    }
 
    // If this large page is not marked a large page, that means it's a pointer already.
    // That page is therefore already split.
    if (!TargetEntry->LargePage)
    {
        return TRUE;
    }
 
    // Free previous buffer
    GuestState[CoreIndex].PreAllocatedMemoryDetails.PreAllocatedBuffer = NULL;
 
    // Allocate the PML1 entries
    NewSplit = (PVMM_EPT_DYNAMIC_SPLIT)PreAllocatedBuffer;
    if (!NewSplit)
    {
        LogError("Failed to allocate dynamic split memory");
        return FALSE;
    }
    RtlZeroMemory(NewSplit, sizeof(VMM_EPT_DYNAMIC_SPLIT));
 
 
    // Point back to the entry in the dynamic split for easy reference for which entry that dynamic split is for.
    NewSplit->Entry = TargetEntry;
 
    // Make a template for RWX
    EntryTemplate.Flags = 0;
    EntryTemplate.ReadAccess = 1;
    EntryTemplate.WriteAccess = 1;
    EntryTemplate.ExecuteAccess = 1;
 
    // Copy the template into all the PML1 entries
    __stosq((SIZE_T*)&NewSplit->PML1[0], EntryTemplate.Flags, VMM_EPT_PML1E_COUNT);
 
 
    // Set the page frame numbers for identity mapping.
    for (EntryIndex = 0; EntryIndex < VMM_EPT_PML1E_COUNT; EntryIndex++)
    {
        // Convert the 2MB page frame number to the 4096 page entry number plus the offset into the frame.
        NewSplit->PML1[EntryIndex].PageFrameNumber = ((TargetEntry->PageFrameNumber * SIZE_2_MB) / PAGE_SIZE) + EntryIndex;
    }
 
    // Allocate a new pointer which will replace the 2MB entry with a pointer to 512 4096 byte entries.
    NewPointer.Flags = 0;
    NewPointer.WriteAccess = 1;
    NewPointer.ReadAccess = 1;
    NewPointer.ExecuteAccess = 1;
    NewPointer.PageFrameNumber = (SIZE_T)VirtualAddressToPhysicalAddress(&NewSplit->PML1[0]) / PAGE_SIZE;
 
    // Add our allocation to the linked list of dynamic splits for later deallocation
    InsertHeadList(&EptPageTable->DynamicSplitList, &NewSplit->DynamicSplitList);
 
    // Now, replace the entry in the page table with our new split pointer.
    RtlCopyMemory(TargetEntry, &NewPointer, sizeof(NewPointer));
 
    return TRUE;
}

7.5.8.应用钩子

EptVmxRootModePageHook 是EPT的重要部分之一。

首先,我们检查以禁止在预分配的缓冲区不可用时从 VMX root-mode调用此函数。

1
2
3
4
5
6
7
// Check whether we are in VMX Root Mode or Not
LogicalCoreIndex = KeGetCurrentProcessorIndex();
 
if (GuestState[LogicalCoreIndex].IsOnVmxRootMode && GuestState[LogicalCoreIndex].PreAllocatedMemoryDetails.PreAllocatedBuffer == NULL && HasLaunched)
{
    return FALSE;
}

然后我们像页表中的地址对齐一样对齐地址。

1
2
3
VirtualTarget = PAGE_ALIGN(TargetFunc);
 
PhysicalAddress = (SIZE_T)VirtualAddressToPhysicalAddress(VirtualTarget);

我们将检查粒度,如果是 LargePage, 则将其拆分(更多详细信息请参阅下一节 - 将 2 MB 页面拆分为 4 KB 页面)。

1
2
3
4
5
6
7
8
9
// Set target buffer
TargetBuffer = GuestState[LogicalCoreIndex].PreAllocatedMemoryDetails.PreAllocatedBuffer;
 
 
if (!EptSplitLargePage(EptState->EptPageTable, TargetBuffer, PhysicalAddress, LogicalCoreIndex))
{
    LogError("Could not split page for the address : 0x%llx", PhysicalAddress);
    return FALSE;
}

然后找到所请求页面的 PML1 条目,由于它已经分为 4 KB 页面,因此 PML1 可用。

1
2
3
4
5
6
7
8
9
10
11
12
// Pointer to the page entry in the page table.
TargetPage = EptGetPml1Entry(EptState->EptPageTable, PhysicalAddress);
 
// Ensure the target is valid.
if (!TargetPage)
{
    LogError("Failed to get PML1 entry of the target address");
    return FALSE;
}
 
// Save the original permissions of the page
OriginalEntry = *TargetPage;

现在,我们更改与 PML1 条目相关的属性,这是该函数中最有趣的部分,例如,您可以禁用对 4 KB 页面的写访问,在我们的示例中,我禁用了从目标页面执行指令(获取) 。

1
2
3
4
5
6
7
8
9
10
11
12
/*
 * Lastly, mark the entry in the table as no execute. This will cause the next time that an instruction is
 * fetched from this page to cause an EPT violation exit. This will allow us to swap in the fake page with our
 * hook.
 */
OriginalEntry.ReadAccess = 1;
OriginalEntry.WriteAccess = 1;
OriginalEntry.ExecuteAccess = 0;
 
 
// Apply the hook to EPT
TargetPage->Flags = OriginalEntry.Flags;

如果我们处于 vmx root 模式,则 TLB 缓存必须失效。

1
2
3
4
5
6
7
8
9
// Invalidate the entry in the TLB caches so it will not conflict with the actual paging structure.
if (HasLaunched)
{
    // Uncomment in order to invalidate all the contexts
    // LogInfo("INVEPT Results : 0x%x\n", InveptAllContexts());
    Descriptor.EptPointer = EptState->EptPointer.Flags;
    Descriptor.Reserved = 0;
    AsmInvept(1, &Descriptor);
}

完毕 ! 钩子已应用。

7.5.9.处理挂钩页面的VM-EXITS

首先,我们尝试对齐guest物理地址(请记住,在 Ept 违规中,我们从 Vmcs 读取了 GUEST_PHYSICAL_ADDRESS 。 这是因为我们只能从 EPT 表中找到对齐的物理地址(我们不想迭代它们!)。

1
PhysicalAddress = PAGE_ALIGN(GuestPhysicalAddr);

现在,正如我上面所描述的,我们找到了与该物理地址相关的 PML1 条目。 我们不是在寻找 PML2,因为如果我们到达这里,那么我们可能会将 2 MB 页面拆分为 4 KB 页面,并且我们拥有 PML1 而不是 PML2。

1
2
3
4
5
6
7
8
TargetPage = EptGetPml1Entry(EptState->EptPageTable, PhysicalAddress);
 
// Ensure the target is valid.
if (!TargetPage)
{
    LogError("Failed to get PML1 entry for target address");
    return FALSE;
}

最后,我们检查违规是否是由 执行访问 引起的(基于 退出资格 ),并且违规页面 的执行访问 权限为0,如果是这样,则只需使 PML1 中的页面条目可执行并使缓存无效,以便此修改生效。

不要忘记告诉我们的 vm-exit 处理程序避免跳过当前指令(避免将指令长度添加到 Guest RIP)并在指令未执行时再次执行它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// If the violation was due to trying to execute a non-executable page, that means that the currently
// swapped in page is our original RW page. We need to swap in the hooked executable page (fake page)
if (!ViolationQualification.EptExecutable && ViolationQualification.ExecuteAccess)
{
 
    TargetPage->ExecuteAccess = 1;
 
    // InveptAllContexts();
    INVEPT_DESCRIPTOR Descriptor;
 
    Descriptor.EptPointer = EptState->EptPointer.Flags;
    Descriptor.Reserved = 0;
    AsmInvept(1, &Descriptor);
 
    // Redo the instruction
    GuestState[KeGetCurrentProcessorNumber()].IncrementRip = FALSE;
 
    LogInfo("Set the Execute Access of a page (PFN = 0x%llx) to 1", TargetPage->PageFrameNumber);
 
    return TRUE;
}

总而言之,我们有以下处理程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/* Check if this exit is due to a violation caused by a currently hooked page. Returns FALSE
 * if the violation was not due to a page hook.
 *
 * If the memory access attempt was RW and the page was marked executable, the page is swapped with
 * the original page.
 *
 * If the memory access attempt was execute and the page was marked not executable, the page is swapped with
 * the hooked page.
 */
BOOLEAN EptHandlePageHookExit(VMX_EXIT_QUALIFICATION_EPT_VIOLATION ViolationQualification, UINT64 GuestPhysicalAddr)
{
    SIZE_T PhysicalAddress;
    PVOID VirtualTarget;
 
    PEPT_PML1_ENTRY TargetPage;
 
 
    /* Translate the page from a physical address to virtual so we can read its memory.
       This function will return NULL if the physical address was not already mapped in
       virtual memory.
    */
    PhysicalAddress = PAGE_ALIGN(GuestPhysicalAddr);
 
    if (!PhysicalAddress)
    {
        LogError("Target address could not be mapped to physical memory");
        return FALSE;
    }
 
    TargetPage = EptGetPml1Entry(EptState->EptPageTable, PhysicalAddress);
 
    // Ensure the target is valid.
    if (!TargetPage)
    {
        LogError("Failed to get PML1 entry for target address");
        return FALSE;
    }
 
    // If the violation was due to trying to execute a non-executable page, that means that the currently
    // swapped in page is our original RW page. We need to swap in the hooked executable page (fake page)
    if (!ViolationQualification.EptExecutable && ViolationQualification.ExecuteAccess)
    {
 
        TargetPage->ExecuteAccess = 1;
 
        // InveptAllContexts();
        INVEPT_DESCRIPTOR Descriptor;
 
        Descriptor.EptPointer = EptState->EptPointer.Flags;
        Descriptor.Reserved = 0;
        AsmInvept(1, &Descriptor);
 
        // Redo the instruction
        GuestState[KeGetCurrentProcessorNumber()].IncrementRip = FALSE;
 
        LogInfo("Set the Execute Access of a page (PFN = 0x%llx) to 1", TargetPage->PageFrameNumber);
 
        return TRUE;
    }
 
    LogError("Invalid page swapping logic in hooked page");
 
    return FALSE;
}

7.6.Invalidating Translations Derived from EPT (INVEPT)

现在我们实施了EPT,这里还有另一个问题。 使缓存无效是软件的责任。 例如,我们更改了 特定页面的执行访问 属性,现在我们必须告诉CPU我们更改了某些内容,并且它必须使其缓存无效,或者以另一种方式,我们对 执行访问 特殊页面的 发生EPT违规,并且现在我们不再需要此页面的这些 EPT 违规。 因此,我们将该 执行访问权限 页面的 设置为1; 因此,我们必须告诉处理器我们更改了页表中的某些内容。 你困惑吗? 让我再解释一次。

假设我们访问物理地址 0x1000,它将被转换为主机物理地址 0x1000(基于 1:1 映射)。 下次,如果我们访问 0x1000,CPU 不会将请求发送到内存总线,而是使用缓存内存。 速度更快了。 现在假设我们更改了页面的 EPT 物理地址 以指向不同的 EPT PD 或更改其中一个 EPT 表的属性( 执行 ),现在我们必须告诉处理器您的缓存是无效的,这正是 INVEPT 执行的操作。

这里有一个问题; 我们必须分别告诉每个逻辑核心,它需要使其 EPT 缓存失效。 换句话说,每个核心都必须在其 VMX root-mode上执行 INVEPT。 我们将在本部分稍后解决这些问题。

虚拟机管理程序的 TLB 失效有两种类型。

  • VMX 特定的 TLB 管理指令:
    • INVEPT - 使处理器中缓存的扩展页表 (EPT) 映射无效,以将虚拟机中的地址转换与内存驻留的 EPT 页面同步。
    • INVVPID - 根据虚拟处理器 ID (VPID) 使缓存的地址转换映射无效。

讨论 INVVPID 。 我们将在第 8 部分详细

因此,如果您在更改 EPT 结构后不执行 INVEPT,您将面临 CPU 重用旧翻译的风险。

对 EPT 结构的任何更改都需要 INVEPT,但切换 EPT(或 VMCS)不需要 INVEPT,因为该转换将用缓存中更改的 EPTP 进行“标记”。

现在我们这里有两个术语, 单上下文全上下文

1
2
3
4
5
typedef enum _INVEPT_TYPE
{
    SINGLE_CONTEXT = 0x00000001,
    ALL_CONTEXTS = 0x00000002
};

我们有一个汇编函数,通常执行 INVEPT。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
; Error codes :
    VMX_ERROR_CODE_SUCCESS              = 0
    VMX_ERROR_CODE_FAILED_WITH_STATUS   = 1
    VMX_ERROR_CODE_FAILED               = 2
 
AsmInvept 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
 
AsmInvept ENDP

从上面的代码来看,RCX描述了Type(可以是 全上下文单上下文 之一),RDX是INVEPT的描述符。

以下结构是 Intel SDM 中描述的 INVEPT 描述符。

1
2
3
4
5
typedef struct _INVEPT_DESC
{
    EPTP EptPointer;
    UINT64  Reserveds;
}INVEPT_DESC, * PINVEPT_DESC;

图片描述
我们将在另一个名为 Invept 的函数中使用我们的汇编函数。

1
2
3
4
5
6
7
8
9
10
11
/* Invoke the Invept instruction */
unsigned char Invept(UINT32 Type, INVEPT_DESC* Descriptor)
{
    if (!Descriptor)
    {
        INVEPT_DESC ZeroDescriptor = { 0 };
        Descriptor = &ZeroDescriptor;
    }
 
    return AsmInvept(Type, Descriptor);
}

是时候看看什么是所谓的“ 全上下文 ”和“ 单上下文 ”了。

7.6.1.使所有上下文无效

All-Context 意味着您使所有 EPT-derived translations无效。 (对于每个虚拟机)。

1
2
3
4
5
/* Invalidates all contexts in ept cache table */
unsigned char InveptAllContexts()
{
    return Invept(ALL_CONTEXTS, NULL);
}

注意:对于每个虚拟机,我指的是特定逻辑核心的每个虚拟机; 每个核心可以有多个 VMCS 和 EPT 表并在它们之间进行切换。 它与其他内核上的 EPT 表无关。

7.6.2.使单一上下文失效

单上下文 意味着您基于单个 EPTP(简而言之:对于逻辑核心中的单个 VM)使所有 EPT-derived translations无效。

1
2
3
4
5
6
/* Invalidates a single context in ept cache table */
unsigned char InveptSingleContext(UINT64 EptPointer)
{
    INVEPT_DESC Descriptor = { EptPointer, 0 };
    return Invept(SINGLE_CONTEXT, &Descriptor);
}

7.6.3.同时向所有逻辑核心广播INVEPT

假设您有两个核心和 1 个 EPTP。 在某些时候,您会更改核心一上的 EPT; 因此,此时您必须使所有核心上的 EPT 无效。 如果您还记得上一节,我们必须使用 KeIpiGenericCall 之类的方法通知所有内核使其 EPT 缓存无效,问题是你不能从 VM-exit 调用 KeIpiGenericCall ,原因很明显 - 你不应该在 Vm-exit 中调用任何 NT API。 从 Vm-exit 调用此 API 可能会导致死锁。

我们可以通过修改 APIC 并创建自定义 IPI 调用例程来解决这个问题。 我们将在以后的部分中遇到 APIC 虚拟化。 不过,目前,如果我们想更改所有内核的 EPT,那么我们可以 调用KeIpiGenericCall 从常规内核模式(而不是 VMX root-mode) ,在该回调中,我们执行 Vmcall 来告诉我们的处理器在 vmx root 模式下使其缓存失效。

这是因为如果我们不立即使 EPT 无效,那么我们可能会失去一些 EPT 违规。 这是因为每个逻辑核心都有不同的内存视图。

如果您还记得上面的部分( EptPageHook ),我们会检查核心是否已处于 vmx 操作( vmlaunch 执行 )。 如果它启动了,那么我们使用 Vmcall 告诉处理器有关从 VMX root-mode修改 EPT 表的信息。 从 Vmcall 返回后,我们立即调用 HvNotifyAllToInvalidateEpt 来告知所有内核其 EPT 缓存中的新失效(请记住,我们不再处于 vmx root 模式,我们处于 vmx 非 root 模式,因此我们可以使用 NT API因为它是常规核函数)。

1
2
3
4
5
6
7
8
9
10
11
12
if (HasLaunched)
{
    if (AsmVmxVmcall(VMCALL_EXEC_HOOK_PAGE, TargetFunc, NULL, NULL, NULL) == STATUS_SUCCESS)
    {
        LogInfo("Hook applied from VMX Root Mode");
 
        // Now we have to notify all the core to invalidate their EPT
        HvNotifyAllToInvalidateEpt();
 
        return TRUE;
    }
}

HvNotifyAllToInvalidateEpt 另一方面, 使用KeIpiGenericCall, 该函数在所有逻辑核心上广播 HvInvalidateEptByVmcall 并将我们当前的 EPTP 传递给该函数。

1
2
3
4
5
6
/* Notify all core to invalidate their EPT */
VOID HvNotifyAllToInvalidateEpt()
{
    // Let's notify them all
    KeIpiGenericCall(HvInvalidateEptByVmcall, EptState->EptPointer.Flags);
}

HvInvalidateEptByVmcall 决定调用者是否需要 全上下文 失效或 单上下文 失效,并基于此,它调用具有足够 Vmcall 编号的 Vmcall。 请注意,我们的虚拟机管理程序没有多个 EPTP,因此它始终是 单上下文 Vmcall。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* Invalidate EPT using Vmcall (should be called from Vmx non root mode) */
VOID HvInvalidateEptByVmcall(UINT64 Context)
{
    if (Context == NULL)
    {
        // We have to invalidate all contexts
        AsmVmxVmcall(VMCALL_INVEPT_ALL_CONTEXT, NULL, NULL, NULL);
    }
    else
    {
        // We have to invalidate all contexts
        AsmVmxVmcall(VMCALL_INVEPT_SINGLE_CONTEXT, Context, NULL, NULL);
    }
}

最后,Vmcall 处理程序根据 vmx root-mode下的 Vmcall 编号调用 InveptAllContexts 或 HvInvalidateEptByVmcall

1
2
3
4
5
6
7
8
9
10
11
12
case VMCALL_INVEPT_SINGLE_CONTEXT:
{
    c(OptionalParam1);
    VmcallStatus = STATUS_SUCCESS;
    break;
}
case VMCALL_INVEPT_ALL_CONTEXT:
{
    InveptAllContexts();
    VmcallStatus = STATUS_SUCCESS;
    break;
}

最后一件事是您无法在 vmx 非 root 模式下执行 INVEPT ,因为它会导致 VM-exit并显示 EXIT_REASON_INVEPT (0x32),并且没有任何效果。

这就是 INVEPT 的全部内容。

7.7.修复以前的设计问题

该主题的其余部分并不是什么新鲜事。 我们希望改进我们的虚拟机管理程序并修复之前部分中的一些问题,并支持一些新功能并克服之前部分中存在的一些死锁和同步问题。

7.7.1.支持超过64个逻辑核心

之前版本的Hypervisor From Scratch存在不支持超过32个核心(32*2逻辑核心)的问题。 这是因为我们使用了 KeSetSystemAffinityThread, 它给出了一个 KAFFINITY 作为其参数,并且它是一个 64 位长的变量掩码。

当我们向所有内核广播 Vmptrld、Vmclear、VMCS Setup (Vmwrite)、Vmlaunch 和 Vmxoff 时,我们使用了 KeSetSystemAffinityThread 。

在所有逻辑核心上运行的最佳方法是让 Windows (API) 在每个核心上同时执行它们。 这涉及提高每个内核上的 IRQL。

我们在这里有不同的选择; 首先,我们可以使用 KeGenericCallDpc 。 这是一个未记录的函数,用于在所有 CPU 上调度特定于 CPU 的 DPC。

KeGenericCallDpc 的定义如下。

1
2
3
4
KeGenericCallDpc(
    _In_ PKDEFERRED_ROUTINE Routine,
    _In_opt_ PVOID Context
);

第一个参数是目标函数的地址 我们要在每个核心上执行该函数, 上下文 是该函数的可选参数。

目标函数 中,我们调用 KeSignalCallDpcSynchronizeKeSignalCallDpcDone 来避免同步问题,以便所有内核同时完成。

KeSignalCallDpcSynchronize 等待所有 DPC 在该点同步(我们称之为 KeSignalCallDpcSynchronize )。

1
2
3
4
LOGICAL
KeSignalCallDpcSynchronize(
    _In_ PVOID SystemArgument2
);

最后, KeSignalCallDpcDone 将 DPC 标记为完成。

1
2
3
4
VOID
KeSignalCallDpcDone(
    _In_ PVOID SystemArgument1
);

上述两个函数必须作为 目标函数 中的最后一步(当一切完成时)执行。

另一种选择是使用 KeIpiGenericCall ,此例程会导致指定的函数同时在所有处理器上运行,并且已记录在案。 我在 Hypervisor From Scratch 中使用了第一种方法,这些更新适用于初始化阶段和 Vmxoff 阶段。

7.7.2.退出VMX时出现同步问题

由于我们现在使用 DPC 支持超过 64 个逻辑核心,并且大多数功能是同时执行的,因此我们之前设计的例程存在一些问题。 例如,在前面的部分中,我使用 gGuestRSPgGuestRIP 返回到之前的状态。 在所有核心上使用一个全局变量会导致错误,因为一个核心可能会保存其 RIP 和 RSP(核心 1),然后其他核心(核心 2)在这些变量中保留相同的数据,当第一个核心(核心 1)尝试恢复状态时,这是第二个核心(核心 2)的状态,您将看到 BSOD :D 。

为了解决这个问题,我们必须存储一个每核结构来保存Guest RIP和Guest RSP。 以下结构用于此目的。

1
2
3
4
5
6
7
typedef struct _VMX_VMXOFF_STATE
{
    BOOLEAN IsVmxoffExecuted;                   // Shows whether the VMXOFF executed or not
    UINT64  GuestRip;                           // Rip address of guest to return
    UINT64  GuestRsp;                           // Rsp address of guest to return
 
} VMX_VMXOFF_STATE, * PVMX_VMXOFF_STATE;

我们将上述结构添加到 VIRTUAL_MACHINE_STATE 中,因为它是每个核心的结构。

1
2
3
4
5
6
typedef struct _VIRTUAL_MACHINE_STATE
{
...
    VMX_VMXOFF_STATE VmxoffState;                                   // Shows the vmxoff state of the guest
...
} VIRTUAL_MACHINE_STATE, * PVIRTUAL_MACHINE_STATE;

我们需要将 Vmxoff 广播到所有逻辑核心。 这是通过使用 HvTerminateVmx 来完成的; 该函数被调用一次,并向所有逻辑核心广播 HvDpcBroadcastTerminateGuest ,并释放(释放)所有 EPT 相关表和预分配缓冲区。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* Terminate Vmx on all logical cores. */
VOID HvTerminateVmx()
{
    // Broadcast to terminate Vmx
    KeGenericCallDpc(HvDpcBroadcastTerminateGuest, 0x0);
 
    /* De-allocatee global variables */
 
    // Free each split
    FOR_EACH_LIST_ENTRY(EptState->EptPageTable, DynamicSplitList, VMM_EPT_DYNAMIC_SPLIT, Split)
        ExFreePoolWithTag(Split, POOLTAG);
    FOR_EACH_LIST_ENTRY_END();
 
    // Free Identity Page Table
    MmFreeContiguousMemory(EptState->EptPageTable);
 
    // Free GuestState
    ExFreePoolWithTag(GuestState, POOLTAG);
 
    // Free EptState
    ExFreePoolWithTag(EptState, POOLTAG);
 
}

HvDpcBroadcastTerminateGuest 负责同步 DPC 并调用 VMX 函数调用 VmxTerminate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* The broadcast function which terminate the guest. */
VOID HvDpcBroadcastTerminateGuest(struct _KDPC* Dpc, PVOID DeferredContext, PVOID SystemArgument1, PVOID SystemArgument2)
{
    // Terminate Vmx using Vmcall
    if (!VmxTerminate())
    {
        LogError("There were an error terminating Vmx");
    }
 
    // Wait for all DPCs to synchronize at this point
    KeSignalCallDpcSynchronize(SystemArgument2);
 
    // Mark the DPC as being complete
    KeSignalCallDpcDone(SystemArgument1);
}

VmxTerminate 取消分配每个核心分配的区域,例如 Vmxon 区域、Vmcs 区域、Vmm 堆栈和 Msr 位图。 当我们实现 Vmcall 机制时,我们可以使用 Vmcall 从 vmx root 模式请求 vmxoff 而不是我们在之前版本中使用 CPUID Handler 所做的事情)。 因此它在每个核心上执行带有VMCALL_VMXOFF的AsmVmxVmcall,并且每个核心将单独运行vmxoff 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/* Broadcast to terminate VMX on all logical cores */
BOOLEAN VmxTerminate()
{
    int CurrentCoreIndex;
    NTSTATUS Status;
 
    // Get the current core index
    CurrentCoreIndex = KeGetCurrentProcessorNumber();
 
    LogInfo("\tTerminating VMX on logical core %d", CurrentCoreIndex);
 
    // Execute Vmcall to to turn off vmx from Vmx root mode
    Status = AsmVmxVmcall(VMCALL_VMXOFF, NULL, NULL, NULL);
 
    // Free the destination memory
    MmFreeContiguousMemory(GuestState[CurrentCoreIndex].VmxonRegionVirtualAddress);
    MmFreeContiguousMemory(GuestState[CurrentCoreIndex].VmcsRegionVirtualAddress);
    ExFreePoolWithTag(GuestState[CurrentCoreIndex].VmmStack, POOLTAG);
    ExFreePoolWithTag(GuestState[CurrentCoreIndex].MsrBitmapVirtualAddress, POOLTAG);
 
    if (Status == STATUS_SUCCESS)
    {
        return TRUE;
    }
 
    return FALSE;
}

我们的 Vmcall 处理程序调用 VmxVmxoff , 并且由于该函数是在 vmx root 模式下执行的,因此允许运行 VMXOFF 。 此函数还将 GuestRipGuestRsp 保存到每核 VMX_VMXOFF_STATE 结构中。 这就是我们解决问题的地方,因为我们不再使用共享的全局变量。 它还设置 IsVmxoffExecuted, 指示逻辑核心是否正在进行 VMX 操作,或者通过执行 VMXOFF 离开 VMX 操作

VmxVmxoff 实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/* Prepare and execute Vmxoff instruction */
VOID VmxVmxoff()
{
    int CurrentProcessorIndex;
    UINT64 GuestRSP;    // Save a pointer to guest rsp for times that we want to return to previous guest stateS
    UINT64 GuestRIP;    // Save a pointer to guest rip for times that we want to return to previous guest state
    UINT64 GuestCr3;
    UINT64 ExitInstructionLength;
 
 
    // Initialize the variables
    ExitInstructionLength = 0;
    GuestRIP = 0;
    GuestRSP = 0;
 
    CurrentProcessorIndex = KeGetCurrentProcessorNumber();
 
    /*
    According to SimpleVisor :
        Our callback routine may have interrupted an arbitrary user process,
        and therefore not a thread running with a system-wide page directory.
        Therefore if we return back to the original caller after turning off
        VMX, it will keep our current "host" CR3 value which we set on entry
        to the PML4 of the SYSTEM process. We want to return back with the
        correct value of the "guest" CR3, so that the currently executing
        process continues to run with its expected address space mappings.
    */
 
    __vmx_vmread(GUEST_CR3, &GuestCr3);
    __writecr3(GuestCr3);
 
    // Read guest rsp and rip
    __vmx_vmread(GUEST_RIP, &GuestRIP);
    __vmx_vmread(GUEST_RSP, &GuestRSP);
 
    // Read instruction length
    __vmx_vmread(VM_EXIT_INSTRUCTION_LEN, &ExitInstructionLength);
    GuestRIP += ExitInstructionLength;
 
    // Set the previous registe states
    GuestState[CurrentProcessorIndex].VmxoffState.GuestRip = GuestRIP;
    GuestState[CurrentProcessorIndex].VmxoffState.GuestRsp = GuestRSP;
 
    // Notify the Vmexit handler that VMX already turned off
    GuestState[CurrentProcessorIndex].VmxoffState.IsVmxoffExecuted = TRUE;
 
    // Execute Vmxoff
    __vmx_off();
 
}

当我们返回 vm-exit 处理程序时,我们检查是否离开了 VMX 操作。

1
2
3
4
if (GuestState[CurrentProcessorIndex].VmxoffState.IsVmxoffExecuted)
{
    return TRUE;
}

我们还定义了另外两个函数“ HvReturnStackPointerForVmxoff ”和“ HvReturnInstructionPointerForVmxoff ”,它们查找逻辑核心索引并返回相应的堆栈指针和RIP以返回。

HvReturnStackPointerForVmxoff 是:

1
2
3
4
5
/* Returns the stack pointer, to change in the case of Vmxoff */
UINT64 HvReturnStackPointerForVmxoff()
{
    return GuestState[KeGetCurrentProcessorNumber()].VmxoffState.GuestRsp;
}

HvReturnInstructionPointerForVmxoff

1
2
3
4
5
/* Returns the instruction pointer, to change in the case of Vmxoff */
UINT64 HvReturnInstructionPointerForVmxoff()
{
    return GuestState[KeGetCurrentProcessorNumber()].VmxoffState.GuestRip;
}

最终,当我们检测到我们离开了 vmx 操作时, 而不是执行VMRESUME 我们将运行 AsmVmxoffHandler ,该函数调用 HvReturnStackPointerForVmxoff 和 HvReturnInstructionPointerForVmxoff 并将 RSP 和 RIP 的值放在通用寄存器之后,这样当我们恢复通用寄存器时,我们可以从堆栈中弹出 RSP 并返回到先前的地址(ret)并继续正常执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
AsmVmxoffHandler PROC
     
    sub rsp, 020h       ; shadow space
    call HvReturnStackPointerForVmxoff
    add rsp, 020h       ; remove for shadow space
 
    mov [rsp+088h], rax  ; now, rax contains rsp
 
    sub rsp, 020h       ; shadow space
    call HvReturnInstructionPointerForVmxoff
    add rsp, 020h       ; remove for shadow space
 
    mov rdx, rsp        ; save current rsp
 
    mov rbx, [rsp+088h] ; read rsp again
 
    mov rsp, rbx
 
    push rax            ; push the return address as we changed the stack, we push
                        ; it to the new stack
 
    mov rsp, rdx        ; restore previous rsp
                         
    sub rbx,08h         ; we push sth, so we have to add (sub) +8 from previous stack
                        ; also rbx already contains the rsp
    mov [rsp+088h], rbx ; move the new pointer to the current stack
 
    RestoreState:
 
    pop rax
    pop rcx
    pop rdx
    pop rbx
    pop rbp              ; rsp
    pop rbp
    pop rsi
    pop rdi
    pop r8
    pop r9
    pop r10
    pop r11
    pop r12
    pop r13
    pop r14
    pop r15
 
    popfq
 
    pop     rsp     ; restore rsp
    ret             ; jump back to where we called Vmcall
 
AsmVmxoffHandler ENDP

正如您所看到的,我们不再存在在所有核心之间使用全局变量的问题。

7.7.3.与Meltdown缓解相关的问题

如您所知, EXIT_REASON_CR_ACCESS 是可能导致 VM-exit的原因之一(特别是如果您受 VMCS 中 CR 设置的影响)。 虚拟机管理程序用于在每次 VM 退出时保存所有通用寄存器,然后在下一个 VMRESUME 时恢复它。

在我们的驱动程序的早期版本中,我们忽略了 RSP 并保存了一些垃圾来代替它,这是因为 guest 的 RSP 已经保存在 VMCS 中的GUEST_RSP 中。VMRESUME之后,它会自动加载,你知道,我们当前的RSP无效(它是主机RSP)。

在缓解熔毁后,Windows 使用 MOV CR3、RSP ,并且当我们保存垃圾而不是 RSP 时,然后将 CR3 更改为无效值,并且它会以 TRIPLE FAULT VM-Exit 静默崩溃。 它不会给你确切的错误。
图片描述
为了解决此问题,我们将以下代码添加到 HvHandleControlRegisterAccess, 因此每次发生 vm-exit 时,我们都会将 RSP 更改为正确的值。

1
2
3
4
5
6
/* Because its RSP and as we didn't save RSP correctly (because of pushes) so we have make it points to the GUEST_RSP */
if (CrExitQualification->Fields.Register == 4)
{
    __vmx_vmread(GUEST_RSP, &GuestRsp);
    *RegPtr = GuestRsp;
}

之前Alex提到过这一点,想要了解更多信息,你可以阅读 这篇 文章。

7.8.调试虚拟机管理程序的一些技巧

始终尝试在单核系统中测试您的虚拟机管理程序。 如果它有效,您可以在多核上检查它,因此当某些东西在多核上不起作用而在单核上起作用时,就知道这是同步问题。

不要尝试在 Vmx root 模式下调用 Nt 函数。 大多数 NT 函数不适合在高 IRQL 下运行,因此如果使用它,会导致奇怪的行为并导致整个崩溃或系统停止。

有关更多信息,我强烈建议阅读 Hyperplatform 的用户文档( 4.4. 编码技巧 )。

7.9.让我门测试一下

让我们看看如何测试我们的虚拟机管理程序,

7.9.1.如何测试

为了测试我们的新虚拟机管理程序,我们有两个场景,以下代码显示了我们如何测试我们的虚拟机管理程序,测试代码可在( Ept.cHypervisorRoutines.c )中找到。

在第一个场景中,我们想要在执行vmlaunch之前测试页面钩子(EptPageHook),这意味着Ept被初始化,然后我们想要在进入VMX之前放置钩子。 (测试代码位于Ept.c上)

1
2
3
///////////////////////// Example Test /////////////////////////
 EptPageHook(ExAllocatePoolWithTag, FALSE);
///////////////////////////////////////////////////////////////

上面的函数将挂钩包含函数(在本例中为 ExAllocatePoolWithTag)的页面的执行。

第二种情况是我们想要在加载虚拟机管理程序后测试 VMCALL 和 EptPageHook ,并且我们处于 Vmx 非 root 模式(测试代码位于 HypervisorRoutines.c 上)。

1
2
3
4
5
6
7
8
9
10
11
12
//  Check if everything is ok then return true otherwise false
if (AsmVmxVmcall(VMCALL_TEST, 0x22, 0x333, 0x4444) == STATUS_SUCCESS)
{
    ///////////////// Test Hook after Vmx is launched /////////////////
    EptPageHook(ExAllocatePoolWithTag, TRUE);
    ///////////////////////////////////////////////////////////////////
    return TRUE;
}
else
{
    return FALSE;
}

正如您所看到的,它首先使用 VMCALL_TEST 测试 Vmcall,然后将挂钩放入函数(在本例中为 ExAllocatePoolWithTag)。

7.9.2.演示

首先,我们加载虚拟机管理程序驱动程序,
图片描述
对于第一种场景,可以看到我们在 vmlaunch 执行后成功通知了 ExAllocatePoolWith tag 的执行,Guest Rip 等于 ExAllocatePoolWithTag 的地址,EptHandleEptViolation 负责处理 Ept 违规。
图片描述
在第二个测试场景中,您可以看到我们的 VMCALL 已成功执行(绿线),并且我们通知了页面的执行,但是等等,我们将 执行访问 挂钩放在 ExAllocatePoolWithTag 上,但 Guest Rip 等于 ExFreePool , 为什么?

原来ExAllocatePoolWithTag和ExFreePool都在同一个页面,而且ExFreePool比ExAllocatePoolWithTag执行得早,所以我们得到这个函数的执行。
图片描述
上述测试结果显示了在 EPT 违规处理程序中检查 Guest Rip 的重要性。 我们将在下一部分中讨论它。

最后你可以看到下图,显示我们的hook是否成功应用。
图片描述

7.10.讨论

添加这部分是为了回答有关 EPT 的问题,我们将讨论不同的方法及其优缺点,因此这部分将积极更新。 感谢 Petr 回答这些问题。

1. 为什么在VMX Root模式下调用NT函数有限制?

这是因为分页和高 IRQL。 原因是 这里 对高 IRQL 的解释,并且由于我们在 VMX root-mode下处于高 IRQL,因此某些页面(分页池)可能会被换出。

虚拟机管理程序可以使用与 NT 内核完全不同的地址空间,我相信这就是像 Hyper-V/XEN 这样的常规虚拟机管理程序所做的事情。 它们不使用“ 身份EPT映射 ”,因此VMX-root 模式下的VA 0x10000并不指向与VMX non-root 模式下的0x10000相同的物理内存。

例如,让我们选择一个可以在 HIGH_IRQL ( MmGetPhysicalAddress ) 处调用的 NT 函数。 假设该函数位于虚拟地址 0x1234 上,但该虚拟地址指向 ntoskrnl 地址空间中 VMX non-root中的该函数。

真正的问题应该是:“为什么我可以在 VMX-root 模式下调用某些 NT 函数”答案是,您将 VMCS 中的HOST_CR3 设置为与 NT 主系统进程的 CR3 相同,因此 vmx root-mode 中的虚拟机管理程序共享与 VMX non-root模式相同的内存视图。

知道这一点很重要,在实践中,对于自虚拟化虚拟机管理程序(例如 hyperplatform/hvpp),您并不关心,因为正如我所说,你的 HOST_CR3 与 NT 的 CR3 相同,因此你可以触摸你想要的任何内存

如果您碰巧使用 HyperV 或 XEN,那么您就没有同样的优势了。 管理程序内存地址空间根本没有映射到虚拟化操作系统中(这正是虚拟化的重点)。

2. 为什么我们不应该在VMX Non-Root 中修改EPT?

在理想情况下,虚拟机管理程序的内存不应该从虚拟化操作系统中可见(例如,您无法从虚拟化操作系统中看到 XEN 内部结构)。

在 hyperplatform/hvpp 中,您可以看到虚拟机管理程序的内存。 为什么? 这次不是因为 HOST_CR3 而是因为身份 EPT 映射 - 您以这样的方式设置 EPT 表,虚拟化操作系统甚至可以看到虚拟机管理程序本身的内存。

我的观点是 - 在理想的世界中,您甚至不应该在 VMX 非 root 模式中看到 EPT 结构,想象一下,您可以从用户模式修改常规页表吗?

答案是视情况而定。 事实上? 没有为什么? 因为页表位于内核内存中,无法从用户模式访问。 这就是内存保护的全部意义。 您能否以可以从用户模式修改页表的方式设置页表? 是的,但这并不意味着你应该这样做。 这是一种安全问题。

还有一个更重要的原因:缓存

现在您可能已经尝试过,并且在您的情况下大部分时间都有效,但这并不意味着这是正确的方法。

3. 为每个处理器单独设置EPT表有什么好处?

当您更改 EPT 结构并且希望该更改在 CPU 之间同步时,您必须从 VMX 根模式内执行 IPI (KeIpiGenericCall) 以刷新所有 CPU 上的缓存。

在理想情况下,您可以从 VMX 根模式调用 KeIpiGenericCall。 但你不能——你很快就会陷入僵局。 您需要实现自己的 IPI 机制并为 VMX-root 模式正确设置 APIC。

现在这是可以做到的——但实施起来并不简单。

当每个 CPU 有多个 EPT 时,您不必执行 IPI,每个核心管理自己的 EPT。

现在它们不会始终 100% 同步,但如果每个核心的 EPT 处理程序逻辑相同并且不随时间变化,则没关系。

7.11.结论

我们到了这一部分的结尾。 我相信 EPT 是研究人员、安全程序和游戏黑客可以使用的最重要的功能,因为它提供了监视操作系统和用户模式应用程序的独特能力。 在下一部分中,我们将使用 EPT 并实现虚拟机管理程序中常用的隐藏挂钩机制。 此外,我们将通过使用 WPP 跟踪来改进我们的虚拟机管理程序,而不是使用 DbgPrint、事件注入以及从 VMX root-mode到 VMX non-root模式的对话机制,最后我们将了解如何使用虚拟处理器标识符 (VPID) )。 请随意使用下面的评论提出问题或要求澄清。

下一部分见。

7.12.参考

[1] 内存类型范围寄存器 - ( https://en.wikipedia.org/wiki/Memory_type_range_register )
[2] KVA Shadow:缓解 Windows 上的 Meltdown - ( https://msrc-blog.microsoft.com/2018/03/23/kva-shadow-mitigating-meltdown-on-windows/ )
[3] 如何利用虚拟化/Hypervisor 技术实现基于软件的 SMEP(管理程序模式执行保护) - ( http://hypervsir.blogspot.com/2014/11/how-to-implement-software-based.html )
[4] 第 3A 卷 – 第 11 章 –(11.11.3 基础和模板计算示例)- ( https://software.intel.com/en-us/articles/intel-sdm )
[5] x86 分页教程 - ( https://cirosantilli.com/x86-paging )
[6] OSDev 笔记 2:内存管理 - ( http://ethv.net/workshops/osdev/notes/notes-2 )
[7] 第 3A 卷 – 第 11 章 –(11.11 存储器类型范围寄存器 (MTRRS))- ( https://software.intel.com/en-us/articles/intel-sdm )
[8] 第 3A 卷 – 第 11 章 – (11.12 页面属性表 (PAT)) - ( https://software.intel.com/en-us/articles/intel-sdm )
[9] HyperPlatform 用户文档 - ( https://tandasat.github.io/HyperPlatform/userdocument/ )
[10] 第 3C 卷 – 第 34 章 – (34.15.2 SMM VM-exit) - ( https://software.intel.com/en-us/articles/intel-sdm )
[11] 第 3C 卷 – 第 34 章 –(34.15.6 激活双显示器处理)- ( https://software.intel.com/en-us/articles/intel-sdm )
[12] Windows 热补丁:演练 - ( https://jpassing.com/2011/05/03/windows-hotpatching-a-walkthrough/ )
[13] 第 3C 卷 – 第 28 章 – (28.2.3.1 EPT 错误配置) - ( https://software.intel.com/en-us/articles/intel-sdm )
[14] 第 3C 卷 – 第 28 章 – (28.2.3.2 EPT 违规) - ( https://software.intel.com/en-us/articles/intel-sdm )
[15] RIP ROP:Windows 20H1 中的 CET 内部 - ( http://windows-internals.com/cet-on-windows )
[16] Windows 内部页框编号 (PFN) 第 1 部分 - ( https://rayanfam.com/topics/inside-windows-page-frame-number-part1 )
[17] Windows 内部页框编号 (PFN) 第 2 部分 - ( https://rayanfam.com/topics/inside-windows-page-frame-number-part2 )
[18] 为什么我们可以从处于或高于调度级别的非分页池访问内存 - ( https://stackoverflow.com/questions/18764211/why-we-can-access-memory-from-non-paged-pool-at -或高于调度级别


[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课

最后于 6天前 被zhang_derek编辑 ,原因:
收藏
点赞5
打赏
分享
最新回复 (1)
雪    币: 19381
活跃值: (29004)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
秋狝 2024-4-1 14:52
2
1
感谢分享
游客
登录 | 注册 方可回帖
返回