Fuzzing random programs without execve() 译文

Fuzzing random programs without execve()

原作者:lcamtuf

原文:https://lcamtuf.blogspot.com/2014/10/fuzzing-binaries-without-execve.html

对数据分析库进行fuzz的最常用方法就是找到一个能运行可疑函数的简单的二进制文件,并且让其保持一遍又一遍的运行————当然,每次运行过程中输入的随机变量都略有不同。在这样一种设置下,对库中明显的内存污染漏洞的检测就会很简单,像在子进程中使用waitpid()并核实其是否因为SIGSEGV,SIGABRT或其它相似的信号量而消亡。

安全研究员喜欢上述方案的原因有两个。首先,它不需要对文档深入研究,了解底层库提供的API,就能够以一个更加直观的方式编写自定义的代码对解析器进行压力测试。其次,它使得fuzz流程变得可重复并且具有鲁棒性:程序运行在一个分离的进程中并以每个输入文件重新开始,因此不必担心库中的随机内存损坏错误会破坏模糊测试器本身的状态,或是对随后测试工具的运行产生奇怪的副作用。

不幸的是,上述方案也会产生一个问题:对特别对简单的库来说,你可能会花费大部分的来等待execve()的运行、链接器以及所有的库初始化例程来完成其工作。我一直在想办法来最大限度地减少AFL中的这种开销,但是大部分我想出来的方法都很复杂。比如,编写一个自定义的ELF加载器并且在进程中运行程序,同时使用mprotect()来暂时锁定fuzzer本身的内存————但是诸如信号得处理将会变得一团糟。另外一个选择是在单个子进程中执行,对子进程的内存空间拍一个快照,稍后通过/proc/pid/mem来“倒带”该镜像————但是同样地,处理信号或者文件描述符需要消耗大量的脑细胞。

幸运的是,Jann Horn想出了一个更简单的方法,并出人意料地给我发了一个AFL的补丁。这个方法归结为将一小段代码注入到被测试的二进制文件中————一个通过使用环境变量LD_PRELOAD、参数PTRACE_PRLETEXT、编译时插桩,或者仅仅提前重写ELF的二进制文件来实现的壮举。注入shim代码的目的是让execve()运行,越过链接器(理想情况下,使用LD_BIND_NOW = 1,这样所有的辛苦工作都能事先完成),然后在处理fuzzer生成的任何输入或进行其他有趣的的操作之前,尽早停止execve函数。实际上,在最简单的案例下,我们可以简单的停止在main()函数。

只要设计在程序中的点达到了,我们的shim代码就会等待来自fuzzer的指令;当它接受到了go,就会调用fork()函数来创建一个已加载程序的复制;由于写时拷贝技术,这个创建复制的过程会非常的快,且与原进程的隔离度很高。在子进程中,注入的代码将控制权返还给原始二进制文件,从而使其能够处理fuzzer提供的输入数据(这样做会带来许多后果)。在父进程中,shim代码将创建的进程的PID传递给fuzzer,并返回到等待fuzzer指令的循环中。

当然,在你开始在Unix上处理进程语义时(?),没有什么比它乍看起来更容易;这里给出一些我们必须在代码中解决的问题:

  • 文件描述符偏移量在被fork()创建的进程之间共享。这意味着在执行shim代码时打开的所有描述符都可能需要回退到它们的初始位置(以保证绝对位置不变);如果我们在main()处停止这不会是一个大问题————由于stdin时描述符的来源,我们可以在fuzzer自身执行lseek()来回退stdin————但若我们将停住的地方对准更远的地方,这将会成为一个障碍。

  • 同样的,有些类型的文件描述符无法修复。在访问管道、字符设备、套接字和类似的不可充值的I/O设备之前,shim代码必须先被执行。当然,这对main()函数停下来的情况不成问题。

  • 复制线程的任务更加复杂并且需要shim代码跟踪所有的线程。因此,在简单的实现中,需要在二进制文件产生任何线程之前注入shim代码。(当然,线程在文件解析库中很少见,而在重量级工具中更常见)

  • fuzzer不再是被测试进程的直系父进程,而是祖父进程,它不能直接使用waitpid()函数;也没有其他简单的可移植的API来获取有关流程退出状态的通知。我们通过让shim代码等待然后发送状态码给fuzzer来解决该问题。从理论上讲,我们应该简单地使用CLONE_PARENT标志调用clone()的syscall,这将使得新进程继承原始的PPID。不幸的是,直接调用syscall会混淆glibc,因为在初始化的时候库会缓存getid()的结果————并且不会更新缓存,依赖PID的调用(诸如abort()或者raise())会误入歧途。还有一个用于clone()调用的库封装程序,它会更新缓存的PID————但是封装程序很不好用,会一直弄乱进程的栈空间。

