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

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

2024-9-5 09:17
461

介绍

如果你还不知道,我们最近在博客上开发了一个模糊测试工具。我甚至不确定“模糊测试器”这个词是否合适来形容我们正在构建的东西,它几乎更像是一个暴露钩子的执行引擎?无论如何,如果你错过了第一集,你可以在这里英文版)补上。我们正在创建一个模糊测试器,它将一个静态构建的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 <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 访问的所有 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<LucidContext>) {
    // 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 时的预期设置调用约定。

1
2
3
4
5
6
7
8
9
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);

现在设置好调用约定后,我们可以像之前一样使用内联汇编。注意,我们已经将 syscall 指令替换为 call r12,即像普通函数一样调用我们的退出处理程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
__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 中用 Rust 实现的。我们直接从 Bochs 代码跳转到 Lucid 代码!

实现上下文切换

我们首先需要做的是为退出处理程序创建一个函数体。在 Rust 中,我们可以通过将函数声明为 extern C 函数并在内联汇编中给它一个标签,使其对 Bochs 可见(通过我们修改过的 Musl):

1
2
3
4
extern "C" { fn exit_handler(); }
global_asm!(
    ".global exit_handler",
    "exit_handler:",

当 Bochs 在 Lucid 下尝试进行系统调用时,这个函数将被跳转到。我们首先需要考虑的是,我们需要像内核在进入上下文切换例程时那样跟踪 Bochs 的状态。我们首先要保存的是通用寄存器。通过这样做,我们可以保留寄存器的状态,同时也解锁它们供我们自己使用。因为我们首先保存了它们,所以我们可以自由使用它们。记住,我们的调用约定使用 r13 来存储执行上下文寄存器库的基地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#[repr(C)]
#[derive(Default, Clone)]
pub struct RegisterBank {
    pub rax:    usize,
    rbx:        usize,
    rcx:        usize,
    pub rdx:    usize,
    pub rsi:    usize,
    pub rdi:    usize,
    rbp:        usize,
    rsp:        usize,
    pub r8:     usize,
    pub r9:     usize,
    pub r10:    usize,
    r11:        usize,
    r12:        usize,
    r13:        usize,
    r14:        usize,
    r15:        usize,
}

我们可以通过以下方式保存寄存器值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Save the GPRS to memory
"mov [r13 + 0x0], rax",
"mov [r13 + 0x8], rbx",
"mov [r13 + 0x10], rcx",
"mov [r13 + 0x18], rdx",
"mov [r13 + 0x20], rsi",
"mov [r13 + 0x28], rdi",
"mov [r13 + 0x30], rbp",
"mov [r13 + 0x38], rsp",
"mov [r13 + 0x40], r8",
"mov [r13 + 0x48], r9",
"mov [r13 + 0x50], r10",
"mov [r13 + 0x58], r11",
"mov [r13 + 0x60], r12",
"mov [r13 + 0x68], r13",
"mov [r13 + 0x70], r14",
"mov [r13 + 0x78], r15",

这将把寄存器值保存到内存中的寄存器库中以进行保留。接下来,我们需要保留 CPU 的标志(flags),幸运的是,有一条指令专门用于此目的,即将标志值推送到堆栈中的 pushfq

我们现在使用的是一个纯汇编的存根代码,但我们希望能够在某个时候开始使用 Rust,而这个时候就是现在。我们已经保存了目前能保存的所有状态,现在是调用一个真正的 Rust 函数的时候了,这将使编程和实现变得更容易。不过,要调用一个函数,我们需要设置寄存器值以符合函数调用的 ABI 约定。我们希望访问的两个数据是执行上下文和退出的原因。记住,它们分别在 r15r14 中。因此,我们可以简单地将它们放入用于传递函数参数的寄存器中,并立即调用一个名为 lucid_handler 的 Rust 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
// Save the CPU flags
"pushfq",
 
// Set up the function arguments for lucid_handler according to ABI
"mov rdi, r15", // Put the pointer to the context into RDI
"mov rsi, r14", // Put the exit reason into RSI
 
// At this point, we've been called into by Bochs, this should mean that
// at the beginning of our exit_handler, rsp was only 8-byte aligned and
// thus, by ABI, we cannot legally call into a Rust function since to do so
// requires rsp to be 16-byte aligned. Luckily, `pushfq` just 16-byte
// aligned the stack for us and so we are free to `call`
"call lucid_handler",

现在,我们可以自由地执行真正的 Rust 代码了!以下是目前的 lucid_handler 函数:

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
81
82
83
// This is where the actual logic is for handling the Bochs exit, we have to
// use no_mangle here so that we can call it from the assembly blob. We need
// to see why we've exited and dispatch to the appropriate function
#[no_mangle]
fn lucid_handler(context: *mut LucidContext, exit_reason: i32) {
    // We have to make sure this bad boy isn't NULL
    if context.is_null() {
        println!("LucidContext pointer was NULL");
        fatal_exit();
    }
 
    // Ensure that we have our magic value intact, if this is wrong, then we
    // are in some kind of really bad state and just need to die
    let magic = LucidContext::ptr_to_magic(context);
    if magic != CTX_MAGIC {
        println!("Invalid LucidContext Magic value: 0x{:X}", magic);
        fatal_exit();
    }
 
    // Before we do anything else, save the extended state
    let save_inst = LucidContext::ptr_to_save_inst(context);
    if save_inst.is_err() {
        println!("Invalid Save Instruction");
        fatal_exit();
    }
    let save_inst = save_inst.unwrap();
 
    // Get the save area
    let save_area =
        LucidContext::ptr_to_save_area(context, SaveDirection::FromBochs);
 
    if save_area == 0 || save_area % 64 != 0 {
        println!("Invalid Save Area");
        fatal_exit();
    }
 
    // Determine save logic
    match save_inst {
        SaveInst::XSave64 => {
            // Retrieve XCR0 value, this will serve as our save mask
            let xcr0 = unsafe { _xgetbv(0) } as u64;
 
            // Call xsave to save the extended state to Bochs save area
            unsafe { _xsave64(save_area as *mut u8, xcr0); }            
        },
        SaveInst::FxSave64 => {
            // Call fxsave to save the extended state to Bochs save area
            unsafe { _fxsave64(save_area as *mut u8); }
        },
        _ => (), // NoSave
    }
 
    // Try to convert the exit reason into BochsExit
    let exit_reason = BochsExit::try_from(exit_reason);
    if exit_reason.is_err() {
        println!("Invalid Bochs Exit Reason");
        fatal_exit();
    }
    let exit_reason = exit_reason.unwrap();
     
    // Determine what to do based on the exit reason
    match exit_reason {
        BochsExit::Syscall => {
            syscall_handler(context);
        },
    }
 
    // Restore extended state, determine restore logic
    match save_inst {
        SaveInst::XSave64 => {
            // Retrieve XCR0 value, this will serve as our save mask
            let xcr0 = unsafe { _xgetbv(0) } as u64;
 
            // Call xrstor to restore the extended state from Bochs save area
            unsafe { _xrstor64(save_area as *const u8, xcr0); }            
        },
        SaveInst::FxSave64 => {
            // Call fxrstor to restore the extended state from Bochs save area
            unsafe { _fxrstor64(save_area as *const u8); }
        },
        _ => (), // NoSave
    }
}

这里有一些重要的部分需要讨论。

扩展状态

让我们从保存区域(save area)的概念开始。那是什么呢?我们已经保存了通用寄存器和 CPU 标志,但处理器还有一个被称为“扩展状态”的部分,我们还没有保存。这可能包括浮点寄存器、向量寄存器以及处理器用于支持高级执行功能(如 SIMD 指令、加密等)的其他状态信息。这重要吗?很难说,我们不知道 Bochs 会做什么,它可能依赖于这些状态在函数调用之间得到保留,所以我认为我们可以先保存它们。

要保存这个状态,你只需执行适用于你的 CPU 的相应保存指令。为了在运行时动态地执行此操作,我会查询处理器是否至少支持两条保存指令,如果不支持,那么现在我们不支持其他任何东西。因此,当我们最初创建执行上下文时,我们会确定需要使用哪条保存指令,并将该答案存储在执行上下文中。然后在上下文切换时,我们可以动态地使用适当的扩展状态保存函数。这之所以有效,是因为我们还没有在 lucid_handler 中使用任何扩展状态,所以它仍然被保留。你可以在这里看到我在上下文初始化期间是如何检查的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pub fn new() -> Result<Self, LucidErr> {
        // Check for what kind of features are supported we check from most
        // advanced to least
        let save_inst = if std::is_x86_feature_detected!("xsave") {
            SaveInst::XSave64
        } else if std::is_x86_feature_detected!("fxsr") {
            SaveInst::FxSave64
        } else {
            SaveInst::NoSave
        };
 
        // Get save area size
        let save_size: usize = match save_inst {
            SaveInst::NoSave => 0,
            _ => calc_save_size(),
        };

其工作原理是处理器接受一个指向内存的指针,指示你希望保存的位置以及希望保存的内容,例如具体的状态。我只是最大化了我希望保存的状态量,并询问CPU这需要多少内存:

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
// Standalone function to calculate the size of the save area for saving the
// extended processor state based on the current processor's features. `cpuid`
// will return the save area size based on the value of the XCR0 when ECX==0
// and EAX==0xD. The value returned to EBX is based on the current features
// enabled in XCR0, while the value returned in ECX is the largest size it
// could be based on CPU capabilities. So out of an abundance of caution we use
// the ECX value. We have to preserve EBX or rustc gets angry at us. We are
// assuming that the fuzzer and Bochs do not modify the XCR0 at any time. 
fn calc_save_size() -> usize {
    let save: usize;
    unsafe {
        asm!(
            "push rbx",
            "mov rax, 0xD",
            "xor rcx, rcx",
            "cpuid",
            "pop rbx",
            out("rax") _,       // Clobber
            out("rcx") save,    // Save the max size
            out("rdx") _,       // Clobbered by CPUID output (w eax)
        );
    }
 
    // Round up to the nearest page size
    (save + PAGE_SIZE - 1) & !(PAGE_SIZE - 1)
}

我对结果进行页对齐,然后在执行上下文初始化期间映射该内存,并将内存地址保存到执行状态中。现在在lucid_handler的运行时,我们可以保存扩展状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Determine save logic
    match save_inst {
        SaveInst::XSave64 => {
            // Retrieve XCR0 value, this will serve as our save mask
            let xcr0 = unsafe { _xgetbv(0) } as u64;
 
            // Call xsave to save the extended state to Bochs save area
            unsafe { _xsave64(save_area as *mut u8, xcr0); }            
        },
        SaveInst::FxSave64 => {
            // Call fxsave to save the extended state to Bochs save area
            unsafe { _fxsave64(save_area as *mut u8); }
        },
        _ => (), // NoSave
    }

目前,我们处理的退出原因只是系统调用,因此我们调用我们的系统调用处理程序,然后在返回到exit_handler汇编存根之前恢复扩展状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Determine what to do based on the exit reason
    match exit_reason {
        BochsExit::Syscall => {
            syscall_handler(context);
        },
    }
 
    // Restore extended state, determine restore logic
    match save_inst {
        SaveInst::XSave64 => {
            // Retrieve XCR0 value, this will serve as our save mask
            let xcr0 = unsafe { _xgetbv(0) } as u64;
 
            // Call xrstor to restore the extended state from Bochs save area
            unsafe { _xrstor64(save_area as *const u8, xcr0); }            
        },
        SaveInst::FxSave64 => {
            // Call fxrstor to restore the extended state from Bochs save area
            unsafe { _fxrstor64(save_area as *const u8); }
        },
        _ => (), // NoSave
    }

让我们看看如何处理系统调用。

实现系统调用

当我们正常运行测试程序时(不在 Lucid 下),我们会得到以下输出:

1
2
3
4
5
6
7
8
9
Argument count: 1
Args:
   -./test
Test alive!
Test alive!
Test alive!
Test alive!
Test alive!
g_lucid_ctx: 0

而当我们使用 strace 运行它时,我们可以看到进行了哪些系统调用:

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
execve("./test", ["./test"], 0x7ffca76fee90 /* 49 vars */) = 0
arch_prctl(ARCH_SET_FS, 0x7fd53887f5b8) = 0
set_tid_address(0x7fd53887f7a8)         = 850649
ioctl(1, TIOCGWINSZ, {ws_row=40, ws_col=110, ws_xpixel=0, ws_ypixel=0}) = 0
writev(1, [{iov_base="Argument count: 1", iov_len=17}, {iov_base="\n", iov_len=1}], 2Argument count: 1
) = 18
writev(1, [{iov_base="Args:", iov_len=5}, {iov_base="\n", iov_len=1}], 2Args:
) = 6
writev(1, [{iov_base="   -./test", iov_len=10}, {iov_base="\n", iov_len=1}], 2   -./test
) = 11
writev(1, [{iov_base="Test alive!", iov_len=11}, {iov_base="\n", iov_len=1}], 2Test alive!
) = 12
nanosleep({tv_sec=1, tv_nsec=0}, 0x7ffc2fb55470) = 0
writev(1, [{iov_base="Test alive!", iov_len=11}, {iov_base="\n", iov_len=1}], 2Test alive!
) = 12
nanosleep({tv_sec=1, tv_nsec=0}, 0x7ffc2fb55470) = 0
writev(1, [{iov_base="Test alive!", iov_len=11}, {iov_base="\n", iov_len=1}], 2Test alive!
) = 12
nanosleep({tv_sec=1, tv_nsec=0}, 0x7ffc2fb55470) = 0
writev(1, [{iov_base="Test alive!", iov_len=11}, {iov_base="\n", iov_len=1}], 2Test alive!
) = 12
nanosleep({tv_sec=1, tv_nsec=0}, 0x7ffc2fb55470) = 0
writev(1, [{iov_base="Test alive!", iov_len=11}, {iov_base="\n", iov_len=1}], 2Test alive!
) = 12
nanosleep({tv_sec=1, tv_nsec=0}, 0x7ffc2fb55470) = 0
writev(1, [{iov_base="g_lucid_ctx: 0", iov_len=14}, {iov_base="\n", iov_len=1}], 2g_lucid_ctx: 0
) = 15
exit_group(0)                           = ?
+++ exited with 0 +++

我们看到前两个系统调用与进程创建有关,我们不需要担心这些,因为我们的进程已经创建并加载到内存中。其他的系统调用是我们需要处理的,比如 set_tid_addressioctlwritev。我们暂时不担心 exit_group,因为如果我们正在进行快照模糊测试,Bochs 不应该退出,这将是一个致命的退出条件。

因此,我们可以使用保存的寄存器库信息从 eax 中提取系统调用号,并将其分派到相应的系统调用函数!你可以在这里看到该逻辑:

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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
// This is where we process Bochs making a syscall. All we need is a pointer to
// the execution context, and we can then access the register bank and all the
// peripheral structures we need
#[allow(unused_variables)]
pub fn syscall_handler(context: *mut LucidContext) {
    // Get a handle to the register bank
    let bank = LucidContext::get_register_bank(context);
 
    // Check what the syscall number is
    let syscall_no = (*bank).rax;
 
    // Get the syscall arguments
    let arg1 = (*bank).rdi;
    let arg2 = (*bank).rsi;
    let arg3 = (*bank).rdx;
    let arg4 = (*bank).r10;
    let arg5 = (*bank).r8;
    let arg6 = (*bank).r9;
 
    match syscall_no {
        // ioctl
        0x10 => {
            //println!("Handling ioctl()...");
            // Make sure the fd is 1, that's all we handle right now?
            if arg1 != 1 {
                println!("Invalid `ioctl` fd: {}", arg1);
                fatal_exit();
            }
 
            // Check the `cmd` argument
            match arg2 as u64 {
                // Requesting window size
                libc::TIOCGWINSZ => {  
                    // Arg 3 is a pointer to a struct winsize
                    let winsize_p = arg3 as *mut libc::winsize;
 
                    // If it's NULL, return an error, we don't set errno yet
                    // that's a weird problem
                    // TODO: figure out that whole TLS issue yikes
                    if winsize_p.is_null() {
                        (*bank).rax = usize::MAX;
                        return;
                    }
 
                    // Deref the raw pointer
                    let winsize = unsafe { &mut *winsize_p };
 
                    // Set to some constants
                    winsize.ws_row      = WS_ROW;
                    winsize.ws_col      = WS_COL;
                    winsize.ws_xpixel   = WS_XPIXEL;
                    winsize.ws_ypixel   = WS_YPIXEL;
 
                    // Return success
                    (*bank).rax = 0;
                },
                _ => {
                    println!("Unhandled `ioctl` argument: 0x{:X}", arg1);
                    fatal_exit();
                }
            }
        },
        // writev
        0x14 => {
            //println!("Handling writev()...");
            // Get the fd
            let fd = arg1 as libc::c_int;
 
            // Make sure it's an fd we handle
            if fd != STDOUT {
                println!("Unhandled writev fd: {}", fd);
            }
 
            // An accumulator that we return
            let mut bytes_written = 0;
 
            // Get the iovec count
            let iovcnt = arg3 as libc::c_int;
 
            // Get the pointer to the iovec
            let mut iovec_p = arg2 as *const libc::iovec;
 
            // If the pointer was NULL, just return error
            if iovec_p.is_null() {
                (*bank).rax = usize::MAX;
                return;
            }
 
            // Iterate through the iovecs and write the contents
            green!();
            for i in 0..iovcnt {
                bytes_written += write_iovec(iovec_p);
 
                // Update iovec_p
                iovec_p = unsafe { iovec_p.offset(1 + i as isize) };
            }
            clear!();
 
            // Update return value
            (*bank).rax = bytes_written;
        },
        // nanosleep
        0x23 => {
            //println!("Handling nanosleep()...");
            (*bank).rax = 0;
        },
        // set_tid_address
        0xDA => {
            //println!("Handling set_tid_address()...");
            // Just return Boch's pid, no need to do anything
            (*bank).rax = BOCHS_PID as usize;
        },
        _ => {
            println!("Unhandled Syscall Number: 0x{:X}", syscall_no);
            fatal_exit();
        }
    }
}

就这样!扮演内核的感觉有点有趣。现在我们的测试程序做的事情不多,但我敢打赌,当我们使用 Bochs 时,我们将不得不弄清楚如何处理文件之类的事情,但那是以后的事。现在我们要做的就是通过 rax 设置返回码,然后优雅地返回到 exit_handler 存根并返回到 Bochs。

优雅地返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Restore the flags
"popfq",
 
// Restore the GPRS
"mov rax, [r13 + 0x0]",
"mov rbx, [r13 + 0x8]",
"mov rcx, [r13 + 0x10]",
"mov rdx, [r13 + 0x18]",
"mov rsi, [r13 + 0x20]",
"mov rdi, [r13 + 0x28]",
"mov rbp, [r13 + 0x30]",
"mov rsp, [r13 + 0x38]",
"mov r8, [r13 + 0x40]",
"mov r9, [r13 + 0x48]",
"mov r10, [r13 + 0x50]",
"mov r11, [r13 + 0x58]",
"mov r12, [r13 + 0x60]",
"mov r13, [r13 + 0x68]",
"mov r14, [r13 + 0x70]",
"mov r15, [r13 + 0x78]",
 
// Return execution back to Bochs!
"ret"

我们恢复 CPU 标志,恢复通用寄存器,然后我们简单地执行 ret,就好像我们完成了函数调用一样。不要忘记我们在从 lucid_context 返回之前已经恢复了扩展状态。

结论

就这样,我们拥有了一种能够处理从 Bochs 到模糊测试器的上下文切换的基础设施。它无疑会发生变化并需要重构,但理念将保持相似。我们可以看到下面的输出展示了测试程序在 Lucid 下运行,我们自己处理系统调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[08:15:56] lucid> Loading Bochs...
[08:15:56] lucid> Bochs mapping: 0x10000 - 0x18000
[08:15:56] lucid> Bochs mapping size: 0x8000
[08:15:56] lucid> Bochs stack: 0x7F8A50FCF000
[08:15:56] lucid> Bochs entry: 0x11058
[08:15:56] lucid> Creating Bochs execution context...
[08:15:56] lucid> Starting Bochs...
Argument count: 4
Args:
   -./bochs
   -lmfao
   -hahahah
   -yes!
Test alive!
Test alive!
Test alive!
Test alive!
Test alive!
g_lucid_ctx: 0x55f27f693cd0
Unhandled Syscall Number: 0xE7

下一步?

接下来,我们将针对 Musl 编译 Bochs,并着手让它运行。我们需要实现它的所有系统调用,并让它运行一个测试目标,我们将对其进行快照并反复运行。因此,下一篇博文应该是一个被系统调用沙盒化的 Bochs,同时对一个“Hello World”类型的目标进行快照并反复运行。敬请期待!

译者言

本文使用chatGPT-4o翻译而成,如有错误之处,请斧正
原文链接:https://h0mbre.github.io/Lucid_Context_Switching/


[课程]Android-CTF解题方法汇总!

收藏
免费 0
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回
//