二进制快照模糊测试工具构建指南

本文详细介绍了如何为二进制目标构建快照模糊测试工具,通过LD_PRELOAD技术拦截系统调用,实现内存输入替换文件输入,提升模糊测试效率。

Fuzzing Like A Caveman 6: 二进制快照模糊测试工具

引言

距离我上次写这类文章已经有一段时间了,今年的目标之一是写更多内容。我的一个副项目即将告一段落,因此我将有更多空闲时间进行自己的研究并再次写博客。期待今年分享更多内容。

在初学者模糊测试圈(显然我也是其中一员)中最常见的问题之一是如何对目标进行工具化,以便在内存中进行模糊测试,有些人称之为“持久”方式,以提高性能。持久模糊测试有一个特定的使用场景,即目标在模糊测试用例之间不涉及太多全局状态,例如库中单个API的紧密模糊测试循环,或者二进制文件中的单个函数。

这种模糊测试方式比反复从头执行目标更快,因为我们绕过了与创建和销毁任务结构相关的所有繁重系统调用/内核例程。

然而,对于没有源代码的二进制目标,有时很难在不进行大量逆向工程(令人厌恶的工作?恶心)的情况下辨别执行任何代码路径时我们影响的全局状态。此外,我们通常希望模糊测试更广泛的循环。模糊测试一个返回结构体但在我们的模糊测试工作流程中从未读取或使用的函数对我们没有太大好处。考虑到这些,我们经常发现“快照”模糊测试对于二进制目标,甚至是经过企业构建系统处理的、我们有源代码的生产二进制文件来说,是一种更稳健的工作流程。

因此,今天我们将学习如何获取一个任意仅二进制目标,该目标从用户那里获取输入文件,并将其转换为从内存中获取输入的目标,并使其在模糊测试用例之间能够很好地重置状态。

目标(简单模式)

为了本博客文章的目的,我们将对objdump进行工具化以进行快照模糊测试。这将满足我们的需求,因为它相对简单(单线程、单进程),并且是一个常见的模糊测试目标,尤其是当人们为其模糊测试器进行开发工作时。这样做不是为了通过沙盒化一些疯狂的目标(如Chrome)来给你留下深刻印象,而是向初学者展示如何开始思考工具化。你希望切除目标的大脑,使它们与原来的自己无法识别,但保留相同的语义。你可以随心所欲地发挥创意,老实说,有时工具化目标是模糊测试中最令人满意的工作之一。成功沙盒化目标并使其与你的模糊测试器良好配合感觉很好。那么,开始吧。

Hello World

第一步是确定我们想要如何改变objdump的行为。让我们尝试在strace下运行它,并反汇编ls,看看它在系统调用级别上的行为:strace objdump -D /bin/ls。我们要找的是objdump开始与我们的输入(在这种情况下是/bin/ls)交互的点。在输出中,如果你滚动过样板内容,可以看到/bin/ls的首次出现:

1
2
3
4
5
stat("/bin/ls", {st_mode=S_IFREG|0755, st_size=133792, ...}) = 0
stat("/bin/ls", {st_mode=S_IFREG|0755, st_size=133792, ...}) = 0
openat(AT_FDCWD, "/bin/ls", O_RDONLY)   = 3
fcntl(3, F_GETFD)                       = 0
fcntl(3, F_SETFD, FD_CLOEXEC)           = 0

请记住,当你阅读本文时,如果你在家跟着做,你的输出可能不完全匹配我的。我可能运行的是与你不同的发行版,运行的是不同的objdump。但博客文章的重点只是展示概念,你可以自己发挥创意。

我还注意到程序直到执行结束才关闭我们的输入文件:

1
2
3
4
5
6
7
read(3, "\0\0\0\0\0\0\0\0\10\0\"\0\0\0\0\0\1\0\0\0\377\377\377\377\1\0\0\0\0\0\0\0"..., 4096) = 2720
write(1, ":(%rax)\n  21ffa4:\t00 00         "..., 4096) = 4096
write(1, "x0,%eax\n  220105:\t00 00         "..., 4096) = 4096
close(3)                                = 0
write(1, "023e:\t00 00                \tadd "..., 2190) = 2190
exit_group(0)                           = ?
+++ exited with 0 +++

