目录

Unicorn Engine 内存虚拟化浅析

Unicorn Engine 由 QEMU 裁剪而来,可以说是 QEMU 的分支,但是又是一个完全独立的项目。Unicorn 提供了丰富的 API,让我们可以很方便的体验 QEMU 虚拟化技术带来的诸多特性,但是同时也面临一些安全问题。我们以分析 CVE-2022-29694 为例,简要概述 Unicorn 内存虚拟化的原理。这里只做一些浅显的分析,让大家快速掌握 QEMU 虚拟化的全貌。

基础结构体

我们首先介绍 QEMU 中的两个重要的结构体,即 Memory Region 和 RAM Block。你也可以先看后面章节的具体代码,再回过头来看他们中的每个域成员的作用,以达到更好的理解效果。

../../../../images/2-null-pointer/MemoryManager.png
QEMU 内存管理架构

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 的父集。

../../../../images/2-null-pointer/MemoryRegion.png
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_allocqemu_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

../../../../images/2-null-pointer/CVE-2022.png
为虚拟机分配物理内存

上层函数并没有判断 HVA 申请是否成功,而虚拟机默认 GVA 已经申请成功。在关闭虚拟机的时候,触发 QEMU Memory Free,这时候这个 Block 其实还不是一个 QLIST,导致空指针解引用。

../../../../images/2-null-pointer/CVE-2022-1.png
释放虚拟机内存时出现空指针

如下所示,虚拟机申请大内存时,GVA 申请成功,但是 GPA->HVA 使用 mmap 函数申请失败。未同步申请的内存导致空指针。

../../../../images/2-null-pointer/CVE-2022-2.png
漏洞产生的原因

修复方案

第一种修复方式比较简单,直接使用 QEMU 提供的 QLIST 链表的安全删除方法,即 QLIST_SAFE_REMOVE

../../../../images/2-null-pointer/fix-1.png
修复方案一

第二种修复方案似乎更为合理,因为这个问题的本质其实是 GVA 与 GPA 申请结果的判断不一致导致的,因此要修改逻辑,添加内存申请一致性判断。

../../../../images/2-null-pointer/fix-2.png
修复方案二

总结

本文只是初步分析了 Unicorn Engine 内存虚拟化的过程,介绍了 QEMU 为虚拟机申请内存的大致方案。QEMU 内存虚拟化是一个复杂的过程,背后更多的机制有待后续进一步研究。