(说句公道话,PTRACE_ATTACH提供了一种临时接收进程并通知其退出状态,但是它以两种方式改变了流程的语义,这需要大量的代码来消除影响。)

即便考虑到上述的问题,shim代码也不复杂,移动的的模块(?)也很少————与我之前想到的解决方案相比,这是一个令人欣慰的缓解方案。它通过读取文件描述符198的命令,使用文件描述符199来发送消息给父进程,并且仅执行最低限度的工作来完成任务。该代码略有删节的版本如下:

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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
__afl_forkserver:

/*告诉父进程正常运行*/

/*write(199,__afl_temp,4);*/

pushl $4 /* length */
pushl $__afl_temp /* data */
pushl $199 /* file desc */
call write
addl $12, %esp

__afl_fork_wait_loop:

/*通过从管道中读取来等待父进程的指令。这将进入循环知道父进程发送消息。如果读取失败将跳出。*/

/*int flag_1 = read(198,__afl_temp,4);*/

pushl $4 /* length */
pushl $__afl_temp /* data */
pushl $198 /* file desc */
call read
addl $12, %esp

/*
if(flag_1 < 4) goto __afl_die;
*/

cmpl $4, %eax
jne __afl_die

/*唤醒,创建子进程*/

/*
pid_t pid = fork();

if(pid < 0) goto __afl_die;
else goto __afl_fork_resume;
*/

call fork

cmpl $0,%eax
jl __afl_die
je __afl_fork_resume

/*将PID写入管道,等待子进程。父进程会处理timeout以及SIGKILL信号。*/

/*
__afl_fork_pid = pid;

write(199,__afl_fork_pid,4);

pid_t tpid = waitpid(__afl_fork_pid,__afl_temp,2);

if(tpid < 0) goto __afl_die;
*/

movl %eax, __afl_fork_pid

pushl $4 /* length */
pushl $__afl_fork_pid /* data */
pushl $199 /* file desc */
call write
addl $12, %esp

pushl $2 /* WUNTRACED */
pushl $__afl_temp /* status */
pushl __afl_fork_pid /* PID */
call waitpid
addl $12, %esp

cmpl $0, %eax
jle __afl_die

/*将等待信息写入管道,然后回到等待循环*/

/*
write(199,__afl_temp,4);
goto __afl_fork_wait_loop;
*/

pushl $4 /* length */
pushl $__afl_temp /* data */
pushl $199 /* file desc */
call write
addl $12, %esp

jmp __afl_fork_wait_loop

/*子进程中:关闭文件描述符*/

/*
close(198);
close(199);
return ;
*/
pushl $198
call close

pushl $199
call close

addl $8, %esp
ret

但话说回来,上述方案值得吗?答案是肯定的:在main()函数处停下来的逻辑已经使用到了afl 0.36b版本中,能够将许多针对图像库的模糊测试的速度提高两个或更多的数量级。考虑到我们仍然使用了fork(),这几乎是难以置信的,因为syscall是久负盛名的龟速运行。

下一个挑战是将shim代码放到更远的地方,以便跳过任何常见的程序初始化步骤,比如读取配置文件————并在程序尝试读取我们正在处理的编译数据时停止一些指令。Jann’s原始的补丁中提供了一个依赖ptrace()来检测文件访问的解决方案;但我们一直在集思广益。