首页
社区
课程
招聘
2
[翻译]fuzzer开发 2:沙盒化系统调用
发表于: 2024-9-5 09:17 6227

[翻译]fuzzer开发 2:沙盒化系统调用

2024-9-5 09:17
6227

介绍

如果你还不知道,我们最近在博客上开发了一个模糊测试工具。我甚至不确定“模糊测试器”这个词是否合适来形容我们正在构建的东西,它几乎更像是一个暴露钩子的执行引擎?无论如何,如果你错过了第一集,你可以在这里英文版)补上。我们正在创建一个模糊测试器,它将一个静态构建的Bochs模拟器加载到自身中,并在为Bochs维护一个沙盒的同时执行Bochs的逻辑。你可以这样理解:我们实在太懒了,不想从零开始实现自己的x86_64模拟器,所以我们就直接把一个完整的模拟器塞进了我们自己的进程中来使用。模糊测试器是用Rust编写的,而Bochs是一个C++代码库。Bochs是一个完整的系统模拟器,因此设备和其他所有东西都只是在软件中模拟的。对我们来说,这很棒,因为我们可以简单地对Bochs本身进行快照和恢复,从而对我们的目标进行快照模糊测试。因此,模糊测试器运行Bochs,而Bochs运行我们的目标。这使我们能够对任意复杂的目标进行快照模糊测试:网页浏览器、内核、网络堆栈等。在本集,我们将深入探讨将Bochs从系统调用中沙盒化的概念。我们不希望Bochs能够逃出其沙盒或从我们的环境外部获取任何数据。因此今天我们将研究我初次尝试的Bochs到模糊测试器的上下文切换实现,以处理系统调用。在未来,我们还需要实现从模糊测试器到Bochs的上下文切换,但目前让我们专注于系统调用。

这个模糊测试器最初由Brandon Falk构思并实现。

这篇文章不会涉及仓库的任何改动。

系统调用

系统调用是用户态自愿切换上下文到内核态,以便利用某些内核提供的实用程序或功能的方式。上下文切换简单来说就是改变代码执行的上下文。当你在进行整数加法、读写内存时,你的进程是在用户态执行的,并且是在你进程的虚拟地址空间内执行的。但是如果你想要打开一个套接字或文件,你就需要内核的帮助。为此,你进行一次系统调用,这将告诉处理器将执行模式从用户态切换到内核态。为了离开用户态、进入内核态并返回用户态,必须非常小心地在每一步准确保存执行状态。一旦你尝试执行系统调用,操作系统首先要做的事情就是在开始执行你请求的内核代码之前保存你当前的执行状态,这样当内核完成你的请求时,它可以优雅地返回到执行你的用户态进程。

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

  1. 识别出Bochs正在尝试进行系统调用(奇怪的是,这并不总是容易做到的)
  2. 拦截执行并重定向到适当的代码路径
  3. 保存Bochs的执行状态
  4. 执行Lucid的逻辑来替代内核的功能,可以把Lucid看作是Bochs的内核
  5. 通过恢复Bochs的状态优雅地返回到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 使我们在处理时更为简单。重要的是,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
#include
#include
 
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 访问的所有 Lucid 特定的数据结构。因此,在头文件中,我们目前定义了一个 lucid_ctx 数据结构,并创建了一个全局实例,名为 g_lucid_ctx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// An execution context definition that we use to switch contexts between the
// fuzzer and Bochs. This should contain all of the information we need to track
// all of the mutable state between snapshots that we need such as file data.
// This has to be consistent with LucidContext in context.rs
typedef struct lucid_ctx {
    // This must always be the first member of this struct
    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;
 
// Pointer to the global execution context, if running inside Lucid, this will
// point to the a struct lucid_ctx_t inside the Fuzzer
lucid_ctx_t *g_lucid_ctx;

在 Lucid 下启动程序

因此,目前在 Lucid 的主函数中,我们执行以下操作:

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

当我们从 Lucid 跳转到 Bochs 的入口点时,最早调用的函数之一是在源文件 dlstart.c 中的 Musl 函数 _dlstart_c。目前,我们在堆上创建该全局执行上下文,然后将该地址传递给任意选择的 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
27
28
29
30
pub fn start_bochs(bochs: Bochs, context: Box) {
    // rdx: we have to clear this register as the ABI specifies that exit
    // hooks are set when rdx is non-null at program start
    //
    // rax: arbitrarily used as a jump target to the program entry
    //
    // rsp: Rust does not allow you to use 'rsp' explicitly with in(), so we
    // have to manually set it with a `mov`
    //
    // r15: holds a pointer to the execution context, if this value is non-
    // null, then Bochs learns at start time that it is running under Lucid
    //
    // We don't really care about execution order as long as we specify clobbers
    // with out/lateout, that way the compiler doesn't allocate a register we
    // then immediately clobber
    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 so no conflict with 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
26
27
hidden void _dlstart_c(size_t *sp, size_t *dynv)
{
    // The start routine is handled in inline assembly in arch/x86_64/crt_arch.h
    // so we can just do this here. That function logic clobbers only a few
    // registers, so we can have the Lucid loader pass the address of the
    // Lucid context in r15, this is obviously not the cleanest solution but
    // it works for our purposes
    size_t r15;
    __asm__ __volatile__(
        "mov %%r15, %0" : "=r"(r15)
    );
 
    // If r15 was not 0, set the global context address for the g_lucid_ctx that
    // is in the Rust fuzzer
    if (r15 != 0) {
        g_lucid_ctx = (lucid_ctx_t *)r15;
 
        // We have to make sure this is true, we rely on this
        if ((void *)g_lucid_ctx != (void *)&g_lucid_ctx->exit_handler) {
            __asm__ __volatile__("int3");
        }
    }
 
    // We didn't get a g_lucid_ctx, so we can just run normally
    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个参数按顺序通过寄存器传递:rdirsirdxr10r8r9

这很直观,但语法有点令人费解,比如在那些 __asm__ __volatile__ ("syscall") 行上,很难看出它在做什么。让我们以最复杂的函数 __syscall6 为例,详细解析一下所有的语法。我们可以将汇编语法看作是一个格式字符串,就像打印时使用格式字符串一样,但这是为了生成代码:

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

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

  • nrax
  • a1rdi
  • a2rsi
  • a3rdx
  • a4rcx
  • a5r8
  • a6r9

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

有条件地调用 Lucid

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

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

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

为了更改这些函数,我首先更改了函数逻辑,以检查我们是否有全局 Lucid 执行上下文,如果没有,则执行正常的 Musl 函数,你可以在这里看到,我已经将正常的函数逻辑移到了一个名为 __syscall6_original 的单独函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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); }

然而,如果我们在 Lucid 下运行,我会通过显式设置寄存器 r12r15 来按照我们在上下文切换到 Lucid 时的预期设置调用约定。


[招生]科锐逆向工程师培训(2025年3月11日实地,远程教学同时开班, 第52期)!

收藏
免费 2
支持
分享
赞赏记录
参与人
雪币
留言
时间
PLEBFE
为你点赞!
2025-1-12 06:51
sinker_
为你点赞!
2024-10-6 19:30
最新回复 (0)
游客
登录 | 注册 方可回帖
返回

账号登录
验证码登录

忘记密码?
没有账号?立即免费注册