知道这一点很好,我们需要我们的工具能够相当好地模拟输入文件,因为objdump不会一次性将我们的文件读入内存缓冲区或mmap()输入文件。它在整个strace输出中持续从文件中读取。

由于我们没有目标的源代码,我们将通过使用LD_PRELOAD共享对象来影响行为。通过使用LD_PRELOAD共享对象,我们应该能够钩住与我们的输入文件交互的系统调用周围的包装函数,并改变它们的行为以适应我们的目的。如果你不熟悉动态链接或LD_PRELOAD,这将是一个很好的停止点,去谷歌搜索更多信息,这是一个很好的起点。首先,让我们加载一个Hello, World!共享对象。

我们可以利用gcc函数属性,通过构造函数属性,让我们的共享对象在目标加载时执行代码。

因此,我们目前的代码将如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
/* 
编译器标志: 
gcc -shared -Wall -Werror -fPIC blog_harness.c -o blog_harness.so -ldl
*/

#include <stdio.h> /* printf */

// 当我们的共享对象加载时要调用的例程
__attribute__((constructor)) static void _hook_load(void) {
    printf("** LD_PRELOAD共享对象已加载!\n");
}

我将编译所需的编译器标志作为注释添加到了文件顶部。这些标志是我很久以前从这篇关于使用LD_PRELOAD共享对象的博客文章中获得的:https://tbrindus.ca/correct-ld-preload-hooking-libc/。

我们现在可以使用LD_PRELOAD环境变量运行objdump和我们的共享对象,加载时应该会打印:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
h0mbre@ubuntu:~/blogpost$ LD_PRELOAD=/home/h0mbre/blogpost/blog_harness.so objdump -D /bin/ls > /tmp/output.txt && head -n 20 /tmp/output.txt
**> LD_PRELOAD共享对象已加载!

/bin/ls:     文件格式 elf64-x86-64


.interp节的反汇编:

0000000000000238 <.interp>:
 238:   2f                      (bad)  
 239:   6c                      ins    BYTE PTR es:[rdi],dx
 23a:   69 62 36 34 2f 6c 64    imul   esp,DWORD PTR [rdx+0x36],0x646c2f34
 241:   2d 6c 69 6e 75          sub    eax,0x756e696c
 246:   78 2d                   js     275 <_init@@Base-0x34e3>
 248:   78 38                   js     282 <_init@@Base-0x34d6>
 24a:   36 2d 36 34 2e 73       ss sub eax,0x732e3436
 250:   6f                      outs   dx,DWORD PTR ds:[rsi]
 251:   2e 32 00                xor    al,BYTE PTR cs:[rax]

.note.ABI-tag节的反汇编:

它工作了,现在我们可以开始寻找要钩住的函数。

寻找钩子

我们需要做的第一件事是创建一个假文件名给objdump,以便我们可以开始测试。我们将/bin/ls复制到当前工作目录,并将其命名为fuzzme。这将允许我们通用地玩转工具进行测试。现在我们有了strace输出,我们知道objdump在调用openat()之前几次对我们的输入文件路径(/bin/ls)调用stat()。由于我们知道我们的文件尚未打开,并且系统调用使用路径作为第一个参数,我们可以猜测这个系统调用是由libc导出的stat()lstat()包装函数产生的。我假设是stat(),因为在我的机器上/bin/ls不涉及任何符号链接。我们可以为stat()添加一个钩子来测试是否命中它,并检查它是否被调用用于我们的目标输入文件(现在改为fuzzme)。

为了创建一个钩子,我们将遵循一个模式,其中我们通过typedef定义一个指向真实函数的指针,然后将指针初始化为NULL。一旦我们需要解析我们正在钩住的真实函数的位置,我们可以使用dlsym(RLTD_NEXT, <符号名称>)来获取其位置,并将指针值更改为真实符号地址。(这将在后面更清楚)。

现在我们需要钩住stat(),它在这里显示为man 3条目(意味着它是libc导出的函数)以及man 2条目(意味着它是系统调用)。这让我困惑了很长时间,我经常误解系统调用实际上是如何工作的,因为这种命名冲突的坚持。你可以阅读我最早的研究博客文章之一,其中困惑显而易见,我经常做出错误的主张。(PS,我永远不会编辑那些有错误的旧博客文章,它们就像时间胶囊,对我来说很酷)。

