afl-gcc
afl-gcc 是 gcc/clang 的替代编译器。afl-gcc 其实就是 gcc 的包装,使用 afl-gcc 依赖 afl-as,因此需要知道 afl-as 的安装路径。afl-as 的默认安装路径是 /usr/local/lib/afl/,通过编译的方式,可以更改安装路径,更改之后,可以通过 AFL_PATH 设置环境变量。
如果设置了 AFL_HARDEN ,afl-gcc 可以用更多的编译选项编译目标程序,这样可以检测到更加可靠的内存问题。比如,通过 AFL_USE_ASAN 打开 ASAN (地址消杀器)。
main
main 函数先调用 find_as(argv[0]) 寻找 afl-as ;然后修改编译器参数,并执行下游编译器。
/* Main entry point */
int main(int argc, char **argv) {
// 如果不是 QUIET 模式,则输出作者信息
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-as
find_as(argv[0]);
// 修改编译参数
edit_params(argc, argv);
// 使用真正的编译器编译代码(使用 -B 参数指定汇编器为 afl-as)
execvp(cc_params[0], (char **) cc_params);
FATAL("Oops, failed to execute '%s' - check your PATH", cc_params[0]);
return 0;
}
find_as
我们平时一般使用,所以正常情况下,会找到 /work/afl/afl-as 这个文件。
/* Try to find our "fake" GNU assembler in AFL_PATH or at the location derived
from argv[0]. If that fails, abort. */
/* Try to find our "fake" GNU assembler in AFL_PATH or at the location derived
from argv[0]. If that fails, abort. */
static void find_as(u8 *argv0) {
// 从环境变量中读取 $AFL_PATH
u8 *afl_path = getenv("AFL_PATH");
u8 *slash, *tmp;
// 如果存在环境变量 $AFL_PATH,且 $AFL_PATH/as 存在,则成功找到
if (afl_path) {
tmp = alloc_printf("%s/as", afl_path);
if (!access(tmp, X_OK)) {
as_path = afl_path;
ck_free(tmp);
return;
}
ck_free(tmp);
}
// 于 argv[0] 所在的目录下寻找 afl-as
slash = strrchr(argv0, '/');
if (slash) {
u8 *dir;
*slash = 0;
dir = ck_strdup(argv0);
*slash = '/';
tmp = alloc_printf("%s/afl-as", dir);
if (!access(tmp, X_OK)) {
as_path = dir;
ck_free(tmp);
return;
}
ck_free(tmp);
ck_free(dir);
}
// fallback,如果前两个位置都找不到,则去编译 afl-gcc 时定义的 AFL_PATH 去找
// 默认情况下,AFL_PATH 由 Makefile 定义成 "/usr/local/lib/afl"
if (!access(AFL_PATH "/as", X_OK)) {
as_path = AFL_PATH;
return;
}
FATAL("Unable to find AFL wrapper binary for 'as'. Please set AFL_PATH");
}
edit_params
首先,分析自己的 argv[0] ,确定自己需要调用哪个下游编译器——例如,如果 argv[0] 是 /work/afl/afl-clang++ ,则下游编译器是 clang++ 。另外,上文提到过,AFL 允许用户自己指定下游编译器,如果 AFL_CC 和 AFL_CXX 存在,则会覆盖掉默认编译器。
接下来,将自己的 argv[] 复制一份,稍后将会原样传递给下游编译器。 由于这一步骤的存在,我们可以直接使用 afl-gcc 代替原有的 gcc 指令。
-
-integrated-as和-pipe开关会被忽略。-integrated-as:用于告诉编译器使用集成的汇编器,而edit_params函数中将此选项忽略,是为了确保可以使用 AFL 自己的汇编器,或者是为了防止潜在的兼容性问题。-pipe这个编译选项使得编译器和汇编器之间通过管道通信,而不是写入临时文件。此选项在 AFL 编译过程中被忽略,是因为afl-as需要根据生成的 asm 临时文件进行插桩。
-
-B参数会被覆盖为as_path。-B:该选项指定了编译器查找其执行文件、库、包括文件和数据文件的位置。在 AFL 中,这个选项被用来确保编译过程中使用的是 AFL 的as_path,即指向 AFL 自己的汇编器,而不是系统默认的。
-
如果是 clang 模式,则打开
-no-integrated-as开关。这样做是为了禁用 clang 的集成汇编器,确保 AFL 能使用其专用的、用于插桩的外部汇编器。 -
如果
AFL_HARDEN打开,则设置-fstack-protector-all和-D_FORTIFY_SOURCE=2。-fstack-protector-all:该选项强化了栈的安全性,防止栈溢出攻击。-D_FORTIFY_SOURCE=2:启用额外的缓冲区检查,增强程序的安全性。-
-D:这个选项用于定义预处理器宏。-D_FORTIFY_SOURCE=2意味着在编译期间定义_FORTIFY_SOURCE宏,并将其值设置为2。 -
_FORTIFY_SOURCE:这是一个用于增强编译时安全检查的宏。它可以检测一些常见的编程错误,尤其是在使用标准库函数时的错误。_FORTIFY_SOURCE有两个级别:1:启用基本的安全检查。2:启用更严格、更全面的安全检查。
定义
_FORTIFY_SOURCE=2将使编译器进行更严格的安全检查,如缓冲区溢出等潜在问题。这些检查主要针对标准库中的字符串和内存处理函数,例如strcpy、sprintf、memcpy等。
-
-
若传入的编译参数中本来就有
-fsanitize=address或-fsanitize=memory(这些选项用于启用 AddressSanitizer 或 MemorySanitizer 来检测内存相关的错误),则设置环境变量AFL_USE_ASAN为 1;否则执行以下步骤:- 如果
AFL_USE_ASAN打开,则设置-U_FORTIFY_SOURCE(取消定义预处理器宏_FORTIFY_SOURCE,其中-U这个选项用于取消定义一个预处理器宏)和-fsanitize=address - 如果
AFL_USE_MSAN打开,则设置-U_FORTIFY_SOURCE和-fsanitize=memory
- 如果
-
默认情况下(未设置
AFL_DONT_OPTIMIZE环境变量),会加入以下优化开关:-g -O3 -funroll-loops -D__AFL_COMPILER=1 -DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION=1-g:生成调试信息-O3:提供了高级的代码优化-funroll-loops是循环展开,可以改善执行性能。
-
如果
AFL_NO_BUILTIN环境变量被打开,则加入以下开关:-fno-builtin-strcmp -fno-builtin-strncmp -fno-builtin-strcasecmp -fno-builtin-strncasecmp -fno-builtin-memcmp -fno-builtin-strstr -fno-builtin-strcasestr使用
-fno-builtin-<function>选项可以禁用 GCC 的某些内建函数,这在 AFL 中是用来防止编译器优化掉可能引发 bug 的代码路径。
以上就是 edit_params 的全流程。注意到,最关键的一步是加入了 -B as_path 这个 flag,使得下游编译器在汇编过程中,以 afl-as 替换了原生的汇编器。 而具体的插桩过程,则是 afl-as 负责实现。
/* Copy argv to cc_params, making the necessary edits. */
/* 编辑参数的函数,主要用于调整 AFL 编译器的参数 */
static void edit_params(u32 argc, char **argv) {
u8 fortify_set = 0, asan_set = 0; // 用于标记是否设置了 FORTIFY_SOURCE 和 Address Sanitizer
u8 *name;
// 特定于FreeBSD x86_64的编译器标记
#if defined(__FreeBSD__) && defined(__x86_64__)
u8 m32_set = 0; // 标记是否设置了-m32参数
#endif
// 为编译参数分配内存,额外增加 128 个空间以存储可能的附加参数
cc_params = ck_alloc((argc + 128) * sizeof(u8 *));
// 获取程序名,忽略路径,只保留程序名称
name = strrchr(argv[0], '/');
if (!name) name = argv[0]; else name++;
// 根据程序名判断使用哪种编译器,并设置环境变量
if (!strncmp(name, "afl-clang", 9)) {
clang_mode = 1;
setenv(CLANG_ENV_VAR, "1", 1);
if (!strcmp(name, "afl-clang++")) {
u8 *alt_cxx = getenv("AFL_CXX");
cc_params[0] = alt_cxx ? alt_cxx : (u8 *) "clang++"; // 使用环境变量指定的 C++ 编译器或默认 clang++
} else {
u8 *alt_cc = getenv("AFL_CC");
cc_params[0] = alt_cc ? alt_cc : (u8 *) "clang"; // 使用环境变量指定的 C 编译器或默认 clang
}
} else {
if (!strcmp(name, "afl-g++")) {
u8 *alt_cxx = getenv("AFL_CXX");
cc_params[0] = alt_cxx ? alt_cxx : (u8 *) "g++"; // 使用环境变量指定的 g++ 或默认 g++
} else if (!strcmp(name, "afl-gcj")) {
u8 *alt_cc = getenv("AFL_GCJ");
cc_params[0] = alt_cc ? alt_cc : (u8 *) "gcj"; // 使用环境变量指定的 gcj 或默认 gcj
} else {
u8 *alt_cc = getenv("AFL_CC");
cc_params[0] = alt_cc ? alt_cc : (u8 *) "gcc"; // 使用环境变量指定的 gcc 或默认 gcc
}
}
// 遍历传入的 argv 并复制到 cc_params,忽略一些特定的编译器选项
while (--argc) {
u8 *cur = *(++argv);
// 忽略 -B 参数(可能会覆盖现有设置)
if (!strncmp(cur, "-B", 2)) {
if (!be_quiet) WARNF("-B is already set, overriding");
if (!cur[2] && argc > 1) {
argc--;
argv++;
}
continue;
}
// 忽略 -integrated-as 参数,这通常用于内部汇编器设置
if (!strcmp(cur, "-integrated-as")) continue;
// 忽略管道选项,确保可以生成汇编文件
if (!strcmp(cur, "-pipe")) continue;
#if defined(__FreeBSD__) && defined(__x86_64__)
// 对于FreeBSD x86_64,检查-m32参数
if (!strcmp(cur, "-m32")) m32_set = 1;
#endif
// 检查是否设置了 Address Sanitizer 或 Memory Sanitizer
if (!strcmp(cur, "-fsanitize=address") ||
!strcmp(cur, "-fsanitize=memory"))
asan_set = 1;
// 检查是否设置了 FORTIFY_SOURCE
// FORTIFY_SOURCE 作为 gcc 参数会使得编译器将一些内存操作函数和格式化字符串函数替换成安全函数从而提高程序安全性。
if (strstr(cur, "FORTIFY_SOURCE")) fortify_set = 1;
// 复制剩余参数到 cc_params
cc_params[cc_par_cnt++] = cur;
}
// 添加必要的编译参数
cc_params[cc_par_cnt++] = "-B";
cc_params[cc_par_cnt++] = as_path;
// 设置 clang 特定的参数
if (clang_mode)
cc_params[cc_par_cnt++] = "-no-integrated-as";
// 如果设置了环境变量 AFL_HARDEN,则添加相关的编译参数
if (getenv("AFL_HARDEN")) {
cc_params[cc_par_cnt++] = "-fstack-protector-all";
if (!fortify_set)
cc_params[cc_par_cnt++] = "-D_FORTIFY_SOURCE=2";
}
// 根据是否设置了 Address Sanitizer 或 Memory Sanitizer 设置环境变量
if (asan_set) {
/* Pass this on to afl-as to adjust map density. */
setenv("AFL_USE_ASAN", "1", 1);
} else if (getenv("AFL_USE_ASAN")) {
// AFL_USE_MSAN 与 AFL_USE_MSAN 或 AFL_HARDEN 互斥
if (getenv("AFL_USE_MSAN"))
FATAL("ASAN and MSAN are mutually exclusive");
if (getenv("AFL_HARDEN"))
FATAL("ASAN and AFL_HARDEN are mutually exclusive");
// 取消 FORTIFY_SOURCE 宏的定义,-U 表示取消宏定义
cc_params[cc_par_cnt++] = "-U_FORTIFY_SOURCE";
cc_params[cc_par_cnt++] = "-fsanitize=address";
} else if (getenv("AFL_USE_MSAN")) {
if (getenv("AFL_USE_ASAN"))
FATAL("ASAN and MSAN are mutually exclusive");
if (getenv("AFL_HARDEN"))
FATAL("MSAN and AFL_HARDEN are mutually exclusive");
// 取消 FORTIFY_SOURCE 宏的定义,-U 表示取消宏定义
cc_params[cc_par_cnt++] = "-U_FORTIFY_SOURCE";
cc_params[cc_par_cnt++] = "-fsanitize=memory";
}
// 添加优化编译参数,除非设置了 AFL_DONT_OPTIMIZE
if (!getenv("AFL_DONT_OPTIMIZE")) {
#if defined(__FreeBSD__) && defined(__x86_64__)
/* On 64-bit FreeBSD systems, clang -g -m32 is broken, but -m32 itself
works OK. This has nothing to do with us, but let's avoid triggering
that bug. */
if (!clang_mode || !m32_set)
cc_params[cc_par_cnt++] = "-g";
#else
cc_params[cc_par_cnt++] = "-g"; // 添加调试信息
#endif
cc_params[cc_par_cnt++] = "-O3"; // 启用高级优化
cc_params[cc_par_cnt++] = "-funroll-loops"; // 展开循环以增加性能
/* Two indicators that you're building for fuzzing; one of them is
AFL-specific, the other is shared with libfuzzer. */
cc_params[cc_par_cnt++] = "-D__AFL_COMPILER=1";
cc_params[cc_par_cnt++] = "-DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION=1";
}
// 如果设置了 AFL_NO_BUILTIN,则添加相关的编译参数以禁用内置函数
if (getenv("AFL_NO_BUILTIN")) {
cc_params[cc_par_cnt++] = "-fno-builtin-strcmp";
cc_params[cc_par_cnt++] = "-fno-builtin-strncmp";
cc_params[cc_par_cnt++] = "-fno-builtin-strcasecmp";
cc_params[cc_par_cnt++] = "-fno-builtin-strncasecmp";
cc_params[cc_par_cnt++] = "-fno-builtin-memcmp";
cc_params[cc_par_cnt++] = "-fno-builtin-strstr";
cc_params[cc_par_cnt++] = "-fno-builtin-strcasestr";
}
// 设置参数数组的结束标记
cc_params[cc_par_cnt] = NULL;
}
afl-as
AFL 的汇编器工具,通常不直接使用,它被 afl-gcc 或 afl-clang 替代性地使用来插桩编译后的代码。这个工具确保生成的程序包含了 AFL 需要的插桩点,这些插桩点可以帮助 afl-fuzz 理解哪些代码路径已经被执行过。
main
afl-as 是原生 GNU as 的 wrapper。从 main 函数可知 afl-as 的工作流程是:
- 初始化随机数种子
- 修改
as参数 - 在汇编指令序列上插桩
- 调用
as生成可执行文件,并清理现场
/* 主入口点 */
int main(int argc, char** argv) {
s32 pid; // 用于存储进程ID
u32 rand_seed; // 用于生成随机种子
int status; // 用于存储子进程的状态
u8* inst_ratio_str = getenv("AFL_INST_RATIO"); // 从环境变量获取插桩率
struct timeval tv; // 时间结构体,用于获取当前时间
struct timezone tz; // 时区结构体,不过这里未使用
clang_mode = !!getenv(CLANG_ENV_VAR); // 检查是否设置了环境变量以使用clang模式
if (isatty(2) && !getenv("AFL_QUIET")) {
// 如果是终端环境并且没有设置AFL_QUIET,则打印版本信息
SAYF(cCYA "afl-as " cBRI VERSION cRST " by <lcamtuf@google.com>\n");
} else be_quiet = 1; // 否则设置静默模式
if (argc < 2) {
// 如果参数少于2个,说明可能直接运行了这个程序,给出错误信息并退出
SAYF("\n"
"This is a helper application for afl-fuzz. It is a wrapper around GNU 'as',\n"
"executed by the toolchain whenever using afl-gcc or afl-clang. You probably\n"
"don't want to run this program directly.\n\n"
"Rarely, when dealing with extremely complex projects, it may be advisable to\n"
"set AFL_INST_RATIO to a value less than 100 in order to reduce the odds of\n"
"instrumenting every discovered branch.\n\n");
exit(1);
}
gettimeofday(&tv, &tz); // 获取当前时间
rand_seed = tv.tv_sec ^ tv.tv_usec ^ getpid(); // 生成随机种子
srandom(rand_seed); // 设置随机种子
edit_params(argc, argv); // 编辑参数
if (inst_ratio_str) {
// 如果设置了AFL_INST_RATIO,检查其值是否合法
if (sscanf(inst_ratio_str, "%u", &inst_ratio) != 1 || inst_ratio > 100)
FATAL("Bad value of AFL_INST_RATIO (must be between 0 and 100)");
}
if (getenv(AS_LOOP_ENV_VAR))
FATAL("Endless loop when calling 'as' (remove '.' from your PATH)"); // 检查环境变量,防止无限循环
setenv(AS_LOOP_ENV_VAR, "1", 1); // 设置环境变量防止无限循环
if (getenv("AFL_USE_ASAN") || getenv("AFL_USE_MSAN")) {
// 如果使用ASAN或MSAN,调整插桩比率
sanitizer = 1;
inst_ratio /= 3;
}
if (!just_version) add_instrumentation(); // 添加插桩
if (!(pid = fork())) {
// 创建子进程执行汇编器
execvp(as_params[0], (char**)as_params);
FATAL("Oops, failed to execute '%s' - check your PATH", as_params[0]);
}
if (pid < 0) PFATAL("fork() failed"); // 检查fork是否成功
if (waitpid(pid, &status, 0) <= 0) PFATAL("waitpid() failed"); // 等待子进程结束
if (!getenv("AFL_KEEP_ASSEMBLY")) unlink(modified_file); // 如果不保留汇编文件,则删除
exit(WEXITSTATUS(status)); // 退出,返回子进程的退出状态
}
edit_params
edit_params 过程很类似于 afl-gcc 修改参数的逻辑:
- 首先确定
as程序的名字,默认就是 GNU as,但用户也可以提供AFL_AS来覆盖 - 设置临时文件
modified_file路径为/tmp/.afl-pid-timestamp.s - 将自己程序的
argv原样复制给as
/* 这个函数负责调整传递给 GNU 汇编器 'as' 的参数,主要是由 afl-gcc 或 afl-clang 调用。
特别地,最后一个参数由 GCC 传递的总是文件名,我们利用这一点来简化处理逻辑。 */
static void edit_params(int argc, char** argv) {
// 尝试从环境变量获取临时目录路径,如果未设置,则依次检查 TEMP 和 TMP
u8 *tmp_dir = getenv("TMPDIR"), *afl_as = getenv("AFL_AS");
if (!tmp_dir) tmp_dir = getenv("TEMP");
if (!tmp_dir) tmp_dir = getenv("TMP");
if (!tmp_dir) tmp_dir = "/tmp"; // 如果都未设置,默认使用 "/tmp"
// 分配足够的内存空间来存储修改后的参数
as_params = ck_alloc((argc + 32) * sizeof(u8*));
// 默认使用 'as' 作为汇编器,如果设置了 AFL_AS 环境变量,则使用该变量的值
as_params[0] = afl_as ? afl_as : (u8*)"as";
// 将数组的最后一个元素初始化为 NULL,为参数列表的终结符
as_params[argc] = 0;
u32 i;
for (i = 1; i < argc - 1; i++) { // 遍历除最后一个文件名之外的所有参数
// 处理特定的命令行选项,设置是否使用 64 位模式
if (!strcmp(argv[i], "--64")) use_64bit = 1;
else if (!strcmp(argv[i], "--32")) use_64bit = 0;
// 将当前参数添加到 as_params 数组中
as_params[as_par_cnt++] = argv[i];
}
// 获取输入文件名,它总是参数列表中的最后一个
input_file = argv[argc - 1];
// 检查输入文件名是否以 '-' 开头,可能是一个特殊的命令行选项
if (input_file[0] == '-') {
if (!strcmp(input_file + 1, "-version")) {
just_version = 1; // 表示显示版本信息,后续不会调用插桩函数
modified_file = input_file; // input_file 是 "-version" 因此直接拷贝到最后一个参数
goto wrap_things_up;
}
if (input_file[1]) FATAL("Incorrect use (not called through afl-gcc?)"); // 如果最后一个参数为 -XXX 则无效
else input_file = NULL; // 如果只有一个 '-',则 input_file 为 null
} else {
// 检查文件名是否在临时目录内,用于判断是否是正常的编译调用
if (strncmp(input_file, tmp_dir, strlen(tmp_dir)) &&
strncmp(input_file, "/var/tmp/", 9) &&
strncmp(input_file, "/tmp/", 5)) pass_thru = 1; // 检查是否为正常的编译流程
}
// 构建一个修改过的文件名,用于汇编器输出
modified_file = alloc_printf("%s/.afl-%u-%u.s", tmp_dir, getpid(), (u32)time(NULL));
wrap_things_up:
// 将修改后的文件名添加到参数列表中,准备传递给汇编器
as_params[as_par_cnt++] = modified_file;
as_params[as_par_cnt] = NULL; // 设置参数列表的终结符
}
add_instrumentation
桩代码
add_instrumentation 会对 gcc 生成的汇编文件进行插桩,其中插桩点代码为 trampoline_fmt_64 ,插桩函数为 main_payload_64 。
其中 trampoline_fmt_64 内容如下,主要功能为保存 rdx ,rcx ,rax 寄存器到栈中,然后调用 __afl_maybe_log 函数并通过 rcx 寄存器传递参数。
.align 4 ; 确保以下代码按4字节对齐,提高代码的执行效率
lea rsp, [rsp - 128 - 24] ; 为局部变量和保存的寄存器腾出空间(152字节)
mov [rsp + 0], rdx ; 保存 rdx 寄存器的值到栈中
mov [rsp + 8], rcx ; 保存 rcx 寄存器的值到栈中
mov [rsp + 16], rax ; 保存 rax 寄存器的值到栈中
mov rcx, 0x%08x ; 将随机生成的 ID (由 AFL 提供) 加载到 rcx 寄存器,用于路径 (跳转边) 记录
call __afl_maybe_log ; 调用 __afl_maybe_log 函数,该函数检查并记录分支是否被触发
mov rax, [rsp + 16] ; 恢复 rax 寄存器的原始值
mov rcx, [rsp + 8] ; 恢复 rcx 寄存器的原始值
mov rdx, [rsp + 0] ; 恢复 rdx 寄存器的原始值
lea rsp, [rsp + 128 + 24] ; 恢复 rsp 寄存器的原始值,清理栈空间
afl_maybe_log
main_payload_64 即 afl_maybe_log 函数对应的反编译代码如下。
/* Designated file descriptors for forkserver commands (the application will
use FORKSRV_FD and FORKSRV_FD + 1): */
#define FORKSRV_FD 198
uint64_t __afl_prev_loc;
uint8_t *_afl_area_ptr;
uint8_t *_afl_global_area_ptr;
bool __afl_setup_failure;
char _afl_temp[4];
pid_t __afl_fork_pid;
void afl_maybe_log(uint16_t cur_location) { // cur_location 实际是通过 rcx 寄存器传递
if (_afl_area_ptr == NULL) { // 如果 _afl_area_ptr 没有初始化就需要先初始化 _afl_area_ptr
if (__afl_setup_failure) return; // 如果 __afl_setup_failure 说明共享内存获取失败,因此直接返回。
if (_afl_global_area_ptr == NULL) { // 如果 _afl_global_area_ptr 为空说明需要调用 shmat 获取共享内存
char *afl_shm_id = getenv("__AFL_SHM_ID"); // 从环境变量 __AFL_SHM_ID 中获取 afl_shm_id
if (afl_shm_id == NULL || (_afl_global_area_ptr = shmat(atoi(afl_shm_id), NULL, 0)) == (void *) -1) { // 通过 shmat 系统调用获取共享内存
__afl_setup_failure = true; // 如果没有 afl_shm_id 或者 shmat 没有获取到共享内存则需要将 __afl_setup_failure 设置为 true 表示失败
return;
}
if (write(FORKSRV_FD + 1, _afl_temp, 4) == 4) { // 与 afl_fuzz 发起会话
while (true) {
if (read(FORKSRV_FD, _afl_temp, 4) != 4 || (__afl_fork_pid = fork()) < 0) { // 收到 forkserver 的回复后 fork 一个子进程
exit(FORKSRV_FD);
}
if (__afl_fork_pid == 0)break; // 子进程跳出循环继续运行程序
write(FORKSRV_FD + 1, &__afl_fork_pid, 4); // 父进(fork-server)程向 afl_fuzz 传递子进程的 pid 也就是被 fuzz 的程序的 pid
if (waitpid(__afl_fork_pid, (void *) _afl_temp, 0) <= 0) { // 等待子进程结束
exit(__afl_fork_pid);
}
write(FORKSRV_FD + 1, _afl_temp, 4); // 发送消息表示当前一轮的 fuzz 结束
}
}
}
// 子进程关闭与 afl_fuzz 的通信管道避免干扰程序运行
close(FORKSRV_FD);
close(FORKSRV_FD + 1);
_afl_area_ptr = _afl_global_area_ptr; // 同步 _afl_area_ptr 为 _afl_global_area_ptr
}
_afl_area_ptr[cur_location ^ __afl_prev_loc]++;
__afl_prev_loc = cur_location >> 1;
}
从上述代码可知 afl_maybe_log 分为两个部分:一部分为 fork-server 负责与进行 afl-fuzz 进行通信并创建接受 fuzz 的子进程和获取共享内存;另一部分在程序的每个代码块中负责记录代码覆盖率。
- 其中
fork-server的主要逻辑如下图所示:- 采用
fork-server的原因是如果直接采用execve启动程序会将大量时间浪费在等待execve执行、链接器和库函数初始化的过程上。而启动一个程序后每次从这个程序fork一个程序就会避免前面加载程序的初始化过程,直接从当前状态继续执行,并且会借助写时复制机制确保数据与父进程隔离(类似于虚拟机恢复快照比重启系统快)。

- 采用
- 记录代码覆盖率的代码如下。afl 为每个代码块生成一个 16 位的随机数来表示该代码块,然后没执行到一个代码块就在共享内存中对应
(上一个位置 >> 1) ^ 当前位置的位置加 1 。这里右移 1 的作用是为了区别:- 代码块跳转到自身的情况(否则不同块跳转到自身的运算结果都等于 0 造成结果不准确)
- 两个块之间不同跳转方向(不同块之间跳转如果直接用位置异或表示则不能体现出跳转方向)
_afl_area_ptr[cur_location ^ __afl_prev_loc]++; __afl_prev_loc = cur_location >> 1;
插桩代码
另外一个关键问题是在哪里插桩。根据 add_instrumentation 函数可知,只要 instrument_next 置位就会在下一条指令前插入桩代码。
插入桩代码的位置是所有函数开头以及所有跳转标签开头(由于要 4 字节对齐,因此桩代码前面会有无意义代码,例如 xchg ax, ax)。
if (strstr(line, ":")) {
if (line[0] == '.') {
// 处理跳转目标标签,跳转标签格式为 ".L<num>" 或 ".LBB<num>"
if ((isdigit(line[2]) || (clang_mode && !strncmp(line, "LBB", 3))) &&
R(100) < inst_ratio) {
if (!skip_next_label) instrument_next = 1; else skip_next_label = 0;
}
} else {
// 处理函数入口点标签,例如 "main:"
instrument_next = 1;
}
}

另外还有下面几种情况不会插入桩代码:
// 判断是否需要立即插入插桩代码
if (!pass_thru && !skip_intel && !skip_app && !skip_csect && instr_ok &&
instrument_next && line[0] == '\t' && isalpha(line[1])) {
fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32, R(MAP_SIZE));
instrument_next = 0;
ins_lines++;
}
!pass_thru:透传模式 (pass-thru mode),即汇编文件不在临时目录中(说明汇编文件就是源文件而不是编译器生成的,与内联汇编一样不能改动)。如果启用,AFL 不会对代码进行任何修改或插桩,直接传递源代码。此条件确保只有在非透传模式下才尝试插桩。!skip_intel:Intel 语法跳过,某些汇编代码可能使用 Intel 语法而不是 AT&T 语法。因为插桩代码是使用 AT&T 汇编编写,Intel 语法与 AT&T 有显著不同,可能不兼容 AFL 的插桩逻辑。!skip_app:跳过内联汇编块:这些是编译器插入的原始汇编代码块。通常,这些块用于优化且不适合插桩,因为它们可能不遵循常规控制流,例如会可能会破坏插桩代码保存环境的栈。!skip_csect:跳过特定的代码段,在某些情况下,代码段(如用于特定数据的.code32或.code64段)不应被插桩。这可以是因为这些段包含特殊的处理器指令或者是非标准控制流程。instr_ok:插桩允许,这个标志用于指示当前处理的代码段是否应该接受插桩(如.text段)。通常,只有在.text段中的代码才是执行代码,适合插桩。instrument_next: 下一行插桩,这是一个特定的标志,用于延迟插桩到当前行之后的位置。这通常是因为某些标签或指令需要在具体的位置后插入跟踪代码,以确保不会破坏原有的逻辑或控制流程。line[0] == '\t' && isalpha(line[1]):代码行格式,这个条件检查确保当前行以一个制表符开头,随后是一个字母,这通常意味着这行是一个有效的汇编指令行。这样的格式检查帮助确认插桩代码被插入到实际的指令行而非注释或其他非执行代码中。
从实际情况来看,上述插桩方法存在缺陷,在一些特殊情况下插桩会有问题。
- 插桩存在冗余,比如说一个条件跳转的下一条指令恰好是另一个跳转的目标地址,那么这个地方会被插桩两次。例如下图中由于
jnz short loc_59的跳转地址与jz short loc_70的下一条指令地址重合导致该位置被插桩了两次。(因此后面又出了一个基于 LLVM Pass 的插桩)

- 由于遇到对齐代码后下一个标签会被忽略,因次一些代码块可能没有被插桩。例如下面的汇编中
.L3代码块会被忽略。
对应到实际插桩效果如下图:.p2align 4,,10 .p2align 3 .L3: .LVL1: .loc 1 13 20 is_stmt 1 discriminator 1 view .LVU7 .loc 1 13 12 is_stmt 0 discriminator 1 view .LVU8 movzx edx, BYTE PTR [rax] .loc 1 13 20 discriminator 1 view .LVU9 add rax, 1 .LVL2: .loc 1 13 20 discriminator 1 view .LVU10 cmp dl, 97 je .L3

