模糊测试器开发实战:系统调用沙盒化技术解析

本文详细介绍了如何开发一个基于Bochs模拟器的模糊测试框架,重点讲解系统调用拦截与沙盒化技术。通过修改Musl C库实现上下文切换,使用Rust编写处理逻辑,构建完整的执行环境隔离机制。

模糊测试器开发实战:系统调用沙盒化技术解析

引言

近期我们正在博客上开发一个模糊测试器。严格来说,“模糊测试器"可能不是最准确的描述——我们构建的更像是一个执行引擎,能够暴露各种钩子函数。如果你错过了第一期内容,可以在此查看。我们正在创建一个模糊测试器,它将静态构建的Bochs模拟器加载到自身中,在执行Bochs逻辑的同时为其维护一个沙盒环境。可以这样理解:我们懒得从头实现自己的x86_64模拟器,所以直接拿了一个完整的模拟器塞进自己的进程中使用。

这个模糊测试器使用Rust编写,而Bochs是基于C++的代码库。Bochs是一个全系统模拟器,所有设备和组件都在软件中模拟。这对我们非常有利,因为我们可以直接对Bochs本身进行快照和恢复,实现对目标的快照模糊测试。因此,模糊测试器运行Bochs,而Bochs运行我们的目标程序。这使我们能够对任意复杂的目标进行快照模糊测试:Web浏览器、内核、网络栈等。

本期我们将深入探讨如何将Bochs与系统调用进行沙盒隔离。我们不希望Bochs能够逃逸其沙盒环境或从外部环境获取任何数据。今天我们将详细介绍我首次尝试实现Bochs到模糊测试器上下文切换来处理系统调用的实现细节。未来我们还需要实现从模糊测试器到Bochs的上下文切换,但现在让我们专注于系统调用。

这个模糊测试器最初由Brandon Falk构思和实现。 本文不会涉及代码库的变更。

系统调用

系统调用是用户态程序自愿切换到内核态以使用某些内核提供的实用程序或功能的一种方式。上下文切换简单来说就是改变代码执行的上下文环境。当你进行整数运算、读写内存时,你的进程在用户态下 within 你的进程虚拟地址空间中执行。但如果你想打开套接字或文件,就需要内核的帮助。为此,你需要进行系统调用,告诉处理器将执行模式从用户态切换到内核态。

为了从用户态进入内核态然后再返回用户态,必须非常小心地在每一步准确保存执行状态。当你尝试执行系统调用时,操作系统首先要做的事情就是在开始执行请求的内核代码之前保存当前的执行状态,这样在内核完成请求后,它能够优雅地返回到用户态进程的执行。

上下文切换可以理解为从一个进程的执行切换到另一个进程的执行。在我们的案例中,是从Bochs执行切换到Lucid执行。Bochs做它的事情:读写内存、进行算术运算等,但当它需要内核帮助时,它会尝试进行系统调用。当这种情况发生时,我们需要:

  1. 识别Bochs正在尝试进行系统调用(奇怪的是,这并不总是容易做到的)
  2. 拦截执行并重定向到适当的代码路径
  3. 保存Bochs的执行状态
  4. 执行我们的Lucid逻辑来代替内核(可以将Lucid视为Bochs的内核)
  5. 通过恢复其状态优雅地返回到Bochs

C库

通常程序员不需要直接担心进行系统调用。他们使用在C库中定义和实现的函数,而这些函数才是实际进行系统调用的。你可以将这些函数视为系统调用的包装器。例如,如果你使用C库的open函数,你不是直接进行系统调用,而是调用库的open函数,该函数发出系统调用指令,实际执行到内核的上下文切换。

这种方式为程序员省去了很多可移植性工作,因为库函数的内部执行所有环境变量的条件检查并相应地执行。程序员只需调用open函数,不必担心系统调用号、错误处理等问题,因为这些在导出给程序员的代码中保持抽象和统一。

