第五届强网杯的虚拟化系列题目,EzQtest 2解477分。这题赛后第二天才完成。承接EzCloud,有意思的才刚刚开始。
整个学期都在搞毕业论文,答辩结束后能解决这样一道大题,有种在水下憋气很久,突然能浮出水面大口呼吸的畅快感觉。
Recon
回顾上一问的post handler,在伪造登录信息以后可以使用vm系列操作,其中createvm
会调用nc 127.0.0.1 6666
并建立管道,之后通过connetvm
向管道读写命令。而6666端口是附件中launch.sh
启动的qemu服务。
1 | unsigned __int64 __fastcall post_handler(request *a1) |
launsh.sh
启动的qemu服务和以前见过的都不同,这里启动了一个qtest命令行,提供了内存读写相关的命令,具体可以参考QTest Device Emulation Testing Framework。
1 | ./qemu-system-x86_64 -display none -machine accel=qtest -m 512M -device qwb -nodefaults -monitor none -qtest stdio |
用apt安装缺少的依赖库之后就可以进入qtest的命令行:
1 | root@matthew-Virtual-Machine:/pwn# ./launch.sh |
为了验证远程的逻辑是否和我们设想的一致,我先写了exp中的交互接口
1 | def createvm(login_id): |
本地启动时用ncat将stdio转发到6666端口,但是测试发现和远程的返回内容不一致。后来发现是ncat没有将stderr也转发到tcp端口,之后找到一条socat转发的命令,可以将stdout和stderr同时转发到tcp端口。
1 | !/bin/sh |
完成配置以后就能够通过EzCloud的binary和qemu服务正常交互了。
QEMU设备逆向
在启动命令的提示下,很容易就能在qemu-system-x86_64
里面找到qwb设备的具体实现,qwb设备设置了一个mmio接口。
1 | void __cdecl pci_qwb_realize(PCIDevice_0 *pdev, Error_0 **errp) |
qwb_mmio_read
和qwb_mmio_write
部分switch语句ida分析不出来,逻辑不复杂直接看汇编代码也能看懂。但是这里推荐一波binary ninja,反编译功能越来越强了。
1 | uint64_t qwb_mmio_read(struct qwb_state* arg1, int64_t arg2, int32_t arg3) |
1 | struct qwb_state* qwb_mmio_write(struct qwb_state* arg1, int64_t arg2, struct qwb_state* arg3, int32_t arg4) |
主要结构体如下:
1 | struct QWBState |
逻辑还是比较简单的,我们可以通过mmio设置dma_info的src,dst,cnt,cmd(读写方向),然后触发qwb_do_dma
完成dma的读写操作。问题就是出在qwb_do_dma
函数中。这里的问题是先检查所有dma_info的越界再进行操作,而检查的循环逻辑也不严谨,可以导致利用前面dma_info来改写后面的dma_info,到执行后面dma_info的时候就可以完成越界读写操作。
1 | void __cdecl qwb_do_dma(QWBState_0 *opaque) |
例如我们可以将idx=0的dma_info设置为:{ src = 0x40000, dst = -0x20, cnt = 0x20, cmd = 1 }
并且在虚拟机0x40000
地址上布局好一个越界读写的dma_info, 这里可以随意填写src和cnt构造任意读:{ src = -0x2000, dst = 0x41000, cnt = 0x1000, cmd = 0 }
任意写也同理。这样在执行完idx=0的dma_info之后,idx=31的dma_info就会填上0x41000处布局好的dma_info。按照这样的原理可以进行任意地址读写操作。
DMA逻辑
为了方便读者理解题目DMA操作的逻辑,这里补充一张图。
首先我们可以通过qtest命令行对Guest的内存地址进行任意读写。在设备被正常初始化的情况下,Guest内存会分配出一段专门处理mmio的内存,通过对mmio段操作我们可以设置QWBState
中的dma_info信息以及触发qwb_do_dma
操作。qwb_do_dma
是Guest和Host通信的管道,本意是提供对dma_buf
区域内存的读写功能。但是由于上面提到的漏洞,我们可以实现对Host(qemu-system-x86_64
进程)的任意地址读写,进而执行任意命令。
1 | ┌──────────────────────┐ ┌──────────────────────┐ |
PCI设备初始化
理清楚题目的目标和运行逻辑以后,接下来最重要的问题就是找到mmio的地址。
之前QEMU逃逸类型的题目都是在Linux系统下进行操作,mmio可以通过类似/sys/devices/pci0000:00/0000:00:04.0/resource1
的路径来操作,但是在qtest命令行下要怎么操作呢。
第一个想法是以为mmio地址是qemu给我们分配好的,我们只需要通过命令找到正确的地址即可。于是我查找到了关于qemu monitor的用法,将launch.sh修改为:
1 | ./qemu-system-x86_64 -display none -machine accel=qtest -m 512M -device qwb -nodefaults -monitor telnet:127.0.0.1:4444,server,nowait -qtest stdio |
这样qemu启动以后会开启4444端口到monitor,我们可以用nc连接上4444端口对qemu进行管理操作。
1 | root@matthew-Virtual-Machine:/pwn# nc 127.0.0.1 4444 |
可以看到device对应的应该就是qwb设备(观察device id可知),但是这里看不出来我们想要的mmio地址是什么;还可以试试info qtree
命令。
1 | (qemu) info qtree |
这里直接列出了设备的名字“qwb”,但是还是没有找到mmio地址,尝试向0xffffe
附近进行读写操作也无法触发mmio操作。
后来找到了一篇非常重要的文档 https://github.com/GiantVM/doc/blob/master/pci.md。根据文档中总结,初始化PCI设备有以下流程:
- 在 do_pci_register_device 中分配内存,对config内容进行设置,如 pci_config_set_vendor_id
- 在 pci_e1000_realize 中继续设置config,包括 pci_register_bar 中将BAR base address设置为全f
- 由于有ROM(efi-e1000.rom),于是调用 pci_add_option_rom ,注册 PCI_ROM_SLOT 为BAR6
- pci_do_device_reset (调用链前面提过) 进行清理和设置
- KVM_EXIT_IO QEMU => KVM => VM 后,当VM运行port I/O指令访问config信息时,发生VMExit,VM => KVM => QEMU,QEMU根据 exit_reason 得知原因是 KVM_EXIT_IO ,于是从 cpu->kvm_run 中取出 io 信息,最终调用pci_default_read_config
- 设置完config后,在Linux完成了了对设备的初始化后,就可以进行通信了。当VM对映射的内存区域进行访问时,发生VMExit,VM => KVM => QEMU,QEMU根据 exit_reason 得知原因是 KVM_EXIT_MMIO ,于是从 cpu->kvm_run 中取出 mmio 信息,最终调用e1000_mmio_write
根据这个流程,结合断点调试,看来我们的qemu启动只进行到了第4步。要正确完成设备配置还得往BAR写MMIO的地址。文档中还提到q35设备将CONFIG读写操作绑定到了0xcf8和0xcfc的端口。
1 |
|
在另一份文档:QEMU如何虚拟PCI设备 中也提到了i440fx的类似操作:
1 | static void i440fx_pcihost_realize(DeviceState *dev, Error **errp) |
但是在我们的qwb设备中并没有进行绑定端口的操作,那要如何对BAR进行配置呢?到比赛结束时我仍卡在这个问题,始终无法找到mmio地址。
比赛后第二天,我静下心来重新阅读上面的参考文档,终于理解了初始化逻辑。我们的qwb其实是挂在i440fx-pcihost下面的设备,从info qtree里面可以观察到,这里是一个类似于子类的概念。设备初始化阶段会沿用父类i44fx绑定的端口,其实从第一份文档中的q35机器和e1000网卡关系类别一下这里的i44fx和qwb设备的关系就容易理解了。
总结一下初始化需要操作的步骤:
- 将MMIO地址写入qwb设备的BAR0地址
- 通过0xcf8端口设置目标地址
- 通过0xcfc端口写值
- 将命令写入qwb设备的COMMAND地址,触发pci_update_mappings
- 通过0xcf8端口设置目标地址
- 通过0xcfc端口写值
首先我们需要知道qwb设备的地址,根据文档二中的图,我们qwb设备的Bus number为0,Device number为2,Function number为0,得出qwb的地址为0x80001000
。
再结合设备中寄存器映射图,可以看到BAR0的偏移为0x10,COMMAND的偏移为4。
然后我们需要解决写什么值的问题。MMIO地址我们可以直接拿文档一中的地址0xfebc0000
。而COMMAND值的设置就另有说法了,文档二中给出了COMMAND的比特位定义:
而两份文档的说法都是选择0x103
,即设置SERR,Memory space和IO space。然而实际发现要把bit 2也设置上才能正确使用dma,所以最后需要写入的COMMAND为0x107
。
所以最后初始化阶段需要执行的命令如下:
outl 0xcf8 0x80001010
outl 0xcfc 0xfebc0000
outl 0xcf8 0x80001004
outw 0xcfc 0x107
执行上述命令之后观察pci设备可以看到BAR0已经设置上了0xfeb00000
,对该地址进行读写能正确触发MMIO handler的断点。
1 | (qemu) info pci |
Exploit
解决MMIO地址问题以后就可以通过DMA操作对qemu内存进行任意地址读写了,具体方法上面已经介绍过。Exploit流程如下:
- 在
QWBState->pdev
中泄露CODE段地址和QWBState地址 - 通过读GOT表泄露libc地址
- 往
QWBState+0x460
写入gadget地址,覆盖pci_default_read_config
指针 inw 3324
操作触发read config动作,控pc跳到gadget。
控PC之后如何get shell又是一个令人头大的问题,这里想了很多方法。
Plan A
Plan A是直接将QWBState+0x460
写入system@plt
地址,然后QWBState+0
写入/bin/sh
地址,改写掉QWBState+0
指针后,执行read config操作时无法找到bus导致流程中断。
Plan B
Plan B是找一个gadget能够通过[rax+off]
或[rdi+off]
设置好rdi,然后call一个可控指针,在libc中找到以下的gadget。
1 | 0x000000001628e5: mov rdi, [rdi+0x8]; push 0x0; lea rcx, [rsi+0x398]; push 0x0; call qword ptr [rax+0x1e0]; |
这里将QWBState+8
设置为/bin/sh
地址,将QWBState+0x1e0
设置为system@plt
地址,将QWBState+0x460
写入这条libc+0x1628e5
gadget。
如此可以正确触发system("/bin/sh")
,问题是调用之后再system流程里面还是报错,卡在movaps xmmword ptr [rsp+0x50],xmm0
的地方。如果是rop的话可以通过添加一条ret
改变rsp地址来解决问题,但是这里没法改变rsp地址,好像也找不到类似的gadget。
Plan C
利用setcontext gadget。但是libc 2.31版本setcontext改成了rdx来布局,从函数头部来做gadget也是会报错。
Plan D
在qemu-system-x86_64里面找到一条有意思的gadget: 0x3d2f05:lea rdi, "/bin/sh"; call execv
。
配合libc里面的另一条gadget把可以把rsi设置为0:0x0000000014bd1e: mov rsi, [rbx+0x10]; mov rdx, r12; mov rdi, r14; call qword ptr [rax+0x20];
用这两条gadget能够调用execv("/bin/sh", 0)
,成功get shell了。
exp.py
1 | from pwn import * |