渗透Xen hypervisor
陈俊誉
2023-12-01
作者微信:15013593099 欢迎交流
渗透Xen hypervisor
作者:fahrenheit 摘自:黑防
引言
BLUEPILL和Vitriol都是目前比较流行的软件项目,可以在运行时给系统安装上恶意的hypervisor(系统管理程序)。原本系统是 纯净的,什么都没有,但是这些软件可以即时的向系统中插入一段恶意代码,不需要重启就可以取得操作系统的控制权。很多安全分析人员都预言,在不久的将来, 很多系统都将默认的构建在一些hypervisor之上,这给攻击者带来了很多机会,但同时也是挑战。
如果渗透进入合法的hypervisor,修改了其代码,也可以达到BLUEPILL和Vitriol相类似的效果。不过还有优点的是,在合法的 hypervisor中无需再像恶意的hypervisor一样隐藏所有的副作用痕迹,它们都可以归为合法hypervisor正常执行的操作范围之内。 除此之外,合法系统几乎提供了所有的底层功能,减轻了攻击者的代码劳动负担,直接使用即可。
在某些体系结构下,hypervisor实行了自我保护,防止运行时外界对自身代码的篡改,即使攻击者获得了OS的最高特权,也不见得行得通。例如 Xen的例子,即使在dom0域下获得了管理员权限,也无法于运行时修改其代码。
在本文中,我们将向大家展示如何通过DMA传输来修改Xen 3.x hypervisor的具体方法,随后还将向其中插入一个后门程序。这里有一个通用的应用程序框架,可以将编译后的C语言代码载入到Xen当中。文中用到 的所有代码都放在了本文的附件里,最后我们还将对两个后门分别作出评估,并给出一些检测的建议。
Xen 3.x体系简介
Xen 3.x的体系结构在Xen源代码包中有详细的文档说明,想要全面的了解Xen还是建议大家仔细阅读该类材料,在本节中我们将只提及和本文相关的Xen内部 细节,其结构简图如图1所示。本文涉及的操作系统为dom0域的Linux,覆盖了x86_32和x86_ 64两种体系结构。
Xen的hypervisor在启动时直接由启动加载程序(bootloader)载入内存,它不像VMWare Workstation,不需要等操作系统启动之后再来激活。hypervisor是唯一在ring 0运行的代码,初始化之后,他启动第一个虚拟机VM,为dom0。相比之下,每一个VM的内核都是运行于较低的特权级,x86_32下为ring 1,x86_ 64则为ring 3,因此VM现在是完全置于hypervisor的掌握和管理之中。Dom0域的操作系统(通常为Linux,也可以是NetBSD和 OpenSolaris)比较特殊,不像其他域的,可以直接和Xen通信,请求执行管理员行为,例如创建,启动或者关闭其他的VM等等。而且dom0下还 允许直接访问计算机的硬件设备,为其他非特权域的VM提供间接访问受限设备的服务。Xen这样的设计允许我们将恶意驱动程序放置在dom0域的OS中,而 不用移植到Xen里。
dom0启动之后,才可以请求Xen去启动其他的VM。目前有两种类型的VM:
完全虚拟化的(Fully virtualized以及泛虚拟化的(Paravirtualized)。
用DMA控制Xen
A. 开始之前
我们假设攻击者已经在dom0取得了root权限,在历史上,曾经有CVE-2007-4993和CVE-2007-5497两个漏洞,可以从其他 非特权域进入dom0。
如果攻击者想要在Xen代码中植入后门,他需要获得修改Xen内存的能力。可以修改启动加载程序,那启动之后,带有后门的Xen就可以开始工作了, 不过这个方法又那么几个缺点:
hypervisor的重启是一个发生入侵的明显信号。
修改启动过程的方法有限,在使用带有写保护的启动介质,或者Intel的可行执行技术(Intel Trusted Execution Technology)的硬件时不好实施。
由此看来,攻击者最好还是在运行时再来修改Xen的内存。我们研究Xen的文档后发现,其中有明显且特别强调的一点——Xen未对DMA传输做任何限制。 因此任何具备DMA能力的设备都可以覆盖和写入到系统的任意内存地址,即便是Xen的内存空间也不在话下。
我们提到具备DMA能力的设备,在某些操作系统下面实施上述方法可能会遇到问题。例如Minix3等微内核的操作系统,将设备驱动和特权代码分隔开 来,不具备特权的代码是不能修改系统内存的。Luic Duflot在其《Programmed I/O accesses: a threat to Virtual Machines ?》一文中曾讨论过类似的问题,他演示的是通过配置AGP GART或USB子系统来修改任意的物理内存。然而我们在此采用的是另外的不同途径,不使用那些特别的,编程简单,但是可能会被管理员禁用的设备,转而使 用一些通用的计算机硬件——网卡或者硬盘。而且我们也不会陷在DMA传输调度的编程细节当中,利用设备驱动等内核支持代码完成大部分的操作任务。
B. 网络接口卡NIC的例子
网卡具有很多优势特性,网络不可靠时,OS可以丢弃掉所有发送到此网卡的数据包,损失只发生在传输介质上。而且通常我们都不应该影响系统网络处理的 高层结构,如ip和路由等等。从某种程度上看,NIC好像并不适合于我们的预定任务,它使用DMA在RAM和内部缓冲区之间来回拷贝数据,后者我们是不可 直接访问的。所以,我们目前能做到的只是将数据从内存拷贝到网络介质,反过来则不太可行。
然而我们相信几乎所有的网卡都支持回环loopback模式。在该模式下,传输操作不会将数据帧拷贝到线路,而是复制到内部的缓冲区里,然后帧再转 发回来。这也就意味着,我们可以访问任意的内存地址X了。首先,我们让网卡停止处理数据帧队列(使用netif_tx_disable()内核函数),接 着设置NIC的工作模式为回环和混杂(promiscuous)模式,这样NIC将不再检查收到数据帧的MAC地址。然后可以执行下面的操作:
读取:设置一个将传输环入口项(transmit ring entry),将数据指针指向X,接收环入口项(receive ring entry),将数据指针指向存放读内容取的缓冲区。
写入:设置一个将传输环入口项(transmit ring entry),将数据指针指向我们的数据,接收环入口项(receive ring entry),将数据指针指向X。
C. 硬盘驱动器的例子
现在安装一个DMA传输设备,需要由上层向设备驱动程序传送一个缓冲区的物理地址。在HDD驱动的例子中,我们使用dma_map_sg()函数。 在某些体系下面,该函数是一个宏定义,而在Xen 3.x dom0 Linux内核中则是真实的函数实现,并由内核导出。
基本的思路是,我们钩挂dma_map_sg()函数之后,准备由它报告攻击者选定的物理地址X。接着用write(somefd, userbuffer, len)系统调用引起从地址X到磁盘的DMA传输过程。现在还有一个问题,我们不能绑定的,硬性的将所有的传输都导向地址X,其他的进程或者内核程序也有 可能会引起磁盘访问,全部都重定向到X之后,难免导致内存错误,重则文件系统崩溃。因此,我们必须确定对dma_map_sg()的调用是否是在指定的进 程当中,由该进程获取我们userbuffer的物理地址。dma_map_sg()函数的原型及相关数据结构为:
int dma_map_sg(struct device *dev,
struct scatterlist *sg, int nents,
enum dma_data_direction direction);
struct scatterlist {
struct page *page;
unsigned int offset;
dma_addr_t dma_address;
unsigned int length;
};
dma_map_sg()函数应该会用scatterlist 结构page域指向结构中的物理地址完成dma_address域的设置过程,我们期待着write(somefd, userbuffer, len)系统调用时,page中的数据可以被设置为userbuffer所在的page。然而结果并不如我们想象的那样,page域指向了文件系统缓存中 使用过的page结构。在此我们要确定在传输过程中使用到的缓存page条目,这可不是件容易的事情,所以我们只好绕过文件系统缓存,在open()系统 调用设置O_DIRECT标志。那么钩挂后的dma_map_sg()就如下面的代码所示:
call_original_dma_map_sg();
for (i = 0; i < nents; i++)
if (sg.page == page_of_userbuffer)
sg.dma_address = our_phys_addr;
Xen可加载模块框架
A.开始之前
如前所述,借助两个控制器的帮助覆盖Xen的物理内存要相对容易一些,现在,我们可以读取,甚至向其中写入数据了。在本节中,我们将介绍一下这个可 以在Xen中加载C语言代码的应用程序框架,它和Linux的可加载内核模块(loadable kernel modules,LKM)机制有些类似。
B. 重要数据结构及代码的定位
启动加载程序将Xen加载到物理地址0x00100000处,如果是Xen 3.2.0及以后的版本,在x86_64体系结构下,Xen启动代码会将hypervisor重定向到RAM的顶端。Hypervisor页面都是线性映 射到高端的虚拟地址:x86_32(带有PAE,Physical Address Extension,物理地址扩展)下从0xff100000开始,x86_64下则从0xffffffff80200000处开始。此时如果知道了一个 变量或函数的虚拟地址,我们就可以轻松转换得到它的物理地址。
hypercall_table数组中存放着所有hypercall函数的地址,通过int 0x82指令可以调用hypercall表中的一个hypercall函数call * hypercall_table(; eax; 4)。类似的,所有的异常处理函数也都由handle_exception函数来处理,它由call * exception_table(; eax; 4)调用合适的异常处理函数。如果我们能够成功的访问到xen-syms的二进制代码,那取得这些表的地址就只是时间迟早的问题了;或者我们可以用模式匹 配的方法来搜索hypercall_args_table,它拥有固定的数据内容“\x01\x04\x02\x02\x04\x01\x02 \x01”,而且由hypercall_table和exception_table所直接使用,因此也可以间接发现后两者的踪迹。
我们还需要其他一些Xen函数的地址,如果xen-syms二进制代码不可用,可以采用下面的方法:
获取xmalloc和printk函数的地址。 xenoprof_struct()函数中有下面的代码片段:
static int alloc xenoprof struct(...)
{
...
d->xenoprof = xmalloc(struct xenoprof);
if (d->xenoprof == NULL) {
printk( "alloc xenoprof struct(): "
"memory allocation failed nn" );
return ENOMEM;
}
...
}
我们在Xen的二进制代码中搜索这些错误信息,找到对其的引用点后也就找了printk,在printk的调用之前不远的地方我们还可以找到 xmalloc函数的地址。
获取copy_from_user函数的地址。有一些hypercalls,如do_physdev_op_compat,在首部可能会包含 copy_from_guest宏,这个宏又调用了copy_from_user和copy_from_hvm函数。
其他很多重要的数据结构都可以从current_vcpu结构中访问到,它位于ring 0栈的底部,在运行时可以轻易找到。
C. Ring 0中执行代码
为了在Ring 0特权级执行代码,我们可以使用下面的步骤:
使用DMA,覆盖do_ni_hypercall的函数体,其hypercall编号为11,它包含一个“return -ENOSYS”的指令,一般情况下并未被外界使用。我们替换的内容是“call 4(%esp)+ret”或者“call %rdi+ret”,只要用其中之一就好。
调用刚才替换的11号hypercall,以一个待执行的函数地址作为参数传递进来,前者可以放置于客户空间当中。
在这个框架中,我们针对上述实现的在ring 0运行代码的函数是run0(void * fun)。
D. 模块的加载和卸载
即将加载的模块是一个普通的ELF重定位对象(ELF relocatable object),用xenload工具加载模块的步骤如下:
将模块和文件xenlib.o链接在一起,后者含有上述各函数和结构的地址,我们的模块将要用到他们。
取得链接后的ELF文件的大小S。
使用run0函数,内部调用Xen的xmalloc在Xen堆区分配一片大小为S的内存空间,返回其地址A。
重新链接模块,这次使用连接器脚本指明其运行时的基地址为A。
用run0函数,内部调用memcpy 将链接的可执行代码拷贝至地址X。因为连接器脚本指明了“impure”的段布局,我们只要拷贝其可执行内容即可,各段都会被自行对齐。
通过xenload的命令行输入“varname=value”,初始化模块加载位置,使用0填充.bss段。
从链接模块中抽取出得到“init_module”函数函数地址。
通过run0调用“init_module”函数
xenunload工具在模块卸载时调用的是“cleanup_module”函数,为简单起见,并未释放其占用的内存空间。在本文的附件里有一段示例的 模块代码。
调试寄存器后门
A. 目标和假设
配备了上述武器,我们开始着手设计基于Xen的后门,它可以远程访问dom0。其主要特性在于它不需要修改dom0下的任何代码和数据结构,内存扫 描工具将无法探知该后门的存在。后门的隐蔽性体现在以下两个方面:
后门未使用时的检测难度
后门使用时的检测难度
在本节中,我们将焦点关注于前一个事项,当激活以后,后门可以提供任意shell命令的执行能力,取得完全控制权之后并不做隐藏命令的尝试,稍后我们再来 解释其中的原因。调试寄存器后门程序的基本设计思路是这样的:
dom0激活以后,Xen配置调试寄存器DR3和DR7,随后对netif_rx()函数的调用会产生一个调试异常。(netif_rx()函数由 网络接口驱动程序在向网络栈传送接收到的数据包时调用)
后门程序替换了Xen的调试异常处理函数,当替换的处理句柄判断异常来源于netif_rx()函数时,将首先检查收到的网络数据包载荷。如果包中 含有特定的特征数据签名,将从包中抽取出参数,在dom0下执行shell命令。
任何时候当dom0查询或设置调试寄存器,它都不会察觉调试寄存器已被用于后门目的。
在这个设计中有一个技术要点,数据包的分析操作是发生在很低的系统底层,此时Linux防火墙还没有运行,因此后门分析过的数据包有可能在上层被防火墙丢 弃。这样,我们也可以强制的让后门程序丢弃数据包(将控制权返回到kfree_skb即可,而不是netif_rx),上层的网络监控程序将不再有机会看 到我们后门相关的任何网络痕迹。
B. 调试寄存器的处理
对所有的VM来说,Xen都保存了其各自的状态,以便在VM间来回切换时可以恢复回来。需要特别注意的 是,arch.guest_context.debugreg数组保存了调试寄存器的值,当客户机请求Xen设置调试寄存器时,该数组也会随设置做相应的 更新操作。
调试寄存器后门钩挂了do_set_debugreg()的hypercall,当客户机请求Xen设置该类寄存器时就会被调用。当后门在钩挂的 hypercall中检测到客户机准备设置DR3时(该寄存器已为后门所用),设置可以生效,后门变为未激活状态;当它检测到客户将DR3和DR7的 “active”掩码修改为0时,它又会设置DR3和DR7中的相关bit,变为激活状态。该后门还钩挂了arch.schedule_tail函数,而 与do_set_debugreg()对应的do_get_debugreg()函数则不需要钩挂,它返回的是 arch.guest_context.debugreg数组的内容,总是和客户机所期望的保持一致。
C. 在dom0中执行代码
当后门发现一个传递命令的数据包之后,它应该会在dom0中创建一个执行shell命令的新进程。理论上,hypervisor对dom0的地址空 间有着完全的控制权,它可以直接修改dom0的内核数据结构,完成进程创建的任务。然而,创建进程是一项非常复杂的动作,在后门中以此实现进程的创建非常 困难,而且也不太可靠。
我们想了另外一种办法,在载入后门之前用vmalloc、exec等在dom0的地址空间中分配一个“scratch”的页面(用作临时存储目的的 页面)。大多时间,该页面仅包含0值。当后门发现命令数据包以后,将执行命令的自销毁代码(trampoline code)拷贝到该页面,而不是返回到netif_rx函数。此时dom0的EIP指针被设置为指向该页面。执行shell命令之后,自销毁代码在末尾返 回到memset函数,将该页面的数据自行析构。这样,我们后门的执行代码在dom0地址空间中就可以销声匿迹了。
因此,我们是将shell执行的任务从hypervisor转移到了dom0内核。即便如此,要实现此功能仍然比较困难,只是比原来轻松一点。自销 毁代码是在中断上下文中运行的,它必须完成下面的一些动作:
延迟其执行,直到调用导出的内核函数execute_in_process_context,中断结束才可以开始执行。
调用call_usermodehelper族的函数之一,创建一个实际的shell进程。
D. 后门的不足之处
后门在空闲时,扫描dom0内存是发现不了其踪迹的,不过还是有其他的方法可供选择。 或许对调试寄存器的处理并不是完全透明的,在干净的和篡改的系统上还是有微小的差别。由于钩挂的调试处理句柄中作了许多额外的操作,使用简单的时钟 周期分析,就可以发现netif_rx()前面几条指令的执行花去了太多的时间,和正常情况下的作比较就可以发现后门的痕迹。
外部域后门
A. 开始之前
调试寄存器后门可以通过时钟周期分析来检测,这也是它在设计上的一个不足之处:它钩挂的dom0中的函数执行消耗了过多的时间。现在为了克服这个问 题,我们不再使用钩挂,而是使用dom0状态检查(state inspection)来实现一个新的后门程序。
Xen为不同的域提供了对应的API,来检查其域内存的数据和状态。首先我们准备新建一个新的域,将其命名为“lurker”,并在Xen的相关结 构中将其标识为“is_privileged”,使其具有足够的权限以访问其他域中的数据。然后,再将某个特定的magic模式储存到域内存当中。该模式 在域内存中要保存尽可能长的时间,到VM上下文切换过后,我们的lurker域有机会定位到该模式,最终lurker才有可能借助hypervisor的 帮助在dom0中执行一段恶意代码。
我们实现了上述的后门代码,可以在检测到dom0中的sshd进程接收到一个带有magic特征标识的字符串之后,由lurker域修改sshd的 进程栈,以及保存在内核栈中的寄存器值,为shellcode的执行做好准备。不过该方法还是有它的不足的地方,有时dom0中的防火墙会阻止我们对 dom0 sshd的访问。此时只要能够访问到其他非特权域的sshd,我们就能在该域中发送并接收一个远程指令,使用run0原语请求hypervisor在 dom0中执行我们的shell命令。
B. Xen相关的实现细节
外部域后门是针对特定的内核版本实现的一个initrd映像。当启动时,它的初始化进程会执行如下的一系列程序动作:
给内核打补丁,禁用掉检测内核是否运行在初始域中的代码,由hypervisor来完成必要的安全检测操作。
使用run0原语,将当前域设置为“is_privileged”状态。
用一个循环枚举遍历dom0中的进程列表,查找sshd进程。
对每一个sshd进程,逐个检查其运行状态。
为了方便的监控dom0的内存,我们需要一组工具来完成虚拟地址和其对应页面的映射工作,Libxenctrl程序库正好为此而来,提供了如下的API:
抽取外部域中的CR3寄存器。
将页框(frame,亦称为页帧)映射到其虚拟地址。
使用上述两类API遍历页表,定位特定的虚拟地址。
当我们访问用户内存空间,而且该进程还没有被调度运行时,在CR3中是不能正确得到当前进程的页表地址的。应该从存储在 task->mm->pgd的进程Page Global Directory中获取正确的页表地址。一旦可以操纵sshd的内存,我们就准备执行后面的动作:
抽取客户定义的特征识别字符串。
如果特征字符串被检测到,那插入并执行shellcode。
我们将在下一届中讲述sshd的修改细节。
C. sshd变形
响应接收到的特征字符串的代码在各不同版本的sshd中看起来都差不多,如下所示,位于 sshd_exchange_identification()函数内部:
/* the ”buf” array is on the stack */
for (i = 0; i < sizeof(buf) 1; i++) {
if (atomicio(read, sock_in, &buf, 1) != 1) {
/* log error */
cleanup_exit(255);
}
/* handle nr */
if (buf == ’ nn’ ) {
buf = 0;
break;
}
}
循环每执行一次,都用read系统调用从外部读入特征字符串的一个字节,并保存在栈中。因此,在x86_32下为了检测特征字符串是否已经全部收 到,需要采用下面的检测步骤:
遍历进程列表,按进程名搜索sshd进程。
对每个sshd进程,检查其内核栈(由task->thread.esp0指出),查找到system_call的栈帧,在 system_call+7的位置获取保存的寄存器的值(由Linux下的栈帧布局而得)。
如果保存的EAX的值不是read系统调用的编号,那就退出。
如果保存的EBX的值不是一个文件描述符(file descriptor)编号,或者ECX并未指向栈,再或者EDX的值不为1,那么退出。
假设特征字符串的长度为N,那么从保存的ECX-N处开始拷贝N字节的数据到临时缓冲区,检测其中是否含有特征字符串。
现在只剩下最后一个问题了,该如何插入shellcode。原来我们可能会采用如下的几个方法,但在当今的Linux版本中,这些都行不通了。
将shellcode拷贝到进程栈或进程堆中,再将system_call栈帧中存储的eip设置为指向shellcode的位置。现在的栈和堆都 是不可执行的了。
将shellcode拷贝到一片映射后的可执行内存区。因为二进制代码都是共享映射的,对一个地方的修改会影响到所有的sshd进程。
在栈中设置一个return-into-libc,返回到有漏洞的libc程序库函数的载荷。这需要知道libc程序库的具体版本,还要知道利用函 数的具体地址,而后者通常都是随机变化的。
将shellcode拷贝到VDSO(Virtual Dynamic Share Object,虚拟动态共享对象)区域。该区域不仅有空间大小的限制,而且同样是为所有进程所共享的。
在此,有一种解决方案是直接修改进程的页表,让栈变成可执行的区域。但是当前我们采用的是另外的方法,主要步骤如下:
修改保存的eip指针,让其指向VDSO中sys_sigreturn的代码。
设置一个sigreturn的调用栈帧,并设置帧中的寄存器值(eip= kernel_vsyscall, eax=NR_mprotect),以便在sys_sigreturn返回之后能调用sys_mprotect,将栈中的一部分区域设置为可执行的。
将shellcode拷贝到标记为可执行的栈区域,并将栈顶的返回值指向shellcode。那么sys_mprotect返回之后,将跳转到 shellcode继续执行。
D. 检测以及可能的改进措施
在lurker域中进行的特征串扫描操作,它消耗的CPU时间都属于lurker域,对dom0几乎可以忽略不计,当然它还要消耗一些必要的内存空 间,但顶多就几个MB,也可以忽略不计了。
我们在单独的域中执行特征串的扫描过程,以后还可以把它放到hypervisor中,钩挂一些周期性调用的功能函数,例如Xen的调度程序等等。为 了避免去映射用户空间的麻烦,我们最好还是只搜索内核中的数据结构,IP分片(fragment)队列正好可以满足此目的。在我们的Linux中,每一个 IP分片都要在对列中保存至少30秒钟,我们有足够的时间检测其中的特征字符串。如果检测到,就可以像调试寄存器后门那样直接执行shell命令。而且, 我们还可以伪造分片的源IP地址,绕过上层防火墙的检测。然而Linux各内核版本间对IP队列哈希表的实现也不尽相同,处理起来程序的移植性可能不会很 好。
结束语
如前所述,当前实现的后门并没有隐藏shell命令的执行,其实要做到隐藏也真的不是件轻松容易的事情。假设我们通过int 0x80劫持了dom0 Linux系统调用的分配处理,那只能向用户空间隐藏后门的行为,对内核空间还是无济于事。
当前的框架和HDD的模块都可以在x86_32和x86_64两种体系下正常工作,后门程序仅对应x86_32,但移植到x86_64也只是时间问 题。我们先前是讨论了通过DMA控制hypervisor的代码,如果hypervisor中有缓冲区溢出漏洞的话,就此也能取得hypervisor的 控制权。而且,如果所述的漏洞可以从非特权域中访问,那我们还可以直接从domU提升到ring 0。其他的hypervisor,例如Hyper-V也可以作为hypervisor安全研究的典型范例。在后续的研究中,我们准备将焦点更多的集中于 hypervisor后门程序的检测上面。