这为我们的目的提供了一个很好的瓶颈点,因为Bochs程序员也使用C库函数而不是直接调用系统调用。当Bochs想要进行系统调用时,它会调用C库函数。这给了我们在系统调用实际发生之前拦截它们的机会。我们可以在这些函数中插入自己的逻辑,检查Bochs是否在Lucid下执行,如果是,我们可以插入将执行导向Lucid而不是内核的逻辑。用伪代码我们可以实现类似以下的内容:

1
2
3
4
5
fn syscall()
  if lucid:
    lucid_syscall()
  else:
    normal_syscall()

Musl

Musl是一个旨在"轻量级"的C库。与像Glibc这样的庞然大物(对上帝的冒犯)相比,这给我们提供了一些简单性。重要的是,Musl在静态链接方面声誉很好,这正是我们构建静态PIE Bochs时所需要的。所以这里的想法是,我们可以手动修改Musl代码,改变调用系统调用的包装器函数的工作方式,以便我们可以以一种上下文切换到Lucid而不是内核的方式劫持执行。

在本文中,我们将使用Musl 1.2.4,这是截至今天的最新版本。

初步尝试

我们不直接跳入Bochs,而是使用一个测试程序来开发我们的第一个上下文切换例程。这样更容易。测试程序如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <unistd.h>
#include <lucid.h>

int main(int argc, char *argv[]) {
    printf("Argument count: %d\n", argc);
    printf("Args:\n");
    for (int i = 0; i < argc; i++) {
        printf("   -%s\n", argv[i]);
    }

    size_t iters = 0;
    while (1) {
        printf("Test alive!\n");
        sleep(1);
        iters++;

        if (iters == 5) { break; }
    }

    printf("g_lucid_ctx: %p\n", g_lucid_ctx);
}

该程序将告诉我们它的参数计数、每个参数、存活约5秒,然后打印Lucid执行上下文数据结构的内存地址。如果程序在Lucid下运行,这个数据结构将由Lucid分配和初始化,否则将为NULL。那么我们如何实现这一点呢?

执行上下文跟踪

我们的问题是需要一种全局可访问的方式,让我们加载的程序(最终是Bochs)能够判断它是在Lucid下运行还是正常运行。我们还必须向Bochs提供许多数据结构和函数地址,因此需要一个载体来实现这一点。

我所做的是创建自己的头文件并将其放在Musl中,称为lucid.h。该文件定义了我们需要Bochs在针对Musl编译时访问的所有Lucid特定数据结构。所以在头文件中,我们现在定义了一个lucid_ctx数据结构,并且还创建了一个名为g_lucid_ctx的全局实例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 我们用于在模糊测试器和Bochs之间切换上下文的执行上下文定义。
// 这应包含我们需要跟踪快照之间所有可变状态的所有信息,例如文件数据。
// 这必须与context.rs中的LucidContext保持一致
typedef struct lucid_ctx {
    // 这必须始终是该结构的第一个成员
    size_t exit_handler;
    int save_inst;
    size_t save_size;
    size_t lucid_save_area;
    size_t bochs_save_area;
    struct register_bank register_bank;
    size_t magic;
} lucid_ctx_t;

// 指向全局执行上下文的指针,如果在Lucid内部运行,这将指向模糊测试器内部的struct lucid_ctx_t
lucid_ctx_t *g_lucid_ctx;

在Lucid下的程序启动

在Lucid的main函数中,我们现在执行以下操作:

  1. 加载Bochs
  2. 创建执行上下文
  3. 跳转到Bochs的入口点并开始执行

