AFL_fuzz源码分析

AFL_fuzz源码分析

自己动手调试并在此记录了一下,在学习的过程中参考了sakura大佬的文章

https://eternalsakura13.com/2020/08/23/afl/

afl-gcc

程序调试的参数是:/usr/local/bin/afl-gcc /path/test.c -o /path/test ,先从主函数开始看第一部分

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
int main(int argc, char** argv) {

if (isatty(2) && !getenv("AFL_QUIET")) {

SAYF(cCYA "afl-cc " cBRI VERSION cRST " by <lcamtuf@google.com>\n");

} else be_quiet = 1;

if (argc < 2) {

SAYF("\n"
"This is a helper application for afl-fuzz. It serves as a drop-in replacement\n"
"for gcc or clang, letting you recompile third-party code with the required\n"
"runtime instrumentation. A common use pattern would be one of the following:\n\n"

" CC=%s/afl-gcc ./configure\n"
" CXX=%s/afl-g++ ./configure\n\n"

"You can specify custom next-stage toolchain via AFL_CC, AFL_CXX, and AFL_AS.\n"
"Setting AFL_HARDEN enables hardening optimizations in the compiled code.\n\n",
BIN_PATH, BIN_PATH);

exit(1);

}

如果没有设置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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  find_as(argv[0]);

edit_params(argc, argv);

for(int i = 0; i < sizeof(cc_params); ++i){
printf("id[%d] = %s\n", i, cc_params[i]);
}

execvp(cc_params[0], (char**)cc_params);

FATAL("Oops, failed to execute '%s' - check your PATH", cc_params[0]);

return 0;

}

最后会通过execvp来执行命令,最终的命令可以借助printf打印出来看一下

1
2
3
4
5
6
7
8
id[0] = gcc
id[1] = /home/z1r0/fuzz_test/test.c
id[2] = -o
id[3] = /home/z1r0/fuzz_test/test
id[4] = -B
id[5] = /usr/local/lib/afl
id[6] = -g
id[7] = -O3

会把/usr/local/lib/afl加到编译器的搜索路径,在这个例子里实际会调用afl-as来作为汇编器

afl-as

从上面得到最终会调用as这个东西来进行插桩,对其进行调试,在调试之前打印一下参数,然后调试的时候传入参数即可

1
2
3
4
5
6
7
8
9
if (!(pid = fork())) {
for(int i = 0; i < sizeof(as_params); ++i){
printf("as[%d] = %s\n", i, as_params[i]);
}

execvp(as_params[0], (char**)as_params);
FATAL("Oops, failed to execute '%s' - check your PATH", as_params[0]);

}
1
2
3
4
5
6
7
8
as[0] = as
as[1] = --64
as[2] = -o
as[3] = /tmp/ccuXPXsJ.o
as[4] = /tmp/.afl-3164329-1677932048.s
as[5] = (null)
as[6] = (null)
as[7] = (null)

主函数前面的逻辑和afl-gcc都差不多,会获取AFL_INST_RATIO这个环境变量的值,设置为inst_ratio_strgettimeofday这个是获取时间,然后设置随机数种子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_asas_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 = 1modified_file = input_file
    • 如果不是-version的话,则报错
    • 如果后面没有的话则input_file=NULL
  • 如果首字符不是-

    • 比较tmp_dir/var/tmp/tmp/,的前strlen(tmp_dir)/9/5个字节是否相等,不相等就设置pass_thru=1

设置modified_filetmp_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是否是\tline[1]是否是字母

    • 如果都满足,向outf中写入对应的trampoline_fmt,设置instrument_next = 0,插桩计数器ins_lines++
    • 这些都是想要插入instrumentation trampoline到所有的标签,宏,注释之后
  • 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
  • 判断一些信息,是att汇编还是intel汇编,是32位还是64位,接着设置相应的flag

  • 插桩^\tjnz foo条件跳转指令,插桩重点是^main ^.L0 ^.LBB0_0 ^\tjnz foo

    • 如果line的值是\tj[!m].,并且R(100) < inst_ratioR(100)是一个100以内的随机数,inst_ratio默认是100,则会进行插桩,如果inst_ratio设置为0就代表不会对该分支进行插桩,R()这个相当于是区分每个桩的,看成一个flag
    • 判断是否对应的位数然后向outf里写入trampoline_fmt_64或者trampoline_fmt_32
  • 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_OPTIONSMSAN_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

