AFL_fuzz源码分析
AFL_fuzz源码分析
自己动手调试并在此记录了一下,在学习的过程中参考了sakura大佬的文章
afl-gcc
程序调试的参数是:/usr/local/bin/afl-gcc /path/test.c -o /path/test
,先从主函数开始看第一部分
1 | int main(int argc, char** argv) { |
如果没有设置AFL_QUIET
这个环境变量,在屏幕上显示信息,argc是4,所以并不会退出,继续
find_as
接下来是find_as函数,这个函数用来寻找
afl-as
的位置
首先会检查是否存在AFL_PATH
这个环境变量,如果存在就给afl_path
并且afl_path/as
可以访问的话,则将afl_path
给到as_path
如果没有afl_path
,检查argv0是否存在/,然后找到最后的/这个位置,并将最后的/+后面的东西赋值给slash,也就是上面的/usr/local/bin/afl-gcc
,最后slash = /afl-gcc
,然后会把前面的/usr/local/bin
赋值给dir,然后检查dir/afl-as
是否可以访问,如果可以访问,则as_path = dir
再下面会检查AFL_PATH/as
可不可以访问,如果可以访问则as_path = AFL_PATH
,这里应该是没有意义的,因为一开始就会检查是否存在AFL_PATH
这个环境变量,但是笔者在调试的时候发现第一步检查环境变量是NULL,重要的是最后一个检查AFL_PATH/as
可不可以访问竟然有值了,是/usr/local/lib/afl
,最终as_path = /usr/local/lib/afl
edit_params
处理afl-gcc后面的参数,处理后放入cc_params[]数组
首先会给cc_params开一个内存空间,空间大小是(argc + 128) * sizeof(u8*)
,在这个例子中argc是4
检查第一个参数也就是/usr/local/bin/afl-gcc
这里有没有/,如果有则name = afl-gcc
,如果没有那name就直接等于第一个参数
接着会判断是否是afl-clang
如果是则设置
clang_name=1
,环境变量CLANG_ENV_VAR
设置为1- 又继续和
afl-clang++
比较,如果相等并且AFL_CXX
环境变量存在,把cc_params[0]
设置成这个环境变量的值,如果不存在则设置成clang++ - 如果不是
afl-clang++
,并且AFL_CC
环境变量存在,把cc_params[0]
设置成这个环境变量的值,如果不存在则设置成clang
- 又继续和
如果不是
afl-clang
,则判断是否是afl-g++
- 是
afl-g++
的话,并且AFL_CXX
环境变量存在,把cc_params[0]
设置成这个环境变量的值,如果不存在则设置成g++ - 是
afl-gcj
的话,并且AFL_GCJ
环境变量存在,把cc_params[0]
设置成这个环境变量的值,如果不存在则设置成gcj - 是其他的话,并且
AFL_CC
环境变量存在,把cc_params[0]
设置成这个环境变量的值,如果不存在则设置成gcc
- 是
在这个例子里cc_params[0]
就等于gcc
,中间还有一个#ifdef __APPLE__
,这个意思是苹果平台需要进行的操作,这里是在linux下的,所以不需要太关注
接着会从argv[1]开始遍历,跳过-B/integrated-as/-pipe
,如果有-fsanitize=address
和-fsanitize=memory
这个参数,则设置asan_set = 1
,如果有FORTIFY_SOURCE
,则fortify_set = 1
,把参数都依次保存在ccparams这里cc_params[cc_par_cnt++] = cur
在后面会加上-B as_path
这两个参数,判断是否是clang_mode,如果是则再加-no-integrated-as
这个参数,如果存在AFL_HARDEN
环境变量,则参数加上-fstack-protector-all
,在环境变量存在的前提下如果fortify_set = 1
,则再加上-D_FORTIFY_SOURCE=2
如果asan_set
被设置了,则设置AFL_USE_ASAN
这个环境变量为1,在asan_set
没有被设置的前提下,如果设置了AFL_USE_ASAN
,则不能再同时指定AFL_USE_MASN, AFL_HARDEN
这两个,设置-U_FORTIFY_SOURCE,-fsanitize=memory
,如果前面都没设置,则看是否设置了AFL_USE_MASN
,和第二个差不多
如果不存在AFL_DONT_OPTIMIZE
这个环境变量则设置-g -O3 -funroll-loops -D__AFL_COMPILER=1 -DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION=1
,如果存在AFL_NO_BUILTIN
这个变量,则设置-fno-builtin-strcmp/-fno-builtin-strncmp
等
最终结尾给一个NULL结束
final
1 | find_as(argv[0]); |
最后会通过execvp来执行命令,最终的命令可以借助printf打印出来看一下
1 | id[0] = gcc |
会把/usr/local/lib/afl
加到编译器的搜索路径,在这个例子里实际会调用afl-as
来作为汇编器
afl-as
从上面得到最终会调用as这个东西来进行插桩,对其进行调试,在调试之前打印一下参数,然后调试的时候传入参数即可
1 | if (!(pid = fork())) { |
1 | as[0] = as |
主函数前面的逻辑和afl-gcc都差不多,会获取AFL_INST_RATIO
这个环境变量的值,设置为inst_ratio_str
,gettimeofday
这个是获取时间,然后设置随机数种子rand_seed = tv.tv_sec ^ tv.tv_usec ^ getpid();
,接着把参数传入edit_params
函数
edit_params
给
tmp_dir
赋值,获得AFL_AS
环境变量如果存在则设置afl_as
的值,还会对apple平台进行额外的设置
- 先判断
TMPDIR/TEMP/TMP
这三个环境变量是否有值,如果哪个有值则把那个值给到tmp_dir - 如果都没有则tmp_dir为/tmp
给as_params开一个空间,空间大小是(argc + 32) * sizeof(u8*)
如果afl_as
为空则as_params[0] = as
,否则as_params[0] = afl_as
,as_params[argc] = 0
遍历传入的参数,如果有–64则use_64bit = 1
,如果有--32则use_64bit = 0
,还会对apple平台进行判断,如果是-arch x86_64
,则use_64bit = 1
,并且会跳过-q和-Q,如果是32的话就不支持
as_params[as_par_cnt++] = argv[i]
把参数都一一放到as_params
这里,apple平台的话进行额外的设置
把最后一个参数传给input_file
如果
input_file
的首字符是-- 后面是-version的话,
just_version = 1
,modified_file = input_file
- 如果不是-version的话,则报错
- 如果后面没有的话则
input_file=NULL
- 后面是-version的话,
如果首字符不是-
- 比较
tmp_dir
、/var/tmp
、/tmp/
,的前strlen(tmp_dir)/9/5
个字节是否相等,不相等就设置pass_thru=1
- 比较
设置modified_file
为tmp_dir/.afl-getpid()-time(NULL).s
设置as_params[as_par_cnt++] = modified_file;
和as_params[as_par_cnt] = NULL;
add_instrumentation
插桩函数
如果input_file
不为空,利用fopen打开这个文件,如果为空则inf = stdin
,最终都会给到inf
这个FILE*指针
打开modified_file
获取outfd,再通过outfd利用fdopen得到outf这个FILE*指针
通过fgets把inf里的内容读取到line这个数组中,每行最多读取MAX_LINE
也就是8192
判断
instr_ok && instrument_next && line[0] == '\t' && isalpha(line[1])
,instr_ok和instrument_next
是否都为1,line
是否是\t
,line[1]
是否是字母- 如果都满足,向outf中写入对应的
trampoline_fmt
,设置instrument_next = 0
,插桩计数器ins_lines++
- 这些都是想要插入instrumentation trampoline到所有的标签,宏,注释之后
- 如果都满足,向outf中写入对应的
instr_ok为1的话就代表在.text部分,如果是0的话就代表不在,如果在的话就会执行插桩逻辑,不在就不执行插桩逻辑
- line为
\t.text\n, \t.section\t.text, \t.section\t__TEXT,__text, \t.section __TEXT, __text
之中的一个,设置instr_ok为1,然后continue,回到while继续从inf读数据到line这个数组中 - line为
\t.section\t, \t.section, \t.bss\n, \t.data\n
之中的一个,设置instr_ok = 0,然后continue,回到while继续从inf读数据到line这个数组中 - 如果
clang_mode = 0, instr_ok = 1
,并且line为\t.p2align
,则skip_next_label = 1
- line为
判断一些信息,是att汇编还是intel汇编,是32位还是64位,接着设置相应的flag
插桩
^\tjnz foo
条件跳转指令,插桩重点是^main ^.L0 ^.LBB0_0 ^\tjnz foo
- 如果line的值是
\tj[!m].
,并且R(100) < inst_ratio
,R(100)
是一个100以内的随机数,inst_ratio默认是100,则会进行插桩,如果inst_ratio
设置为0就代表不会对该分支进行插桩,R()
这个相当于是区分每个桩的,看成一个flag - 判断是否对应的位数然后向outf里写入
trampoline_fmt_64
或者trampoline_fmt_32
- 如果line的值是
ins_lines
插桩计数器+1检查line是否有
:
,然后检查第一个是不是.
如果第一个是
.
,则就是想插桩^.L0:
或者^.LBB0_0
这样的branch label,即style jump destination- 检查
line[2]
是否是数字,或者在clang_mode
下比较line[1]
开始的3个字节是否是LBB
- 上面的结果和
R(100) < inst_ratio
相与- 如果满足条件,并且
skip_next_label
不为0,则instrument_next
为1,反之为0
- 如果满足条件,并且
- 检查
否则是一个function,插桩
^func
,把instrument_next
设置成1
上面执行完之后满足第一个条件,那就真正的插桩了
如果插桩计数器不为0,那么就在上面操作之后把对应的main_payload
写入outf,然后关闭这两个文件
上面就是afl-as的主要逻辑,afl的插桩相当简单粗暴,就是通过汇编的前导命令来判断这是否是一个分支或者函数,然后插入instrumentation trampoline
最后就是afl-as的main结尾,fork出一个子进程,让子进程来执行execvp(as_params[0], (char **) as_params);
,如果没有设置环境变量AFL_KEEP_ASSEMBLY
的值,就unlink掉modified_file
afl-fuzz
初始配置
gettimeofday
获取时间,然后设置随机数种子rand_seed = tv.tv_sec ^ tv.tv_usec ^ getpid();
这个和afl-as前面一样
setup_signal_handlers
注册必要的信号处理函数
- SIGHUP,由一个处于非连接状态的终端发送给控制进程,或者由控制进程在自身结束时发送给每个前台进程
- SIGINT,一般由从终端敲入的Crl+C组合键或预先设置好的中断字符产生
- SIGTERM,作为一个请求被发送,要求进程结束运行。UNIX在关机时用这个信号要求系统服务停止运行。它是kill命今默认发送的信号
- SIGALRM,由alarm函数设置的定时器产生
- SIGWINCH,处理窗口大小的变化信号
- SIGPIPE,如果在向管道写数据时没有与之对应的读进程,就会产生这个信号
- SIGUSR1,进程之问可以用这个信号进行通信,例如让进程报告状态信息等
check_asan_opts
读取ASAN_OPTIONS
和MSAN_OPTIONS
,进行一些检查
fix_up_sync
如果通过-M或者-S指定了sync_id,设置sync_dir值为out_dir
,设置out_dir的值为out_dir/sync_id
save_cmdline
将当前输入参数拷贝进buf空间中
fix_up_banner
如果没有设置
use_banner
- 如果设置了sync_id,则
use_banner = sync_id
- 如果没有设置sync_id,trim为最后一个包含/的参数的后面部分(/目标测试文件)
- 如果trim存在,则
use_banner = trime + 1
就是目标测试文件,否则use_banner = name
- 如果trim存在,则
如果use_banner长度大于40则只保留40个长度
check_if_tty
检查是否在tty终端上运行,如果不是tty的话not_on_tty=1
get_core_count
这个其实就是获取cpu的核心数量
bind_to_free_cpu
检查是否有空闲的核心了,如果有的话就绑定到空闲cpu
check_crash_handling
确保核心转储不会进入程序
check_cpu_governor
检查CPU调节器
setup_post
如果没有设置AFL_POST_LIBRARY
这个环境变量则直接返回,设置了这个变量会加载afl_postprocess
函数
setup_shm
如果in_bitmap
为空,则通过memset初始化virgin_bits数组里的每个元素为255(\xff)
通过memset初始化virgin_tmout和virgin_crash
数组里的每个元素为255(\xff)
shmget分配了一个共享内存,将返回的共享内存标识符保存到shm_id里
shmget(IPC_PRIVATE, MAP_SIZE, IPC_CREAT | IPC_EXCL | 0600);
- 一参与信号量的semget函数一样,程序需要提供一个参数key(非0整数),它有效地为共享内存段命名,shmget()函数成功时返回一个与key相关的共享内存标识符(非负整数),用于后续的共享内存函数。调用失败返回-1
IPC_PRIVATE
为0所以创建一块新的共享内存,大小是MAP_SIZE- 第三个参数它的作用与open函数的mode参数一样
IPC_CREAT
如果共享内存不存在,则创建一个共享内存,否则打开操作IPC_EXC
L只有在共享内存不存在的时候,新的共享内存才创建,否则产生错误0600
代表拥有者具有读写权限
注册程序正常终止时调用remove_shm
函数
shmctl(shm_id, IPC_RMID, NULL);
用来控制共享内存- 第一个参数,shm_id是shmget()函数返回的共享内存标识符
- 第二个参数,command是要采取的操作,它可以取下面的三个值 :
IPC_STAT
:把shmid_ds结构中的数据设置为共享内存的当前关联值,即用共享内存的当前关联值覆盖shmid_ds的值IPC_SET
:如果进程有足够的权限,就把共享内存的当前关联值设置为shmid_ds结构中给出的值IPC_RMID
:删除共享内存段
- 所以第二个参数意思就是删除共享内存段
- 第三个参数,buf是一个结构指针,它指向共享内存模式和访问权限的结构
创建shm_str,这个里面存放的还是shm_id
如果不是dumb_mode,则设置环境变量SHM_ENV_VAR
的值为shm_str
释放shm_str指针,并trace_bits = shmat(shm_id, NULL, 0);
shmat(shm_id, NULL, 0);
- 第一次创建完共享内存时,它还不能被任何进程访问,shmat()函数的作用就是用来启动对该共享内存的访问,并把共享内存连接到当前进程的地址空间
- 第一个参数,shm_id是由shmget()函数返回的共享内存标识
- 第二个参数,指定共享内存连接到当前进程中的地址位置,通常为空,表示让系统来选择共享内存的地址
- 第三个参数,shm_flg是一组标志位,通常为0
- 调用成功时返回一个指向共享内存第一个字节的指针,失败返回-1
trace_bits指向共享内存第一个字节的指针,如果失败则报错
init_count_class16
这个用来初始化
count_class_lookup16
数组,将count_class_lookup16
数组分成256段,每段256份
count_class_lookup8
,把这个路径命中数进行规整- 执行了4-7次的其计数为8,比如32次到127次都会认为是64
- 变量
trace_bits
来记录分支执行次数,而count_class_lookup8实际就是对于trace_bits
的规整。
而初始化
count_class_lookup16
实际是因为 AFL 中对于一条分支径的表示是由一个二元组来表示的- 比如A->B->C->D,可用
[A, B], [B, C], [C, D]
来表示,基于效率考虑,使用了count_class_lookup16
这个数组
- 比如A->B->C->D,可用
setup_dirs_fds
准备输出文件夹和fd
如果sync_id存在,并且创建sync_dir文件夹,权限是0700,并且errno != EEXIST
则异常
创建out_dir文件夹,权限是0700,创建成功返回0,创建错误返回-1,并且错误保存在errno中
- 创建失败
- 如果errno不是EEXIST则异常
- maybe_delete_out_dir,这个函数会删除out_dir里面的旧东西
- 如果errno不是EEXIST则异常
- 创建成功
- 如果设置了
in_place_resume
则异常 - 以只读打开
out_dir
,返回文件句柄到out_dir_fd
- 如果没有定义宏
__sun
- 打开失败或者通过flock建立互斥锁定失败,异常
- 如果设置了
创建out_dir/queue
文件夹,权限为0700
- 创建
out_dir/queue/.state/
,设置权限为0700,该文件夹主要保存用于session resume和related tasks的queue metadata- 创建
out_dir/queue/.state/deterministic_done/
,设置权限为0700,该文件夹标记过去经历过deterministic fuzzing的queue entries - 创建
out_dir/queue/.state/auto_extras/
,设置权限为0700,Directory with the auto-selected dictionary entries - 创建
out_dir/queue/.state/redundant_edges/
,设置权限为0700,保存当前被认为是多余的路径集合 - 创建
out_dir/queue/.state/variable_behavior/
,设置权限为0700,The set of paths showing variable behavior
- 创建
如果sync_id存在
- 创建
out_dir/.synced
文件夹
创建out_dir/crashes
文件夹,用于记录crashes
创建out_dir/hangs
文件夹,用于记录hangs
以读写模式打开/dev/null
,以只读的模式打开/dev/urandom
以只写方式打开out_dir/plot_data
文件,如果没有则创建一个,获取句柄,根据句柄得到FILE* plot_file
,并向里写入# unix_time, cycles_done, cur_path, paths_total, pending_total, pending_favs, map_size, unique_crashes, unique_hangs, max_depth, execs_per_sec
read_testcases
从输入文件夹中读取所有文件,然后将它们排队进行测试
判断in_dir/queue
文件是否存在,成功0,失败-1
- 如果存在这个文件,
in_dir = fn = in_dir/queue
- 不存在则释放fn
扫描in_dir,并将结果保存在struct dirent **nl
里(. ..也会被算进去),不使用readdir,因为测试用例的顺序将随机地有些变化,并且将难以控制
如果shuffle_queue
的值为真,且nl_cnt大于1,则shuffle_ptrs((void **) nl, nl_cnt)
,重排nl里的指针的位置
遍历nl
u8* fn = alloc_printf("%s/%s", in_dir, nl[i]->d_name);
,fn = in_dir/nl[i]->d_name
,nl[i]->d_name
的值为input文件夹下的文件名字符串u8 *dfn = alloc_printf("%s/.state/deterministic_done/%s", in_dir, nl[i]->d_name);
通过文件属性过滤掉
.
和..
这样的regular文件,并检查文件大小,如果文件大小大于MAX_FILE则异常,默认是1024*1024字节,即1M通过access检查
in_dir/.state/deterministic_done/nl[i]->d_name
是否存在,这应该是为了用在resume恢复扫描使用- 如果存在则设置
passwd_det = 1
- 此检查用来判断是否这个入口已经完成deterministic fuzzing。在恢复异常终止的扫描时不想重复进行deterministic fuzzing
- 如果存在则设置
add_to_queue(fn, st.st_size, passed_det);
如果queued_paths为0,则代表输入文件夹为0,抛出异常
设置last_path_time为0
queued_at_start的值设置为queued_paths
add_to_queue
queue_entry是一个链表数据结构,先通过calloc动态分配一个queue_entry结构体,并初始化其fname为文件名fn,len为文件大小,depth为cur_depth + 1,passed_det为传递进来的passed_det,具体代码如下
1 | q->fname = fname; |
如果q->depth > max_depth
,则设置max_depth为q->depth
如果queue_top不为空,则设置queue_top->next为q,queue_top = q;
,否则q_prev100 = queue = queue_top = q;
queue计数器queued_paths和待fuzz的样例计数器pending_not_fuzzed加一
cycles_wo_finds设置为0
如果queued_paths % 100 = 0
,则q_prev100->next_100 = q; q_prev100 = q;
设置last_path_time = get_cur_time()
load_auto
load自动生成的提取出来的词典token
遍历50次
- 以只读的方式打开
in_dir/.state/auto_extras/auto_%6u
- 如果打开失败,结束
- 如果打开成功,读取
MAX_AUTO_EXTRA+1
个字节到tmp里,长度保存到len中 - 调用
maybe_add_auto
按规则加入字典
pivot_inputs
在
out_put
目录中为input
测试用例创建硬链接
遍历queue里的queue_entry
- 找到q->fname最后一个/的位置,然后连带测试用例文件名称赋值给rsl
- 如果没有获取到上面的东西那么
rsl = q->fname
,如果获取到那么rsl就等于testcase的文件名(把前面的/给去了) - rsl前三个字节和
id:
比较,如果相等则把后面的部分化成%06u这种格式放入orig_id中,并且判断orig_id是否与id相等- 如果相等则设置
resuming_fuzz = 1
- 如果不相等,在rsl里寻找
,orig:
- 如果找到了use_name指向后面
- 如果没有找到则use_name = rsl
nfn = out_dir/queue/id:%06u,orig:use_name
- 创建硬链接,
q->fname
到nfn,q->fname
指向这个nfn - 如果
q->passed_detf
为1,则调用mark_as_det_done(q)
,代表queue这一项已经fuzz过了 - 如果设置了
in_place_resume
,则调用nuke_resume_dir
函数- nuke_resume_dir这个函数删除了很多
out_dir/_resume/.stat/
目录下的东西 - 如果删除成功则正常返回,如果删除失败则异常
- nuke_resume_dir这个函数删除了很多
- 如果相等则设置
load_extras
如果定义了extras_dir,那到加载extras_dir下的文件放入extra数组并排序
find_timeout
如果timeout_given
没有被设置则进入find_timeout
函数,在不指定-t的情况下,防止不停的调整超时时间
如果resuming_fuzz为0,则直接return
如果in_place_resume
为1,则fn = alloc_printf("%s/fuzzer_stats", out_dir);
,否则fn = alloc_printf("%s/../fuzzer_stats", in_dir);
只读方式打开fn,读取sizeof(tmp) - 1)
大小的数据到tmp中,如果里面有exec_timeout :
就读取这个值并且大于四exec_tmout
就设置这个值,如果没有则直接返回,timeout_given = 3
detect_file_args
检查输入argv中是否存在@@,有的话替换成out_dir/.cur_input
setup_stdio_file
如果out_file没有值(没有使用-f),则调用此函数,会删除原本的out_dir/.cur_input
,创建一个新的out_dir/.cur_input
,保存其文件描述符在out_fd中
check_binary
指定路径处要执行的程序是否存在,且它不能是一个shell script,同时检查elf文件头是否合法及程序是否被插桩
get_cur_time
获取时间
perform_dry_run
执行所有的测试用例,以检查是否按预期工作
设置cal_failures = 0
,读取AFL_SKIP_CRASHES
环境变量到skip_crashes
变量里
遍历queue
打开q->fname,这里的q->fname就是在queue里面的id:000xxx这些文件,然后读取到use_mem中
res = calibrate_case(argv, q, use_mem, 0, 1);
,校准该测试用例,评估input文件夹下的case如果stop_soon为1,则直接return
如果res的结果为crash_mode或者为
FAULT_NOBITS
,打印相关的信息依据res的结果查看是哪种错误并进行判断。一共有以下几种错误类型
- FAULT_NONE
- 如果q是第一个测试用例,调用
check_map_coverage()
函数,评估覆盖率 - 如果是crash_mode则,则异常,代表该测试用例不产生crash
- 如果q是第一个测试用例,调用
- FAULT_TMOUT
- 如果指定了-t参数,该测试用例产生超时错误。当
timeout_given
为2时跳过该文件
- 如果指定了-t参数,该测试用例产生超时错误。当
- FAULT_CRASH
- 如果设置了crash_mod,直接break
- 如果设置了skip_crashes
- 设置q->cal_failed为CAL_CHANCES
- cal_fialures计数器+1
- 如果设置了
mem_limit
,提示内存不够,抛出异常
- FAULT_ERROR
- 目标程序无法执行,抛出异常
- FAULT_NOINST
- 样例没有出现任何路径信息,抛出异常
- FAULT_NOBITS
- 有路径信息但没新路径,认为这是无用路径,useless_at_start计数器加一
- FAULT_NONE
如果样例的var_behavior为真,则代表它多次运行,同样的输入条件下,却出现不同的覆盖信息,抛出警告信息
继续读取下面一个样例,直到所以样例读完为止
如果设置了
cal_failures
cal_failures = queued_paths
代表所有用例均超时- 计算
cal_failures \* 5
是否大于queued_paths
,如果大于说明测试用例的问题比例很高,可能需要重新检查设置
输出所有testcase运行完成
calibrate_case
校准input文件夹下的测试用例,判断该用例是否异常,以及发现新路径时,评估新发现的testcase行为是否可变(这里的可变是指多次执行这个case,发现的路径不同)
如果q->exec_cksum
为0,代表这是这个case第一次运行,即来自input文件夹下,所以将first_run置为1
保存原有的stage_cur、stage_max、stage_name
设置use_tmout
为exec_tmout
,如果from_queue
是0或者resuming_fuzz
被置为1,即代表不来自于queue中或者在resuming sessions
的时候,则use_tmout的值被设置的更大,q->cal_failed++
设置stage_name
为calibration
,根据fast_cal是否为1来设置stage_max
为3还是CAL_CYCLES
(默认为8),含义是每个新测试用例(以及显示出可变行为的测试用例)的校准周期数,也就是说这个stage要执行几次的意思
如果不是dumb_mode模式,并且no_forkserver为0,forksrv_pid为0,则init_forkserver(argv)
启动fork server
(后面说)
如果这个queue不是来自input文件夹,而是评估新case,则此时q->exec_cksum
不为空,拷贝trace_bits到first_trace里,然后计算has_new_bits
的值,赋值给new_bits
获取时间
循环stage_max次
- 如果这个queue不是来自input文件夹下,并且第一轮
calibration stage
执行结束时,刷新一次展示界面show_stats
,用来展示这次执行的结果,此后不再展示 write_to_testcase
将从q->fname
中读取的内容写入到.cur_input
中u8 run_target(argv, use_tmout)
,结果保存在fault中- 如果出现终止或者fault结果不为crash_mode,跳转到abort_calibration
- 如果不是dumb_mode,并且是
calibration stage
为第一次运行,且共享内存里没有任何路径(即没有任何byte被置位)设置fault为FAULT_NOINST
,然后goto abort_calibration
- 计算
hash32(trace_bits, MAP_SIZE, HASH_CONST)
的结果,结果保存到cksum中 - 如果
q->exec_cksum
不等于cksum,即代表这是第一次运行,或者在相同的参数下,每次执行,cksum却不同,是一个路径可变的queue- 如果hnb大于new_bits,设置new_bits的值为hnb
- 判断
q->exec_cksum
是否是可变queue,如果为1,则说明不是第一次执行queue- 遍历
MAP_SIZE
次,如果first_trace[i]不等于trace_bits[i],代表发现了可变queue,且var_bytes为空,则将该字节设置为1,并将stage_max设置为CAL_CYCLES_LONG
,即需要执行40次 var_detected=1
- 遍历
q->exec_cksum
为0的话就代表是第一次执行这个queue- 设置
q->exec_cksum
的值为之前计算出来的本次执行的cksum - 把
trace_bits
拷贝到first_trace
中
- 设置
保存所有轮次总的执行时间,加到total_cal_us里,总的执行轮次,加到total_cal_cycles里
计算一些信息
- 计算出单次执行时间的平均值保存到q->exec_us里
- 将最后一次执行所覆盖到的路径数保存到q->bitmap_size里
q->handicap = handicap;
q->cal_failed = 0;
- total_bitmap_size里加上这个queue所覆盖到的路径数
- total_bitmap_entries++
update_bitmap_score(struct queue_entry *q)
如果不是dumb_mode,并且是第一次运行,并且fault里有值,并且new_bits为0,代表在这个样例所有轮次的执行里,都没有发现任何新路径和出现异常,设置fault为FAULT_NOBITS
如果new_bits为2并且q->has_new_cov
为0,则设置q->has_new_cov
为1,并将queued_with_cov
加一,代表有一个queue发现了新路径
如果这个queue是可变路径,即var_detected
为1,则计算var_bytes里被置位的tuple个数,保存到var_byte_count
里,代表这些tuple具有可变的行为
queue标记为一个variable
创建符号连接
out_dir/queue/.state/variable_behavior/fname
,设置queue的var_behavior为1设置queue的var_behavior为1
计数variable behavior的计数器
queued_variable
的值加一
恢复之前的stage值
如果不是第一次运行这个queue,则show_stats
返回fault值
init_forkserver
建立管道st_pipe和ctl_pipe
fork一个子进程,成功之后会出现两个进程,一个是父进程,一个是子进程,在子进程中,fork函数返回0,在父进程中,fork返回新创建子进程的进程ID
子进程和父进程都会向下执行,此时可以通过pid来使它们执行不同的代码if(!forksrv_pid)
首先是子进程
- 重定向文件描述符1和2到
dev_null_fd
,相当于关闭了子进程的全部输出 - 如果指定了out_file则把文件描述符0重定向到
dev_null_fd
,否则重定向到out_fd - 重定向
FORKSRV_FD
到ctl_pipe[0],重定向FORKSRV_FD + 1
到st_pipe[1]- 子进程只能读取命令
- 子进程只能发送写出状态
- 关闭子进程里的一些文件描述符
- 读取环境变量
LD_BIND_LAZY
,如果没有设置,则设置环境变量LD_BIND_NOW
为1 - 设置环境变量
ASAN_OPTIONS
为"abort_on_error=1:" "detect_leaks=0:" "symbolize=0:", "allocator_may_return_null=1"
MSAN_OPTIONS
也做相应的设置execv(target_path, argv)
- execv会替换掉原有的进程空间为target_path代表的程序,所以相当于后续就是去执行target_path,这个程序结束的话,子进程就结束
- 而在这里非常特殊,第一个target会进入
__afl_maybe_log
里的__afl_fork_wait_loop
,并充当fork server,在整个Fuzz的过程中,它都不会结束,每次要Fuzz一次target,都会从这个fork server fork出来一个子进程去fuzz
- 使用一个独特的
bitmaps EXEC_FAIL_SIG(0xfee1dead)
写入trace_bits,来告诉父进程执行失败,并结束子进程。
- 重定向文件描述符1和2到
然后是父进程
- 关闭ctl_pipe[0]和st_pipe[1]
fsrv_ctl_fd = ctl_pipe[1]
父进程只能发送(“写出”)命令fsrv_st_fd = st_pipe[0]
父进程只能读取状态- 等待fork server启动,但是不能等太久
- 从管道中读取4个字节放在status中,如果成功则代表fork server成功启动
- 如果超时则抛出异常
has_new_bits
检查有没有新路径或者某个路径的执行次数有所不同
初始化current和virgin为trace_bits和virgin_map的u64首元素地址,设置ret的值为0
8字节一组,每次从trace_bits,也就是共享内存里取出8个字节
如果current不为0,并且
current & virgin
不为0,代表current发现了新路径或者某条路径的执行次数和之前有所不同如果ret小于2
cur指向current第一个字节,vir指向virgin的第一个字节
判断cur[i] && vir[i]==0xff
如果有一个为真则设置ret为2,代表发现了之前没有出现过的tuple(产生了新的路径覆盖)
如果没有则设置ret为1,代表只是命中次数更新
*virgin &= ~*current
current和virgin移动到下一组8个字节,直到MAPSIZE全被遍历完
如果传入参数virgin_map的值为virgin_bits,且ret不为0,则设置bitmap_changed为1
run_target
通过子进程的方式执行目标应用,和前面的init_forkserver很像,但是这个函数处理了在设置了no_forkserver的情况的下的处理过程
清空trace_bits
如果设置了dumb_mode
并且no_forkserver
,则直接fork一个子进程,然后让子进程execv去执行target,如果execv执行失败,则向trace_bits写入EXEC_FAIL_SIG
否则向控制管道中写入4个字节的prev_timed_out,命令fork server开始fork出一个子进程进行fuzz,然后从状态管道读取fork server返回的fork出的子进程的ID到child_pid
上面两个都会设置计数器为timeout,如果超时,就杀死正在执行的子进程,并设置child_timed_out为1
计算执行时间,然后total_execs++
(执行次数计数器加一)
如果是dumb_mode,执行结束的状态码直接保存到status中,如果不是dumb_mode,则直接从状态管道中读取结束状态码
设置prev_timed_out的值为child_timed_out
接着依据status的值,向调用者返回结果
WIFSIGNALED(status)
若为异常结束子进程返回的状态,则为真- 如果是设置了uses_asan,必须使用特殊的退出代码
- 如果是dumb_mode,并且是no_forkserver,且trace_bits为
EXEC_FAIL_SIG
,就返回FAULT_ERROR
- 如果最慢执行时间小于当前执行时间,并且
time_out
小于等于exec_tmout
则更新slowest_exec_ms
为exec_ms
返回FAULT_NONE
classify_counts
八字节一组循环去读,直到遍历完mem
- 每次取两个字节
- i从0到3,计算mem[i]的值,在
count_class_lookup16[mem16[i]]
中找到对应的取值付给mem16[i]
这样处理之后,对分支执行次数就会有一个简单的归类。例如,如果对某个测试用例处理时,分支A执行了32次;对另外一个测试用例,分支A执行了33次,那么AFL就会认为这两次的代码覆盖是相同的。当然,这样的简单分类肯定不能区分所有的情况,不过在某种程度上,处理了一些因为循环次数的微小区别,而误判为不同执行结果的情况
count_bytes
初始化ret为0,循环读取mem里的值,每次读取4个字节到u32变量v中
- 如果v是0,则代表这四个字节都是0,则继续
- 如果v不是0,则依次计算
v & FF(0),v & FF(1),v & FF(2),v&FF(3)
的结果,如果不为0,则计数器ret加一#define FF(_b) (0xff << ((_b) << 3))
- 也就是
0x000000ff
左移(_b * 8)
位 - 最终结果可以是
0x000000ff
,0x0000ff00
,0x00ff0000
,0xff000000
其中之一
update_bitmap_score
每当发现新的路径,就调用这个函数来判断其是不是更加地favorable,这个favorable的意思是说是否包含最小的路径集合来遍历到所有bitmap中的位
首先计算出这个case的fav_factor,计算方法是q->exec_us * q->len
即执行时间和样例大小的乘积,以这两个指标来衡量权重
遍历trace_bits数组,如果该字节的值不为0,则代表这是已经被覆盖到的path
- 然后检查对应于这个path的top_rated是否存在
- 如果存在,则比较
fav_factor > top_rated[i]->exec_us * top_rated[i]->len
,即比较执行时间和样例大小的乘积,哪个更小- 如果
top_rated[i]
的更小,则代表top_rated[i]
的更优,不做任何处理,继续遍历下一个path - 如果该path长度top_rated乘积更大,则将top_rated对应的tc_ref字段-1,并将其trace_mini字段置为空
- 如果
- 设置
top_rated[i]
为q,即当前case,也就是替换成最优,然后将其tc_ref的值加一 - 如果
q->trace_mini
为空,则将trace_bits经过minimize_bits
压缩,然后存到trace_mini字段里 - 设置score_changed为1
- 如果存在,则比较
minimize_bits
压缩数据大小,将数据本身转换成位置记录下来(是否覆盖到和覆盖了多少次的byte,压缩成是否覆盖到的bit)
这个就是个算法,算法意思就是如果15这里有值,正常存放到数组中,这样子就需要[14],如果上亿个数的话,内存吃不消
所以就有了bitmap这个东西,运用bitmap的话,一个二进制数就代表对应的num是否有值,也就是上面的15,0000000000000001
这样子就代表15有值
分8字节一组,byte[0]和byte[1],15在byte[1]里,相对byte[1]里offset为7,设index为字节数组的下标,position为字节内部偏移
1 | { |
这样子算下来就能精确的把二进制数据的15那个位置从0变成1
cull_queue
精简队列
如果设置了dumb_mode或没有设置scor_changed,直接返回
设置score_changed的值为0
将temp_v的值初始化成0xff,每位如果为1代表还没有被覆盖到,如果为0就代表已经被覆盖到了
设置queued_favored为0,pending_favored为0
遍历queue队列,设置其favored的值都为0
循环MAP_SIZE
次
判断该path对应的bit有没有被置位
如果
top_rated[i]
有值,且该path在temp_v里被置位- 从temp_v中清除掉所有top_rated[i]覆盖到的path,将对应的bit设置为0
设置
top_rated[i]->favored
为1,queued_favored计数器加一如果
top_rated[i]
的was_fuzzed字段是0,代表其还没有fuzz过,则将pending_favored计数器加一
遍历queue
mark_as_redundant
如果不是favored的case,就被标记成redundant_edges
mark_as_redundant
如果state和q->fs_redundant
相等,就直接返回
设置q->fs_redundant
的值为state
如果state的值为1
- 创建
out_dir/queue/.state/redundant_edges/fname
如果state的值不为1
- 尝试删除路径
out_dir/queue/.state/redundant_edges/fname
show_init_stats
在处理输入目录的末尾显示统计信息,以及一堆警告,以及几个硬编码的常量
total_cal_us总轮次执行时间、total_cal_cycles总执行轮次,avg_us = total_cal_us / total_cal_cycles
,计算出平均单轮执行时间avg_us
遍历queue
- 更新
min_us、max_us、min_bits、max_bits、max_len
如果avg_us大于10000就警告
如果avg_us大于50000,设置havoc_div为10
大于20000,设置havoc_div为5
如果大于10000,设置havoc_div为2
如果不是resuming session
,则对queue的大小和个数超限提出警告,且如果useless_at_start
不为0,就警告有可以精简的样本。
如果timeout_given
为0
- 根据
avg_us
来计算出exec_tmout
- avg_us的单位是微秒,而exec_tmout单位是毫秒,所以需要除以1000
- 在上面计算出来的
exec_tmout
和所有样例中执行时间最长的样例进行比较,取最大值赋给exec_tmout
- 如果
exec_tmout
大于EXEC_TIMEOUT
,就设置exec_tmout = EXEC_TIMEOUT
- 设置
timeout_given
为1
如果timeout_give
不为0,且为3,代表这是resuming session
如果是dumb_mode
且没有设置环境变量AFL_HANG_TMOUT
- 设置
hang_tmout
为EXEC_TIMEOUT
和exec_tmout * 2 + 100
中的最小值
find_start_position
主要作用为在resume时,尝试查找要开始的队列的位置
如果不是resuming_fuzz
,则直接返回
如果是in_place_resume,就打开out_dir/fuzzer_stats
文件,否则打开in_dir/../fuzzer_stats
文件
读文件内容到tmp中,找到cur_path
,并设置为ret的值,如果大于queued_paths
就设置ret为0,返回ret
write_stats_file
更新统计信息文件以进行无人值守的监视
创建out_dir/fuzzer_stats
文件
打开并写入统计信息
- start_time
- fuzz运行的开始时间,start_time / 1000
- last_update
- 当前时间
- fuzzer_pid
- 获取当前pid
- cycles_done
queue_cycle
在queue_cur
为空,即执行到当前队列尾的时候才增加1,所以这代表queue队列被完全变异一次的次数。
- execs_done
- total_execs,target的总的执行次数,每次
run_target
的时候会增加1
- total_execs,target的总的执行次数,每次
- execs_per_sec
- 每秒执行的次数
- paths_total
- queued_paths在每次
add_to_queue
的时候会增加1,代表queue里的样例总数
- queued_paths在每次
- paths_favored
- queued_favored,有价值的路径总数
- paths_found
- queued_discovered在每次
common_fuzz_stuff
去执行一次fuzz时,发现新的interesting case的时候会增加1,代表在fuzz运行期间发现的新queue entry。
- queued_discovered在每次
- paths_imported
- queued_imported是master-slave模式下,如果sync过来的case是interesting的,就增加1
- max_depth
- 最大路径深度
- cur_path
- current_entry一般情况下代表的是正在执行的queue entry的整数ID,queue首节点的ID是0
- pending_favs
- pending_favored 等待fuzz的favored paths数
- pending_total
- pending_not_fuzzed 在queue中等待fuzz的case数
- variable_paths
- queued_variable在
calibrate_case
去评估一个新的test case的时候,如果发现这个case的路径是可变的,则将这个计数器加一,代表发现了一个可变case
- queued_variable在
- stability
- bitmap_cvg
- unique_crashes
- unique_crashes这是在
save_if_interesting
时,如果fault是FAULT_CRASH,就将unique_crashes计数器加一
- unique_crashes这是在
- unique_hangs
- unique_hangs这是在
save_if_interesting
时,如果fault是FAULT_TMOUT,且exec_tmout小于hang_tmout,就以hang_tmout为超时时间再执行一次,如果还超时,就让hang计数器加一。
- unique_hangs这是在
- last_path
- 在
add_to_queue
里将一个新case加入queue时,就设置一次last_path_time为当前时间,last_path_time / 1000
- 在
- last_crash
- 同上,在unique_crashes加一的时候,last_crash也更新时间,
last_crash_time / 1000
- 同上,在unique_crashes加一的时候,last_crash也更新时间,
- last_hang
- 同上,在unique_hangs加一的时候,last_hang也更新时间,
last_hang_time / 1000
- 同上,在unique_hangs加一的时候,last_hang也更新时间,
- execs_since_crash
- total_execs - last_crash_execs,这里last_crash_execs是在上一次crash的时候的总计执行了多少次
- exec_tmout
- 配置好的超时时间,有三种可能的配置方式,见上文
写入文件之后关闭文件
save_auto
保存自动生成的extras
如果auto_changed
为0,则直接返回
如果不为0,就设置为0,然后创建名为alloc_printf("%s/queue/.state/auto_extras/auto_%06u", out_dir, i);
的文件,并写入a_extras的内容
上面的前置结束之后,如果是终端环境,会睡4秒,开始时间会加上4秒
Fuzz主循环
在进行第一轮fuzz之后进入fuzz主循环
首先精简队列
如果queue_cur
为空,代表所有queue都被执行完一轮
设置
queue_cycle
计数器加一,即代表所有queue被完整执行了多少轮设置
current_entry
为0,和queue_cur
为queue首元素,开始新一轮fuzz如果是
resume fuzz
情况,则先检查seek_to是否为空,如果不为空,就从seek_to指定的queue项开始执行刷新展示界面
show_stats
如果执行一次完整的扫描之后,新发现的路径数与执行之前的一样,这代表没有发现任何新的路径
- 如果
use_splicing
为1,就设置cycles_wo_finds
计数器加1,本次扫描无新路径 - 否则
use_splicing
为1,代表我们接下来要通过splice重组queue里的case
- 如果
如果执行后和执行前路径数不一样,那么设置
cycles_wo_finds
为0更新
prev_queued
如果设置了
sync_id
并且queue_cycle==1
并且能过获取AFL_IMPORT_FIRS
T环境变量- 利用
sync_fuzzers
读取其他sync文件夹下的queue文件,然后保存到自己的queue里
- 利用
执行
skipped_fuzz = fuzz_one(use_argv)
来对queue_cur进行一次测试,如果不执行返回1,否则返回0如果skipped_fuzz为0,且存在sync_id
sync_interval_cnt
计数器+1,对SYNC_INTERVAL
(默认为5)求余,即如果是5的倍数- 调用sync_fuzzers,同步其他fuzzers
queue_cur = queue_cur->next;current_entry++;
,开始测试下一个queue
sync_fuzzers
读取其他sync文件夹下的queue文件,然后保存到自己的queue里
打开sync_dir
文件夹
循环读取文件夹下的目录和文件
- 跳过
.
开头的文件和sync_id
即我们自己的输出文件夹 - 尝试打开
sync_dir/sd_ent->d_name/queue
- 尝试打开
out_dir/.synced/sd_ent->d_name
,读取4个字节到min_accept中,设置next_min_accept
为min_accept
,这个值代表之前从这个文件夹里读取到的最后一个queue的id - 设置stage_name为
sprintf(stage_tmp, "sync %u", ++sync_cnt);
,设置stage_cur为0,stage_max为0 - 循环读取
sync_dir/sd_ent->d_name/queue
文件夹里的目录和文件- 跳过
.
开头的文件和标识小于min_accept
的文件,因为这些文件应该已经被sync过了 - 如果
syncing_case
标识大于等于next_min_accept
next_min_accept = syncing_case + 1
- 打开
qd_path/qd_ent->d_name
这个路径- 如果文件大小不为0,且小于
MAX_FILE
,就不进行sync - 否则mmap这个文件到内存mem里,通过
write_to_testcase
将mem写到.cur_input
中 run_target
,运行对应文件,返回值赋值给fault- 然后通过
save_if_interesting
来决定是否要导入这个文件到自己的queue里,如果发现了新的path,就导入- syncing_party的值为
sd_ent->d_name
- 如果返回值为1,则
queued_imported
计数器+1
- syncing_party的值为
- stage_cur计数器加一,如果stage_cur是
stats_update_freq
的倍数,就刷新一次展示界面
- 如果文件大小不为0,且小于
- 向当前id_fd中写入当前的
next_min_accept
值
- 跳过
- 先读取有哪些fuzzer文件夹,然后读取其他fuzzer文件夹下的queue文件夹里的case,并依次执行,如果发现了新path,就保存到自己的queue文件夹里,而且将最后一个sync的case id写入到
.synced/其他fuzzer文件夹名
文件里,以避免重复运行
save_if_interesting
这个case的执行结果是否是interesting的,决定是否保存或跳过
设置keeping为0
如果fault为crash_mode
hnb = has_new_bits(virgin_bits)
,如果没有新的path发现或者path命中次数相同,就直接返回0
否则,将case保存到fn = alloc_printf("%s/queue/id:%06u,%s", out_dir, queued_paths, describe_op(hnb))
文件里
利用add_to_queue将其添加到队列里
如果hnb的值是2,代表发现了新path,设置刚刚加入到队列里的queue的has_new_cov
字段为1,即queue_top->has_new_cov = 1
,然后queued_with_cov
计数器加一
保存hash到其exec_cksum
评估这个queue
设置keeping值为1
根据fault结果进入不同的分支
- FAULT_TMOUT
- 设置total_tmouts计数器加一
- 如果
unique_hangs
的个数超过能保存的最大数量KEEP_UNIQUE_HANG
,就直接返回keeping的值 - 如果不是dumb mode,就
simplify_trace((u64 *) trace_bits)
进行规整 - 如果没有发现新的超时路径则返回keeping
- 否则,代表发现了新的超时路径,unique_tmouts计数器加一
- 如果
hang_tmout
大于exec_tmout
,mem写入out_file,以hang_tmout
为timeout
,重新执行一次runt_target
- 如果结果不是
FAULT_TMOUT
,就返回keeping,否则就使unique_hangs
计数器加一,然后更新last_hang_time的值,并保存到alloc_printf("%s/hangs/id:%06llu,%s", out_dir, unique_hangs, describe_op(0))
文件
- 如果结果不是
- FAULT_CRASH
- Total_crashes计数器加一
- 如果unique_crashes大于能保存的最大数量
KEEP_UNIQUE_CRASH
即5000,就直接返回keeping的值 - 如果不是dumb_mode模式
- 就
simplify_trace((u64 *) trace_bits)
进行规整
- 就
- 如果没有发现新的路径,则返回keeping
- 如果发现了路径,则调用
write_crash_readme()
写out_dir/crashes/README.txt
- 然后unique_crashes计数器加一,并将结果保存到
alloc_printf("%s/crashes/id:%06llu,sig:%02u,%s", out_dir,unique_crashes, kill_signal, describe_op(0))
文件 - 更新last_crash_time和last_crash_execs
simplify_trace
8字节一组读入,直到读取完mem
如果mem不为空
- i从0-7,
mem8[i] = simplify_lookup[mem8[i]]
,代表规整该路径的命中次数到指令值,这个路径如果没有命中,就设置为1,如果命中了,就设置为128,即二进制的1000 0000
- 否则设置mem为
0x0101010101010101ULL
,即代表这8个字节代表的path都没有命中,每个字节的值被置为1
fuzz_one
从queue中取出entry进行fuzz,成功返回0,跳过或退出的话返回1
如果pending_favored
不为0,则对于queue_cur被fuzz过或者不是favored的,有99%的几率直接返回1
如果pending_favored
为0,且不是dumb_mode模式,且本次case不是favored,且queue中的case数量大于10
如果
queue_cycle
大于1并且没有被fuzz过的,75%的概率返回1如果queue_cur被fuzz过,否则有95%的概率直接返回1
打开testcase文件并设置len为queue_cur->len
打开该case对应的文件,并通过mmap映射到内存里,地址赋值给in_buf
和orig_in
CALIBRATION阶段
假如当前项有校准错误,并且校准错误次数小于3次,那么就用calibrate_case再次校准。
TRIMMING阶段
测试用例有没有修剪过
如果不是dumb_mode模式,并且case没有经过修剪
调用函数trim_case
进行修剪
设置queue_cur的trim_done为1,代表已经修剪
重新读取一次queue_cur->len
到len中
将in_buf拷贝len个字节到out_buf中
PERFORMANCE SCORE阶段
是否已经经历过deterministic
阶段,如果已经经历过,直接跳转至havoc阶段
调用calculate_score
函数计算当前case的可取性得分
如果skip_deterministic
为1,或者queue_cur被fuzz过,或者queue_cur的passed_det为1,则跳转去havoc_stage
阶段
如果执行路径校验和将其置于此主实例的范围之外,则跳转至havoc_stage
设置doing_det
为1
SIMPLE BITFLIP (+dictionary construction)阶段
1 |
它的作用是将一个数组 _ar
中指定位置 _b
的二进制位(bit)进行翻转(0 变成 1,1 变成 0)将case中内容按位取反的变异过程
首先将 _ar
强制转换为一个 u8
指针,即无符号 8 位整型指针,举个例子,如果queue里面现在是abcdefg,那么第一组取值就为abcd
_ar的取值是out_buf,而_bf的取值在[0: len << 3)
,所以用_bf & 7
能够得到0,1,2...7 0,1,2...7
这样的取值一共len组
然后是(128 >> ((_bf) & 7))
也就是128 >> 0 = 10000000, 128 >> 1 = 01000000....128 >> 7 = 00000001
_bf >> 3
就是8个全0+8个全1+8个全2+8个全3
最后是_arf[(_bf) >> 3] ^= (128 >> ((_bf) & 7))
= _arf[(_bf) >> 3] = _arf[(_bf) >> 3] ^ (128 >> ((_bf) & 7))
_arf[0]为a,a的二进制为97 = 01100001
,_arf[0] = 01100001 ^ 10000000, _arf[0] = 01100001 ^ 01000000 .....
然后执行一次common_fuzz_stuff
,然后再翻转回来
在进行bitflip 1/1变异时,对于每个byte的最低位(least significant bit)翻转还进行了额外的处理:如果连续多个bytes的最低位被翻转后,程序的执行路径都未变化,而且与原始执行路径不一致,那么就把这一段连续的bytes判断是一条token
比如对于SQL的SELECT *
,如果SELECT
被破坏,则肯定和正确的路径不一致,而被破坏之后的路径却肯定是一样的,比如AELECT
和SBLECT
,显然都是无意义的,而只有不破坏token,才有可能出现和原始执行路径一样的结果,所以AFL在这里就是在猜解关键字token
如果不是dumb_mode模式,并且stage_cur & 7 == 0
stage_cur == 7、15...
,当翻转到每个字节最低有效位的时候进入分支- hash32运算,值记录在cksum中
- 如果循环到
stage_max - 1
,并且结果和上一次结果相同- 如果当前token数量小于32
- 将当前字符作为token拼接到
a_collect[]
数组中
- 将当前字符作为token拼接到
- 如果token数量大于等于3,并且小于等于32
- 调用
maybe_add_auto
将累计的a_collect[]
数组中的内容添加到a_extras[]
数组中
- 调用
- 如果当前token数量小于32
- 如果和上一次结果不相同
- 如果token数量大于等于3,并且小于等于32
- 调用
maybe_add_auto
将累计的a_collect[]
数组中的内容添加到a_extras[]
数组中
- 调用
- 如果token数量大于等于3,并且小于等于32
- 如果当前和原始路径不一样,则可能是因为token被破坏
- 如果token数量小于32
- 添加到a_collect数组中
- 如果token数量小于32
stage_finds[STAGE_FLIP1]
的值加上在整个FLIP_BIT中新发现的路径和Crash总和
stage_cycles[STAGE_FLIP1]
的值加上在整个FLIP_BIT中执行的target次数stage_max
设置stage_name
为bitflip 2/1
,这次是连续翻转相邻的两位,保存结果到stage_finds[STAGE_FLIP2]和stage_cycles[STAGE_FLIP2]
设置stage_name为bitflip 4/1
,翻转连续的四位并记录
在进行bitflip 8/8变异时,AFL还生成了一个非常重要的信息:effector map
。这个effector map
几乎贯穿了整个deterministic fuzzing
的始终
具体地,在对每个byte进行翻转时,如果其造成执行路径与原始路径不一致,就将该byte在effector map
中标记为1,即“有效”的,否则标记为0,即”无效的。
这样做的逻辑是:如果一个byte完全翻转,都无法带来执行路径的变化,那么这个byte很有可能是属于”data”,而非”metadata”(例如size, flag等),对整个fuzzing的意义不大。所以,在随后的一些变异中,会参考effectormap
,跳过那些”无效”的byte,从而节省了执行资源
由此,通过极小的开销(没有增加额外的执行次数),AFL又一次对文件格式进行了启发式的判断
不过,在某些情况下并不会检测有效字符。第一种情况就是dumb mode或者从fuzzer,此时文件所有的字符都有可能被变异
设置stage_name为bitflip 8/8
,以字节为单位,直接通过和0xff
亦或运算去翻转整个字节的位,然后执行一次,并记录
- 调用
common_fuzz_stuff
对变异后数据进行测试,记录interesting - 如果
eff_map[stage_cur>>3]
为0- 如果不是dumb_mode模式并且len大于等于128
- 计算cksum
- 否则如果是dumb_mode模式或len小于128
- cksum等于
~queue_cur->exec_cksum
- cksum等于
- 如果
cksum != queue_cur->exec_cksum
- 则代表产生了新的路径,此时对应的eff_map中的项标记为1
- 接着复位+记录
- 如果不是dumb_mode模式并且len大于等于128
设置stage_name为bitflip 16/8
,设置stage_max
为len - 1
,以字为单位和0xffff
进行亦或运算,连续两byte翻转
- 这里要注意在翻转之前会先检查eff_map里对应于这两个字节的标志是否为0,如果为0,则这两个字节是无效的数据,stage_max减一,然后开始变异下一个字
- 变异完成之后,还原
设置stage_name为bitflip 32/8
,然后设置stage_max
为len - 3
,以双字为单位,直接通过和0xffffffff
亦或运算去相邻四个字节的位,连续四个byte翻转,然后执行一次,并记录
- 在每次翻转之前会检查eff_map里对应于这四个字节的标志是否为0,如果是0,则这两个字节是无效的数据,stage_max减一,然后开始变异下一组双字
ARITHMETIC INC/DEC阶段
加减变异,会对目标整数进行+1、+2…+35,-1、-2…-35
,由于整数存在大端序和小端序两种表现形式,所以这个阶段会对两种端序进行变异
这个和上面的bitflip有相似的过程,目标大小的不同,也分为了多个子阶段
这个阶段有两个非常智能的做法,第一个是前面提到的effector map
:如果一个整数的所有bytes都被判断为”无效”,那么就跳过对整数的变异。第二种情况是之前bitflip已经生成过的变异:如果加/减某个数后,其效果与之前的某种bitflip相同,那么这次变异肯定在上一个阶段已经执行过了,此次便不会再执行
设置stage_name
为arith 8/8
,每次对8个bit进行加减运算,按照每8个bit的步长从头开始,即对文件的每个byte进行整数加减变异
设置stage_name
为arith 16/8
,每次对16个bit进行加减运算,按照每8个bit的步长从头开始,即对文件的每个word进行整数加减变异
设置stage_name
为arith 32/8
,每次对32个bit进行加减运算,按照每8个bit的步长从头开始,即对文件的每个dword进行整数加减变异
INTERESTING VALUES 阶段
将out_buf中的字节替换成AFL内部预设的数值
1 | static s8 interesting_8[] = {INTERESTING_8}; |
- interest 8/8,每次对8个bit进替换,按照每8个bit的步长从头开始,即对文件的每个byte进行替换
- interest 16/8,每次对16个bit进替换,按照每8个bit的步长从头开始,即对文件的每个word进行替换
- interest 32/8,每次对32个bit进替换,按照每8个bit的步长从头开始,即对文件的每个dword进行替换
这个变异同样很智能,effector map
仍然会用于判断是否需要变异;此外,如果某个interesting value
,是可以通过bitflip
或者arithmetic
变异达到,那么这样的重复性变异也是会跳过的
DICTIONARY STUFF阶段
用户提供的tokens,是在词典文件中设置并通过-x选项指定的,如果没有则跳过相应的子阶段
- user extras(over),从头开始,将用户提供的tokens依次替换到原文件中,stage_max为
extras_cnt * len
- user extras(insert),从头开始,将用户提供的tokens依次插入到原文件中,stage_max为
extras_cnt * len
- auto extras(over),从头开始,将自动检测的tokens依次替换到原文件中,stage_max为
MIN(a_extras_cnt, USE_AUTO_EXTRAS) * len
RANDOM HAVOC阶段
这个阶段就是dumb_mode和从fuzzer一开始就经历的阶段,充满各种随机性,对原文件进行大量变异
- 随机选取某个bit进行翻转
- 随机选取某个byte,将其设置为随机的interesting value
- 随机选取某个word,并随机选取大、小端序,将其设置为随机的interesting value
- 随机选取某个dword,并随机选取大、小端序,将其设置为随机的interesting value
- 随机选取某个byte,对其减去一个随机数
- 随机选取某个byte,对其加上一个随机数
- 随机选取某个word,并随机选取大、小端序,对其减去一个随机数
- 随机选取某个word,并随机选取大、小端序,对其加上一个随机数
- 随机选取某个dword,并随机选取大、小端序,对其减去一个随机数
- 随机选取某个dword,并随机选取大、小端序,对其加上一个随机数
- 随机选取某个byte,将其设置为随机数
- 随机删除一段bytes
- 随机选取一个位置,插入一段随机长度的内容,其中75%的概率是插入原文中随机位置的内容,25%的概率是插入一段随机选取的数
- 随机选取一个位置,替换为一段随机长度的内容,其中75%的概率是替换成原文中随机位置的内容,25%的概率是替换成一段随机选取的数
- 随机选取一个位置,用随机选取的token(用户提供的或自动生成的)替换
- 随机选取一个位置,用随机选取的token(用户提供的或自动生成的)插入
- 怎么样,看完上面这么多的“随机”,有没有觉得晕?还没完,AFL会生成一个随机数,作为变异组合的数量,并根据这个数量,每次从上面那些方式中随机选取一个(可以参考高中数学的有放回摸球),依次作用到文件上。如此这般丧心病狂的变异,原文件就大概率面目全非了,而这么多的随机性,也就成了fuzzing过程中的不可控因素,即所谓的“看天吃饭”了。
- splice
- 设置ret_val的值为0
- 如果queue_cur通过了评估,且was_fuzzed字段是0,就设置
queue_cur->was_fuzzed
为1,然后pending_not_fuzzed计数器减一 - 如果queue_cur是favored, pending_favored计数器减一
SPLICING 阶段
RANDOM HAVOC阶段后没有什么效果,那么就会进入到SPLICING阶段,尝试拼接两个测试用例中的内容,拼接之后重新走一遍RANDOM HAVOC阶段
至此,第一轮变异完成,开始第二轮
trim_case
直接这个case的大小len小于5字节,就直接返回
设置stage_name
的值为tmp,在bytes_trim_in
的值里加上len,bytes_trim_in
代表被trim过的字节数
len_p2,其值是大于等于q->len的第一个2的幂次
取len_p2的1/16
为remove_len,这是起始步长
进入while循环,终止条件是remove_len小于终止步长len_p2的1/1024
,每轮循环步长会除2
设置remove_pos的值为remove_len
stage_name = trim 512/512
设置stage_cur为0,stage_max为
q->len / remove_len
remove_pos < q->len
,即每次前进remove_len个步长,直到整个文件都被遍历完为止由in_buf中
remove_pos
处开始,向后跳过remove_len
个字节,写入到.cur_input
里,然后运行一次fault = run_target
,trim_execs计数器加一如果主动中断,或返回值报错,直接跳转至
abort_trimming
由所得trace_bits计算出一个cksum,和
q->exec_cksum
比较- 如果相等
- 从
q->len
中减去remove_len个字节,并由此重新计算出一个len_p2
,这里注意一下while (remove_len >= MAX(len_p2 / TRIM_END_STEPS, TRIM_MIN_BYTES))
- 将
in_buf+remove_pos+remove_len
到最后的字节,前移到in_buf+remove_pos
处,等于删除了remove_pos向后的remove_len个字节 - 如果needs_write为0,则设置其为1,并保存当前trace_bits到clean_trace中
- 从
- 如果不相等
- remove_pos加上remove_len,即前移remove_len个字节。注意,如果相等,就无需前移
- 如果相等
注意trim过程可能比较慢,所以每执行
stats_update_freq
次,就刷新一次显示界面show_stats
stage_cur加一
如果needs_write为1
- 删除原来的q->fname,创建一个新的q->fname,将in_buf里的内容写入,然后用clean_trace恢复trace_bits的值。
- 进行一次
update_bitmap_score
返回fault
calculate_score
根据queue entry的执行速度、覆盖到的path数和路径深度来评估出一个得分,这个得分perf_score在后面havoc的时候使用
计算平均时间、bitmap大小、设置初始perf_score为100
- 处理输入时
- 在
read_testcases
的时候会调用add_to_queue
,此时所有的input case
的queue depth
都会被设置为1`
- 在
- fuzz_one时
- 然后在后面fuzz_one的时候,会先设置cur_depth为当前queue的depth,然后这个queue经过mutate之后调用
save_if_interesting
,如果是interesting case
,就会被add_to_queue
,此时就建立起了queue之间的关联关系,所以由当前queue变异加入的新queue,深度都在当前queue的基础上再增加
- 然后在后面fuzz_one的时候,会先设置cur_depth为当前queue的depth,然后这个queue经过mutate之后调用
common_fuzz_stuff
简单的说就是写入文件并执行,然后处理结果,如果出现错误,就返回1
如果定义了post_handler
,就通过out_buf = post_handler(out_buf, &len)
处理一下out_buf,如果out_buf或者len有一个为0,则直接返回0,对变异完的queue,最一层包装在写入的时候非常有用
write_to_testcase
写入.cur_input
fault = run_target(argv, exec_tmout)
如果fault是FAULT_TMOUT
- 如果
subseq_tmouts++ > TMOUT_LIMIT
(默认250),就将cur_skipped_paths
加一,直接返回1 subseq_tmout
是连续超时数
否则设置subseq_tmout
为0
如果skip_requested
为1
- 设置
skip_requested
为0,然后将cur_skipped_paths
加一,直接返回1
如果发现了新的路径,queued_discovered+1
如果stage_cur
除以stats_update_freq
余数是0,或者其加一等于stage_max
,就更新展示界面show_stats
总结
AFL_fuzz核心代码都调试结束了,还有afl-clang-fast,后续如果有时间会继续