当我们跳转到Bochs的入口点时,最早调用的函数之一是Musl中的一个函数,称为_dlstart_c,位于源文件dlstart.c中。现在,我们在Lucid的堆上创建那个全局执行上下文,然后将其地址传递到任意选择的r15中。这个整个函数最终必须更改,因为将来我们希望从Lucid上下文切换到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
pub fn start_bochs(bochs: Bochs, context: Box<LucidContext>) {
    // rdx: 我们必须清除这个寄存器,因为ABI指定当rdx在程序启动时非空时设置退出钩子
    //
    // rax: 任意用作程序入口的跳转目标
    //
    // rsp: Rust不允许你明确使用in()中的'rsp',所以我们必须用`mov`手动设置它
    //
    // r15: 持有执行上下文的指针,如果这个值非空,那么Bochs在启动时就知道它在Lucid下运行
    //
    // 只要我们使用out/lateout指定clobbers,我们就不太关心执行顺序,
    // 这样编译器就不会分配一个我们立即破坏的寄存器
    unsafe {
        asm!(
            "xor rdx, rdx",
            "mov rsp, {0}",
            "mov r15, {1}",
            "jmp rax",
            in(reg) bochs.rsp,
            in(reg) Box::into_raw(context),
            in("rax") bochs.entry,
            lateout("rax") _,   // Clobber (inout所以与in不冲突)
            out("rdx") _,       // Clobber
            out("r15") _,       // Clobber
        );
    }
}

所以当我们从Lucid跳转到Bochs入口点时,r15应该持有执行上下文的地址。在_dlstart_c中,我们可以检查r15并相应地行动。以下是我对Musl的启动例程所做的添加:

 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
hidden void _dlstart_c(size_t *sp, size_t *dynv)
{
    // 启动例程在arch/x86_64/crt_arch.h的内联汇编中处理
    // 所以我们可以在这里做这个。该函数逻辑只破坏几个寄存器,
    // 所以我们可以让Lucid加载器在r15中传递Lucid上下文的地址,
    // 这显然不是最干净的解决方案,但为了我们的目的是有效的
    size_t r15;
    __asm__ __volatile__(
        "mov %%r15, %0" : "=r"(r15)
    );

    // 如果r15不为0,为在Rust模糊测试器中的g_lucid_ctx设置全局上下文地址
    if (r15 != 0) {
        g_lucid_ctx = (lucid_ctx_t *)r15;

        // 我们必须确保这是真的,我们依赖这个
        if ((void *)g_lucid_ctx != (void *)&g_lucid_ctx->exit_handler) {
            __asm__ __volatile__("int3");
        }
    }

    // 我们没有得到g_lucid_ctx,所以可以正常运行
    else {
        g_lucid_ctx = (lucid_ctx_t *)0;
    }

当调用此函数时,最早的Musl逻辑不会触及r15。因此我们使用内联汇编将其值提取到名为r15的变量中并检查是否有数据。如果有数据,我们将全局上下文变量设置为r15中的地址;否则我们明确将其设置为NULL并正常运行。现在有了全局设置,我们可以进行运行时环境检查,并选择性地调用真实内核或Lucid。

修改Musl系统调用

现在有了全局设置,是时候编辑负责进行系统调用的函数了。Musl组织得非常好,因此找到调用系统调用的逻辑并不太困难。对于我们的目标架构x86_64,那些调用系统调用的函数在arch/x86_64/syscall_arch.h中。它们按系统调用接受的参数数量组织:

 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
static __inline long __syscall0(long n)
{
    unsigned long ret;
    __asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n) : "rcx", "r11", "memory");
    return ret;
}

static __inline long __syscall1(long n, long a1)
{
    unsigned long ret;
    __asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1) : "rcx", "r11", "memory");
    return ret;
}

static __inline long __syscall2(long n, long a1, long a2)
{
    unsigned long ret;
    __asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2)
                          : "rcx", "r11", "memory");
    return ret;
}

static __inline long __syscall3(long n, long a1, long a2, long a3)
{
    unsigned long ret;
    __asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2),
                          "d"(a3) : "rcx", "r11", "memory");
    return ret;
}

static __inline long __syscall4(long n, long a1, long a2, long a3, long a4)
{
    unsigned long ret;
    register long r10 __asm__("r10") = a4;
    __asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2),
                          "d"(a3), "r"(r10): "rcx", "r11", "memory");
    return ret;
}

