AFL进阶教学——插桩、执行、覆盖率收集与反馈(解析)

目录

一、插桩

1、afl-gcc.c

2、afl-as.c

2.1、基本块

二、第一次执行

1、afl-fuzz.c

2、fork server

三、内存共享

四、覆盖率收集与反馈

1、分支信息记录

2、分支信息处理

2.1、classify_counts()

3、反馈

3.1、hash32()

3.2、has_new_bits()

3.3、update_bitmap_score()


  • AFL(American Fuzzy Lop)是一个面向安全的模糊测试工具,它使用编译时插桩技术和遗传算法,可以自动发现触发目标二进程程序的测试用例,从而大大提高测试代码的功能覆盖率。
  • 本文主要讲述AFL是如何实现插桩的,如何对覆盖率进行收集的,以及如何利用覆盖率指导种子进行变异的。

一、插桩

  • 使用AFL对目标程序进行测试时,首先需要通过afl-gcc/afl-clang等工具对其进行编译,并且在这个过程中会对其进行插桩。

1、afl-gcc.c

  • 这里以afl-gcc为例。afl-gcc.c其实就是gcc的一个包装。尝试打印afl-gcc在编译文件时所执行的所有命令行参数。
    • gcc test.c -B /root/src/afl-2.52b -g -O3 -funroll-loops -D__AFL_COMPILER=1 -DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION=1
    • 可以看出,afl-gcc调用了gcc,并定义了一些宏,设置了一些参数。
    • 其中最关键的是:-B /root/src/afl-2.52b。-B 选项用于设置编译器的搜索路径,这里设置成了 /root/src/afl-2.52b,也就是afl-as的路径。
  • 把源文件编译成二进制文件,需要经过“源代码 -> 汇编代码 -> 二进制代码”的过程。将汇编代码编译成二进制代码就需要使用到汇编器。Linux系统下常用的汇编器为as。AFL目录下也有一个as文件,其作为一个符号链接指向了afl-as。
  •  从afl-gcc.c的main中也能看出这点。
  • 所以-B 选项的设置是为了把afl-as作为汇编器来使用。而AFL插桩,就是在源代码编译为汇编代码后,由afl-as完成的。
  • 下图为gcc的编译流程,只不过在第三阶段时,将as替换为了afl-as。

2、afl-as.c

  • afl-as又是如何实现插桩的呢?阅读afl-as.c。其大致逻辑是处理汇编代码,在程序代码段中的每个基本块插入了桩代码,并最终再调用as进行真正的汇编。具体插入代码的部分如下:
    • 这里通过fprintf()将格式化字符串添加到汇编文件的相应位置。其中trampoline_fmt_64和trampoline_fmt_64是定义的汇编代码,也就是桩代码。
      •  
      • 功能:
        • 保存一些寄存器的值
        • 将生成的随机数保存在ecx/rcx中
        • 调用__afl_maybe_log()
        • 恢复寄存器
      • fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32, R(MAP_SIZE));
        • 其中R(MAP_SIZE))是ecx/rcx要设置的值。MAP_SIZE定义为64K,R(x)定义为(random() % (x)) ,故R(MAP_SIZE))为0~64K的一个随机数。
      • 所以,在一个基本块中插入桩代码时,afl-as会生成一个随机数,保存在ecx/rcx中,用来标记该基本块。
2.1、基本块
  • 基本块 (BB)指一组顺序执行的指令,BB中第一条指令被执行后,后续的指令也会被全部执行,每个BB中所有指令的执行次数是相同的,也就是说一个BB必须满足以下特征:
    • 只有一个入口,BB中的指令不是任何跳转指令的目标。
    • 只有一个出口,只有最后一条指令执行完跳到另一个BB中。
  • 例如,下面代码分为4个基本块。

二、第一次执行

  • 编译完目标程序后,就可以进行fuzzing了。(本文只讲述了第一次fuzzing)

1、afl-fuzz.c

  • 在main中,首先会对所有初始测试用例进行试运行。(本文未提及对种子的变异操作)
  • 在perform_dry_run()中,会对测试用例进行校准,根据校准的返回值res ,查看是哪种错误并进行判断。
  • 校验测试用例,就需要将这个测试用例输入到目标程序中运行。首先需要启动fork server。

