First Steps in Hyper-V Research
Microsoft在Hyper-V安全方面投入了大量精力。Hyper-V及其整个虚拟化栈运行于我们许多产品的核心:云计算、Windows Defender Application Guard以及基于虚拟化安全(VBS)的技术。由于Hyper-V对我们如此关键,我们鼓励研究人员研究它并告诉我们发现的漏洞:我们甚至为发现漏洞的研究者提供25万美元的奖金。在本博客系列中,我们将探讨如何开始深入研究并寻找Hyper-V漏洞。
一种寻找虚拟化平台中的错误和漏洞的方法是静态分析。我们最近发布了存储组件的公共符号,加上之前已可用的符号,意味着虚拟化栈的大部分符号现已公开可用。这使得静态分析更加容易。
除了静态分析,审计代码时的另一个有用工具是实时调试。它帮助我们在实际运行时检查代码路径和数据。当尝试触发有趣的代码路径、在代码片段上中断或需要检查特定点的内存布局或寄存器时,它也使我们的生活更轻松。虽然这种做法在研究Windows内核或用户空间时广泛使用,但调试管理程序和虚拟化栈的文档很少。在本博客文章中,我们将改变这一点。
虚拟化栈简介
当我们谈论“分区”时,我们指的是运行在管理程序之上的不同虚拟机。我们区分三种类型的分区:根分区(也称为父分区)、启用的客户分区和未启用的客户分区。与其他客户虚拟机不同,“根分区”是我们的主机操作系统。虽然它是一个功能齐全的Windows虚拟机,我们可以运行常规程序如网页浏览器,但虚拟化栈本身的部分运行在根分区内核和用户空间中。我们可以将根分区视为一个特殊的、有特权的客户虚拟机,它与管理程序协同工作。
例如,Hyper-V管理服务运行在根分区中,可以创建新的客户分区。这些分区随后将使用hypercall与管理程序内核通信。有更高级的API用于通信,其中最重要的是VMBus。VMBus是一个跨分区IPC组件,被虚拟化栈广泛使用。
客户分区的虚拟设备也可以利用名为“启用的I/O”的功能,用于存储、网络、图形和输入子系统。启用的I/O是通信协议(例如SCSI)的专门虚拟化感知实现,运行在VMBus之上。这绕过了设备仿真层,提供了更好的性能和功能,因为客户已经意识到虚拟化栈(因此称为“启用的”)。然而,这需要客户虚拟机使用此功能的特殊驱动程序。
Hyper-V启用的I/O和管理程序感知的内核在安装Hyper-V集成服务时可用。集成组件,包括虚拟服务器客户端(VSC)驱动程序,可用于Windows和一些更常见的Linux发行版。
在Hyper-V中创建客户分区时,我们可以选择“第1代”或“第2代”客户。我们不会在这里讨论两种虚拟机类型的差异,但值得一提的是,第1代虚拟机支持大多数客户操作系统,而第2代虚拟机支持的操作系统更有限(主要是64位)。在本文中,我们将仅使用第1代虚拟机。关于差异的详细解释可在此处找到。
总结一下,通常的虚拟化设置如下所示:
- 根分区:Windows
- 启用的子分区:Windows或Linux
- 未启用的子分区:其他操作系统如FreeBSD或旧版Linux
这就是您需要了解的全部内容。更多信息,请参见此演示或TLFS。
调试环境
我们将讨论的设置是针对嵌套Windows 10 Hyper-V客户的管理程序和根分区内核的调试环境。出于明显的原因,我们无法调试自己主机的内核和管理程序。为此,我们需要创建一个客户,在其中启用Hyper-V,并配置一切以便准备好调试连接。幸运的是,Hyper-V支持嵌套虚拟化,我们将在这里使用它。调试环境将如下所示:
由于我们想调试一个管理程序,但不是运行我们调试的虚拟机的那个,我们将使用嵌套虚拟化。我们将要调试的管理程序作为另一个管理程序的客户运行。
为了澄清,让我们介绍一些基本的嵌套虚拟化术语:
- L0 = 在物理主机上运行的代码。运行管理程序。
- L1 = L0的管理程序客户。运行我们想要调试的管理程序。
- L2 = L1的管理程序客户。
简而言之,我们将从L0根分区的用户空间调试L1管理程序和根分区的内核。
关于在Hyper-V上使用嵌套虚拟化的更多信息可在此处找到。
管理程序中内置了调试支持,允许我们使用调试器连接到Hyper-V。要启用它,我们需要在BCD(启动配置数据)变量中配置一些设置。
让我们从设置要调试的虚拟机开始:
如果您的主机上尚未运行Hyper-V:
- 启用Hyper-V,如文档所述。
- 重新启动主机。
设置一个新的客户虚拟机进行调试:
- 创建一个第1代Windows 10客户,如文档所述。虽然此过程也适用于第2代客户,但您必须禁用安全启动才能工作。
- 在客户的处理器中启用VT-x。没有这个,Hyper-V平台将无法在客户内部运行。请注意,我们只能在客户关闭电源时执行此操作。我们可以从主机上的提升的PowerShell提示符执行此操作:
1Set-VMProcessor -VMName <VMName> -ExposeVirtualizationExtensions $true - 由于我们将在客户内部调试管理程序,我们还需要在客户内部启用Hyper-V(文档在此处)。之后重新启动客户虚拟机。
- 现在我们需要设置BCD变量以启用调试。在L1主机(即我们刚刚设置的内部操作系统)内部,从提升的cmd.exe运行以下命令:
这将:
1 2 3 4 5bcdedit /hypervisorsettings serial DEBUGPORT:1 BAUDRATE:115200 bcdedit /set hypervisordebug on bcdedit /set hypervisorlaunchtype auto bcdedit /dbgsettings serial DEBUGPORT:2 BAUDRATE:115200 bcdedit /debug on- 通过串行端口启用Hyper-V调试
- 启用Hyper-V在启动时启动,并加载内核
- 通过不同的串行端口启用内核调试
这些更改将在下次启动后生效。
为了与这些串行端口通信,我们需要将它们暴露给L0主机,即我们将从中工作的操作系统。我们无法在客户启动时设置硬件配置,因此:
- 关闭客户。
- 从Hyper-V管理器,右键单击虚拟机,单击“设置”,然后在左窗格中的“硬件”下选择COM1。从选项中选择命名管道并将其设置为“debug_hv”。
- 对COM2执行相同操作,并将其名称设置为“debug_kernel”。
另一种方法是使用Set-VMComPort cmdlet。
这将在主机上创建两个命名管道,“\.\pipe\debug_hv”和“\.\pipe\debug_kernel”,分别用作管理程序和根分区内核的串行调试链接。这只是一个示例,您可以随意命名管道。
启动两个提升的WinDbg实例,并将它们的输入设置为两个命名管道。为此,单击:开始调试 -> 附加到内核(ctrl+k)。
启动客户。现在我们可以调试根分区内核和管理程序了!
就这样。两个WinDbg实例运行,分别用于内核和Hyper-V(hvix64.exe,管理程序本身)。还有其他调试Hyper-V的方法,但这是我已经使用了一年多的方法。然而,其他人可能更喜欢不同的设置,例如@gerhart_x的博客文章关于使用VMware和IDA设置类似环境。
与此设置类似的另一个选项是使用名为kdnet的组件通过网络而不是串行端口进行调试。这与此环境有些类似,并在此处有文档记录。
静态分析
在我们开始调试之前,拥有一个基本的idb用于管理程序hvix64.exe可能很有用。这里有一些提示帮助设置它。
下载vmx.h和hvgdk.h(来自WDK),并加载它们。这些头文件包括一些重要的结构、枚举、常量等。要加载头文件,转到文件 -> 加载文件 -> 解析C头文件(ctrl+F9)。这样,我们可以将立即值定义为它们的vmx名称,因此当您到达处理VT-x逻辑的函数时,它们会漂亮得多。例如:
定义hypercall表、hypercall结构和hypercall组枚举。有一个公共仓库有点过时,但它肯定会帮助您开始。此外,TLFS包含大多数hypercall的详细文档。
找到可从分区访问的重要入口点函数。基本的是读/写MSR处理程序、vmexit处理程序等。我强烈建议定义所有读/写VMCS函数,因为这些在二进制文件中频繁使用。
其他标准库函数如memcmp、memcpy、strstr可以通过将hvix64.exe与ntoskrnl.exe进行差异比较来轻松揭示。这是许多Hyper-V研究人员使用的常见技巧(例如,这里和这里)。
您可能会注意到对由主要gs结构指向的不同结构的访问。这些结构表示当前状态(例如,当前运行的分区、当前虚拟处理器等)。例如,大多数hypercall通过测试gs:CurrentPartition结构中的权限标志来检查调用者是否有权限执行hypercall。另一个例子可以在先前的屏幕截图中看到:当处理器不支持相关的启发功能时,我们可以看到CurrentVMCS结构(也从gs获取)的使用。还有其他重要结构的偏移,如CurrentPartition、CpuIndex和CurrentVP(虚拟处理器)。这些偏移在不同构建之间变化,但很容易找到它们。
对于未知的hypercall,可能更容易在ntos中找到调用者并从那里开始。在ntos中,每个hypercall都有一个包装函数,它设置环境并调用一个通用的hypercall包装器(nt!HvcallCodeVa),它只是发出一个vmcall指令。
攻击面
现在我们有了研究的基础,我们可以考虑客户到主机逃逸的相关攻击面。有许多接口可以从根分区访问管理程序:
- Hypercall处理程序
- 故障(三重故障、EPT页面故障等)
- 指令仿真
- 拦截
- 寄存器访问(控制寄存器、MSR)
让我们看看如何在这些接口的一些上设置断点。
Hyper-V hypercall
Hyper-V的hypercall在CONST部分的一个表中组织,使用hypercall结构(参见此)。让我们在一个hypercall处理程序中命中一个断点。为此,我们需要在根分区中执行一个vmcall。请注意,尽管VT-x允许我们从CPL3执行vmcall,但Hyper-V禁止它,只允许我们从CPL0执行vmcall。调用hypercall的经典选项是编译一个驱动程序并自己调用hypercall。一个更简单(虽然有点hacky)的选项是在nt!HvcallInitiateHypercall上中断,它包装了hypercall:
其中rcx是hypercall id,rdx、r8是输入和输出参数。对于快速和非快速调用约定,寄存器映射不同,如TLFS中记录: 当快速标志为零时,hypercall输入的寄存器映射:
当快速标志为一时,hypercall输入的寄存器映射:
让我们看一个例子。我们将在一个hypercall处理程序上中断,在从根分区手动触发它之后。为了简单起见,我们将选择一个在正常操作中通常不会被调用的hypercall:hv!HvInstallIntercept。首先,我们需要找到它的hypercall id。在TLFS的附录A中有一个方便的文档化hypercall参考。具体来说,hv!HvInstallIntercept是hypercall id == 0x4d。
假设我们已经有两个调试器运行和一个用作参考的IDA实例,我们应该首先重新定位我们的二进制文件(编辑 -> 段 -> 重新定位程序)到运行的管理程序地址的基址。此步骤是可选的,但由于ASLR,它将节省我们手动计算VA的需要。要获取Hyper-V基址,在连接到管理程序的调试器中运行“lm m hv”,并使用该地址重新定位idb:
太好了!现在,在连接到内核的第二个调试器上,在nt!HvcallInitiateHypercall上设置断点。
当此断点命中时(经常发生),ntos即将向Hyper-V发出一个hypercall。“hypercall输入值”,它保存一些标志和hypercall调用代码,在rcx上(nt!HvCallInitiateHypercall的第一个参数)。通过修改rcx,我们可以更改将被调用的hypercall。
hypercall输入值不仅仅是id:它是用一些标志编码的id,这些标志指示我们正在发出什么类型的hypercall,以及我们希望以什么“方式”调用此hypercall。在Hyper-V中有不同的调用hypercall的方法:
- 简单 – 执行单个操作,并具有固定大小的输入和输出参数集。
- Rep(重复) – 执行多个操作,具有固定大小的输入和/或输出元素列表。
hypercall输入值的编码在TLFS中可用。低位表示hypercall的调用代码:
字段的含义可以在这里看到(来源:TLFS):
当我们在nt!HvcallInitiateHypercall上中断时,我们可能会最终命中一个rep或快速hypercall。一些hypercall必须“快速”调用(由第16位指示)或以重复方式调用。您可以在TLFS附录A中的同一hypercall表中看到这些要求。对于我们的示例,我们使用基于寄存器的调用约定调用它,因此设置rcx=1«16 | 0x4d(换句话说,hypercall id 0x4d,带有“快速”位)。
现在,让我们谈谈当从根分区调用它们时,我们如何将参数传递给hypercall处理程序。参数设置在rdx和r8寄存器中。请注意,快速调用和非快速调用之间存在差异(在非快速调用中,r8是输出参数)。在即将到来的示例中,我们将看到一个快速调用,因此r8将是第二个参数。
在位于管理程序中的hypercall处理程序内部,寄存器的管理方式不同。hypercall处理程序的第一个参数rcx是一个指向保存所有hypercall参数的结构(因此,rdx和r8将是其中的前两个qword)的指针。其余的参数rdx、r8将由管理程序设置,我们从客户分区无法直接控制它们。
让我们在hv!HvInstallIntercept上检查一下。在左边我们看到我们的内核调试器,我们在nt!HvcallInitiateHypercall上中断并设置寄存器。然后我们继续执行,看到hypercall处理程序在hvix64.exe中被命中。在那里,*rcx将保存我们在寄存器上传递的参数。
成功! 如果我们继续单步执行,我们将相对快速地从此函数返回,因为此hypercall处理程序做的第一件事是通过PartitionID获取相关分区,这是它的第一个参数。只需设置rdx=ffffffffffffffff,即HV_PARTITION_ID_SELF(在TLFS中的其他定义中定义):
然后流程可以继续。这是我们在许多不同的hypercall处理程序中发现的非常常见的模式,因此值得记住。
MSR
Hyper-V在其VMEXIT循环处理程序中处理MSR访问(读和写)。在IDA中很容易看到:它是一个大型switch case,覆盖所有支持的MSR值,默认情况是回退到rdmsr/wrmsr,如果该MSR没有管理程序的特殊处理。请注意,MSR读/写处理程序中有身份验证检查,检查当前分区权限。从那里我们可以找到Hyper-V支持的不同MSR,以及处理读和写的函数。
在此示例中,我们将读取MSR 0x40000200,它返回管理程序lowstub地址,如一些研究人员记录(例如,这里和这里)。我们可以直接从内核调试器使用rdmsr命令读取MSR值:
我们可以在MSR读处理程序函数(在管理程序中)中看到处理此特定MSR的相关代码:
而不是仅仅在MSR访问上中断,让我们在管理程序调试器中跟踪所有MSR访问:
|
|
这将在MSR读函数上设置一个断点。函数地址在不同构建之间变化。一种方法是查找二进制文件中MSR独有的常量,并向上查找调用者函数,直到到达MSR读函数。另一种方法是自上而下跟随流程,从vmexit循环处理程序:在VMExit原因(VMCS结构中的VM_EXIT_REASON)的switch case中,您将找到EXIT_REASON_MSR_READ,最终到达此函数。
设置此断点后,我们可以从内核调试器运行rdmsr,并看到此断点命中,带有我们读取的MSR值:
您还可以设置一个更复杂的断点来找到rdmsr的返回值,并以类似方式跟踪MSR写入。
管理程序外的攻击面
为了保持管理程序最小化,虚拟化栈中的许多组件运行在根分区中。这减少了管理程序的攻击面和复杂性。诸如内存管理和进程管理之类的事情在根分区的内核中处理,没有提升的权限(例如,SLAT管理权限)。此外,提供