static __inline long __syscall5(long n, long a1, long a2, long a3, long a4, long a5)
{
    unsigned long ret;
    register long r10 __asm__("r10") = a4;
    register long r8 __asm__("r8") = a5;
    __asm__ __volatile__ ("syscall" : "=a"(ret) : "a"(n), "D"(a1), "S"(a2),
                          "d"(a3), "r"(r10), "r"(r8) : "rcx", "r11", "memory");
    return ret;
}

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;
}

对于系统调用,有一个明确定义的调用约定。系统调用接受一个"系统调用号”,它决定了你想要的系统调用,放在eax中,然后接下来的n个参数按顺序通过寄存器传递:rdi、rsi、rdx、r10、r8和r9。

这相当直观,但语法有点神秘,例如在那些__asm__ volatile (“syscall”)行上,很难看出它在做什么。让我们以最复杂的函数__syscall6为例,分解所有语法。我们可以将汇编语法视为像打印一样的格式字符串,但这是用于发出代码的:

  • unsigned long ret是我们将存储系统调用结果以指示是否成功的地方。在原始汇编中,我们可以看到有一个:然后是"=a(ret)",初始冒号后的第一组参数用于指示输出参数。我们说的是请将eax中的结果(在语法中象征为a)存储到变量ret中。
  • 下一个冒号后的一系列参数是输入参数。“a”(n)是说,将函数参数n(系统调用号)放入eax(再次象征为a)。接下来是将a1存储在rdi中(象征为D),依此类推
  • 参数4-6放在上面的寄存器中,例如语法register long r10 __asm__("r10") = a4;是一个强烈的编译器提示,将a4存储到r10中。然后后来我们看到"r"(r10)说将变量r10输入到通用寄存器中(已经满足)。
  • 最后一组冒号分隔的值被称为"clobbers"。这些告诉编译器我们的系统调用预期会破坏什么。因此系统调用调用约定指定rcx、r11和内存可能被内核覆盖。

通过解释语法,我们看到了正在发生的事情。这些函数的任务是将函数调用转换为系统调用。函数的调用约定,称为System V ABI,与系统调用的调用约定不同,寄存器利用率不同。因此当我们调用__syscall6并传递其参数时,每个参数存储在以下寄存器中:

  • n → rax
  • a1 → rdi
  • a2 → rsi
  • a3 → rdx
  • a4 → rcx
  • a5 → r8
  • a6 → r9

因此编译器将从System V ABI获取这些函数参数,并通过我们上面解释的汇编将它们转换为系统调用。所以现在这些是我们需要编辑的函数,以便我们不发出该系统调用指令,而是调用Lucid。

有条件地调用Lucid

因此我们需要在这些函数体中一种调用Lucid而不是发出系统调用指令的方法。为此我们需要定义自己的调用约定,目前我一直在使用以下内容:

  • r15:包含全局Lucid执行上下文的地址
  • r14:包含一个"退出原因",这只是一个解释我们为什么进行上下文切换的枚举
  • r13:是Lucid执行上下文的寄存器库结构的基础地址,我们需要这个内存部分来存储我们的寄存器值以在上下文切换时保存我们的状态
  • r12:存储"退出处理程序"的地址,这是要调用以进行上下文切换的函数

随着我们添加更多功能/功能性,这无疑会有所改变。我还应该注意,根据ABI,函数有责任保留这些值,因此函数调用者期望这些值在函数调用期间不会改变,嗯,我们正在改变它们。没关系,因为在我们使用它们的函数中,我们将它们标记为clobbers,记得吗?所以编译器知道它们会改变,编译器现在要做的是在执行任何代码之前,将这些寄存器推入堆栈以保存它们,然后在退出之前,将它们弹出回寄存器,以便调用者获得预期的值。所以我们可以自由使用它们。

因此

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计