Skip to main content

Command Palette

Search for a command to run...

记一次从零设计 Vmi 代码注入引擎:劫持、执行、清理

Updated
4 min read

0.摘要

在虚拟化安全的研究中,如何在尽量不修改 Guest 内核的前提下执行自定义代码,一直是一个有趣且充满挑战的话题。本文尝试分享一套基于 Hypervisor 的完整代码注入思路,涵盖了从劫持 Guest 执行流基于 EPT 的透明注入双向通信协议生命周期管理的全过程。

仓库位置:[mini-int3-injector].(https://github.com/ania0-art/mini-int3-injector).


1. 引言:一点背景与思考

1.1 传统方案遇到的一些挑战

在过往的虚拟化安全工作中,如果想要在 Guest 内核中执行自定义代码(比如监控或加固),我们通常会面临一些棘手的选择困难:

  • 内核模块:虽然功能强大,但在生产环境部署往往受到签名限制,容易被 rmmod 卸载,而且需要重启 Guest,这对业务往往是不可接受的。

  • eBPF:非常火热且安全,但受限于指令集,无法调用任意内核函数,且难以分配堆内存,处理复杂逻辑时稍显吃力。

  • Kprobe:灵活但可见性较高,攻击者可以通过 kprobes_all_disarmed 轻松禁用。

1.2 我们的目标

我们试图设计一套方案,它需要满足:Guest 里的用户看不见(透明)、关不掉(不可禁用),同时还能像写普通 C 代码一样灵活。

我们设计的架构大致如下:

┌───────────────────────────────────────────────────┐
│ Hypervisor 层 (管理者)                            │
│  • 注入管理器:负责编译 Payload、分配内存、安装 Hook  │
│  • 执行引擎:负责劫持 RIP、处理 INT3 回调            │
└───────────────────────────────────────────────────┘
                    ↕ EPT Violation
┌───────────────────────────────────────────────────┐
│ Guest 内核                                        │
│  • Syscall Entry → ... STI → INT3 (隐形 Hook) ...  │
│  • Injected Payload [Header][Data][Code]          │
└───────────────────────────────────────────────────┘

2. 技术选型:我们的一点取舍

为什么倾向于 INT3?

在通信机制上,我们放弃了常见的 VMCALL,而是选择了 INT3。虽然这看起来有点“非主流”,但通过对比可以发现它的独特优势:

特性INT3VMCALL
兼容性✅ 通吃所有 x86 架构⚠️ 通常需要虚拟化扩展支持
隐蔽性✅ 普通单字节指令 (0xCC)❌ 显式的 Hypercall,特征明显
寄存器占用✅ 不占用 RAX❌ 往往占用 RAX 传递调用号

最关键的是,INT3 不占用 RAX,这让我们能够用 RAX 来传递“魔数 (Magic Number)”以区分不同的回调类型。


3. 核心技术详解

3.1 尝试“隐形”:基于 EPT 的 Hook 实现

如何在代码段插入 INT3 却不被 发现?我们利用了 EPT 的“双视图”特性:让 Guest 读的时候看原始页,执行的时候走影子页。 以下是我们实现该逻辑的核心伪代码:

// 数据结构:隐形补丁上下文
struct stealth_hook {
    uint64_t    target_addr;        // 目标指令地址
    uint8_t     original_bytes[16]; // 原始指令
    uint8_t     original_len;       // 原始长度
    void* ept_entry;          // EPT 表项句柄
    bool        installed;          // 是否已安装
}

// 安装隐形补丁的逻辑
function install_stealth_hook(hook, target_addr) {
    // 1. 保存原始指令,防止破坏逻辑
    hook.target_addr = target_addr
    guest_read_memory(target_addr, hook.original_bytes, 16)
    hook.original_len = disasm_get_instruction_length(hook.original_bytes)

    // 2. 分配影子页(Shadow Page)
    shadow_page = allocate_physical_page()

    // 3. 复制整页内容到影子页(先保证内容一致)
    original_page = get_guest_physical_page(target_addr)
    copy_page(shadow_page, original_page)

    // 4. 在影子页中写入 INT3(这是真正执行的代码)
    offset_in_page = target_addr & 0xFFF
    shadow_page[offset_in_page] = 0xCC    // INT3

    // 5. 修改 EPT 执行权限:执行时映射到影子页
    ept_remap_execute(target_addr, shadow_page)

    // 6. 保持 EPT 读写权限:读写时依然映射到原始页
    ept_keep_read_write(target_addr, original_page)

    hook.installed = true
}

3.2 Payload 格式与编译

为了让注入的代码能像“插件”一样被加载,代码必须是位置无关 (PIC) 的,且需要一个协议头来告诉 Hypervisor 入口和出口在哪里。

Header 设计:

struct payload_header {
    uint32_t    magic;          // 魔数:0x494E4A43 ("INJC")
    uint16_t    entry_offset;   // 入口点偏移
    uint16_t    exit_offset;    // 退出点偏移
    uint16_t    code_size;      // 代码大小
    uint64_t    runtime_addr;   // 运行时地址(由 Hypervisor 填充)
} __attribute__((packed));

Linker Script (关键部分):

这是生成可用 Payload 的关键,我们需要精确控制内存布局:

/* payload.ld - 位置无关 Payload 的链接脚本 */
OUTPUT_FORMAT("binary")

SECTIONS
{
    . = 0x0000;

    /* 头部:固定 18 字节 */
    .header : ALIGN(1) {
        LONG(0x494E4A43)            /* magic */
        SHORT(_entry - _start)      /* entry_offset */
        SHORT(_exit - _start)       /* exit_offset */
        SHORT(_code_end - _entry)   /* code_size */
        QUAD(0)                     /* runtime_addr (待填充) */
    }

    /* 数据段:存放运行时参数 */
    .data : ALIGN(1) { *(.data .data.*) }

    /* 入口段:Trampoline 代码 */
    .entry : ALIGN(1) {
        _entry = .;
        *(.entry .entry.*)
    }

    /* 代码段:主逻辑 */
    .text : ALIGN(1) {
        *(.text .text.*)
        _code_end = .;
    }

    /* 退出标记:INT3 */
    .exit : ALIGN(1) { _exit = .; }

    /DISCARD/ : { *(.note.* .comment .eh_frame) }
    _start = 0;
}

3.3 寻找完美的劫持点

我们选择 Syscall 入口 (entry_SYSCALL_64) 作为劫持点,因为它是必经之路。但为了系统稳定性,我们特意避开了 STI 指令之前的区域。

查找逻辑:

// 在 syscall 入口路径中查找合适的 hook 点
function find_hook_point(syscall_entry_addr) {
    current_addr = syscall_entry_addr
    sti_found = false

    while current_addr < syscall_entry_addr + 0x1000 {
        instruction = disasm(current_addr)

        if !sti_found {
            // 必须先找到 STI(开中断)
            if instruction.type == INST_STI { sti_found = true }
        } else {
            // 在 STI 之后,找第一条非条件跳转指令,确保能安全返回
            if instruction.length >= 1 && !instruction.is_conditional {
                return current_addr
            }
        }
        current_addr += instruction.length
    }
    return NULL;
}

3.4 Guest-Hypervisor 通信设计

Guest 里的代码怎么跟外面的 Hypervisor 说话?我们设计了一套基于寄存器传参的简易协议。

Guest 端实现 (payload.c):

// 数据段:由 Hypervisor 填充实际地址和魔数
struct payload_data {
    uint64_t hypercall_magic;
    uint64_t completion_magic;
    uint64_t error_magic;
    void* (*module_alloc)(size_t size);
    // ... 其他内核函数指针
} __attribute__((section(".data")));

// Hypercall 封装
macro hypercall_2(magic, arg1, arg2) {
    register uint64_t _rax asm("rax") = magic;
    register uint64_t _r8  asm("r8")  = arg1;
    register uint64_t _r9  asm("r9")  = arg2;
    asm volatile("int3" : "+r"(_rax) : "r"(_r8), "r"(_r9));
}

// 主逻辑示例
function main_logic() {
    // 1. 在 Guest 内部分配内存
    void *ptr = g_data.module_alloc(1024);

    // 2. 告诉 Hypervisor 分配结果
    hypercall_2(g_data.hypercall_magic, ptr, 0);

    // 3. 任务完成,通知退出
    register uint64_t _rax asm("rax") = g_data.completion_magic;
    asm volatile("int3" : "+r"(_rax));
}

Hypervisor 端处理 (handle_vm_exit):

function handle_payload_breakpoint(ctx, guest_rip) {
    magic = read_guest_register(RAX); // 检查魔数

    if magic == ctx.magic.hypercall {
        // 处理调用请求
        arg1 = read_guest_register(R8);
        process_hypercall(arg1);
        return HANDLED;
    }

    if magic == ctx.magic.completion {
        // 处理完成信号
        cleanup_payload(ctx);
        return HANDLED;
    }

    // ... 处理错误或退出
}

4. 完整实现流程

4.1 注入与激活

这是整个流程中最关键的一步,涉及内存分配、符号解析和补丁安装。

// injection_manager.c

function inject_payload(payload_type) {
    // 1. 准备上下文
    ctx = allocate(struct payload_context);
    ctx.raw_data = get_payload_binary(payload_type);

    // 2. 动态生成本次会话的魔数 (增强安全性)
    ctx.magic.hypercall   = generate_magic_number();

    // 3. 填充 Payload 数据段 (把外部世界的知识传进去)
    // 填魔数
    write_payload_data(ctx, offset, &ctx.magic.hypercall, 8);
    // 填内核函数地址 (通过 kallsyms 查找)
    addr_alloc = find_kernel_symbol("module_alloc");
    write_payload_data(ctx, offset, &addr_alloc, 8);

    // 4. 在 Guest 内存缝隙 (Slack Space) 中写入 Payload
    ctx.payload_addr = allocate_guest_slack_space(ctx.raw_size);
    guest_write_memory(ctx.payload_addr, ctx.raw_data, ctx.raw_size);

    // 5. 找到并安装 Hook
    syscall_entry = find_kernel_symbol("entry_SYSCALL_64");
    hook_addr = find_hook_point(syscall_entry);
    install_stealth_hook(&ctx.hook, hook_addr);

    // 6. 激活
    list_add(&g_active_payloads, ctx);
}

4.2 执行与通信

当 Guest 触发 Hook 时的处理逻辑:

// 当 Guest 触发 Hook 点的 INT3
function start_payload_execution(ctx) {
    // 1. 临时卸载 Hook,恢复原始指令,防止重入或死锁
    uninstall_stealth_hook(&ctx.hook);

    // 2. 计算 Payload 入口地址
    entry_addr = ctx.payload_addr + ctx.header.entry_offset;

    // 3. 简单粗暴:直接修改 RIP 寄存器
    guest_rip = read_guest_register(RIP);
    write_guest_register(RIP, entry_addr);

    // 4. Guest 继续跑,下一条指令就是我们的 trampoline
    return SKIP_INSTRUCTION;
}

4.3 退出与清理

Payload 跑完后的“擦屁股”工作同样重要,不能留下痕迹。

function cleanup_payload(ctx) {
    // 1. 将 RIP 拨回最初的 Hook 点 (模拟原始 syscall 路径)
    original_rip = ctx.hook.target_addr;
    write_guest_register(RIP, original_rip);

    // 2. 释放占用的 Guest 内存
    free_guest_slack_space(ctx.payload_addr, ctx.raw_size);

    // 3. 销毁上下文
    list_remove(&g_active_payloads, ctx);
    free(ctx);

    // Guest 会认为刚才只是做了一次普通的 syscall
    return SKIP_INSTRUCTION;
}

5. 小结

本文介绍了一套基于 Hypervisor 的内核代码注入方案,核心技术包括:

  • EPT 双视图:实现隐蔽 Hook

  • PIE + Linker Script:支持任意地址加载

  • INT3 + Magic Number:Guest-Host 通信

  • Slack Space + RIP 劫持:临时执行与清理

本文部分技术思路参考了 Bitdefender HVMI 等开源 VMI 项目的设计模式。文中所有伪代码和实现细节为作者原创整理。

More from this blog

记一次基于 Linux 5.15.0-139 内核源码中ebpf辅助函数解惑学习

在 eBPF 开发中,辅助函数(Helper Functions)是连接沙箱代码与内核原生的唯一桥梁, 辅助函数既不是动态加载的插件,也不是脆弱的符号引用。它们是在内核启动那一刻,由引导代码根据链接脚本的‘施工图’,强行焊接在内存只读区域的物理基石。 本文起源于我在开发ebpf程序时思考辅助函数到底是什么?它和内核提供的其他函数有什么区别? 。 本文记录了我是如何通过源码分析、逆向思考和底层调试,一步步打通 eBPF 辅助函数逻辑的全过程。所有的代码均出自于5.15.0-139内核源码 第一...

Feb 6, 20265 min read9

记一次Introcore 内核级 Bug 调试记录:从 PUSH 指令到页表模拟崩溃

在虚拟机内省(VMI)的底层开发中,稳定性往往比功能更难攻克。最近在对 Windows 7 进行监控实验时,Bitdefender 的 HVMI (Introcore) 引擎频繁触发致命崩溃。错误指向了页表写入模拟逻辑。 通过对 hvmid 进程进行深度的 GDB 挂载调试,我还原了一个关于指令建模、地址不匹配以及异常处理机制的完整 Bug 链路。 1. 现象:内省引擎的“自杀” 在监控环境下,Introcore 抛出两类致命日志并导致守护进程崩溃: [ERROR] Access at 79c...

Jan 26, 20263 min read32

记一次虚拟化下的detour框架实现

1.Detour机制基础 1.1 什么是Detour? 在虚拟化安全监控领域,我们经常需要监控Guest OS内部的关键操作——进程创建、权限提升、内存映射变化等。传统的EPT(Extended Page Table)监控虽然可以捕获内存访问,但存在明显的局限性: 语义鸿沟:从"某个地址被写入"推断"进程被创建"需要复杂的分析 性能开销:细粒度的内存监控会产生大量VM-Exit 覆盖盲区:纯寄存器操作、链表修改等无法通过内存监控捕获 因此,我们需要一种更直接的方法:在关键函数的入口和出...

Jan 17, 20266 min read21

once2twice

8 posts

有的没的杂七杂八