AFL源码分析(I)——白盒模式下的afl-gcc分析

 

0x00 写在前面

本文所用目标文件

  1. 使用sudo apt-get install autoconf安装工具包
  2. 准备待测文件,本文使用ctf-wiki中的ret2text.c作为目标文件
  3. 执行autoscan ./生成configure.scan文件如果在此步骤中收到了错误信息,形如:
    Unescaped left brace in regex is deprecated, passed through in regex; marked by <-- HERE in m/\${ <-- HERE [^\}]*}/ at /usr/bin/autoscan line 361.
    

    请执行sudo vi /usr/bin/autoscan,编辑/usr/bin/autoscan文件,将第361行的s/\${[^\}]*}//g;变更为s/\$\{[^\}]*\}//g;

  4. 接下来将configure.scan重命名为configure.ac,并修改相关内容
    #                                               -*- Autoconf -*-
    # Process this file with autoconf to produce a configure script.
    
    AC_PREREQ([2.69])
    AC_INIT([ret2text], [1.0], [lanjing@furry.com])
    AM_INIT_AUTOMAKE
    AC_CONFIG_SRCDIR([ret2text.c])
    AC_CONFIG_HEADERS([config.h])
    
    # Checks for programs.
    AC_PROG_CC
    
    # Checks for libraries.
    
    # Checks for header files.
    AC_CHECK_HEADERS([stdlib.h])
    
    # Checks for typedefs, structures, and compiler characteristics.
    
    # Checks for library functions.
    
    AC_OUTPUT(Makefile)
    

  5. 执行aclocal,生成aclocal.m4
  6. 执行autoconf,生成相关配置文件
  7. 执行autoheader,生成config.h.in
  8. 建立Makefile.am并修改其内容为:
    AUTOMAKE_OPTIONS=foreign
    bin_PROGRAMS=ret2text
    ret2text_SOURCES=ret2text.c
    
  9. 最后执行automake --add-missing,生成configure

 

0x01 关于AFL白盒模式

当应用源代码可用时,可以通过使用配套的代码注入工具进行代码插桩,该工具可以在任何第三方代码的标准构建过程中替代gccclang使用。经过此工具进行代码的代码插桩对待测程序性能的影响相当小。结合afl-fuzz实现的其他优化,大多数程序可以比传统模式更快的被模糊测试。一个通用的代码编译方式是:

CC=/path/to/afl/afl-gcc ./configure
make clean all

 

0x02 afl-gcc源码分析

afl-gccmain函数的起始进行了一系列的检查,包括调用isatty(2)检查stderr是否为终端环境、调用getenv检查AFL_QUIET环境变量是否存在、检查参数个数是否合法等。检查结束后,执行的是以下核心代码:

find_as(argv[0]);
edit_params(argc, argv);
execvp(cc_params[0], (char**)cc_params);

find_as函数

此函数尝试在AFL_PATH或从argv[0]中找到所需的“伪” GNU汇编器。 如果此步骤失败,将产生致命错误导致afl-gcc流程中止。

  1. 检查AFL_PATH是否已经设置,若已设置,则将as_path设置为$[AFL_PATH]/as,随后检查as_path是否可以访问,若可访问,返回上层函数
  2. 若未设置AFL_PATH,寻找传入的路径中最后一个/之后的字符串,若找到,则将传入的路径之后拼接/afl-as,赋值给as_path,随后检查as_path是否可以访问,若可访问,返回上层函数
  3. 若未设置AFL_PATH且未找到目标字符串,使用默认路径/usr/local/lib/afl/as,赋值给as_path,随后检查as_path是否可以访问,若可访问,返回上层函数
  4. 引发致命错误Unable to find AFL wrapper binary for 'as'. Please set AFL_PATH,中断afl-gcc

edit_params函数

