首页
社区
课程
招聘
[翻译]野蛮fuzz - part 4:快照与代码覆盖率
发表于: 2024-8-30 17:18 1514

[翻译]野蛮fuzz - part 4:快照与代码覆盖率

2024-8-30 17:18
1514

介绍

上次我们写博客时,我们有一个简单的模糊测试器,它会测试一个故意有漏洞的程序,该程序会对文件进行一些检查,如果输入文件通过了检查,它会继续进行下一个检查,如果输入通过了所有检查,程序就会发生段错误。我们发现了代码覆盖的重要性,以及它如何帮助将模糊测试过程中指数级罕见的事件减少为线性罕见的事件。让我们直接进入如何改进我们的简单模糊测试器!

特别感谢@gamozolabs的所有内容,让我对这个话题产生了兴趣。

性能

首先,我们的简单模糊测试器非常慢。如果你还记得,我们的简单模糊测试器平均每秒大约进行1,500次模糊测试。在我的测试中,AFL在QEMU模式下(模拟没有可用的源代码进行编译插桩)每秒大约进行1,000次模糊测试。这是有道理的,因为AFL做的事情远比我们的简单模糊测试器多,尤其是在QEMU模式下,我们在模拟CPU并提供代码覆盖。

我们的目标二进制文件(-> 这里 <-)会执行以下操作:

从磁盘上的文件中提取字节到缓冲区
对缓冲区执行3次检查,查看检查的索引是否与硬编码值匹配
如果通过所有检查则发生段错误,如果有一个检查失败则退出
我们的简单模糊测试器会执行以下操作:

从磁盘上的有效jpeg文件中提取字节到字节缓冲区
通过随机字节覆盖变异缓冲区中2%的字节
将变异后的文件写入磁盘
通过在每次模糊测试迭代中执行fork()execvp()将变异后的文件传递给目标二进制文件
如你所见,这涉及大量的文件系统交互和系统调用。让我们在我们的漏洞二进制文件上使用strace,看看二进制文件发出了哪些系统调用(为了便于测试,在这篇文章中,我已经将.jpeg文件硬编码到漏洞二进制文件中,这样我们就不必使用命令行参数):

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
execve("/usr/bin/vuln", ["vuln"], 0x7ffe284810a0 /* 52 vars */) = 0
brk(NULL)                               = 0x55664f046000
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=88784, ...}) = 0
mmap(NULL, 88784, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f0793d2e000
close(3)                                = 0
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\260\34\2\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=2030544, ...}) = 0
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f0793d2c000
mmap(NULL, 4131552, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f079372c000
mprotect(0x7f0793913000, 2097152, PROT_NONE) = 0
mmap(0x7f0793b13000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1e7000) = 0x7f0793b13000
mmap(0x7f0793b19000, 15072, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f0793b19000
close(3)                                = 0
arch_prctl(ARCH_SET_FS, 0x7f0793d2d500) = 0
mprotect(0x7f0793b13000, 16384, PROT_READ) = 0
mprotect(0x55664dd97000, 4096, PROT_READ) = 0
mprotect(0x7f0793d44000, 4096, PROT_READ) = 0
munmap(0x7f0793d2e000, 88784)           = 0
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0
brk(NULL)                               = 0x55664f046000
brk(0x55664f067000)                     = 0x55664f067000
write(1, "[>] Analyzing file: Canon_40D.jp"..., 35[>] Analyzing file: Canon_40D.jpg.
) = 35
openat(AT_FDCWD, "Canon_40D.jpg", O_RDONLY) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=7958, ...}) = 0
fstat(3, {st_mode=S_IFREG|0644, st_size=7958, ...}) = 0
lseek(3, 4096, SEEK_SET)                = 4096
read(3, "\v\260\v\310\v\341\v\371\f\22\f*\fC\f\\\fu\f\216\f\247\f\300\f\331\f\363\r\r\r&"..., 3862) = 3862
lseek(3, 0, SEEK_SET)                   = 0
write(1, "[>] Canon_40D.jpg is 7958 bytes."..., 33[>] Canon_40D.jpg is 7958 bytes.
) = 33
read(3, "\377\330\377\340\0\20JFIF\0\1\1\1\0H\0H\0\0\377\341\t\254Exif\0\0II"..., 4096) = 4096
read(3, "\v\260\v\310\v\341\v\371\f\22\f*\fC\f\\\fu\f\216\f\247\f\300\f\331\f\363\r\r\r&"..., 4096) = 3862
close(3)                                = 0
write(1, "[>] Check 1 no.: 2626\n", 22[>] Check 1 no.: 2626
) = 22
write(1, "[>] Check 2 no.: 3979\n", 22[>] Check 2 no.: 3979
) = 22
write(1, "[>] Check 3 no.: 5331\n", 22[>] Check 3 no.: 5331
) = 22
write(1, "[>] Check 1 failed.\n", 20[>] Check 1 failed.
)   = 20
write(1, "[>] Char was 00.\n", 17[>] Char was 00.
)      = 17
exit_group(-1)                          = ?
+++ exited with 255 +++