/* 处理输入文件,生成 modified_file,将插桩代码插入合适的位置。 */
static void add_instrumentation(void) {
static u8 line[MAX_LINE]; // 定义一个数组,用来存储读取的每一行代码
FILE* inf; // 输入文件指针
FILE* outf; // 输出文件指针
s32 outfd; // 文件描述符
u32 ins_lines = 0; // 记录插入了多少行插桩代码
// 定义一些标志变量,用来控制插桩的逻辑
u8 instr_ok = 0, skip_csect = 0, skip_next_label = 0,
skip_intel = 0, skip_app = 0, instrument_next = 0;
if (input_file) {
// 如果指定了输入文件,打开文件读取
inf = fopen(input_file, "r");
if (!inf) PFATAL("Unable to read '%s'", input_file);
} else inf = stdin; // 否则从标准输入读取
// 打开输出文件,用于写入插桩后的代码
outfd = open(modified_file, O_WRONLY | O_EXCL | O_CREAT, 0600);
if (outfd < 0) PFATAL("Unable to write to '%s'", modified_file);
outf = fdopen(outfd, "w");
if (!outf) PFATAL("fdopen() failed");
// 逐行读取输入文件
while (fgets(line, MAX_LINE, inf)) {
// 判断是否需要插入插桩代码
if (!pass_thru && !skip_intel && !skip_app && !skip_csect && instr_ok &&
instrument_next && line[0] == '\t' && isalpha(line[1])) {
fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32, R(MAP_SIZE));
instrument_next = 0;
ins_lines++;
}
fputs(line, outf); // 输出当前行
if (pass_thru) continue; // 如果是透传模式,直接继续下一行
// 检测是否进入了.text段,这是大部分需要插桩的代码所在段
if (line[0] == '\t' && line[1] == '.') {
if (!clang_mode && instr_ok && !strncmp(line + 2, "p2align ", 8) &&
isdigit(line[10]) && line[11] == '\n') skip_next_label = 1; // 对于指令对齐的部分需要跳过直到下一个标签才能开始插桩
if (!strncmp(line + 2, "text\n", 5) ||
!strncmp(line + 2, "section\t.text", 13) ||
!strncmp(line + 2, "section\t__TEXT,__text", 21) ||
!strncmp(line + 2, "section __TEXT,__text", 21)) {
instr_ok = 1;
continue;
}
if (!strncmp(line + 2, "section\t", 8) ||
!strncmp(line + 2, "section ", 8) ||
!strncmp(line + 2, "bss\n", 4) ||
!strncmp(line + 2, "data\n", 5)) {
instr_ok = 0;
continue;
}
}
// 处理特殊汇编指令,跳过不适合插桩的部分
if (strstr(line, ".code")) {
if (strstr(line, ".code32")) skip_csect = use_64bit;
if (strstr(line, ".code64")) skip_csect = !use_64bit;
}
// 跳过 Intel 语法的代码,回到 AT&T 语法继续插桩
if (strstr(line, ".intel_syntax")) skip_intel = 1;
if (strstr(line, ".att_syntax")) skip_intel = 0;
// 跳过 ad-hoc 的 __asm__ 块
if (line[0] == '#' || line[1] == '#') {
if (strstr(line, "#APP")) skip_app = 1;
if (strstr(line, "#NO_APP")) skip_app = 0;
}
// 识别并插桩条件分支等关键位置
if (skip_intel || skip_app || skip_csect || !instr_ok ||
line[0] == '#' || line[0] == ' ') continue;
if (line[0] == '\t') {
if (line[1] == 'j' && line[2] != 'm' && R(100) < inst_ratio) {
fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32, R(MAP_SIZE));
ins_lines++;
}
continue;
}
if (strstr(line, ":")) {
if (line[0] == '.') {
// 处理跳转目标标签,跳转标签格式为 ".L<num>" 或 ".LBB<num>"
if ((isdigit(line[2]) || (clang_mode && !strncmp(line, "LBB", 3))) &&
R(100) < inst_ratio) {
if (!skip_next_label) instrument_next = 1; else skip_next_label = 0;
}
} else {
// 处理函数入口点标签,例如 "main:"
instrument_next = 1;
}
}
}
// 如果插入了插桩代码,写入 afl_maybe_log 函数
if (ins_lines)
fputs(use_64bit ? main_payload_64 : main_payload_32, outf);
// 关闭文件
if (input_file) fclose(inf);
fclose(outf);
// 如果不是静默模式,输出插桩信息
if (!be_quiet) {
if (!ins_lines) WARNF("No instrumentation targets found%s.", pass_thru ? " (pass-thru mode)" : "");
else OKF("Instrumented %u locations (%s-bit, %s mode, ratio %u%%).",
ins_lines, use_64bit ? "64" : "32",
getenv("AFL_HARDEN") ? "hardened" : (sanitizer ? "ASAN/MSAN" : "non-hardened"),
inst_ratio);
}
}
afl-tmin
afl-tmin 用于最小化模糊测试用例。它通过减少触发特定行为所需的输入大小来简化测试案例。这对于调试和理解触发错误的特定条件非常有用。
main
main 函数主要逻辑如下:
- 根据
argv,设置变量。这包括原始 input 位置、优化结果的输出位置、目标程序运行时的输入文件该放在哪里(如果不是从stdin输入的话)、是否只考虑 edge 覆盖率而不考虑 hit count、是否将非 0 返回码视为 crash、程序内存限制、程序运行时间限制、是否使用 qemu 模式,以及一个神秘的-B选项,具体用途见注释。 - 初始化 shm。
- 设置几个 signal handler,例如当用户按下 Ctrl+C 时,要优雅地结束程序。
- 处理环境变量。这逻辑包括:若用户没有指定
-f,那就去TMPDIR找个地方,存放将要输入给程序的文件(文件位置记录在全局变量prog_in中);设置ASAN和MSAN相关的环境变量;若存在环境变量AFL_PRELOAD,则设置LD_PRELOAD。 - 分析我们将要传递给目标程序的
argv(也就是afl-tmin自己的命令行中,--之后的那部分);如果有@@,则把它覆写为输入文件位置prog_in。 - 设置 qemu 相关参数。与我们无关。
- 看一眼初始 input 文件。如果大于 10MB,就喷我们一句「Input file is too large」。
- 调用
run_target()进行一次 dry run。主要看是否超时、是否 crash。 - 调用
minimize()执行优化。 - 删除
prog_in文件并输出优化结果。
// 主程序入口
int main(int argc, char** argv) {
s32 opt; // 用于存储命令行参数解析结果
u8 mem_limit_given = 0, timeout_given = 0, qemu_mode = 0; // 标记是否已指定内存限制、超时、QEMU模式
char** use_argv; // 存储最终传递给目标程序的参数
// 检查文档路径是否存在,选择正确的文档路径
doc_path = access(DOC_PATH, F_OK) ? "docs" : DOC_PATH;
// 打印程序的版本信息
SAYF(cCYA "afl-tmin " cBRI VERSION cRST " by <lcamtuf@google.com>\n");
// 解析命令行参数
while ((opt = getopt(argc, argv, "+i:o:f:m:t:B:xeQV")) > 0)
switch (opt) {
case 'i': // 输入文件选项
if (in_file) FATAL("Multiple -i options not supported");
in_file = optarg;
break;
case 'o': // 输出文件选项
if (out_file) FATAL("Multiple -o options not supported");
out_file = optarg;
break;
case 'f': // 指定输入文件,而非使用标准输入
if (prog_in) FATAL("Multiple -f options not supported");
use_stdin = 0;
prog_in = optarg;
break;
case 'e': // 不统计边的数量
if (edges_only) FATAL("Multiple -e options not supported");
edges_only = 1;
break;
case 'x': // 退出时崩溃
if (exit_crash) FATAL("Multiple -x options not supported");
exit_crash = 1;
break;
case 'm': // 内存限制
if (mem_limit_given) FATAL("Multiple -m options not supported");
mem_limit_given = 1;
if (!strcmp(optarg, "none")) {
mem_limit = 0;
break;
}
// 解析内存大小和单位
u8 suffix = 'M';
if (sscanf(optarg, "%llu%c", &mem_limit, &suffix) < 1 || optarg[0] == '-')
FATAL("Bad syntax used for -m");
switch (suffix) {
case 'T': mem_limit *= 1024 * 1024; break;
case 'G': mem_limit *= 1024; break;
case 'k': mem_limit /= 1024; break;
case 'M': break;
default: FATAL("Unsupported suffix or bad syntax for -m");
}
if (mem_limit < 5) FATAL("Dangerously low value of -m");
if (sizeof(rlim_t) == 4 && mem_limit > 2000)
FATAL("Value of -m out of range on 32-bit systems");
break;
case 't': // 设置超时
if (timeout_given) FATAL("Multiple -t options not supported");
timeout_given = 1;
exec_tmout = atoi(optarg);
if (exec_tmout < 10 || optarg[0] == '-')
FATAL("Dangerously low value of -t");
break;
case 'Q': // 启动 QEMU 模式
if (qemu_mode) FATAL("Multiple -Q options not supported");
if (!mem_limit_given) mem_limit = MEM_LIMIT_QEMU;
qemu_mode = 1;
break;
case 'B': // 加载外部位图文件,用于更精确的覆盖率测试
if (mask_bitmap) FATAL("Multiple -B options not supported");
mask_bitmap = ck_alloc(MAP_SIZE);
read_bitmap(optarg);
break;
case 'V': // 显示版本号并退出
exit(0);
default:
usage(argv[0]);
}
// 检查是否所有必要的选项都已提供
// 因为 afl-tmin 的用法是 ./afl-tmin [ options ] -- /path/to/target_app [ ... ]
// 所以除了 argc 个参数外还要有 /path/to/target_app [ ... ] 部分,因此 optind == argc 是不满足条件的。
if (optind == argc || !in_file || !out_file) usage(argv[0]);
setup_shm(); // 设置共享内存
setup_signal_handlers(); // 设置信号处理器
set_up_environment(); // 配置环境变量等
find_binary(argv[optind]); // 查找并确认目标二进制文件
detect_file_args(argv + optind); // 特判文件名为 @@ 的情况,这种情况需要把它覆写为输入文件位置 prog_in
// 根据是否在 QEMU 模式下运行来准备参数
if (qemu_mode)
use_argv = get_qemu_argv(argv[0], argv + optind, argc - optind);
else
use_argv = argv + optind;
exact_mode = !!getenv("AFL_TMIN_EXACT"); // 确定是否在精确模式下运行
read_initial_file(); // 读取并处理初始输入文件
// 运行一次目标程序,检查基本的运行情况
ACTF("Performing dry run (mem limit = %llu MB, timeout = %u ms%s)...",
mem_limit, exec_tmout, edges_only ? ", edges only" : "");
run_target(use_argv, in_data, in_len, 1); // 运行目标程序
// 如果目标程序超时,提示可能需要调整超时设置
if (child_timed_out)
FATAL("Target binary times out (adjusting -t may help).");
// 根据程序的运行结果,决定使用哪种模式继续最小化
if (!crash_mode) {
OKF("Program terminates normally, minimizing in "
cCYA "instrumented" cRST " mode.");
if (!anything_set()) FATAL("No instrumentation detected.");
} else {
OKF("Program exits with a signal, minimizing in " cMGN "%scrash" cRST
" mode.", exact_mode ? "EXACT " : "");
}
minimize(use_argv); // 开始最小化处理
ACTF("Writing output to '%s'...", out_file); // 输出最小化后的结果
unlink(prog_in); // 清理临时文件
prog_in = NULL;
close(write_to_file(out_file, in_data, in_len)); // 关闭并保存输出文件
OKF("We're done here. Have a nice day!\n"); // 完成最小化处理
exit(0); // 退出程序
}
setup_shm
/* 程序退出时调用的函数,用于清理共享内存和临时文件。 */
static void remove_shm(void) {
// 如果指定了输入文件路径,并尝试删除该文件,忽略可能发生的错误
if (prog_in) unlink(prog_in);
// 控制共享内存,选择删除操作,不需要传递额外的结构指针
shmctl(shm_id, IPC_RMID, NULL);
}
/* 设置共享内存用于存储程序的执行轨迹或代码覆盖信息。 */
static void setup_shm(void) {
u8* shm_str; // 用于存储共享内存标识符的字符串
// 创建共享内存段,大小为 MAP_SIZE,模式为只有创建者有访问权限
shm_id = shmget(IPC_PRIVATE, MAP_SIZE, IPC_CREAT | IPC_EXCL | 0600);
// 如果创建失败,打印错误并退出
if (shm_id < 0) PFATAL("shmget() failed");
// 注册退出时的回调函数,以确保在程序退出时清理共享内存
atexit(remove_shm);
// 将共享内存标识符转换为字符串,以便可以通过环境变量传递给其他进程
shm_str = alloc_printf("%d", shm_id);
// 设置环境变量 SHM_ENV_VAR(通常是 __AFL_SHM_ID),将共享内存标识符传递给被测程序
setenv(SHM_ENV_VAR, shm_str, 1);
// 释放用于存储标识符的字符串内存
ck_free(shm_str);
// 将共享内存段附加到当前进程的地址空间
trace_bits = shmat(shm_id, NULL, 0);
// 如果附加失败,打印错误并退出
if (trace_bits == (void *)-1) PFATAL("shmat() failed");
}
run_target
run_target(char** argv, u8* mem, u32 len, u8 first_run) 函数主要用来验证代码执行路径是否发生变化。函数参数解释如下:
argv:目标应用程序的命令行参数数组。mem:指向当前测试用例(输入数据)的指针。len:测试用例的长度。first_run:标识是否为首次运行。首次运行可能具有特殊逻辑,如初始化操作。
run_target 函数主要结构如下:
/* Execute target application. Returns 0 if the changes are a dud, or
1 if they should be kept. */
static u8 run_target(char** argv, u8* mem, u32 len, u8 first_run) {
// ... 运行前的准备 ...
child_pid = fork();
if (child_pid < 0) PFATAL("fork() failed");
if (!child_pid) {
// ... 子进程部分 ...
}
// ... 父进程部分 ...
// ... 等待子进程结束或超时 ...
if (waitpid(child_pid, &status, 0) <= 0) FATAL("waitpid() failed");
// ... 分析 trace_bits ...
cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST);
}
运行前准备
fork 之前的准备逻辑如下:
- 把共享内存清零,这里的
trace_bits就是共享内存区域。 - 将 input 内容写进
prog_in文件中,记录fd。由于父子进程共享fd,故子进程也可以使用这个fd。- 这段代码调用了
write_to_file函数,它的作用是删除prog_in文件、重新创建prog_in文件并以rw模式打开,所以父进程写入、子进程读取是没问题的。
- 这段代码调用了
static u8 run_target(char** argv, u8* mem, u32 len, u8 first_run) {
static struct itimerval it; // 用于配置超时的结构体
int status = 0; // 用来存储子进程的退出状态
s32 prog_in_fd; // 文件描述符,用于写入输入数据到临时文件
u32 cksum; // 用于计算当前执行轨迹的校验和
// 初始化覆盖位图,也就是 setup_shm 初始化的共享内存
memset(trace_bits, 0, MAP_SIZE);
MEM_BARRIER(); // 内存屏障,确保前面的内存操作已完成
// 将内存缓冲区 mem 的内容写入到输入文件
prog_in_fd = write_to_file(prog_in, mem, len);
// 创建子进程来执行目标应用程序
child_pid = fork();
if (child_pid < 0) PFATAL("fork() failed");
// 子进程代码逻辑
if (!child_pid) {
...
}
// 父进程逻辑
...
子进程
fork 之后的逻辑如下。
- 如果目标程序是
stdin输入的,则将stdin重定向到prog_in_fd,否则将 stdin 重定向到/dev/null。另外,将stdout和stderr重定向到/dev/null。 - 创建新的进程组,自己为 leader。
- 如果指定了内存限制,则配置相应的资源限制。
execv执行目标程序。- 若
execv调用失败,则把共享内存的前四个字节设为0xfee1dead并退出。
于是,父进程只需要监控子进程的退出状态,即可知道目标程序是否崩溃;另外,从共享内存的前 4 字节,能得知 execv 调用是否失败。
// 子进程代码逻辑
if (!child_pid) {
struct rlimit r;
// 尝试重定向 stdin, stdout 和 stderr 到适当的文件描述符。
if (dup2(use_stdin ? prog_in_fd : dev_null_fd, 0) < 0 ||
dup2(dev_null_fd, 1) < 0 ||
dup2(dev_null_fd, 2) < 0) {
// 如果重定向失败,记录错误信号到 trace_bits 中,并输出错误信息后退出。
*(u32*)trace_bits = EXEC_FAIL_SIG;
PFATAL("dup2() failed");
}
// 关闭不再需要的文件描述符,释放资源。
close(dev_null_fd);
close(prog_in_fd);
// 创建新的会话,并设置当前子进程为会话领导,脱离控制终端。
setsid();
// 如果指定了内存限制,则配置相应的资源限制。
if (mem_limit) {
r.rlim_max = r.rlim_cur = ((rlim_t)mem_limit) << 20; // 将内存限制单位转换为字节
#ifdef RLIMIT_AS
setrlimit(RLIMIT_AS, &r); // 设置进程的最大地址空间大小
#else
setrlimit(RLIMIT_DATA, &r); // 设置进程数据段的最大大小
#endif
}
// 禁用核心转储以避免在发生崩溃时生成核心文件。
r.rlim_max = r.rlim_cur = 0;
setrlimit(RLIMIT_CORE, &r);
// 尝试执行目标应用程序。
execv(target_path, argv);
// 如果 execv 返回,说明发生错误,记录失败信号并退出。
*(u32*)trace_bits = EXEC_FAIL_SIG;
exit(0);
}
父进程
根据子进程的逻辑可知,父进程只需要监控子进程的退出状态,即可知道目标程序是否崩溃;另外,从共享内存的前 4 字节,能得知 execv 调用是否失败。父进程做的事情是:
- 给子进程定时,并等待子进程结束。
- 对 hit count 分桶,并计算 hash,与原始 input 的运行 hash 比对。若一致,则本 input 有效。
// 父进程逻辑
close(prog_in_fd); // 关闭输入文件的文件描述符。
// 设置超时定时器。
child_timed_out = 0;
it.it_value.tv_sec = (exec_tmout / 1000);
it.it_value.tv_usec = (exec_tmout % 1000) * 1000;
setitimer(ITIMER_REAL, &it, NULL);
// 等待子进程完成,检查是否有错误。
if (waitpid(child_pid, &status, 0) <= 0) PFATAL("waitpid() failed");
// 清除定时器。
child_pid = 0;
it.it_value.tv_sec = 0;
it.it_value.tv_usec = 0;
setitimer(ITIMER_REAL, &it, NULL);
MEM_BARRIER(); // 内存屏障,确保之前的操作已经完成。
// 检查是否因为执行失败而退出。
if (*(u32*)trace_bits == EXEC_FAIL_SIG)
FATAL("Unable to execute '%s'", argv[0]);
// 分类并应用覆盖率掩码,更新统计。
classify_counts(trace_bits);
apply_mask((u32*)trace_bits, (u32*)mask_bitmap);
total_execs++;
// 如果用户请求停止,终止执行。
if (stop_soon) {
SAYF(cRST cLRD "\n+++ Minimization aborted by user +++\n" cRST);
close(write_to_file(out_file, in_data, in_len));
exit(1);
}
// 处理执行超时情况,忽略此次结果。
if (child_timed_out) {
missed_hangs++;
return 0;
}
// 处理由信号导致的退出,如果是第一次发现崩溃,设置崩溃模式。
if (WIFSIGNALED(status) ||
(WIFEXITED(status) && WEXITSTATUS(status) == MSAN_ERROR) ||
(WIFEXITED(status) && WEXITSTATUS(status) && exit_crash)) {
// 若发现目标程序 crash
// 如果是初次运行就 crash,说明该使用 crash mode
if (first_run) crash_mode = 1;
if (crash_mode) {
// 如果是 crash mode,且不要求 crash 路径与原 input 相同,则立即报告 input 有效,该保留
if (!exact_mode) return 1;
} else {
// 是 non-crash mode,但现在 crash 了,说明这个 input 与原 input 路径不同,该丢弃
missed_crashes++;
return 0;
}
}
// 发现目标程序没有 crash,而目前处于 crash mode,则本 input 与原 input 路径不同,该丢弃
if (crash_mode) {
missed_paths++;
return 0;
}
// 计算当前执行的轨迹的哈希值,检查是否与第一次执行的哈希值相同。
cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST);
if (first_run) orig_cksum = cksum;
if (orig_cksum == cksum) return 1; // 如果哈希值相同,保留这次的输入。
missed_paths++;
return 0; // 如果哈希值不同,丢弃这次的输入。
其中子进程超时会发送 SIGALRM 信号,而在 setup_signal_handlers 函数中该信号注册的处理函数为 handle_timeout 。handle_timeout 会杀死 child_pid 进程导致父进程 waitpid 函数返回,并且将 child_timed_out 置为 1 表示子进程超时。
/* Handle timeout signal. */
static void handle_timeout(int sig) {
child_timed_out = 1;
if (child_pid > 0) kill(child_pid, SIGKILL);
}
classify_counts 函数负责将 hit count 分桶。
/* Classify tuple counts. This is a slow & naive version, but good enough here. */
static const u8 count_class_lookup[256] = {
[0] = 0,
[1] = 1,
[2] = 2,
[3] = 4,
[4 ... 7] = 8,
[8 ... 15] = 16,
[16 ... 31] = 32,
[32 ... 127] = 64,
[128 ... 255] = 128
};
static void classify_counts(u8* mem) {
u32 i = MAP_SIZE;
if (edges_only) {
// 若打开 -e 开关,则只要命中,无论命中多少次,都视为相同。
while (i--) {
if (*mem) *mem = 1;
mem++;
}
} else {
// 将 hit count 改为其所在的桶 id
while (i--) {
*mem = count_class_lookup[*mem];
mem++;
}
}
}
想看懂以上代码的意图,需要先了解 AFL 的 hit count 分桶机制(可参考 AFL 白皮书)。假如我们现在要设计一个灰盒 fuzzer,现在有一个 input 走了某条边 100 次,另一个 input 走了 101 次,我们要不要认为这两个 input 的执行路径本质不同呢?如果认为这算是不同的路径,那么 corpus 数量立刻会爆炸——因为修改 input 有极大概率变更某个循环的执行次数,从而被认为与先前的 input 是本质不同的。另一方面,假如我们认为一条边只有「被走过」和「未被走过」两种状态,那么 fuzzer 将会严重丧失搜索能力。例如,假如目标程序中有一句 strcmp(input, "abcd"),则 fuzzer 能发现 a*** 是有趣的,但它变异出 ab** 之后,会认为它与前述输入路径相同,于是丢弃这个变异。只有等到该 fuzzer 变异出完整的 abcd,它才会认为这是有趣的——但这概率实在太低了。
因此,AFL 的思路是这两个方案的折中——既要避免 corpus 爆炸,又要在循环次数增加的过程中给一些奖励。所以,AFL 设计了 8 个 hit count 桶:
1, 2, 3, 4-7, 8-15, 16-31, 32-127, 128+
如果对于两个 input,某条边的 hit count 在同一个桶内,则认为这两个 hit count 没有本质区别。因此,AFL 认为循环次数 0、1、2、3、4 是有本质区别的,7 次与 8 次也有本质区别,但 100 次与 101 次没有本质区别。
说到这里,classify_counts 函数的意图就很明确了:若没有打开 -e 开关,则将 hit count 分桶;若打开了 -e 开关,则只关心是否命中,不关心命中多少次。
classify_counts() 过程结束后,本质相同的 input,其生成的 shm 也相同了。接下来, apply_mask() 过程是配合 -B 开关用的,只留下我们所关心的那些 edge 的 hit count 信息。由于正常情况下,我们关心所有 edge,故 apply_mask() 对我们没有影响。
对应 hash32 函数,由于 shm 的大小是 65536 字节。如果将 shm 的全文都保留下来用于比较,会产生很大的开销(afl-fuzz 运行时,要把新 input 的运行路径与所有 corpus 的运行路径相比较)。因此,我们不妨用 shm 的「消息摘要」来代替它的全文。AFL 采用的 hash32() 是一个自创的简单算法,我们无需关心其细节。总之,它从 65536 字节的 shm 中,计算出 4 字节的消息摘要,用来代表这个 input 的执行路径。如果本 input 的 hash 与原始 input 相同,则本 input 是有效的;否则丢弃本 input。
minimize
minimize 函数的结构如下:
/* Actually minimize! */
static void minimize(char** argv) {
// ... BLOCK NORMALIZATION
next_pass:
// ... BLOCK DELETION
// ... ALPHABET MINIMIZATION
// ... CHARACTER MINIMIZATION
if (changed_any) goto next_pass;
finalize_all:
// 输出结果
}
也就是说,这个函数会先做一遍 block normalization,然后循环执行 block deletion、alphabet minimization、character minimization 三个优化,直到无法再进一步优化为止。
block normalization
首先,输入被划分为大约 128 个块(除了最后一个块外,块长度为 2 的幂次)。对于每个块,afl-tmin 尝试将其改为全 '0',若其运行路径与原始 input 相同,则保留该更改。
block normalization 的意义在于,如果 input 中有一部分内容是不敏感的(例如,一个负责报告 bmp 图片尺寸的程序,显然不会关心像素颜色),则这部分不敏感内容块会被以 '0' 代替。这对后续进行的优化有好处。
/***********************
* BLOCK NORMALIZATION *
***********************/
// 计算每次处理的数据块大小,大小是输入长度除以定义的步长的下一个幂次方
set_len = next_p2(in_len / TMIN_SET_STEPS);
set_pos = 0; // 初始化数据块的起始位置
// 确保每个数据块的大小不小于最小尺寸
if (set_len < TMIN_SET_MIN_SIZE) set_len = TMIN_SET_MIN_SIZE;
// 打印开始块归一化的消息
ACTF(cBRI "Stage #0: " cRST "One-time block normalization...");
// 遍历整个输入数据
while (set_pos < in_len) {
u8 res;
// 计算当前块的使用长度,不能超过剩余的输入长度
u32 use_len = MIN(set_len, in_len - set_pos);
// 检查当前块内的数据是否全为 '0'
for (i = 0; i < use_len; i++)
if (in_data[set_pos + i] != '0') break;
// 如果当前块不全是 '0'
if (i != use_len) {
// 复制整个输入到临时缓冲区
memcpy(tmp_buf, in_data, in_len);
// 将当前块的内容设置为 '0'
memset(tmp_buf + set_pos, '0', use_len);
// 运行目标程序检查更改后是否有影响
res = run_target(argv, tmp_buf, in_len, 0);
// 如果更改后程序行为未变(res 为 true),应用这个更改
if (res) {
memset(in_data + set_pos, '0', use_len);
changed_any = 1;
alpha_del0 += use_len; // 记录成功替换的字节数
}
}
// 移动到下一个块的位置
set_pos += set_len;
}
// 累加总共替换的字节数
alpha_d_total += alpha_del0;
// 完成块归一化的消息,显示替换的总字节
OKF("Block normalization complete, %u byte%s replaced.", alpha_del0,
alpha_del0 == 1 ? "" : "s");
// 跳转到下一个处理阶段
next_pass:
// 打印进入下一阶段的消息
ACTF(cYEL "--- " cBRI "Pass #%u " cYEL "---", ++cur_pass);
changed_any = 0; // 重置改变标志
block deletion
首先将 input 分为 16 个块,然后从前往后尝试删除每一个块。结束一轮之后,将块大小减半,再尝试上述步骤,直到块大小为 1。
另外,有一个小小的加速逻辑:从前往后删除块的过程中,若前一个块不可删除,且这个块与前一个块一模一样,则直接推断这个块也不可删除,不必去实际运行程序。由于 block normalization 过程过程产生了大量的连续 '0',故这个加速逻辑是可以取得一定效果的。
/******************
* BLOCK DELETION *
******************/
// 设置初始删除块的大小为输入长度与启动步骤的商的下一个幂次方
del_len = next_p2(in_len / TRIM_START_STEPS);
stage_o_len = in_len; // 记录初始长度以便于计算总共删除了多少字节
ACTF(cBRI "Stage #1: " cRST "Removing blocks of data...");
next_del_blksize:
// 如果计算的删除长度为零,则设置为最小长度 1
if (!del_len) del_len = 1;
del_pos = 0; // 初始化删除的起始位置
prev_del = 1; // 表示前一个块是否被删除
// 打印当前处理的块长度和剩余的文件大小
SAYF(cGRA " Block length = %u, remaining size = %u\n" cRST,
del_len, in_len);
while (del_pos < in_len) { // 循环遍历文件内容,尝试删除数据
u8 res;
s32 tail_len;
// 计算剩余未处理部分的长度
tail_len = in_len - del_pos - del_len;
if (tail_len < 0) tail_len = 0;
// 检查是否需要跳过当前块:如果当前块与前一个块相同,并且前一个块未被删除则当前块也不删除
if (!prev_del && tail_len && !memcmp(in_data + del_pos - del_len,
in_data + del_pos, del_len)) {
del_pos += del_len;
continue;
}
prev_del = 0;
// 创建临时缓冲区,包括头部和尾部数据,但跳过当前块
memcpy(tmp_buf, in_data, del_pos); // 复制头部
memcpy(tmp_buf + del_pos, in_data + del_pos + del_len, tail_len); // 复制尾部
// 运行目标应用程序检查删除后的数据是否影响程序执行
res = run_target(argv, tmp_buf, del_pos + tail_len, 0);
if (res) { // 如果删除有效,则更新原始数据
memcpy(in_data, tmp_buf, del_pos + tail_len);
prev_del = 1; // 标记发生了改变
in_len = del_pos + tail_len; // 更新当前数据长度
changed_any = 1; // 标记发生了改变
} else { // 如果删除无效,则移动到下一个块
del_pos += del_len;
}
}
// 如果块的大小大于1并且仍然有剩余数据,则减少块大小并重新尝试
if (del_len > 1 && in_len >= 1) {
del_len /= 2;
goto next_del_blksize; // 返回标签以用更小的块大小再次尝试
}
// 完成删除操作,打印删除的字节数
OKF("Block removal complete, %u bytes deleted.", stage_o_len - in_len);
// 如果文件大小减小到0,并且发生了改变,发出警告
if (!in_len && changed_any)
WARNF(cLRD "Down to zero bytes - check the command line and mem limit!" cRST);
// 如果这不是第一轮,并且没有发生任何改变,则完成最小化处理
if (cur_pass > 1 && !changed_any) goto finalize_all;
alphabet minimization
这是一个非常简单的优化,旨在缩小 input 文件的字符集。其逻辑是:对于每种字符(共有 256 种),尝试在 input 中将其替换为 '0'。
/*************************
* ALPHABET MINIMIZATION *
*************************/
// 初始化统计数组,alpha_map 用于记录每个字符的出现次数
alpha_size = 0; // 记录不同字符的数量
alpha_del1 = 0; // 记录本阶段总共替换掉的字符数
syms_removed = 0; // 记录总共替换掉的不同字符种类
memset(alpha_map, 0, 256 * sizeof(u32)); // 清空字符计数数组
// 计算输入数据中每个字符的出现次数,并统计不同字符的数量
for (i = 0; i < in_len; i++) {
if (!alpha_map[in_data[i]]) alpha_size++; // 如果是首次遇到该字符,增加不同字符计数
alpha_map[in_data[i]]++; // 对该字符的出现次数累加
}
// 输出开始符号缩减的消息
ACTF(cBRI "Stage #2: " cRST "Minimizing symbols (%u code point%s)...",
alpha_size, alpha_size == 1 ? "" : "s");
// 遍历可能的所有字符
for (i = 0; i < 256; i++) {
u32 r;
u8 res;
// 如果当前字符是 '0' 或从未在输入数据中出现过,跳过
if (i == '0' || !alpha_map[i]) continue;
// 将输入数据复制到临时缓冲区
memcpy(tmp_buf, in_data, in_len);
// 尝试将所有当前字符 (i) 替换为 '0'
for (r = 0; r < in_len; r++)
if (tmp_buf[r] == i) tmp_buf[r] = '0';
// 运行目标程序,查看替换后是否影响程序行为
res = run_target(argv, tmp_buf, in_len, 0);
// 如果替换有效,则将这种替换应用到实际输入数据中
if (res) {
memcpy(in_data, tmp_buf, in_len);
syms_removed++; // 增加替换掉的不同字符种类计数
alpha_del1 += alpha_map[i]; // 增加本字符替换掉的总数
changed_any = 1; // 标记本轮有有效的改变
}
}
// 累加到总的字符替换数
alpha_d_total += alpha_del1;
// 输出符号缩减完成的消息,显示本阶段替换掉的符号种类和字符数
OKF("Symbol minimization finished, %u symbol%s (%u byte%s) replaced.",
syms_removed, syms_removed == 1 ? "" : "s",
alpha_del1, alpha_del1 == 1 ? "" : "s");
character minimization
从前往后扫描 input 中的每个字节,尝试将其替换为 '0'。
/**************************
* CHARACTER MINIMIZATION *
**************************/
alpha_del2 = 0; // 初始化此阶段替换的字符数计数
// 输出开始字符最小化的消息
ACTF(cBRI "Stage #3: " cRST "Character minimization...");
// 将当前的输入数据复制到临时缓冲区,以便进行试验性修改
memcpy(tmp_buf, in_data, in_len);
// 遍历输入数据中的每个字符
for (i = 0; i < in_len; i++) {
u8 res, orig = tmp_buf[i]; // 保存原始字符
// 如果原始字符已经是 '0',跳过,无需再次替换
if (orig == '0') continue;
// 尝试将当前位置的字符替换为 '0'
tmp_buf[i] = '0';
// 执行目标程序,检查替换后是否影响了程序的行为
res = run_target(argv, tmp_buf, in_len, 0);
// 如果替换后程序行为没有改变(即替换是有效的)
if (res) {
// 将实际输入数据中的字符更新为 '0'
in_data[i] = '0';
// 更新替换计数
alpha_del2++;
// 标记本轮有有效的改变
changed_any = 1;
} else {
// 如果替换无效,恢复原始字符
tmp_buf[i] = orig;
}
}
// 将此阶段的字符替换总数累加到总计数中
alpha_d_total += alpha_del2;
// 输出字符最小化完成的消息,显示替换掉的字符数
OKF("Character minimization done, %u byte%s replaced.",
alpha_del2, alpha_del2 == 1 ? "" : "s");
// 如果在本轮有任何有效的字符替换,可能需要再次尝试其他优化阶段
if (changed_any) goto next_pass;
afl-showmap
afl-showmap 用于运行一遍目标程序,并以「人类可读的方式」展示 hit count 。
main
main 函数主要逻辑为:
- 设置 shm 和一些环境变量
- 调用
run_target执行一遍目标程序 - 调用
write_results把结果写进文件。
/* 主函数入口 */
int main(int argc, char** argv) {
s32 opt; // 用于存储当前解析的命令行选项
u8 mem_limit_given = 0, timeout_given = 0, qemu_mode = 0; // 标志变量,记录是否已指定内存限制、超时和QEMU模式
u32 tcnt; // 用于存储处理结果的计数
char** use_argv; // 指向实际传递给目标程序的参数数组
// 检查文档路径是否可访问,如果不可访问则使用默认路径
doc_path = access(DOC_PATH, F_OK) ? "docs" : DOC_PATH;
// 解析命令行选项
while ((opt = getopt(argc, argv, "+o:m:t:A:eqZQbcV")) > 0) {
switch (opt) {
case 'o': // 指定输出文件
if (out_file) FATAL("Multiple -o options not supported");
out_file = optarg;
break;
case 'm': // 指定内存限制
{
u8 suffix = 'M';
if (mem_limit_given) FATAL("Multiple -m options not supported");
mem_limit_given = 1;
if (!strcmp(optarg, "none")) {
mem_limit = 0;
break;
}
if (sscanf(optarg, "%llu%c", &mem_limit, &suffix) < 1 || optarg[0] == '-') {
FATAL("Bad syntax used for -m");
}
switch (suffix) { // 将单位统一转化为 MB
case 'T': mem_limit *= 1024 * 1024; break;
case 'G': mem_limit *= 1024; break;
case 'k': mem_limit /= 1024; break;
case 'M': break;
default: FATAL("Unsupported suffix or bad syntax for -m");
}
if (mem_limit < 5) FATAL("Dangerously low value of -m");
if (sizeof(rlim_t) == 4 && mem_limit > 2000) {
FATAL("Value of -m out of range on 32-bit systems");
}
}
break;
case 't': // 指定执行超时时间
if (timeout_given) FATAL("Multiple -t options not supported");
timeout_given = 1;
if (strcmp(optarg, "none")) {
exec_tmout = atoi(optarg);
if (exec_tmout < 20 || optarg[0] == '-') {
FATAL("Dangerously low value of -t");
}
}
break;
case 'e': // 不统计边的数量
if (edges_only) FATAL("Multiple -e options not supported");
edges_only = 1;
break;
case 'q': // 静默模式
if (quiet_mode) FATAL("Multiple -q options not supported");
quiet_mode = 1;
break;
case 'Z': // 未记录模式,为 afl-cmin 准备的特殊模式
cmin_mode = 1;
quiet_mode = 1;
break;
case 'A': // 另一个专门为 afl-cmin 设计的选项
at_file = optarg;
break;
case 'Q': // 启用 QEMU 模式
if (qemu_mode) FATAL("Multiple -Q options not supported");
if (!mem_limit_given) mem_limit = MEM_LIMIT_QEMU;
qemu_mode = 1;
break;
case 'b': // 二进制模式,输出格式与 afl-fuzz 的 fuzz_bitmap 类似
binary_mode = 1;
break;
case 'c': // 保持核心转储文件
if (keep_cores) FATAL("Multiple -c options not supported");
keep_cores = 1;
break;
case 'V': // 显示版本号并退出
show_banner();
exit(0);
default: // 无效的命令行选项
usage(argv[0]);
}
}
// 检查是否所有必要的参数都已指定
if (optind == argc || !out_file) usage(argv[0]);
// 设置共享内存、信号处理和环境变量
setup_shm();
setup_signal_handlers();
set_up_environment();
// 确定要执行的二进制文件路径
find_binary(argv[optind]);
// 如果非静默模式,显示程序执行信息
if (!quiet_mode) {
show_banner();
ACTF("Executing '%s'...\n", target_path);
}
// 分析命令行参数以确定文件参数
detect_file_args(argv + optind);
// 根据是否在 QEMU 模式下调整参数数组
if (qemu_mode)
use_argv = get_qemu_argv(argv[0], argv + optind, argc - optind);
else
use_argv = argv + optind;
// 运行目标程序
run_target(use_argv);
// 写入执行结果
tcnt = write_results();
// 如果非静默模式,显示执行总结
if (!quiet_mode) {
if (!tcnt) FATAL("No instrumentation detected");
OKF("Captured %u tuples in '%s'.", tcnt, out_file);
}
// 根据子进程是否崩溃或超时来决定退出状态
exit(child_crashed * 2 + child_timed_out);
}
hit count 分桶
afl-showmap 的大部分函数与 afl-tmin 中的几乎一样,不过 run_target 过程中,给 hit count 分桶的逻辑有细微区别:
/* Classify tuple counts. Instead of mapping to individual bits, as in
afl-fuzz.c, we map to more user-friendly numbers between 1 and 8. */
static const u8 count_class_human[256] = {
[0] = 0,
[1] = 1,
[2] = 2,
[3] = 3,
[4 ... 7] = 4,
[8 ... 15] = 5,
[16 ... 31] = 6,
[32 ... 127] = 7,
[128 ... 255] = 8
};
static const u8 count_class_binary[256] = {
[0] = 0,
[1] = 1,
[2] = 2,
[3] = 4,
[4 ... 7] = 8,
[8 ... 15] = 16,
[16 ... 31] = 32,
[32 ... 127] = 64,
[128 ... 255] = 128
};
static void classify_counts(u8* mem, const u8* map) {
u32 i = MAP_SIZE;
if (edges_only) {
while (i--) {
if (*mem) *mem = 1;
mem++;
}
} else {
while (i--) {
*mem = map[*mem];
mem++;
}
}
}
可见,在分桶时,有两种分桶方案:
- binary mode 下,桶 id 与 afl-tmin 是一致的;
- 人类可读模式下,桶 id 是从 0 到 8 的自然数。
write_results
/* 写入结果到文件的函数 */
static u32 write_results(void) {
s32 fd; // 文件描述符
u32 i, ret = 0; // 计数器i和结果计数ret
// 根据环境变量设置过滤条件
u8 cco = !!getenv("AFL_CMIN_CRASHES_ONLY"), // 只记录崩溃情况
caa = !!getenv("AFL_CMIN_ALLOW_ANY"); // 允许任何情况
// 根据输出文件路径的不同情况处理文件打开方式
if (!strncmp(out_file, "/dev/", 5)) {
// 设备文件直接打开
fd = open(out_file, O_WRONLY, 0600);
if (fd < 0) PFATAL("Unable to open '%s'", out_file);
} else if (!strcmp(out_file, "-")) {
// 标准输出的特殊处理
fd = dup(1);
if (fd < 0) PFATAL("Unable to open stdout");
} else {
// 通常的文件打开方式,先尝试删除已有文件
unlink(out_file); /* 忽略错误 */
fd = open(out_file, O_WRONLY | O_CREAT | O_EXCL, 0600);
if (fd < 0) PFATAL("Unable to create '%s'", out_file);
}
// 根据是否是二进制模式处理数据写入
if (binary_mode) {
// 二进制模式下,遍历trace_bits数组并计数非零元素
for (i = 0; i < MAP_SIZE; i++) {
if (trace_bits[i]) ret++;
}
// 将整个trace_bits数组写入文件
ck_write(fd, trace_bits, MAP_SIZE, out_file);
close(fd);
} else {
// 文本模式下,使用FILE流操作
FILE* f = fdopen(fd, "w");
if (!f) PFATAL("fdopen() failed");
// 遍历trace_bits数组,只处理非零元素
for (i = 0; i < MAP_SIZE; i++) {
if (!trace_bits[i]) continue;
ret++;
// 根据cmin_mode决定输出格式
if (cmin_mode) {
// cmin模式特殊处理:检查超时和崩溃条件
if (child_timed_out) break;
if (!caa && child_crashed != cco) break;
fprintf(f, "%u%u\n", trace_bits[i], i);
} else {
// 通常模式下的输出格式
fprintf(f, "%06u:%u\n", i, trace_bits[i]);
}
}
fclose(f);
}
return ret; // 返回处理的元素数量
}
afl-analyze
afl-analyze 的用途是分析一个输入文件,猜测它各个部分的语义——例如 magic number、checksum、length 等。
main
/* 主函数入口点 */
int main(int argc, char** argv) {
s32 opt; // 用于存储命令行选项解析结果
u8 mem_limit_given = 0, timeout_given = 0, qemu_mode = 0; // 标志位,分别用于检查内存限制、超时和QEMU模式是否已指定
char** use_argv; // 用于存储转换后的命令行参数
// 检查文档路径是否存在,不存在则使用默认路径 "docs"
doc_path = access(DOC_PATH, F_OK) ? "docs" : DOC_PATH;
// 打印程序版本信息
SAYF(cCYA "afl-analyze " cBRI VERSION cRST " by <lcamtuf@google.com>\n");
// 解析命令行参数
while ((opt = getopt(argc,argv,"+i:f:m:t:eQV")) > 0) {
switch (opt) {
case 'i': // 输入文件选项
if (in_file) FATAL("Multiple -i options not supported");
in_file = optarg;
break;
case 'f': // 输入作为程序输入而非直接参数
if (prog_in) FATAL("Multiple -f options not supported");
use_stdin = 0;
prog_in = optarg;
break;
case 'e': // 不统计边的数量
if (edges_only) FATAL("Multiple -e options not supported");
edges_only = 1;
break;
case 'm': // 内存限制选项
{
u8 suffix = 'M';
if (mem_limit_given) FATAL("Multiple -m options not supported");
mem_limit_given = 1;
if (!strcmp(optarg, "none")) {
mem_limit = 0;
break;
}
if (sscanf(optarg, "%llu%c", &mem_limit, &suffix) < 1 ||
optarg[0] == '-') FATAL("Bad syntax used for -m");
switch (suffix) {
case 'T': mem_limit *= 1024 * 1024; break;
case 'G': mem_limit *= 1024; break;
case 'k': mem_limit /= 1024; break;
case 'M': break;
default: FATAL("Unsupported suffix or bad syntax for -m");
}
if (mem_limit < 5) FATAL("Dangerously low value of -m");
if (sizeof(rlim_t) == 4 && mem_limit > 2000)
FATAL("Value of -m out of range on 32-bit systems");
}
break;
case 't': // 超时限制选项
if (timeout_given) FATAL("Multiple -t options not supported");
timeout_given = 1;
exec_tmout = atoi(optarg);
if (exec_tmout < 10 || optarg[0] == '-')
FATAL("Dangerously low value of -t");
break;
case 'Q': // QEMU模式选项
if (qemu_mode) FATAL("Multiple -Q options not supported");
if (!mem_limit_given) mem_limit = MEM_LIMIT_QEMU;
qemu_mode = 1;
break;
case 'V': // 显示版本号并退出
exit(0);
default: // 未知选项处理
usage(argv[0]);
}
}
// 参数有效性检查
if (optind == argc || !in_file) usage(argv[0]);
// 环境变量检查,决定是否使用十六进制偏移
use_hex_offsets = !!getenv("AFL_ANALYZE_HEX");
// 设置共享内存和信号处理
setup_shm();
setup_signal_handlers();
// 设置环境变量
set_up_environment();
// 查找二进制文件路径
find_binary(argv[optind]);
// 检测文件参数
detect_file_args(argv + optind);
// 根据是否在QEMU模式下选择命令行参数
if (qemu_mode)
use_argv = get_qemu_argv(argv[0], argv + optind, argc - optind);
else
use_argv = argv + optind;
// 读取初始文件
read_initial_file();
// 打印 run_target 函数调用信息
ACTF("Performing dry run (mem limit = %llu MB, timeout = %u ms%s)...",
mem_limit, exec_tmout, edges_only ? ", edges only" : "");
// 执行目标程序
run_target(use_argv, in_data, in_len, 1);
// 检查是否超时
if (child_timed_out)
FATAL("Target binary times out (adjusting -t may help).");
// 检查是否有任何执行迹象,本质是检查共享内存是否有不为 0 的项
if (!anything_set()) FATAL("No instrumentation detected.");
// 分析执行结果
analyze(use_argv);
// 完成消息
OKF("We're done here. Have a nice day!\n");
// 退出程序
exit(0);
}
analyze
analyze 函数做的事情是:对于 input 文件中的每一个位置,将 a 改为a^0xff、a^0x01、a+0x10、a-0x10 这四个数,分别运行实验。分类讨论实验结果:

