模糊测试开发:构建Bochs、MMU与文件I/O的技术实践

本文详细介绍了在开发基于Bochs的模糊测试工具过程中,如何优化系统调用拦截机制、实现简易内存管理单元(MMU)以及模拟文件I/O操作,涵盖上下文切换、内存池预分配和文件内容预加载等核心技术细节。

背景

这是系列博客的第三篇,详细介绍了开发基于Bochs作为目标执行引擎的快照模糊测试工具的过程。相关代码可在Lucid代码库中找到。

引言

今天我们继续模糊测试工具的开发之旅。上次我们开发了上下文切换基础设施的雏形,以便在系统调用时将Bochs(实际上是一个测试程序)与操作系统内核隔离开来。

系统调用基础设施更新

在上篇博客发布后,我收到了Fuzzing Discord传奇人物WorksButNotTested的一些宝贵反馈和建议。他告诉我,如果我们放弃完整的上下文切换/C-ABI到Syscall-ABI寄存器转换例程,而直接让Bochs从C调用Rust函数来处理系统调用,可以大大简化复杂度。事后看来,这非常直观和明显,我承认有点尴尬忽略了这种可能性。

之前,在我们的自定义Musl代码中,有一个如下的C函数调用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
static __inline long __syscall6(long n, long a1, long a2, long a3, long a4, long a5, long a6)
{
	unsigned long ret;
	register long r10 __asm__("r10") = a4;
	register long r8 __asm__("r8") = a5;
	register long r9 __asm__("r9") = a6;
	__asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2),
						  "d"(a3), "r"(r10), "r"(r8), "r"(r9) : "rcx", "r11", "memory");
	return ret;
}

这是程序需要6个参数进行系统调用时调用的函数。在上篇博客中,我们将此函数改为if/else结构,这样如果程序在Lucid下运行,我们会在将C ABI寄存器洗牌到系统调用寄存器后调用Lucid的上下文切换函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
static __inline long __syscall6_original(long n, long a1, long a2, long a3, long a4, long a5, long a6)
{
	unsigned long ret;
	register long r10 __asm__("r10") = a4;
	register long r8  __asm__("r8")  = a5;
	register long r9  __asm__("r9")  = a6;
	__asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2), "d"(a3), "r"(r10),
							"r"(r8), "r"(r9) : "rcx", "r11", "memory");

	return ret;
}

static __inline long __syscall6(long n, long a1, long a2, long a3, long a4, long a5, long a6)
{
    if (!g_lucid_ctx) { return __syscall6_original(n, a1, a2, a3, a4, a5, a6); }
	
    register long ret;
    register long r12 __asm__("r12") = (size_t)(g_lucid_ctx->exit_handler);
    register long r13 __asm__("r13") = (size_t)(&g_lucid_ctx->register_bank);
    register long r14 __asm__("r14") = SYSCALL;
    register long r15 __asm__("r15") = (size_t)(g_lucid_ctx);
    
    __asm__ __volatile__ (
        "mov %1, %%rax\n\t"
	"mov %2, %%rdi\n\t"
	"mov %3, %%rsi\n\t"
	"mov %4, %%rdx\n\t"
	"mov %5, %%r10\n\t"
	"mov %6, %%r8\n\t"
	"mov %7, %%r9\n\t"
        "call *%%r12\n\t"
        "mov %%rax, %0\n\t"
        : "=r" (ret)
        : "r" (n), "r" (a1), "r" (a2), "r" (a3), "r" (a4), "r" (a5), "r" (a6),
		  "r" (r12), "r" (r13), "r" (r14), "r" (r15)
        : "rax", "rcx", "r11", "memory"
    );
	
	return ret;
}

这相当复杂。我过于执着于"Lucid必须是内核"的想法,认为当用户态程序执行系统调用时,它们的状态应该被保存,执行应该在内核中开始。这证明让我误入歧途,因为我们的目的不需要如此复杂的例程,我们实际上不是内核,我们只是想为一个行为良好的特定程序隔离系统调用。

WorksButNotTested建议直接调用Rust函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
static __inline long __syscall6(long n, long a1, long a2, long a3, long a4, long a5, long a6)
{
	if (g_lucid_syscall)
		return g_lucid_syscall(g_lucid_ctx, n, a1, a2, a3, a4, a5, a6);
	
	unsigned long ret;
	register long r10 __asm__("r10") = a4;
	register long r8 __asm__("r8") = a5;
	register long r9 __asm__("r9") = a6;
	__asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2),
						  "d"(a3), "r"(r10), "r"(r8), "r"(r9) : "rcx", "r11", "memory");
	return ret;
}

显然这是一个简单得多的解决方案,我们可以避免寄存器混乱/状态保存/内联汇编等。要设置此函数,我们只需在Musl的lucid.h中创建一个新的函数指针全局变量,并在src/lucid.c中给出定义,您可以在代码库的Musl补丁中看到。

在Rust端,g_lucid_syscall看起来像这样:

1
2
3
pub extern "C" fn lucid_syscall(contextp: *mut LucidContext, n: usize,
    a1: usize, a2: usize, a3: usize, a4: usize, a5: usize, a6: usize)
    -> u64 