你可以看到,在目标二进制文件的处理过程中,我们在打开输入文件之前运行了大量代码。查看strace的输出,我们甚至在打开输入文件之前已经运行了以下系统调用:

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
execve
brk
access
access
openat
fstat
mmap
close
access
openat
read
opeant
read
fstat
mmap
mmap
mprotect
mmap
mmap
arch_prctl
mprotect
mprotect
mprotect
munmap
fstat
brk
brk
write

在所有这些系统调用之后,我们终于从磁盘打开文件并读取字节,以下是strace输出中的一行:

1
openat(AT_FDCWD, "Canon_40D.jpg", O_RDONLY) = 3

所以请记住,我们的简单模糊测试器在每次模糊测试迭代中都会运行这些系统调用。我们的简单模糊测试器(-> 这里 <-)每次迭代都会将一个文件写入磁盘,并通过fork()execvp()生成目标程序的一个实例。漏洞二进制文件每次迭代都会运行所有的启动系统调用,最终从磁盘读取文件。因此,每次模糊测试迭代都会有几十个系统调用和两次文件系统交互。难怪我们的简单模糊测试器如此之慢。

基本的快照机制

我开始思考如何在模糊测试这样一个简单的目标二进制文件时节省时间,并认为如果我能找到一种方法在程序从磁盘读取文件并将内容存储在堆中之后拍摄其内存快照,我可以保存该进程状态,并手动插入一个新的模糊测试用例替换目标读取的字节,然后让程序运行直到它到达exit()调用。一旦目标到达exit调用,我会将程序状态倒回到拍摄快照时的状态,并插入一个新的模糊测试用例,然后再重复这一过程。

你可以看到这将如何提高性能。我们将跳过所有目标二进制文件的启动开销,并完全绕过所有文件系统交互。一个巨大的区别是我们只会调用一次fork(),这是一个昂贵的系统调用。假设进行100,000次模糊测试迭代,我们将从200,000次文件系统交互(一次是简单模糊测试器在磁盘上创建一个变异的.jpeg文件,一次是目标读取变异的.jpeg文件)和100,000次fork()调用减少到0次文件系统交互和仅一次初始fork()

总而言之,我们的模糊测试过程应如下所示:

  1. 启动目标二进制文件,但在任何操作运行之前在第一条指令上中断
  2. 在“开始”和“结束”位置设置断点(开始将在程序从磁盘读取字节之后,结束将在exit()的地址)
  3. 运行程序直到它到达“开始”断点
  4. 将进程的所有可写内存段收集到一个缓冲区中
  5. 捕获所有寄存器状态
  6. 将我们的模糊测试用例插入堆中,覆盖程序从磁盘读取的字节
  7. 恢复目标二进制文件,直到它到达“结束”断点
  8. 将进程状态倒回到“开始”时的状态
  9. 从第6步开始重复