2、fork server

  • 为了更高效地进行上述过程,AFL实现了一套fork server机制。其基本思路是:启动目标进程后,目标会运行一个fork server。afl-fuzz不需要负责fork子进程,只需要与这个fork server通信,并由fork server来完成fork及继续执行目标的操作。
  • 简单来说,当执行第一个基本块时会启动fork server,afl-fuzz和fork server之间通过管道通信,每当afl-fuzz生成一个测试用例,就会通知fork server去fork一个子进程,然后子进程会从fork server的位置继续往下执行并处理数据,而fork server则继续等待afl-fuzz的请求。
  • 下面讲述fork server的具体运行原理:
    • 首先,afl-fuzz执行fork()得到父进程和子进程,父进程为afl-fuzz,子进程为fork server
    • 父子进程之间,是通过管道进行通信。具体使用了2个管道,一个用于传递状态,另一个用于传递命令。
    • 对于子进程(fork server),会进行一系列设置,其中包括将上述两个管道分配到预先指定的文件描述符 (fd),并最终执行目标程序。
      • 【注】内核利用文件描述符 (fd)来访问文件。文件描述符是非负整数。打开现存文件或新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件。
    • 对于父进程(afl-fuzz),则会读取状态管道的信息,如果一切正常,则说明fork server创建完成。
  • 下面讲述afl-fuzz是如何与fork server进行通信的:
    • fork server的实现过程,是插桩代码中调用的_afl_maybe_log()函数(afl-as.h)实现的。
      • _afl_maybe_log:用于保护现场和检查共享内存是否以分配。
      • 如已分配,则跳转到__afl_setup。初始化共享内存指针等。
      • 如一切顺利,则进入_afl_forkserver。
        • 首先,通过写入状态管道,fork server会通知afl-fuzz,其已经准备完毕,可以开始fork了。上面则父进程等待的信息。
      • 然后,fork server进入等待状态__afl_fork_wait_loop,读取命令管道,直到afl-fuzz通知其开始fork。
      • 一旦fork server接收到afl-fuzz的信息,便调用fork(),得到一个子进程(目标进程)。
      • 子进程(目标进程)则跳转执行 __afl_fork_resume,该函数会关闭不需要的管道并恢复现场。然后执行__afl_store(该函数用于记录命中桩代码的次数,并计算覆盖率,后面再讲)。
      • 子进程(目标进程)执行期间,fork server会将子进程的pid通过状态管道发送给afl-fuzz,并执行waitpid等待子进程执行完毕。一旦子进程(目标进程)执行完毕,fork server则再通过状态管道,将其结束状态发送给afl-fuzz。之后再次进入__afl_fork_wait_loop,重新循环操作。
    • 父进程(afl-fuzz)的实现过程,就是在fork server启动完成后,一旦需要执行某个测试用例,就调用run_target()方法。
      • run_target()方法会通过命令管道,通知fork server准备fork,并通过状态管道,获取子进程(目标进程)的pid。
      • 之后,afl-fuzz再次读取状态管道,获取子进程的退出状态,并由此来判断子进程结束的原因,例如正常退出、超时、崩溃等,并进行相应的记录。

三、内存共享

  • AFL最大的特点是可以通过覆盖率来指导种子的变异。也就是AFL对目标代码插桩编译再执行时,会收集执行过程中的分支信息,即覆盖率。
  • AFL使用共享内存来实现alf-fuzz和目标程序之间的信息传递。
    • afl-fuzz.c的main中会调用setup_shm()来配置共享内存。
    • 该函数中,首先会调用shmget()分配一块大小为64KB(MAP_SIZE=64K)的共享内存。
    • 分配成功后,该共享内存的标志符会被设置到环境变量中,从而之后fork()得到的子进程可以通过该环境变量,得到这块共享内存的标志符。
    • afl-fuzz也会使用变量 trace_bits 保存该共享内存地址。
    • 并且每次执行目标进程时,首先会将该共享内存清零。
  • 接下来讲述目标进程时如何获取并使用这块共享内存的。同样也是在_afl_maybe_log()函数(afl-as.h)实现的。
    • 首先会检查共享内存是否映射完成。
      • __afl_area_ptr中保存的就是共享内存映射到目标进程的内存空间中的地址,如果非空,则保存在edx中继续执行;否则跳转到__afl_setup。
    • __afl_setup会做一些错误检查,然后获取环境变量AFL_SHM_ENV的内容并将其转为整型。AFL_SHM_ENV存放的是之前afl-fuzz保存的共享内存的标志符。
    • 最后目标进程调用shmat()函数,将这块共享内存也映射到了自己的内存空间中,之后将其地址保存在__afl_area_ptr和edx中。
    • 以上就完成了afl-fuzz与目标进程之间共享内存的设置。
    • 【注】如果是fork server模式,那么上述获取共享内存的操作,是在fork server中进行。之后fork出来的子进程(目标进程),只需直接使用这个共享内存即可。