此函数将参数传入cc_params,进行必要的编辑。cc_params的空间由ck_alloc分配,长度为(argc + 128) * sizeof(u8*)

  1. 通过检查argv[0]的最后一个/后是否为afl-clang来检查是否为clang编译器模式,若是,将clang_mode置位。
  2. 修正主编译器路径,即将afl-clang++afl-clangafl-g++afl-gcjafl-gcc替换为正确的路径并将其作为cc_params[0],若AFL_CXXAFL_CCAFL_CXXAFL_GCJAFL_CC(注:AFL_CXXAFL_CC同时生效于clang++/g++clang/gcc,这两种编译器将在函数入口处进行区分)环境变量已被设置,则使用环境变量中的值。否则,直接替换为clang++clangg++gcjgcc关键字。
  3. 随后此函数开始遍历已设置的所有选项,当检测到-B选项存在时,将显示一个警告以提示此选项将被afl编译器覆盖,随后继续遍历下一个选项,此选项将被忽略。
    • -B选项表示编译器系列文件(包括编译器本身这个可执行文件、库文件、依赖文件、数据文件)所在目录,当使用-B参数指定一个自定义目录时,编译器将首先在指定的目录查找编译器所需要的文件,包括但不限于cpp(预处理程序,它是一种宏处理器,编译器会自动使用该宏处理器在编译之前对程序中的宏定义进行转换), cc1(编译器,用于将源代码文件转换为汇编码文件), as(汇编器,用于将汇编码文件转换为字节码文件) 以及ld(链接器,将程序所需的各种字节码文件汇总,链接到一起,输出可执行文件),若-B不存在,编译器将会在默认路径/usr/lib/gcc//usr/local/lib/gcc/查找,若依然不存在,将在PATH(即环境变量)中的路径寻找。
  4. -integrated-as选项存在时,继续遍历下一个选项,此选项将被忽略。
    • -integrated-as选项表示Clang编译器将使用LLVM集成汇编器进行代码的编译工作。对于Clang编译器而言,既可以使用LLVM提供的集成汇编器进行汇编工作,也可以在GNU系统中使用GNU汇编器。
  5. -pipe选项存在时,继续遍历下一个选项,此选项将被忽略。
    • -pipe选项表示在编译的各个阶段之间使用管道而不是临时文件进行通信。 注意,在某些无法从管道读取数据的汇编器的系统上,这种方法无法正常工作,但是GNU汇编器可以使用此方式进行通信。
  6. -fsanitize=address-fsanitize=memory选项存在时,将asan_set标志位进行置位。
    • 这两个选项都是Clang编译器所使用的选项,-fsanitize=address代表启用LLVM的内存泄漏检测器,-fsanitize=memory代表启用LLVM的未初始化变量引用检测器。
  7. FORTIFY_SOURCE选项存在时,将fortify_set标志位进行置位。
  8. 将当前选项加入cc_params中,继续遍历下一个选项
  9. 遍历结束后,向cc_params中添加参数-B <as_path>(as_pathfind_as函数获取并设置)。
  10. 如果clang_mode标志位置位,向cc_params中添加参数-no-integrated-as
  11. 如果AFL_HARDEN环境变量被设置,向cc_params中添加参数-fstack-protector-all
    • -fstack-protector-all选项表示启用对所有函数的栈保护机制(Canary)。
  12. 如果fortify_set标志位未置位,向cc_params中添加参数-D_FORTIFY_SOURCE=2
    • -D_FORTIFY_SOURCE选项表示将开启缓冲区溢出保护,当此参数的级别为2时,代表启用了较强的保护。同时,此保护需要同时与-O2/-O3参数使用,否则将不会生效。
  13. 如果asan_set标志位置位,设置环境变量AFL_USE_ASAN=1
  14. 如果asan_set标志位未置位,但是环境变量AFL_USE_ASAN已被设置,检查AFL_USE_MSANAFL_HARDEN环境变量是否被设置,如果两个环境变量之一被设置,则中断afl-gcc过程。若两个标志均未被设置,则向cc_params中添加参数-U_FORTIFY_SOURCE以及-fsanitize=address
  15. 如果asan_set标志位未置位,环境变量AFL_USE_ASAN未被设置,但是环境变量AFL_USE_MSAN已被设置,检查AFL_USE_ASANAFL_HARDEN环境变量是否被设置,如果两个环境变量之一被设置,则中断afl-gcc过程。若两个标志均未被设置,则向cc_params中添加参数-U_FORTIFY_SOURCE以及-fsanitize=memory
    • 这里不允许同时设置AFL_USE_MSANAFL_USE_ASANAFL_HARDEN的原因是因为若同时设置将导致运行速度过慢。
  16. 若环境变量AFL_DONT_OPTIMIZE未被设置,向cc_params中添加参数-g-O3-funroll-loops-D__AFL_COMPILER=1-DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION=1
    • -g选项表示在编译过程中输出调试信息。
    • -O3选项表示启动最高等级的编译优化。
    • -funroll-loops选项表示进行循环的编译优化,即展开循环,以较小的恒定迭代次数完全除去循环。执行循环强度消除并消除在循环内部使用的变量。这是用简单而快速的操作(如加法和减法)替代耗时操作(如乘法和除法)的过程。
    • -DXXXX选项表示在编译时定义宏,在此例中相当于#define __AFL_COMPILER 1#define FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION 1
  17. 若环境变量AFL_NO_BUILTIN被设置,向cc_params中添加参数-fno-builtin-strcmp-fno-builtin-strncmp-fno-builtin-strcasecmp-fno-builtin-strncasecmp-fno-builtin-memcmp-fno-builtin-strstr-fno-builtin-strcasestr
    • -fno-builtin-*选项表示不使用指定的内建函数。例如,-fno-builtin-strcmp表示不使用内建的strcmp函数,而是使用源代码中的strcmp函数。