我们只需要执行步骤1-5一次,所以这个过程不需要非常快。步骤6-9是模糊测试器将花费99%时间的地方,所以我们需要这个过程非常快。

使用Ptrace编写一个简单的调试器
为了实现我们的快照机制,我们需要使用非常直观但显然缓慢且有约束的ptrace()接口。当我几周前开始编写模糊测试器的调试器部分时,我主要参考了Eli Bendersky的这篇博客文章,这是一个很好的ptrace()入门教程,展示了如何创建一个简单的调试器。

断点

我们代码中的调试器部分实际上不需要太多功能,它只需要能够插入和删除断点。使用ptrace()设置和删除断点的方法是覆盖地址处的单字节指令,使用int3操作码\xCC。然而,如果你在设置断点时直接覆盖该值,将无法删除断点,因为你不知道那里原本持有的值是什么,因此你不知道用什么来覆盖\xCC

要开始使用ptrace(),我们使用fork()生成第二个进程。

1
2
3
4
5
pid_t child_pid = fork();
if (child_pid == 0) {
    //we're the child process here
    execute_debugee(debugee);
}

现在我们需要让子进程自愿被父进程“跟踪”。这是通过使用PTRACE_TRACEME参数完成的,我们将在execute_debugee函数中使用它:

1
2
3
4
5
6
7
// request via PTRACE_TRACEME that the parent trace the child
long ptrace_result = ptrace(PTRACE_TRACEME, 0, 0, 0);
if (ptrace_result == -1) {
    fprintf(stderr, "\033[1;35mdragonfly>\033[0m error (%d) during ", errno);
    perror("ptrace");
    exit(errno);
}

函数的其余部分不涉及ptrace,但我会继续在这里展示,因为有一个重要的函数可以强制在被调试进程中禁用ASLR(地址空间布局随机化)。这是至关重要的,因为我们将利用静态地址的断点,这些地址不能在不同进程间变化。我们通过调用带有ADDR_NO_RANDOMIZEpersonality()来禁用ASLR。另外,我们会将stdoutstderr重定向到/dev/null,这样我们就不会因为目标二进制文件的输出弄乱我们的终端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// disable ASLR
int personality_result = personality(ADDR_NO_RANDOMIZE);
if (personality_result == -1) {
    fprintf(stderr, "\033[1;35mdragonfly>\033[0m error (%d) during ", errno);
    perror("personality");
    exit(errno);
}
  
// dup both stdout and stderr and send them to /dev/null
int fd = open("/dev/null", O_WRONLY);
dup2(fd, 1);
dup2(fd, 2);
close(fd);
  
// exec our debugee program, NULL terminated to avoid Sentinel compilation
// warning. this replaces the fork() clone of the parent with the
// debugee process
int execl_result = execl(debugee, debugee, NULL);
if (execl_result == -1) {
    fprintf(stderr, "\033[1;35mdragonfly>\033[0m error (%d) during ", errno);
    perror("execl");
    exit(errno);
}

所以首先,我们需要一种方法在插入断点之前抓取地址处的单字节值。对于模糊测试器,我开发了一个头文件和源文件,称为ptrace_helpers,以帮助简化使用ptrace()的开发过程。为了抓取该值,我们将抓取地址处的64位值,但只关心最右边的字节。(我使用类型long long unsigned,因为在<sys/user.h>中寄存器值是这样定义的,我希望保持一致)。

1
2
3
4
5
6
7
8
9
10
11
12
long long unsigned get_value(pid_t child_pid, long long unsigned address) {
     
    errno = 0;
    long long unsigned value = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)address, 0);
    if (value == -1 && errno != 0) {
        fprintf(stderr, "dragonfly> Error (%d) during ", errno);
        perror("ptrace");
        exit(errno);
    }
 
    return value;  
}

