原文首发:看雪论坛[翻译]Xen hypervisor的跨虚拟机代码执行
前言
2017-03-14,我给 Xen’s security teamp 报告了一个 Bug。该 Bug 允许位于paravirtualized(半虚拟化)Guest 中的一个拥有 root 权限的的攻击者跳出 hypervisor 的管理,完全控制宿主机的物理内存。Xen Project 在 2017-04-04
发布了一个公告和 Patch 补丁。
背景知识
在 x86-64 上,Xen PV(paravirtualized) guests 和 hypervisor 共享虚拟地址空间。粗略的内存布局如下:
Xen 允许 guest 内核执行 hypercall,即使用 Sytem V AMD64 AMI 来实现从 guest 内核到 hypervisor 的一个必备的系统调用。通常是使用指令 syscall 实现,最多 6 个参数,通过寄存器传递。就像正常内核的 syscall,Xen hypercall 通常直接
使用 guest 上的指针来作为参数。由于 hypervisor 共享它的地址空间,故它能理解直接传递过来的 guest-virtual 指针。
就像所有内核一样,在需要解引用guest-virtual指针的时候,Xen必须确保它们并没有实际指向hypervisor-owned的内存区域。它实际上使用用户态的accessor(和Linux内核中的那些相似)来完成这些任务。
access_ok(addr,size) :检查是否一个 guest-supplied 的虚拟地址可以安全访问,换句话说,它会检查访问内存区域不会修改到hypervisor内存。
__copy_to_guest(hnd, ptr, nr) :从 hypervisor 的 ptr 地址拷贝 nr 个字节到 guest地址 hnd ,但是不检查 hnd 是否有效。
copy_to_guest(hnd, ptr, nr) :从hypervisor的 ptr 地址拷贝 nr 个字节到 guest 地址 hnd ,验证 hnd 有效性。
在 Linux 内核中,宏 access_ok() 检测地址范围 addr 到 addr+size-1 是否可以安全访问,使用任意的内存访问模式。然而,Xen 的 access_ok() 并不确保这些:
/*
* Valid if in +ve half of 48‐bit address space, or above Xen‐reserved area.
* This is also valid for range checks (addr, addr+size). As long as the
* start address is outside the Xen‐reserved area then we will access a
* non‐canonical address (and thus fault) before ever reaching VIRT_START.
*/
#define __addr_ok(addr) \
(((unsigned long)(addr) < (1UL<<47)) || \
((unsigned long)(addr) >= HYPERVISOR_VIRT_END))
#define access_ok(addr, size) \
(__addr_ok(addr) || is_compat_arg_xlat_range(addr, size))
Xen 通常只是检查指针 addr 位于地址空间或者内核空间,但不检查大小 size 。如果实际的 Guest Memory 访问从地址addr附近开始,线性的处理,只要一个 guest memory 访问失败,则 bails out 是有意义的,因为大片的 non-canonical 地址空间正好充当了一个大的保护区。然而,如果一个 hypercall 想要访问一个起始于 64-bit offset 的 guest buffer,则它需要确保调用时 access_ok() 填入正确的偏移,检查整个 userspace buffer 是不安全的。
Xen 提供了围绕 access_ok() 的封装来检查 guest 中访问数组。如果想检查是否可以安全的访问一个数组(从下标 0 开始),可以使用 guest_handle_okay(hnd,nr) 。然而,如果你想检查不从下标 0 开始的数组,应该使用 guest_handle_subrange_okay(hnd, first, last) 。
当我看到了 access_ok 的定义,发现其缺乏完整的安全性验证,所以,我开始查找调用它的地方,来看是否有不安全的使用行为。
Hypercall 的抢占机制(Preemption)
当调度 Tick 发生,Xen 需要快速的从当前执行的 vCPU 切换到另一个虚拟机的vCPU。然而简单的中断 hypercall 的执行是不会起作用的(eg.hypercall 可能正拥有一个自旋锁),故 Xen(像其他操作系统)需要一些机制来延迟 vCPU 的切
换,直到它足够安全的来执行。
在 Xen 里,hypercall 的抢占是通过使用 自愿性抢占(voluntary preemption) 实现的:任何长时间运行的 hypercall 代码需要提前调用 hypercall_preempt_check() 来检查是否调度器想要调度到另一个 vCPU 上。如果该事件发生了,hypercall
代码退出到 guest,因此,以信号的方式通知调度器,抢占当前任务是安全的,调整了 hypercall argument 参数后(在guest register或者guest 内存),只要当前 vCPU 再次被调度,它将重新进入 hypercall 之后,执行剩余的工作。
Hypercall 无法区分抢占之后的正常的 hypercall entry 和 hypercall re-entry。
Xen 使用 Hypercall re-entry 的机制,因为 Xen 并不是每一个 vCPU 都有一个hypervisor 栈。它是针对每一个物理核有一个 hypervisor 栈。这意味着,别的操作系统,比如 Linux,可以简单的离开一个中断的 syscall 的状态,而 Xen 无法轻松的做到。
这个设计意味着对于一些 hypercall,允许他们适当地 resume 它们的工作,额外的数据保存到 guest memory 中,而 guest momory 的数据有可能被修改 guest 精心修改而用来攻击 hypervisor。
memory_exchange()
hypercall HYPERVISOR_memory_op(XENMEM_exchange, arg) 调用函数 memory_exchange(arg)(source code: xen/common/memory.c) 。该函数允许一个 guest 将它们拥有的的物理内存用来交换一些在物理地址连续性上有限制的新的物理内存。该功能对于想实现 DMA 功能的 guest 非常有用,因为 DMA 需要物理上连续的缓冲区。
该 hypercall 需要一个结构为 struct xen_memory_exchage 的参数,定义如下:
truct xen_memory_reservation {
/* [...] */
XEN_GUEST_HANDLE(xen_pfn_t) extent_start; /* in: physical page list */
/* Number of extents, and size/alignment of each (2^extent_order pages). */
xen_ulong_t nr_extents;
unsigned int extent_order;
/* XENMEMF flags. */
unsigned int mem_flags;
/*
* Domain whose reservation is being changed.
* Unprivileged domains can specify only DOMID_SELF.
*/
domid_t domid;
};
struct xen_memory_exchange {
/*
* [IN] Details of memory extents to be exchanged (GMFN bases).
* Note that @in.address_bits is ignored and unused.
*/
struct xen_memory_reservation in;
/*
* [IN/OUT] Details of new memory extents.
* We require that:
* 1. @in.domid == @out.domid
* 2. @in.nr_extents << @in.extent_order ==
* @out.nr_extents << @out.extent_order
* 3. @in.extent_start and @out.extent_start lists must not overlap
* 4. @out.extent_start lists GPFN bases to be populated
* 5. @out.extent_start is overwritten with allocated GMFN bases
*/
struct xen_memory_reservation out;
/*
* [OUT] Number of input extents that were successfully exchanged:
* 1. The first @nr_exchanged input extents were successfully
* deallocated.
* 2. The corresponding first entries in the output extent list correctly
* indicate the GMFNs that were successfully exchanged.
* 3. All other input and output extents are untouched.
* 4. If not all input exents are exchanged then the return code of this
* command will be non‐zero.
* 5. THIS FIELD MUST BE INITIALISED TO ZERO BY THE CALLER!
*/
xen_ulong_t nr_exchanged;
};
和该 Bug 相关的成员有: in.extent_start, in.nr_extents, out.extent_start, out.nr_extents 和 nr_exchangednr_exchanged 文档中默认总是被 guest 初始化为 0,这是因为,它不仅用来返回一个结果,同时 hypercall 的抢占中也会用到。当 memory_exchange() 被抢占后, nr_exchaged 存储它的进度,这样,当下一次执行 memory_exchage() 时,使用 nr_exchanged 来决定输入数组中 in.extent_start和out.extent_start 的哪个点应该被resume。原来的 memory_exchange() 并不检查用户空间的数组指针,在使用 __copy_from_guest_offset和__copy_to_guest_offset() 访问它们之前,而且不进行自身的任何检查,故,使用提供的hypervisor指针,可能导致Xen去读或者写hypervisor内存–一个非常严重的Bug。该问题在2012年被发现(XSA-29,CVE-2012-5513),同时进行了如下修补(https://xenbits.xen.org/xsa/xsa29-4.1.patch):
diff ‐‐git a/xen/common/memory.c b/xen/common/memory.c
index 4e7c234..59379d3 100644
‐‐‐ a/xen/common/memory.c
+++ b/xen/common/memory.c
@@ ‐289,6 +289,13 @@ static long memory_exchange(XEN_GUEST_HANDLE(xen_memory_exchange_t)
arg)
goto fail_early;
}
+ if ( !guest_handle_okay(exch.in.extent_start, exch.in.nr_extents) ||
+ !guest_handle_okay(exch.out.extent_start, exch.out.nr_extents) )
+ {
+ rc = ‐EFAULT;
+ goto fail_early;
+ }
+
/* Only privileged guests can allocate multi‐page contiguous extents. */
if ( !multipage_allocation_permitted(current‐>domain,
exch.in.extent_order) ||
The Bug
如下代码片段所示,64bit resumption 偏移 nr_exchanged ,可以被 guest 控制,由于Xen’s 的 hypercall resumption 机制,可以被 guest 用来从 out.extent_start 选择一个偏移用来写:
static long memory_exchange(XEN_GUEST_HANDLE_PARAM(xen_memory_exchange_t) arg)
{
[...]
/* Various sanity checks. */
[...]
if ( !guest_handle_okay(exch.in.extent_start, exch.in.nr_extents) ||
!guest_handle_okay(exch.out.extent_start, exch.out.nr_extents) )
{
rc = ‐EFAULT;
goto fail_early;
} [
...]
for ( i = (exch.nr_exchanged >> in_chunk_order);
i < (exch.in.nr_extents >> in_chunk_order);
i++ )
{
[...]
/* Assign each output page to the domain. */
for ( j = 0; (page = page_list_remove_head(&out_chunk_list)); ++j )
{
[...]
if ( !paging_mode_translate(d) )
{
[...]
if ( __copy_to_guest_offset(exch.out.extent_start,
(i << out_chunk_order) + j,
&mfn, 1) )
rc = ‐EFAULT;
}
}[
...]
}[
...]
}
然而, guest_handle_okay() 只检查了是否可以安全访问从下标 0 开始的 guest 数组 exch.out.extent_start 。 guest_handle_subrange_okay 才应该是正确的方式。因此,一个 attacker 可以攻击者可以通过如下条件来达到给任意地址写 8 个字节的数据的目的:
exch.in.extent_order 和 exch.out.extent_order 为0(新页大小的块替换原来的页大小的块)。
exch.out.extent_start
和 exch.nr_exchanged : exch.out.extent_start 指向用户空间内存,然而
exch.out.extent_start+8*exch.nr_exchanged
指向hypervisor内存中的目标地址,当exch.out.extent_start趋近与NULL时,可以这样计算:
exch.out.extent_start=target_add%8, exch.nr_exchanged=target_addr/8 。
exch.in.nr_extents 和 exch.out.nr_extents 为 exch.nr_exchanged+1 。
exch.in.extent_start
为
input_buffer‐8*exch.nr_exchanged(input_buffer是一个合理的指向物理页的guest_内核地址指针)
这个确保总是指向guest用户空间范围(通过了access_ok()检查),因为 exch.out.extent_start
粗略地指向用户空间地址范围的起始地址,而且,guest内核地址范围和用户空间地址范围一样大。
写入到攻击者控制的地址中的值是一个PFN号。
利用该bug:获取页表的控制
在一个忙碌的系统上,控制由内核写的页号是非常困难的。因此,出于稳定性的考虑,有必要将该 bug 视为一个 primitive,即循环地在一个固定的地址写 8 个字节的数据,同时大部分有效的位初始化为 0(由于有限的物理内存),同时少量的有效位初始化为随机值。对于我的 exploit,我决定将这个 primitive 视为写一个必须地随机字节和紧随 7 个垃圾字节的方式。
事实证明,对于一个 x86-64PV guest,有这样一个 primitive,对于稳定的利用是非常有效的,因为:
x86-64PV guest 知道所有它可以访问的物理页的页号。
x86-64PV guest 可以映射属于它们 domain 的页表(4个level的)为可读。Xem只阻止将它们映射为可写。
Xen 映射所有的物理内存为可写,在地址 0xffff830000000000。
攻击的目标是将 level 3 页表(我称为“受害页表”)中的一个 entry 指向一个 guest 有写权限的页(我称为“假页表”)。这意味着,攻击者必须写入 8 个字节数据,同时要有假页表的物理页号和一些其它的 Flag,同时还要确保,之后的 8 个字节的页表项保持禁用状态(eg.通过设置下一个entry的第一个字节为0)。最终,攻击者必须写8个控制地字节,之后地 7 个字节不用关心。因为所有相关页的物理页号和可写的映射的物理地址对于 guest 来说都是可知的,因此,找出写到哪和要写什么非常轻松,所以,唯一的问题,就是如何利用 primitive 来真正地写入数据。
因为攻击者想使用 primivite 来写到一个可读地页面,故写入一个字节随机数据和7字节垃圾数据的方式可以轻松地转换为写入一个字节的控制数据和 7 个字节的垃圾数据,而写入一个字节的控制数据和 7 个字节垃圾数据的 primitive 可以转换为写入控制数据和 7 个字节垃圾数据的 primitive,通过写入字节到连续的地址,这才是真正的 primitive needed。
到此时,攻击者可以控制一个实时的页表,来允许攻击者映射任意地物理地址到guest 的虚拟地址。也意味着攻击者可以从内存中可靠的读取和写入,包括代码和数据等,到 hypervisor 和该系统上所有其他的虚拟机中。
在其他虚拟机中执行 shell 命令
在此时,攻击者可以完全控制机器了,相当于拥有了 hypervisor 的特权级,同时通过搜索物理内存可以轻易地获取一些机密信息。但是一个实际主义的攻击者,考虑到更多的被检测到的风险,很可能不会注入代码到虚拟机中。
但是在别的 VM 中运行任意一个 shell 命令更低调一些。所以,我打算继续完善我的exploit 使其可以给所有的其他 PV 虚拟机注入一个 shell 命令。
第一步,我打算在 hypervisor 上下文中,获取可靠的代码执行能力。通过读写物理内存的能力,一个相对 OS (或者 hypervisor )独立的以 kernel/hypervisor 权限调用任意地址的方式是使用非特权指令 SIDT 来定位 IDT 表,同时写入一个 DPL3 的 IDT entry,之后产生中断。Xen 支持 SMEP 和 SMAP,故不可能将 IDT Entry 指到 guest 内存中,但是使用读写页表项的能力,可以映射一个 guest 拥有的,位于 hypervisor上下文的 shellcode 页为 non‐user‐accessible ,这样可以绕过 SMEP。
之后,在 hypervisor 上下文中,可以通过读写 IA32_LSTAR MSR 寄存起来 hook Syscall 入口点。Syscall 入口点,同时适用于来自 guest 用户空间的 syscall 和来自 guest 内核的 hypercall。通过映射一个攻击者控制的页面到 guest‐user‐accessible 内存,改变寄存器状态,调用 sysret,有可能将用户空间的执行转移到任意 guest 用户的 shellcode上,该操作独立于 hypervisor 和 guest 操作系统。
我的 exploit 注入 shellcode 到所有 guest 用户空间每一个调用 write() Syscall 的进程。当 shellcode 运行时,它检查它是否它是否以 root 权限运行,同时是否在 guest 的文件系统不存在 lockfile 文件。如果这些条件被满足,调用 clone() syscall 来创建一个子进程来执行任意的 shell 命令。(注意,我的 exploit 并没有结束自身,故当attacking domain 之后关闭了,hoo k点将导致 hypervisor 崩溃)。
如下是一个 Qube OS3.2 成功攻击的一个截图。该代码实在一个非特权的 domain “test1234”中执行的,截图显示,它成功注入代码到 dom0 和 firewallvm 虚拟机中:
结论
我坚信导致该问题的根本原因是由于 accessok() 的低安全验证。当前版本的 access_ok() 是 2005 年提交的,两年后,发布了 Xen 和 XSA 的第一个版本。而且看起来老的代码比新的代码更可能包含多的相对直接的安全缺陷,因为当时提交时并没有太多考虑安全因素,如此老代码一直沿用至今。
当基于这些设想的安全相关的代码被优化时,一定要留意防止这些设想被利用。 access_ok() 事实上常用来检测是否整个范围和 hypervisor 内存重复,这样将阻止该bug 的产生。不幸的是,2005 年, a commit with "x86_64 fixes/cleanups" 改变了 access_ok() 在 x86_64 上的行为,一直沿用到现在的版本。就我目前认为,唯一没有直接使 MEMOP_increase_revervation 和 MEMOP_decrease_reservation hypercall有漏洞的原因是因为 do_dom_mem_op() 的参数 nr_extents 是 32 位的—-一个相对脆弱的防御。
然而,已经发现了好些 Xen 漏洞是只影响 PV guest 的,因为当处理 HVM guest 时,代码中的那些问题不是必须的,我坚信这个 bug 不是其中的一个。对于 PV guest 来说访问 guest 虚拟内存比 HVM guest 更直接:对于 PVguest,raw_copy_from_guest 调用 copy_from_user() ,只是简单的做了一次边界判断,之后便是有内存页 fixup 的一个memcpy,和正常操作系统执行用户态空间内存检查一致。对于 HVM guest, raw_copy_from_guest() 调用 copy_from_user_hvm() ,会做一个遍历 guest 页表的page-wise 的拷贝(因为内存区域可能物理上是不连续的,同时 hypervisor 并没有一个连续的虚拟映射),同时 guest frame 查找是对每一个页的,包括引用,映射 guest页到 hypervisor 内存和比如阻止 HVM guest 写只读页的各种检查等。故对于 HVM,处理 guest memory 的复杂性要高于 PV。
对于安全研究员来说,我认为了解正常内核调用后来理解半虚拟化不是很难。如果你审计过内核代码,其实 hypercall entry(lstar_enter and int80_direct_trap in xen/arch/x86/x86_64/entry.S) ,基本的 hypercall(for x86 PV: listed in the pv_hypercall_table in xen/arch/x86/pv/hypercall.c) 设计处理和正常的
系统调用看起来差不多。
本文由 看雪翻译小组 ghostway 编译,来源 Jann Horn, Project Zero