模糊测试开发:构建基于Bochs模拟器的快照模糊测试架构

本文详细介绍了基于Bochs模拟器的快照模糊测试架构开发过程,包括ELF文件的内存加载、堆栈构建以及执行环境设置,为复杂目标如Linux内核和浏览器提供高效的模糊测试解决方案。

模糊测试开发1:新机器的灵魂

2023年11月4日

引言与致谢Gamozolabs

长期以来,我一直想在博客上利用周末和空闲时间开发一个模糊测试器,但由于各种原因,始终无法构思出一个不仅具有教育价值,还能为模糊测试社区带来实用性的项目。最近,出于Linux内核漏洞利用的原因,我对Nyx产生了浓厚兴趣。Nyx是一个基于KVM的虚拟机监控程序模糊测试器,可用于对传统上难以模糊测试的目标进行快照模糊测试。

很多时候(大多数时候?),我们想要模糊测试的目标并不适合传统的模糊测试方法。面对模糊测试中的目标复杂性(暂时抛开输入生成和细节问题),通常有两种方法。

一种方法是"切除"目标,只隔离出你认为"有趣"的一小部分进行模糊测试。这可能表现为多种形式,比如从内核中提取一小部分子系统,编译成用户态应用程序,用传统模糊测试工具进行测试;或者从Web浏览器中提取输入解析例程,只模糊测试解析逻辑。但这种方法有其局限性,在理想情况下,我们希望模糊测试任何可能接触或受这个"有趣"目标逻辑产物影响的部分。这种"切除"方法在很大程度上减少了我们可以探索的目标状态量。想象一下,如果假设的解析例程成功生成了一个数据结构,随后被另一个目标逻辑使用并最终暴露了一个漏洞,这种模糊测试方法就无法探索这种可能性。

另一种方法是有效地沙盒化目标,以便对其执行环境施加一定控制,并完整地模糊测试目标。这就是Nyx等模糊测试器采用的方法。通过对整个虚拟机进行快照模糊测试,我们能够以探索更多状态的方式模糊测试复杂目标,如Web浏览器或内核。Nyx提供了一种对整个虚拟机/系统进行快照模糊测试的方法。我认为这是模糊测试的理想方式,因为它大大缩小了人为设计的模糊测试环境与目标应用程序在"现实世界"中存在方式之间的差距。

显然,这里存在权衡,其中之一是模糊测试工具本身的复杂性。但考虑到复杂本地代码应用程序存在无限漏洞的倾向,为了增加我们模糊测试工作流程发现漏洞的潜力,手动劳动和复杂性是值得的。

因此,在追求理解Nyx工作原理以便在其基础上构建模糊测试器的过程中,我重新观看了gamozolabs(Brandon Falk)对Nyx论文进行的流媒体论文评审。这是一个很棒的流媒体,Nyx作者也在Twitch聊天中,所以有一些很好的互动,流媒体真正突出了Nyx作为模糊测试工具的惊人效用。但在流媒体中,除了Nyx之外,还有其他东西引起了我的兴趣!在流媒体中,Gamozo描述了他之前构建的一个模糊测试架构,该架构利用Bochs模拟器对复杂目标和整个系统进行快照模糊测试。这个架构对我来说听起来非常有趣和巧妙,巧合的是,它与我和朋友一直在设计的用于模糊测试的沙盒工具具有几个共同属性。

这个模糊测试架构似乎满足了我个人在博客上进行模糊测试器开发项目时重视的几个标准:

  • 设计相对简单
  • 允许添加几乎无限的内省工具
  • 适合迭代开发周期
  • 可以扩展并用于我为模糊测试购买的服务器(但由于没有模糊测试器而尚未使用!)
  • 可以模糊测试Linux内核
  • 可以模糊测试其他操作系统和平台(Windows、MacOS)上的用户态和内核组件
  • 与现有的开源模糊测试工具相比,设计相当独特
  • 可以从头设计,与现有的灵活工具(如LibAFL)良好配合
  • 没有任何公开可用的源代码,因此我可以自由地按照我认为合适的方式从头实现
  • 可以设计成可移植的,即没有什么阻止我们在Windows上运行这个模糊测试器,而不仅仅是在Linux上
  • 将允许我进行大量学习和低级计算研究

考虑到所有这些因素,这似乎是在博客上实现的理想项目,因此我联系了Gamozo以确保他对此没有意见,因为我不想被视为利用他的想法追逐影响力,他非常慷慨并鼓励我这样做。非常感谢Gamozo分享如此多的内容,我们开始开发模糊测试器。

还要特别感谢@is_eqv和@ms_s3c,至少两位Nyx作者,他们总是非常友好和慷慨地花费时间/回答问题。身边有一些很棒的人。

另一个特别感谢@Kharosx0帮助我理解Bochs并回答我所有关于设计意图的问题,另一位非常慷慨的人,总是在Fuzzing discord上提供帮助。

杂项