所以这个函数将使用PTRACE_PEEKTEXT参数读取子进程(child_pid)中位于地址处的值,这是我们的目标。现在我们有了这个值,可以将其保存并插入我们的断点,代码如下:

1
2
3
4
5
6
7
8
9
10
11
void set_breakpoint(long long unsigned bp_address, long long unsigned original_value, pid_t child_pid) {
 
    errno = 0;
    long long unsigned breakpoint = (original_value & 0xFFFFFFFFFFFFFF00 | 0xCC);
    int ptrace_result = ptrace(PTRACE_POKETEXT, child_pid, (void*)bp_address, (void*)breakpoint);
    if (ptrace_result == -1 && errno != 0) {
        fprintf(stderr, "dragonfly> Error (%d) during ", errno);
        perror("ptrace");
        exit(errno);
    }
}

你可以看到,这个函数将使用之前函数收集的原始值,并执行两个按位操作来保持前7个字节不变,但将最后一个字节替换为\xCC。注意,我们现在使用的是PTRACE_POKETEXTptrace()接口的一个令人沮丧的特点是我们一次只能读取和写入8个字节!

既然我们可以设置断点,最后需要实现的函数是删除断点,这涉及用原始字节值覆盖int3

1
2
3
4
5
6
7
8
9
10
void revert_breakpoint(long long unsigned bp_address, long long unsigned original_value, pid_t child_pid) {
 
    errno = 0;
    int ptrace_result = ptrace(PTRACE_POKETEXT, child_pid, (void*)bp_address, (void*)original_value);
    if (ptrace_result == -1 && errno != 0) {
        fprintf(stderr, "dragonfly> Error (%d) during ", errno);
        perror("ptrace");
        exit(errno);
    }
}

同样,使用PTRACE_POKETEXT,我们可以用原始字节值覆盖\xCC。现在我们有能力设置和删除断点了。

最后,我们需要一种方法来恢复被调试进程的执行。这可以通过在ptrace()中使用PTRACE_CONT参数来实现,如下所示:

1
2
3
4
5
6
7
8
9
void resume_execution(pid_t child_pid) {
 
    int ptrace_result = ptrace(PTRACE_CONT, child_pid, 0, 0);
    if (ptrace_result == -1) {
        fprintf(stderr, "dragonfly> Error (%d) during ", errno);
        perror("ptrace");
        exit(errno);
    }
}

需要注意的重要一点是,如果我们在地址0x0000000000000000处遇到断点,rip实际上会在0x0000000000000001处。因此,在恢复被覆盖的指令为其先前的值之后,我们还需要在恢复执行之前从rip中减去1,我们将在下一节中学习如何通过ptrace来做到这一点。

现在让我们学习如何利用ptrace/proc伪文件来创建目标的快照!

使用ptrace和/proc进行快照

寄存器状态

ptrace()的另一个很酷的功能是能够捕获和设置被调试进程中的寄存器状态。我们可以分别使用我放在ptrace_helpers.c中的辅助函数来完成这两件事:

1
2
3
4
5
6
7
8
9
10
11
// retrieve register states
struct user_regs_struct get_regs(pid_t child_pid, struct user_regs_struct registers) {                                                                                                
    int ptrace_result = ptrace(PTRACE_GETREGS, child_pid, 0, &registers);                                                                             
    if (ptrace_result == -1) {                                                                             
        fprintf(stderr, "dragonfly> Error (%d) during ", errno);                                                                        
        perror("ptrace");                                                                             
        exit(errno);                                                                             
    }
 
    return registers;                                                                             
}
1
2
3
4
5
6
7
8
9
10
// set register states
void set_regs(pid_t child_pid, struct user_regs_struct registers) {
 
    int ptrace_result = ptrace(PTRACE_SETREGS, child_pid, 0, &registers);
    if (ptrace_result == -1) {
        fprintf(stderr, "dragonfly> Error (%d) during ", errno);
        perror("ptrace");
        exit(errno);
    }
}