execvp函数

此函数用于执行cc_params[0] cc_params[1] cc_params[1]......命令。

 

0x03 afl-gcc实例分析

使用CC=/home/error404/AFL/afl-gcc ./configure生成的Makefile与使用./configure生成的Makefile对比,主要有以下区别:

此时我们可以修改afl-gcc.c用来打印出cc_params的内容

打印出内容后,可以看到afl-gcc按我们上文所预期的那样添加了部分参数。

gcc -DHAVE_CONFIG_H -I. -g -O2 -MT ret2text.o -MD -MP -MF .deps/ret2text.Tpo -c -o ret2text.o ret2text.c -B /home/error404/AFL -g -O3 -funroll-loops -D__AFL_COMPILER=1 -DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION=1

此外,我们还发现在编译过程还调用了afl-as

这是因为afl-gcc使用了-B参数限定了编译器中汇编器的位置,并且通过我们的分析,afl-gcc并未进行代码的插桩,仅仅是针对gcc进行了参数的整理与优化,那么可以猜测afl-as是主要负担插桩工作的。

 

0x04 afl-as源码分析(第一部分)

afl-gcc相同,afl-as也在程序入口点设计了一系列的代码检查操作。那么,其主逻辑如下所示:

gettimeofday(&tv, &tz);
rand_seed = tv.tv_sec ^ tv.tv_usec ^ getpid();
srandom(rand_seed);

edit_params(argc, argv);

if (inst_ratio_str) {
    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);

/* When compiling with ASAN, we don't have a particularly elegant way to skip
   ASAN-specific branches. But we can probabilistically compensate for 
   that... */