四、覆盖率收集与反馈

1、分支信息记录

  • 上面已经讲述了afl-fuzz与子进程使用内存共享进行通信,通信的内容是执行流程和代码覆盖情况。那AFL是如何记录这些信息的呢?
  • AFL通过插桩代码捕获边 (edge)覆盖率。什么是边呢?将程序看成一个控制流图,图的每个节点表示一个基本块,而边表示在基本块之间的转跳。知道了每个基本块和跳转的执行次数,就可以知道程序中的每个语句和分支的执行次数,从而获得比记录基本块更细粒度的覆盖率信息。
  • 而AFL是利用二元组(当前基本块+前一基本块)来记录分支(边)信息的,其伪代码如下:
    • cur_location = <COMPILE_TIME_RANDOM>;  // 用一个随机数标记当前基本块
      shared_mem[cur_location ^ prev_location]++;  // 将当前块和前一块异或作为该分支的key,并在shared_mem数组(共享内存)对应的位置+1
      prev_location = cur_location >> 1;  // cur_location右移1位赋给prev_location,原因看下方
  • 信息收集也是在_afl_maybe_log()函数中完成的。当afl-fuzz保存了共享内存地址并且完成了fork server的初始操作后,会调用__afl_store(32位是ecx,64位是rcx,下面以64为例)
    • 其中,rcx 开始保存的是 cur_location,然后保存的是 cur_location ^ prev_location。__afl_prev_loc 开始保存的是伪代码中的 cur_location,之后右移了1位,变成了 prev_location。
    • 回顾前面插桩时做的操作,R(MAP_SIZE) 生成了一个 0~64K 的随机数,并保存到了 rcx 中。这就实现了伪代码中的 cur_location = <COMPILE_TIME_RANDOM> 操作。
  • 【原理】
    • ​​​​​AFL为每一个代码块生成了一个随机数,用于标记其位置。之后对分支处的当前块和前一块的位置(随机数)进行异或操作,并将异或结果作为该分支的key。当该分支被执行,共享内存中的对应位置就会 +1(用1Byte储存分支的执行次数),实际就是哈希表的映射。共享内存保存的是一张哈希表,哈希表中记录的是边覆盖率和分支的执行次数。
    • 因为使用的是哈希算法,所以会存在碰撞问题。但如果目标程序不是太复杂的话,碰撞概率也不会很高。
    • 另外,为什么需要将 cur_location 右移1位后,再保存到 prev_location 中?
      • 如果存在 A -> A 或 B -> B 这样的跳转,如果不右移的话,那这两个分支异或的值都是0,无法区分。
      • 如果存在 A ->B 和 B -> A 这样的跳转,如果不右移的话,那这两个分支异或的值是相同的,也无法区分。
    • 当目标进程执行结束后,afl-fuzz便开始对这张表进行分析,从而判断代码的执行情况。

2、分支信息处理

2.1、classify_counts()
  • 回到第一次执行所有测试用例中,在执行目标进程的最后,会调用classify_counts()先对共享内存中的执行次数进行重新计数,传入的参数trace_bits即为共享内存的地址。
    • (这里将trace_bits强制转换为了64位的指针,也就是一次读取8字节)
    •  
    • 实际就是对分支执行次数进行分类。例如,执行了4~7次的计数为8,32~127次的计数为64。
    • 目的是处理一些微小误差,而被误判为不同执行结果的情况。例如,分支A执行了32次;对另外一个测试用例,分支A执行了33次,那么AFL就会认为这两次的代码覆盖是相同的。因为它们都被处理为了64。
    • 【注】使用 count_class_lookup16 是因为AFL在后面实际进行规整的时候,是一次读两个字节去处理的,为了提高效率,这只是出于效率的考量。

3、反馈

3.1、hash32()
  • 目标进程执行完后,会调用hash32()计算共享内存的校验和。
    • hash32是自编的函数,暂时不关注它是如何实现的。
3.2、has_new_bits()
  • 如果检验和发生了改变,则会调用has_new_bits()判断,本次执行是产生了新的路径还是执行次数发生了改变,并记录到q中。
3.3、update_bitmap_score()
  • 最后会调用update_bitmap_score()更新相关信息。实际维护一个queue_entry类型的top_rated数组,其中存储的是更favorable的测试用例。而favorable的判定是通过该测试用例的执行时间和测试用例长度的乘积来判定的,乘积越小越favorable。所以top_rated数组存储了能抵达当前已覆盖路径的最favorable的测试用例。
  • 这样,AFL就能利用这些信息选中最佳的种子进行突变了。
  • 23
    点赞
  • 35
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

恣睢s

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值