结构体user_regs_struct<sys/user.h>中定义。你可以看到我们分别使用PTRACE_GETREGSPTRACE_SETREGS来检索寄存器数据和设置寄存器数据。因此,通过这两个函数,我们将能够在‘开始’断点处创建快照寄存器值的struct user_regs_struct,并在到达‘结束’断点时恢复寄存器状态(最重要的是rip)到快照时的状态。

使用/proc进行可写内存段的快照

现在我们有了一种捕获寄存器状态的方法,我们还需要一种捕获快照的可写内存状态的方法。我通过与/proc伪文件交互来实现这一点。我使用GDB在vuln中执行检查的第一个函数上断点,重要的是这个函数是在vuln从磁盘读取jpeg之后,将作为我们的‘开始’断点。一旦我们在GDB中在这里断点,我们可以cat /proc/$pid/maps文件来查看进程中的内存映射(请记住,GDB也使用我们调试器中相同的方法强制关闭ASLR)。我们可以在这里看到通过grep筛选出的可写段的输出(即,在我们的模糊测试运行期间可能被覆盖的段):

1
2
3
4
5
6
7
8
9
h0mbre@pwn:~/fuzzing/dragonfly_dir$ cat /proc/12011/maps | grep rw
555555756000-555555757000 rw-p 00002000 08:01 786686                     /home/h0mbre/fuzzing/dragonfly_dir/vuln
555555757000-555555778000 rw-p 00000000 00:00 0                          [heap]
7ffff7dcf000-7ffff7dd1000 rw-p 001eb000 08:01 1055012                    /lib/x86_64-linux-gnu/libc-2.27.so
7ffff7dd1000-7ffff7dd5000 rw-p 00000000 00:00 0
7ffff7fe0000-7ffff7fe2000 rw-p 00000000 00:00 0
7ffff7ffd000-7ffff7ffe000 rw-p 00028000 08:01 1054984                    /lib/x86_64-linux-gnu/ld-2.27.so
7ffff7ffe000-7ffff7fff000 rw-p 00000000 00:00 0
7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                          [stack]

所以这是七个不同的内存段。你会注意到堆是其中一个段。重要的是要意识到我们的模糊测试用例将插入到堆中,但存储模糊测试用例的堆地址在我们的模糊测试器中与在GDB中是不一样的。我认为这可能是由于两个调试器之间某种环境变量的差异。如果我们在GDB中查看,当我们在vuln中断在check_one()时,我们看到rax是指向我们输入的开头的指针,在这个例子中是Canon_40D.jpg

1
$rax   : 0x00005555557588b0  →  0x464a1000e0ffd8ff

这个指针0x00005555557588b0位于堆中。所以我所要做的就是在我们的调试器/模糊测试器中找到这个指针的位置,只需在相同的位置断点并使用ptrace()检索rax值。