我们可以利用C ABI的优势,保持程序通常使用Musl的语义,这是一个非常值得赞赏的建议,我对结果非常满意。

调用约定更改

在系统调用的重构过程中,我还简化了上下文切换调用约定的工作方式。我没有使用4个单独的寄存器作为调用约定,而是决定只需传递一个指向Lucid执行上下文的指针,让context_switch函数本身根据上下文的值来决定其行为。本质上,我们将复杂性从调用方转移到了被调用方。

这意味着复杂性不会在整个代码库中重复出现,而是在context_switch逻辑本身中一次性封装。然而,这确实需要一些hacky/脆弱的代码,例如我们必须为Lucid执行数据结构硬编码一些结构偏移量,但在我看来,为了大幅降低复杂性,这是一个很小的代价。

context_switch代码已更改为以下内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
extern "C" { fn context_switch(); }
global_asm!(
    ".global context_switch",
    "context_switch:",

    // 在执行任何操作之前保存CPU标志
    "pushfq",

    // 保存我们用作暂存区的寄存器
    "push r14",
    "push r13",

    // 确定我们处于哪种执行模式
    "mov r14, r15",
    "add r14, 0x8",     // 模式在基址偏移0x8处
    "mov r14, [r14]",
    "cmp r14d, 0x0",
    "je save_bochs",

    // 我们处于Lucid模式,所以保存Lucid GPRs
    "save_lucid: ",
    "mov r14, r15",
    "add r14, 0x10",    // lucid_regs在偏移0x10处
    "jmp save_gprs",             

    // 我们处于Bochs模式,所以保存Bochs GPRs
    "save_bochs: ",
    "mov r14, r15",
    "add r14, 0x90",    // bochs_regs在偏移0x90处
    "jmp save_gprs",

您可以看到,一旦我们进入context_switch函数,我们会在执行任何可能影响它们的操作之前保存CPU标志,然后保存几个用作暂存寄存器的寄存器。然后我们可以自由检查context->mode的值以确定我们处于哪种执行模式。基于该值,我们能够知道使用哪个寄存器库来保存我们的通用寄存器。

是的,我们确实必须硬编码一些偏移量,但我相信总体而言,这是一个更好的API和系统,用于上下文切换被调用方,并且数据结构本身在这一点上应该相对稳定,不需要大规模重构。

引入故障

自上一篇博客以来,我引入了Fault的概念,这是一个错误类,保留用于在上下文切换代码或系统调用处理期间遇到某种错误的情况。此错误与我们的最高级别错误LucidErr不同。最终,这些故障在遇到时会传回给Lucid,以便Lucid可以处理它们。截至目前,Lucid将任何Fault视为致命错误。

我们能够将这些传回给Lucid,因为在开始Bochs执行之前,我们现在保存Lucid的状态并上下文切换到启动Bochs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#[inline(never)]
pub fn start_bochs(context: &mut LucidContext) {
    // 设置执行模式和我们退出Lucid VM的原因
    context.mode = ExecMode::Lucid;
    context.exit_reason = VmExit::StartBochs;

    // 设置调用约定,然后通过上下文切换启动Bochs
    unsafe {
        asm!(
            "push r15", // 我们必须保留的被调用方保存寄存器
            "mov r15, {0}", // 将上下文移动到R15
            "call qword ptr [r15]", // 调用context_switch
            "pop r15",  // 恢复被调用方保存寄存器
            in(reg) context as *mut LucidContext,
        );
    }
}

我们对执行上下文进行了一些更改,即标记执行模式(Lucid模式)并设置我们上下文切换的原因(启动Bochs)。然后在内联汇编中,我们调用执行上下文结构中偏移量为0的函数指针:

1
2
3
4
5
6
// 在Lucid和Bochs之间传递的执行上下文,跟踪
// 我们需要进行上下文切换的所有可变状态信息
#[repr(C)]
#[derive(Clone)]
pub struct LucidContext {
    pub context_switch: usize,  // context_switch()的地址

然后我们的Lucid状态在context_switch例程中保存,然后我们被传递给这个逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 在这里处理Lucid上下文切换
    if LucidContext::is_lucid_mode(context) {
        match exit_reason {
            // 分发到Bochs入口点
            VmExit::StartBochs => {
                jump_to_bochs(context);
            },
            _ => {
                fault!(context, Fault::BadLucidExit);
            }
        }
    }

最后,我们调用jump_to_bochs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 独立函数,直接跳转到Bochs入口并为Bochs提供堆栈地址
fn jump_to_bochs(context: *mut LucidContext) {
    // RDX:我们必须清除此寄存器,因为ABI规定在程序启动时
    // 当rdx非空时设置退出钩子
    //
    // RAX:任意用作程序入口的跳转目标
    //
    // RSP:Rust不允许您显式使用'in()'中的'rsp',所以我们必须
    // 用`mov`手动设置它
    //
    // R15:持有指向执行上下文的指针,如果此值非空,
    // 那么Bochs在启动时就知道它在Lucid下运行
    //
    // 我们不太关心执行顺序,只要我们用out/lateout指定clobbers,
    // 这样编译器就不会分配一个我们立即clobber的寄存器
    unsafe {
        asm!(
            "xor rdx, rdx",
            "mov rsp, {0}",
            "mov r15, {1}",
            "jmp rax",
            in(reg) (*context).bochs_rsp,
            in(reg) context,
            in("rax") (*context).bochs_entry,
            lateout("rax") _,   // Clobber (inout所以与in没有冲突)
            out("rdx") _,       // Clobber
            out("r15") _,       // Clobber
        );
    }
}

像这样的完整上下文切换允许我们遇到Fault,然后将该错误传回给Lucid进行处理。在fault_handler中,我们在执行上下文中设置Fault类型,然后尝试将执行恢复回Lucid:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
// 我们在这里处理从Bochs上下文切换时可能发生的故障。
// 我们只想让故障对Lucid可见,所以我们在上下文中设置它,
// 然后我们尝试从其最后已知的良好状态恢复Lucid执行
pub fn fault_handler(contextp: *mut LucidContext, fault: Fault) {
    let context = unsafe { &mut *contextp };
    match fault {
        Fault::Success => context.fault = Fault::Success,
        ...
    }

    // 尝试恢复Lucid执行
    restore_lucid_execution(contextp);
}

// 我们使用此函数将Lucid执行恢复到其最后已知的良好状态
// 这实际上只是试图将故障传递到能够辨别采取什么操作的级别。
// 现在,我们可能只是称之为致命。
// 我们并不真正处理双重故障,当单个故障可能已经致命时,
// 目前没有太大意义。也许以后?
fn restore_lucid_execution(contextp: *mut LucidContext) {
    let context = unsafe { &mut *contextp };
    
    // 应该设置故障,但现在更改执行模式,因为我们正在
    // 跳回Lucid
    context.mode = ExecMode::Lucid;

    // 恢复扩展状态
    let save_area = context.lucid_save_area;
    let save_inst = context.save_inst;
    match save_inst {
        SaveInst::XSave64 => {
            // 检索XCR0值,这将作为我们的保存掩码
            let xcr0 = unsafe { _xgetbv(0) };

            // 调用xrstor从Bochs保存区域恢复扩展状态
            unsafe { _xrstor64(save_area as *const u8, xcr0); }             
        },
        SaveInst::FxSave64 => {
            // 调用fxrstor从Bochs保存区域恢复扩展状态
            unsafe { _fxrstor64(save_area as *const u8); }
        },
        _ => (), // NoSave
    }

    // 接下来,我们需要恢复我们的GPRs。这与从成功上下文切换返回的顺序有点不同,
    // 因为通常我们仍然会使用自己的堆栈;然而现在,我们仍然有Bochs的堆栈,
    // 所以我们需要恢复我们自己的Lucid堆栈,它作为RSP保存在我们的寄存器库中
    let lucid_regsp = &context.lucid_regs as *const _;

    // 将该指针移动到R14并恢复我们的GPRs。之后,我们有了
    // 当我们调用context_switch时保存的RSP值,这个RSP然后
    // 被减去0x8以用于紧随其后的pushfq操作。
    // 所以为了恢复我们的CPU标志,我们需要手动从堆栈指针中减去0x8。
    // 将CPU标志弹出回原位,然后返回到最后已知的良好Lucid状态
    unsafe {
        asm!(
            "mov r14, {0}",
            "mov rax, [r14 + 0x0]",
            "mov rbx, [r14 + 0x8]",
            "mov rcx, [r14 + 0x10]",
            "mov rdx, [r14 + 0x18]",
            "mov rsi, [r14 + 0x20]",
            "mov rdi, [r14 + 0x28]",
            "mov rbp, [r14 + 0x30]",
            "mov rsp, [r14 + 0x38]",
            "mov r8, [r14 + 0x40]",
            "mov r9, [r14 + 0x48]",
            "mov r10, [r14 + 0x50]",
            "mov r11, [r14 + 0x58]",
            "mov r12, [r14 + 0x60]",
            "mov r13, [r14 + 0x68]",
            "mov r15, [r14 + 0x78]",
            "mov r14, [r14 + 0x70]",
            "sub rsp, 0x8",
            "popfq",
            "ret",
            in(reg) lucid_regsp,
        );
    }
}

如您所见,恢复Lucid状态和恢复执行相当复杂。我们必须处理的一个棘手问题是,现在当发生故障时,我们很可能在Bochs模式下运行,这意味着我们的堆栈是Bochs的堆栈,而不是Lucid的。所以即使这技术上只是一个上下文切换,我们也不得不稍微改变顺序,将Lucid的保存状态弹出到我们当前状态并恢复执行。

现在当Lucid调用上下文切换的函数时,它可以通过检查执行上下文中是否有Fault来简单地检查这些函数的"返回"值:

1
2
3
	// 开始执行Bochs
    prompt!("Starting Bochs...");
    start_bochs(&mut lucid
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计