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