我会在check_one处断点,然后打开/proc/$pid/maps以获取程序中包含可写内存段的偏移量,然后我会打开/proc/$_pid_/_maps_以获取程序中包含可写内存段的偏移量,然后我会打开/_proc_/$pid/mem并从这些偏移量读取到缓冲区以存储可写内存。这段代码存储在一个名为snapshot.c的源文件中,其中包含一些定义和函数,用于捕获快照和恢复快照。对于这一部分,捕获可写内存,我使用了以下定义和函数:

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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
unsigned char* create_snapshot(pid_t child_pid) {
  
    struct SNAPSHOT_MEMORY read_memory = {
        {
            // maps_offset
            0x555555756000,
            0x7ffff7dcf000,
            0x7ffff7dd1000,
            0x7ffff7fe0000,
            0x7ffff7ffd000,
            0x7ffff7ffe000,
            0x7ffffffde000
        },
        {
            // snapshot_buf_offset
            0x0,
            0xFFF,
            0x2FFF,
            0x6FFF,
            0x8FFF,
            0x9FFF,
            0xAFFF
        },
        {
            // rdwr length
            0x1000,
            0x2000,
            0x4000,
            0x2000,
            0x1000,
            0x1000,
            0x21000
        }
    }; 
  
    unsigned char* snapshot_buf = (unsigned char*)malloc(0x2C000);
  
    // this is just /proc/$pid/mem
    char proc_mem[0x20] = { 0 };
    sprintf(proc_mem, "/proc/%d/mem", child_pid);
  
    // open /proc/$pid/mem for reading
    // hardcoded offsets are from typical /proc/$pid/maps at main()
    int mem_fd = open(proc_mem, O_RDONLY);
    if (mem_fd == -1) {
        fprintf(stderr, "dragonfly> Error (%d) during ", errno);
        perror("open");
        exit(errno);
    }
  
    // this loop will:
    //  -- go to an offset within /proc/$pid/mem via lseek()
    //  -- read x-pages of memory from that offset into the snapshot buffer
    //  -- adjust the snapshot buffer offset so nothing is overwritten in it
    int lseek_result, bytes_read;
    for (int i = 0; i < 7; i++) {
        //printf("dragonfly> Reading from offset: %d\n", i+1);
        lseek_result = lseek(mem_fd, read_memory.maps_offset[i], SEEK_SET);
        if (lseek_result == -1) {
            fprintf(stderr, "dragonfly> Error (%d) during ", errno);
            perror("lseek");
            exit(errno);
        }
  
        bytes_read = read(mem_fd,
            (unsigned char*)(snapshot_buf + read_memory.snapshot_buf_offset[i]),
            read_memory.rdwr_length[i]);
        if (bytes_read == -1) {
            fprintf(stderr, "dragonfly> Error (%d) during ", errno);
            perror("read");
            exit(errno);
        }
    }
  
    close(mem_fd);
    return snapshot_buf;
}

你可以看到我硬编码了所有的偏移量和段的长度。请记住,这不需要很快。我们只需要捕获一次快照,所以与文件系统交互是可以的。因此,我们将遍历这7个偏移量和长度,并将它们全部写入一个名为snapshot_buf的缓冲区中,该缓冲区将存储在我们模糊测试器的堆中。所以现在我们有了进程在开始check_one(我们的‘开始’断点)时的寄存器状态和内存状态。

现在让我们弄清楚如何在到达‘结束’断点时恢复快照。

恢复快照

为了恢复进程的内存状态,我们可以像读取时一样写入/proc/$pid/mem;然而,这部分需要很快,因为我们现在在每次模糊测试迭代中都要这样做。每次模糊测试迭代都与文件系统交互会大大降低我们的速度。幸运的是,自从Linux内核版本3.2以来,支持一种更快的进程间内存读写API,叫做process_vm_writev()。由于这个过程直接与另一个进程交互,不会遍历内核且不涉及文件系统,它将大大提高我们的写入速度。