我们想编写一个函数,当被调用时,只是打印一些东西并退出,以便我们知道我们的钩子被命中。目前,我们的代码看起来像这样:

 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
/* 
编译器标志: 
gcc -shared -Wall -Werror -fPIC blog_harness.c -o blog_harness.so -ldl
*/

#include <stdio.h> /* printf */
#include <sys/stat.h> /* stat */
#include <stdlib.h> /* exit */

// 我们试图模拟的输入文件的文件名
#define FUZZ_TARGET "fuzzme"

// 将真实的stat声明为函数指针的原型
typedef int (*stat_t)(const char *restrict path, struct stat *restrict buf);
stat_t real_stat = NULL;

// 钩子函数,objdump将调用这个stat而不是真实的那个
int stat(const char *restrict path, struct stat *restrict buf) {
    printf("** stat()钩子!\n");
    exit(0);
}

// 当我们的共享对象加载时要调用的例程
__attribute__((constructor)) static void _hook_load(void) {
    printf("** LD_PRELOAD共享对象已加载!\n");
}

然而,如果我们编译并运行那个,我们永远不会打印和退出,所以我们的钩子没有被调用。出了点问题。有时,libc中的文件相关函数有64位变体,例如open()open64(),根据配置和标志,它们可以互换使用。我尝试钩住stat64(),但仍然没有运气钩子被到达。

幸运的是,我不是第一个遇到这个问题的人,Stackoverflow上有一个很好的答案,描述了libc实际上并不像其他函数如open()open64()那样导出stat(),而是导出一个名为__xstat()的符号,它具有稍微不同的签名,并需要一个名为version的新参数,该参数旨在描述调用者期望的stat结构版本。这应该都在幕后神奇地发生,但那就是我们现在生活的地方,所以我们必须自己实现魔法。同样的规则也适用于lstat()fstat(),它们分别有__lxstat()__fxstat()

我在这里找到了函数的定义。因此,我们可以将__xstat()钩子添加到我们的共享对象中,以代替stat(),看看我们的运气是否改变。我们的代码现在看起来像这样:

 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
/* 
编译器标志: 
gcc -shared -Wall -Werror -fPIC blog_harness.c -o blog_harness.so -ldl
*/

#include <stdio.h> /* printf */
#include <sys/stat.h> /* stat */
#include <stdlib.h> /* exit */
#include <unistd.h> /* __xstat, __fxstat */

// 我们试图模拟的输入文件的文件名
#define FUZZ_TARGET "fuzzme"

// 将真实的stat声明为函数指针的原型
typedef int (*__xstat_t)(int __ver, const char *__filename, struct stat *__stat_buf);
__xstat_t real_xstat = NULL;

// 钩子函数,objdump将调用这个stat而不是真实的那个
int __xstat(int __ver, const char *__filename, struct stat *__stat_buf) {
    printf("** 命中我们的__xstat()钩子!\n");
    exit(0);
}

// 当我们的共享对象加载时要调用的例程
__attribute__((constructor)) static void _hook_load(void) {
    printf("** LD_PRELOAD共享对象已加载!\n");
}

现在如果我们运行我们的共享对象,我们得到了期望的结果,某处我们的钩子被命中。现在我们可以帮助自己一点,打印钩子请求的文件名,然后实际代表调用者调用真实的__xstat()。现在当我们的钩子被命中时,我们将不得不按名称解析真实的__xstat()的位置,因此我们将在我们的共享对象中添加一个符号解析函数。我们的共享对象代码现在看起来像这样:

 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
/* 
编译器标志: 
gcc -shared -Wall -Werror -fPIC blog_harness.c -o blog_harness.so -ldl
*/

#define _GNU_SOURCE     /* dlsym */
#include <stdio.h> /* printf */
#include <sys/stat.h> /* stat */
#include <stdlib.h> /* exit */
#include <unistd.h> /* __xstat, __fxstat */
#include <dlfcn.h> /* dlsym和朋友们 */

// 我们试图模拟的输入文件的文件名
#define FUZZ_TARGET "fuzzme"

// 将真实的stat声明为函数指针的原型
typedef int (*__xstat_t)(int __ver, const char *__filename, struct stat *__stat_buf);
__xstat_t real_xstat = NULL;

