距离我上次写这类文章已经有一段时间了,今年我的目标之一是写更多的文章,所以我们再次回到了这里。我正在进行的一个副项目正逐步接近一个好的停顿点,因此我将有更多的闲暇时间来做自己的研究并再次写博客。期待今年分享更多内容。
在初学者模糊测试圈子中(显然我也是其中一员),最常见的问题之一是如何HOOK目标,使其能够在内存中进行模糊测试,有些人称之为“持久性”模糊测试,以获得更高的性能。持久性模糊测试有一个特定的用例,即当目标在不同的模糊测试用例之间不涉及太多全局状态时,这种方式会很有用。一个例子是对库中的单个API或二进制文件中的单个函数进行紧密的模糊测试循环。
这种模糊测试风格比一次又一次从头开始重新执行目标要快,因为我们绕过了与创建和销毁任务结构相关的所有繁重的系统调用/内核例程。
然而,对于没有源代码的二进制目标来说,在不进行深入逆向工程的情况下(真恶心,工作?呃),有时很难辨别在执行任何代码路径时我们影响了哪些全局状态。此外,我们通常希望模糊测试更广泛的循环。模糊测试一个返回一个结构体的函数并没有多大帮助,因为该结构体在我们的模糊测试工作流程中从未被读取或使用。考虑到这些情况,我们通常发现“快照”模糊测试对于二进制目标,甚至是那些我们有源代码但经过企业构建系统处理的生产二进制文件来说,是一种更稳健的工作流程。
因此,今天我们将学习如何将一个任意的仅二进制目标(该目标从用户那里接收输入文件)转换为一个从内存中接收输入的目标,并使其能够在不同的模糊测试用例之间重置状态。
在这篇博客文章中,我们将使用objdump
作为快照模糊测试的HOOK目标。这能够满足我们的需求,因为它相对简单(单线程、单进程),并且它是一个常见的模糊测试目标,尤其是在人们开发模糊测试工具时。本文的重点不是通过沙盒化像Chrome这样复杂的目标来打动你,而是向初学者展示如何开始思考HOOK目标。你需要将你的目标改造得面目全非,但保持相同的语义。你可以尽情发挥创造力,坦白说,有时候HOOK目标是与模糊测试相关的最令人满意的工作之一。成功地将目标沙盒化,并使其与模糊测试器良好配合,感觉非常棒。那么我们开始吧。
Hello World
第一步是确定我们想如何改变objdump
的行为。让我们尝试在strace
下运行它,并反汇编ls
,看看它在系统调用级别上如何表现,命令为strace objdump -D /bin/ls
。我们要寻找的是objdump
开始与我们的输入(在本例中为/bin/ls
)交互的地方。在输出中,如果你滚动浏览过那些样板内容,你可以看到/bin/ls
的首次出现:
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
。但这篇博客文章的目的是展示一些概念,你可以自行发挥创意。
我还注意到,程序在执行结束前并不会关闭我们的输入文件:
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 +++
这点很重要,我们需要让我们的HOOK程序能很好地模拟一个输入文件,因为objdump
并不会一次性将文件读入内存缓冲区或使用mmap()
映射输入文件。它会在整个strace
输出过程中不断地从文件中读取数据。
由于我们没有目标的源代码,我们将通过使用LD_PRELOAD
共享对象来影响其行为。通过使用LD_PRELOAD
共享对象,我们应该能够HOOK与输入文件交互的系统调用的包装函数,并修改它们的行为以满足我们的需求。如果你不熟悉动态链接或LD_PRELOAD
,现在是一个很好的时机去搜索更多信息,这是一个很好的起点。首先,我们先加载一个“Hello, World!”的共享对象。
我们可以利用gcc
的函数属性(Function Attributes)来在共享对象被目标加载时执行代码,通过构造器属性(constructor attribute)来实现。
因此,迄今为止我们的代码将如下所示:
#include
__attribute__((constructor)) static void _hook_load(void) {
printf("** LD_PRELOAD shared object loaded!\n");
}
我将编译所需的标志添加到了文件顶部作为注释。这些标志来自于我之前阅读的关于使用LD_PRELOAD
共享对象的博客文章:https://tbrindus.ca/correct-ld-preload-hooking-libc/。
现在我们可以使用LD_PRELOAD
环境变量,并运行带有我们的共享对象的objdump
,它应该在加载时打印信息:
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 shared object loaded!
/bin/ls: file format elf64-x86-64
Disassembly of section .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]
Disassembly of section .note.ABI-tag:
它有效,现在我们可以开始寻找需要HOOK的函数了。
寻找HOOK点
首先我们需要做的是创建一个虚假的文件名给objdump
,以便我们可以开始测试。我们将ls
可执行文件复制到当前工作目录,并将其命名为fuzzme
。这将允许我们在测试时通用地操作HOOK程序。现在我们有了strace
输出,我们知道objdump
在调用openat()
之前,会多次对输入文件路径(/bin/ls)调用stat()
。由于我们知道文件尚未被打开,并且系统调用的第一个参数使用路径,我们可以猜测这个系统调用是由libc
导出的stat()
或lstat()
包装函数引发的。我假设是stat()
,因为我们在我的机器上没有处理/bin/ls
的符号链接。我们可以添加一个stat()
的HOOK来测试是否命中,并检查它是否被调用来处理我们的目标输入文件(现在改为fuzzme
)。
为了创建一个HOOK,我们将遵循一个模式,即通过typedef
定义指向真实函数的指针,然后将指针初始化为NULL
。一旦我们需要解析我们所HOOK的真实函数的位置,我们可以使用dlsym(RLTD_NEXT, )
来获取它的位置,并将指针值更改为真实符号地址。(稍后这将变得更清晰)。
现在我们需要HOOKstat()
,它在man 3
中作为一个条目(意味着它是一个libc
导出的函数),同时也在man 2
中作为一个条目(意味着它是一个系统调用)。这让我困惑了很长时间,因为这个命名冲突,我经常误解系统调用的实际工作原理。你可以阅读我最早的研究博客文章之一,在那里这种困惑显而易见,我也经常做出错误的断言。(顺便说一下,我永远不会编辑那些有错误的旧博客文章,它们就像时间胶囊,对我来说这很酷)。
我们想要编写一个函数,当被调用时,只需打印一些信息并退出,以便我们知道我们的HOOK被命中了。目前,我们的代码如下所示:
#include
#include
#include
#define FUZZ_TARGET "fuzzme"
typedef int (*stat_t)(const char *restrict path, struct stat *restrict buf);
stat_t real_stat = NULL;
int stat(const char *restrict path, struct stat *restrict buf) {
printf("** stat() hook!\n");
exit(0);
}
__attribute__((constructor)) static void _hook_load(void) {
printf("** LD_PRELOAD shared object loaded!\n");
}
然而,如果我们编译并运行该代码,我们并没有打印任何内容并退出,所以我们的HOOK没有被调用。出了点问题。有时候,libc
中与文件相关的函数有 64 位变体,比如open()
和open64()
,它们会根据配置和标志的不同而交替使用。我尝试HOOKstat64()
,但仍然没有成功。
幸运的是,我并不是第一个遇到这个问题的人。Stackoverflow 上有一个很棒的答案,讨论了这个问题,解释了libc
并没有像其他函数(如open()
和open64()
)那样导出stat()
,而是导出了一个叫做__xstat()
的符号。这个符号具有略微不同的签名,并需要一个名为version
的新参数,用于描述调用者期望的stat
结构体的版本。这一切本应在底层自动处理,但我们现在必须自己让这些“魔法”发挥作用。对于lstat()
和fstat()
也是同样的规则,它们分别有__lxstat()
和__fxstat()
。
我在这里找到了这些函数的定义。于是我们可以将__xstat()
HOOK添加到我们的共享对象中,替换掉stat()
,看看是否有不同的结果。现在我们的代码如下所示:
#include
#include
#include
#include
#define FUZZ_TARGET "fuzzme"
typedef int (*__xstat_t)(int __ver, const char *__filename, struct stat *__stat_buf);
__xstat_t real_xstat = NULL;
int __xstat(int __ver, const char *__filename, struct stat *__stat_buf) {
printf("** Hit our __xstat() hook!\n");
exit(0);
}
__attribute__((constructor)) static void _hook_load(void) {
printf("** LD_PRELOAD shared object loaded!\n");
}
现在,如果我们运行我们的共享对象,我们得到了预期的结果,某处我们的HOOK被命中。现在我们可以帮自己一把,打印出HOOK请求的文件名,然后实际上代表调用者调用真实的__xstat()
。当我们的HOOK被命中时,我们需要通过名称解析真正的__xstat()
的位置,所以我们会向共享对象中添加一个符号解析函数。现在我们的共享对象代码如下所示:
#define _GNU_SOURCE
#include
#include
#include
#include
#include
#define FUZZ_TARGET "fuzzme"
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("Err resolving '%s' addr: %s\n", symbol, err);
exit(-1);
}
return addr;
}
int __xstat(int __ver, const char *__filename, struct stat *__stat_buf) {
printf("** __xstat() hook called for filename: '%s'\n", __filename);
if (!real_xstat) {
real_xstat = _resolve_symbol("__xstat");
}
return real_xstat(__ver, __filename, __stat_buf);
}
__attribute__((constructor)) static void _hook_load(void) {
printf("** LD_PRELOAD shared object loaded!\n");
}
好了,现在当我们运行这个代码,并检查我们的打印语句时,事情变得有趣起来了。
h0mbre@ubuntu:~/blogpost$ LD_PRELOAD=/home/h0mbre/blogpost/blog_harness.so objdump -D fuzzme > /tmp/output.txt && grep "** __xstat" /tmp/output.txt
** __xstat() hook called for filename: 'fuzzme'
** __xstat() hook called for filename: 'fuzzme'
现在我们可以开始享受过程了。
__xstat()
HOOK
这个HOOK的目的是欺骗objdump
,让它以为成功地对输入文件执行了stat()
。记住,我们正在制作一个快照模糊测试HOOK,所以我们的目标是不断地创建新输入并通过这个HOOK传递给objdump
。最重要的是,我们的HOOK需要能够将我们存储在内存中的可变长度输入表示为文件。每个模糊测试用例中,文件长度可能会发生变化,而我们的HOOK需要适应这一点。
此时我的想法是创建一个看起来“合法”的stat
结构体,它通常会为我们的实际文件fuzzme
(只是/bin/ls
的副本)返回。我们可以全局存储这个stat
结构体,并在每个新的模糊测试用例中只更新大小字段。因此,我们的快照模糊测试工作流程的时间线可能如下所示:
1.当我们的共享对象被加载时,构造函数被调用。
2.构造函数设置一个全局的“合法”stat
结构体,我们可以为每个模糊测试用例更新这个结构体,并将其传递给试图对我们的模糊测试目标执行stat()
的__xstat()
调用者。
3.假想的模糊测试器运行objdump
到达快照位置。
4.我们的__xstat()
HOOK更新全局“合法”stat
结构体的大小字段,并将stat
结构体复制到调用者的缓冲区中。
5.假想的模糊测试器将objdump
的状态恢复到快照时的状态。
6.假想的模糊测试器将新的输入复制到HOOK,并更新输入大小。
7.我们的__xstat()
HOOK再次被调用,我们重复步骤 4,这个过程会无休止地进行下去。
因此,我们可以想象模糊测试器有类似这样的伪代码例程,尽管它可能是跨进程的,并且需要process_vm_writev
。
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
字段恢复到原始值。
我们还需要一个全局的、可识别的地址来存储可变的全局数据,比如当前输入的大小。几个快照模糊测试器具有忽略连续内存范围以用于恢复目的的灵活性。因此,如果我们能够在可识别的地址上创建一些连续的内存缓冲区,我们可以让我们的假想模糊测试器在快照恢复时忽略这些内存范围。因此,我们需要有一个地方来存储输入,以及有关其大小的信息。然后我们可以以某种方式告诉模糊测试器这些位置,当它生成新输入时,它会将其复制到输入位置,然后更新当前输入大小信息。
所以现在我们的构造函数有了一个额外的任务:设置输入位置以及输入大小信息。我们可以通过调用mmap()
来轻松实现这一点,这使我们能够通过MAP_FIXED
标志指定我们希望将映射映射到的地址。我们还会创建一个MAX_INPUT_SZ
定义,以便我们知道从输入位置映射多少内存。
仅仅是与映射输入本身及其大小信息相关的函数看起来是这样的。请注意,我们使用了MAP_FIXED
并检查了mmap()
的返回地址,以确保调用成功但没有将我们的内存映射到不同的位置:
static void _create_mem_mappings(void) {
void *result = NULL;
result = mmap(
(void *)(INPUT_SZ_ADDR),
sizeof(size_t),
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
0,
0
);
if ((MAP_FAILED == result) || (result != (void *)INPUT_SZ_ADDR)) {
printf("Err mapping INPUT_SZ_ADDR, mapped @ %p\n", result);
exit(-1);
}
*(size_t *)INPUT_SZ_ADDR = 0;
result = mmap(
(void *)(INPUT_ADDR),
(size_t)(MAX_INPUT_SZ),
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
0,
0
);
if ((MAP_FAILED == result) || (result != (void *)INPUT_ADDR)) {
printf("Err mapping INPUT_ADDR, mapped @ %p\n", result);
exit(-1);
}
memset((void *)INPUT_ADDR, 0, (size_t)MAX_INPUT_SZ);
}
mmap()
实际上会映射系统上页面大小的倍数(通常是 4096 字节)。所以,当我们要求映射sizeof(size_t)
字节时,mmap()
就像:“嗯,这只是一页,兄弟”,并且从0x1336000
到0x1337000
给了我们一整页(不包括高端)。
随机附带提一下,在定义和宏中进行算术运算时要小心,就像我在这里用MAX_INPUT_SIZE
所做的那样,预处理器很容易将你的文本替换为定义关键字,从而破坏一些操作顺序,甚至溢出特定的基本类型,如int
。
现在我们已经为模糊测试器设置了存储输入及其大小信息的内存,我们可以创建那个全局的stat
结构体了。但是我们实际上遇到了一个大问题。如果我们已经HOOK了__xstat()
,我们如何调用__xstat()
以获取我们的“合法”stat
结构体呢?我们会命中自己的HOOK。为了解决这个问题,我们可以使用一个特殊的__ver
参数调用__xstat()
,我们知道这意味着它是从构造函数中调用的,变量是一个int
类型,所以我们可以使用0x1337
作为特殊值。
这样,在我们的HOOK中,如果我们检查__ver
并且它是0x1337
,我们就知道它是从构造函数中调用的,我们可以实际对我们的真实文件进行stat
调用并创建一个全局的“合法”stat
结构体。当我转储objdump
对__xstat()
的正常调用时,__version
总是值1
,所以我们会在HOOK中将其修补回去。现在我们整个共享对象的源文件应该如下所示:
#define _GNU_SOURCE
#include
#include
#include
#include
#include
#include
#include
#define FUZZ_TARGET "fuzzme"
#define INPUT_SZ_ADDR 0x1336000
#define INPUT_ADDR 0x1337000
#define MAX_INPUT_SZ (1024 * 1024)
struct stat st;
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("Err resolving '%s' addr: %s\n", symbol, err);
exit(-1);
}
return addr;
}
int __xstat(int __ver, const char* __filename, struct stat* __stat_buf) {
if (NULL == real_xstat) {
real_xstat = _resolve_symbol("__xstat");
}
int ret = -1;
if (0x1337 == __ver) {
__ver = 1;
ret = real_xstat(__ver, __filename, __stat_buf);
real_xstat = NULL;
return ret;
}
if (!strcmp(__filename, FUZZ_TARGET)) {
st.st_size = *(size_t *)INPUT_SZ_ADDR;
memcpy(__stat_buf, &st, sizeof(struct stat));
ret = 0;
}
else {
ret = real_xstat(__ver, __filename, __stat_buf);
}
return ret;
}
static void _create_mem_mappings(void) {
void *result = NULL;
result = mmap(
(void *)(INPUT_SZ_ADDR),
sizeof(size_t),
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
0,
0
);
if ((MAP_FAILED == result) || (result != (void *)INPUT_SZ_ADDR)) {
printf("Err mapping INPUT_SZ_ADDR, mapped @ %p\n", result);
exit(-1);
}
*(size_t *)INPUT_SZ_ADDR = 0;
result = mmap(
(void *)(INPUT_ADDR),
(size_t)(MAX_INPUT_SZ),
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
0,
0
);
if ((MAP_FAILED == result) || (result != (void *)INPUT_ADDR)) {
printf("Err mapping INPUT_ADDR, mapped @ %p\n", result);
exit(-1);
}
memset((void *)INPUT_ADDR, 0, (size_t)MAX_INPUT_SZ);
}
__attribute__((constructor)) static void _hook_load(void) {
_create_mem_mappings();
}
现在,如果我们运行这个代码,我们得到如下输出:
h0mbre@ubuntu:~/blogpost$ LD_PRELOAD=/home/h0mbre/blogpost/blog_harness.so objdump -D fuzzme
objdump: Warning: 'fuzzme' is not an ordinary file
这很酷,这意味着objdump
的开发人员做了一些正确的事情,他们的stat()
会说:“嘿,这个文件长度为零,发生了什么怪事。”然后他们会输出这个错误消息并退出。干得好,开发者们!
所以我们已经确定了一个问题,我们需要模拟模糊测试器将一个真实的输入放入内存中,为此,我将开始使用#ifdef
来定义我们是否在测试我们的共享对象。所以基本上,如果我们编译共享对象并定义TEST
,我们的共享对象将复制一个“输入”到内存中,以模拟模糊测试器在模糊测试期间的行为,我们可以看看我们的HOOK是否工作得当。所以如果我们定义了TEST
,我们将把/bin/ed
复制到内存中,并更新我们的全局“合法”stat
结构体的大小成员,并将/bin/ed
的字节放入内存中。
你现在可以按照如下方式编译共享对象以进行测试:
gcc -D TEST -shared -Wall -Werror -fPIC blog_harness.c -o blog_harness.so -ld
我们还需要设置我们的全局“合法”stat
结构体,代码应如下所示。记住,我们传递一个假的__ver
变量让__xstat()
HOOK知道是在构造函数例程中调用的,从而允许HOOK正常运行并为我们提供所需的stat
结构体:
static void _setup_stat_struct(void) {
int result = __xstat(0x1337, FUZZ_TARGET, &st);
if (-1 == result) {
printf("Error creating stat struct for '%s' during load\n", FUZZ_TARGET);
}
}
总的来说,我们的整个HOOK现在看起来是这样的:
#define _GNU_SOURCE
#include
#include
#include
#include
#include
#include
#include
#include
#define FUZZ_TARGET "fuzzme"
#define INPUT_SZ_ADDR 0x1336000
#define INPUT_ADDR 0x1337000
#define MAX_INPUT_SZ (1024 * 1024)
#define TEST_FILE "/bin/ed"
struct stat st;
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("Err resolving '%s' addr: %s\n", symbol, err);
exit(-1);
}
return addr;
}
int __xstat(int __ver, const char* __filename, struct stat* __stat_buf) {
if (!real_xstat) {
real_xstat = _resolve_symbol("__xstat");
}
int ret = -1;
if (0x1337 == __ver) {
__ver = 1;
ret = real_xstat(__ver, __filename, __stat_buf);
real_xstat = NULL;
return ret;
}
if (!strcmp(__filename, FUZZ_TARGET)) {
st.st_size = *(size_t *)INPUT_SZ_ADDR;
memcpy(__stat_buf, &st, sizeof(struct stat));
ret = 0;
}
else {
ret = real_xstat(__ver, __filename, __stat_buf);
}
return ret;
}
static void _create_mem_mappings(void) {
void *result = NULL;
result = mmap(
(void *)(INPUT_SZ_ADDR),
sizeof(size_t),
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
0,
0
);
if ((MAP_FAILED == result) || (result != (void *)INPUT_SZ_ADDR)) {
printf("Err mapping INPUT_SZ_ADDR, mapped @ %p\n", result);
exit(-1);
}
*(size_t *)INPUT_SZ_ADDR = 0;
result = mmap(
(void *)(INPUT_ADDR),
(size_t)(MAX_INPUT_SZ),
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
0,
0
);
if ((MAP_FAILED == result) || (result != (void *)INPUT_ADDR)) {
printf("Err mapping INPUT_ADDR, mapped @ %p\n", result);
exit(-1);
}
memset((void *)INPUT_ADDR, 0, (size_t)MAX_INPUT_SZ);
}
static void _setup_stat_struct(void) {
int result = __xstat(0x1337, FUZZ_TARGET, &st);
if (-1 == result) {
printf("Error creating stat struct for '%s' during load\n", FUZZ_TARGET);
}
}
#ifdef TEST
static void _test_func(void) {
int fd = open(TEST_FILE, O_RDONLY);
if (-1 == fd) {
printf("Failed to open '%s' during test\n", TEST_FILE);
exit(-1);
}
ssize_t bytes = read(fd, (void*)INPUT_ADDR, (size_t)MAX_INPUT_SZ);
close(fd);
*(size_t *)INPUT_SZ_ADDR = (size_t)bytes;
}
#endif
__attribute__((constructor)) static void _hook_load(void) {
_create_mem_mappings();
_setup_stat_struct();
#ifdef TEST
_test_func();
#endif
}
现在,如果我们在strace
下运行这个程序,我们注意到我们的两个stat()
调用明显不见了。
close(3) = 0
openat(AT_FDCWD, "fuzzme", O_RDONLY) = 3
fcntl(3, F_GETFD) = 0
fcntl(3, F_SETFD, FD_CLOEXEC) = 0
我们不再看到openat()
之前的stat()
调用,并且程序没有在任何显著的地方崩溃。因此,这个HOOK似乎工作得很好。我们现在需要处理openat()
,并确保我们实际上不与输入文件交互,而是欺骗objdump
与内存中的输入交互。
寻找HOOKopenat()
的方法
我的非专家直觉告诉我,可能有几种方式使得libc
函数在底层调用openat()
。这些方式可能包括包装函数open()
以及fopen()
。我们还需要注意它们的 64 位变体(open64()
、fopen64()
)。我决定首先尝试HOOKfopen()
:
typedef FILE* (*fopen_t)(const char* pathname, const char* mode);
fopen_t real_fopen = NULL;
typedef FILE* (*fopen64_t)(const char* pathname, const char* mode);
fopen64_t real_fopen64 = NULL;
...
FILE* fopen(const char* pathname, const char* mode) {
printf("** fopen() called for '%s'\n", pathname);
exit(0);
}
FILE* fopen64(const char* pathname, const char* mode) {
printf("** fopen64() called for '%s'\n", pathname);
exit(0);
}
如果我们编译并运行我们的探索性HOOK,我们得到以下输出:
h0mbre@ubuntu:~/blogpost$ LD_PRELOAD=/home/h0mbre/blogpost/blog_harness.so objdump -D fuzzme
** fopen64() called for 'fuzzme'
Bingo,成功找到目标。
所以现在我们可以稍微完善一下这个HOOK函数,使其按我们的预期运行。
改进fopen64()
HOOK
fopen64()
的定义是:FILE *fopen(const char *restrict pathname, const char *restrict mode);
。返回的FILE *
对我们来说有点麻烦,因为这是一个不透明的数据结构,调用者不应理解它的内容。也就是说,调用者不应访问这个数据结构的任何成员,也不应担心它的布局。你只需要将返回的FILE *
作为一个对象传递给其他函数,例如fclose()
。系统在这些相关函数中处理数据结构,这样程序员就不必担心特定的实现。
我们实际上并不知道返回的FILE *
将如何被使用,它可能根本不会被使用,或者可能会被传递给诸如fread()
之类的函数,因此我们需要一种方式返回一个令人信服的FILE *
数据结构给调用者,这个数据结构实际上是从我们内存中的输入构建的,而不是从输入文件构建的。幸运的是,有一个叫做fmemopen()
的libc
函数,其行为与fopen()
非常相似,也返回一个FILE *
。所以我们可以继续创建一个FILE *
返回给fopen64()
的调用者,目标输入文件是fuzzme
。感谢 @domenuk 向我展示了fmemopen()
,我以前从未遇到过它。
不过有一个关键区别。fopen()
实际上会为底层文件获取文件描述符,而fmemopen()
因为它实际上并未打开文件,所以不会。因此,在FILE *
数据结构中的某处,如果它是从fopen()
返回的,则存在一个底层文件的文件描述符,而如果它是从fmemopen()
返回的,则不存在。这一点非常重要,因为诸如int fileno(FILE *stream)
之类的函数可以解析一个FILE *
并将其底层文件描述符返回给调用者。objdump
可能出于某种原因想要这样做,我们需要能够稳健地处理这个问题。因此,我们需要一种方法来知道有人是否试图使用我们伪造的FILE *
的底层文件描述符。
我的想法是简单地找到fmemopen()
返回的FILE *
中包含文件描述符的结构成员,并将其更改为类似 1337 这样的荒谬值,这样如果objdump
试图使用该文件描述符,我们就会知道它的来源,并可以尝试HOOK与该文件描述符的任何交互。现在我们的fopen64()
HOOK应该看起来如下所示:
FILE* fopen64(const char* pathname, const char* mode) {
if (NULL == real_fopen64) {
real_fopen64 = _resolve_symbol("fopen64");
}
FILE* ret = NULL;
if (!strcmp(FUZZ_TARGET, pathname)) {
if (strcmp(mode, "r")) {
printf("Attempt to open fuzz-target in illegal mode: '%s'\n", mode);
exit(-1);
}
ret = fmemopen((void*)INPUT_ADDR, *(size_t*)INPUT_SZ_ADDR, mode);
if (faked_fp) {
printf("Attempting to fopen64() fuzzing target more than once\n");
exit(-1);
}
faked_fp = ret;
ret->_fileno = 1337;
}
else {
ret = real_fopen64(pathname, mode);
}
return ret;
}
你可以看到我们:
◆如果符号位置尚未解析,则解析它
◆检查我们是否正在对模糊测试目标输入文件进行调用
◆调用fmemopen()
并打开内存缓冲区,其中包含我们当前的输入及其大小
你可能还注意到了一些安全检查,以确保事情不会被忽视。我们有一个全局变量FILE *faked_fp
,我们将其初始化为NULL
,这让我们知道是否多次打开了我们的输入(在后续尝试打开时它将不再是NULL
)。
我们还检查了mode
参数,以确保我们得到的是一个只读的FILE *
。我们不希望objdump
修改我们的输入或以任何方式写入它,如果它试图这样做,我们需要知道。
此时运行我们的共享对象会产生以下输出:
h0mbre@ubuntu:~/blogpost$ LD_PRELOAD=/home/h0mbre/blogpost/blog_harness.so objdump -D fuzzme
objdump: fuzzme: Bad file descriptor
我的直觉告诉我,有什么东西试图与文件描述符 1337 进行交互。让我们再次在strace
下运行,看看会发生什么。
h0mbre@ubuntu:~/blogpost$ strace -E LD_PRELOAD=/home/h0mbre/blogpost/blog_harness.so objdump -D fuzzme > /tmp/output.txt
在输出中,我们可以看到一些系统调用fcntl()
和fstat()
都是用文件描述符 1337 调用的,该描述符显然不存在于我们的objdump
进程中,因此我们已经能够找到问题所在。
fcntl(1337, F_GETFD) = -1 EBADF (Bad file descriptor)
prlimit64(0, RLIMIT_NOFILE, NULL, {rlim_cur=4*1024, rlim_max=4*1024}) = 0
fstat(1337, 0x7fff4bf54c90) = -1 EBADF (Bad file descriptor)
fstat(1337, 0x7fff4bf54bf0) = -1 EBADF (Bad file descriptor)
正如我们已经了解到的,libc
中没有直接导出的fstat()
,它像stat()
一样是那种奇怪的函数,我们实际上必须HOOK__fxstat()
。所以让我们尝试HOOK它,看看它是否会被调用用于我们的 1337 文件描述符。HOOK函数的初始代码如下:
typedef int (*__fxstat_t)(int __ver, int __filedesc, struct stat *__stat_buf);
__fxstat_t real_fxstat = NULL;
...
int __fxstat (int __ver, int __filedesc, struct stat *__stat_buf) {
printf("** __fxstat() called for __filedesc: %d\n", __filedesc);
exit(0);
}
现在我们还需要处理fcntl()
,幸运的是,这个HOOK比较简单。如果有人请求F_GETFD
(即与那个特殊的 1337 文件描述符关联的标志),我们只需返回O_RDONLY
,因为它是以这些标志“打开”的,如果有人为不同的文件描述符调用它,我们暂时只会触发一个 panic。这个HOOK如下所示:
typedef int (*fcntl_t)(int fildes, int cmd, ...);
fcntl_t real_fcntl = NULL;
...
int fcntl(int fildes, int cmd, ...) {
if (NULL == real_fcntl) {
real_fcntl = _resolve_symbol("fcntl");
}
if (fildes == 1337) {
return O_RDONLY;
}
else {
printf("** fcntl() called for real file descriptor\n");
exit(0);
}
}
现在在strace
下运行时,fcntl()
调用如预期那样消失了:
openat(AT_FDCWD, "/usr/lib/x86_64-linux-gnu/gconv/gconv-modules.cache", O_RDONLY) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=26376, ...}) = 0
mmap(NULL, 26376, PROT_READ, MAP_SHARED, 3, 0) = 0x7ff61d331000
close(3) = 0
prlimit64(0, RLIMIT_NOFILE, NULL, {rlim_cur=4*1024, rlim_max=4*1024}) = 0
fstat(1, {st_mode=S_IFREG|0664, st_size=0, ...}) = 0
write(1, "** __fxstat() called for __filed"..., 42) = 42
exit_group(0) = ?
+++ exited with 0 +++
现在我们可以完善我们的__fxstat()
HOOK逻辑。调用者希望通过传递特殊的文件描述符 1337 从函数中获取一个针对模糊测试目标fuzzme
的stat
结构体。幸运的是,我们有一个全局的stat
结构体,在我们更新其大小以匹配内存中当前输入的大小(由我们和模糊测试器通过INPUT_SIZE_ADDR
处的值进行跟踪)之后,我们可以返回这个结构体。因此,如果被调用,我们只需更新stat
结构体的大小,并将我们的结构体通过memcpy
复制到调用者的*__stat_buf
中。我们完整的HOOK代码现在如下:
int __fxstat (int __ver, int __filedesc, struct stat *__stat_buf) {
if (NULL == real_fxstat) {
real_fxstat = _resolve_symbol("__fxstat");
}
int ret = -1;
if (1337 == __filedesc) {
st.st_size = *(size_t*)INPUT_SZ_ADDR;
memcpy(__stat_buf, &st, sizeof(struct stat));
ret = 0;
}
else {
ret = real_fxstat(__ver, __filedesc, __stat_buf);
}
return ret;
}
现在,如果我们运行这个代码,程序实际上不会崩溃,并且objdump
可以在strace
下干净地退出。
为了测试我们是否做得不错,我们将输出objdump -D fuzzme
到一个文件中,然后我们将加载我们的HOOK共享对象并输出相同的命令。最后,我们将运行objdump -D /bin/ed
并输出到一个文件中,以查看我们的HOOK是否生成了相同的输出。
h0mbre@ubuntu:~/blogpost$ objdump -D fuzzme > /tmp/fuzzme_original.txt
h0mbre@ubuntu:~/blogpost$ LD_PRELOAD=/home/h0mbre/blogpost/blog_harness.so objdump -D fuzzme > /tmp/harness.txt
h0mbre@ubuntu:~/blogpost$ objdump -D /bin/ed > /tmp/ed.txt
然后我们对这些文件进行sha1sum
:
h0mbre@ubuntu:~/blogpost$ sha1sum /tmp/fuzzme_original.txt /tmp/harness.txt /tmp/ed.txt
938518c86301ab00ddf6a3ef528d7610fa3fd05a /tmp/fuzzme_original.txt
add4e6c3c298733f48fbfe143caee79445c2f196 /tmp/harness.txt
10454308b672022b40f6ce5e32a6217612b462c8 /tmp/ed.txt
我们实际上得到了三个不同的哈希值,我们希望HOOK和/bin/ed
输出相同的结果,因为/bin/ed
是我们加载到内存中的输入。
h0mbre@ubuntu:~/blogpost$ ls -laht /tmp
total 14M
drwxrwxrwt 28 root root 128K Apr 3 08:44 .
-rw-rw-r-- 1 h0mbre h0mbre 736K Apr 3 08:43 ed.txt
-rw-rw-r-- 1 h0mbre h0mbre 736K Apr 3 08:43 harness.txt
-rw-rw-r-- 1 h0mbre h0mbre 2.2M Apr 3 08:42 fuzzme_original.txt
啊,它们的长度至少是一样的,这意味着一定存在一些细微的差异,diff
命令显示了哈希值不同的原因:
h0mbre@ubuntu:~/blogpost$ diff /tmp/ed.txt /tmp/harness.txt
2c2
< /bin/ed: file format elf64-x86-64
---
> fuzzme: file format elf64-x86-64
argv[]
数组中的文件名不同,这是唯一的区别。最终,我们能够向objdump
提供一个输入文件,但实际上它从我们HOOK中的内存缓冲区获取输入。
还有一件事情,我们实际上忘记了objdump
关闭了我们的文件,不是吗!于是我添加了一个快速的fclose()
HOOK。如果fclose()
只是想释放与fmemopen()
返回的FILE *
关联的堆内存,我们不会有任何问题;然而,它可能还会尝试调用close()
关闭那个奇怪的文件描述符,而我们不希望这样做。最终这可能都无关紧要,但为了安全起见还是加上了。读者可以自行实验,看看会产生什么变化。假想的模糊测试器应该会在其快照恢复例程中恢复FILE *
的堆内存。
结论
有无数种方法可以实现这个目标,我只是想带你们走一遍我的思考过程。实际上,有很多很酷的事情你可以用这个HOOK做,其中一件事是HOOKmalloc()
,让它在大规模分配时失败,这样我就不会浪费模糊测试周期在最终会超时的事情上。你还可以创建一个at_exit()
卡点,这样无论如何,程序每次退出时都会执行你的at_exit()
函数,这对于快照重置很有用,尤其是在程序可能有多个退出路径的情况下,因为你只需要覆盖一个退出点。
希望这对某些人有帮助!HOOK的完整代码在下面,祝模糊测试顺利!
#define _GNU_SOURCE
#include
#include
#include
#include
#include
#include
#include
#include
#define FUZZ_TARGET "fuzzme"
#define INPUT_SZ_ADDR 0x1336000
#define INPUT_ADDR 0x1337000
#define MAX_INPUT_SZ (1024 * 1024)
#define TEST_FILE "/bin/ed"
struct stat st;
FILE *faked_fp = NULL;
typedef int (*__xstat_t)(int __ver, const char *__filename, struct stat *__stat_buf);
__xstat_t real_xstat = NULL;
typedef FILE* (*fopen_t)(const char* pathname, const char* mode);
fopen_t real_fopen = NULL;
typedef FILE* (*fopen64_t)(const char* pathname, const char* mode);
fopen64_t real_fopen64 = NULL;
typedef int (*__fxstat_t)(int __ver, int __filedesc, struct stat *__stat_buf);
__fxstat_t real_fxstat = NULL;
typedef int (*fcntl_t)(int fildes, int cmd, ...);
fcntl_t real_fcntl = 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("** Err resolving '%s' addr: %s\n", symbol, err);
exit(-1);
}
return addr;
}
int __xstat(int __ver, const char* __filename, struct stat* __stat_buf) {
if (!real_xstat) {
real_xstat = _resolve_symbol("__xstat");
}
int ret = -1;
if (0x1337 == __ver) {
__ver = 1;
ret = real_xstat(__ver, __filename, __stat_buf);
real_xstat = NULL;
return ret;
}
if (!strcmp(__filename, FUZZ_TARGET)) {
st.st_size = *(size_t *)INPUT_SZ_ADDR;
memcpy(__stat_buf, &st, sizeof(struct stat));
ret = 0;
}
else {
ret = real_xstat(__ver, __filename, __stat_buf);
}
return ret;
}
FILE* fopen(const char* pathname, const char* mode) {
printf("** fopen() called for '%s'\n", pathname);
exit(0);
}
FILE* fopen64(const char* pathname, const char* mode) {
if (NULL == real_fopen64) {
real_fopen64 = _resolve_symbol("fopen64");
}
FILE* ret = NULL;
if (!strcmp(FUZZ_TARGET, pathname)) {
if (strcmp(mode, "r")) {
printf("** Attempt to open fuzz-target in illegal mode: '%s'\n", mode);
exit(-1);
}
ret = fmemopen((void*)INPUT_ADDR, *(size_t*)INPUT_SZ_ADDR, mode);
if (faked_fp) {
printf("** Attempting to fopen64() fuzzing target more than once\n");
exit(-1);
}
faked_fp = ret;
ret->_fileno = 1337;
}
else {
ret = real_fopen64(pathname, mode);
}
return ret;
}
int __fxstat (int __ver, int __filedesc, struct stat *__stat_buf) {
if (NULL == real_fxstat) {
real_fxstat = _resolve_symbol("__fxstat");
}
int ret = -1;
if (1337 == __filedesc) {
st.st_size = *(size_t*)INPUT_SZ_ADDR;
memcpy(__stat_buf, &st, sizeof(struct stat));
ret = 0;
}
else {
ret = real_fxstat(__ver, __filedesc, __stat_buf);
}
return ret;
}
int fcntl(int fildes, int cmd, ...) {
if (NULL == real_fcntl) {
real_fcntl = _resolve_symbol("fcntl");
}
if (fildes == 1337) {
return O_RDONLY;
}
else {
printf("** fcntl() called for real file descriptor\n");
exit(0);
}
}
static void _create_mem_mappings(void) {
void *result = NULL;
result = mmap(
(void *)(INPUT_SZ_ADDR),
sizeof(size_t),
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
0,
0
);
if ((MAP_FAILED == result) || (result != (void *)INPUT_SZ_ADDR)) {
printf("** Err mapping INPUT_SZ_ADDR, mapped @ %p\n", result);
exit(-1);
}
*(size_t *)INPUT_SZ_ADDR = 0;
result = mmap(
(void *)(INPUT_ADDR),
(size_t)(MAX_INPUT_SZ),
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
0,
0
);
if ((MAP_FAILED == result) || (result != (void *)INPUT_ADDR)) {
printf("** Err mapping INPUT_ADDR, mapped @ %p\n", result);
exit(-1);
}
memset((void *)INPUT_ADDR, 0, (size_t)MAX_INPUT_SZ);
}
static void _setup_stat_struct(void) {
int result = __xstat(0x1337, FUZZ_TARGET, &st);
if (-1 == result) {
printf("** Err creating stat struct for '%s' during load\n", FUZZ_TARGET);
}
}
#ifdef TEST
static void _test_func(void) {
int fd = open(TEST_FILE, O_RDONLY);
if (-1 == fd) {
printf("** Failed to open '%s' during test\n", TEST_FILE);
exit(-1);
}
ssize_t bytes = read(fd, (void*)INPUT_ADDR, (size_t)MAX_INPUT_SZ);
close(fd);
*(size_t *)INPUT_SZ_ADDR = (size_t)bytes;
}
#endif
__attribute__((constructor)) static void _hook_load(void) {
_create_mem_mappings();
_setup_stat_struct();
#ifdef TEST
_test_func();
#endif
}
译者言
本文使用chatGPT-4o翻译而成,如有错误之处,请斧正
原文链接:https://h0mbre.github.io/Fuzzing-Like-A-Caveman-6/
看雪ID:pureGavin
https://bbs.kanxue.com/user-home-777502.htm