乍一看有点令人困惑,但man手册中的示例实际上是理解其工作原理所需的全部内容,我选择硬编码所有偏移量,因为这个模糊测试器只是一个POC。我们可以如下恢复可写内存:

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
void restore_snapshot(unsigned char* snapshot_buf, pid_t child_pid) {
  
    ssize_t bytes_written = 0;
    // we're writing *from* 7 different offsets within snapshot_buf
    struct iovec local[7];
    // we're writing *to* 7 separate sections of writable memory here
    struct iovec remote[7];
  
    // this struct is the local buffer we want to write from into the
    // struct that is 'remote' (ie, the child process where we'll overwrite
    // all of the non-heap writable memory sections that we parsed from
    // proc/$pid/memory)
    local[0].iov_base = snapshot_buf;
    local[0].iov_len = 0x1000;
    local[1].iov_base = (unsigned char*)(snapshot_buf + 0xFFF);
    local[1].iov_len = 0x2000;
    local[2].iov_base = (unsigned char*)(snapshot_buf + 0x2FFF);
    local[2].iov_len = 0x4000;
    local[3].iov_base = (unsigned char*)(snapshot_buf + 0x6FFF);
    local[3].iov_len = 0x2000;
    local[4].iov_base = (unsigned char*)(snapshot_buf + 0x8FFF);
    local[4].iov_len = 0x1000;
    local[5].iov_base = (unsigned char*)(snapshot_buf + 0x9FFF);
    local[5].iov_len = 0x1000;
    local[6].iov_base = (unsigned char*)(snapshot_buf + 0xAFFF);
    local[6].iov_len = 0x21000;
  
    // just hardcoding the base addresses that are writable memory
    // that we gleaned from /proc/pid/maps and their lengths
    remote[0].iov_base = (void*)0x555555756000;
    remote[0].iov_len = 0x1000;
    remote[1].iov_base = (void*)0x7ffff7dcf000;
    remote[1].iov_len = 0x2000;
    remote[2].iov_base = (void*)0x7ffff7dd1000;
    remote[2].iov_len = 0x4000;
    remote[3].iov_base = (void*)0x7ffff7fe0000;
    remote[3].iov_len = 0x2000;
    remote[4].iov_base = (void*)0x7ffff7ffd000;
    remote[4].iov_len = 0x1000;
    remote[5].iov_base = (void*)0x7ffff7ffe000;
    remote[5].iov_len = 0x1000;
    remote[6].iov_base = (void*)0x7ffffffde000;
    remote[6].iov_len = 0x21000;
  
    bytes_written = process_vm_writev(child_pid, local, 7, remote, 7, 0);
    //printf("dragonfly> %ld bytes written\n", bytes_written);
}

对于7个不同的可写段,我们将从包含原始快照数据的snapshot_buf中,在/proc/$pid/maps中定义的偏移量处写入调试进程。这样会非常快!

现在我们已经有能力恢复可写内存了,我们只需要恢复寄存器状态,就可以完成我们的基本快照机制。这很容易,通过使用我们在ptrace_helpers中定义的函数,你可以在模糊测试循环中看到以下两个函数调用:

1
2
3
4
5
// restore writable memory from /proc/$pid/maps to its state at Start
restore_snapshot(snapshot_buf, child_pid);
 
// restore registers to their state at Start
set_regs(child_pid, snapshot_registers);

所以这就是我们的快照过程是如何工作的,在我的测试中,我们比愚蠢的模糊测试器快了大约20-30倍!

让我们的愚蠢模糊测试器变得智能

此时,我们仍然有一个愚蠢的模糊测试器(尽管现在快了很多)。我们需要能够跟踪代码覆盖率。一个非常简单的方法是在check_oneexit之间的每个“基本块”处放置一个断点,这样如果我们到达新代码,就会触发一个断点,我们可以在那时执行某些操作。

这正是我所做的,只是为了简单起见,我只在check_twocheck_three的入口点放置了“动态”(代码覆盖)断点。当触发“动态”断点时,我们将到达该代码的输入保存到一个名为“corpus”的字符指针数组中,现在我们可以开始变异这些保存的输入,而不仅仅是我们原型输入的Canon_40D.jpg

因此,我们的代码覆盖反馈机制将如下工作:

  1. 变异原型输入并将模糊测试用例插入堆中
  2. 恢复被调试进程
  3. 如果到达“动态断点”,将输入保存到corpus中
  4. 如果corpus大于0,则随机从corpus或原型中选择一个输入并从步骤1重复
    我们还必须移除动态断点,以便停止在其上中断。好在我们已经知道如何很好地做到这一点!

