-
-
[翻译]fuzzer开发 1:一个新机器
-
发表于: 2024-9-4 09:40 6524
-
引言 && 向 Gamozolabs 致敬
很长一段时间以来,我一直想在博客上利用周末和空闲时间开发一个模糊测试器,但由于种种原因,我从未真正构思出一个既有教育意义又能为模糊测试社区提供某种实用性的项目。最近,由于Linux内核漏洞利用的原因,我对 Nyx 产生了浓厚的兴趣。Nyx 是一个基于 KVM 的虚拟机模糊测试器,可以用来对传统上难以进行模糊测试的目标进行快照模糊测试。很多时候(大多数时候?),我们想要模糊测试的东西并不自然地适合传统的模糊测试方法。当面对模糊测试中的目标复杂性时(暂且不论输入生成和细微差别),通常有两种方法。
一种方法是对目标进行“脑叶切除”,以便你可以隔离出你认为“有趣”的一小部分目标,然后只对此进行模糊测试。这可以有许多形式,比如将内核子系统的一小部分从内核中剥离出来,并将其编译成可以用传统模糊测试工具进行测试的用户态应用程序。这也可以表现为从 Web 浏览器中提取一个输入解析例程,并仅对解析逻辑进行模糊测试。然而,这种方法有其局限性,在理想情况下,我们希望对任何可能接触到或受到这种“有趣”目标逻辑影响的东西进行模糊测试。这种“脑叶切除”方法极大地减少了我们可以探索的目标状态的数量。试想一下,假设解析例程成功生成了一个数据结构,而这个数据结构随后被其他目标逻辑所使用,并且实际上暴露了一个漏洞。这种模糊测试方法未能探索这种可能性。
另一种方法是有效地将目标沙盒化,以便你可以对其执行环境施加一些控制,并对整个目标进行模糊测试。这是像 Nyx 这样的模糊测试器所采用的方法。通过对整个虚拟机进行快照模糊测试,我们能够以更大范围探索状态的方式来测试复杂的目标,例如 Web 浏览器或内核。Nyx 为我们提供了一种对整个虚拟机/系统进行快照模糊测试的方法。在我看来,这是模糊测试的理想方式,因为你大大缩小了一个人为设计的模糊测试环境与目标应用程序在“现实世界”中存在的方式之间的差距。当然,这里存在权衡,其中之一就是模糊测试工具本身的复杂性。但是,我认为考虑到复杂的本地代码应用程序可能存在无限漏洞的倾向,为了提高我们模糊测试工作流程的漏洞发现潜力,这种手工劳动和复杂性是值得的。
因此,为了理解 Nyx 的工作原理,以便我可以在其基础上构建一个模糊测试器,我重新观看了 gamozolabs(Brandon Falk)在 Nyx 论文上做的流媒体纸质评论。那是一个很棒的直播,Nyx 的作者参与了 Twitch 聊天,因此有一些不错的讨论,这场直播真正突出了 Nyx 在模糊测试中作为一个惊人工具的价值。但在直播中,除了 Nyx 之外,还有其他一些东西引起了我的兴趣!在直播中,Gamozo 描述了他以前构建的一种模糊测试架构,该架构利用 Bochs 模拟器对复杂目标和整个系统进行快照模糊测试。这种架构对我来说非常有趣且聪明,巧合的是,它与我和朋友为模糊测试设计的沙盒工具也有几个共同点。
这种模糊测试架构似乎符合我在博客上进行模糊测试器开发项目时个人看重的几个标准:
- 它的设计相对简单,
- 它允许添加几乎无穷无尽的内省(introspection)工具,
- 它适合迭代开发周期,
- 它可以扩展并在我为模糊测试购买的服务器上使用(但由于我还没有模糊测试器,所以还未使用!),
- 它可以对 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,但用例少得多,性能也低得多。与 QEMU 的“让我们模拟任何东西并以良好的性能完成它”方法不同,Bochs 采用了“让我们完全在软件中模拟整个 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
例程中,实际上包含了所有OS ELF加载器的正常逻辑,这些逻辑是执行自身重定位所必需的。这是不是很酷?但在我们深入探讨之前,第一个目标只是简单地构建并加载一个-static-pie
测试程序,并确保我们能正确地做到这一点。
为了确保我们已正确实现所有内容,我们将确保测试程序可以正确访问我们传递的任何命令行参数,并且能够执行和退出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
#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) { return 0; }
}
} |
记住,在这一点上,我们还没有对加载的程序进行沙盒处理,我们此时只是尝试将其加载到fuzzer虚拟地址空间中并跳转到它,并确保堆栈和一切都正确设置。因此,如果我们直接跳转到执行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解析代码,但不太记得它是如何工作的,所以我不得不重新学习一切,参考了维基百科: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
|
// Constituent parts of the 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,
} |
我们真正关心的是这些结构成员中的几个。首先,我们肯定需要知道入口地址,这是你应该开始执行的地方。因此,最终我们的代码将跳转到这个地址以开始执行测试程序。我们还关心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个可加载段。它们还有几个我们需要跟踪的属性:
-
Flags
描述了该段应具有的内存权限,我们有3种不同的内存保护方案:READ
,READ | EXECUTE
, 和READ | WRITE
。 -
Offset
描述了我们可以在物理文件内容中找到此段的位置。 -
PhysAddr
我们不太关心。 -
VirtAddr
是该段应加载到的虚拟地址,你可以看到第一个段的值是0x0000000000000000
,这意味着它对加载位置没有预期。 -
MemSiz
描述了该段在虚拟内存中的大小。 -
Align
描述了如何在虚拟内存中对齐段。
对于我们自己创建的-static-pie
ELF的非常简化的用例,我们基本上可以忽略解析的ELF的其他部分。
加载ELF
现在我们已经成功解析出ELF文件的相关属性,我们可以在内存中创建一个可执行镜像。目前,我选择仅在Linux环境中实现所需的功能,但没有理由不能将这个ELF加载到我们的内存中,即使我们是一个Windows用户态进程。这也就是这个设计很酷的原因之一。某个时候,可能有人会想要添加Windows支持,我们就会加上它。
我们需要做的第一件事是,根据标记为LOAD
的段的组合大小计算我们需要的虚拟内存大小。我们还必须记住,在未对齐的段之后有一些填充,为此,我采用了以下逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
// Read the executable file into memory let data = read(BOCHS_IMAGE).map_err(|_| LucidErr::from( "Unable to read binary data from Bochs binary" ))?;
// Parse ELF let elf = parse_elf(&data)?; // We need to iterate through all of the loadable program headers and // determine the size of the address range we need let mut mapping_size: usize = 0; for ph in elf.program_headers.iter() {
if ph.is_load() {
let end_addr = (ph.vaddr + ph.memsz) as usize;
if mapping_size < end_addr { mapping_size = end_addr; }
}
} // Round the mapping up to a page if mapping_size % PAGE_SIZE > 0 {
mapping_size += PAGE_SIZE - (mapping_size % PAGE_SIZE);
} |
我们遍历解析的ELF中的所有程序头,查看最大的“end_addr
”在哪里。这也考虑了段之间的页对齐填充。如你所见,我们还对最后一个段进行了页对齐,确保其大小四舍五入到最接近的页大小。此时我们知道需要多少内存来mmap
以容纳可加载的ELF段。我们在这里mmap
了一段连续的内存区域:
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
|
// Call `mmap` to map memory into our process to hold all of the loadable // program header contents in a contiguous range. Right now the perms will be // generic across the entire range as PROT_WRITE, // later we'll go back and `mprotect` them appropriately fn initial_mmap(size: usize) -> Result // We don't want to specify a fixed address
let addr = LOAD_TARGET as *mut libc::c_void;
// Length is straight forward
let length = size as libc:: size_t ;
// Set the protections for now to writable
let prot = libc::PROT_WRITE;
// Set the flags, this is anonymous memory
let flags = libc::MAP_ANONYMOUS | libc::MAP_PRIVATE;
// We don't have a file to map, so this is -1
let fd = -1 as libc::c_int;
// We don't specify an offset
let offset = 0 as libc::off_t;
// Call `mmap` and make sure it succeeds
let result = unsafe {
libc::mmap(
addr,
length,
prot,
flags,
fd,
offset
)
};
if result == libc::MAP_FAILED {
return Err(LucidErr::from( "Failed to `mmap` memory for Bochs" ));
}
Ok(result as usize)
} |
现在我们已经划出足够的内存来写入可加载的段。段数据当然是从文件中获取的,所以我们首先再次遍历程序头,提取我们需要的所有相关数据,以便将文件数据从内存中memcpy到我们刚创建的内存中。你可以在这里看到这个逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
|
let mut load_segments = Vec:: new ();
for ph in elf.program_headers.iter() {
if ph.is_load() {
load_segments.push((
ph.flags, // segment.0
ph.vaddr as usize, // segment.1
ph.memsz as usize, // segment.2
ph.offset as usize, // segment.3
ph.filesz as usize, // segment.4
));
}
}
|
在提取了段的元数据后,我们可以复制内容,并调用mprotect在内存中的段上,使其权限完全匹配我们之前讨论的Flags段元数据。这个逻辑在这里:
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
|
// Iterate through the loadable segments and change their perms and then // copy the data over for segment in load_segments.iter() {
// Copy the binary data over, the destination is where in our process
// memory we're copying the binary data to. The source is where we copy
// from, this is going to be an offset into the binary data in the file,
// len is going to be how much binary data is in the file, that's filesz
// This is going to be unsafe no matter what
let len = segment.4;
let dst = (addr + segment.1) as *mut u8;
let src = (elf.data[segment.3..segment.3 + len]).as_ptr();
unsafe {
std::ptr::copy_nonoverlapping(src, dst, len);
}
// Calculate the `mprotect` address by adding the mmap address plus the
// virtual address offset, we also mask off the last 0x1000 bytes so
// that we are always page-aligned as required by `mprotect`
let mprotect_addr = ((addr + segment.1) & !(PAGE_SIZE - 1))
as *mut libc::c_void;
// Get the length
let mprotect_len = segment.2 as libc:: size_t ;
// Get the protection
let mut mprotect_prot = 0 as libc::c_int;
if segment.0 & 0x1 == 0x1 { mprotect_prot |= libc::PROT_EXEC; }
if segment.0 & 0x2 == 0x2 { mprotect_prot |= libc::PROT_WRITE; }
if segment.0 & 0x4 == 0x4 { mprotect_prot |= libc::PROT_READ; }
// Call `mprotect` to change the mapping perms
let result = unsafe {
libc::mprotect(
mprotect_addr,
mprotect_len,
mprotect_prot
)
};
if result < 0 {
return Err(LucidErr::from( "Failed to `mprotect` memory for Bochs" ));
}
} |
在那之后,如果成功了,我们的ELF镜像基本上就完成了。我们可以跳转到它并开始执行!开玩笑的,我们首先需要为新“进程”设置一个堆栈,我了解到这是一个巨大的痛点。