导语:KVM(用于基于内核的虚拟机)是基于 Linux 的云环境的标准管理程序。除了 Azure ,几乎所有大型云服务和托管服务提供商都在 KVM 之上运行,将其变成了云服务中的基本安全边界之一。
0x01 基本介绍
KVM(用于基于内核的虚拟机)是基于 Linux 的云环境的标准管理程序。除了 Azure ,几乎所有大型云服务和托管服务提供商都在 KVM 之上运行,将其变成了云服务中的基本安全边界之一。
在这篇文章中,我记录了 KVM 和 AMD CPU 中发现的一个漏洞,并研究了如何将此bug转化为完整的虚拟机逃逸。据我所知,这是 KVM guest到物理主机突破的第一篇公开文章,它不依赖于用户空间组件(如 QEMU)中的漏洞。此漏洞被分配的漏洞编号是 CVE-2021-29657,影响内核版本v5.10-rc1 到 v5.12-rc6, 并于2021 年3月底做了修补。由于该漏洞仅在 v5.10 中才可被利用并在大约 5 个月后被发现,因此 KVM 的大多数部署设备不受影响。我认为这个问题是一个有趣的案例研究,需要构建一个稳定的guest到主机的 KVM 逃逸路径,使得获取KVM虚拟机管理程序权限不再仅仅是理论问题。
https://bugs.chromium.org/p/project-zero/issues/detail?id=2177&q=owner%3Afwilhelm%40google.com&can=1 https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=a58d9166a756a0f4a6618e4f593232593d6df134
下文首先简要概述 KVM 的架构,然后再深入研究漏洞及其利用。
0x02 KVM
KVM 是一个基于 Linux 的开源管理程序,支持 x86、ARM、PowerPC 和 S/390 上的硬件加速虚拟化。与其他大型开源管理程序 Xen 相比,KVM 与 Linux 内核深度集成,并在其调度、内存管理和硬件集成的基础上构建,以提供高效的虚拟化。
KVM 由一个或多个内核模块(kvm.ko 加上 kvm-intel.ko 或 x86 上的 kvm-amd.ko)实现,它们 通过 /dev/kvm 设备向用户空间进程公开一个基于 IOCTL 的低级API。使用此 API,用户空间进程(通常称为 Virtual Machine Manager 的 VMM)可以创建新的 VM,分配 vCPU 和内存,并拦截内存或 IO 访问以提供对模拟或虚拟化感知硬件设备的访问。 长期以来,QEMU一直是基于 KVM 的虚拟化的标准用户空间选择,但在最近几年,LKVM、crosvm 或Firecracker等替代方案开始流行。
低级API:https://www.kernel.org/doc/html/latest/virt/kvm/api.html [LKVM](https://github.com/lkvm/lkvm) [crosvm](https://chromium.googlesource.com/chromiumos/platform/crosvm/) [Firecracker](https://github.com/firecracker-microvm/firecracker)
虽然 KVM 依赖于单独的用户空间组件起初可能看起来很复杂,但它有一个非常优秀的好处:在 KVM 主机上运行的每个 VM 都有一个到 Linux 进程的 1:1 映射,使其可以使用标准 Linux 工具进行管理。
这意味着,例如,可以通过转储其用户空间进程的分配内存来检查guest的内存,或者可以轻松应用 CPU 时间和内存的资源限制。此外,KVM 可以将大部分与设备模拟相关的工作卸载到用户空间组件。除了与中断处理相关的几个性能敏感设备之外,所有用于提供虚拟磁盘、网络或 GPU 访问的复杂低级代码都可以在用户空间中实现。
在查看 KVM 相关漏洞和利用的公开文章时,很明显这种设计是一个明智的决定。大多数公开的漏洞和所有公开可用的漏洞都会影响 QEMU 及其对模拟/半虚拟化设备的支持。
尽管 KVM 的内核攻击面比默认 QEMU 配置或类似的用户空间 VMM 暴露的要小得多,但 KVM 漏洞对攻击者来说非常的有价值:
尽管可以对用户空间 VMM 进行沙箱化以减少 VM 逃逸的影响,但 KVM 本身没有这样的选项。一旦攻击者能够在主机内核的上下文中实现代码执行(或类似的强大原语,如对页表的写访问),系统就会完全受到损害。
由于 QEMU 的安全历史有些糟糕,像 crosvm 或 Firecracker 这样的新用户空间 VMM 是用 Rust编写的。当然,由于 KVM API 的不正确使用或漏洞利用,仍然可能存在非内存安全漏洞或问题,但使用 Rust 有效地防止了过去在基于 C 的用户空间 VMM 中发现的大部分漏洞。
最后,纯 KVM 漏洞可以针对使用专有或大量修改的用户空间 VMM 的目标。虽然大型云提供商不会公开详细介绍他们的虚拟化堆栈组件,但可以假设他们不依赖未修复的 QEMU 版本来处理其生产工作负载。相比之下,KVM 较小的代码库不太可能进行大量修改。
考虑到这些优势,我决定花一些时间挖掘一个 KVM 漏洞,该漏洞可能会实现从客户机到主机的逃逸。过去,我在 KVM 对 Intel CPU 上嵌套虚拟化的支持组件中挖掘到一些漏洞,因此可以想象 AMD 的相同功能似乎也是一个很好的挖掘点。因为最近 AMD 在服务器领域的市场份额增加意味着 KVM 的 AMD 实现突然成为一个比过去几年更有趣的目标。
https://bugs.chromium.org/p/project-zero/issues/list?q=vmx owner%3Afwilhelm&can=1
嵌套虚拟化,VM(称为 L1)生成嵌套guest(L2)的能力,在很长一段时间内也是一个小众功能。然而,由于硬件改进减少了它的开销并增加了客户需求,它变得越来越广泛可用。例如,微软正在大力推动基于虚拟化的安全性作为较新 Windows 版本的一部分,需要嵌套虚拟化来支持云托管的 Windows 安装。 默认情况下,KVM 在 AMD 和Intel上都启用对嵌套虚拟化的支持,因此如果管理员或用户空间 VMM 没有明确禁用它,它就是恶意或受感染 VM 的攻击面的一部分。
https://docs.microsoft.com/en-us/windows-hardware/design/device-experiences/oem-vbs
AMD 的虚拟化扩展称为 SVM(用于安全虚拟机),为了支持嵌套虚拟化,主机管理程序需要拦截由其guest执行的所有 SVM 指令,模拟它们的行为并保持其状态与底层硬件同步。正确实现这一点非常困难,并且存在很大的复杂逻辑缺陷,使其成为手动代码审查的完美目标。
0x03 漏洞细节
在深入研究 KVM 代码库和我发现的漏洞之前,我想快速介绍一下 AMD SVM 的工作原理,以使文章的其余部分更容易理解。有关详细文档,请参阅AMD64 架构程序员手册,第 2 卷:系统编程第 15 章。如果通过设置 EFER MSR 中的 SVME 位启用 SVM 支持,则 SVM 将支持 6 条新指令到 x86-64。这些指令中最有趣的是VMRUN ,它负责运行 VMguest。VMRUN通过 RAX 寄存器获取一个隐式参数,该参数指向“虚拟机控制块”(VMCB)数据结构的页面对齐物理地址,它描述了 VM 的状态和配置。
AMD64 架构程序员手册,第2卷:系统编程第15章:https://www.amd.com/system/files/TechDocs/24593.pdf
VMCB 分为两部分:首先是状态保存区,它存储所有guest寄存器的值,包括段和控制寄存器。第二,控制区,描述了虚拟机的配置。Control 区域描述了为 VM 启用的虚拟化功能,设置拦截哪些 VM 操作以触发 VM 退出并存储一些基本配置值,例如用于嵌套分页的页表地址。
用于嵌套分页的页表地址:https://en.wikipedia.org/wiki/Second_Level_Address_Translation
如果正确准备了 VMCB,VMRUN 将首先将主机状态保存在主机保存区的内存区域中,其地址是通过向 VM_HSAVE_PA MSR 写入物理地址来配置的。一旦保存了主机状态,CPU 就会切换到 VM 上下文,并且 VMRUN 仅在由于某种原因触发 VM 退出时才返回。
SVM 的一个有趣方面是,VM 退出后的许多状态恢复必须由管理程序完成。一旦发生 VM 退出,只有 RIP、RSP 和 RAX 会恢复为之前的主机值,所有其他通用寄存器仍包含guest值。此外,完整的上下文切换需要手动执行 VMSAVE/VMLOAD 指令,这些指令从内存中保存/加载额外的系统寄存器(FS、SS、LDTR、STAR、LSTAR……)。
为了使嵌套虚拟化工作,KVM 会拦截 VMRUN 指令的执行,并基于 L1 guest准备的 VMCB(在 KVM 术语中称为 vmcb12)创建自己的 VMCB。当然,KVM 不能信任guest提供的 vmcb12,并且需要仔细验证最终在传递给硬件(称为 vmcb02)的真实 VMCB 中的所有字段。
AMD 上用于嵌套虚拟化的 KVM 代码大部分在arch/x86/kvm/svm/nested.c中实现,拦截嵌套guest的 VMRUN 指令的代码在nested_svm_vmrun 中实现:
https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/kvm/svm/nested.c?h=v5.11 int nested_svm_vmrun(struct vcpu_svm *svm) { int ret; struct vmcb *vmcb12; struct vmcb *hsave = svm->nested.hsave; struct vmcb *vmcb = svm->vmcb; struct kvm_host_map map; u64 vmcb12_gpa; vmcb12_gpa = svm->vmcb->save.rax; ** 1 ** ret = kvm_vcpu_map(&svm->vcpu, gpa_to_gfn(vmcb12_gpa), &map); ** 2 ** … ret = kvm_skip_emulated_instruction(&svm->vcpu); vmcb12 = map.hva; if (!nested_vmcb_checks(svm, vmcb12)) { ** 3 ** vmcb12->control.exit_code = SVM_EXIT_ERR; vmcb12->control.exit_code_hi = 0; vmcb12->control.exit_info_1 = 0; vmcb12->control.exit_info_2 = 0; goto out; } ... /* * Save the old vmcb, so we don't need to pick what we save, but can * restore everything when a VMEXIT occurs */ hsave->save.es = vmcb->save.es; hsave->save.cs = vmcb->save.cs; hsave->save.ss = vmcb->save.ss; hsave->save.ds = vmcb->save.ds; hsave->save.gdtr = vmcb->save.gdtr; hsave->save.idtr = vmcb->save.idtr; hsave->save.efer = svm->vcpu.arch.efer; hsave->save.cr0 = kvm_read_cr0(&svm->vcpu); hsave->save.cr4 = svm->vcpu.arch.cr4; hsave->save.rflags = kvm_get_rflags(&svm->vcpu); hsave->save.rip = kvm_rip_read(&svm->vcpu); hsave->save.rsp = vmcb->save.rsp; hsave->save.rax = vmcb->save.rax; if (npt_enabled) hsave->save.cr3 = vmcb->save.cr3; else hsave->save.cr3 = kvm_read_cr3(&svm->vcpu); copy_vmcb_control_area(&hsave->control, &vmcb->control); svm->nested.nested_run_pending = 1; if (enter_svm_guest_mode(svm, vmcb12_gpa, vmcb12)) ** 4 ** goto out_exit_err; if (nested_svm_vmrun_msrpm(svm)) goto out; out_exit_err: svm->nested.nested_run_pending = 0; svm->vmcb->control.exit_code = SVM_EXIT_ERR; svm->vmcb->control.exit_code_hi = 0; svm->vmcb->control.exit_info_1 = 0; svm->vmcb->control.exit_info_2 = 0; nested_svm_vmexit(svm); out: kvm_vcpu_unmap(&svm->vcpu, &map, true); return ret; }
该函数首先从当前活动的 vmcb ( svm->vcmb ) 中获取 1中的RAX 的值。对于使用嵌套分页的guest,RAX 包含一个guest物理地址(GPA),需要先将其转换为主机物理地址(HPA)。kvm_vcpu_map ( 2 ) 负责此转换并将底层页面映射到可由 KVM 直接访问的主机虚拟地址 (HVA)。
映射VMCB 后, 将调用nested_vmcb_checks在3 中进行一些基本验证。之后, 在KVM通过调用enter_svm_guest_mode ( 4 )进入嵌套的guest context之前,将存储在svm->vmcb中的L1 guest context 复制到主机保存区svm->nested.hsave中。
int enter_svm_guest_mode(struct vcpu_svm *svm, u64 vmcb12_gpa,struct vmcb *vmcb12) { int ret; svm->nested.vmcb12_gpa = vmcb12_gpa; load_nested_vmcb_control(svm, &vmcb12->control); nested_prepare_vmcb_save(svm, vmcb12); nested_prepare_vmcb_control(svm); ret = nested_svm_load_cr3(&svm->vcpu, vmcb12->save.cr3, nested_npt_enabled(svm)); if (ret) return ret; svm_set_gif(svm, true); return 0; } static void load_nested_vmcb_control(struct vcpu_svm *svm, struct vmcb_control_area *control) { copy_vmcb_control_area(&svm->nested.ctl, control); ... }
查看enter_svm_guest_mode 我们可以看到 KVM 将 vmcb12 控制区直接复制到 svm->nested.ctl 中,并且不会对复制的值进行任何进一步的检查。
熟悉 double fetch 或 Time-of-Check-to-Time-of-Use 漏洞的读者可能已经在这里看到了一个潜在问题:在nested_svm_vmrun 开头对nested_vmcb_checks的调用对VMCB的副本执行所有检查,该副本是存储在guest内存中。这意味着具有多个 CPU 内核的guest可以在nested_vmcb_checks 中验证VMCB 中的字段后,在将它们复制到load_nested_vmcb_control 中的svm->nested.ctl 之前修改它们。
让我们看一下nested_vmcb_checks ,看看可以用这种方法绕过什么样的检查:
static bool nested_vmcb_check_controls(struct vmcb_control_area *control) { if ((vmcb_is_intercept(control, INTERCEPT_VMRUN)) == 0) return false; if (control->asid == 0) return false; if ((control->nested_ctl & SVM_NESTED_CTL_NP_ENABLE) && !npt_enabled) return false; return true; }
control->asid 没有使用,最后一次检查仅与不支持嵌套分页的系统相关。
SVM VMCB 包含一个bit,用于在guest内部执行时启用或禁用 VMRUN 指令的拦截。硬件实际上不支持清除此位,并会导致立即 VMEXIT,因此,nested_vmcb_check_controls 中的检查 只是复制了此行为。当我们通过反复翻转 INTERCEPT_VMRUN 位的值来竞争并绕过检查时,我们最终可能会遇到 svm->nested.ctl 包含 0 代替 INTERCEPT_VMRUN 位的情况。要了解影响,我们首先需要了解嵌套的 vmexit 在 KVM 中是如何处理的:
主SVM退出处理句柄是在 arch/x86/kvm/svm.c中的函数handle_exit ,每当发生VMEXIT被调用就会调用此函数。当 KVM 运行嵌套的guest时,它首先必须检查退出是否应该由它自己或 L1 管理程序处理。为此,它调用了函数nested_svm_exit_handled ( 5 ),如果 vmexit 将由 L1 管理程序处理并且不需要由 L0 管理程序进一步处理,则该函数将返回NESTED_EXIT_DONE :
https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/kvm/svm/svm.c?h=v5.11 static int handle_exit(struct kvm_vcpu *vcpu, fastpath_t exit_fastpath) { struct vcpu_svm *svm = to_svm(vcpu); struct kvm_run *kvm_run = vcpu->run; u32 exit_code = svm->vmcb->control.exit_code; … if (is_guest_mode(vcpu)) { int vmexit; trace_kvm_nested_vmexit(exit_code, vcpu, KVM_ISA_SVM); vmexit = nested_svm_exit_special(svm); if (vmexit == NESTED_EXIT_CONTINUE) vmexit = nested_svm_exit_handled(svm); ** 5 ** if (vmexit == NESTED_EXIT_DONE) return 1; } } static int nested_svm_intercept(struct vcpu_svm *svm) { // exit_code==INTERCEPT_VMRUN when the L2 guest executes vmrun u32 exit_code = svm->vmcb->control.exit_code; int vmexit = NESTED_EXIT_HOST; switch (exit_code) { case SVM_EXIT_MSR: vmexit = nested_svm_exit_handled_msr(svm); break; case SVM_EXIT_IOIO: vmexit = nested_svm_intercept_ioio(svm); break; … default: { if (vmcb_is_intercept(&svm->nested.ctl, exit_code)) ** 7 ** vmexit = NESTED_EXIT_DONE; } } return vmexit; } int nested_svm_exit_handled(struct vcpu_svm *svm) { int vmexit; vmexit = nested_svm_intercept(svm); ** 6 ** if (vmexit == NESTED_EXIT_DONE) nested_svm_vmexit(svm); ** 8 ** return vmexit; }
Nested_svm_exit_handled 首先调用nested_svm_intercept (6) 来查看是否应该处理出口。当通过在 L2 guest中执行 VMRUN 触发退出时,将执行默认情况 ( 7 )以查看 svm->nested.ctl 中的 INTERCEPT_VMRUN 位是否已设置。通常情况下,应该始终如此,该函数返回 NESTED_EXIT_DONE 以触发从 L2 到 L1 的嵌套 VM 退出,并让 L1 管理程序处理退出 ( 8 )。这种方式 KVM 支持虚拟机管理程序的无限嵌套。
但是,如果 L1 guest利用上述竞争条件,svm->nested.ctl 将不会设置 INTERCEPT_VMRUN 位,并且 VM 退出将由 KVM 本身处理。这会导致在 L2 guest上下文中仍然运行时再次调用nested_svm_vmrun 。Nested_svm_vmrun 不是为了处理这种情况而开发的,它会使用当前活动的svm->vmcb 中包含 L2 guest的数据覆盖存储在svm->nested.hsave 中的 L1 上下文:
/* * Save the old vmcb, so we don't need to pick what we save, but can * restore everything when a VMEXIT occurs */ hsave->save.es = vmcb->save.es; hsave->save.cs = vmcb->save.cs; hsave->save.ss = vmcb->save.ss; hsave->save.ds = vmcb->save.ds; hsave->save.gdtr = vmcb->save.gdtr; hsave->save.idtr = vmcb->save.idtr; hsave->save.efer = svm->vcpu.arch.efer; hsave->save.cr0 = kvm_read_cr0(&svm->vcpu); hsave->save.cr4 = svm->vcpu.arch.cr4; hsave->save.rflags = kvm_get_rflags(&svm->vcpu); hsave->save.rip = kvm_rip_read(&svm->vcpu); hsave->save.rsp = vmcb->save.rsp; hsave->save.rax = vmcb->save.rax; if (npt_enabled) hsave->save.cr3 = vmcb->save.cr3; else hsave->save.cr3 = kvm_read_cr3(&svm->vcpu); copy_vmcb_control_area(&hsave->control, &vmcb->control);
由于为嵌套guest处理模型特定寄存器 (MSR) 拦截的方式,这里存在一个安全问题:
SVM 使用权限位图来控制 VM 可以访问哪些 MSR。位图是一个 8KB 的数据结构,每个 MSR 有两位,其中一位控制读访问,另一个控制写访问。此位置的 1 位表示访问被拦截并触发 vm 退出,0 位表示 VM 可以直接访问 MSR。位图的 HPA 地址存储在 VMCB 控制区域中,对于普通的 L1 KVM guest,一旦创建了 vCPU,就会分配页面并将其固定到内存中。
对于嵌套guest,MSR 权限位图存储在svm->nested.msrpm 中, 并且在嵌套guest运行时,其物理地址被复制到活动 VMCB(在svm->vmcb->control.msrpm_base_pa 中)。使用所描述的双调用nested_svm_vmrun ,当copy_vmcb_control_area 被执行时恶意guest可以将此值复制到svm-> nested.hsave VMCB。这很有趣,因为 KVM 的 hsave 区域通常只包含来自 L1 guest上下文的数据,因此svm->nested.hsave.msrpm_base_pa 通常会指向固定的 vCPU 特定 MSR 位图页面。
由于 KVM 的变化,这种漏洞就变得可利用了:
自去年 10 月提交“ [2fcf4876:KVM:nSVM:实现嵌套状态分配”以来,svm->nested.msrpm 会在客户机更改 MSR_EFER 寄存器的 SVME 位时动态分配和释放:
https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit?h=v5.11&id=2fcf4876ada8a293d3b92a1033b8b990a7c613d3 int svm_set_efer(struct kvm_vcpu *vcpu, u64 efer) { struct vcpu_svm *svm = to_svm(vcpu); u64 old_efer = vcpu->arch.efer; vcpu->arch.efer = efer; if ((old_efer & EFER_SVME) != (efer & EFER_SVME)) { if (!(efer & EFER_SVME)) { svm_leave_nested(svm); svm_set_gif(svm, true); ... /* * Free the nested guest state, unless we are in SMM. * In this case we will return to the nested guest * as soon as we leave SMM. */ if (!is_smm(&svm->vcpu)) svm_free_nested(svm); } ... } }
对于“禁用 SVME”的情况,KVM 会先调用svm_leave_nested,嵌套guest释放svm->嵌套数据结构中的svm_free_nested 。由于svm_leave_nested 认为svm->nested.hsave 包含 L1 guest的已保存上下文,因此它只是将其控制区域复制到真实的 VMCB:
void svm_leave_nested(struct vcpu_svm *svm) { if (is_guest_mode(&svm->vcpu)) { struct vmcb *hsave = svm->nested.hsave; struct vmcb *vmcb = svm->vmcb; ... copy_vmcb_control_area(&vmcb->control, &hsave->control); ... } }
但是前面提到过,svm->nested.hsave->control.msrpm_base_pa 仍然可以指向svm->nested->msrpm 。一旦svm_free_nested 完成并且 KVM 将控制权交还给guest,CPU 将使用释放的页面进行 MSR 权限检查。如果页面被重用并且部分被零覆盖,这将允许guest不受限制地访问主机 MSR。
总而言之,恶意guest可以使用以下方法访问主机 MSR:
启用 MSR_EFER 中的 SVME 位以启用嵌套虚拟化
反复尝试使用 VMRUN 指令启动 L2 guest,同时翻转第二个 CPU 内核上的 INTERCEPT_VMRUN 位。
如果 VMRUN 成功,请尝试使用另一个 VMRUN 调用启动“L3”guest。如果失败,就必须再试一次。如果 VMRUN 调用成功,我们就成功地用 L2 上下文覆盖了svm->nested.hsave 。
清除 MSR_EFER 中的 SVME 位,仍在“L3”上下文中运行。这释放了现在再次执行的 L2 guest使用的 MSR 权限位图支持页面。
等到 KVM 主机重新使用支持页面。这可能会清除所有或部分bit,使guest可以访问主机 MSR。
当我最初发现并报告此漏洞时,我非常确信这种类型的 MSR 访问应该或多或少等同于主机上的完整代码执行。虽然事实证明是正确的,但我仍然花了数周的时间进行漏洞利用开发。在下一节中,我将描述将这个原语转变为从客户到主机逃逸的利用步骤。
0x04 漏洞利用
假设我们的guest可以完全不受限制地访问任何 MSR(这只是时间问题,因为 init_on_alloc=1 是大多数现代发行版的默认设置),我们如何将其升级为在 KVM 主机的上下文中运行任意代码?要回答这个问题,我们首先需要了解现代 AMD 系统支持什么样的 MSR。 查看最近 AMD 处理器的BIOS 和内核开发人员指南,我们可以找到非常多关于 MSR 的资料,例如 EFER(扩展功能启用寄存器)或、LSTAR(系统调用目标地址)、 SMI_ON_IO_TRAP(可用于在访问特定 IO 端口范围时生成系统管理模式中断)。
[BIOS 和内核开发人员指南](https://www.amd.com/system/files/TechDocs/52740_16h_Models_30h-3Fh_BKDG.pdf)
像 LSTAR 或 KERNEL_GSBASE 这样的几个寄存器似乎是重定向主机内核执行的目标。对这些寄存器的无限制访问实际上是默认启用的,但是它们会在 vmexit 后由 KVM 自动恢复到有效状态,因此修改它们不会导致主机行为发生任何变化。
尽管如此,我们之前提到的一个 MSR 似乎为我们提供了一种实现代码执行的直接方法:VM_HSAVE_PA 存储主机保存区的物理地址,用于在发生 vmexit 时恢复主机上下文。如果可以将此 MSR 指向我们控制下的内存位置,我们应该能够伪造恶意主机上下文并在 vmexit 后执行我们自己的代码。
虽然这在理论上听起来很简单,但实现它仍然存在一些挑战:
AMD 非常清楚软件不应该以任何方式接触主机保留分区(MSR),并且该区域中存储的数据依赖于 CPU:“处理器实现可能只存储部分主机状态或不存储指向的内存区域通过 VM_HSAVE_PA MSR 并且可以将部分或全部主机状态存储在隐藏的片上存储器中。不同的实现可以选择保存主机段寄存器和选择器的隐藏部分。由于这些原因,软件不能依赖主机状态保存区的格式或内容,也不能试图通过修改主机保存区的内容来改变主机状态。”(AMD64 架构程序员手册,第 2 卷:系统编程,第 477 页)。为了强调这一点,主机保留分区的格式没有公开。
调试涉及无效主机状态的问题非常繁琐,因为任何问题都会导致处理器立即关闭。更糟糕的是,我不确定在 VM 内部运行时重写 VM_HSAVE_PA MSR 是否可行。在正常操作期间这并不是真正应该发生的事情,因此在最坏的情况下,覆盖 MSR 只会导致立即崩溃。
即使我们可以在我们的guest中创建一个有效的主机保留分区,我们仍然需要某种方法来识别其主机物理地址(HPA)。因为guest运行时启用了嵌套分页,所以在guest (GPA) 中可以看到的物理地址仍然与它们的 HPA 相差一个地址转换。
在花了一些时间浏览 AMD 的文档后,我仍然认为 VM_HSAVE_PA 似乎是最好的利用方式,并决定一一解决这些问题。
转储运行在 AMD EPYC 7351P CPU 上的普通 KVM 客户机的主机保留分区后,第一个问题很快就消失了:事实证明,主机保留分区与普通 VMCB 的布局相同,只有几个相关字段进行了初始化。更好的是,初始化字段包括 AMD 手册中记录的所有保存的主机信息,因此担心所有有趣的主机状态都存储在片上存储器中似乎是没有根据的。
保存主机状态。 为确保主机在#VMEXIT 后可以恢复运行,VMRUN 至少保存以下主机状态信息:
CS.SEL, NEXT_RIP—VMRUN 之后的指令的 CS 选择器和 rIP,在#VMEXIT 上,主机恢复在该地址运行。
RFLAGS、RAX——主机处理器模式和 VMRUN 用来寻址 VMCB 的寄存器。
SS.SEL、RSP—主机的堆栈指针。
CRO、CR3、CR4、EFER——主机的调用/操作模式。
IDTR、GDTR——伪描述符,VMRUN 不会保存或恢复主机 LDTR。
ES.SEL 和 DS.SEL。
接下来需要一个信息泄漏漏洞,使我能够将 GPA 转换为 HPA,了解了 AMD 的性能监控功能后知道需要调用指令采样 ( IBS )。当 IBS 通过将正确的magic调用写入一组 MSR 来启用时,它会对每条执行的第 N 条指令进行采样,并收集有关该指令的信息。此信息记录在另一组 MSR 中,可用于分析 CPU 上运行的任何代码段的性能。虽然 IBS 的大部分文档都非常稀少或难以理解,但开源项目AMD IBS Toolkit 包含工作代码、可读的 IBS 高级描述和许多有用的参考资料。
https://github.com/jlgreathouse/AMD_IBS_Toolkit
IBS 支持两种不同的操作模式,一种对指令提取进行采样,另一种对微操作进行采样(你可以将其视为更复杂的 x64 指令的内部 RISC 表示)。根据操作模式,收集不同的数据。除了很多我们不关心的缓存和延迟信息外,fetch 采样还返回了所获取指令的虚拟地址和物理地址。Op 采样更有用,因为它返回底层指令的虚拟地址以及任何加载或存储微操作访问的虚拟和物理地址。
有趣的是,IBS 似乎并不关心其用户的虚拟化上下文,并且它返回的每个物理地址都是一个 HPA 。IBS 返回的大量数据完全由 MSR 读写驱动使其成为为我们利用信息泄漏漏洞的完美工具。
构建 GPA -> HPA 泄漏归结为启用 IBS ops 采样,执行大量访问我们 VM 中特定内存页面的指令并读取 IBS_DC_PHYS_AD MSR 以找出其 HPA:
// This function leaks the HPA of a guest page using // AMD's Instruction Based Sampling. We try to sample // one of our memory loads/writes to *p, which will // store the physical memory address in MSR_IBC_DH_PHYS_AD static u64 leak_guest_hpa(u8 *p) { volatile u8 *ptr = p; u64 ibs = scatter_bits(0x2, IBS_OP_CUR_CNT_23) | scatter_bits(0x10, IBS_OP_MAX_CNT) | IBS_OP_EN; while (true) { wrmsr(MSR_IBS_OP_CTL, ibs); u64 x = 0; for (int i = 0; i < 0x1000; i++) { x = ptr[i]; ptr[i] += ptr[i - 1]; ptr[i] = x; if (i % 50 == 0) { u64 valid = rdmsr(MSR_IBS_OP_CTL) & IBS_OP_VAL; if (valid) { u64 op3 = rdmsr(MSR_IBS_OP_DATA3); if ((op3 & IBS_ST_OP) || (op3 & IBS_LD_OP)) { if (op3 & IBS_DC_PHY_ADDR_VALID) { printf("[x] leak_guest_hpa: %lx %lx %lx\n", rdmsr(MSR_IBS_OP_RIP), rdmsr(MSR_IBS_DC_PHYS_AD), rdmsr(MSR_IBS_DC_LIN_AD)); return rdmsr(MSR_IBS_DC_PHYS_AD) & ~(0xFFF); } } wrmsr(MSR_IBS_OP_CTL, ibs); } } } wrmsr(MSR_IBS_OP_CTL, ibs & ~IBS_OP_EN); } }
使用这个 infoleak 原语,通过准备自己的页表(用于将 CR3 指向它们)、中断描述符表和段描述符并将 RIP 指向将写入串行控制台的原始 shellcode 来创建一个假主机保存区域。当然,我的第一次尝试立即使整个系统崩溃,即使在花了多天时间确保一切设置正确之后,一旦我将 hsave MSR 指向我自己的位置,系统也会立即崩溃。
在一筹莫展后,看着我的服务器第一百次重启,我想要找到一个不同的利用策略,了解了 Linux 上物理页面的迁移规律后,我意识到我挖到了一个重要的漏洞。仅仅因为 CPU 初始化了主机保存区中的所有预期字段,假设这些字段实际上用于恢复主机上下文是不安全的。我发现我的 AMD EPYC CPU 忽略了主机保存区域中除了 RIP、RSP 和 RAX 寄存器的值之外的所有内容。
虽然这种寄存器控制将使本地特权升级变得简单,但逃逸 VM 边界还是比较复杂的。RIP 和 RSP 控制使启动内核 ROP 链成为下一个合乎逻辑的步骤,但这需要我们首先打破主机内核的地址随机化,并找到一种将受控数据存储在已知主机虚拟地址 (HVA) 的方法。
幸运的是,我们有 IBS 作为强大的信息泄漏构建原语,可以使用它在一次运行中收集所有必需的信息:
泄漏主机内核的(或更具体地说是 kvm-amd.ko 的)基地址可以通过以较小的采样间隔启用 IBS 采样并立即触发 VM 退出来完成。当 VM 继续执行时,IBS 结果 MSR 将包含 KVM 在退出处理期间执行的指令的 HVA。
将数据存储在已知 HVA 的最强大方法是泄漏内核线性映射(也称为physmap )的位置,这是系统上所有物理页面的 1:1 映射。这给了我们一个 GPA->HVA 转换原语,首先使用我们从上面的 GPA->HPA 信息泄漏,然后将 HPA 添加到 physmap 基地址。通过对主机内核中的微操作进行采样,直到我们找到读或写操作,其中访问的虚拟地址和物理地址的低约 30 位是相同的,可以泄漏 physmap。
有了所有这些构建块,我们现在可以尝试构建一个内核 ROP 链来执行一些有趣的payload。但是,有一个重要的问题。当我们在 vmexit 后接管执行时,系统仍然处于某种不稳定状态。如上所述,SVM 的上下文切换非常少,我们至少需要一条 VMLOAD 指令并重新启用可用系统之外的中断。虽然肯定可以利用这个漏洞并使用足够复杂的 ROP 链恢复原始主机上下文,但我决定找到一种方法来运行自己的代码。
几年前,Linux physmap 仍然被映射为可执行文件,执行我们自己的代码就像跳转到我们的一个guest页面的 physmap 映射一样简单。当然,这已经不可能了,内核会努力避免将任何内存页面映射为可写和可执行的。尽管如此,页面保护仅适用于虚拟内存访问,那么为什么不使用直接将受控数据写入物理地址的指令呢?你可能还记得文章前面我们对 SVM 的初步讨论,SVM 支持 VMSAVE 指令以在 VMCB 中存储隐藏的guest状态。与 VMRUN 类似,VMSAVE 将存储在 RAX 寄存器中的 VMCB 的物理地址作为隐式参数。然后它将以下寄存器状态写入 VMCB:
FS、GS、TR、LDTR
Kernel GsBase
STAR、LSTAR、CSTAR、SFMASK
SYSENTER_CS、SYSENTER_ESP、SYSENTER_EIP
对我们来说,VMSAVE 比较重要有以下几个原因:
它用作 KVM 正常 SVM 退出处理程序的一部分,并且可以轻松集成到最小 ROP 链中。
它对物理地址进行操作,因此我们可以使用它来写入任意内存位置,包括 KVM 中自己的代码。
所有写入的寄存器仍然包含我们的 VM 设置的guest值,允许我们通过一些限制来控制写入的内容
VMSAVE 作为漏洞利用原语的最大缺点是 RAX 需要页面对齐,从而减少了我们对目标地址的控制。VMSAVE 写入内存偏移量 0x440-0x480 和 0x600-0x638,因此我们需要小心不要破坏任何正在使用的内存。
在我们的例子中,这不是问题,因为 KVM 包含几个代码页,其中很少或从未使用的函数(例如 cleanup_module 或 SEV 特定代码)存储在这些偏移量处。
虽然不能完全控制写入的数据并且有效的寄存器值有些限制,但仍然可以通过用正确的值填充guest MSR 来将最小的 stage0 shellcode 写入主机内核中的任意页面。我的利用使用了 STAR、LSTAR 和 CSTAR 寄存器,这些寄存器被写入物理偏移量 0x400、0x408 和 0x410。由于所有三个寄存器都需要包含规范地址,我们只能将部分寄存器用于 shellcode,并使用相对跳转来跳过 STAR 和 LSTAR MSR 的不可用部分:
// mov cr0, rbx; jmp wrmsr(MSR_STAR, 0x00000003ebc3220f); // pop rdi; pop rsi; pop rcx; jmp wrmsr(MSR_LSTAR, 0x00000003eb595e5fULL); // rep movsb; pop rdi; jmp rdi; wrmsr(MSR_CSTAR, 0xe7ff5fa4f3);
上面的代码利用了这样一个事实,即当我们作为初始 ROP 链的一部分返回时,我们会控制 RBX 寄存器和堆栈的值。首先,我们将 RBX (0x80040033) 的值复制到 CR0 中,从而禁用内核内存访问的写保护 (WP)。这使得所有内核代码都可以在该 CPU 上写入,从而允许我们将更大的 stage1 shellcode 复制到任意未使用的内存位置并跳转到该位置。
一旦 cr0 中的 WP 位被禁用并且 stage1 有效负载执行,我们就有了多种选择。对于我的漏洞利用PoC,我决定采用一种有点无聊但易于实现的方法来生成随机用户空间命令:主机仍然处于非常奇怪的状态,因此我们的 stage1 负载无法直接调用其他内核函数,但我们可以轻松地将一个函数指针作为后门,该函数指针将在稍后的某个时间点被调用。KVM 使用内核的全局工作队列功能在不同的 vCPU 之间定期同步 VM 的时钟。负责这项工作的函数指针作为 kvm->arch.kvmclock_update_work 存储在(每个 VM)kvm->arch 数据结构中。stage1 有效负载使用 stage2 有效负载的地址覆盖此函数指针。
最后的 stage2 负载在稍后的某个时间点作为内核全局工作队列的一部分执行,并使用 call_usermodehelper 以 root 权限运行任意命令。
让我们将所有这些串在一起,并逐步完成攻击:
通过拆分并设置正确的guest MSR 来准备 stage0 有效负载。
触发nested_svm_vmrun 中的TOCTOU 漏洞并通过禁用EFER MSR 中的SVME 位来释放MSR 权限位图。
等待页面被重用并初始化为 0 以获得不受限制的 MSR 访问。
准备一个假主机保存区、一个用于初始 ROP 链的堆栈和一个用于 stage1 和 stage2 负载的暂存内存区。
使用不同的 IBS 信息泄漏泄漏主机保存区的 HPA、堆栈和临时页面的 HVA 地址以及 kvm-amd.ko 的基地址。
通过在假主机保存区域中设置 RIP、RSP 和 RAX,将 VM_HSAVE_PA MSR 指向它并触发 VM 退出,将执行重定向到 VMSAVE 小工具。
当小工具返回 stage0 被执行时,VMSAVE 将 stage0 负载写入 kvm-amd 代码段中未使用的偏移量。
stage0 禁用 CR0 中的写保护,并在跳转到 stage1 之前用 stage1 和 stage2 负载覆盖未使用的可执行内存位置。
在恢复原始主机上下文之前,stage1 使用指向 stage2 的指针覆盖 kvm->arch.kvmclock_update_work.work.func。
在稍后的某个时间点 kvm->arch.kvmclock_update_work.work.func 作为全局内核 work_queue 的一部分被调用,stage2 使用 call_usermodehelper 生成任意命令。
PoC:ttps://bugs.chromium.org/p/project-zero/issues/detail?id=2177#c5
0x05 分析总结
这篇文章描述了 KVM 的 AMD 特定代码中支持嵌套虚拟化的一个漏洞,从而使仅 KVM 的 VM 逃逸成为可能。幸运的是,在发现问题之前,使该漏洞可被利用的函数仅包含在两个内核版本(v5.10、v5.11)中,从而将该漏洞的实际影响降至最低。该漏洞及其利用仍然证明了高度可利用的安全漏洞仍然存在于虚拟化引擎的核心中,虚拟化引擎几乎可以肯定是一个小型且经过良好审计的代码库。虽然从LoC 的角度来看,KVM 等虚拟机管理程序的攻击面相对较小,但其低级性质、与硬件的密切交互和纯粹的复杂性使得很难避免安全关键漏洞。
虽然我们还没有看到任何针对 Pwn2Own 等竞赛之外的虚拟机管理程序的野外攻击,但对于资金充足的对手来说,这些利用显然是可以实现的。我花了大约两个月的时间进行这项研究。看看这种的漏洞利用的潜在回报率,可以假设现在有更多的人正在研究类似的问题,并且 KVM、Hyper-V、Xen 或 VMware 中的漏洞迟早会被广泛利用。
我们能做些什么呢?从事虚拟化安全工作的安全工程师应该尽可能减少攻击面。将复杂的功能转移到内存安全的用户空间组件是一个巨大的胜利,即使它不能帮助缓解像上述那样的漏洞,但已经极大地减小了攻击面。
依赖虚拟化进行多租户隔离的托管商、云提供商和其他企业应设计其架构,以限制攻击者利用 VM 逃逸漏洞的影响:
VM 主机的隔离:托管不受信任的 VM 的机器至少应被视为部分不受信任。虽然 VM 逃逸可以让攻击者完全控制单个主机,但从一个受感染的主机移动到另一个主机是不容易的。这要求控制平面和后端基础设施足够坚固,并且磁盘映像或加密密钥等用户资源仅暴露给需要它们的主机。 进一步限制 VM 逃逸影响的一种方法是仅在单个机器上运行特定客户或特定敏感度的 VM。
检测能力:在大多数架构中,VM 主机的行为应该是可预测的,一旦攻击者试图逃逸到其他系统,就会使受感染的主机迅速发现。虽然很难排除虚拟化堆栈中存在漏洞的可能性,但良好的检测能力使攻击者变得更加艰难,并增加了快速销毁高价值漏洞的风险。在 VM 主机上运行的代理是第一个检测机制,但重点应该是检测异常的网络通信和资源访问。
本文翻译自:https://googleprojectzero.blogspot.com/2021/06/an-epyc-escape-case-study-of-kvm.html如若转载,请注明原文地址