// 返回库搜索顺序中符号的*下一个*位置的内存地址
static void *_resolve_symbol(const char *symbol) {
    // 清除之前的错误
    dlerror();

    // 获取符号地址
    void* addr = dlsym(RTLD_NEXT, symbol);

    // 检查错误
    char* err = NULL;
    err = dlerror();
    if (err) {
        addr = NULL;
        printf("解析'%s'地址错误: %s\n", symbol, err);
        exit(-1);
    }
    
    return addr;
}

// 钩子函数,objdump将调用这个stat而不是真实的那个
int __xstat(int __ver, const char *__filename, struct stat *__stat_buf) {
    // 打印请求的文件名
    printf("** __xstat()钩子被调用,文件名: '%s'\n", __filename);

    // 按需解析真实的__xstat()地址,且仅一次
    if (!real_xstat) {
        real_xstat = _resolve_symbol("__xstat");
    }

    // 为调用者调用真实的__xstat(),以便一切继续
    return real_xstat(__ver, __filename, __stat_buf);
}

// 当我们的共享对象加载时要调用的例程
__attribute__((constructor)) static void _hook_load(void) {
    printf("** LD_PRELOAD共享对象已加载!\n");
}

好的,所以现在当我们运行这个,并检查我们的打印语句时,事情变得有点 spicy。

1
2
3
h0mbre@ubuntu:~/blogpost$ LD_PRELOAD=/home/h0mbre/blogpost/blog_harness.so objdump -D fuzzme > /tmp/output.txt && grep "** __xstat" /tmp/output.txt
** __xstat()钩子被调用,文件名: 'fuzzme'
** __xstat()钩子被调用,文件名: 'fuzzme'

所以现在我们可以玩一些了。

__xstat() 钩子

所以这个钩子的目的是对objdump撒谎,让它认为它成功地对输入文件进行了stat()。记住,我们正在制作一个快照模糊测试工具,所以我们的目标是不断创建新的输入,并通过这个工具将它们提供给objdump。最重要的是,我们的工具需要能够将我们的可变长度输入(将纯粹存储在内存中)表示为文件。每个模糊测试用例,文件长度可以改变,我们的工具需要适应这一点。

我此时的想法是创建一个某种程度上“合法”的stat结构,该结构通常为我们的实际文件fuzzme(只是/bin/ls的副本)返回。我们可以全局存储这个stat结构,并仅在每个新的模糊测试用例到来时更新size字段。因此,我们的快照模糊测试工作流程的时间线将如下所示:

  1. 当我们的共享对象加载时,我们的构造函数被调用
  2. 我们的构造函数设置一个全局“合法”的stat结构,我们可以为每个模糊测试用例更新并传递回试图对我们的模糊测试目标进行stat()__xstat()调用者
  3. 想象的模糊测试器运行objdump到快照位置
  4. 我们的__xstat()钩子更新全局“合法”stat结构的size字段,并将stat结构复制到调用者的缓冲区
  5. 想象的模糊测试器将objdump的状态恢复到快照时的状态
  6. 想象的模糊测试器将新输入复制到工具中并更新输入大小
  7. 我们的__xstat()钩子再次被调用,我们重复步骤4,这个过程永远重复发生。

因此,我们想象模糊测试器有一些像这样的伪代码例程,即使它可能是跨进程的并且需要process_vm_writev

1
2
3
4
insert_fuzzcase(config.input_location, config.input_size_location, input, input_size) {
  memcpy(config.input_location, &input, input_size);
  memcpy(config.input_size_location, &input_size, sizeof(size_t));
}

要记住的一件重要事情是,如果快照模糊测试器每个模糊测试迭代都将objdump恢复到其快照状态,我们必须小心不要依赖任何全局可变内存。全局stat结构将是安全的,因为它将在构造函数期间实例化,但是,它的size字段将被模糊测试器的快照恢复例程每个模糊测试迭代恢复为其原始值。

我们还需要一个全局的、可识别的地址来存储可变可变全局数据,如当前输入的大小。几个快照模糊测试器具有灵活性,可以为了恢复目的而忽略连续的内存范围。因此,如果我们能够在内存中在可识别的地址创建一些连续缓冲区,我们可以让我们的想象模糊测试器忽略这些范围进行快照恢复。因此,我们需要一个地方来存储输入,以及关于它们大小的信息。然后,我们会以某种方式告诉模糊测试

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