if (getenv("AFL_USE_ASAN") || getenv("AFL_USE_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");

if (waitpid(pid, &status, 0) <= 0) PFATAL("waitpid() failed");

if (!getenv("AFL_KEEP_ASSEMBLY")) unlink(modified_file);

exit(WEXITSTATUS(status));

生成随机数并设置种子

在整个主逻辑伊始,afl-as将生成一个与当前时间、PID相关的随机数种子并将其设置。

gettimeofday(&tv, &tz);
rand_seed = tv.tv_sec ^ tv.tv_usec ^ getpid();
srandom(rand_seed);

随后进入edit_params函数逻辑

edit_params函数

  1. TMPDIR环境变量赋给tmp_dir,检查tmp_dir是否为NULL,若是,将TEMP环境变量赋给tmp_dir,检查tmp_dir是否为NULL,若是,将TMP环境变量赋给tmp_dir,检查tmp_dir是否为NULL,若是,将tmp_dir赋值为/tmp
  2. 创建参数列表as_params,并检查AFL_AS环境变量的值是否设置,若已设置,则将其内部的路径作为as_params[0];否则,将as关键字作为as_params[0](即使用PATH环境变量中所定义的as汇编器)。
  3. 遍历传入的选项列表(从第二个参数开始,到倒数第二个参数为止),检查当前参数,若参数为是--64,将use_64bit标志位置位;若参数为是--32,将use_64bit标志位清除。
    • 这里避开第一个参数和最后一个参数是因为一般的调用格式为afl-as <选项1> <选项2> <选项3> <选项4> <input-file>。第一个参数一般是汇编器本身,已经在第二步处理;最后一个参数一般是输入文件,将在第五步处理;第三步第四步遍历处理的仅仅是选项。
  4. 随后,将当前参数加入as_params遍历下一个参数
  5. 取最后一个参数,判断第一个字符是否为-。若是,继续判断后续字符是否为-version。若是,则将just_version标志位置位,随后将--version加入as_params,最后将NULL加入as_params函数结束
  6. 若最后一个参数的第一个字符为-但后续字符不为-version且不为空,那么引发致命错误Incorrect use (not called through afl-gcc?),中断afl-gcc
  7. 若最后一个参数的第一个字符为-但后续字符为空,那么将<tmp_dir>/.afl-<PID>-<TIME>.s(尖括号包围的内容用对应变量替换)加入as_params,最后将NULL加入as_params函数结束
  8. 若最后一个参数的第一个字符不为-且最后一个参数不为tmp_dir的值、/tmp/var/tmp三者之一,将pass_thru标志位置位。
  9. <tmp_dir>/.afl-<PID>-<TIME>.s(尖括号包围的内容用对应变量替换)加入as_params,最后将NULL加入as_params函数结束

确认环境变量&相关设置

if (inst_ratio_str) {
    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);

/*  When compiling with ASAN, we don't have a particularly elegant way to skip
    ASAN-specific branches. But we can probabilistically compensate for
    that... */

if (getenv("AFL_USE_ASAN") || getenv("AFL_USE_MSAN")) {
    sanitizer = 1;
    inst_ratio /= 3;
}
  1. 首先检查AFL_INST_RATIO环境变量的值是否为空,若非空,将其以无符号数的形式写入inst_ratio中,并验证其是否小于等于100,若写入过程出错或其大于100引发致命错误Bad value of AFL_INST_RATIO (must be between 0 and 100),中断afl-gcc
  2. 检查__AFL_AS_LOOPCHECK环境变量的值是否为空,若非空,引发致命错误Endless loop when calling 'as' (remove '.' from your PATH),中断afl-gcc
  3. 设置__AFL_AS_LOOPCHECK环境变量的值为1
  4. 检查AFL_USE_ASAN或者AFL_USE_MSAN是否被设置,若二者之一被设置,则将sanitizer标志位置位,并将inst_ratio除三。
    • inst_ratio代表插桩密度,密度越高插桩越多,对资源负担越大,当设置AFL_USE_ASAN或者AFL_USE_MSAN时,这个密度会被强制置为33左右。

接下来若just_version标志位未置位,进入add_instrumentation主逻辑

add_instrumentation函数(核心插桩函数)

检查文件权限

首先检查是否可以打开待插桩文件,以及确定可以将已插桩的文件写入目标位置

接下来进入插桩逻辑,打开待插桩文件循环读取一行(至多8192个字符)进line变量

合法代码插桩——插入调用__afl_maybe_log的汇编码(Ⅰ)

pass_thruskip_intelskip_appskip_csect四个标志位均被清除,且instr_ok(这个标志位表征当前读入的行处于.text部分,将在后续设置,初始为清除状态)、instrument_next两个标志位均被设置,且当前行的第一个字符是\t且第二个字符是字母,则向已插桩的文件写入trampoline_fmt_64/trampoline_fmt_32(取决于use_64bit标志位状态)

static const u8* trampoline_fmt_32 =
  "\n"
  "/* --- AFL TRAMPOLINE (32-BIT) --- */\n"
  "\n"
  ".align 4\n"
  "\n"
  "leal -16(%%esp), %%esp\n"
  "movl %%edi,  0(%%esp)\n"
  "movl %%edx,  4(%%esp)\n"
  "movl %%ecx,  8(%%esp)\n"
  "movl %%eax, 12(%%esp)\n"
  "movl $0x%08x, %%ecx\n"
  "call __afl_maybe_log\n"
  "movl 12(%%esp), %%eax\n"
  "movl  8(%%esp), %%ecx\n"
  "movl  4(%%esp), %%edx\n"
  "movl  0(%%esp), %%edi\n"
  "leal 16(%%esp), %%esp\n"
  "\n"
  "/* --- END --- */\n"
  "\n";

static const u8* trampoline_fmt_64 =
  "\n"
  "/* --- AFL TRAMPOLINE (64-BIT) --- */\n"
  "\n"
  ".align 4\n"
  "\n"
  "leaq -(128+24)(%%rsp), %%rsp\n"
  "movq %%rdx,  0(%%rsp)\n"
  "movq %%rcx,  8(%%rsp)\n"
  "movq %%rax, 16(%%rsp)\n"
  "movq $0x%08x, %%rcx\n"
  "call __afl_maybe_log\n"
  "movq 16(%%rsp), %%rax\n"
  "movq  8(%%rsp), %%rcx\n"
  "movq  0(%%rsp), %%rdx\n"
  "leaq (128+24)(%%rsp), %%rsp\n"
  "\n"
  "/* --- END --- */\n"
  "\n";

经过整理,最终插入的汇编码分别是:

/* --- AFL TRAMPOLINE (32-BIT) --- */
.align 4
leal -16(%esp), %esp
movl %edi,  0(%esp)
movl %edx,  4(%esp)
movl %ecx,  8(%esp)
movl %eax, 12(%esp)
movl $0x%08x, %ecx
call __afl_maybe_log
movl 12(%esp), %eax
movl  8(%esp), %ecx
movl  4(%esp), %edx
movl  0(%esp), %edi
leal 16(%esp), %esp
/* --- END --- */

/* --- AFL TRAMPOLINE (64-BIT) --- */
.align 4
leaq -(128+24)(%rsp), %rsp
movq %rdx,  0(%rsp)
movq %rcx,  8(%rsp)
movq %rax, 16(%rsp)
movq $0x%08x, %rcx
call __afl_maybe_log
movq 16(%rsp), %rax
movq  8(%rsp), %rcx
movq  0(%rsp), %rdx
leaq (128+24)(%rsp), %rsp
/* --- END --- */

⚠️:此处的%08x(random() % ((1 << 16)))生成,在编译期确定。

插入结束后,将instrument_next标志位清除,桩代码计数器ins_lines加一。

最后将原始的汇编码(即line变量的内容),追加到插桩后文件中。

此时检查pass_thru标志位是否被置位,若已置位,则忽略以下流程,继续循环,读取下一行待插桩文件

寻找合法有效的待插桩段

这里是真正的插桩函数的核心了,但是在这里我们真正感兴趣的事实上只有.text段,因此,执行以下操作:

若当前行的第一个字符是\t,第二个字符是.(即段标识符)执行以下判断处理逻辑:

  1. clang_mode标志位清除且instr_ok标志位置位且段标识符是p2align且段标识符后一位是数字且紧跟一个\n,则将skip_next_label标志位置位。
    • OpenBSD将跳转表直接与代码内联,这很难被处理。 通常.p2align可以视为此种情况的标志,因此我们检测其用作信号。
  2. 若段标识符是text\nsection\t.textsection\t__TEXT,__textsection __TEXT,__text之一,则将instr_ok标志位置位,忽略以下流程,继续循环,读取下一行待插桩文件
  3. 若段标识符是section\tsectionbss\ndata\n之一,则将instr_ok标志位清除,忽略以下流程,继续循环,读取下一行待插桩文件

此时,有可能此行的的段标识符并不是以上所述的段。那么,就会有以下的几种特殊情况:

  1. 若存在CSECT指令,则需要想办法跳过,此类指令表示插入一段额外的可执行汇编代码块。特殊的,可以使用.code32/.code64指令来引导与当前程序位数不同的指令。此时afl-as将不能处理此情况,因此不进行插桩。检测处理逻辑如下:
    1. 若当前行包含.code32,将use_64bit标志位的状态赋给skip_csect标志位。
    2. 若当前行包含.code64,将use_64bit标志位的状态取反赋给skip_csect标志位。
  2. 若存在Intel汇编指令,则需要想办法跳过,此类指令与当前的AT&T语法不符。此时afl-as将不能处理此情况,因此不进行插桩。检测处理逻辑如下:
    1. 若当前行包含.intel_syntax,将skip_intel标志位置位。
    2. 若当前行包含.att_syntax,将skip_intel标志位清除。
  3. afl-as将不能处理内嵌汇编(C语言中由__asm__引导,汇编语言中由#APP#NO_APP包围)语句,因此不进行插桩。检测处理逻辑如下:
    1. 若当前行的第一个字符或第二个字符是#,且当前行包含#APP,将skip_app标志位置位。
    2. 若当前行的第一个字符或第二个字符是#,且当前行包含#NO_APP,将skip_app标志位清除。

skip_intelskip_appskip_csect中的任何一个标志位置位或者instr_ok标志位被清除或者当前行第一个字母是#<空格># BB#0# BB#0Clang中表示注释),表示当前行以及其下面的行不是一个有效的待插桩代码行,应当予以跳过,直到遇到结束标识使得对应标志位清除或置位。那么,afl-as将执行,忽略以下流程,继续循环,读取下一行待插桩文件的操作。

分支跳转代码插桩——插入调用__afl_maybe_log的汇编码

我们接下来检测条件跳转指令(例如:jnzjz之类的语句),afl-as为了标记此处将会有另一条分支并期望在后续的测试过程中覆盖另一条分支,将在跳转指令之后插入trampoline_fmt_64/trampoline_fmt_32(取决于use_64bit标志位状态),关于这两段代码上文已分析过,此处不再赘述。

注意,JMP表示无条件跳转,因此其另一条分支将永远不会被运行到,那么将不会影响代码覆盖率,因此不在JMP指令后插桩。

那么,此处插桩逻辑为:若此行代码的第一个字符为\t,则再次检测第二个字符是不是j,若是,再检查第三个字符是不是m,若不是则进行插桩逻辑,插桩结束后将桩代码计数器ins_lines加一。无论第二个第三个字符为什么,只要第一个字符为\t,则忽略以下流程,继续循环,读取下一行待插桩文件

对标签段进行处理(Label)

若此行代码中有:字符但是第一个字符不是.字符,则将instrument_next置位(此标志位表示下一条语句是有效语句,将在代码插桩——插入调用__afl_maybe_log的汇编码(Ⅰ)过程中使用),随后继续循环,读取下一行待插桩文件

若此行代码中有:字符且第一个字符是.字符且满足下列情况之一:

  • 第三个字符是数字且inst_ratio大于random(100)
  • clang_mode置位且此行的前四个字符是.LBBinst_ratio大于random(100)

则执行对skip_next_label的检查,若此标志位清除,则将instrument_next置位,随后继续循环,读取下一行待插桩文件

若此行代码中有:字符且第一个字符是.字符但不满足上述情况之一,继续循环,读取下一行待插桩文件

至此,循环正式结束。

末尾代码插桩——插入AFL主逻辑汇编码

最后,若桩代码计数器ins_lines不为0,那么将main_payload_64/main_payload_32(取决于use_64bit标志位状态)插入整个汇编文件末尾。

限于篇幅,此处的代码将在下一篇文章中予以说明。

 

0x05 后记

虽然网上有很多关于AFL源码的分析,但是绝大多数文章都是抽取了部分代码进行分析的,本文则逐行对源码进行了分析,下一篇文章将针对afl-as源码做下一步分析并给出相关实例。

(完)