正如你可能还记得的,代码覆盖对于我们能够使这个测试二进制漏洞崩溃至关重要,因为它执行了3个字节比较,所有这些都必须通过才能崩溃。我们在上次帖子中数学地确定了通过第一个检查的机会大约是1/13,000,通过前两个检查的机会大约是1/170,000,000。因为我们保存了通过check_one的输入并进一步变异它,我们可以将通过check_two的概率降低到接近1/13,000的数字。这也适用于然后通过check_two的输入,因此我们可以轻松地到达并通过check_three

运行模糊测试器

我们模糊测试器的第一阶段,收集快照数据并设置代码覆盖的“动态断点”,即使它不打算快,也能非常快地完成。这是因为所有的值都是硬编码的,因为我们的目标非常简单。在一个复杂的多线程目标中,我们需要某种方式通过Ghidra或objdump等脚本发现动态断点地址,并且我们需要让该脚本为我们的模糊测试器写一个配置文件,但那是很远的事情。目前,对于一个POC,这样做是可以的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
h0mbre@pwn:~/fuzzing/dragonfly_dir$ ./dragonfly
 
dragonfly> debuggee pid: 12156
dragonfly> setting 'start/end' breakpoints:
 
   start-> 0x555555554b41
   end  -> 0x5555555548c0
 
dragonfly> set dynamic breakpoints:
 
           0x555555554b7d
           0x555555554bb9
 
dragonfly> collecting snapshot data
dragonfly> snapshot collection complete
dragonfly> press any key to start fuzzing!

你可以看到,模糊测试器会有帮助地显示“开始”和“结束”断点,并列出“动态断点”,这样我们可以在模糊测试之前检查它们是否正确。模糊测试器会暂停并等待我们按任意键开始模糊测试。我们还可以看到,快照数据收集已经成功完成,所以现在我们在“开始”处中断,并且拥有了开始模糊测试所需的所有数据。

一旦我们按下回车键,就会得到一个统计输出,显示模糊测试的进展情况:

1
2
3
4
5
6
dragonfly> stats (target:vuln, pid:12156)
 
fc/s       : 41720
crashes    : 5
iterations : 0.3m
coverage   : 2/2 (%100.00)

正如你所看到的,它几乎瞬间找到了两个“动态断点”,并且当前每秒CPU时间运行大约41k次模糊测试迭代(比我们的愚蠢模糊测试器快了大约20-30倍)。

最重要的是,你可以看到我们在仅仅300k次迭代中已经能够使二进制程序崩溃5次!我们以前的模糊测试器是绝对做不到这一点的。

结论

对我来说,从这次实践中最大的收获之一是,只要针对目标自定义模糊测试器,就能大幅提升性能。使用像AFL这样的现成框架非常棒,这些工具都非常令人印象深刻,我希望这个模糊测试器有一天能成长为一个可与之媲美的工具。对于这个非常简单的目标,我们能够比AFL快20-30倍,并且通过一点点逆向工程和自定义几乎瞬间使其崩溃。我认为这非常有趣且具有教育意义。未来,当我将这个模糊测试器适配到一个真实目标时,我应该能够再次超越这些框架。

改进的想法

从哪里开始呢?我们有很多可以改进的地方,但一些可以立即进行的改进包括:

通过重构代码、改变全局变量的位置来优化性能
通过可以通过Python脚本创建的配置文件启用模糊测试器的动态配置
实现更多的变异方法
实现更多的代码覆盖机制
开发模糊测试器,使其能够并行运行多个实例并共享发现的输入/覆盖数据
也许我们会在后续的帖子中看到这些改进,以及使用相同的一般方法对真实目标进行模糊测试的结果。直到那时!

代码

所有与本博文相关的代码都可以在这里找到:https://github.com/h0mbre/Fuzzing/tree/master/Caveman4

译者言

本文使用chatGPT-4o翻译,如有错误之处,请斧正
原文链接:https://h0mbre.github.io/Fuzzing-Like-A-Caveman-4/


[课程]Android-CTF解题方法汇总!

收藏
免费 0
支持
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回
//