RESP_NONE(0x00):四次实验的运行路径全部与原始路径相同。说明这个位置对程序没什么影响。(典型例子是,一个负责输出图片 exif 信息的程序不会管每个像素是什么颜色)RESP_MINOR(0x01):有至少一次实验的运行路径与原始路径相同。说明这个位置不太重要,可以改。(例如,这个位置是什么不太重要,但唯独不能是\x00,因为会造成截断)RESP_FIXED(0x03):四次实验的运行路径与原始路径不同,且这四次实验的路径一致。说明这个位置不能改。(典型例子是 magic number 检查,只要 magic 不对,程序就结束)RESP_VARIABLE(0x02):四次实验都与原始路径不同,且各自路径存在不一致的情况。说明这个地方严重影响控制流,应该狠狠地改。(典型例子是 type 字段,决定程序接下来如何处理数据)
另外,对于每个位置,如果对它的修改造成的影响,与修改前一个字节完全不一样,则认为它与前一个字节分别属于不同的 field。这是有道理的,例如 uint32 magic_number 的第二、第三个字节被修改后的行为,肯定是完全一样的。而当前后两个字节分属不同的 field 时,对它们的修改大概率会产生完全不同的效果。
上面的代码把文件划分成了若干个 field(通过设置最高位即 MSB),并给每个字节标注了「不重要 / 不太重要 / 不能改 / 敏感」四种标签。
/* Actually analyze! */
static void analyze(char** argv) {
// 初始化变量
u32 i;
u32 boring_len = 0, prev_xff = 0, prev_x01 = 0, prev_s10 = 0, prev_a10 = 0;
// 分配用于记录响应的缓冲区并设置终止符
u8* b_data = ck_alloc(in_len + 1);
u8 seq_byte = 0;
b_data[in_len] = 0xff; // 设置终止符为0xFF, 用于标记数据结束
// 打印开始分析的信息
ACTF("Analyzing input file (this may take a while)...\n");
// 显示图例(如果使用颜色)
#ifdef USE_COLOR
show_legend();
#endif /* USE_COLOR */
// 主循环遍历输入数据
for (i = 0; i < in_len; i++) {
// 用于记录每种操作的结果
u32 xor_ff, xor_01, sub_10, add_10;
u8 xff_orig, x01_orig, s10_orig, a10_orig;
// 第一种操作:in_data[i] ^= 0xff;。
in_data[i] ^= 0xff;
xor_ff = run_target(argv, in_data, in_len, 0);
// 第二种操作:in_data[i] ^= 0x01;
in_data[i] ^= 0xfe;
xor_01 = run_target(argv, in_data, in_len, 0);
// 第三种操作:in_data[i] -= 0x10;
in_data[i] = (in_data[i] ^ 0x01) - 0x10;
sub_10 = run_target(argv, in_data, in_len, 0);
// 第四种操作:in_data[i] += 0x10;
in_data[i] += 0x20;
add_10 = run_target(argv, in_data, in_len, 0);
in_data[i] -= 0x10;
// 观察 4 次运行路径与原始路径是否相同
xff_orig = (xor_ff == orig_cksum);
x01_orig = (xor_01 == orig_cksum);
s10_orig = (sub_10 == orig_cksum);
a10_orig = (add_10 == orig_cksum);
// 分类输入字节的响应
if (xff_orig && x01_orig && s10_orig && a10_orig) {
// 4 次变异都不改变路径,则这个位置不重要
b_data[i] = RESP_NONE;
boring_len++;
} else if (xff_orig || x01_orig || s10_orig || a10_orig) {
// 有至少一个不改变路径的变异,说明这个位置不关键,可以变异
b_data[i] = RESP_MINOR;
boring_len++;
} else if (xor_ff == xor_01 && xor_ff == sub_10 && xor_ff == add_10) {
// 4 次实验都与原路径不同,且这 4 次实验的路径一致,说明这个地方不能改
// 典型场景是 magic 检查,magic 不对就退出程序
b_data[i] = RESP_FIXED;
} else b_data[i] = RESP_VARIABLE; // 4 次实验都与原路径不同,且各次实验的执行路径存在不一致的情况,说明这位置对控制流很重要
// 看这个位置与前一个位置的行为是否完全不一样,给 field 划定边界,即切换 b_data 的最高位
// 侧面说明了 b_data 最高位相同的可以归类与同一字段
if (prev_xff != xor_ff && prev_x01 != xor_01 &&
prev_s10 != sub_10 && prev_a10 != add_10) seq_byte ^= 0x80;
b_data[i] |= seq_byte;
// 更新之前的校验和结果,用于下一次比较
prev_xff = xor_ff;
prev_x01 = xor_01;
prev_s10 = sub_10;
prev_a10 = add_10;
}
// 输出处理后的数据和响应分类
dump_hex(in_data, in_len, b_data);
SAYF("\n");
// 打印分析完成的信息和有趣的数据比例
OKF("Analysis complete. Interesting bits: %0.02f%% of the input file.",
100.0 - ((double)boring_len * 100) / in_len);
// 如果遇到执行挂起,警告可能的结果偏差
if (exec_hangs)
WARNF(cLRD "Encountered %u timeouts - results may be skewed." cRST,
exec_hangs);
// 释放资源
ck_free(b_data);
}
dump_hex
dump_hex 函数不仅负责输出十六进制数据,还给各个 field 打上了细化的标签。被标为「不能改」的块会被细分为:
RESP_LEN(0x04):长度RESP_CKSUM(0x05):校验和RESP_SUSPECT(0x06):可疑块(根据「不能改」的特性这里可以理解为被计算校验和的数据)
其他块(不重要、不太重要、敏感)则保持原标签。具体而言,对于一个被标为「不能改」的块,其处理逻辑如下:
- 若长度为 2 字节
- 值比输入数据总长度小,则认为是 length 字段
- 否则如果两个字节之差大于 32,则认为是 checksum 字段(考虑到 checksum 理应随机,这里识别成功的几率约为 76.2%)
- 若长度为 4 字节
- 值比输入数据总长度小,则认为是 length 字段
- 否则如果第一个字节的 MSB 与其他三个字节有不相同的情况,则认为是 checksum 字段(考虑到 checksum 理应随机,这里识别成功的几率为 87.5%)
- 若长度为小于 32 的奇数,则继续认为是 magic ,不改标签
- 如果以上情况都不是,则认为是被计算 checksum 的字段
/* 解释和报告输入文件中的模式 */
static void dump_hex(u8* buf, u32 len, u8* b_data) {
u32 i; // 循环索引
#ifdef USE_COLOR
u32 rlen = 1, off; // 连续相同响应的长度,和循环中的偏移量
#else
u32 rlen = 1; // 连续相同响应的长度
#endif /* ^USE_COLOR */
// 遍历输入数据
for (i = 0; i < len; i++) {
u8 rtype = b_data[i] & 0x0f; // 获取当前位置的响应类型
/* 查看接下来的字节,确定连续相同行为的长度 */
while (i + rlen < len && (b_data[i] >> 7) == (b_data[i + rlen] >> 7)) {
// 取优先级最高的类型作为当前字段的类型
// 优先级:RESP_FIXED=3, RESP_VARIABLE=2, RESP_MINOR=1, RESP_NONE=0
if (rtype < (b_data[i + rlen] & 0x0f)) rtype = b_data[i + rlen] & 0x0f;
rlen++;
}
/* 基于连续相同字节的长度和值尝试进一步分类 */
// 对 RESP_FIXED 类型的字段进一步分类
if (rtype == RESP_FIXED) {
switch (rlen) {
case 2: {
u16 val = *(u16*)(buf + i); // 读取两个字节的值
// 2 字节的 fixed 块,值比输入长度小,猜测是 length
if (val && (val <= len || SWAP16(val) <= len)) {
rtype = RESP_LEN;
break;
}
// 2 字节的 fixed 块,两个字节差距大于 32,猜测是 checksum
if (val && abs(buf[i] - buf[i + 1]) > 32) {
rtype = RESP_CKSUM;
break;
}
break;
}
case 4: {
u32 val = *(u32*)(buf + i); // 读取四个字节的值
// 4 字节的 fixed 块,值比输入长度小,猜测是 length
if (val && (val <= len || SWAP32(val) <= len)) {
rtype = RESP_LEN;
break;
}
// 4 字节的 fixed 块,第一个字节的 MSB 与其他字节有不相同的情况,猜测是 checksum
if (val && (buf[i] >> 7 != buf[i + 1] >> 7 ||
buf[i] >> 7 != buf[i + 2] >> 7 ||
buf[i] >> 7 != buf[i + 3] >> 7)) {
rtype = RESP_CKSUM;
break;
}
break;
}
// 长度小于 32 的奇数长度 fixed 块猜测是 magic 字段
case 1: case 3: case 5 ... MAX_AUTO_EXTRA - 1: break;
default: rtype = RESP_SUSPECT; // 猜测是被计算 checksum 的字段
}
}
/* 打印整个连续相同响应的序列 */
#ifdef USE_COLOR
for (off = 0; off < rlen; off++) {
/* 每16个字节显示偏移量 */
if (!((i + off) % 16)) {
if (off) SAYF(cRST cLCY ">");
if (use_hex_offsets)
SAYF(cRST cGRA "%s[%06x] " cRST, (i + off) ? "\n" : "", i + off);
else
SAYF(cRST cGRA "%s[%06u] " cRST, (i + off) ? "\n" : "", i + off);
}
switch (rtype) {
case RESP_NONE: SAYF(cLGR bgGRA); break;
case RESP_MINOR: SAYF(cBRI bgGRA); break;
case RESP_VARIABLE: SAYF(cBLK bgCYA); break;
case RESP_FIXED: SAYF(cBLK bgMGN); break;
case RESP_LEN: SAYF(cBLK bgLGN); break;
case RESP_CKSUM: SAYF(cBLK bgYEL); break;
case RESP_SUSPECT: SAYF(cBLK bgLRD); break;
}
show_char(buf[i + off]);
if (off != rlen - 1 && (i + off + 1) % 16) SAYF(" "); else SAYF(cRST " ");
}
#else
if (use_hex_offsets)
SAYF(" Offset %x, length %u: ", i, rlen);
else
SAYF(" Offset %u, length %u: ", i, rlen);
switch (rtype) {
case RESP_NONE: SAYF("no-op block\n"); break;
case RESP_MINOR: SAYF("superficial content\n"); break;
case RESP_VARIABLE: SAYF("critical stream\n"); break;
case RESP_FIXED: SAYF("\"magic value\" section\n"); break;
case RESP_LEN: SAYF("suspected length field\n"); break;
case RESP_CKSUM: SAYF("suspected cksum or magic int\n"); break;
case RESP_SUSPECT: SAYF("suspected checksummed block\n"); break;
}
#endif /* ^USE_COLOR */
i += rlen - 1; // 跳过已处理的长度
}
#ifdef USE_COLOR
SAYF(cRST "\n");
#endif /* USE_COLOR */
}
afl-fuzz
参数处理
s32 opt; // 用于存储 getopt 返回的选项字符
u64 prev_queued = 0; // 之前排队的路径数,用于统计和同步
u32 sync_interval_cnt = 0, seek_to; // 同步计数器和用于存储 seek 位置的变量
u8 *extras_dir = 0; // 额外字典目录
u8 mem_limit_given = 0; // 内存限制标志,表示是否提供了内存限制
u8 exit_1 = !!getenv("AFL_BENCH_JUST_ONE"); // 如果设置了环境变量 AFL_BENCH_JUST_ONE,则设置 exit_1 为 1
char** use_argv; // 存储命令行参数的数组
struct timeval tv; // 用于存储当前时间的结构体
struct timezone tz; // 用于存储时区信息的结构体
// 打印版本信息
SAYF(cCYA "afl-fuzz " cBRI VERSION cRST " by <lcamtuf@google.com>\n");
// 检查文档路径是否存在,如果不存在则使用 "docs"
doc_path = access(DOC_PATH, F_OK) ? "docs" : DOC_PATH;
// 获取当前时间
gettimeofday(&tv, &tz);
// 初始化随机数种子,使用当前时间和进程 ID
srandom(tv.tv_sec ^ tv.tv_usec ^ getpid());
// 使用 getopt 解析命令行参数
// afl-fuzz 的参数解析代码
while ((opt = getopt(argc, argv, "+i:o:f:m:b:t:T:dnCB:S:M:x:QV")) > 0) {
switch (opt) {
case 'i': /* input dir */
// 设置初始 corpus 目录,用于存放输入的测试用例文件
if (in_dir) FATAL("Multiple -i options not supported");
in_dir = optarg;
// 如果指定为 "-",表示 in-place resume,继续上一次中断的测试
if (!strcmp(in_dir, "-")) in_place_resume = 1;
break;
case 'o': /* output dir */
// 设置工作目录,用于存放输出的测试结果和生成的测试用例
if (out_dir) FATAL("Multiple -o options not supported");
out_dir = optarg;
break;
case 'M': { /* master sync ID */
// 设置为 master 模式,并解析 sync_id,master fuzzer 负责进行 deterministic 变异
u8* c;
if (sync_id) FATAL("Multiple -S or -M options not supported");
sync_id = ck_strdup(optarg);
// 如果 sync_id 包含冒号,解析 master_id 和 master_max
if ((c = strchr(sync_id, ':'))) {
*c = 0;
// 解析 master_id 和 master_max,确保其值有效
if (sscanf(c + 1, "%u/%u", &master_id, &master_max) != 2 ||
!master_id || !master_max || master_id > master_max ||
master_max > 1000000) FATAL("Bogus master ID passed to -M");
}
// master fuzzer 要执行 deterministic 变异
force_deterministic = 1;
}
break;
case 'S':
// 设置为 slave 模式,并解析 sync_id
if (sync_id) FATAL("Multiple -S or -M options not supported");
sync_id = ck_strdup(optarg);
break;
case 'f': /* target file */
// 如果目标程序是从某个固定的文件读入,则通过 -f 选项告知 afl-fuzz
// afl-fuzz 会将变异出的 input 放进那个文件,然后执行目标程序
if (out_file) FATAL("Multiple -f options not supported");
out_file = optarg;
break;
case 'x': /* dictionary */
// 导入字典,字典文件包含一组有趣的测试用例片段,用于提升 fuzzing 效率
if (extras_dir) FATAL("Multiple -x options not supported");
extras_dir = optarg;
break;
case 't': { /* timeout */
// 设置执行超时限制,单位是毫秒,超时后终止当前测试用例的执行
u8 suffix = 0;
if (timeout_given) FATAL("Multiple -t options not supported");
// 解析超时参数
if (sscanf(optarg, "%u%c", &exec_tmout, &suffix) < 1 ||
optarg[0] == '-') FATAL("Bad syntax used for -t");
if (exec_tmout < 5) FATAL("Dangerously low value of -t");
// 如果超时值以 '+' 结尾,表示允许超时
if (suffix == '+') timeout_given = 2; else timeout_given = 1;
break;
}
case 'm': { /* mem limit */
// 设置内存限制,限制目标程序的最大内存使用量
u8 suffix = 'M';
if (mem_limit_given) FATAL("Multiple -m options not supported");
mem_limit_given = 1;
// 处理特殊值 "none",表示没有内存限制
if (!strcmp(optarg, "none")) {
mem_limit = 0;
break;
}
// 解析内存限制参数
if (sscanf(optarg, "%llu%c", &mem_limit, &suffix) < 1 ||
optarg[0] == '-') FATAL("Bad syntax used for -m");
// 根据后缀调整内存单位
switch (suffix) {
case 'T': mem_limit *= 1024 * 1024; break;
case 'G': mem_limit *= 1024; break;
case 'k': mem_limit /= 1024; break;
case 'M': break;
default: FATAL("Unsupported suffix or bad syntax for -m");
}
// 检查内存限制值是否太低
if (mem_limit < 5) FATAL("Dangerously low value of -m");
// 在 32 位系统上,内存限制不能超过 2000 MB
if (sizeof(rlim_t) == 4 && mem_limit > 2000)
FATAL("Value of -m out of range on 32-bit systems");
}
break;
case 'b': { /* bind CPU core */
// 绑定到特定 CPU 核心,以确保稳定的性能
if (cpu_to_bind_given) FATAL("Multiple -b options not supported");
cpu_to_bind_given = 1;
// 解析 CPU 核心编号
if (sscanf(optarg, "%u", &cpu_to_bind) < 1 ||
optarg[0] == '-') FATAL("Bad syntax used for -b");
break;
}
case 'd': /* skip deterministic */
// 跳过 deterministic 变异阶段,直接进入 havoc 阶段
if (skip_deterministic) FATAL("Multiple -d options not supported");
skip_deterministic = 1;
use_splicing = 1;
break;
case 'B': /* load bitmap */
// 加载 bitmap 文件,用于指定感兴趣的路径
if (in_bitmap) FATAL("Multiple -B options not supported");
in_bitmap = optarg;
read_bitmap(in_bitmap);
break;
case 'C': /* crash mode */
// 启用 crash 模式,只关注导致崩溃的测试用例
if (crash_mode) FATAL("Multiple -C options not supported");
crash_mode = FAULT_CRASH;
break;
case 'n': /* dumb mode */
// 启用 dumb 模式(不插桩),适用于黑盒测试
if (dumb_mode) FATAL("Multiple -n options not supported");
if (getenv("AFL_DUMB_FORKSRV")) dumb_mode = 2; else dumb_mode = 1;
break;
case 'T': /* banner */
// 设置自定义 banner,用于在输出中显示
if (use_banner) FATAL("Multiple -T options not supported");
use_banner = optarg;
break;
case 'Q': /* QEMU mode */
// 启用 QEMU 模式,用于对二进制文件进行模糊测试
if (qemu_mode) FATAL("Multiple -Q options not supported");
qemu_mode = 1;
// 在 QEMU 模式下,默认内存限制为 MEM_LIMIT_QEMU
if (!mem_limit_given) mem_limit = MEM_LIMIT_QEMU;
break;
case 'V': /* Show version number */
// 显示版本号并退出
exit(0);
default:
// 对于未知选项,显示使用信息并退出
usage(argv[0]);
}
}
// 检查是否提供了必要的参数(输入目录和输出目录),如果没有提供则显示使用信息并退出
if (optind == argc || !in_dir || !out_dir) usage(argv[0]);
-i <in_dir>:指定输入目录(input directory)。这是存储初始测试案例的目录,afl-fuzz会从这个目录读取文件作为种子进行模糊测试。如果指定为-,则启用原地恢复模式,允许程序在同一个位置恢复运行状态。-o <out_dir>:指定输出目录(output directory)。这是afl-fuzz存放其输出数据的目录,包括崩溃报告和生成的测试案例。-M <sync_id>:主模式同步 ID(master sync ID)。- 在主模式下,
afl-fuzz将首先执行确定性变异(deterministic mutations)。这种变异是系统化的和可预测的,它按照一定的规则(如轻微改变输入的某些字节)来修改输入数据。 sync_id是一个标识符,用于在多个afl-fuzz实例之间区分不同的同步会话。- 如果
sync_id包含冒号,还可以解析master_id和master_max,用于分配多个 master 实例。
- 在主模式下,
-S <sync_id>:从模式同步 ID(secondary sync ID)。- 在从模式下,
afl-fuzz实例通常不进行确定性变异,而是直接进行更随机的变异。这有助于在测试中引入新的变量,可能会发现主模式未能触及的问题。 -M -d等价于-S,因为-d会跳过确定性变异,但是对于前者afl-fuzz会警告用户使用后者代替。
- 在从模式下,
-f <out_file>:指定目标文件(target file)。设置afl-fuzz的输入文件。-x <extras_dir>:指定字典文件(dictionary)。这是一个可选参数,用来提供一个包含有用字符串的文件,这些字符串可能帮助模糊测试过程更有效地触发程序中的特殊行为。-t <exec_tmout>:设置每个测试用例的执行超时(timeout),单位是毫秒。如果执行时间超过这个值,则认为测试案例导致了性能问题或挂起。如果后跟+(如50+),表示允许额外的超时。-m <mem_limit>:设置内存限制(memory limit),单位可以是k(千字节)、M(兆字节)、G(吉字节)、或T(太字节)。- 这个限制用来防止测试过程中使用过多内存导致系统不稳定。
none表示没有内存限制。
-b <cpu_to_bind>:绑定到特定的 CPU 核心(bind to CPU)。这允许你将afl-fuzz的进程绑定到特定的 CPU 核心上运行,可能用于性能优化。-d:跳过确定性阶段(skip deterministic stages)。这通常用于在已经运行了很长时间的模糊测试之后,快速进入更随机的变异阶段。-B <in_bitmap>:加载一个特定的位图文件(bitmap file),这通常用于高级用户,希望在不重新发现已知路径的情况下,继续对某个特定的案例进行测试。-C:设置为崩溃模式(crash mode),仅关注导致程序崩溃的输入。-n:启用简单模式(dumb mode)- 如果设置
AFL_DUMB_FORKSRV环境变量则dumb_mode = 2,貌似是不黑盒的简单模式,会简化一些 fuzz 流程。 - 如果未设置
AFL_DUMB_FORKSRV环境变量则dumb_mode = 1,即不插桩模式,适用于黑盒测试。
- 如果设置
-T <use_banner>:设置横幅(banner),这是显示在afl-fuzz用户界面上的自定义文本,通常用于标识测试会话或者配置。-Q:启用 QEMU 模式,使得afl-fuzz可以用来模糊测试编译为不同架构的二进制文件。-V:显示版本号并退出,这是一个快速检查工具版本的方式。
一些预处理操作
首先为一些特定信号注册处理函数。
// 以下为 fuzz 循环开始前的准备工作
// 设置一些信号的 handler,例如 alarm 响了就要关闭 child 进程
setup_signal_handlers()
这个函数是用于设置几个 handler:
SIGHUP, SIGINT, SIGTERM:将全局变量stop_soon设为1,要求 fuzzer 尽早退出,并关掉 fork server 和 child。SIGALRM:如果 alarm 响了,若 child 进程在运行,则认为 child 进程超时,关掉它;若现在没有 child 进程,说明是 fuzzer 与 fork server 的通讯超时了,此时关掉 fork server。SIGWINCH:ui 相关。SIGUSR1:将全局变量skip_requested设为1。用户可以发送这个信号,来放弃对当前用例的变异,开始 fuzz 下一个用例。
检查 ASAN 选项。
// 检查 ASan 选项
check_asan_opts();
check_asan_opts 函数是用于检查环境变量中设置的 ASAN 和 MSAN 选项是否符合预期的。如果设置不正确,程序将终止并给出错误提示。
ASAN(Address Sanitizer) 和 MSAN(Memory Sanitizer) 是用于检测C/C++程序中的内存错误的工具。它们通过环境变量进行配置,以控制错误处理和报告行为。
abort_on_error=1:这个选项告诉 ASAN 在检测到错误时应该终止程序,而不是继续执行。这对于及时捕捉和调试问题非常重要。symbolize=0:通常,当 ASAN/MSAN 检测到错误时,会尝试使用符号化的堆栈跟踪来显示错误位置。设置为 0 表示禁用此行为,这通常用于自动化测试或当符号化工具不可用时。exit_code=:这个选项用于设置 MSAN 在检测到内存错误时应返回的退出代码。STRINGIFY(MSAN_ERROR) 是一个预处理器宏,用于将 MSAN_ERROR 宏转换为字符串。
/* 检查 ASAN 选项。 */
static void check_asan_opts(void) {
u8* x = getenv("ASAN_OPTIONS"); // 从环境变量中获取 ASAN_OPTIONS 的值
if (x) { // 如果存在 ASAN_OPTIONS 环境变量
// 检查 ASAN_OPTIONS 是否包含 "abort_on_error=1"
if (!strstr(x, "abort_on_error=1"))
FATAL("Custom ASAN_OPTIONS set without abort_on_error=1 - please fix!"); // 如果没有设置该选项,则报错并要求修正
// 检查 ASAN_OPTIONS 是否包含 "symbolize=0"
if (!strstr(x, "symbolize=0"))
FATAL("Custom ASAN_OPTIONS set without symbolize=0 - please fix!"); // 如果没有设置该选项,同样报错并要求修正
}
x = getenv("MSAN_OPTIONS"); // 从环境变量中获取 MSAN_OPTIONS 的值
if (x) { // 如果存在 MSAN_OPTIONS 环境变量
// 检查 MSAN_OPTIONS 是否包含 "exit_code=" 后跟 MSAN_ERROR 的值
if (!strstr(x, "exit_code=" STRINGIFY(MSAN_ERROR)))
FATAL("Custom MSAN_OPTIONS set without exit_code="
STRINGIFY(MSAN_ERROR) " - please fix!"); // 如果没有设置该选项,报错并要求修正
// 检查 MSAN_OPTIONS 是否包含 "symbolize=0"
if (!strstr(x, "symbolize=0"))
FATAL("Custom MSAN_OPTIONS set without symbolize=0 - please fix!"); // 如果没有设置该选项,同样报错并要求修正
}
}
检查 sync_id 字符串是否合法。
if (sync_id) fix_up_sync();
具体过程为:
- 互斥选项检查:
- 首先检查是否在简单模式(
-n,dumb_mode)下,因为有sync_id说明是在-S或-M下,这与简单模式互斥。 - 检查
-d模式下是否开启-M模式,因为-M模式首先执行确定性变异而-d模式会跳过确定性变异,因此会报错选项冲突,建议用-S代替。 - 检查
-d模式下是否开启-S模式,如果开启则警告用户-S选项包含-d选项。
- 首先检查是否在简单模式(
- 同步标识符验证:接着,代码遍历同步标识符
sync_id,确保其只包含字母数字、下划线或连字符,且长度不超过 32 字节。这是为了防止非法字符或长度过长导致潜在的错误或安全问题。 - 路径构建和更新:将
out_dir和sync_id结合构建新的目录路径,并更新out_dir和sync_dir,以支持多实例同步工作。 - 行为调整:如果没有明确要求确定性行为,则程序将自动跳过确定性变异步骤,并启用拼接策略,这有助于增加随机性和覆盖范围。
/* 验证并调整使用 -S 时的 out_dir 和 sync_dir。 */
static void fix_up_sync(void) {
u8* x = sync_id; // 指向同步标识符的指针
// 检查是否在“简单模式”下使用了 -S 或 -M,这两者是互斥的
if (dumb_mode)
FATAL("-S / -M and -n are mutually exclusive");
// 如果设置了跳过确定性变异
if (skip_deterministic) {
// 如果同时强制要求确定性,则提示使用 -S 替代 -M -d
if (force_deterministic)
FATAL("use -S instead of -M -d");
else
// 提示 -S 本身就隐含了 -d 的效果
FATAL("-S already implies -d");
}
// 验证 sync_id 是否仅包含字母数字、下划线或连字符
while (*x) {
if (!isalnum(*x) && *x != '_' && *x != '-')
FATAL("Non-alphanumeric fuzzer ID specified via -S or -M");
x++;
}
// 如果同步标识符的长度超过 32 个字符,则报错
if (strlen(sync_id) > 32) FATAL("Fuzzer ID too long");
// 构建新的 out_dir 路径,将原 out_dir 和 sync_id 组合
x = alloc_printf("%s/%s", out_dir, sync_id);
sync_dir = out_dir; // 设置同步目录为当前的输出目录
out_dir = x; // 更新输出目录为新构建的路径
// 如果没有强制确定性模式,则自动跳过确定性变异,并启用拼接策略
if (!force_deterministic) {
skip_deterministic = 1;
use_splicing = 1;
}
}
确保输入目录 (in_dir) 和输出目录 (out_dir) 不是同一个目录。
// 检查输入目录和输出目录是否相同
if (!strcmp(in_dir, out_dir))
// 如果输入目录和输出目录相同,则抛出致命错误,因为这样会导致数据处理上的问题
FATAL("Input and output directories can't be the same");
检查在简单模式(dumb_mode)下是否还启用了崩溃模式(crash_mode)或 QEMU 模式(qemu_mode)。如果这些互斥的选项被同时启用,程序将抛出致命错误并终止。
// 检查是否处于简单模式(dumb_mode)
if (dumb_mode) {
// 如果同时启用了崩溃模式(crash_mode),则抛出致命错误
if (crash_mode) FATAL("-C and -n are mutually exclusive");
// 如果同时启用了 QEMU 模式(qemu_mode),则抛出致命错误
if (qemu_mode) FATAL("-Q and -n are mutually exclusive");
}
从环境变量读取配置,并根据这些配置调整模糊测试工具的行为。
// 从环境变量中读取各种设置,并对全局变量进行相应的赋值
// 如果环境变量 AFL_NO_FORKSRV 存在,则禁用 forkserver
if (getenv("AFL_NO_FORKSRV")) no_forkserver = 1;
// 如果环境变量 AFL_NO_CPU_RED 存在,则关闭 CPU 使用率的红色显示
if (getenv("AFL_NO_CPU_RED")) no_cpu_meter_red = 1;
// 如果环境变量 AFL_NO_ARITH 存在,则禁用算术运算变异
if (getenv("AFL_NO_ARITH")) no_arith = 1;
// 如果环境变量 AFL_SHUFFLE_QUEUE 存在,则启用队列洗牌功能
if (getenv("AFL_SHUFFLE_QUEUE")) shuffle_queue = 1;
// 如果环境变量 AFL_FAST_CAL 存在,则启用快速校准模式,calibrate_case 函数只运行 3 次程序而不是原来默认的 8 次
if (getenv("AFL_FAST_CAL")) fast_cal = 1;
// 如果环境变量 AFL_HANG_TMOUT 存在,则读取并设置挂起超时时间
if (getenv("AFL_HANG_TMOUT")) {
hang_tmout = atoi(getenv("AFL_HANG_TMOUT")); // 将字符串转换为整数
// 如果转换结果为0,则认为是无效值,并报错终止程序
if (!hang_tmout) FATAL("Invalid value of AFL_HANG_TMOUT");
}
// dumb_mode = 2 时有 forkserver ,这与禁用 forkserver 是互斥的,因此报错并终止程序
if (dumb_mode == 2 && no_forkserver)
FATAL("AFL_DUMB_FORKSRV and AFL_NO_FORKSRV are mutually exclusive");
// 如果环境变量 AFL_PRELOAD 存在,将其值设置为 LD_PRELOAD 和 DYLD_INSERT_LIBRARIES
// 这是用于在 Linux 和 macOS 下预加载用户指定的库
if (getenv("AFL_PRELOAD")) {
setenv("LD_PRELOAD", getenv("AFL_PRELOAD"), 1);
setenv("DYLD_INSERT_LIBRARIES", getenv("AFL_PRELOAD"), 1);
}
// 如果使用了已废弃的环境变量 AFL_LD_PRELOAD,则提示用户改用 AFL_PRELOAD
if (getenv("AFL_LD_PRELOAD"))
FATAL("Use AFL_PRELOAD instead of AFL_LD_PRELOAD");
保存命令行参数,在保存崩溃信息时会写入这里保存的命令行参数。
// 把 fuzzer 运行参数存进 orig_cmdline
save_cmdline(argc, argv);
更新 use_banner 。
// ui 相关
fix_up_banner(argv[optind]);
根据环境变量 AFL_NO_UI 设置 not_on_tty 进而确定是界面显示还是逐行输出。
// 若有环境变量 AFL_NO_UI,则 not_on_tty = 1
check_if_tty();
接下来是 cpu 相关的内容。
// 获取核心数量
get_core_count();
#ifdef HAVE_AFFINITY
// 如果定义了 HAVE_AFFINITY 宏,表明系统支持设置 CPU 亲和力
bind_to_free_cpu(); // 绑定到一个空闲的 CPU,这有助于提升多线程或多进程的性能
#endif /* HAVE_AFFINITY */
// 如果 crash 掉的进程的崩溃报告会被发给某个程序,那么会引入延迟,于是 crash 可能会被误认为是超时
// 检查系统配置 /proc/sys/kernel/core_pattern,如果是这种情况,提醒一下用户
check_crash_handling();
// 若发现 cpu 频率可调,则建议用户把 cpu 定在最高频率
check_cpu_governor();
加载和设置一个 AFL_POST_LIBRARY 环境变量指定的外部库,该库的 u8* afl_postprocess(u8* data, u32* len) 函数可用于处理由 AFL(American Fuzzy Lop)生成的模糊测试数据(common_fuzz_stuff 函数)。
// 若指定了环境变量 AFL_POST_LIBRARY,则设置 post_handler 为 lib 中的 afl_postprocess 函数
setup_post();
setup_post 函数具体实现如下:
/* 如果可用,加载后处理器。 */
static void setup_post(void) {
void* dh; // 动态库句柄
u8* fn = getenv("AFL_POST_LIBRARY"); // 从环境变量中获取动态库的文件名
u32 tlen = 6; // 用于测试的数据长度
// 如果环境变量未设置,则直接返回
if (!fn) return;
// 输出正在从指定路径加载后处理器的消息
ACTF("Loading postprocessor from '%s'...", fn);
// 使用 dlopen 函数尝试打开指定的动态库,RTLD_NOW 表示需要立即解析所有符号
dh = dlopen(fn, RTLD_NOW);
// 如果动态库无法加载,输出错误信息并终止程序
if (!dh) FATAL("%s", dlerror());
// 使用 dlsym 函数查找动态库中名为 "afl_postprocess" 的符号(函数)
post_handler = dlsym(dh, "afl_postprocess");
// 如果找不到该符号,输出错误信息并终止程序
if (!post_handler) FATAL("Symbol 'afl_postprocess' not found.");
/* 执行快速测试。现在出现段错误总比稍后出现好 =) */
// 调用后处理函数进行测试,看是否能正常运行
post_handler("hello", &tlen);
// 如果一切顺利,输出后处理器安装成功的消息
OKF("Postprocessor installed successfully.");
}
初始化共享内存。
// 初始化 shm,流程与 afl-tmin 类似。若处于 dumb 模式则不设置 __AFL_SHM_ID
setup_shm();
setup_shm 具体实现如下:
- 内存初始化:
virgin_bits、virgin_tmout、和virgin_crash用于记录在模糊测试中未遇到的路径、超时和崩溃情况。这些数组被初始化为全 0xFF,表示所有情况最初都是未遇到的。(例如,假设virgin_bits[12345] = 0b01101011,那就说明 id 为 12345 的这条边,在过往的 fuzz 过程中,命中过 4、16、128 这三个桶;其余 5 个桶则未被命中。)virgin_bits:常规 fuzz 过程的探索情况virgin_tmout:超时用例的探索情况virgin_crash:crash 用例的探索情况
- 共享内存配置:
shmget创建一个共享内存段,供本程序和它创建的所有子进程使用。shm_id是这段内存的标识符。 - 环境变量设置:如果不在简单模式下运行,设置环境变量
SHM_ENV_VAR为共享内存的标识符,允许检测工具在子进程中使用。 - 内存附加:
shmat将创建的共享内存段映射到调用进程的地址空间中,使trace_bits指针指向这块内存,用于跟踪测试执行情况。
/* 清除共享内存(退出时处理器)。 */
static void remove_shm(void) {
// 调用 shmctl 函数,使用 IPC_RMID 命令来删除之前创建的共享内存段
shmctl(shm_id, IPC_RMID, NULL);
}
/* 配置共享内存和 virgin_bits,这在启动时调用。 */
EXP_ST void setup_shm(void) {
u8* shm_str; // 用于存储共享内存ID的字符串
// 如果没有提供输入位图,则初始化 virgin_bits 数组为全 0xFF
if (!in_bitmap) memset(virgin_bits, 255, MAP_SIZE);
// 将 virgin_tmout 和 virgin_crash 数组初始化为全 0xFF
memset(virgin_tmout, 255, MAP_SIZE);
memset(virgin_crash, 255, MAP_SIZE);
// 创建共享内存段,大小为 MAP_SIZE,权限设置为仅本程序可读写
shm_id = shmget(IPC_PRIVATE, MAP_SIZE, IPC_CREAT | IPC_EXCL | 0600);
// 如果创建共享内存失败,输出错误并终止程序
if (shm_id < 0) PFATAL("shmget() failed");
// 注册一个退出时调用的函数,以便在程序结束时清理共享内存
atexit(remove_shm);
// 将共享内存ID格式化为字符串
shm_str = alloc_printf("%d", shm_id);
/* 如果我们被要求在“简单模式”下对带有检测工具的二进制文件进行模糊测试,
我们不希望他们检测到检测工具,因为我们不会发送 fork 服务器命令。
这种检测机制可能以后需要用更好的自动检测技术来替换。 */
// 如果不是简单模式,则设置环境变量 SHM_ENV_VAR 为共享内存ID字符串,确保子进程可以访问
if (!dumb_mode) setenv(SHM_ENV_VAR, shm_str, 1);
// 释放格式化的共享内存ID字符串内存
ck_free(shm_str);
// 将 trace_bits 指针附加到共享内存段
trace_bits = shmat(shm_id, NULL, 0);
// 如果附加共享内存失败,输出错误并终止程序
if (trace_bits == (void *)-1) PFATAL("shmat() failed");
}
调用 init_count_class16 函数初始化 16bit 查找表,这样在 classify_counts 的时候可以一次处理 2 字节,从而提高效率。
/* Destructively classify execution counts in a trace. This is used as a
preprocessing step for any newly acquired traces. Called on every exec,
must be fast. */
static const u8 count_class_lookup8[256] = {
[0] = 0,
[1] = 1,
[2] = 2,
[3] = 4,
[4 ... 7] = 8,
[8 ... 15] = 16,
[16 ... 31] = 32,
[32 ... 127] = 64,
[128 ... 255] = 128
};
static u16 count_class_lookup16[65536];
EXP_ST void init_count_class16(void) {
u32 b1, b2;
for (b1 = 0; b1 < 256; b1++)
for (b2 = 0; b2 < 256; b2++)
count_class_lookup16[(b1 << 8) + b2] =
(count_class_lookup8[b1] << 8) |
count_class_lookup8[b2];
}
在后续 run_target 函数中的 classify_counts 函数会一次处理两字节。另外还会对连续 8 字节做循环展开,进一步提升效率。
/* 对内存中的计数值进行分类处理以优化位图的存储 */
static inline void classify_counts(u64* mem) {
u32 i = MAP_SIZE >> 3; // 计算需要处理的64位块的数量,MAP_SIZE通常是位图的总大小
while (i--) { // 对每个64位块执行循环
/* 针对稀疏位图进行优化。 */
if (unlikely(*mem)) { // 如果当前64位块不为零(使用unlikely提示编译器这种情况较少发生)
u16* mem16 = (u16*)mem; // 将64位块视为16位的四个部分
// 对每个16位的部分应用查找表转换,优化存储和访问效率
mem16[0] = count_class_lookup16[mem16[0]]; // 转换第一个16位
mem16[1] = count_class_lookup16[mem16[1]]; // 转换第二个16位
mem16[2] = count_class_lookup16[mem16[2]]; // 转换第三个16位
mem16[3] = count_class_lookup16[mem16[3]]; // 转换第四个16位
}
mem++; // 移动到下一个64位块
}
}
接下来是一些文件操作:
// 在工作目录下创建一些文件夹,并打开一些 fd 备用,例如 /dev/urandom
setup_dirs_fds();
// 把初始 corpus 读入 queue
read_testcases();
// 读入自动生成的 extra(如果有)
load_auto();
// 把初始 corpus 复制到工作目录的 queue 文件夹下
pivot_inputs();
// 如果用户通过 -x 选项指定了 dictionary,则从那里导入 extra
if (extras_dir) load_extras(extras_dir);
// 若是 in-place resume(通过 "-i -" 选项指定),则继承上次 fuzz 的 exec_timeout
if (!timeout_given) find_timeout();
之后判断目标程序的两种输入方式。无论是哪种方式,后续的 write_to_testcase 会将 fuzz 的用例写入对应文件中。
- 如果是目标程序是在参数中指定输入文件(在目标程序的参数中会用
@@来指定输入文件)则detect_file_args函数会在工作目录下创建一个.cur_input文件然后将out_file设置为该文件名(当然用户也可以使用-f参数指定out_file)并且将目标程序参数中的@@替换为out_file的完整路径。 - 如果是目标程序是通过标准输入读入则在工作目录下创建一个
.cur_input文件然后将该文件句柄保存在out_fd。
这些都完成后,AFL 进行一次 dry run 。
// 特判文件名为 @@ 的情况,这种情况需要把它覆写为输入文件位置 aa_subst (如果 out_file 没有通过参数指定则这里设置 out_file 为 [<cwd>/]<out_dir>/.cur_input)
detect_file_args(argv + optind + 1);
// 创建 .cur_input 文件并打开,设为 out_fd。接下来 fuzzer 要把变异出的 input 写进这里,由 child 读取
// 注意 out_file 和 out_fd 只能有一个能用,其中 out_file 主要在程序在参数中指定输入文件时使用,否则指定 out_fd 为 stdin
if (!out_file) setup_stdio_file();
// 检查目标程序,看找不找得到、在不在 /tmp 等。
check_binary(argv[optind]);
start_time = get_cur_time();
if (qemu_mode)
use_argv = get_qemu_argv(argv[0], argv + optind, argc - optind);
else
use_argv = argv + optind;
// dry run
perform_dry_run(use_argv);
首先调用 cull_queue 精简队列。这个过程在白皮书中有描述,简而言之就是给 queue 中的用例打分,分为 favored 和 non-favored 两类。精简队列之后,更新 ui 信息和 fuzzer_stats 文件。这个文件以人类可读的方式记录了 fuzzer 的当前状态,并随着 fuzz 过程不断更新。如果我们想监控 fuzz 流程,只需要定期观测这个文件。
// 精简队列
cull_queue();
// ui,展示一些信息
show_init_stats();
// 若是恢复之前的 fuzz,则找到该从队列的什么位置继续
seek_to = find_start_position();
// 更新 fuzzer_stats 文件
write_stats_file(0, 0, 0);
// 保存 auto extras
save_auto();
if (stop_soon) goto stop_fuzzing;
// 下面开始 fuzz 循环
/* Woop woop woop */
当这些流程都结束后,fuzzer 进入我们所熟知的循环,正式开始工作。
perform_dry_run
简而言之,perform_dry_run 对于 queue 中的每一个用例,调用 calibrate_case 函数进行校准。用例会被运行多次。对于校准结果:
- 若 timeout 了,且
-t参数里面没有容忍超时、也不处于 resume 模式,则直接退出。 - 若 crash 了,则直接退出(除非有
AFL_SKIP_CRASHES环境变量)。 - 若无法执行目标程序,或目标程序没被插桩,则直接退出。
- 另外,若该用例多次运行的行为不一致,则向用户抱怨两句。
/* 执行所有测试用例的干运行以确认应用程序正常工作。这只针对初始输入执行一次。 */
static void perform_dry_run(char** argv) {
struct queue_entry* q = queue; // 指向测试队列的头部
u32 cal_failures = 0; // 用于记录校准失败的次数
u8* skip_crashes = getenv("AFL_SKIP_CRASHES"); // 从环境变量获取是否跳过崩溃测试用例的设置
// 遍历测试队列中的每一个条目
while (q) {
u8* use_mem; // 指向用于存储测试用例数据的内存
u8 res; // 存储测试结果
s32 fd; // 文件描述符
// 从测试用例文件名中提取基本名称
u8* fn = strrchr(q->fname, '/') + 1;
// 输出正在使用特定测试用例进行干运行的消息
ACTF("Attempting dry run with '%s'...", fn);
// 打开测试用例文件
fd = open(q->fname, O_RDONLY);
if (fd < 0) PFATAL("Unable to open '%s'", q->fname);
// 为测试用例数据分配内存
use_mem = ck_alloc_nozero(q->len);
// 从文件中读取测试用例数据
if (read(fd, use_mem, q->len) != q->len)
FATAL("Short read from '%s'", q->fname);
// 关闭文件描述符
close(fd);
// 校准测试用例,检查它是否按预期工作
res = calibrate_case(argv, q, use_mem, 0, 1);
// 释放为测试用例数据分配的内存
ck_free(use_mem);
// 如果需要立即停止,则返回
if (stop_soon) return;
// 根据测试结果进行相应处理
switch (res) {
case FAULT_NONE: // 没有错误
// 如果是队列中的第一个条目,检查映射覆盖是否均匀
if (q == queue) check_map_coverage();
// 如果在崩溃模式下,测试用例未引起崩溃,报错
if (crash_mode) FATAL("Test case '%s' does *NOT* crash", fn);
break;
case FAULT_TMOUT: // 超时错误
// 如果设置了超时标志
if (timeout_given) {
if (timeout_given > 1) {
WARNF("Test case results in a timeout (skipping)"); // 超时但设置为跳过
q->cal_failed = CAL_CHANCES; // 标记为校准失败
cal_failures++; // 增加失败计数
break;
}
// 否则不容忍超时,直接退出
SAYF("\n" cLRD "[-] " cRST
"The program took more than %u ms to process one of the initial test cases.\n"
" Usually, the right thing to do is to relax the -t option - or to delete it\n"
" altogether and allow the fuzzer to auto-calibrate. That said, if you know\n"
" what you are doing and want to simply skip the unruly test cases, append\n"
" '+' at the end of the value passed to -t ('-t %u+').\n", exec_tmout,
exec_tmout);
FATAL("Test case '%s' results in a timeout", fn); // 超时且未设置跳过,报错
} else {
SAYF("\n" cLRD "[-] " cRST
"The program took more than %u ms to process one of the initial test cases.\n"
" This is bad news; raising the limit with the -t option is possible, but\n"
" will probably make the fuzzing process extremely slow.\n\n"
" If this test case is just a fluke, the other option is to just avoid it\n"
" altogether, and find one that is less of a CPU hog.\n", exec_tmout);
FATAL("Test case '%s' results in a timeout", fn);
}
case FAULT_CRASH: // 崩溃错误
if (crash_mode) break; // 如果是崩溃模式且预期内崩溃,则继续
// skip_crashes 是 AFL_SKIP_CRASHES 环境变量指定的
if (skip_crashes) {
WARNF("Test case results in a crash (skipping)"); // 设置跳过崩溃
q->cal_failed = CAL_CHANCES;
cal_failures++;
break;
}
if (mem_limit) {
SAYF("\n" cLRD "[-] " cRST
"Oops, the program crashed with one of the test cases provided. There are\n"
" several possible explanations:\n\n"
" - The test case causes known crashes under normal working conditions. If\n"
" so, please remove it. The fuzzer should be seeded with interesting\n"
" inputs - but not ones that cause an outright crash.\n\n"
" - The current memory limit (%s) is too low for this program, causing\n"
" it to die due to OOM when parsing valid files. To fix this, try\n"
" bumping it up with the -m setting in the command line. If in doubt,\n"
" try something along the lines of:\n\n"
" ( ulimit -Sv $[%llu << 10]; /path/to/binary [...] <testcase )\n\n"
" Tip: you can use http://jwilk.net/software/recidivm to quickly\n"
" estimate the required amount of virtual memory for the binary. Also,\n"
" if you are using ASAN, see %s/notes_for_asan.txt.\n\n"
" - Least likely, there is a horrible bug in the fuzzer. If other options\n"
" fail, poke <lcamtuf@coredump.cx> for troubleshooting tips.\n",
DMS(mem_limit << 20), mem_limit - 1, doc_path);
} else {
SAYF("\n" cLRD "[-] " cRST
"Oops, the program crashed with one of the test cases provided. There are\n"
" several possible explanations:\n\n"
" - The test case causes known crashes under normal working conditions. If\n"
" so, please remove it. The fuzzer should be seeded with interesting\n"
" inputs - but not ones that cause an outright crash.\n\n"
" - Least likely, there is a horrible bug in the fuzzer. If other options\n"
" fail, poke <lcamtuf@coredump.cx> for troubleshooting tips.\n");
}
FATAL("Test case '%s' results in a crash", fn); // 未设置跳过且崩溃,报错
case FAULT_ERROR: // 执行错误
FATAL("Unable to execute target application ('%s')", argv[0]);
case FAULT_NOINST: // 未检测到插桩
FATAL("No instrumentation detected");
case FAULT_NOBITS: // 未检测到覆盖信息
useless_at_start++;
if (!in_bitmap && !shuffle_queue)
WARNF("No new instrumentation output, test case may be useless.");
break;
}
// 该用例的行为有变动则发出警告
if (q->var_behavior) WARNF("Instrumentation output varies across runs.");
q = q->next; // 移动到队列中的下一个测试用例
}
// 存在校准失败的用例
if (cal_failures) {
// 如果所有测试用例都超时或崩溃,则报告致命错误并放弃
if (cal_failures == queued_paths)
FATAL("All test cases time out%s, giving up!",
skip_crashes ? " or crash" : "");
// 报告例由于超时或崩溃跳过了多少测试用
WARNF("Skipped %u test cases (%0.02f%%) due to timeouts%s.", cal_failures,
((double)cal_failures) * 100 / queued_paths,
skip_crashes ? " or crashes" : "");
// 如果被拒绝的测试用例占总数的五分之一以上,发出警告,提示检查设置
if (cal_failures * 5 > queued_paths)
WARNF(cLRD "High percentage of rejected test cases, check settings!");
}
// 所有测试用例处理完毕
OKF("All test cases processed.");
}
calibrate_case
根据注释,calibrate_case 函数的运行时机至少有两个:一是程序运行之初,用于校准初始 corpus;二是发现了新路径,将有趣的用例加入 queue 时。总结一句:进了 queue 的用例,都要被运行一遍calibrate_case 函数。 其代码如下:
/* 校准新的测试用例。这在处理输入目录时完成,用于及早警告关于不稳定或有问题的测试用例;
并且在发现新路径时进行,以便检测变量行为等。 */
static u8 calibrate_case(char** argv, struct queue_entry* q, u8* use_mem,
u32 handicap, u8 from_queue) {
static u8 first_trace[MAP_SIZE]; // 用于存储第一次执行的覆盖痕迹
// 定义各种状态变量
u8 fault = 0, new_bits = 0, var_detected = 0, hnb = 0,
first_run = (q->exec_cksum == 0);
u64 start_us, stop_us; // 开始和结束时间(微秒)
s32 old_sc = stage_cur, old_sm = stage_max; // 保存当前阶段信息
u32 use_tmout = exec_tmout; // 使用的超时时间
u8* old_sn = stage_name; // 保存当前阶段名称
/* 当恢复会话或尝试校准已添加的发现时,对超时时间更宽容一些。这有助于避免因间歇性延迟带来的问题。 */
if (!from_queue || resuming_fuzz)
use_tmout = MAX(exec_tmout + CAL_TMOUT_ADD,
exec_tmout * CAL_TMOUT_PERC / 100);
q->cal_failed++; // 增加校准失败次数
stage_name = "calibration"; // 设置当前阶段名称为校准
stage_max = fast_cal ? 3 : CAL_CYCLES; // 设置校准循环次数
/* 在做任何事情之前,确保forkserver已启动,并且不将其启动时间计入二进制校准时间。 */
if (dumb_mode != 1 && !no_forkserver && !forksrv_pid)
init_forkserver(argv);
if (q->exec_cksum) { // 如果已有执行校验和
memcpy(first_trace, trace_bits, MAP_SIZE); // 复制第一次执行的覆盖痕迹
hnb = has_new_bits(virgin_bits); // 检查是否有新的覆盖位 返回值:1 = 仅 hit count 更新;2 = 出现了新的边
if (hnb > new_bits) new_bits = hnb; // 更新为更高优先级的状态
}
start_us = get_cur_time_us(); // 记录开始时间
for (stage_cur = 0; stage_cur < stage_max; stage_cur++) { // 循环执行校准
u32 cksum;
if (!first_run && !(stage_cur % stats_update_freq)) show_stats(); // 显示统计信息
write_to_testcase(use_mem, q->len); // 将测试用例写入测试程序
fault = run_target(argv, use_tmout); // 运行测试用例
/* 如果接收到Ctrl+C的停止信号,或遇到非崩溃模式的故障,立即中止校准。 */
if (stop_soon || fault != crash_mode) goto abort_calibration;
if (!dumb_mode && !stage_cur && !count_bytes(trace_bits)) { // 如果不是简单模式且是第一轮运行且未检测到插桩输出
fault = FAULT_NOINST; // 标记为无插桩错误
goto abort_calibration;
}
cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST); // 计算当前覆盖痕迹的哈希值
if (q->exec_cksum != cksum) { // 如果当前执行的哈希与之前的不同
hnb = has_new_bits(virgin_bits); // 再次检测是否有新的覆盖位 返回值:1 = 仅 hit count 更新;2 = 出现了新的边
if (hnb > new_bits) new_bits = hnb; // 更新为更高优先级的状态
// 如果存在之前的执行校验和,检查是否有变异行为
if (q->exec_cksum) {
u32 i;
// 遍历每一个覆盖字节,检查是否有变化
for (i = 0; i < MAP_SIZE; i++) {
// 如果之前没有标记当前边变异且当前边的统计结果发生变化
if (!var_bytes[i] && first_trace[i] != trace_bits[i]) {
var_bytes[i] = 1; // 标记此边为变异
stage_max = CAL_CYCLES_LONG; // 发送变异则需要增加测试次数
}
}
var_detected = 1; // 标记检测到变异
} else {
// 如果之前没有执行校验和,设置当前校验和并保存第一次覆盖痕迹
q->exec_cksum = cksum;
memcpy(first_trace, trace_bits, MAP_SIZE);
}
}
}
stop_us = get_cur_time_us(); // 记录停止时间
total_cal_us += stop_us - start_us; // 更新总的测试时间
total_cal_cycles += stage_max; // 更新总的测试轮数
// 收集有关此测试用例性能的统计数据,用于计算分数
q->exec_us = (stop_us - start_us) / stage_max; // 计算平均每次执行时间
q->bitmap_size = count_bytes(trace_bits); // 计算覆盖边的数量
q->handicap = handicap; // 记录障碍(延迟开始的优势)
q->cal_failed = 0; // 标记此次校准未失败
total_bitmap_size += q->bitmap_size; // 更新总覆盖边的数量
total_bitmap_entries++; // 更新产生的 bitmap 数量(校准次数??)
update_bitmap_score(q); // 更新位图得分
// 如果不是简单模式且是第一次运行且未失败且这个用例没有从插桩中得到新的输出则标记为无插桩错误
if (!dumb_mode && first_run && !fault && !new_bits) fault = FAULT_NOBITS;
abort_calibration:
// 如果发现新覆盖且之前未记录,更新状态
if (new_bits == 2 && !q->has_new_cov) {
q->has_new_cov = 1;
queued_with_cov++;
}
// 如果发生变异
if (var_detected) {
var_byte_count = count_bytes(var_bytes); // 计算变异边的数量
if (!q->var_behavior) {
mark_as_variable(q); // 标记为变异测试用例
queued_variable++;
}
}
// 恢复之前的阶段信息
stage_name = old_sn;
stage_cur = old_sc;
stage_max = old_sm;
// 如果不是第一次运行,显示统计信息
if (!first_run) show_stats();
return fault; // 返回检测到的错误类型
}
可见,校准过程是多次运行用例(默认是 8 次),统计各次运行的结果。
- 若 fork server 没有准备好,就调用
init_forkserver()初始化 fork server - 多次调用
run_target()运行目标程序,观察结果。若没有任何 hit count 命中,则认为程序未插桩,报告错误。 - 如果发现对某用例多次运行程序,其表现不一致,则将执行次数提升到 40 次,并更新
var_bytes[](这个全局变量表示 shm 中哪些位置存在不一致性)。另外,将 queue entry 的var_behavior标记设为1。 - 更新 queue entry 信息,例如将
exec_us字段设为校准过程中的执行时间均值。 - 给这个用例打分,并更新
top_rated指针数组。
update_bitmap_score
白皮书在 Culling the corpus 章节提到:当 fuzz 进行到后期,可能一些用例的边覆盖度,是它们祖先的边覆盖度的严格超集,因此可以考虑缩小 corpus,专注于这些超级用例(当然,其他用例不是被彻底放弃了,而是被以很大的概率忽略掉)。因此,AFL 倾向于找个 queue 中用例的子集,使得它们在覆盖所有已知边的同时尽可能小。这样的用例被认为是 favored 的。AFL 作者声称,这样形成的 favored 集合,比整个 corpus 可以小 5 到 10 倍。
然而,子集覆盖问题是 NP-完全的。AFL 必须保证速度,所以它采用了一个不准确但是很高速的算法:对于 shm 的每一个位置(这代表一条边),记录 top_rated 指针,指向 queue 中覆盖了这条边的、分数最小的那个用例。一个用例的分数等于 exec_us * len,即「长度×执行时间」最小。
update_bitmap_score 就是一个简单的擂台法,贪心地寻找一组测试用例用例集合,满足:
- 该集合能覆盖所有测试用例能覆盖的边。
- 给集合中的每个测试用例都存在至少一条边满足在所有覆盖这条边的测试用例中,该测试用例的「长度×执行时间」最小。
- 每条边如果能被覆盖则对应集合中的一个测试用例使得该测试用例在集合中所有覆盖该边的测试用例中「长度×执行时间」最小。
另外这里还会记一个 score_changed 表示新加入的测试用例会不会引起这组测试用例集合改变。因为如果不会引起这组测试用例集合改变就不会影响最终的 favored 用例集合改变。(因为这里只是预处理了「对于每一条已知的边它对应的偏爱的测试用例」,后续在 cull_queue 函数中 favored 用例集合需要根据这个来计算得到。)
/* 当我们遇到一个新路径时,调用此函数来判断这个路径是否比已有的路径更“有利”。
“有利”的目的是保持一个最小的路径集合,这些路径触发了到目前为止在位图中看到的所有位,
并且专注于对它们进行模糊测试,而不是其他路径。
过程的第一步是为位图中的每个字节维护一个 top_rated[] 条目列表。
我们赢得这个位置如果没有之前的竞争者,或者如果竞争者具有更有利的速度x大小因子。 */
static void update_bitmap_score(struct queue_entry* q) {
u32 i; // 循环索引
u64 fav_factor = q->exec_us * q->len; // 计算当前测试用例的“有利因子”(执行时间乘以长度)
/* 对于 trace_bits[] 中的每个设置的字节,检查是否有之前的获胜者,并与当前的样例比较。 */
for (i = 0; i < MAP_SIZE; i++) // 遍历整个位图
if (trace_bits[i]) { // 如果当前位被设置
if (top_rated[i]) { // 如果这个位已经有获胜者
/* 更快执行或更小的测试用例被优先考虑。 */
if (fav_factor > top_rated[i]->exec_us * top_rated[i]->len) continue; // 如果当前用例的因子大于获胜者,跳过
/* 看起来我们将要获胜。为之前的获胜者减少引用计数,必要时丢弃其 trace_bits[]。 */
if (!--top_rated[i]->tc_ref) { // 减少引用计数,如果为0
ck_free(top_rated[i]->trace_mini); // 释放内存
top_rated[i]->trace_mini = 0; // 清空指针
}
}
/* 将我们自己插入为新的获胜者。 */
top_rated[i] = q;
q->tc_ref++; // 增加当前测试用例的引用计数
if (!q->trace_mini) { // 如果还没有最小化的位图
q->trace_mini = ck_alloc(MAP_SIZE >> 3); // 分配内存
minimize_bits(q->trace_mini, trace_bits); // 最小化位图
}
score_changed = 1; // 标记得分已改变
}
}
代码中有一个细节:每个 queue entry 里面保存着这个用例的 trace_mini,它的大小是 8192 字节,即 65536 bit。它是一个「缩小版」的 hit count 数组——只保存各个边是否被击中过,不关心击中次数。
显然,具体的「构建 favored 用例集合」这件事不是这个函数做的。这个函数只是做了一些前置的工作,更新各个边的偏爱用例,并设置 score_changed 标记。
init_forkserver
main payload 汇编中的 fork server。它在程序的第一个入口点处停下,先往 fd 199 写入 4 个字节的 \x00,然后通过 read() 阻塞在那里,等待 supervisor (一般是 afl-fuzz)从 fd 198 发来指令。它一旦从 fd 198 读到 4 个字节(不管内容是什么),它就调用 fork() 启动 child 进程,并将子进程的 pid 写进 fd 199。接下来,调用 waitpid() 等待子进程结束,并将子进程的退出原因写进 fd 198。结束这些工作之后,再次等待 supervisor 发来指令,照此循环。