如果您发现任何编程错误或对代码有挑剔之处,请告诉我。我已经尝试对所有内容进行大量注释,并且由于我在几个周末的时间里拼凑了这些内容,代码可能存在一些问题。我还没有真正充实存储库的外观,或文件将被称为什么,或者类似的事情,因此请对代码质量保持耐心。这主要是为了学习目的,此时它只是一个概念验证,用于将Bochs加载到内存中以解释架构的第一部分。

我决定暂时将项目命名为"Lucid",参考清醒梦,因为我们的模糊测试目标在模拟器中执行时处于某种梦境状态。

Bochs

什么是Bochs?好问题。Bochs是一个x86全系统模拟器,能够运行整个操作系统,具有软件模拟的硬件设备。简而言之,它是一个无JIT、更小、更简单的模拟工具,类似于QEMU,但用例少得多,性能也差得多。Bochs没有采用QEMU的"让我们模拟任何东西并以良好性能完成"的方法,而是采取了"让我们100%在软件中模拟整个x86系统,大部分情况下不担心性能"的方法。这种方法有明显的缺点,但如果你只对运行x86系统感兴趣,Bochs是一个很好的工具。我们将在模糊测试器中使用Bochs作为目标执行引擎。我们的目标代码将在Bochs内部运行。因此,例如,如果我们要模糊测试Linux内核,该内核将在Bochs内部生存和执行。Bochs是用C++编写的,显然仍在维护,但不要期望有太多代码更改或快速开发,上次发布是在两年多前。

模糊测试器架构

这里我们将讨论如何根据Gamozo在流媒体中提供的信息设计模糊测试器。简单来说,我们将创建一个"模糊测试器"进程,该进程将执行Bochs,而Bochs又执行我们的模糊测试目标。我们不会在每个模糊测试迭代中快照和恢复目标,而是重置包含目标和所有目标系统模拟状态的Bochs。通过快照和恢复Bochs,我们就是在快照和恢复我们的目标。

更深入一点,这种设置要求我们沙盒化Bochs并在我们的"模糊测试器"进程内部运行它。为了将Bochs与用户的操作系统和内核隔离,我们将沙盒化Bochs,使其无法与我们的操作系统交互。这使我们能够实现一些事情,但主要是这应该使Bochs具有确定性。正如Gamozo在流媒体中解释的那样,将Bochs与操作系统隔离可以防止Bochs访问任何随机/类随机数据源。这意味着我们将阻止Bochs向内核进行系统调用,以及执行任何检索硬件源数据(如CPUID或类似指令)的指令。我实际上还没有对后者考虑太多,但对于系统调用我有一个计划。通过将Bochs与操作系统隔离,我们可以期望它在每个模糊测试迭代中以相同的方式行为。给定模糊测试输入A,Bochs应该以完全相同的方式执行1万亿次连续迭代。

其次,这也意味着Bochs的整个状态将包含在我们的沙盒中,这应该使我们能够更轻松地重置Bochs的状态,而不是作为一个远程进程。在Bochs作为普通Linux进程执行的范例中,重置其状态并非易事,可能需要采取强硬的方法,例如在每个模糊测试迭代中在内核中进行页表遍历或更糟糕的方法。

因此,总的来说,我们的模糊测试设置应该如下所示:

为了提供沙盒环境,我们必须将可执行的Bochs映像加载到我们自己的模糊测试器进程中。为此,我选择将Bochs构建为ELF,然后将ELF加载到内存中的模糊测试器进程中。让我们深入探讨到目前为止是如何实现这一点的。

在内存中加载ELF

为了使加载Bochs到内存的这部分尽可能简单,我选择将Bochs编译为-static-pie ELF。这意味着构建的ELF对其加载位置没有期望。在其_start例程中,它实际上具有正常操作系统ELF加载器执行所有自身重定位所需的所有逻辑。这有多酷?但在我们过于超前之前,第一个目标只是简单地构建和加载一个-static-pie测试程序,并确保我们能正确完成。

为了确保我们正确实现了所有内容,我们将确保测试程序能够正确访问我们传递的任何命令行参数,并且能够执行和退出。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <unistd.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) { return 0; }
    }
}

请记住,此时我们根本不沙盒化加载的程序,此时我们只是尝试将其加载到模糊测试器虚拟地址空间中并跳转到它,确保堆栈和所有内容正确设置。因此,如果我们此时直接跳转到执行Bochs,可能会遇到不是真正问题的问题。

因此,编译测试程序并用readelf -l检查它,我们可以看到实际上有一个DYNAMIC段。可能是因为需要在上述_start例程期间执行重定位。

 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
dude@lol:~/lucid$ gcc test.c -o test -static-pie
dude@lol:~/lucid$ file test
test: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, BuildID[sha1]=6fca6026edb756fa32c966844b29529d579e83b9, for GNU/Linux 3.2.0, not stripped
dude@lol:~/lucid$ readelf -l test

