Unicorn Engine 内存虚拟化浅析

Unicorn Engine 由 QEMU 裁剪而来,可以说是 QEMU 的分支,但是又是一个完全独立的项目。Unicorn 提供了丰富的 API,让我们可以很方便的体验 QEMU 虚拟化技术带来的诸多特性,但是同时也面临一些安全问题。我们以分析 CVE-2022-29694 为例,简要概述 Unicorn 内存虚拟化的原理。这里只做一些浅显的分析,让大家快速掌握 QEMU 虚拟化的全貌。
基础结构体
我们首先介绍 QEMU 中的两个重要的结构体,即 Memory Region 和 RAM Block。你也可以先看后面章节的具体代码,再回过头来看他们中的每个域成员的作用,以达到更好的理解效果。
Memory Region
MemoryRegion 是 QEMU 内存管理中一个重要的结构体,是管理 GVA(Guest Virtual Address, 客户机即虚拟机中的虚拟空间地址) 每块内存空间的结构体。
struct MemoryRegion {
/* private: */
/* The following fields should fit in a cache line */
bool ram; //
bool subpage;
bool readonly; // 是否是只读
bool is_iommu;
RAMBlock *ram_block; // 实际分配的物理内存
const MemoryRegionOps *ops;
void *opaque;
MemoryRegion *container; // 该MemoryRegion的上一级MemoryRegion
Int128 size;
hwaddr addr;
void (*destructor)(MemoryRegion *mr);
uint64_t align;
bool terminates;
bool enabled;
int32_t priority;
QTAILQ_HEAD(, MemoryRegion) subregions;
QTAILQ_ENTRY(MemoryRegion) subregions_link;
struct uc_struct *uc;
uint32_t perms;
hwaddr end;
};
RAM Block
RAM Block 表示虚拟机中的一块内存条,即 GPA(Guest Physical Address, 虚拟机物理地址)。所有的 RAMBlock 会通过 next 域连接到链表中,链表头是 ram_list.blocks。
struct RAMBlock {
struct MemoryRegion *mr; // GPA->GVA,所属的MR
uint8_t *host; // GPA->HVA
ram_addr_t offset; // 内存条在整个虚拟机中的偏移地址
ram_addr_t used_length; // 已经使用的大小
ram_addr_t max_length;
uint32_t flags;
/* RCU-enabled, writes protected by the ramlist lock */
QLIST_ENTRY(RAMBlock) next;
size_t page_size; // 系统页面大小
};
内存申请入口
uc_mem_map 是 Unicorn 提供给用户的接口,我们可以使用这个函数为模拟器申请一块内存空间,这个空间既可以存放将要模拟的指令,也可以用作虚拟机的堆栈。
uc_err uc_mem_map(uc_engine *uc, uint64_t address, size_t size, uint32_t perms)
{
uc_err res;
/* 初始化unicorn引擎 */
UC_INIT(uc);
/* 只在MIPS定义,假设我们要申请的内存超过一定范围
* 自动重新申请符合系统要求的内存块
*/
if (uc->mem_redirect) {
address = uc->mem_redirect(address);
}
/* 用于内存安全检查,检查传入的地址范围 */
res = mem_map_check(uc, address, size, perms);
if (res) {
return res;
}
/* mem_map 函数将QEMU申请的内存单独存入Unicorn提供的mapped_blocks
* memory_map 利用QEMU的能力,申请内存(GVA)
*/
return mem_map(uc, address, size, perms,
uc->memory_map(uc, address, size, perms));
}
申请 Memory Region
继续分析 memory_map,此函数定义在 qemu/softmmu/memory.c 文件中,用于正式申请 Memory Region。从函数 memory_region_add_subregion 可以看出来,Unicorn 使用 uc->system_memory 作为一个管理所有 Region 的父集。
代码
MemoryRegion *memory_map(struct uc_struct *uc, hwaddr begin, size_t size, uint32_t perms)
{
/* ram是待分配的 */
MemoryRegion *ram = g_new(MemoryRegion, 1);
/* 分配虚拟机RAM */
memory_region_init_ram(uc, ram, size, perms);
if (ram->addr == -1) {
// out of memory
return NULL;
}
/* 将一个MemoryRegion添加到另外一个MemoryRegion,使其成为另外一个MemoryRegion
* 的子集
*/
memory_region_add_subregion(uc->system_memory, begin, ram);
if (uc->cpu) {
tlb_flush(uc->cpu);
}
return ram;
}
GVA(Guest Virtual Address,即虚拟机中的虚拟地址空间)。memory_region_init_ram 是虚拟机申请 RAM 的一个重要函数。
void memory_region_init_ram(struct uc_struct *uc,
MemoryRegion *mr,
uint64_t size,
uint32_t perms)
{
/* 初始化申请的MR */
memory_region_init(uc, mr, size);
mr->ram = true;
if (!(perms & UC_PROT_WRITE)) {
mr->readonly = true;
}
mr->perms = perms; // 权限
mr->terminates = true;
mr->destructor = memory_region_destructor_ram;
/* 重要!用于申请GPA和HVA */
mr->ram_block = qemu_ram_alloc(uc, size, mr);
}
函数 qemu_ram_alloc 是 qemu_ram_alloc_from_ptr的封装,是用来申请 RAMBlock 的地方。
申请 RAM Block
RAMBlock 初始化如下
RAMBlock *qemu_ram_alloc_from_ptr(struct uc_struct *uc, ram_addr_t size, void *host,
MemoryRegion *mr)
{
RAMBlock *new_block;
ram_addr_t max_size = size;
size = HOST_PAGE_ALIGN(uc, size);
max_size = HOST_PAGE_ALIGN(uc, max_size);
new_block = g_malloc0(sizeof(*new_block));
if (new_block == NULL)
return NULL;
new_block->mr = mr;
new_block->used_length = size;
new_block->max_length = max_size;
assert(max_size >= size);
new_block->page_size = uc->qemu_real_host_page_size;
new_block->host = host;
if (host) {
new_block->flags |= RAM_PREALLOC;
}
ram_block_add(mr->uc, new_block);
return new_block;
}
函数 ram_block_add 用于将一根内存条添加到系统中,此函数首先申请 PVA,再将 RAMBlock 添加到系统空间中。
非一致性结果导致的空指针
QEMU 虚拟机申请内存,最终还是需要在宿主机上申请相应的内存。那么问题来了,当虚拟机申请一块超大内存时,实际申请必然失败。而下面的函数没有返回申请结果的状态,当前这个 RAMBlock 也没有被添加到 ram_list.blocks
上层函数并没有判断 HVA 申请是否成功,而虚拟机默认 GVA 已经申请成功。在关闭虚拟机的时候,触发 QEMU Memory Free,这时候这个 Block 其实还不是一个 QLIST,导致空指针解引用。
如下所示,虚拟机申请大内存时,GVA 申请成功,但是 GPA->HVA 使用 mmap 函数申请失败。未同步申请的内存导致空指针。
修复方案
第一种修复方式比较简单,直接使用 QEMU 提供的 QLIST 链表的安全删除方法,即 QLIST_SAFE_REMOVE
第二种修复方案似乎更为合理,因为这个问题的本质其实是 GVA 与 GPA 申请结果的判断不一致导致的,因此要修改逻辑,添加内存申请一致性判断。
总结
本文只是初步分析了 Unicorn Engine 内存虚拟化的过程,介绍了 QEMU 为虚拟机申请内存的大致方案。QEMU 内存虚拟化是一个复杂的过程,背后更多的机制有待后续进一步研究。
Violent Binary