创建了两个管道,一个用于 supervisor 向 fork server 发指令,另一个用于 fork server 回复信息。接下来,调用 fork() 创建一个子进程,让它变成 fork server。
/* 初始化 fork server(只在带插桩模式下使用)。这个概念在此博客中有解释:
http://lcamtuf.blogspot.com/2014/10/fuzzing-binaries-without-execve.html
本质上,插桩允许我们跳过 execve(),只需不断克隆一个已停止的子进程。
因此,我们只需执行一次,然后通过管道发送命令。这部分逻辑的其他部分在 afl-as.h 中。 */
EXP_ST void init_forkserver(char** argv) {
static struct itimerval it; // 用于设置定时器
int st_pipe[2], ctl_pipe[2]; // 状态管道和控制管道
int status; // 子进程状态
s32 rlen; // 从管道读取的长度
ACTF("Spinning up the fork server..."); // 启动 fork server
// 写入 st_pipe[1] 的内容可以在 st_pipe[0] 读到。这个管道用于 fd 199,是 fork server -> supervisor 方向
// 写入 ctl_pipe[1] 的内容可以在 ctl_pipe[0] 读到。这个管道用于 fd 198,是 supervisor -> fork server 方向
if (pipe(st_pipe) || pipe(ctl_pipe)) PFATAL("pipe() failed"); // 创建管道,失败则报错
// fork 出一个进程,让它变成 fork server
forksrv_pid = fork();
if (forksrv_pid < 0) PFATAL("fork() failed"); // fork失败报错
子进程做的事情是:
- 把内存大小限制为 50MB、把 core dump 大小上限设为 0。
- 创建一个新的进程组。
- 关掉不用的 fd。
- 若目标程序从
stdin读入,则重定向到用例文件。 - 把 fd 198 重定向到接收 supervisor 发来消息的管道、把 fd 199 重定向到往 supervisor 回复信息的管道。
- 如果用户没有给出 ASan 和 MSan 的选项,则指定为默认值。
- 执行目标程序。目标程序会在第一个入口点停下来,往 fd 199 写四个字节的 hello 包。由于发送成功,目标程序会担任 fork server,等待 supervisor 发来的指令。
if (!forksrv_pid) { // 在子进程中
struct rlimit r; // 用于设置资源限制
/* 在OpenBSD上,默认的文件描述符限制对root用户是软128。我们尝试修复这个问题... */
// 针对 OpenBSD,修改 fd 上限为 200
if (!getrlimit(RLIMIT_NOFILE, &r) && r.rlim_cur < FORKSRV_FD + 2) {
r.rlim_cur = FORKSRV_FD + 2;
setrlimit(RLIMIT_NOFILE, &r); // 尝试设置资源限制,忽略错误
}
if (mem_limit) { // 如果设置了内存限制
r.rlim_max = r.rlim_cur = ((rlim_t)mem_limit) << 20;
#ifdef RLIMIT_AS
setrlimit(RLIMIT_AS, &r); // 设置地址空间大小限制,忽略错误
#else
setrlimit(RLIMIT_DATA, &r); // 在不支持 RLIMIT_AS 的系统上使用 RLIMIT_DATA
#endif
}
r.rlim_max = r.rlim_cur = 0;
setrlimit(RLIMIT_CORE, &r); // 禁用核心转储以加快速度
setsid(); // 起一个新的进程组
dup2(dev_null_fd, 1); // 重定向 stdout
dup2(dev_null_fd, 2); // 重定向 stderr
if (out_file) {
dup2(dev_null_fd, 0); // 如果指定了输出文件,stdin 重定向到 /dev/null
} else {
// 目标程序从 stdin 读输入,把 stdin 重定向到 .cur_input 文件
dup2(out_fd, 0); // 否则,stdin 复制 out_fd
close(out_fd); // 关闭原始的 out_fd
}
// 设置控制和状态管道,关闭不需要的原始文件描述符
// 将 fd 198 重定向到 ctl_pipe[0] 以接收 supervisor 指令
// 将 fd 199 重定向到 st_pipe[1] 以向 supervisor 发送回复
if (dup2(ctl_pipe[0], FORKSRV_FD) < 0 || dup2(st_pipe[1], FORKSRV_FD + 1) < 0) PFATAL("dup2() failed");
// 关掉没用的 fd,只保留 198、199
close(ctl_pipe[0]); close(ctl_pipe[1]);
close(st_pipe[0]); close(st_pipe[1]);
// 这几个 fd 是从 afl-fuzz 进程继承下来的,与 fork server 没关系,需要关掉
close(out_dir_fd);
close(dev_null_fd);
close(dev_urandom_fd);
close(fileno(plot_file));
// 提升性能,防止链接器在 fork 后做额外工作
if (!getenv("LD_BIND_LAZY")) setenv("LD_BIND_NOW", "1", 0);
// 如果未指定 ASAN 的其他设置,则设置一些合理的
setenv("ASAN_OPTIONS", "abort_on_error=1:"
"detect_leaks=0:"
"symbolize=0:"
"allocator_may_return_null=1", 0);
// MemorySanitizer 的设置较为复杂,因为它暂不支持 abort_on_error=1
setenv("MSAN_OPTIONS", "exit_code=" STRINGIFY(MSAN_ERROR) ":"
"symbolize=0:"
"abort_on_error=1:"
"allocator_may_return_null=1:"
"msan_track_origins=0", 0);
// 执行目标程序,它会在第一个入口点停下来,给 supervisor 发送 4 字节的 hello,并等待指令
execv(target_path, argv);
/* 如果 execv() 执行失败,使用独特的位图签名告诉父进程出现了问题 */
*(u32*)trace_bits = EXEC_FAIL_SIG;
exit(0); // 如果到达这里,说明 execv() 失败,退出子进程
}
父进程会等待 fork server 发来 hello 包,若接收成功,则 fork server 成功启动,初始化完成;否则做一点错误处理。
/* 父进程关闭不需要的管道端点 */
close(ctl_pipe[0]);
close(st_pipe[1]);
fsrv_ctl_fd = ctl_pipe[1]; // 控制管道用于发送控制信号给子进程
fsrv_st_fd = st_pipe[0]; // 状态管道用于从子进程接收状态信息
/* 等待 fork 服务器启动,但不要等太久 */
it.it_value.tv_sec = ((exec_tmout * FORK_WAIT_MULT) / 1000);
it.it_value.tv_usec = ((exec_tmout * FORK_WAIT_MULT) % 1000) * 1000;
setitimer(ITIMER_REAL, &it, NULL); // 设置实时计时器
rlen = read(fsrv_st_fd, &status, 4); // 读取 fork 服务器的状态
it.it_value.tv_sec = 0;
it.it_value.tv_usec = 0;
setitimer(ITIMER_REAL, &it, NULL); // 停止计时器
/* 如果我们从服务器收到四字节的“hello”消息,那么一切设置正确 */
if (rlen == 4) {
OKF("All right - fork server is up.");
return;
}
/* 如果超时,可能需要调整 -t 参数来帮助初始化 fork server */
// 全局变量 child_timed_out 由 SIGALRM handler 设置,要么 fork server 自己超时了,要么 child 超时了
// 鉴于现在还没有让 fork server 启动 child,说明肯定是 fork server 本身超时
if (child_timed_out)
FATAL("Timeout while initializing fork server (adjusting -t may help)");
/* 检查子进程的状态,查看是否出现错误 */
// SIGALRM handler 里面会结束 fork server,所以这个 waitpid 会马上返回
if (waitpid(forksrv_pid, &status, 0) <= 0)
PFATAL("waitpid() failed");
if (WIFSIGNALED(status)) {
// 推测错误原因,并给用户提些建议,略
}
FATAL("Fork server handshake failed");
}
run_target
run_target 函数做的事情如下:
- 把 shm 写零。
- 给 fork server 发四个字节,要求它启动一个 child。
- 从 fork server 读 child pid,以便在 child 超时之后关掉它。
- 从 fork server 读 child 的退出原因。
- 给 shm 分桶。
- 更新
slowest_exec_ms信息,并向调用者返回执行结果(child 进程是否 crash)。
/* 运行目标程序,返回执行结果 */
static u8 run_target(char** argv, u32 timeout) {
static struct itimerval it; // 定时器结构,用于设置超时
static u32 prev_timed_out = 0; // 上一次是否超时
static u64 exec_ms = 0; // 执行时间(毫秒)
int status = 0; // 子进程的退出状态
u32 tb4; // 用于存储trace_bits的一个标志位
child_timed_out = 0; // 子进程是否超时的标志
/* 清零trace_bits数组,确保之前的操作不会影响到它 */
memset(trace_bits, 0, MAP_SIZE);
MEM_BARRIER(); // 内存屏障,防止编译器优化
/* 在“dumb”模式下,不依赖于目标程序编译进的 fork server 逻辑,直接调用 execve() */
if (dumb_mode == 1 || no_forkserver) {
child_pid = fork(); // 创建子进程
if (child_pid < 0) PFATAL("fork() failed"); // fork失败报错
if (!child_pid) { // 子进程中
struct rlimit r; // 用于设置资源限制
if (mem_limit) { // 如果设置了内存限制
r.rlim_max = r.rlim_cur = ((rlim_t)mem_limit) << 20;
#ifdef RLIMIT_AS
setrlimit(RLIMIT_AS, &r); // 设置地址空间限制
#else
setrlimit(RLIMIT_DATA, &r); // 在不支持RLIMIT_AS的系统上使用RLIMIT_DATA
#endif
}
r.rlim_max = r.rlim_cur = 0;
setrlimit(RLIMIT_CORE, &r); // 禁止生成core dump文件
setsid(); // 新建会话,使得子进程完全独立
dup2(dev_null_fd, 1); // 重定向标准输出到/dev/null
dup2(dev_null_fd, 2); // 重定向标准错误到/dev/null
if (out_file) {
dup2(dev_null_fd, 0); // 重定向标准输入到/dev/null
} else {
dup2(out_fd, 0); // 重定向标准输入到输出文件描述符
close(out_fd); // 关闭原始的输出文件描述符
}
close(dev_null_fd); // 关闭不需要的文件描述符
close(out_dir_fd);
close(dev_urandom_fd);
close(fileno(plot_file));
/* 设置 AddressSanitizer 和 MemorySanitizer 的默认环境变量 */
setenv("ASAN_OPTIONS", "abort_on_error=1:detect_leaks=0:symbolize=0:allocator_may_return_null=1", 0);
setenv("MSAN_OPTIONS", "exit_code=" STRINGIFY(MSAN_ERROR) ":symbolize=0:msan_track_origins=0", 0);
execv(target_path, argv); // 执行目标程序
/* 如果execv执行失败,使用特定的位图值通知父进程 */
*(u32*)trace_bits = EXEC_FAIL_SIG;
exit(0); // 退出子进程
}
} else {
// 在非“dumb”模式下,已有 fork server 运行,直接发送控制命令并获取结果
s32 res;
// 要求 fork server 启动 child
if ((res = write(fsrv_ctl_fd, &prev_timed_out, 4)) != 4) {
if (stop_soon) return 0;
RPFATAL(res, "Unable to request new process from fork server (OOM?)");
}
// 从 fork server 读 child pid
if ((res = read(fsrv_st_fd, &child_pid, 4)) != 4) {
if (stop_soon) return 0;
RPFATAL(res, "Unable to communicate with fork server (OOM?)");
}
if (child_pid <= 0) FATAL("Fork server is misbehaving (OOM?)");
}
/* 设置用户请求的超时时间,然后等待子进程结束。 */
it.it_value.tv_sec = (timeout / 1000);
it.it_value.tv_usec = (timeout % 1000) * 1000;
setitimer(ITIMER_REAL, &it, NULL); // 启动真实时间定时器
/* SIGALRM处理程序只是简单地杀死child_pid并设置child_timed_out标志。 */
if (dumb_mode == 1 || no_forkserver) {
// 在“dumb”模式或无forkserver模式下,通过waitpid等待子进程结束
if (waitpid(child_pid, &status, 0) <= 0) PFATAL("waitpid() failed");
} else {
// 在有forkserver的模式下,通过读取状态管道来获取子进程的退出状态
s32 res;
if ((res = read(fsrv_st_fd, &status, 4)) != 4) {
if (stop_soon) return 0;
RPFATAL(res, "Unable to communicate with fork server (OOM?)");
}
}
// 检查子进程是否已停止,如果是,清空child_pid
if (!WIFSTOPPED(status)) child_pid = 0;
// 读取并停止计时器,计算实际执行时间
getitimer(ITIMER_REAL, &it);
exec_ms = (u64) timeout - (it.it_value.tv_sec * 1000 + it.it_value.tv_usec / 1000);
it.it_value.tv_sec = 0;
it.it_value.tv_usec = 0;
setitimer(ITIMER_REAL, &it, NULL); // 重置定时器
total_execs++; // 更新总执行次数统计
/* 在此点之后对trace_bits的任何操作都不应被编译器移动到此位置之前。*/
MEM_BARRIER();
tb4 = *(u32*)trace_bits; // 读取trace_bits的前四个字节,用于后续逻辑判断
#ifdef WORD_SIZE_64
classify_counts((u64*)trace_bits); // 对trace_bits中的计数进行分类
#else
classify_counts((u32*)trace_bits);
#endif
prev_timed_out = child_timed_out; // 更新超时记录
/* 根据子进程的退出状态报告结果给调用者。 */
if (WIFSIGNALED(status) && !stop_soon) {
// 如果子进程因为信号退出且不是即将停止
kill_signal = WTERMSIG(status);
if (child_timed_out && kill_signal == SIGKILL) return FAULT_TMOUT; // 如果因超时被杀,则返回超时故障
return FAULT_CRASH; // 否则返回崩溃故障
}
/* MSAN使用时的特殊处理,它不支持abort_on_error,必须使用特殊的退出代码。 */
if (uses_asan && WEXITSTATUS(status) == MSAN_ERROR) {
kill_signal = 0;
return FAULT_CRASH;
}
if ((dumb_mode == 1 || no_forkserver) && tb4 == EXEC_FAIL_SIG)
return FAULT_ERROR; // 如果execv调用失败,返回错误故障
/* 如果在用户定义的超时下运行测试用例,则只记录最慢的单元。 */
if (!(timeout > exec_tmout) && (slowest_exec_ms < exec_ms)) {
slowest_exec_ms = exec_ms; // 更新最慢执行时间
}
return FAULT_NONE; // 如果一切正常,则返回无错误
}
主循环
AFL 一次次地遍历 queue,针对 queue 里的每一个元素:
- 调用
fuzz_one(),以这个用例为基础,变异出许多新的用例,拿去实验 - 并行模式下,每处理完 5 个用例的变异,便与其他 fuzzer 同步。
当处理完队列中的全部用例时,如果整轮都没有新发现,则打开 splicing 变异阶段。另外,维护一个计数器 cycles_wo_finds 表示「连续多少轮没有新发现」。
while (1) { // 无限循环,直到遇到停止条件
u8 skipped_fuzz; // 用于记录是否跳过当前模糊测试的标志
cull_queue(); // 清理队列中无效的条目
if (!queue_cur) { // 如果当前队列指针为空
// 在fuzz循环最开始,或fuzz每完成一轮后,会进入这个分支
queue_cycle++; // 增加队列循环计数
current_entry = 0; // 重置当前条目索引
cur_skipped_paths = 0; // 重置跳过的路径数
queue_cur = queue; // 重置队列指针到队列头部
// 在 resume 模式下,恢复先前的位置
while (seek_to) { // 如果存在定位需求
current_entry++;
seek_to--;
queue_cur = queue_cur->next; // 移动到指定的队列位置
}
show_stats(); // 显示当前统计数据
if (not_on_tty) { // 如果不是在终端环境下运行
ACTF("Entering queue cycle %llu.", queue_cycle); // 输出当前循环周期
fflush(stdout); // 刷新输出,确保立即显示
}
/* 如果一个完整的队列循环没有发现新路径,尝试组合策略 */
if (queued_paths == prev_queued) {
if (use_splicing) cycles_wo_finds++; else use_splicing = 1; // 如果使用剪接,则增加无发现循环计数,否则启用剪接
} else {
cycles_wo_finds = 0; // 重置无发现循环计数
}
prev_queued = queued_paths; // 更新已加入队列的路径数
/* 如果设置了同步ID,并且是第一次循环,且设置了环境变量AFL_IMPORT_FIRST */
// 按照文档,设置环境变量 AFL_IMPORT_FIRST,可以让 AFL 在干活之前先与其他 fuzzer 同步一次
if (sync_id && queue_cycle == 1 && getenv("AFL_IMPORT_FIRST"))
sync_fuzzers(use_argv); // 同步fuzzer状态
}
skipped_fuzz = fuzz_one(use_argv); // 执行一次模糊测试,返回是否跳过
// 若这个用例没有被跳过,且处于并行模式
if (!stop_soon && sync_id && !skipped_fuzz) { // 如果没有停止信号,有同步ID,并且没有跳过当前模糊测试
// 每发生 5 次这样的事件,就执行 sync_fuzzers()
if (!(sync_interval_cnt++ % SYNC_INTERVAL)) // 如果达到同步间隔
sync_fuzzers(use_argv); // 同步fuzzer状态
}
if (!stop_soon && exit_1) stop_soon = 2; // 如果设置了单次执行标志,设置即将停止标志
if (stop_soon) break; // 如果接收到停止信号,退出循环
queue_cur = queue_cur->next; // 移动到队列中的下一个条目
current_entry++; // 当前条目索引递增
}
cull_queue
前面 update_bitmap_score 找到了一组测试用例的集合 top_rated ,虽然这组测试用例确保了每条边如果能被覆盖则对应集合中的一个测试用例使得该测试用例在所有覆盖该边的测试用例中「长度×执行时间」最小。但是由于每一条能覆盖的边都对应一个用例确保“最小”,因此这组测试用例集合存在冗余,即不能保证“如果去除这组用例中的一个使得一定存在一个能被覆盖的边现在不能被覆盖了”,因此还需要在这组测试用例中选择一个子集使得不会冗余。
具体实现 AFL采用如下的贪心近似算法:
- 维护「favored 用例」以及「已被 favored 用例覆盖的边」的集合,初始均为 ∅
- 找一个还没被 favored 用例覆盖到的边,将其偏爱的那个用例加入 favored 集合
- 用刚刚加入的那个用例更新「已被 favored 用例覆盖的边」集合
- 重复步骤 2~3,直到所有的已知边都被覆盖
/* 上述机制的第二部分是一个过程,该过程遍历 top_rated[] 条目,然后顺序抓取之前未见过的字节(temp_v)的获胜者,并至少标记它们为受青睐的,
直到下一次运行。在所有模糊测试步骤中,受青睐的条目会获得更多的执行时间。*/
static void cull_queue(void) {
struct queue_entry* q; // 队列条目指针
static u8 temp_v[MAP_SIZE >> 3]; // 用于临时存储哪些字节被标记为受青睐
u32 i; // 循环索引
if (dumb_mode || !score_changed) return; // 如果是“dumb”模式或得分未改变,则直接返回
score_changed = 0; // 重置得分改变标志
memset(temp_v, 255, MAP_SIZE >> 3); // 将 temp_v 初始化为全1,表示所有位都未处理
queued_favored = 0; // 重置队列中受青睐的条目数
pending_favored = 0; // 重置待处理的受青睐条目数
q = queue; // 指向队列头部
while (q) {
q->favored = 0; // 将当前队列条目标记为非受青睐
q = q->next; // 移动到下一个条目
}
/* 检查位图中是否有未在 temp_v 中捕获的内容。
如果是,并且它有一个 top_rated[] 竞争者,我们就使用它。 */
for (i = 0; i < MAP_SIZE; i++)
if (top_rated[i] && (temp_v[i >> 3] & (1 << (i & 7)))) { // 如果有对应的 top_rated 条目且该位在 temp_v 中标记
u32 j = MAP_SIZE >> 3; // 获取 temp_v 的大小
/* 从 temp_v 中移除当前条目的所有相关位。 */
while (j--)
if (top_rated[i]->trace_mini[j])
temp_v[j] &= ~top_rated[i]->trace_mini[j]; // 对应位取反再与操作
top_rated[i]->favored = 1; // 标记为受青睐
queued_favored++; // 受青睐队列数增加
if (!top_rated[i]->was_fuzzed) pending_favored++; // 如果之前未模糊测试过,增加待处理的受青睐数
}
q = queue; // 重新指向队列头部
while (q) {
mark_as_redundant(q, !q->favored); // 标记冗余的条目,即那些不受青睐的条目
q = q->next; // 移动到下一个条目
}
}
sync_fuzzers
这个函数用于在各个 fuzzer 之间同步状态。若一个 fuzzer 实例处于并行状态,那么用户通过 -o 选项提供给它的工作目录,将被视为 sync_dir;它自己的 out_dir 则是 sync_dir 下以它的 sync_id 命名的子文件夹。另外,master 执行 deterministic 变异,slave 不执行 deterministic 变异。
既然 AFL 没有使用 SQLite 等数据库,它只能把文件系统当成数据库用。例如,在工作目录的 queue 子文件夹里保存队列中的用例。可以猜测,fuzzer 的同步过程,大致就是去读取其他实例的文件夹,把里面有用的东西拿过来。
/* 从其他fuzzer抓取有趣的测试用例。 */
static void sync_fuzzers(char** argv) {
DIR* sd; // 目录流
struct dirent* sd_ent; // 目录项结构
u32 sync_cnt = 0; // 同步计数器
sd = opendir(sync_dir); // 打开同步目录
if (!sd) PFATAL("Unable to open '%s'", sync_dir); // 如果打开失败,输出错误
stage_max = stage_cur = 0; // 初始化阶段计数器
cur_depth = 0; // 当前深度
/* 遍历同步目录中每个其他fuzzer创建的条目。 */
while ((sd_ent = readdir(sd))) {
static u8 stage_tmp[128]; // 临时阶段字符串
DIR* qd; // 队列目录流
struct dirent* qd_ent; // 队列目录项
u8 *qd_path, *qd_synced_path; // 队列路径和同步路径
u32 min_accept = 0, next_min_accept; // 接受的最小ID和下一次接受的最小ID
s32 id_fd; // ID文件描述符
/* 跳过点文件和我们自己的输出目录。 */
if (sd_ent->d_name[0] == '.' || !strcmp(sync_id, sd_ent->d_name)) continue;
// 现在 qd 是某个其他实例的 out_dir/queue;sd_ent->d_name 是该实例的 sync_id
/* 跳过没有queue/子目录的目录。 */
// 如果一个子文件夹拥有 queue 目录,就认为它是一个 fuzzer 实例的 out_dir
qd_path = alloc_printf("%s/%s/queue", sync_dir, sd_ent->d_name);
if (!(qd = opendir(qd_path))) {
ck_free(qd_path);
continue;
}
/* 检索最后一个已看到测试用例的ID。 */
// 在自己的 out_dir/.synced 下面,打开一个以其他实例 sync_id 为名的文件
qd_synced_path = alloc_printf("%s/.synced/%s", out_dir, sd_ent->d_name);
id_fd = open(qd_synced_path, O_RDWR | O_CREAT, 0600);
if (id_fd < 0) PFATAL("Unable to create '%s'", qd_synced_path);
// 若这个文件已经存在,则它的首 4 字节表示 min_accept
if (read(id_fd, &min_accept, sizeof(u32)) > 0)
lseek(id_fd, 0, SEEK_SET);
next_min_accept = min_accept;
/* 显示统计信息 */
sprintf(stage_tmp, "sync %u", ++sync_cnt);
stage_name = stage_tmp;
stage_cur = 0;
stage_max = 0;
/* 检查此fuzzer排队的每个文件,解析ID,看看我们是否之前已经看过;
如果没有,执行测试用例。 */
while ((qd_ent = readdir(qd))) {
u8* path;
s32 fd;
struct stat st;
// 跳过 . 开头的文件、跳过 id 小于 min_accept 的用例(id 就在文件名里)
if (qd_ent->d_name[0] == '.' ||
sscanf(qd_ent->d_name, CASE_PREFIX "%06u", &syncing_case) != 1 ||
syncing_case < min_accept) continue;
// 现在,这个用例我们没见过
if (syncing_case >= next_min_accept)
next_min_accept = syncing_case + 1;
path = alloc_printf("%s/%s", qd_path, qd_ent->d_name);
/* 允许打开文件失败,以防其他fuzzer正在恢复等情况… */
// 打开用例
fd = open(path, O_RDONLY);
if (fd < 0) {
ck_free(path);
continue;
}
if (fstat(fd, &st)) PFATAL("fstat() failed");
/* 忽略大小为零或超过最大文件大小的文件。 */
if (st.st_size && st.st_size <= MAX_FILE) {
u8 fault;
u8* mem = mmap(0, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
if (mem == MAP_FAILED) PFATAL("Unable to mmap '%s'", path);
/* 看看会发生什么。我们依赖于 `save_if_interesting()` 来捕捉主要错误并保存测试用例。 */
// 把这用例写进 out_file,并执行一次 run_target
write_to_testcase(mem, st.st_size); // 将内存映射的文件内容写入当前的测试用例
// 注意 run_target 只会跑一遍程序并给 shm 分桶,不会把用例加入 queue
fault = run_target(argv, exec_tmout); // 运行目标程序,传入执行超时时间
if (stop_soon) return; // 如果收到停止信号,直接返回
// 若这个用例有趣,则加入 queue
syncing_party = sd_ent->d_name; // 设置当前同步的fuzzer的名称
queued_imported += save_if_interesting(argv, mem, st.st_size, fault); // 如果是有趣的测试用例,则保存并增加导入计数
syncing_party = 0; // 重置同步方名称
munmap(mem, st.st_size); // 解除内存映射
if (!(stage_cur++ % stats_update_freq)) show_stats(); // 按一定频率显示统计信息
}
ck_free(path); // 释放路径字符串内存
close(fd); // 关闭文件描述符
}
ck_write(id_fd, &next_min_accept, sizeof(u32), qd_synced_path); // 更新最小接受ID
close(id_fd); // 关闭ID文件
closedir(qd); // 关闭队列目录
ck_free(qd_path); // 释放队列路径内存
ck_free(qd_synced_path); // 释放同步路径内存
}
closedir(sd); // 关闭同步目录
}
对于每一个其他实例,fuzzer 要检查它的 queue 目录,寻找新的用例。对于新用例,也不是直接加入 queue,而是调用 run_target() 实际跑一遍这个用例,再调用 save_if_interesting()。
为了寻找新的用例,afl 在自身的工作目录 out_dir 下的 .synced 文件夹下保存了其他 fuzzer 的 sync_id 为名的文件,其中前 4 字节为上一轮同步时计算的 next_min_accept 也是当前轮的 min_accept(第一轮同步可能没有这个文件则 min_accept 为 0)。其他 fuzzer 的 queue 目录下的测试用例文件名前缀为 id:%06u ,在当前轮的同步时只接受 id 大于等于 min_accept 的测试用例,同时会维护一个 next_min_accept 为「最大的 id 值 + 1」并写入 .synced 文件夹下的对应文件共下次使用。
save_if_interesting
这个函数的签名是 static u8 save_if_interesting(char** argv, void* mem, u32 len, u8 fault),其中 mem 是用例内容,len 是长度; fault 参数则是先前 run_target 函数返回的。
我们知道,run_target 函数返回值是:
FAULT_NONE(0),表示 child 正常结束。FAULT_TMOUT(1),表示超时。FAULT_CRASH(2),表示程序崩溃。FAULT_ERROR(3),表示 fuzzer 本身出现问题。
这其中并没有提到是否发现了新的路径。因此, save_if_interesting 必须自行扫描 shm 区域,来判断这个用例是否有趣。
/* 检查执行结果是否有趣,如果有趣则保存或排队输入测试用例以供进一步分析。如果保存了条目,则返回1,否则返回0。 */
static u8 save_if_interesting(char** argv, void* mem, u32 len, u8 fault) {
u8 *fn = ""; // 文件名初始化为空字符串
u8 hnb; // 是否发现新的位
s32 fd; // 文件描述符
u8 keeping = 0, res; // 是否保持测试用例的标志和临时结果变量
// 若 fault = crash_mode = 2,则处于 crash exploration 模式且崩溃了
// 若 fault = crash_mode = 0,则处于普通模式,且没有崩溃或超时
// 因此,这个 if 分支是大部分用例的「正常情况」
if (fault == crash_mode) {
/* 只有当 map 中有变化时才保留,将其添加到队列中以便将来进行模糊测试等。 */
// 对于「正常情况」只会忽略无变化的测试用例而不是必须有新路径发现
// has_new_bits 返回值:0 表示无成果;1 表示 hit count 变动;2 表示发现了新的边
if (!(hnb = has_new_bits(virgin_bits))) {
if (crash_mode) total_crashes++; // 如果是崩溃模式,增加崩溃总数
return 0; // 没有变化,不保留
}
#ifndef SIMPLE_FILES
// 根据不同的模式,生成文件名
fn = alloc_printf("%s/queue/id:%06u,%s", out_dir, queued_paths, describe_op(hnb));
#else
fn = alloc_printf("%s/queue/id_%06u", out_dir, queued_paths);
#endif
add_to_queue(fn, len, 0); // 添加到队列
if (hnb == 2) {
queue_top->has_new_cov = 1; // 标记为有新覆盖
queued_with_cov++; // 增加有覆盖的队列数
}
queue_top->exec_cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST); // 计算执行校验和
/* 尝试进行校准;如果成功,还会调用 update_bitmap_score()。 */
res = calibrate_case(argv, queue_top, mem, queue_cycle - 1, 0);
if (res == FAULT_ERROR)
FATAL("Unable to execute target application"); // 如果校准失败,报错退出
fd = open(fn, O_WRONLY | O_CREAT | O_EXCL, 0600);
if (fd < 0) PFATAL("Unable to create '%s'", fn); // 创建测试用例文件,如果失败则报错退出
ck_write(fd, mem, len, fn); // 将测试用例写入文件
close(fd); // 关闭文件
keeping = 1; // 标记为保持
}
// 分类讨论 child 退出原因
switch (fault) {
对于正常情况(也就是说,普通模式下 child 正常退出,crash 模式下程序果真 crash),我们考虑是否将其加入 queue。这里调用了 has_new_bits() 判断是否为本质不同的路径(0 表示无成果;1 表示 hit count 变动;2 表示发现了新的边),若是,则加入 queue、执行校准、并写进文件系统。队列在理论上最多有65536×8=524288 个元素。
在考虑完要不要把一个元素加入 queue 后,再考虑要不要将其保存到文件系统。AFL 运行过程中,至多保留 500 个 hang 用例和 5000 个 crash 用例,且如果一个 crash 用例对比起之前的 crash 用例,没有探索到新的边,则它会被丢弃。hang 用例也照此办理,不过它们会被重新跑一次,看看是否仍然超时。
由于 shm 要经历 simplify_trace() 过程,保存一个 hang 或 crash 的条件比正常用例入队更加苛刻——常规用例只要能发现新的 hit count,就能入队;hang 或 crash 则必须发现新的边,才能被保存。
// 分类讨论 child 退出原因
switch (fault) {
case FAULT_TMOUT: // 超时错误处理
total_tmouts++; // 增加超时总数
if (unique_hangs >= KEEP_UNIQUE_HANG) return keeping; // 如果 unique_hangs 已达到上限(500),不再处理
if (!dumb_mode) {
#ifdef WORD_SIZE_64
// simplify_trace 函数是只保留「是否命中」而不保留 count
simplify_trace((u64*)trace_bits); // 简化跟踪位,这样 has_new_bits 只能反应出是否有新的边增加
#else
simplify_trace((u32*)trace_bits);
#endif
// 若在 timeout 用例中,没有发现新的边,则丢弃
if (!has_new_bits(virgin_tmout)) return keeping; // 检查超时的样例与之前保存的超时样例路径相比是否有新的边增加,如果没有则丢弃
}
unique_tmouts++; // 增加独特超时数
/* 在保存前,我们确保这是一个真正的挂起,通过使用更宽松的超时重新运行目标。 */
if (exec_tmout < hang_tmout) {
u8 new_fault;
write_to_testcase(mem, len);
new_fault = run_target(argv, hang_tmout);
/* 一个用户报告的边界情况:增加超时实际上揭示了一个崩溃。如果是这样,确保我们不会丢弃它。 */
// 重跑结果是 crash,报告
if (!stop_soon && new_fault == FAULT_CRASH) goto keep_as_crash;
// 重跑正常结束了,丢弃
if (stop_soon || new_fault != FAULT_TMOUT) return keeping;
}
#ifndef SIMPLE_FILES
// 根据不同的模式,生成文件名
// 这个 hang 用例要被保留,确定文件名
fn = alloc_printf("%s/hangs/id:%06llu,%s", out_dir, unique_hangs, describe_op(0));
#else
fn = alloc_printf("%s/hangs/id_%06llu", out_dir, unique_hangs);
#endif
unique_hangs++; // 增加 unique_hangs
last_hang_time = get_cur_time(); // 记录最后一次挂起时间
break;
case FAULT_CRASH: // 崩溃错误处理
keep_as_crash:
/* 处理方式类似于超时,但略有不同的限制,无需重新运行测试用例。 */
total_crashes++; // 增加总崩溃次数
if (unique_crashes >= KEEP_UNIQUE_CRASH) return keeping; // 如果独特崩溃已达到上限(5000),不再处理
if (!dumb_mode) {
#ifdef WORD_SIZE_64
simplify_trace((u64*)trace_bits); // 简化跟踪位
#else
simplify_trace((u32*)trace_bits);
#endif
if (!has_new_bits(virgin_crash)) return keeping; // 检查 crash 的样例与之前保存的 crash 样例路径相比是否有新的边增加,如果没有则丢弃
}
if (!unique_crashes) write_crash_readme(); // 如果是第一次崩溃,生成 README 文件
#ifndef SIMPLE_FILES
// 根据不同的模式,生成文件名
fn = alloc_printf("%s/crashes/id:%06llu,sig:%02u,%s", out_dir, unique_crashes, kill_signal, describe_op(0));
#else
fn = alloc_printf("%s/crashes/id_%06llu_%02u", out_dir, unique_crashes, kill_signal);
#endif
unique_crashes++; // 增加 unique_crashes 次数
last_crash_time = get_cur_time(); // 记录最后一次崩溃时间
last_crash_execs = total_execs; // 记录最后一次崩溃执行次数
break;
case FAULT_ERROR:
FATAL("Unable to execute target application"); // 处理错误的目标应用执行
default:
return keeping; // 对于其他情况,直接返回是否保持
}
/* 如果我们到这里,显然我们也想保存崩溃或挂起的测试用例。 */
fd = open(fn, O_WRONLY | O_CREAT | O_EXCL, 0600); // 创建文件
if (fd < 0) PFATAL("Unable to create '%s'", fn); // 如果创建文件失败,输出错误
ck_write(fd, mem, len, fn); // 将测试用例写入文件
close(fd); // 关闭文件
ck_free(fn); // 释放文件名字符串内存
return keeping; // 返回保持标志
}
add_to_queue
queue_entry 用于存储模糊测试中每个测试用例的详细信息。
fname:用于存储当前测试用例的文件路径。len:记录当前测试用例的长度。cal_failed:标记该测试用例是否在校准阶段失败。- 只要在
calibrate_case函数中校准失败该值就会加 1 ;否则就将该值置为 0 。
- 只要在
trim_done:标记该测试用例是否已被精简过。- 在
fuzz_one函数中如果当前测试用例已经经过了trim_case函数精简则会标记为trim_done。
- 在
was_fuzzed:标记该测试用例是否已经进行过一次或多次模糊测试。- 经历过
fuzz_one函数的测试用例都会标记为was_fuzzed。
- 经历过
passed_det:标记该测试用例是否已经通过了所有确定性变异阶段。- 在
fuzz_one函数中如果当前测试用例已经经过了确定性变异阶段则会调用mark_as_det_done函数标记passed_det。
- 在
has_new_cov:标记该测试用例是否在执行时触发了新的代码覆盖。calibrate_case或save_if_interesting函数中如果发现有路径上的变化(hit count 变化或者有探测到新的边)则会标记has_new_cov。
var_behavior:标记该测试用例是否在多次执行中显示出变化的行为。calibrate_case校准样例时会将多次执行路径发生变化的样例标记为var_behavior。
favored:标记该测试用例在模糊测试中是否被优先考虑。cull_queue函数在精简队列时会将感兴趣的样例标记为favored。
fs_redundant:标记该测试用例在文件系统中是否被视为冗余。cull_queue函数在精简队列时会将精简后不是favored的样例标记为fs_redundant。
bitmap_size:记录在执行该测试用例时,位图中有多少个字节非 0 ,即发现了多少条边。- 在
calibrate_case函数中会调用count_bytes函数更新该值。
- 在
exec_cksum:记录该测试用例对应的路径的校验和。calibrate_case或fuzz_one函数中更新该值。
exec_us:记录执行该测试用例所花费的时间(以微秒为单位)。handicap:记录该测试用例在队列中的处理次数落后于其他用例的程度。depth:记录该测试用例在最初的测试用例的基础上变异多少次得到的。trace_mini:如果使用该字段则该字段保留该测试用例执行路径的边,在原先的位图的基础上省略了次数,用 1 个比特代表一条边。- 在
update_bitmap_score函数中调用minimize_bits函数在原先得到的trace_bits基础上计算而来。
- 在
tc_ref:该测试用例的引用计数。主要用于update_bitmap_score和cull_queue函数精简队列时判断该测试用例是否在集合中。next:指向链表中下一个queue_entry结构体的指针,整个queue构成一个单向链表,由全局变量queue指向链表头。next_100:指向链表中100个元素之后的queue_entry结构体的指针,每 100 个用例才会有一个使用该字段,分块思想。
struct queue_entry {
u8* fname; /* 测试用例的文件名 */
u32 len; /* 输入长度 */
u8 cal_failed, /* 校准是否失败? */
trim_done, /* 是否已经修剪? */
was_fuzzed, /* 是否已经进行过模糊测试? */
passed_det, /* 是否通过了确定性阶段? */
has_new_cov, /* 是否触发了新的覆盖率? */
var_behavior, /* 是否表现出变化的行为? */
favored, /* 是否当前被优先考虑? */
fs_redundant; /* 在文件系统中是否标记为冗余? */
u32 bitmap_size, /* 位图中设置的位数 */
exec_cksum; /* 执行跟踪的校验和 */
u64 exec_us, /* 执行时间(微秒) */
handicap, /* 相对于队列周期的落后数量 */
depth; /* 路径深度 */
u8* trace_mini; /* 保留的跟踪字节,如果有的话 */
u32 tc_ref; /* 跟踪字节的引用计数 */
struct queue_entry *next, /* 链表中的下一个元素,如果有的话 */
*next_100; /* 链表中100个元素之后的元素 */
};
/* 将新的测试用例添加到队列中。 */
static void add_to_queue(u8* fname, u32 len, u8 passed_det) {
struct queue_entry* q = ck_alloc(sizeof(struct queue_entry)); // 为新的队列条目分配内存
q->fname = fname; // 设置文件名
q->len = len; // 设置输入长度
q->depth = cur_depth + 1; // 设置该测试用例在队列中的深度
q->passed_det = passed_det; // 设置是否通过了确定性测试阶段
if (q->depth > max_depth) max_depth = q->depth; // 更新队列的最大深度
if (queue_top) { // 如果队列非空
queue_top->next = q; // 将新条目添加到队列末尾
queue_top = q; // 更新队列末尾指针
} else q_prev100 = queue = queue_top = q; // 如果队列为空,初始化队列
queued_paths++; // 增加队列长度计数
pending_not_fuzzed++; // 增加尚未模糊测试的条目数
cycles_wo_finds = 0; // 重置未发现新路径的周期数
/* 每100个元素设置一次next_100指针(索引0,100等),以加快迭代速度。 */
if ((queued_paths - 1) % 100 == 0 && queued_paths > 1) {
q_prev100->next_100 = q; // 设置前100个元素的next_100指针
q_prev100 = q; // 更新前100个元素的指针
}
last_path_time = get_cur_time(); // 更新最后一个路径的时间
}
上面的代码里面提到了 depth 字段和 passed_det 字段。由于 AFL 是变异式 fuzzer,每个用例肯定是从某个已有用例的基础上变异来的。因此,假如初始语料集的深度为 1,那么从初始语料集直接变异出的用例,其深度就为 2,以此类推。至于 passed_det 字段,它用来表示一个用例是否完成过 deterministic 变异。
deterministic 变异阶段,是对用例执行一套「确定性」的变异策略,例如逐 bit 翻转。
由于 deterministic 变异很慢(实验次数与用例长度成正比),且对一个用例多次执行 deterministic 变异阶段,显然不可能有新的发现,因此 AFL 做了一个优化:假如一个用例已经完成过 deterministic 变异,就不会再做了。
现在,我们已经了解 corpus 是如何被存储在队列中的,也知道了一个用例如何被判定为「有趣」、如何在各个 fuzzer 之间同步。接下来,只要源源不断地产生新用例,交由 run_target 函数执行实验,然后由 save_if_interesting 判断是否有趣并保存,一个完整的 fuzzer 便诞生了。在主函数中我们已经看到,对于队列中的每一个元素,都将会调用 fuzz_one() 尝试在其基础上变异。
fuzz_one
fuzz_one 函数首先进行一些准备工作(例如把用例内容 mmap 到内存中),然后分别执行 deterministic、havoc、splicing 三个阶段,最后做一点清扫。
/* Take the current entry from the queue, fuzz it for a while. This
function is a tad too long... returns 0 if fuzzed successfully, 1 if
skipped or bailed out. */
static u8 fuzz_one(char** argv) {
// 决定是否直接跳过这个用例
// 准备用例文件
// 校准(若有必要)
// 用例裁剪
// 计算 perf_score
// 决定是否要跳过 deterministic 阶段
// 下面开始 deterministic 阶段
// ..................
// ..................
// 下面开始 havoc 阶段
// ..................
// ..................
// 下面开始 splicing 阶段
// ..................
// ..................
// 打扫现场
}
准备工作
拿到一个用例后,首先决定是否直接跳过它。
static u8 fuzz_one(char** argv) {
s32 len, fd, temp_len, i, j; // 定义一些局部变量,用于存储长度、文件描述符等
u8 *in_buf, *out_buf, *orig_in, *ex_tmp, *eff_map = 0; // 输入、输出缓冲区,原始输入,临时扩展,有效性映射
u64 havoc_queued, orig_hit_cnt, new_hit_cnt; // 用于记录havoc阶段的数量,原始和新的命中计数
u32 splice_cycle = 0, perf_score = 100, orig_perf, prev_cksum, eff_cnt = 1; // 定义周期、性能得分等变量
u8 ret_val = 1, doing_det = 0; // 返回值默认为1,表示进行确定性模糊测试的标志
u8 a_collect[MAX_AUTO_EXTRA]; // 自动收集的数组
u32 a_len = 0; // 自动收集的长度
if (pending_favored) {
// 若有暂未被 fuzz 过的 favored 用例
// 若当前用例已经被 fuzz 过了,或当前用例并非 favored,则以 99% 的概率跳过,让新 favored 用例先 fuzz
if ((queue_cur->was_fuzzed || !queue_cur->favored) &&
UR(100) < SKIP_TO_NEW_PROB) return 1; // 以99%的概率跳过已fuzz或非favored用例
} else if (!dumb_mode && !queue_cur->favored && queued_paths > 10) {
// 所有 favored 用例都被 fuzz 过,且当前用例并非 favored,且 corpus 大小超过 10
/* 否则,依旧可能会跳过非受青睐的用例,尽管概率较低。
跳过的概率对于已经fuzz过的输入更高,对于从未fuzz过的输入较低。 */
if (queue_cycle > 1 && !queue_cur->was_fuzzed) {
// 如果队列已经循环过一次且当前用例未被fuzz过,以75%的概率跳过
if (UR(100) < SKIP_NFAV_NEW_PROB) return 1;
} else {
// 若当前用例已被fuzz过,则以95%的概率跳过
if (UR(100) < SKIP_NFAV_OLD_PROB) return 1;
}
}
if (not_on_tty) {
// 如果不在终端上运行,打印当前正在fuzz的用例信息
ACTF("Fuzzing test case #%u (%u total, %llu uniq crashes found)...",
current_entry, queued_paths, unique_crashes);
fflush(stdout); // 刷新标准输出,确保信息被立即显示
}
上述代码的功能是概率性地跳过 non-favored 用例。白皮书中提到,当考虑 fuzz 一个 non-favored 用例时:
- 若队列中存在一个从来没被 fuzz 过的 favored 用例,则以 99% 概率跳过当前 fuzz 过的或非 favored 用例(要尽快去 fuzz 全新 favored 用例)
- 若没有全新的 favored 用例,且当前用例已经被 fuzz 过,则以 95% 概率跳过
- 若没有全新的 favored 用例,而当前用例没被 fuzz 过,则以 75% 概率跳过
于是,在 fuzz 运行后期,一个 non-favored 用例被跳过的几率高达 95%。这节省下来的时间,投入到 favored 用例的 fuzz 去了。这究竟是否合理,有待商榷(事实上 favored 集的选取过程也不是无懈可击)——存在很多论文,通过改进 AFL 对各个种子的资源分配,提升了挖漏洞的效率。AFLFast 就是其代表。
之后把用例文件 mmap 进地址空间,并给 out_buf 分配内存。这个 out_buf 用于存储变异出来的用例。
/* 将测试用例映射到内存中。 */
// 打开当前队列中指定的用例文件
fd = open(queue_cur->fname, O_RDONLY); // 以只读方式打开文件
if (fd < 0) PFATAL("Unable to open '%s'", queue_cur->fname); // 如果文件打开失败,输出错误信息
len = queue_cur->len; // 获取当前测试用例的长度
// 使用 mmap() 将文件映射到地址空间,允许读写,但不会对原文件进行修改(MAP_PRIVATE)
orig_in = in_buf = mmap(0, len, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
if (orig_in == MAP_FAILED) PFATAL("Unable to mmap '%s'", queue_cur->fname); // 如果映射失败,输出错误信息
close(fd); // 关闭文件描述符
/* 我们可以使用 MAP_PRIVATE 方式映射 out_buf,但是我们最终会修改每一个字节,
所以这样做不会带来性能或内存使用上的优势。 */
// 为 out_buf 分配内存,out_buf 用来存储变异后的测试用例,这些用例将被交给目标程序执行
out_buf = ck_alloc_nozero(len);
subseq_tmouts = 0; // 用于跟踪后续的超时次数
// 设置全局变量 cur_depth 为当前测试用例的深度
cur_depth = queue_cur->depth;
对当前用例进行校准。我们提到过,进了 queue 的用例都需要校准,无论是初始 corpus 还是 fuzz 过程中发现的有趣用例。但校准不一定成功,所以在 fuzz 这个用例的时候,若它没有被校准成功过,则尝试校准。
/*******************************************
* CALIBRATION (only if failed earlier on) *
*******************************************/
// 若本用例上次校准失败,则重新校准一次
if (queue_cur->cal_failed) {
u8 res = FAULT_TMOUT; // 默认将结果设置为超时
// 最多校准一个用例3次
if (queue_cur->cal_failed < CAL_CHANCES) {
/* 重置 exec_cksum 以通知 calibrate_case 重新执行测试用例,
避免使用无效的 trace_bits。
更多信息参见:https://github.com/AFLplusplus/AFLplusplus/pull/425 */
queue_cur->exec_cksum = 0;
// 重新进行校准,传入当前测试用例和输入缓冲区
res = calibrate_case(argv, queue_cur, in_buf, queue_cycle - 1, 0);
// 如果校准过程中发生错误,输出致命错误并终止程序
if (res == FAULT_ERROR)
FATAL("Unable to execute target application");
}
// 若行为异常(理应正常运行的用例 crash 掉了),则跳过这个用例
if (stop_soon || res != crash_mode) {
cur_skipped_paths++; // 增加跳过的路径数
goto abandon_entry; // 跳转到 abandon_entry,放弃处理当前条目
}
}
接下来进行用例裁剪:
/************
* TRIMMING *
************/
// 如果当前用例没有被裁剪过,并且不是在“dumb模式”下运行,则进行裁剪
if (!dumb_mode && !queue_cur->trim_done) {
// 调用 trim_case 函数尝试裁剪当前测试用例,返回结果存储在 res 中
u8 res = trim_case(argv, queue_cur, in_buf);
// 如果裁剪过程中发生错误,输出致命错误并终止程序
if (res == FAULT_ERROR)
FATAL("Unable to execute target application");
// 如果收到停止信号,增加跳过的路径计数,并跳到 abandon_entry 标签,放弃当前条目
if (stop_soon) {
cur_skipped_paths++;
goto abandon_entry;
}
/* 即使裁剪失败,也不再重试裁剪。 */
// 标记当前用例的裁剪已完成
queue_cur->trim_done = 1;
// 如果裁剪后长度有变化,更新 len 变量为新的长度
if (len != queue_cur->len) len = queue_cur->len;
}
// 将裁剪后的输入缓冲区内容复制到输出缓冲区
memcpy(out_buf, in_buf, len);
afl-fuzz 中的裁剪算法如下:
/*******************************************
* 裁剪:对新测试用例进行裁剪,以节约在进行确定性检查时的循环次数。裁剪器使用文件大小
* 之间的2的幂次增量(介于1/16到1/1024之间),以保持阶段短小精悍。
*******************************************/
static u8 trim_case(char** argv, struct queue_entry* q, u8* in_buf) {
static u8 tmp[64]; // 临时缓冲区
static u8 clean_trace[MAP_SIZE]; // 用于存放干净的执行追踪
u8 needs_write = 0, fault = 0; // 标志是否需要写入,以及错误标志
u32 trim_exec = 0; // 裁剪执行次数
u32 remove_len; // 要移除的长度
u32 len_p2; // 2的幂次长度
// 如果检测到变量行为,裁剪器可能不太有用,但仍然可以在一定程度上工作,因此我们不检查这一点
// 不裁剪长度小于5的用例
if (q->len < 5) return 0;
// 更新全局变量
stage_name = tmp;
bytes_trim_in += q->len; // 累计裁剪输入的字节数
/* 选择初始块长度,从大步长开始。 */
// 计算大于等于len的最小2的幂
len_p2 = next_p2(q->len);
// 以2的幂次中的1/16作为起始块大小,最小块大小为4字节
remove_len = MAX(len_p2 / TRIM_START_STEPS, TRIM_MIN_BYTES);
/* 继续裁剪,直到步长变得太高或步进变得太小。 */
while (remove_len >= MAX(len_p2 / TRIM_END_STEPS, TRIM_MIN_BYTES)) {
u32 remove_pos = remove_len;
sprintf(tmp, "trim %s/%s", DI(remove_len), DI(remove_len));
stage_cur = 0;
stage_max = q->len / remove_len; // 计算最大阶段数
while (remove_pos < q->len) {
u32 trim_avail = MIN(remove_len, q->len - remove_pos); // 确定可裁剪的实际长度
u32 cksum; // 校验和
// 裁剪指定块
write_with_gap(in_buf, q->len, remove_pos, trim_avail);
// 运行裁剪后的用例
fault = run_target(argv, exec_tmout);
trim_execs++;
if (stop_soon || fault == FAULT_ERROR) goto abort_trimming; // 如有停止信号或错误,中止裁剪
cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST); // 计算裁剪后的校验和
// 如果裁剪无影响,则将其设为永久
if (cksum == q->exec_cksum) {
u32 move_tail = q->len - remove_pos - trim_avail;
q->len -= trim_avail; // 更新长度
len_p2 = next_p2(q->len); // 更新2的幂次长度
memmove(in_buf + remove_pos, in_buf + remove_pos + trim_avail, move_tail); // 移动内存
// 保存干净的执行追踪
if (!needs_write) {
needs_write = 1;
memcpy(clean_trace, trace_bits, MAP_SIZE);
}
} else {
remove_pos += remove_len; // 更新裁剪位置
}
// 更新统计信息
if (!(trim_exec++ % stats_update_freq)) show_stats();
stage_cur++;
}
remove_len >>= 1; // 块长度减半,继续下一轮裁剪。
}
/* 如果我们对输入缓冲区进行了更改,则还需要更新磁盘上的测试用例版本。 */
if (needs_write) {
s32 fd;
unlink(q->fname); // 删除原文件,忽略错误
// 重新创建文件,以独占方式打开
fd = open(q->fname, O_WRONLY | O_CREAT | O_EXCL, 0600);
if (fd < 0) PFATAL("Unable to create '%s'", q->fname); // 如果无法创建文件,报告致命错误
// 写入裁剪后的数据到新文件
ck_write(fd, in_buf, q->len, q->fname);
close(fd); // 关闭文件
// 将裁剪后保持的干净追踪复制回trace_bits
memcpy(trace_bits, clean_trace, MAP_SIZE);
// 更新位图分数
update_bitmap_score(q);
}
abort_trimming:
// 记录裁剪后的总字节数
bytes_trim_out += q->len;
return fault; // 返回裁剪过程中发生的任何错误
}
可见 afl-fuzz 中的用例裁剪算法,就是 afl-tmin 的子集。它只使用了 afl-tmin 中的 block deletion 优化,而没有使用 alphabet minimization 和 character minimization。这显然是为了提升 fuzz 效率,尽量少浪费时间。
fuzzer 会给用例打分,依靠这个分数决定 havoc 阶段的投入资源(指「实验次数」,下同)。另外,如果全局变量 skip_deterministic 为 1、或这个用例曾经被 fuzz 过、或这个用例已经完成了 deterministic 变异阶段,则跳过 deterministic 变异,直接进入 havoc 。
如果同时运行了多个 master(AFL 确实支持这样做,需要提供特定的 -M 选项),则它只会承担自己该处理的那部分用例的 deterministic 变异。
/*********************
* 表现评分 *
*********************/
// 计算当前测试用例的表现评分
orig_perf = perf_score = calculate_score(queue_cur);
/* 如果设置了跳过确定性模糊测试的标志 `-d`,或者这个条目已经在我们手上经过了确定性模糊测试
(was_fuzzed),或者它在之前的恢复运行中已经经过了确定性测试 (passed_det),就立即跳过。 */
if (skip_deterministic || queue_cur->was_fuzzed || queue_cur->passed_det)
goto havoc_stage; // 直接跳转到havoc阶段
/* 如果执行路径校验和使这个条目不在此 master 实例的作用范围内,也跳过确定性模糊测试。 */
// 如果设置了master最大值,并且当前测试用例的执行校验和与master ID不符,也跳过确定性模糊测试
if (master_max && (queue_cur->exec_cksum % master_max) != master_id - 1)
goto havoc_stage;
// 设置正在进行确定性模糊测试的标志
doing_det = 1;
calculate_score 函数负责打分。简而言之,跑得越快、覆盖度越高、深度越大,分数就会越高,在 havoc 阶段就会有更多资源来尝试变异。
/* 计算测试用例的性能得分,以调整havoc模糊测试的长度。
这是fuzz_one()函数的辅助函数。某些常量应该放在config.h中。 */
static u32 calculate_score(struct queue_entry* q) {
// 计算全局平均执行时间
u32 avg_exec_us = total_cal_us / total_cal_cycles;
// 计算全局平均bitmap大小
u32 avg_bitmap_size = total_bitmap_size / total_bitmap_entries;
u32 perf_score = 100; // 基础性能得分设置为100
/* 根据这个路径的执行速度与全局平均值比较,调整得分。乘数范围从0.1x到3x。
执行速度快的输入成本较低,因此我们给予它们更多的运行时间。 */
// 如果执行时间较短,则提高得分,执行快的用例更有可能在短时间内被多次测试,提高发现漏洞的概率
if (q->exec_us * 0.1 > avg_exec_us) perf_score = 10;
else if (q->exec_us * 0.25 > avg_exec_us) perf_score = 25;
else if (q->exec_us * 0.5 > avg_exec_us) perf_score = 50;
else if (q->exec_us * 0.75 > avg_exec_us) perf_score = 75;
else if (q->exec_us * 4 < avg_exec_us) perf_score = 300;
else if (q->exec_us * 3 < avg_exec_us) perf_score = 200;
else if (q->exec_us * 2 < avg_exec_us) perf_score = 150;
/* 根据bitmap大小调整得分。理论是更好的覆盖率意味着更好的目标。乘数从0.25x到3x。 */
// 如果bitmap覆盖率高,说明可能探测到更多的代码路径,因此增加得分
if (q->bitmap_size * 0.3 > avg_bitmap_size) perf_score *= 3;
else if (q->bitmap_size * 0.5 > avg_bitmap_size) perf_score *= 2;
else if (q->bitmap_size * 0.75 > avg_bitmap_size) perf_score *= 1.5;
else if (q->bitmap_size * 3 < avg_bitmap_size) perf_score *= 0.25;
else if (q->bitmap_size * 2 < avg_bitmap_size) perf_score *= 0.5;
else if (q->bitmap_size * 1.5 < avg_bitmap_size) perf_score *= 0.75;
/* 根据劣势(handicap)调整得分。劣势与我们了解这条路径的晚早成正比。
新发现的路径被允许运行更长时间,直到它们赶上其他路径。 */
// 对后来发现的路径给予更多的运行时间,以提高其被测试的机会
if (q->handicap >= 4) {
perf_score *= 4;
q->handicap -= 4;
} else if (q->handicap) {
perf_score *= 2;
q->handicap--;
}
/* 根据输入深度的最后调整,假设对更深的测试用例进行模糊测试更可能揭示一些传统模糊器无法发现的东西。 */
// 更深的测试用例可能探测到更隐蔽的错误,因此提高其得分
switch (q->depth) {
case 0 ... 3: break; // 不调整
case 4 ... 7: perf_score *= 2; break; // 适度增加
case 8 ... 13: perf_score *= 3; break; // 进一步增加
case 14 ... 25: perf_score *= 4; break; // 大幅增加
default: perf_score *= 5; // 最大增加
}
/* 确保得分不超过上限。 */
// 最终得分的上限是100乘以混沌阶段的最大倍数,最多给 1600 分
if (perf_score > HAVOC_MAX_MULT * 100) perf_score = HAVOC_MAX_MULT * 100;
return perf_score; // 返回计算出的性能得分
}
deterministic 变异阶段
第一个确定性变异算子是 bitflip 1/1,代码如下:
/*********************************************
* 简单的比特翻转 (+字典构建) *
*********************************************/
// 定义一个宏 FLIP_BIT,用于翻转数组 `_ar` 的第 `_b` 个位
#define FLIP_BIT(_ar, _b) do { \
u8* _arf = (u8*)(_ar); \
u32 _bf = (_b); \
_arf[(_bf) >> 3] ^= (128 >> ((_bf) & 7)); \
} while (0)
/* 单比特步进 */
stage_short = "flip1"; // 阶段简称
stage_max = len << 3; // 最大迭代次数,等于长度乘以8(每个字节8位)
stage_name = "bitflip 1/1"; // 阶段名称
stage_val_type = STAGE_VAL_NONE; // 阶段值类型
// 原始的命中次数
orig_hit_cnt = queued_paths + unique_crashes;
// 上一次的执行校验和
prev_cksum = queue_cur->exec_cksum;
// 对每一个位进行翻转
for (stage_cur = 0; stage_cur < stage_max; stage_cur++) {
// 计算当前位的字节位置
stage_cur_byte = stage_cur >> 3;
// 翻转当前位
FLIP_BIT(out_buf, stage_cur);
// 执行模糊测试
if (common_fuzz_stuff(argv, out_buf, len)) goto abandon_entry;
// 再次翻转以恢复原状
FLIP_BIT(out_buf, stage_cur);
/* 当翻转每个字节的最低有效位时,额外检测可能的语法标记。理念是,如果你有如下的二进制数据:
xxxxxxxxIHDRxxxxxxxx
改变首尾字节导致的程序流变化不大,但触碰"IHDR"中任何字符总是产生相同的、独特的路径,
高度可能"IHDR"是对被模糊处理格式具有特殊意义的原子性检查的魔术值。
我们在这里而不是作为单独阶段进行检测,因为这是保持操作近似“免费”的好方法(即,无需额外执行)。*/
// 如果不是"dumb"模式且当前是字节的最后一位
if (!dumb_mode && (stage_cur & 7) == 7) {
// 计算当前追踪位的校验和
u32 cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST);
// 如果到达文件末尾且校验和未变,则收集最后一个字符并尝试输出
if (stage_cur == stage_max - 1 && cksum == prev_cksum) {
// 如果自动额外字节长度小于最大限制,则收集当前字节
if (a_len < MAX_AUTO_EXTRA) a_collect[a_len] = out_buf[stage_cur >> 3];
a_len++;
// 如果收集的长度符合自动添加的最小和最大要求,则可能添加到自动额外数据中
if (a_len >= MIN_AUTO_EXTRA && a_len <= MAX_AUTO_EXTRA)
maybe_add_auto(a_collect, a_len);
} else if (cksum != prev_cksum) { // 否则,如果校验和发生变化,检查是否有值得队列的内容
// 如果收集的长度符合要求,则添加
if (a_len >= MIN_AUTO_EXTRA && a_len <= MAX_AUTO_EXTRA)
maybe_add_auto(a_collect, a_len);
// 清空当前收集的字典 token
a_len = 0;
// 更新前一个校验和
prev_cksum = cksum;
}
/* 如果校验和发生了变化并且与队列当前执行校验和不同,继续收集字符串。
我们不想收集无操作变化的标记。 */
if (cksum != queue_cur->exec_cksum) {
if (a_len < MAX_AUTO_EXTRA) {
// 如果自动收集的长度还没达到上限,继续收集当前翻转后的字节
a_collect[a_len] = out_buf[stage_cur >> 3];
}
a_len++; // 自动收集长度增加
}
}
}
// 更新阶段发现的新命中数量
new_hit_cnt = queued_paths + unique_crashes;
// 更新本阶段发现的新路径和循环次数
stage_finds[STAGE_FLIP1] += new_hit_cnt - orig_hit_cnt;
stage_cycles[STAGE_FLIP1] += stage_max;
主要的逻辑就是翻转用例的每一个 bit,调用 common_fuzz_stuff() 去实验。但这里额外做了一件事——自动寻找 extra,构建词典。
如果翻转一个字节的 LSB,发现程序行为与原用例不同,则这个字节可能属于一个 extra token。如果翻转这个字节的 LSB,程序行为与翻转前一个 LSB 的行为不同,则说明这里是 token 的分界点。
首先是 common_fuzz_stuff 函数,它的流程是将变异出的用例写入 out_file 文件、调用 run_target() 执行实验、调用 save_if_interesting() 以考虑将这个用例保存下来。除此之外,它会维护一个计数器 subseq_tmouts,这是全局变量,表示当前这个用例的 fuzz 过程中超时了多少次。如果连续超时 250 次,则认为继续变异下去也会超时,于是放弃 fuzz 这个用例。
/* 写入修改后的测试用例,运行程序,处理结果。
处理错误情况,如果需要退出则返回1。
这是 fuzz_one() 的辅助函数。 */
EXP_ST u8 common_fuzz_stuff(char** argv, u8* out_buf, u32 len) {
u8 fault; // 存储运行结果状态
// 如果存在后处理函数,则调用该函数处理输出缓冲区
if (post_handler) {
// 调用配置的 post_handler 函数处理 out_buf,可能修改 len
out_buf = post_handler(out_buf, &len);
// 如果处理后没有输出或长度为0,则直接返回0
if (!out_buf || !len) return 0;
}
// 将测试用例写入文件,准备运行
write_to_testcase(out_buf, len);
// 运行被测程序,并记录运行结果(如崩溃、超时等)
fault = run_target(argv, exec_tmout);
// 如果接收到停止信号,则返回1以退出
if (stop_soon) return 1;
// 记录连续超时的次数。如果连续超时次数过多,放弃这个测试用例
if (fault == FAULT_TMOUT) {
if (subseq_tmouts++ > TMOUT_LIMIT) {
cur_skipped_paths++;
return 1;
}
} else subseq_tmouts = 0; // 如果不是超时,重置连续超时计数器
/* 用户可以通过发送 SIGUSR1 信号来请求放弃当前输入。 */
// 如果收到跳过当前测试用例的请求,则重置请求标志,增加跳过路径计数,返回1
if (skip_requested) {
skip_requested = 0;
cur_skipped_paths++;
return 1;
}
/* 这里处理 FAULT_ERROR 状态: */
// 调用 save_if_interesting 函数判断当前测试用例是否有趣(比如是否触发了新的代码路径或崩溃)
queued_discovered += save_if_interesting(argv, out_buf, len, fault);
// 每隔一定次数更新统计信息,或者在阶段结束时更新
if (!(stage_cur % stats_update_freq) || stage_cur + 1 == stage_max)
show_stats();
return 0; // 正常结束返回0
}
maybe_add_auto 函数负责自动添加有效的额外数据(如潜在的重要字节序列)到一个动态字典中。
从实现上可见,afl-fuzz 中有两个词典:
- 一个是程序运行之初导入的用户词典,存放在
extras数组; - 另一个是 fuzzer 自己发现的 extra token 组成的词典,放在
a_extras数组。
后者至多 500 个,如果超过了,则随机驱逐一个排名 250 以后的 token。
/* 尝试添加自动额外数据 */
static void maybe_add_auto(u8* mem, u32 len) {
u32 i;
/* 允许用户指定不使用自动字典 */
if (!MAX_AUTO_EXTRAS || !USE_AUTO_EXTRAS) return;
/* 跳过连续相同字节的运行 */
for (i = 1; i < len; i++)
if (mem[0] ^ mem[i]) break;
if (i == len) return; // 如果所有字节都相同,返回
/* 排除内置的有趣值 */
if (len == 2) {
i = sizeof(interesting_16) >> 1;
while (i--)
if (*((u16*)mem) == interesting_16[i] ||
*((u16*)mem) == SWAP16(interesting_16[i])) return; // 如果长度为2的值与有趣值匹配,返回
}
if (len == 4) {
i = sizeof(interesting_32) >> 2;
while (i--)
if (*((u32*)mem) == interesting_32[i] ||
*((u32*)mem) == SWAP32(interesting_32[i])) return; // 如果长度为4的值与有趣值匹配,返回
}
/* 排除与现有 extras 项匹配的内容。不区分大小写比较。通过利用 extras[] 按大小排序的事实来优化 */
for (i = 0; i < extras_cnt; i++)
if (extras[i].len >= len) break;
for (; i < extras_cnt && extras[i].len == len; i++)
if (!memcmp_nocase(extras[i].data, mem, len)) return; // 如果与已存在的 extras 项匹配,返回
/* 最后检查 a_extras[] 以寻找匹配。没有特定的排序顺序保证 */
auto_changed = 1;
for (i = 0; i < a_extras_cnt; i++) {
if (a_extras[i].len == len && !memcmp_nocase(a_extras[i].data, mem, len)) {
a_extras[i].hit_cnt++; // 如果找到匹配,增加命中计数
goto sort_a_extras;
}
}
/* 在这一点上,看来我们正在处理一个新条目。因此,如果有空间,就添加它。
否则,从列表的下半部随机逐出其他条目 */
if (a_extras_cnt < MAX_AUTO_EXTRAS) {
a_extras = ck_realloc_block(a_extras, (a_extras_cnt + 1) * sizeof(struct extra_data));
a_extras[a_extras_cnt].data = ck_memdup(mem, len);
a_extras[a_extras_cnt].len = len;
a_extras_cnt++;
} else {
i = MAX_AUTO_EXTRAS / 2 + UR((MAX_AUTO_EXTRAS + 1) / 2);
ck_free(a_extras[i].data);
a_extras[i].data = ck_memdup(mem, len);
a_extras[i].len = len;
a_extras[i].hit_cnt = 0;
}
sort_a_extras:
/* 首先,按使用计数降序对所有自动额外项进行排序 */
qsort(a_extras, a_extras_cnt, sizeof(struct extra_data), compare_extras_use_d);
/* 然后,按大小对前 USE_AUTO_EXTRAS 个条目排序 */
qsort(a_extras, MIN(USE_AUTO_EXTRAS, a_extras_cnt), sizeof(struct extra_data), compare_extras_len);
}
之后进行 bitflip 2/1 变异,对于用例中所有的连续 2bit 进行翻转。
bitflip a/b 的意思是翻转连续的 a 个 bit、步长为 b。例如 bitflip 2/1 就是翻转所有连续 2bit,bitflip 8/8 是每隔 8 个 bit 尝试翻转连续 8bit,也就是尝试翻转每一个字节。另外这里的连续是大端序下的连续。
/* 两个连续比特翻转 */
stage_name = "bitflip 2/1"; // 阶段名称
stage_short = "flip2"; // 阶段简称
stage_max = (len << 3) - 1; // 最大执行次数,减1是因为每次翻转两个比特
orig_hit_cnt = new_hit_cnt; // 保存原始的命中计数(路径数量加上独特崩溃数量)
// 遍历所有可能的比特位置,直到倒数第二个比特(因为每次需要翻转两个比特)
for (stage_cur = 0; stage_cur < stage_max; stage_cur++) {
stage_cur_byte = stage_cur >> 3; // 计算当前字节位置
FLIP_BIT(out_buf, stage_cur); // 翻转当前比特
FLIP_BIT(out_buf, stage_cur + 1); // 翻转下一个比特
// 执行测试用例并处理结果,如果需要中止,则跳转到 abandon_entry 标签
if (common_fuzz_stuff(argv, out_buf, len)) goto abandon_entry;
// 恢复原来的比特状态
FLIP_BIT(out_buf, stage_cur);
FLIP_BIT(out_buf, stage_cur + 1);
}
new_hit_cnt = queued_paths + unique_crashes; // 更新新的命中计数
// 记录在这个阶段中找到的新路径和崩溃数
stage_finds[STAGE_FLIP2] += new_hit_cnt - orig_hit_cnt;
// 记录执行次数
stage_cycles[STAGE_FLIP2] += stage_max;
bitflip 4/1 变异,尝试翻转连续 4bit 。
/* Four walking bits. */
stage_name = "bitflip 4/1";
stage_short = "flip4";
stage_max = (len << 3) - 3;
orig_hit_cnt = new_hit_cnt;
for (stage_cur = 0; stage_cur < stage_max; stage_cur++) {
stage_cur_byte = stage_cur >> 3;
FLIP_BIT(out_buf, stage_cur);
FLIP_BIT(out_buf, stage_cur + 1);
FLIP_BIT(out_buf, stage_cur + 2);
FLIP_BIT(out_buf, stage_cur + 3);
if (common_fuzz_stuff(argv, out_buf, len)) goto abandon_entry;
FLIP_BIT(out_buf, stage_cur);
FLIP_BIT(out_buf, stage_cur + 1);
FLIP_BIT(out_buf, stage_cur + 2);
FLIP_BIT(out_buf, stage_cur + 3);
}
new_hit_cnt = queued_paths + unique_crashes;
stage_finds[STAGE_FLIP4] += new_hit_cnt - orig_hit_cnt;
stage_cycles[STAGE_FLIP4] += stage_max;
bitflip 8/8 ,尝试翻转每个字节。在这个过程中,顺路做了敏感度分析。这个敏感度分析也比我们分析过的 afl-analyze 要粗糙,只要翻转后的执行路径与原始用例不同,就认为这个位置敏感(准确地说,认为这个位置所处的 8 字节的块敏感)。如果一个位置不敏感(对应 afl-analyze 中的「no-op」),那我们就不需要对那个位置做一些比较耗时的确定性变异。
/* 初始化影响图(effector map),用于标记哪些字节改变会影响程序行为。
始终标记第一个和最后一个字节为有效(即有影响)。*/
eff_map = ck_alloc(EFF_ALEN(len)); // 分配足够存储影响图的空间
eff_map[0] = 1; // 第一个字节始终被标记为有影响
// 如果最后一个字节的位置不是第一个字节,则也将其标记为有影响
if (EFF_APOS(len - 1) != 0) {
eff_map[EFF_APOS(len - 1)] = 1;
eff_cnt++;
}
/* 字节级别的翻转 */
stage_name = "bitflip 8/8"; // 阶段名称
stage_short = "flip8"; // 阶段简称
stage_max = len; // 此阶段的最大执行次数,等于输入长度
orig_hit_cnt = new_hit_cnt; // 保存原始命中计数(路径数量加上独特崩溃数量)
// 遍历输入的每个字节进行翻转
for (stage_cur = 0; stage_cur < stage_max; stage_cur++) {
stage_cur_byte = stage_cur; // 当前处理的字节位置
out_buf[stage_cur] ^= 0xFF; // 对当前字节进行翻转
// 执行测试用例并处理结果,如果需要中止,则跳转到 abandon_entry 标签
if (common_fuzz_stuff(argv, out_buf, len)) goto abandon_entry;
/* 使用这个阶段来识别那些即使完全翻转也不影响当前执行路径的字节,
并在更昂贵的确定性阶段(如算术运算或已知整数处理)中跳过它们。*/
if (!eff_map[EFF_APOS(stage_cur)]) { // 如果当前字节位置尚未标记为有影响
u32 cksum; // 用于存储执行后的校验和
// 如果不是"dumb"模式且文件长度足够长,则计算校验和
if (!dumb_mode && len >= EFF_MIN_LEN)
cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST);
else
cksum = ~queue_cur->exec_cksum;
// 如果翻转字节后校验和发生变化,则标记该位置为有影响
if (cksum != queue_cur->exec_cksum) {
eff_map[EFF_APOS(stage_cur)] = 1;
eff_cnt++;
}
}
// 恢复字节的原始状态
out_buf[stage_cur] ^= 0xFF;
}
/* 如果影响图的密度超过 90%,则标记整个数组为值得模糊测试,
因为我们无论如何都不会节省太多时间。*/
if (eff_cnt != EFF_ALEN(len) &&
eff_cnt * 100 / EFF_ALEN(len) > EFF_MAX_PERC) {
memset(eff_map, 1, EFF_ALEN(len)); // 将整个影响图标记为 1
blocks_eff_select += EFF_ALEN(len);
} else {
blocks_eff_select += eff_cnt;
}
blocks_eff_total += EFF_ALEN(len);
new_hit_cnt = queued_paths + unique_crashes; // 更新新的命中计数
// 记录在这个阶段中找到的新路径和崩溃数
stage_finds[STAGE_FLIP8] += new_hit_cnt - orig_hit_cnt;
// 记录执行次数
stage_cycles[STAGE_FLIP8] += stage_max;
bitflip 16/8 尝试翻转所有连续的 2 字节。它使用到了刚刚构建的敏感度信息——若当前位置不敏感,则直接放弃翻转,节省一次实验。
// 如果用例长度小于2,跳出bitflip阶段
if (len < 2) goto skip_bitflip;
stage_name = "bitflip 16/8"; // 阶段名称,指示当前进行的是16位翻转
stage_short = "flip16"; // 阶段简称
stage_cur = 0; // 当前阶段进度初始化
stage_max = len - 1; // 这个阶段的最大次数,因为每次处理2字节,所以长度减1
orig_hit_cnt = new_hit_cnt; // 保存原始的命中计数
// 遍历每个可能的两字节组合
for (i = 0; i < len - 1; i++) {
/* 查询 eff_map ,判断这两个字节是否值得测试 */
// 如果当前两个字节在影响图中都标记为无效,则跳过此次循环,并且阶段最大次数减一
if (!eff_map[EFF_APOS(i)] && !eff_map[EFF_APOS(i + 1)]) {
stage_max--;
continue;
}
stage_cur_byte = i; // 当前处理的起始字节
// 在out_buf中的位置i处翻转2字节
*(u16*)(out_buf + i) ^= 0xFFFF;
// 执行变异的测试用例,并处理结果,如果需要中止,跳转到abandon_entry
if (common_fuzz_stuff(argv, out_buf, len)) goto abandon_entry;
stage_cur++; // 更新当前阶段的进度
// 再次翻转回原始状态
*(u16*)(out_buf + i) ^= 0xFFFF;
}
// 更新新的命中计数
new_hit_cnt = queued_paths + unique_crashes;
// 在这个阶段中找到的新路径和崩溃数更新
stage_finds[STAGE_FLIP16] += new_hit_cnt - orig_hit_cnt;
// 执行的总次数更新
stage_cycles[STAGE_FLIP16] += stage_max;
bitflip 32/8,试图翻转所有连续的 uint32 。
// 如果输入长度小于4字节,则跳过32位翻转阶段
if (len < 4) goto skip_bitflip;
/* 四字节连续翻转 */
stage_name = "bitflip 32/8"; // 阶段名称
stage_short = "flip32"; // 阶段简称
stage_cur = 0; // 当前阶段进度初始化
stage_max = len - 3; // 这个阶段的最大次数,因为每次处理4字节,所以长度减3
orig_hit_cnt = new_hit_cnt; // 保存原始的命中计数
// 遍历每个可能的四字节块
for (i = 0; i < len - 3; i++) {
/* 查询影响图,判断这四个字节是否值得测试 */
// 如果这四个字节位置的影响图都标记为无效,则跳过此次循环,并且阶段最大次数减一
if (!eff_map[EFF_APOS(i)] && !eff_map[EFF_APOS(i + 1)] &&
!eff_map[EFF_APOS(i + 2)] && !eff_map[EFF_APOS(i + 3)]) {
stage_max--;
continue;
}
stage_cur_byte = i; // 当前处理的起始字节
// 在out_buf中的位置i处翻转4字节
*(u32*)(out_buf + i) ^= 0xFFFFFFFF;
// 执行变异的测试用例,并处理结果,如果需要中止,跳转到abandon_entry
if (common_fuzz_stuff(argv, out_buf, len)) goto abandon_entry;
stage_cur++; // 更新当前阶段的进度
// 再次翻转回原始状态
*(u32*)(out_buf + i) ^= 0xFFFFFFFF;
}
// 更新新的命中计数
new_hit_cnt = queued_paths + unique_crashes;
// 在这个阶段中找到的新路径和崩溃数更新
stage_finds[STAGE_FLIP32] += new_hit_cnt - orig_hit_cnt;
// 执行的总次数更新
stage_cycles[STAGE_FLIP32] += stage_max;
arith 变异就是给每个 uint8 、uint16 、uint32 加上和减去一个量(1 到 35 之间),进行实验。另外,如果 bitflip 已经覆盖到了,则不重复实验。由于 arith 变异对每个位置要尝试大约 35×3 次实验,耗时很长。
// 如果设置了环境变量 AFL_NO_ARITH,跳过算术变异阶段
if (no_arith) goto skip_arith;
/**********************
* ARITHMETIC INC/DEC *
**********************/
/* 8-bit 算术操作 */
stage_name = "arith 8/8"; // 阶段名称,表示进行8位的算术变异
stage_short = "arith8"; // 阶段简称
stage_cur = 0; // 当前处理的进度
stage_max = 2 * len * ARITH_MAX; // 这个阶段的最大次数
stage_val_type = STAGE_VAL_LE; // 设置值类型为 little-endian
orig_hit_cnt = new_hit_cnt; // 保存原始的命中计数
// 遍历每个字节
for (i = 0; i < len; i++) {
u8 orig = out_buf[i]; // 保存原始字节值
/* 查询影响图,判断这个字节是否值得测试 */
if (!eff_map[EFF_APOS(i)]) {
stage_max -= 2 * ARITH_MAX; // 如果字节不敏感,减少这个阶段的最大次数
continue;
}
stage_cur_byte = i; // 设置当前处理的字节位置
// ARITH_MAX 定义为35,表示尝试的最大值
for (j = 1; j <= ARITH_MAX; j++) {
// 对当前字节加j
u8 r = orig ^ (orig + j); // 计算异或结果,用来判断是否可能由bitflip产生
/* 只有当加法结果不能由bitflip产生时才执行操作 */
if (!could_be_bitflip(r)) {
stage_cur_val = j;
out_buf[i] = orig + j; // 执行加法
// 执行变异的测试用例,并处理结果
if (common_fuzz_stuff(argv, out_buf, len)) goto abandon_entry;
stage_cur++;
} else stage_max--; // 如果结果可能是bitflip,则减少最大次数
// 对当前字节减j
r = orig ^ (orig - j); // 计算异或结果,用来判断是否可能由bitflip产生
if (!could_be_bitflip(r)) {
stage_cur_val = -j;
out_buf[i] = orig - j; // 执行减法
// 执行变异的测试用例,并处理结果
if (common_fuzz_stuff(argv, out_buf, len)) goto abandon_entry;
stage_cur++;
} else stage_max--; // 如果结果可能是bitflip,则减少最大次数
out_buf[i] = orig; // 恢复原始字节
}
}
new_hit_cnt = queued_paths + unique_crashes; // 更新新的命中计数
stage_finds[STAGE_ARITH8] += new_hit_cnt - orig_hit_cnt; // 更新发现的新路径或崩溃
stage_cycles[STAGE_ARITH8] += stage_max; // 更新执行的总次数
/* 16-bit arithmetics, both endians. */
// 下面是 16bit 的 arith,大致与 8bit 的相同。但分别考虑了大小端
// ... 略
/* 32-bit arithmetics, both endians. */
// 下面是 32bit 的 arith
// ... 略
接下来是 interesting values 变异。interest 8/8 就是对于每个字节,把它替换成有趣的值。8bit 的有趣的值包括: -128, -1, 0, 1, 16, 32, 64, 100, 127。不敏感的位置不参与这个变异,被 bitflip 和 arith 覆盖过的也不再重复实验。
interest 16/8 和 32/8 大致逻辑与 8/8 相同,但大小端都会尝试。16bit 的有趣值,是在 8bit 有趣值的基础上添加 -32768, -129, 128, 255, 256, 512, 1000, 1024, 4096, 32767。32bit 的有趣值是在 8bit、16bit 的基础上添加 -2147483648LL, -100663046, -32769, 32768, 65535, 65536, 100663045, 2147483647。
/**********************
* INTERESTING VALUES *
**********************/
// 设置阶段名称和简写,表示本阶段要进行 8 位整数插入的变异操作
stage_name = "interest 8/8";
stage_short = "int8";
// 当前阶段的当前计数和最大计数
stage_cur = 0;
stage_max = len * sizeof(interesting_8);
// 设置当前阶段的值类型为 STAGE_VAL_LE
stage_val_type = STAGE_VAL_LE;
// 记录当前的 hits 计数
orig_hit_cnt = new_hit_cnt;
/* 设置 8 位整数 */
// 遍历输入缓冲区的每个字节
for (i = 0; i < len; i++) {
// 保存当前字节的原始值
u8 orig = out_buf[i];
/* 检查效应图,决定是否跳过该位置的变异 */
if (!eff_map[EFF_APOS(i)]) {
// 如果该位置不在效应图上,则跳过,并调整最大计数
stage_max -= sizeof(interesting_8);
continue;
}
// 设置当前字节位置
stage_cur_byte = i;
// 遍历有趣的 8 位整数列表
for (j = 0; j < sizeof(interesting_8); j++) {
/* 如果该值可能是 bitflip 或 arith 变异的结果,则跳过 */
if (could_be_bitflip(orig ^ (u8)interesting_8[j]) ||
could_be_arith(orig, (u8)interesting_8[j], 1)) {
stage_max--;
continue;
}
// 设置当前变异值
stage_cur_val = interesting_8[j];
// 将该有趣值写入输出缓冲区的当前位置
out_buf[i] = interesting_8[j];
// 进行 fuzzing 操作,若返回非零值,则中止当前输入的变异
if (common_fuzz_stuff(argv, out_buf, len)) goto abandon_entry;
// 恢复原始值
out_buf[i] = orig;
stage_cur++;
}
}
// 更新 hits 计数
new_hit_cnt = queued_paths + unique_crashes;
// 记录当前阶段找到的新路径数和总的循环次数
stage_finds[STAGE_INTEREST8] += new_hit_cnt - orig_hit_cnt;
stage_cycles[STAGE_INTEREST8] += stage_max;
/* 设置 16 位整数,分别考虑大端和小端 */
// 省略...
/* 设置 32 位整数,分别考虑大端和小端 */
// 省略...
对于每一个位置,尝试将那里替换为用户词典中的每一个元素。
/********************
* DICTIONARY STUFF *
********************/
// 如果没有用户提供的 extras,则跳过这一部分
if (!extras_cnt) goto skip_user_extras;
/* 用用户提供的 extras 进行覆盖变异 */
stage_name = "user extras (over)";
stage_short = "ext_UO";
// 当前阶段的当前计数和最大计数
stage_cur = 0;
stage_max = extras_cnt * len;
// 设置当前阶段的值类型为 STAGE_VAL_NONE
stage_val_type = STAGE_VAL_NONE;
// 记录当前的 hits 计数
orig_hit_cnt = new_hit_cnt;
// 遍历输入缓冲区的每个字节
for (i = 0; i < len; i++) {
u32 last_len = 0;
// 设置当前字节位置
stage_cur_byte = i;
/* Extras 是按大小从小到大排序的,这意味着我们在外循环确定的特定偏移位置
写入后,不必担心恢复缓冲区。 */
// 遍历每一个用户提供的 extra
for (j = 0; j < extras_cnt; j++) {
/* 如果 extras_cnt 超过 MAX_DET_EXTRAS,则概率性地跳过某些 extras。
如果没有足够的空间插入 payload,或者 token 是冗余的,
或者整个跨度没有字节在效应图中被设置为 1,则跳过。 */
// 若 extras 超过 200 个,则概率性地放弃
if ((extras_cnt > MAX_DET_EXTRAS && UR(extras_cnt) >= MAX_DET_EXTRAS) ||
extras[j].len > len - i ||
!memcmp(extras[j].data, out_buf + i, extras[j].len) ||
!memchr(eff_map + EFF_APOS(i), 1, EFF_SPAN_ALEN(i, extras[j].len))) {
stage_max--;
continue;
}
// 记录当前 extra 的长度
last_len = extras[j].len;
// 将 extra 的内容复制到输出缓冲区的当前位置
memcpy(out_buf + i, extras[j].data, last_len);
// 进行 fuzzing 操作,若返回非零值,则中止当前输入的变异
if (common_fuzz_stuff(argv, out_buf, len)) goto abandon_entry;
stage_cur++;
}
/* 恢复所有被覆盖的内存 */
memcpy(out_buf + i, in_buf + i, last_len);
}
// 更新 hits 计数
new_hit_cnt = queued_paths + unique_crashes;
// 记录当前阶段找到的新路径数和总的循环次数
stage_finds[STAGE_EXTRAS_UO] += new_hit_cnt - orig_hit_cnt;
stage_cycles[STAGE_EXTRAS_UO] += stage_max;
接下来开始处理 fuzzer 自动发现的词典。对于每个位置,尝试将其替换为 auto extra 词典中的前 50 个元素。上文已经分析过,a_extras 数组的前 50 个元素是在所有 auto extra token 中 hit_cnt 次数最多的那些 token,按长度升序排序。按照代码,auto extras 只尝试替换,不会尝试插入。
// 设置阶段名称为 "auto extras (over)",简短名称为 "ext_AO"
stage_name = "auto extras (over)";
stage_short = "ext_AO";
// 当前阶段的当前计数和最大计数
stage_cur = 0;
// 计算最大循环次数:自动 extras 的数量与 USE_AUTO_EXTRAS(定义为 50)中较小的那个乘以输入缓冲区长度
stage_max = MIN(a_extras_cnt, USE_AUTO_EXTRAS) * len;
// 设置当前阶段的值类型为 STAGE_VAL_NONE
stage_val_type = STAGE_VAL_NONE;
// 记录当前的 hits 计数
orig_hit_cnt = new_hit_cnt;
// 遍历输入缓冲区的每个字节
for (i = 0; i < len; i++) {
u32 last_len = 0;
// 设置当前字节位置
stage_cur_byte = i;
// 遍历每一个自动发现的 extra,最多遍历 USE_AUTO_EXTRAS 次
for (j = 0; j < MIN(a_extras_cnt, USE_AUTO_EXTRAS); j++) {
/* 请参阅前面的注释;extras 按大小排序 */
// 如果 extra 的长度大于剩余的缓冲区长度,或者 extra 的内容与当前缓冲区内容相同,
// 或者 eff_map 中对应的区域没有设置为 1,则跳过这个 extra
if (a_extras[j].len > len - i ||
!memcmp(a_extras[j].data, out_buf + i, a_extras[j].len) ||
!memchr(eff_map + EFF_APOS(i), 1, EFF_SPAN_ALEN(i, a_extras[j].len))) {
stage_max--;
continue;
}
// 记录当前 extra 的长度
last_len = a_extras[j].len;
// 将 extra 的内容复制到输出缓冲区的当前位置
memcpy(out_buf + i, a_extras[j].data, last_len);
// 进行 fuzzing 操作,若返回非零值,则中止当前输入的变异
if (common_fuzz_stuff(argv, out_buf, len)) goto abandon_entry;
stage_cur++;
}
/* 恢复所有被覆盖的内存 */
memcpy(out_buf + i, in_buf + i, last_len);
}
// 更新 hits 计数
new_hit_cnt = queued_paths + unique_crashes;
// 记录当前阶段找到的新路径数和总的循环次数
stage_finds[STAGE_EXTRAS_AO] += new_hit_cnt - orig_hit_cnt;
stage_cycles[STAGE_EXTRAS_AO] += stage_max;
skip_extras:
/* 如果我们到达这里而没有跳转到 havoc_stage 或 abandon_entry,
那么我们已经正确完成了 deterministic 步骤,可以在 .state/ 目录中将其标记为已完成。 */
// 将当前队列条目标记为「已完成 deterministic 阶段」
if (!queue_cur->passed_det) mark_as_det_done(queue_cur);
havoc 变异阶段
havoc 阶段做的事情是「随机应用几个变异算子,进行实验」。
/****************
* RANDOM HAVOC *
****************/
havoc_stage:
// 初始化当前字节位置为 -1
stage_cur_byte = -1;
/* The havoc stage mutation code is also invoked when splicing files; if the
splice_cycle variable is set, generate different descriptions and such. */
// 如果 splice_cycle 未设置,则执行 havoc 变异阶段
if (!splice_cycle) {
// 设置阶段名称和简短名称
stage_name = "havoc";
stage_short = "havoc";
// 决定 havoc 实验次数
// 如果刚刚做过 deterministic 阶段,则多 4 倍实验次数
// perf_score 是对测试用例的打分,跑得越快、覆盖度越高的用例分数会高
// havoc_div 是 fuzzer dry run 时,观察执行速度得到的。
// 程序越慢,havoc_div 越高,例如 0-19 execs/sec 时,havoc_div 是 10
stage_max = (doing_det ? HAVOC_CYCLES_INIT : HAVOC_CYCLES) *
perf_score / havoc_div / 100;
} else { // 否则执行 splice 变异阶段
static u8 tmp[32];
perf_score = orig_perf;
// 设置阶段名称和简短名称
sprintf(tmp, "splice %u", splice_cycle);
stage_name = tmp;
stage_short = "splice";
stage_max = SPLICE_HAVOC * perf_score / havoc_div / 100;
}
// havoc 至少实验 16 次
if (stage_max < HAVOC_MIN) stage_max = HAVOC_MIN;
// 记录原始输入长度
temp_len = len;
orig_hit_cnt = queued_paths + unique_crashes;
havoc_queued = queued_paths;
/* We essentially just do several thousand runs (depending on perf_score)
where we take the input file and make random stacked tweaks. */
// 执行 havoc 阶段
for (stage_cur = 0; stage_cur < stage_max; stage_cur++) {
// 决定组合多少个变异算子。可能的选项是 2、4、8、16、32、64、128
u32 use_stacking = 1 << (1 + UR(HAVOC_STACK_POW2));
stage_cur_val = use_stacking;
// 依次应用变异算子
for (i = 0; i < use_stacking; i++) {
switch (UR(15 + ((extras_cnt + a_extras_cnt) ? 2 : 0))) {
// 17 个变异算子,根据情况随机选择
// 具体的变异算子在代码的其他部分定义
}
}
// 如果发现新路径或产生崩溃,则跳出循环
if (common_fuzz_stuff(argv, out_buf, temp_len))
goto abandon_entry;
/* out_buf might have been mangled a bit, so let's restore it to its
original size and shape. */
// 将 out_buf 恢复到原始输入的大小和内容
if (temp_len < len) out_buf = ck_realloc(out_buf, len);
temp_len = len;
memcpy(out_buf, in_buf, len);
/* If we're finding new stuff, let's run for a bit longer, limits
permitting. */
// 如果找到新的东西,增加实验次数
if (queued_paths != havoc_queued) {
if (perf_score <= HAVOC_MAX_MULT * 100) {
stage_max *= 2;
perf_score *= 2;
}
havoc_queued = queued_paths;
}
}
new_hit_cnt = queued_paths + unique_crashes;
// 记录新发现的路径和循环次数
if (!splice_cycle) {
stage_finds[STAGE_HAVOC] += new_hit_cnt - orig_hit_cnt;
stage_cycles[STAGE_HAVOC] += stage_max;
} else {
stage_finds[STAGE_SPLICE] += new_hit_cnt - orig_hit_cnt;
stage_cycles[STAGE_SPLICE] += stage_max;
}
可见,havoc 阶段执行多少次实验,是由用例得分、 havoc_div 系数等共同决定的。每次实验,首先随机选择要组合多少个变异算子(选项是 2、4、8、16、32、64、128),然后均匀随机选取变异算子执行。
havoc 阶段中,每个算子被选取的概率是相同的。有许多论文尝试改进变异算子的选取策略,例如 MOPT 和 DARWIN。
全部 17 种变异算子如下表:
▲ 17 种变异算子
接下来,我们分析这些变异算子:
case 0:
/* Flip a single bit somewhere. Spooky! */
FLIP_BIT(out_buf, UR(temp_len << 3));
break;
算子 0 是随机选择一个 bit 进行翻转。
case 1:
/* Set byte to interesting value. */
out_buf[UR(temp_len)] = interesting_8[UR(sizeof(interesting_8))];
break;
算子 1 是随机选择一个字节,将其改为 8bit 的 interesting value。
case 2:
/* Set word to interesting value, randomly choosing endian. */
if (temp_len < 2) break;
if (UR(2)) {
*(u16*)(out_buf + UR(temp_len - 1)) =
interesting_16[UR(sizeof(interesting_16) >> 1)];
} else {
*(u16*)(out_buf + UR(temp_len - 1)) = SWAP16(
interesting_16[UR(sizeof(interesting_16) >> 1)]);
}
break;
算子 2 是随机选择一个 word,将其替换为 16bit 的有趣值(随机选择大小端)。
case 3:
/* Set dword to interesting value, randomly choosing endian. */
if (temp_len < 4) break;
if (UR(2)) {
*(u32*)(out_buf + UR(temp_len - 3)) =
interesting_32[UR(sizeof(interesting_32) >> 2)];
} else {
*(u32*)(out_buf + UR(temp_len - 3)) = SWAP32(
interesting_32[UR(sizeof(interesting_32) >> 2)]);
}
break;
算子 3 是随机选择一个 dword,将其替换为 32bit 有趣值。也是随机选取大小端。
case 4:
/* Randomly subtract from byte. */
// ARITH_MAX 是 35,UR(35) 返回值在 [0, 34] 之间
// 所以这里是随机减去 1~35 之间的值
out_buf[UR(temp_len)] -= 1 + UR(ARITH_MAX);
break;
算子 4 是选择一个字节,减去 [1,35] 之间的随机整数。
case 5:
/* Randomly add to byte. */
out_buf[UR(temp_len)] += 1 + UR(ARITH_MAX);
break;
算子 5 是选择一个字节,加上 [1,35] 之间的随机整数。
case 6:
/* Randomly subtract from word, random endian. */
if (temp_len < 2) break;
if (UR(2)) {
u32 pos = UR(temp_len - 1);
*(u16*)(out_buf + pos) -= 1 + UR(ARITH_MAX);
} else {
u32 pos = UR(temp_len - 1);
u16 num = 1 + UR(ARITH_MAX);
*(u16*)(out_buf + pos) =
SWAP16(SWAP16(*(u16*)(out_buf + pos)) - num);
}
break;
算子 6 是随机选择一个 word,减去 [1,35] 之间的随机整数。随机选择大小端。
case 7:
/* Randomly add to word, random endian. */
if (temp_len < 2) break;
if (UR(2)) {
u32 pos = UR(temp_len - 1);
*(u16*)(out_buf + pos) += 1 + UR(ARITH_MAX);
} else {
u32 pos = UR(temp_len - 1);
u16 num = 1 + UR(ARITH_MAX);
*(u16*)(out_buf + pos) =
SWAP16(SWAP16(*(u16*)(out_buf + pos)) + num);
}
break;
算子 7 是随机选择一个 word,加上 [1,35] 之间的随机整数。随机选择大小端。
case 8:
/* Randomly subtract from dword, random endian. */
if (temp_len < 4) break;
if (UR(2)) {
u32 pos = UR(temp_len - 3);
*(u32*)(out_buf + pos) -= 1 + UR(ARITH_MAX);
} else {
u32 pos = UR(temp_len - 3);
u32 num = 1 + UR(ARITH_MAX);
*(u32*)(out_buf + pos) =
SWAP32(SWAP32(*(u32*)(out_buf + pos)) - num);
}
break;
算子 8 是随机选择一个 dword,减去 [1,35] 之间的随机整数。随机选择大小端。
case 9:
/* Randomly add to dword, random endian. */
if (temp_len < 4) break;
if (UR(2)) {
u32 pos = UR(temp_len - 3);
*(u32*)(out_buf + pos) += 1 + UR(ARITH_MAX);
} else {
u32 pos = UR(temp_len - 3);
u32 num = 1 + UR(ARITH_MAX);
*(u32*)(out_buf + pos) =
SWAP32(SWAP32(*(u32*)(out_buf + pos)) + num);
}
break;
算子 9 是随机选择一个 dword,加上 [1,35] 之间的随机整数。随机选择大小端。
case 10:
/* Just set a random byte to a random value. Because,
why not. We use XOR with 1-255 to eliminate the
possibility of a no-op. */
out_buf[UR(temp_len)] ^= 1 + UR(255);
break;
算子 10 是随机选择一个字节,将其改为随机数。
case 11 ... 12: {
/* Delete bytes. We're making this a bit more likely
than insertion (the next option) in hopes of keeping
files reasonably small. */
u32 del_from, del_len;
if (temp_len < 2) break;
/* Don't delete too much. */
del_len = choose_block_len(temp_len - 1);
del_from = UR(temp_len - del_len + 1);
memmove(out_buf + del_from, out_buf + del_from + del_len,
temp_len - del_from - del_len);
temp_len -= del_len;
break;
}
算子 11 和 12 都是随机选择一个位置,删掉之后的随机长度的内容。这里占用 2 个算子,使得「随机删除」这个操作的执行概率是其他算子的两倍,也算是贯彻了 AFL 的「数据越短,执行越快」哲学。
```c
case 13:
// 如果当前缓冲区长度加上最大克隆块长度小于文件最大长度
if (temp_len + HAVOC_BLK_XL < MAX_FILE) {
/* Clone bytes (75%) or insert a block of constant bytes (25%). */
// 克隆字节(75% 的概率)或者插入一个常量字节块(25% 的概率)
u8 actually_clone = UR(4); // 随机数,决定是否克隆
u32 clone_from, clone_to, clone_len; // 克隆的起始位置、目标位置和长度
u8* new_buf; // 新的缓冲区
if (actually_clone) {
clone_len = choose_block_len(temp_len); // 选择一个块长度
clone_from = UR(temp_len - clone_len + 1); // 选择克隆的起始位置
} else {
clone_len = choose_block_len(HAVOC_BLK_XL); // 选择一个块长度
clone_from = 0; // 从位置 0 开始插入
}
clone_to = UR(temp_len); // 选择插入的目标位置
new_buf = ck_alloc_nozero(temp_len + clone_len); // 分配新的缓冲区
/* Head */
// 复制插入位置之前的部分到新缓冲区
memcpy(new_buf, out_buf, clone_to);
/* Inserted part */
// 复制插入的部分到新缓冲区
if (actually_clone)
memcpy(new_buf + clone_to, out_buf + clone_from, clone_len); // 克隆字节
else
memset(new_buf + clone_to,
UR(2) ? UR(256) : out_buf[UR(temp_len)], clone_len); // 插入常量字节块
/* Tail */
// 复制插入位置之后的部分到新缓冲区
memcpy(new_buf + clone_to + clone_len, out_buf + clone_to,
temp_len - clone_to);
// 释放旧的缓冲区
ck_free(out_buf);
out_buf = new_buf; // 更新缓冲区指针
temp_len += clone_len; // 更新缓冲区长度
}
// 结束此分支
break;
算子 13,随机插入块。在 75% 的概率下,将用例的随机一部分复制,插入到随机位置。在 25% 的概率下,插入一个随机长度的块,这个块的每个字节都是相同的。
case 14: {
/* Overwrite bytes with a randomly selected chunk (75%) or fixed
bytes (25%). */
u32 copy_from, copy_to, copy_len;
if (temp_len < 2) break;
copy_len = choose_block_len(temp_len - 1);
copy_from = UR(temp_len - copy_len + 1);
copy_to = UR(temp_len - copy_len + 1);
if (UR(4)) {
if (copy_from != copy_to)
memmove(out_buf + copy_to, out_buf + copy_from, copy_len);
} else memset(out_buf + copy_to,
UR(2) ? UR(256) : out_buf[UR(temp_len)], copy_len);
break;
}
算子 14,随机覆写块。在 75% 的概率下,选择一个随机部分,将其覆写到随机位置。在 25% 的概率下,选择一个随机部分,将其覆写为同一个字节。
/* Values 15 and 16 can be selected only if there are any extras
present in the dictionaries. */
// case 15: 使用 extras 覆盖字节
case 15: {
/* Overwrite bytes with an extra. */
// 如果没有用户指定的 extras 或者有自动 extras 且随机数为 1
if (!extras_cnt || (a_extras_cnt && UR(2))) {
/* No user-specified extras or odds in our favor. Let's use an
auto-detected one. */
// 使用自动 extras
u32 use_extra = UR(a_extras_cnt);
u32 extra_len = a_extras[use_extra].len;
u32 insert_at;
// 如果 extra 的长度大于当前长度,则跳过
if (extra_len > temp_len) break;
// 随机选择插入位置
insert_at = UR(temp_len - extra_len + 1);
// 将自动 extra 覆盖到插入位置
memcpy(out_buf + insert_at, a_extras[use_extra].data, extra_len);
} else {
/* No auto extras or odds in our favor. Use the dictionary. */
// 使用用户指定的 extras
u32 use_extra = UR(extras_cnt);
u32 extra_len = extras[use_extra].len;
u32 insert_at;
// 如果 extra 的长度大于当前长度,则跳过
if (extra_len > temp_len) break;
// 随机选择插入位置
insert_at = UR(temp_len - extra_len + 1);
// 将用户指定的 extra 覆盖到插入位置
memcpy(out_buf + insert_at, extras[use_extra].data, extra_len);
}
break;
}
算子 15 和算子 16 只有在启用字典的情况下才会考虑。算子 15 是用字典中的随机 token 覆写用例中的随机位置。
case 16: {
u32 use_extra, extra_len, insert_at = UR(temp_len + 1);
u8* new_buf;
/* 插入一个 extra。与之前的情况一样,掷骰子决定使用哪个 extra。 */
// 如果没有 extras 或者有自动 extras 且随机数为 1
if (!extras_cnt || (a_extras_cnt && UR(2))) {
// 使用自动 extras
use_extra = UR(a_extras_cnt);
extra_len = a_extras[use_extra].len;
// 如果插入 extra 后文件大小超过最大限制,则跳过
if (temp_len + extra_len >= MAX_FILE) break;
// 分配新的缓冲区,大小为当前长度加上 extra 的长度
new_buf = ck_alloc_nozero(temp_len + extra_len);
/* 头部 */
memcpy(new_buf, out_buf, insert_at);
/* 插入部分 */
memcpy(new_buf + insert_at, a_extras[use_extra].data, extra_len);
} else {
// 使用用户提供的 extras
use_extra = UR(extras_cnt);
extra_len = extras[use_extra].len;
// 如果插入 extra 后文件大小超过最大限制,则跳过
if (temp_len + extra_len >= MAX_FILE) break;
// 分配新的缓冲区,大小为当前长度加上 extra 的长度
new_buf = ck_alloc_nozero(temp_len + extra_len);
/* 头部 */
memcpy(new_buf, out_buf, insert_at);
/* 插入部分 */
memcpy(new_buf + insert_at, extras[use_extra].data, extra_len);
}
/* 尾部 */
memcpy(new_buf + insert_at + extra_len, out_buf + insert_at,
temp_len - insert_at);
// 释放旧的输出缓冲区
ck_free(out_buf);
// 将新缓冲区赋值给输出缓冲区
out_buf = new_buf;
// 更新临时长度
temp_len += extra_len;
break;
}
算子 16 是把字典中的随机 token,插入到用例中的随机位置。
我们终于分析完了全部 17 个 havoc 阶段的变异算子。如果某次实验发现了新成果,那么剩余的 havoc 执行次数会翻倍,来让这个用例再多跑一会,看看能不能继续发现更多成果。
splicing 变异阶段
如果 deterministic 阶段和 havoc 阶段都未产生成果,则执行 splicing 阶段(否则不执行)。
splicing 阶段执行「杂交」操作。也就是说,将这个用例的一部分拼接上其他用例的一部分。来看代码:
/************
* SPLICING *
************/
/* This is a last-resort strategy triggered by a full round with no findings.
It takes the current input file, randomly selects another input, and
splices them together at some offset, then relies on the havoc
code to mutate that blob. */
retry_splicing:
// 如果使用了杂交策略,并且杂交次数未超过 15 次(SPLICE_CYCLES),
// 且队列中有多个路径(queued_paths > 1),且当前输入长度大于 1
if (use_splicing && splice_cycle++ < SPLICE_CYCLES &&
queued_paths > 1 && queue_cur->len > 1) {
struct queue_entry* target;
u32 tid, split_at;
u8* new_buf;
s32 f_diff, l_diff;
/* 首先,如果我们已经为 havoc 修改了 in_buf,让我们清理它... */
// 如果 in_buf 已经被 havoc 修改了,将其恢复为原始输入 orig_in
if (in_buf != orig_in) {
ck_free(in_buf);
in_buf = orig_in;
len = queue_cur->len;
}
/* 随机选择一个队列条目并跳转到它。不要与自己杂交。 */
// 随机选择一个队列中的条目作为杂交对象,不与自己进行杂交
do { tid = UR(queued_paths); } while (tid == current_entry);
splicing_with = tid;
// 在链表中跳转,找到目标
target = queue;
while (tid >= 100) { target = target->next_100; tid -= 100; }
while (tid--) target = target->next;
// 杂交目标要足够长,且不能是当前的输入
/* 确保目标有合理的长度。 */
while (target && (target->len < 2 || target == queue_cur)) {
target = target->next;
splicing_with++;
}
// 如果没有找到合适的目标,重新尝试
if (!target) goto retry_splicing;
// 读入杂交目标
/* 将测试用例读入新的缓冲区。 */
fd = open(target->fname, O_RDONLY);
if (fd < 0) PFATAL("Unable to open '%s'", target->fname);
new_buf = ck_alloc_nozero(target->len);
ck_read(fd, new_buf, target->len, target->fname);
close(fd);
/* 在第一个和最后一个不同字节之间找到合适的杂交位置。如果差异只是一个字节或类似的字节,则退出。 */
// 找到两个缓冲区第一个和最后一个不同的位置
locate_diffs(in_buf, new_buf, MIN(len, target->len), &f_diff, &l_diff);
// 如果两个缓冲区差异只有一两个字节,则重新选择
if (f_diff < 0 || l_diff < 2 || f_diff == l_diff) {
ck_free(new_buf);
goto retry_splicing;
}
/* 在第一个和最后一个不同字节之间的某个位置进行分割。 */
split_at = f_diff + UR(l_diff - f_diff);
/* 执行杂交操作。 */
// 生成一个新用例,将新缓冲区的前 split_at 个字节替换为当前缓冲区的前 split_at 个字节
len = target->len;
memcpy(new_buf, in_buf, split_at);
in_buf = new_buf;
// 分配新的输出缓冲区,并将新缓冲区的内容复制到输出缓冲区
ck_free(out_buf);
out_buf = ck_alloc_nozero(len);
memcpy(out_buf, in_buf, len);
// 执行一遍 havoc
goto havoc_stage;
}
splicing 顶多执行 15 次。在尝试杂交时,首先随机选取一个足够长、且差异足够大的杂交目标,然后随机选择分割点,把本用例的前面一段和杂交目标的后面一段拼接起来。这样会形成一个新的用例,将这个新用例拿去执行 havoc 阶段变异。
可以预见,如果真的执行了 splicing 阶段,那它耗费的时间将会比 havoc 阶段长很多(因为 splicing 阶段产生的用例都要跑一遍 havoc 阶段)。
3156

被折叠的 条评论
为什么被折叠?