如果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_EXCL只有在共享内存不存在的时候,新的共享内存才创建,否则产生错误
    • 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这个数组

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里面的旧东西
  • 创建成功
    • 如果设置了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_namenl[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
2
3
4
q->fname        = fname;
q->len = len;
q->depth = cur_depth + 1;
q->passed_det = passed_det;

如果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/目录下的东西
      • 如果删除成功则正常返回,如果删除失败则异常

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
    • FAULT_TMOUT
      • 如果指定了-t参数,该测试用例产生超时错误。当timeout_given为2时跳过该文件
    • 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计数器加一
  • 如果样例的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_tmoutexec_tmout,如果from_queue是0或者resuming_fuzz被置为1,即代表不来自于queue中或者在resuming sessions的时候,则use_tmout的值被设置的更大,q->cal_failed++

设置stage_namecalibration,根据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,来告诉父进程执行失败,并结束子进程。
  • 然后是父进程

    • 关闭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_msexec_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
2
3
4
5
6
{
int index = num >> 3;// num/8得到byte[]的index
int position = num & 0x07; // num%8得到在byte[index]的位置
int val = 1 << position;//将1左移position后,那个位置自然就是1
byte[index] = byte[index] | val; //,然后和以前的数据做|,这样,那个位置就替换成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_tmoutEXEC_TIMEOUTexec_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_cyclequeue_cur为空,即执行到当前队列尾的时候才增加1,所以这代表queue队列被完全变异一次的次数。
  • execs_done
    • total_execs,target的总的执行次数,每次run_target的时候会增加1
  • execs_per_sec
    • 每秒执行的次数
  • paths_total
    • queued_paths在每次add_to_queue的时候会增加1,代表queue里的样例总数
  • paths_favored
    • queued_favored,有价值的路径总数
  • paths_found
    • queued_discovered在每次common_fuzz_stuff去执行一次fuzz时,发现新的interesting case的时候会增加1,代表在fuzz运行期间发现的新queue entry。
  • 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
  • stability
  • bitmap_cvg
  • unique_crashes
    • unique_crashes这是在save_if_interesting时,如果fault是FAULT_CRASH,就将unique_crashes计数器加一
  • unique_hangs
    • unique_hangs这是在save_if_interesting时,如果fault是FAULT_TMOUT,且exec_tmout小于hang_tmout,就以hang_tmout为超时时间再执行一次,如果还超时,就让hang计数器加一。
  • last_path
    • add_to_queue里将一个新case加入queue时,就设置一次last_path_time为当前时间,last_path_time / 1000
  • last_crash
    • 同上,在unique_crashes加一的时候,last_crash也更新时间,last_crash_time / 1000
  • last_hang
    • 同上,在unique_hangs加一的时候,last_hang也更新时间,last_hang_time / 1000
  • 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_FIRST环境变量

    • 利用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_acceptmin_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
      • stage_cur计数器加一,如果stage_cur是stats_update_freq的倍数,就刷新一次展示界面
    • 向当前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_tmouttimeout,重新执行一次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_buforig_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
2
3
4
5
#define FLIP_BIT(_ar, _b) do { \
u8* _arf = (u8*)(_ar); \
u32 _bf = (_b); \
_arf[(_bf) >> 3] ^= (128 >> ((_bf) & 7)); \
} while (0)

它的作用是将一个数组 _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被破坏,则肯定和正确的路径不一致,而被破坏之后的路径却肯定是一样的,比如AELECTSBLECT,显然都是无意义的,而只有不破坏token,才有可能出现和原始执行路径一样的结果,所以AFL在这里就是在猜解关键字token

如果不是dumb_mode模式,并且stage_cur & 7 == 0

  • stage_cur == 7、15...,当翻转到每个字节最低有效位的时候进入分支
  • hash32运算,值记录在cksum中
  • 如果循环到stage_max - 1,并且结果和上一次结果相同
    • 如果当前token数量小于32
      • 将当前字符作为token拼接到a_collect[]数组中
    • 如果token数量大于等于3,并且小于等于32
      • 调用maybe_add_auto将累计的a_collect[]数组中的内容添加到a_extras[]数组中
  • 如果和上一次结果不相同
    • 如果token数量大于等于3,并且小于等于32
      • 调用maybe_add_auto将累计的a_collect[]数组中的内容添加到a_extras[]数组中
  • 如果当前和原始路径不一样,则可能是因为token被破坏
    • 如果token数量小于32
      • 添加到a_collect数组中

stage_finds[STAGE_FLIP1]的值加上在整个FLIP_BIT中新发现的路径和Crash总和

stage_cycles[STAGE_FLIP1]的值加上在整个FLIP_BIT中执行的target次数stage_max

设置stage_namebitflip 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 != queue_cur->exec_cksum
      • 则代表产生了新的路径,此时对应的eff_map中的项标记为1
    • 接着复位+记录

设置stage_name为bitflip 16/8,设置stage_maxlen - 1,以字为单位和0xffff进行亦或运算,连续两byte翻转

  • 这里要注意在翻转之前会先检查eff_map里对应于这两个字节的标志是否为0,如果为0,则这两个字节是无效的数据,stage_max减一,然后开始变异下一个字
  • 变异完成之后,还原

设置stage_name为bitflip 32/8,然后设置stage_maxlen - 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_namearith 8/8,每次对8个bit进行加减运算,按照每8个bit的步长从头开始,即对文件的每个byte进行整数加减变异

设置stage_namearith 16/8,每次对16个bit进行加减运算,按照每8个bit的步长从头开始,即对文件的每个word进行整数加减变异

设置stage_namearith 32/8,每次对32个bit进行加减运算,按照每8个bit的步长从头开始,即对文件的每个dword进行整数加减变异

INTERESTING VALUES 阶段

将out_buf中的字节替换成AFL内部预设的数值

1
2
3
static s8 interesting_8[] = {INTERESTING_8};
static s16 interesting_16[] = {INTERESTING_8, INTERESTING_16};
static s32 interesting_32[] = {INTERESTING_8, INTERESTING_16, INTERESTING_32};
  • 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 casequeue depth都会被设置为1`
  • fuzz_one时
    • 然后在后面fuzz_one的时候,会先设置cur_depth为当前queue的depth,然后这个queue经过mutate之后调用save_if_interesting,如果是interesting case,就会被add_to_queue,此时就建立起了queue之间的关联关系,所以由当前queue变异加入的新queue,深度都在当前queue的基础上再增加

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,后续如果有时间会继续

Reference

https://eternalsakura13.com/2020/08/23/afl/

https://paper.seebug.org/1732/#331-fuzz_one