其实还是挺简单的
在一次期末报告里面做了这个报告
关于AFL的基本步骤
工作流程基本上可以用 5 个步骤来描述:预处理、输入构建、输入选择、评估、后模糊测试。 真正的内核处理是步骤 2 到 4
-
预处理
分析和获取有用信息,使用PIN,符号执行,污点检查
黑盒白盒
-
输入构建
从数据 S(种子)产生大量变异数据 I。
-
输入选择
过滤无效数据,优化模糊测试
-
评估
大多数关于模糊的研究集中在两个指标上:覆盖率和利用漏洞的平均时间
源码分析
编译插桩
afl-gcc
根据使用方法,首先是使用afl-gcc
进行编译,在编译时就完成插桩
本就是是包裹的GCC盒CLANG
1 | int main(int argc, char** argv) { |
SAYF
就是fprintf
函数,检测到参数小于2的时候报错并退出
- 使用
find_as
进行参数的解析到自身程序路径,找到afl-as
的afl汇编器 edit_params
中,解析参数,设置自生对应的编译器,获得和设置一系列环境变量,最后得到的cc_params
就是编译参数execvp(cc_params[0], (char**)cc_params);
,通过前面找到的汇编器as_path
和参数创建进程进行插桩编译。(具体就是替换了程序名称之类的,源码很简单)
afl-as
main
进入main创建了一些列变量然后就是在a
中将 cc_params
转为as_params
add_instrumentation
重头函数
1 | while (fgets(line, MAX_LINE, inf)) { |
所以,AFL的代码插桩,就是在将源文件编译为汇编代码后,通过afl-as
完成。开始重写汇编指令,准备在分支处插入代码
先看看32位,64位和这个也差不多
trampoline_fmt_32
1 | static const u8* trampoline_fmt_32 = |
这就是汇编,听chatGPT说
1 | section .text |
32位是经典的栈传参,这里使用对esp的移动实现了从edi
edx
ecx
eax
的保存,然后又将ecx
设置为了0x08000000,这个是chatGPT翻译错了,正确的理解是"%08x" 是格式说明符,用于将一个整数格式化为一个带有前导零的 8 位十六进制数。例如,“%08x” 将把数字 10 格式化为 “0000000A”。这段汇编仍然是属于C语言的范畴。
似乎我们下一步要看的是__afl_maybe_log
,**但是你就没想过R(MAP_SIZE)**有什么用吗?R(x)
的定义是(random() % (x))
,所以R(MAP_SIZE)
即为0到MAP_SIZE之间的一个随机数。
因此,在处理到某个分支,需要插入桩代码时,afl-as
会生成一个随机数,作为运行时保存在ecx
中的值。而这个随机数,便是用于标识这个代码块的key。
接下来来到__afl_maybe_log
运行
开始fuzz,那么afl如何启动这个程序,可以看看afl-fuzz.c
,不过最开始后世设置参数,这里将重要点的部分。
调用的链条有点复杂首先在L8044
1 | skipped_fuzz = fuzz_one(use_argv); |
然后的fuzz_one
中,使用队列queue_cur
来管理
1 | res = calibrate_case(argv, queue_cur, in_buf, queue_cycle - 1, 0); |
在calibrate_case
1 | if (dumb_mode != 1 && !no_forkserver && !forksrv_pid) |
总算是找到了
为了更高效地进行上述过程,AFL实现了一套fork server机制。其基本思路是:启动target进程后,target会运行一个fork server;fuzzer并不负责fork子进程,而是与这个fork server通信,并由fork server来完成fork及继续执行目标的操作。这样设计的最大好处,就是不需要调用
execve()
,从而节省了载入目标文件和库、解析符号地址等重复性工作
L1987
1 | int st_pipe[2], ctl_pipe[2]; |
使用pipe和forkserver进行通讯
L2088的位置开始执行execv(target_path, argv);
。其实看到这里我也很疑惑上面的解释
在L2103中测试和forkserver的通讯
1 | fsrv_ctl_fd = ctl_pipe[1]; |
接下来的fork server是如何与fuzzer通信是面试考点(/doge)
forkserver
知道创宇不知道是讲的太碎了还是我理解有问题,在这里我就不怎么追的了代码了,不过想起开始说过的:fuzzer并不负责fork子进程,而是与这个fork server通信,并由fork server来完成fork及继续执行目标的操作,那么很有可能和插进去的__afl_maybe_log
一样是插入入进去了一个forkserver
在代码当中,那么回到afl-as
,L446
1 | if (ins_lines) |
依然以32位举例子main_payload_32
1 | "__afl_forkserver:\n" |
看注释也看的明白了,给fuzzer说已经准备完毕,其中$__afl_temp
就是四字节长度的验证信息,然后开始等待循环,读取命令管道,直到fuzzer通知其开始
1 | "__afl_fork_wait_loop:\n" |
有错误就寄__afl_die
,那么成功的话
1 | " /* Once woken up, create a clone of our process. This is an excellent use\n" |
得到父子进程的ID,对待子进程走到__afl_fork_resume
1 | "__afl_fork_resume:\n" |
对于大于0的返回值(即父进程)他继续运行
1 | " /* In parent process: write PID to pipe, then wait for child. */\n" |
继续进行forkserver的管道通讯,然后__afl_fork_wait_loop
,
fuzzer
回顾一下目前“栈”的情况:main
-> calibrate_case
-> init_forkserver
回退到calibrate_case
继续执行,write_to_testcase
就是设置测试样例,重点在于run_target
。AFL的文件编译策略不是胡乱变换的,主要是dumb_mode
有没有被开启,一般都是不会开启的,那么就来到了L2362
1 | s32 res; |
看注释和代码,知道通过管道向forkserver通知准备完毕,并得到返回的PID,然后就是L2438
1 | if (WIFSIGNALED(status) && !stop_soon) { |
fuzzer再次读取状态管道,获取子进程退出状态,并由此来判断子进程结束的原因,例如正常退出、超时、崩溃等,并进行相应的记录
共享内存机制
解决程序插桩和启动运行后,就来到了对示例的fuzz阶段。在程序待测试部分,程序先是初始化变异方式。AFL通过共享内存机制来方便高并发的样例读取,减小IO的损耗。
代码不长这里贴一下
1 | EXP_ST void setup_shm(void) { |
在afl-fuzz
的L7955来到setup_shm
,这里设置共享内存,然后从shemget()
(这是一个LinuxC的函数)获得一块内存。
shmget() returns the identifier of the System V shared memory segment associated with the value of the argu‐
ment key. A new shared memory segment, with size equal to the value of size rounded up to a multiple of
PAGE_SIZE, is created if key has the value IPC_PRIVATE or key isn’t IPC_PRIVATE, no shared memory segment cor‐
responding to key exists, and IPC_CREAT is specified in shmflg.
子进程通过环境变量SHM_ENV_VAR
得到内存,对于fuzzer本身则会回到trace_bits
保存内存地址
shmat() attaches the System V shared memory segment identified by shmid to the address space of the calling
process.
我们在且回到被测试的target中,看下他被插桩的代码(早于之前的forkserver)
1 | " /* Check if SHM region is already mapped. */\n" |
-
__afl_area_ptr
:共享内存映射到target的内存空间中的地址 -
__afl_setup
:获取环境变量AFL_SHM_ENV
的内容并将其转为整型,最后,通过调用shmat()
,target将这块共享内存也映射到了自己的内存空间中,并将其地址保存在__afl_area_ptr
及edx
中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"\n"
"__afl_setup:\n"
"\n"
" /* Do not retry setup if we had previous failures. */\n"
"\n"
" cmpb $0, __afl_setup_failure\n"
" jne __afl_return\n"
"\n"
" /* Map SHM, jumping to __afl_setup_abort if something goes wrong.\n"
" We do not save FPU/MMX/SSE registers here, but hopefully, nobody\n"
" will notice this early in the game. */\n"
"\n"
" pushl %eax\n"
" pushl %ecx\n"
"\n"
" pushl $.AFL_SHM_ENV\n"
" call getenv\n"
" addl $4, %esp\n"
"\n"
" testl %eax, %eax\n"
" je __afl_setup_abort\n"
"\n"
" pushl %eax\n"
" call atoi\n"
" addl $4, %esp\n"
"\n"
" pushl $0 /* shmat flags */\n"
" pushl $0 /* requested addr */\n"
" pushl %eax /* SHM ID */\n"
" call shmat\n"
" addl $12, %esp\n"
"\n"
" cmpl $-1, %eax\n"
" je __afl_setup_abort\n"
分支信息的记录
来到文章开始抛出的疑问__afl_maybe_log
干了什么
1 | "__afl_maybe_log:\n" |
读代码就知道了:
-
检查共享内存的映射,有错进入到
__afl_setup
-
开始
__afl_store
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21"__afl_store:\n"
"\n"
" /* Calculate and store hit for the code location specified in ecx. There\n"
" is a double-XOR way of doing this without tainting another register,\n"
" and we use it on 64-bit systems; but it's slower for 32-bit ones. */\n"
"\n"
" movl __afl_prev_loc, %edi\n"
" xorl %ecx, %edi\n"
" shrl $1, %ecx\n"
" movl %ecx, __afl_prev_loc\n"
" movl %ecx, %edi\n"
"\n"
" orb $1, (%edx, %edi, 1)\n"
" incb (%edx, %edi, 1)\n"
"\n"__afl_prev_loc
:前一次跳转的”位置”ecx
:最开始插桩的随机数位置,即代码块的标号
因此,AFL为每个代码块生成一个随机数,作为其“位置”的记录;随后,对分支处的”源位置“和”目标位置“进行异或,并将异或的结果作为该分支的key,保存每个分支的执行次数。用于保存执行次数的实际上是一个哈希表,大小为
MAP_SIZE=64K
,当然会存在碰撞的问题;但根据AFL文档中的介绍,对于不是很复杂的目标,碰撞概率还是可以接受的:在最后
map density
就是指的哈希表的密度,越小发生碰撞的概率越小
分支信息的分析
前文提到了
trace_bits
保存了共享内存- 共享内存保存了分析记录
那么,对于AFL从这块共享内存中获得信息就显得很合理了
在预处理共享内存时
1 | static inline void classify_counts(u32* mem) { |
1 | /* Destructively classify execution counts in a trace. This is used as a |
最重要的是count_class_lookup8
,target是将每个分支的执行次数用1个byte来储存,而fuzzer则进一步把这个执行次数归入buckets中,比如执行1次,mem16 = count_class_lookup8[1]; == 1
执行3次mem16 = count_class_lookup8[3]; == 4
。执行4次mem16 = count_class_lookup8[4]; == 8
好处就在于分支A执行了32次;对另外一个测试用例,分支A执行了33次,那么AFL就会认为这两次的代码覆盖是相同的。这样通过代码块的执行次数就可以统计相关路径,AFL根据相关路径的HASH判断路径是否发生改变。
文件变异规则
看到好多研究生论文都是从这里下手的。。。
-
bitflip,按位翻转,1变为0,0变为1
在对每个byte进行翻转时,如果其造成执行路径与原始路径不一致,就将该byte在effector map中标记为1,即“有效”的,否则标记为0,即“无效”的
-
arithmetic,整数加/减算术运算
对目标整数会进行+1, +2, …, +35, -1, -2, …, -35的变异。特别地,由于整数存在大端序和小端序两种表示方式,AFL会贴心地对这两种整数表示方式都进行变异。
-
interest,把一些特殊内容替换到原文件中
用于替换的"interesting values",是AFL预设的一些比较特殊的数。
config.h
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 -
dictionary,把自动生成或用户提供的token替换/插入到原文件中(从头开始)
-x
选项设置的token -
havoc,中文意思是“大破坏”,此阶段会对原文件进行大量变异,具体见下文
开始智力下降,我在fuzz中看到的大多数都是这个阶段(阿巴阿巴)
- 随机选取某个bit进行翻转
- 随机选取某个byte,将其设置为随机的interesting value
- 随机选取某个word,并随机选取大、小端序,将其设置为随机的interesting value
- 随机选取某个dword,并随机选取大、小端序,将其设置为随机的interesting value
- 随机选取某个byte,对其减去一个随机数
- 随机选取某个byte,对其加上一个随机数
- 随机选取某个word,并随机选取大、小端序,对其减去一个随机数
- 随机选取某个word,并随机选取大、小端序,对其加上一个随机数
- 随机选取某个dword,并随机选取大、小端序,对其减去一个随机数
- 随机选取某个dword,并随机选取大、小端序,对其加上一个随机数
- 随机选取某个byte,将其设置为随机数
- 随机删除一段bytes
- 随机选取一个位置,插入一段随机长度的内容,其中75%的概率是插入原文中随机位置的内容,25%的概率是插入一段随机选取的数
- 随机选取一个位置,替换为一段随机长度的内容,其中75%的概率是替换成原文中随机位置的内容,25%的概率是替换成一段随机选取的数
- 随机选取一个位置,用随机选取的token(用户提供的或自动生成的)替换
- 随机选取一个位置,用随机选取的token(用户提供的或自动生成的)插入
-
splice,中文意思是“绞接”,此阶段会将两个文件拼接起来得到一个新的文件
splice是将两个seed文件拼接得到新的文件,并对这个新文件继续执行havoc变异
-
cycle:对队列所有文件全部按照上面的步骤来一遍就算完成了一个cycle,整个队列又会从第一个文件开始,再次进行变异,不过与第一次变异不同的是,这一次就不需要再进行deterministic fuzzing了。