引言 && 向 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 <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; }
}
}
|
记住,在这一点上,我们还没有对加载的程序进行沙盒处理,我们此时只是尝试将其加载到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 | #[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 | let data = read(BOCHS_IMAGE).map_err(|_| LucidErr::from(
"Unable to read binary data from Bochs binary" ))?;
let elf = parse_elf(&data)?;
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; }
}
}
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 | fn initial_mmap(size: usize) -> Result<usize, LucidErr> {
let addr = LOAD_TARGET as *mut libc::c_void;
let length = size as libc:: size_t ;
let prot = libc::PROT_WRITE;
let flags = libc::MAP_ANONYMOUS | libc::MAP_PRIVATE;
let fd = -1 as libc::c_int;
let offset = 0 as libc::off_t;
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,
ph.vaddr as usize,
ph.memsz as usize,
ph.offset as usize,
ph.filesz as usize,
));
}
}
|
在提取了段的元数据后,我们可以复制内容,并调用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 | for segment in load_segments.iter() {
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);
}
let mprotect_addr = ((addr + segment.1) & !(PAGE_SIZE - 1))
as *mut libc::c_void;
let mprotect_len = segment.2 as libc:: size_t ;
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; }
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镜像基本上就完成了。我们可以跳转到它并开始执行!开玩笑的,我们首先需要为新“进程”设置一个堆栈,我了解到这是一个巨大的痛点。
为Bochs设置堆栈
我在这上面花了很多时间,实际上可能还有一些bug!我会说这是最难的部分,因为其他部分基本上都是直截了当的。为了完成这部分,我严重依赖于这个描述如何构建x86 32位应用程序堆栈的资源:https://articles.manugarg.com/aboutelfauxiliaryvectors。
以下是从上述链接资源中摘取的一个非常有用的描述32位堆栈的图表:
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 | position content size (bytes) + comment
------------------------------------------------------------------------
stack pointer -> [ argc = number of args ] 4
[ argv[0] (pointer) ] 4 (program name)
[ argv[1] (pointer) ] 4
[ argv[..] (pointer) ] 4 * x
[ argv[n - 1] (pointer) ] 4
[ argv[n] (pointer) ] 4 (= NULL)
[ envp[0] (pointer) ] 4
[ envp[1] (pointer) ] 4
[ envp[..] (pointer) ] 4
[ envp[term] (pointer) ] 4 (= NULL)
[ auxv[0] (Elf32_auxv_t) ] 8
[ auxv[1] (Elf32_auxv_t) ] 8
[ auxv[..] (Elf32_auxv_t) ] 8
[ auxv[term] (Elf32_auxv_t) ] 8 (= AT_NULL vector)
[ padding ] 0 - 16
[ argument ASCIIZ strings ] >= 0
[ environment ASCIIZ str. ] >= 0
(0xbffffffc) [ end marker ] 4 (= NULL)
(0xc0000000) < bottom of stack > 0 (virtual)
------------------------------------------------------------------------
|
当我们在命令行上传递参数给进程时,比如ls / -laht
,Linux操作系统必须将ls
ELF加载到内存中并创建其环境。在这个例子中,我们还向进程传递了几个参数值,比如/
和-laht
。操作系统通过参数向量(即argv
的简写)将这些参数传递给进程,argv
是一个字符串指针的数组。参数的数量由参数计数(即argc
)表示。argv
的第一个成员通常是命令行上传递的可执行文件的名称,所以在我们的例子中它将是ls
。如你所见,堆栈上的第一件事,即堆栈的顶部,位于堆栈地址范围的下端,是argc,接下来是所有表示程序参数的字符串数据指针。重要的是要注意数组在末尾是以NULL
终止的。
在那之后,我们有一个类似的数据结构,即envp
数组,它是表示环境变量的字符串数据指针的数组。你可以通过在GDB下运行一个程序并使用命令show environment
自己检索这些数据,环境变量通常以“KEY=VALUE”的形式表示,例如在我的机器上,语言环境变量的键值对是“LANG=en_US.UTF-8
”。对于我们的目的,我们可以忽略环境变量。这个向量也是以NULL
终止的。
接下来是辅助向量(auxiliary vector),这对我们来说非常重要。此信息详细描述了程序的几个方面。向量中的这些辅助条目每个条目占用16字节。它们像我们的环境变量条目一样包含一个键和值,但这些基本上是u64值。对于测试程序,我们实际上可以通过在GDB中使用info aux
来转储辅助信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | gef➤ info aux
33 AT_SYSINFO_EHDR System-supplied DSO's ELF header 0x7ffff7f2e000
51 ??? 0xe30
16 AT_HWCAP Machine-dependent CPU capability hints 0x1f8bfbff
6 AT_PAGESZ System page size 4096
17 AT_CLKTCK Frequency of times () 100
3 AT_PHDR Program headers for program 0x7ffff7f30040
4 AT_PHENT Size of program header entry 56
5 AT_PHNUM Number of program headers 12
7 AT_BASE Base address of interpreter 0x0
8 AT_FLAGS Flags 0x0
9 AT_ENTRY Entry point of program 0x7ffff7f39f50
11 AT_UID Real user ID 1000
12 AT_EUID Effective user ID 1000
13 AT_GID Real group ID 1000
14 AT_EGID Effective group ID 1000
23 AT_SECURE Boolean, was exec setuid-like? 0
25 AT_RANDOM Address of 16 random bytes 0x7fffffffe3b9
26 AT_HWCAP2 Extension of AT_HWCAP 0x2
31 AT_EXECFN File name of executable 0x7fffffffefe2 "/home/dude/lucid/test"
15 AT_PLATFORM String identifying platform 0x7fffffffe3c9 "x86_64"
0 AT_NULL End of vector 0x0
|
键在左边,值在右边。例如,在堆栈上我们可以预期AT_PHNUM
的值是0x5,这描述了程序头的数量,伴随的值是12
。我们可以转储堆栈并看到这一点。
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 | gef➤ x /400gx $rsp
0x7fffffffe0b0: 0x0000000000000001 0x00007fffffffe3d6
0x7fffffffe0c0: 0x0000000000000000 0x00007fffffffe3ec
0x7fffffffe0d0: 0x00007fffffffe3fc 0x00007fffffffe44e
0x7fffffffe0e0: 0x00007fffffffe461 0x00007fffffffe475
0x7fffffffe0f0: 0x00007fffffffe4a2 0x00007fffffffe4b9
0x7fffffffe100: 0x00007fffffffe4e5 0x00007fffffffe505
0x7fffffffe110: 0x00007fffffffe52e 0x00007fffffffe542
0x7fffffffe120: 0x00007fffffffe559 0x00007fffffffe56c
0x7fffffffe130: 0x00007fffffffe588 0x00007fffffffe59d
0x7fffffffe140: 0x00007fffffffe5b8 0x00007fffffffe5c5
0x7fffffffe150: 0x00007fffffffe5da 0x00007fffffffe60e
0x7fffffffe160: 0x00007fffffffe61d 0x00007fffffffe646
0x7fffffffe170: 0x00007fffffffe667 0x00007fffffffe674
0x7fffffffe180: 0x00007fffffffe67d 0x00007fffffffe68d
0x7fffffffe190: 0x00007fffffffe69b 0x00007fffffffe6ad
0x7fffffffe1a0: 0x00007fffffffe6be 0x00007fffffffeca0
0x7fffffffe1b0: 0x00007fffffffecc1 0x00007fffffffeccd
0x7fffffffe1c0: 0x00007fffffffecde 0x00007fffffffed34
0x7fffffffe1d0: 0x00007fffffffed63 0x00007fffffffed73
0x7fffffffe1e0: 0x00007fffffffed8b 0x00007fffffffedad
0x7fffffffe1f0: 0x00007fffffffedc4 0x00007fffffffedd8
0x7fffffffe200: 0x00007fffffffedf8 0x00007fffffffee02
0x7fffffffe210: 0x00007fffffffee21 0x00007fffffffee2c
0x7fffffffe220: 0x00007fffffffee34 0x00007fffffffee46
0x7fffffffe230: 0x00007fffffffee65 0x00007fffffffee7c
0x7fffffffe240: 0x00007fffffffeed1 0x00007fffffffef7b
0x7fffffffe250: 0x00007fffffffef8d 0x00007fffffffefc3
0x7fffffffe260: 0x0000000000000000 0x0000000000000021
0x7fffffffe270: 0x00007ffff7f2e000 0x0000000000000033
0x7fffffffe280: 0x0000000000000e30 0x0000000000000010
0x7fffffffe290: 0x000000001f8bfbff 0x0000000000000006
0x7fffffffe2a0: 0x0000000000001000 0x0000000000000011
0x7fffffffe2b0: 0x0000000000000064 0x0000000000000003
0x7fffffffe2c0: 0x00007ffff7f30040 0x0000000000000004
0x7fffffffe2d0: 0x0000000000000038 0x0000000000000005
0x7fffffffe2e0: 0x000000000000000c 0x0000000000000007
|
在数据的末端,可以看到在0x7fffffffe2d8
处有键0x5
,而在0x7fffffffe2e0
处有值0xc
(即十六进制的12)。为了正确加载我们的ELF,我们需要其中的一些信息,因为ELF的_start
例程需要其中一些信息来正确设置环境。我在堆栈中包含了以下这些项,它们可能并非全都必要:
AT_ENTRY
,保存程序入口点;
AT_PHDR
,这是指向程序头数据的指针;
AT_PHNUM
,这是程序头的数量;
AT_RANDOM
,这是指向16字节随机种子的指针,该值应由内核放置。这个16字节的值用作RNG(随机数生成器)的种子,以构建堆栈的canary值。我发现我们加载的程序实际上确实需要这个信息,因为在最初的测试中,我遇到了NULL指针解引用的问题,随后我将这个auxp对设置为0x4141414141414141
,并在尝试访问该地址时崩溃了。对于我们的目的来说,我们并不关心堆栈canary值是否具有加密安全性,因此我只是将另一个指针指向程序入口,因为这是保证存在的。
AT_NULL
,用于终止辅助向量。
因此,在考虑了所有这些值后,我们现在知道了构建程序堆栈所需的所有数据。
分配堆栈
首先,我们需要分配内存来容纳Bochs的堆栈,因为我们需要知道它映射的位置,以便制定我们的指针。我们将知道表示堆栈数据的向量中的偏移量,但除非提前知道堆栈在内存中的位置,否则我们无法知道绝对地址。分配堆栈非常简单,因为我只是像处理程序段一样使用了mmap
。现在我使用了1MB
的堆栈,这似乎足够大了。
构建堆栈数据
在我的堆栈创建逻辑中,我从底部开始创建堆栈,然后将值插入到堆栈顶部。
因此,我们放置到堆栈的第一个值是图表中的“end-marker”,在Rust中它只是一个0u64
。
接下来,我们需要将所有需要的字符串放入堆栈,即我们的命令行参数。为了分隔fuzzer的命令行参数和Bochs的命令行参数,我创建了一个命令行参数--bochs-args
,它用来作为这两类参数之间的分界点。--bochs-args
之后的每个参数都是给Bochs的。我遍历所有提供的命令行参数,然后将它们放入堆栈中。我还记录了每个字符串参数的长度,这样以后在需要在argv
向量中放置指向字符串的指针时,我们可以计算它们的绝对地址。顺便说一句,我还确保在字符串推送过程中保持8字节对齐,这样我们就不必处理任何奇怪的指针值。这不是必需的,但使得我更容易推理堆栈状态。这是通过以下逻辑执行的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | let mut stack_data = Vec:: new ();
push_u64(&mut stack_data, 0u64);
let args = parse_bochs_args();
let mut arg_lens = Vec:: new ();
for arg in args.iter() {
let old_len = stack_data.len();
push_string(&mut stack_data, arg.to_string());
let arg_len = stack_data.len() - old_len;
arg_lens.push(arg_len);
}
|
推送字符串的操作如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | fn push_string(stack: &mut Vec<u8>, string: String) {
let mut bytes = string.as_bytes().to_vec();
bytes.push(0x0);
if bytes.len() % U64_SIZE > 0 {
let pad = U64_SIZE - (bytes.len() % U64_SIZE);
for _ in 0..pad { bytes.push(0x0); }
}
for &byte in bytes.iter().rev() {
stack.insert(0, byte);
}
}
|
然后我们添加一些填充和辅助向量成员:
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 | push_u64(&mut stack_data, 0u64);
push_u64(&mut stack_data, 0u64);
push_u64(&mut stack_data, 0u64);
push_u64(&mut stack_data, elf.elf_header.entry + base as u64);
push_u64(&mut stack_data, 9u64);
push_u64(&mut stack_data, (base + ELF_HDR_SIZE) as u64);
push_u64(&mut stack_data, 3u64);
push_u64(&mut stack_data, elf.program_headers.len() as u64);
push_u64(&mut stack_data, 5u64);
push_u64(&mut stack_data, elf.elf_header.entry + base as u64);
push_u64(&mut stack_data, 25u64);
|
然后,由于我们忽略了环境变量,我们只是在堆栈上推送了一个NULL
指针,并且还推送了终止argv
向量的NULL
指针:
1 2 3 4 5 | push_u64(&mut stack_data, 0u64);
push_u64(&mut stack_data, 0u64);
|
这是我花了很多时间调试的地方。现在我们必须将指针添加到我们的参数中。为此,我首先计算了堆栈数据的总长度,因为我们已经知道了所有变量部分的长度,比如参数的数量和所有字符串的长度。我们已经知道了当前存在的堆栈长度,其中包括了字符串,并且我们知道还剩下多少指针和成员需要添加到堆栈中(参数数量和argc
)。既然我们知道这些信息,我们就可以在将argv
指针推入堆栈时计算字符串数据的绝对地址。计算长度的方法如下:
1 2 3 4 5 6 7 8 9 | let mut stack_length = stack_data.len();
stack_length += args.len() * POINTER_SIZE;
stack_length += std::mem::size_of::<u64>();
|
接下来,我们从堆栈的底部开始,创建一个可移动的偏移量,该偏移量将通过堆栈跟踪,直到每个字符串的开始位置,以便我们可以计算其绝对地址。偏移量表示从堆栈顶部到达的深度。一开始,偏移量是可以达到的最大值,因为它位于堆栈的底部(高内存地址)。我们从中减去值,以指向我们推入堆栈的每个argv
字符串的开头。因此,堆栈的底部看起来是这样的:
1 2 3 4 | NULL
string_1
string_2
end-marker <--- offset
|
因此,凭借我们记录的参数及其长度,我们可以在每次迭代参数长度时调整偏移量,以指向字符串的开头。不过,有一个陷阱,在第一次迭代时,我们必须考虑到结束标记及其8字节。因此,逻辑如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | for (idx, arg_len) in arg_lens.iter().enumerate() {
if idx == 0 {
curr_offset -= arg_len + U64_SIZE;
}
else {
curr_offset -= arg_len;
}
let absolute_addr = (stack_addr + curr_offset) as u64;
push_u64(&mut stack_data, absolute_addr);
}
|
这非常酷!而且看起来确实有效?最后,我们用argc
封装堆栈,并完成了在向量中填充所有堆栈数据的操作。接下来,我们要将数据复制到堆栈分配中,这很简单,所以这里没有代码片段。
我认为值得注意的最后一件事是,我创建了一个名为STACK_DATA_MAX
的常量,堆栈数据的长度不能超过这个可调节的值。我们使用这个值在将程序加载到内存中并开始执行时设置RSP
。RSP
被设置为绝对最低地址,即堆栈分配大小减去STACK_DATA_MAX
。这样,当堆栈增长时,我们为堆栈的增长留下了尽可能多的空间,因为堆栈在内存中是向下增长的。
执行加载的程序
此时,一切都应该在内存中正确设置好了,我们只需跳转到目标代码并开始执行。目前,我还没有完善上下文切换例程或其他任何东西,我们实际上只是跳转到程序并执行它,希望一切顺利。实现这一目标的代码非常简单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | pub fn start_bochs(bochs: Bochs) {
unsafe {
asm!(
"mov rax, {0}" ,
"mov rsp, {1}" ,
"xor rdx, rdx" ,
"jmp rax" ,
in(reg) bochs.entry,
in(reg) bochs.rsp,
);
}
}
|
我们清除RDX
的原因是,如果_start
例程看到RDX
中的值不为零,它将解释为我们试图注册一个位于RDX
地址的挂钩,以在程序退出时调用它。目前我们不需要运行这样的挂钩,所以暂时将其设置为NULL
。其他寄存器的值并不重要。我们将程序入口点移动到RAX
中,并将其用作长跳转目标,并提供我们手工制作的RSP
,以便程序有一个堆栈可以用来进行重定位并正确运行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | dude@lol:~ /lucid/target/release $ . /lucid --bochs-args -AAAAA -BBBBBBBBBB
[17:43:19] lucid> Loading Bochs...
[17:43:19] lucid> Bochs loaded { Entry: 0x19F50, RSP: 0x7F513F11C000 }
Argument count: 3
Args:
-. /bochs
--AAAAA
--BBBBBBBBBB
Test alive!
Test alive!
Test alive!
Test alive!
Test alive!
Test alive!
dude@lol:~ /lucid/target/release $
|
程序运行,解析我们的命令行参数,并退出而没有崩溃!所以看起来一切都已经准备就绪。通常这会是一个很好的停止点,但我出于病态的好奇……
Bochs会运行吗?
我们必须看看,对吧?首先,我们必须将Bochs编译为-static-pie
ELF,这本身就是一个头痛的问题,但我最终弄明白了。
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 | ude@lol:~ /lucid/target/release $ . /lucid --bochs-args -AAAAA -BBBBBBBBBB
[12:30:40] lucid> Loading Bochs...
[12:30:40] lucid> Bochs loaded { Entry: 0xA3DB0, RSP: 0x7FEB0F565000 }
========================================================================
Bochs x86 Emulator 2.7
Built from SVN snapshot on August 1, 2021
Timestamp: Sun Aug 1 10:07:00 CEST 2021
========================================================================
Usage: bochs [flags] [bochsrc options]
-n no configuration file
-f configfile specify configuration file
-q quick start (skip configuration interface)
-benchmark N run Bochs in benchmark mode for N millions of emulated ticks
-dumpstats N dump Bochs stats every N millions of emulated ticks
-r path restore the Bochs state from path
-log filename specify Bochs log file name
-unlock unlock Bochs images leftover from previous session
--help display this help and exit
--help features display available features / devices and exit
--help cpu display supported CPU models and exit
For information on Bochs configuration file arguments, see the
bochsrc section in the user documentation or the man page of bochsrc.
00000000000p[ ] >>PANIC<< command line arg '-AAAAA' was not understood
00000000000e[SIM ] notify called, but no bxevent_callback function is registered
========================================================================
Bochs is exiting with the following message:
[ ] command line arg '-AAAAA' was not understood
========================================================================
00000000000i[SIM ] quit_sim called with exit code 1
|
Bochs运行了!它无法理解我们胡乱编写的命令行参数,但是我们成功地加载并运行了它。
接下来的步骤
接下来的步骤和博客文章将是开发一个上下文切换例程,我们将使用它在Fuzzer执行和Bochs执行之间进行切换。这将涉及每次保存我们的状态,并且基本上以类似于正常用户到内核上下文切换的方式运行。
之后,我们必须非常熟悉Bochs,并尝试在普通的Bochs中运行一个目标。一旦我们做到这一点,我们将尝试在Fuzzer中运行它。
资源
- 在学习如何在内存中加载ELF时,我大量参考了这篇出色的博客文章:Faster Than Lime。
- 同时感谢@netspooky, 帮助我理解了堆栈布局!
- 也感谢ChatGPT, 虽然你在帮助我解决堆栈创建的bug时并没有成功,但你还是我的倾听者。
代码
https://github.com/h0mbre/Lucid
译者言
本文使用chatGPT-4o翻译而成,如有错误之处,请斧正
原文链接:https://h0mbre.github.io/New_Fuzzer_Project/