Elf file type is DYN (Shared object file)
Entry point 0x9f50
There are 12 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000008158 0x0000000000008158  R      0x1000
  LOAD           0x0000000000009000 0x0000000000009000 0x0000000000009000
                 0x0000000000094d01 0x0000000000094d01  R E    0x1000
  LOAD           0x000000000009e000 0x000000000009e000 0x000000000009e000
                 0x00000000000285e0 0x00000000000285e0  R      0x1000
  LOAD           0x00000000000c6de0 0x00000000000c7de0 0x00000000000c7de0
                 0x0000000000005350 0x0000000000006a80  RW     0x1000
  DYNAMIC        0x00000000000c9c18 0x00000000000cac18 0x00000000000cac18
                 0x00000000000001b0 0x00000000000001b0  RW     0x8
  NOTE           0x00000000000002e0 0x00000000000002e0 0x00000000000002e0
                 0x0000000000000020 0x0000000000000020  R      0x8
  NOTE           0x0000000000000300 0x0000000000000300 0x0000000000000300
                 0x0000000000000044 0x0000000000000044  R      0x4
  TLS            0x00000000000c6de0 0x00000000000c7de0 0x00000000000c7de0
                 0x0000000000000020 0x0000000000000060  R      0x8
  GNU_PROPERTY   0x00000000000002e0 0x00000000000002e0 0x00000000000002e0
                 0x0000000000000020 0x0000000000000020  R      0x8
  GNU_EH_FRAME   0x00000000000ba110 0x00000000000ba110 0x00000000000ba110
                 0x0000000000001cbc 0x0000000000001cbc  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10
  GNU_RELRO      0x00000000000c6de0 0x00000000000c7de0 0x00000000000c7de0
                 0x0000000000003220 0x0000000000003220  R      0x1

 Section to Segment mapping:
  Segment Sections...
   00     .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .rela.dyn .rela.plt 
   01     .init .plt .plt.got .plt.sec .text __libc_freeres_fn .fini 
   02     .rodata .stapsdt.base .eh_frame_hdr .eh_frame .gcc_except_table 
   03     .tdata .init_array .fini_array .data.rel.ro .dynamic .got .data __libc_subfreeres __libc_IO_vtables __libc_atexit .bss __libc_freeres_ptrs 
   04     .dynamic 
   05     .note.gnu.property 
   06     .note.gnu.build-id .note.ABI-tag 
   07     .tdata .tbss 
   08     .note.gnu.property 
   09     .eh_frame_hdr 
   10     
   11     .tdata .init_array .fini_array .data.rel.ro .dynamic .got

对于我们的加载目的,我们实际上关心这个ELF映像的哪些部分?我们可能不需要大部分信息来简单地加载和运行ELF。起初,我不知道需要什么,所以我只是解析了所有的ELF头。

请记住,这个ELF解析代码不需要健壮,因为我们只使用它来解析和加载我们自己的可执行文件,我只是确保在解析各种头时构建的可执行文件中没有明显的问题。

ELF头

我以前写过ELF解析代码,但不太记得它是如何工作的,所以我不得不从Wikipedia重新学习一切:https://en.wikipedia.org/wiki/Executable_and_Linkable_Format。幸运的是,我们不是试图解析任意ELF,只是解析我们自己构建的64位ELF。目标是从ELF头信息中创建一个数据结构,提供我们将ELF加载到内存中所需的数据。所以我跳过了一些ELF头值,但最终将ELF头解析为以下数据结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Elf的组成部分
#[derive(Debug)]
pub struct ElfHeader {
    pub entry: u64,
    pub phoff: u64,
    pub shoff: u64,
    pub phentsize: u16,
    pub phnum: u16,
    pub shentsize: u16,
    pub shnum: u16,
    pub shrstrndx: u16,
}

我们真正关心其中一些结构成员。首先,我们肯定需要知道entry,这是你应该开始执行的地方。因此,最终我们的代码将跳转到这个地址开始执行测试程序。我们还关心phoff。这是ELF中的偏移量,我们可以在那里找到程序头表的基础。这基本上只是一个程序头数组。与phoff一起,我们还需要知道该数组中的条目数以及每个条目的大小,以便解析它们。这就是phnum和phentsize分别派上用场的地方。给定数组中索引0的偏移量、数组成员的数量以及每个成员的大小,我们可以解析程序头。

单个程序头,即数组中的单个条目,可以合成以下数据结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#[derive(Debug)]
pub struct ProgramHeader {
    pub typ: u32,
    pub flags: u32,
    pub offset: u64,
    pub vaddr: u64,
    pub paddr: u64,
    pub filesz: u64,
    pub memsz: u64,
    pub align: u64, 
}

这些程序头描述了ELF映像中应该在内存中存在的段。特别是,我们关心类型为LOAD的可加载段,因为这些段是加载ELF映像时必须考虑的段。以我们的readelf输出为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000008158 0x0000000000008158  R      0x1000
  LOAD           0x0000000000009000 0x0000000000009000 0x0000000000009000
                 0x0000000000094d01 0x0000000000094d01  R E    0x1000
  LOAD           0x000000000009e000 0x000000000009e000 0x000000000009e000
                 0x00000000000285e0 0x00000000000285e0  R      0x1000
  LOAD           0x00000000000c6de0 0x00000000000c7de0 0x00000000000c7de0
                 0x0000000000005350 0x0000000000006a80  RW     0x1000

我们可以看到有4个可加载段。它们还有

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