Hyper-V研究入门:调试与攻击面分析

本文详细介绍了如何搭建Hyper-V调试环境,包括嵌套虚拟化配置、WinDbg调试器设置,并分析了Hyper-V的攻击面,如hypercall处理、MSR访问及内核组件漏洞挖掘方法。

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提示符执行此操作:
    1
    
    Set-VMProcessor -VMName <VMName> -ExposeVirtualizationExtensions $true
    
  • 由于我们将在客户内部调试管理程序,我们还需要在客户内部启用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”,分别用作管理程序和根分区内核的串行调试链接。这只是一个示例,您可以随意命名管道。

启动两个提升的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访问:

1
bp <msr_read_handler_addr> ".printf \"msr_handler called, MSR == 0x%x\\r\\n\", @rdx;g"

这将在MSR读函数上设置一个断点。函数地址在不同构建之间变化。一种方法是查找二进制文件中MSR独有的常量,并向上查找调用者函数,直到到达MSR读函数。另一种方法是自上而下跟随流程,从vmexit循环处理程序:在VMExit原因(VMCS结构中的VM_EXIT_REASON)的switch case中,您将找到EXIT_REASON_MSR_READ,最终到达此函数。

设置此断点后,我们可以从内核调试器运行rdmsr,并看到此断点命中,带有我们读取的MSR值:

您还可以设置一个更复杂的断点来找到rdmsr的返回值,并以类似方式跟踪MSR写入。

管理程序外的攻击面

为了保持管理程序最小化,虚拟化栈中的许多组件运行在根分区中。这减少了管理程序的攻击面和复杂性。诸如内存管理和进程管理之类的事情在根分区的内核中处理,没有提升的权限(例如,SLAT管理权限)。此外,提供

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计