fuzzer AFL 源码分析(三)-反馈

f0cus77 2022-09-26 09:25:00

上一部分对afl-fuzz的总流程进行了概要性的阐述,接下来将会对关键的代码模块进行详细的分析。

先对afl-fuzz过程中反馈与监控机制的实现进行分析,反馈是指afl在对目标程序的模糊测试过程中,目标程序可以将本次运行过程中的状态反馈给afl。本文主要介绍该afl是如何具体实现分支信息的记录以及更高效的运行目标程序的。

基础知识-system V共享内存

进行介绍前,要先对共享内存有一定的了解与掌握,详细的可以去看之前写的《进程共享内存技术》,这里只阐述和afl相关的SYSTEM V共享内存。

共享内存就是多个进程间共同使用同一段物理内存空间,它是通过将同一段物理内存映射到不同进程的虚空间中来实现的。由于映射到不同进程的虚拟地址空间中,不同进程可以直接使用,不需要进行内存的复制,所以共享内存的效率很高。

基本介绍

System V曾经也被称为AT&T System V,是Unix操作系统众多版本中的一支。它最初由AT&T开发,在1983年第一次发布。一共发行了4个System V的主要版本:版本1、2、3和4。

System V共享内存机制为了在多个进程之间交换数据,内核专门留出了一块内存区域用于共享,共享这个内存区域的进程就只需要将该区域映射到本进程的地址空间中即可。内核直接实现了shmget/at系统调用,最终也是靠tmpfs来实现的。

System VIPC对象有共享内存、消息队列、信号灯(量)。注意:在IPC的通信模式下,不管是共享内存、消息队列还是信号灯,每个IPC的对象都有唯一的名字,称为"键(key)"。通过"键",进程能够识别所用的对象。"键"与IPC对象的关系就如同文件名称于文件,通过文件名,进程能够读写文件内的数据,甚至多个进程能够公用一个文件。而在IPC的通信模式下,通过"键"的使用也能使得一个IPC对象能为多个进程所共用。

使用步骤

共享内存的使用过程可分为 创建->连接->使用->分离->销毁 这几步。

  1. 创建/打开共享内存
  2. 映射共享内存,即把指定的共享内存映射到进程的地址空间用于访问
  3. 撤销共享内存的映射
  4. 删除共享内存对象

执行过程先调用shmget,获得或者创建一个IPC共享内存区域,并返回获得区域标识符。类似于mmap中先open一个磁盘文件返回文件标识符一样。
再调用shmat,完成获得的共享区域映射到本进程的地址空间中,并返回进程映射地址。类似与mmap函数原理。
使用完成后,调用shmdt解除共享内存区域和进程地址的映射关系。每个共享的内存区,内核维护一个struct shmid_ds信息结构,定义在sys/shm.h头文件中

相关API

#include<sys/ipc.h>
#include<sys/shm.h>

int shmget(key_t key, size_t size, int shmflg)

共享内存的创建使用shmget函数(shared memory get)函数。shmget根据shm_key创建一个大小为page_size的共享内存空间,参数shmflag是一系列的创建参数。如果shm_key已经创建,使用该shm_key会返回可以连接到该以创建共享内存的id

调用成功返回一个shmid(类似打开一个或创建一个文件获得的文件描述符一样),调用失败返回-1

#include<sys/types.h>
#include<sys/shm.h>

void * shmat(int shmid, const void *shmaddr, int shmflg);

创建后,为了使共享内存可以被当前进程使用,必须紧接着进行连接操作。使用函数shmatshared memory attach),参数传入通过shmget返回的共享内存id即可。
shmat返回映射到进程虚拟地址空间的地址指针,这样进程就能像访问一块普通的内存缓冲一样访问共享内存。

int shmdt(const void * shmadr);

当共享内存使用完毕后,使用函数shmdt (shared memory detach)进行解连接。该函数以shmat返回的内存地址作为参数。

单个进程detach时,并不会从内核中删除该共享内存,而是把相关shmid_ds结构的shm_nattch域的值减1,当这个值为0时,内核才从物理上删除这个共享内存。即最后一个使用该共享内存的进程并detach该共享内存后,内核将会自动销毁该共享内存自动销毁。当然,最好能显式的进行销毁,以避免不必要的共享内存资源浪费。

#inlcude<sys/ipc.h>
#include<sys/shm.h>

int shmctl(int shmid, int cmd, struct shmid_ds *buf);

函数shmctl (shared memory control)可以返回共享内存的信息并对其进行控制。通过cmd指定相应的控制操作,具体包括IPC_STAT(得到共享内存的状态)IPC_SET(改变共享内存的状态)、IPC_RMID(删除共享内存);buf是一个结构体指针,IPC_STAT时,获取内存状态并存储在buf种。如果要改变共享内存的状态,通过buf来进行设定。

示例

writer

/***** writer.c *******/
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>

int main(int argc, char** argv)
{
    int shm_id,i;
    key_t key;
    char buff[0x20];
    char* p_map;
    char* name = "./test_shm";

    setbuf(stdin, 0);
    setbuf(stdout, 0);

    key = ftok(name,0);
    if(key==-1)
        perror("ftok error");

    shm_id=shmget(key,4096,IPC_CREAT);    
    if(shm_id==-1)
    {
        perror("shmget error");
        return;
    }
    p_map=(people*)shmat(shm_id,NULL,0);

    printf("[+] shared memory in writer's addr: %p\n", p_map);

    printf("[+] input: ");
    read(0, buff, 0x20);
    memcpy(p_map, buff, strlen(buff));
    if(shmdt(p_map)==-1)
        perror(" detach error ");
}

reader

/********** reader.c ************/
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <unistd.h>
int main(int argc, char** argv)
{
    int shm_id,i;
    key_t key;
    char* p_map;
    char* name = "./test_shm";
    key = ftok(name,0);
    if(key == -1) {
        perror("ftok error");
        return -1;
    }
    shm_id = shmget(key,4096,IPC_CREAT);    
    if(shm_id == -1)
    {
        perror("shmget error");
        return -1;
    }
    p_map = (char *)shmat(shm_id,NULL,0);
    printf("%s\n",p_map);
    if(shmdt(p_map) == -1) {
        perror(" detach error ");
        return -1;
    }
}

我试了试,需要用root权限运行。

运行结果:

writer.png

reader.png

反馈信息记录

afl是基于反馈的模糊测试工具,能够根据样本的运行记录目标程序的反馈信息并判断是否样例是否有效。一个理所当然的问题则是记录的反馈信息是什么?

根据afl的技术白皮书Technical "whitepaper" for afl-fuzz,在目标程序运行过程中记录的信息是程序的覆盖率,而这个覆盖率具体落实则是边覆盖率。在程序插桩的过程中会在每个基本块插入一个随机值作为唯一编号,当样本从一个基本块运行到另一个基本块的时候会根据两个块的唯一的编号形成一条边,以此形成覆盖率。

具体来说,在fuzzer初始化的过程中会利用system V共享内存申请一片64KB大小的共享内存,并将对应的id值存入到环境变量当中,这片共享内存用于记录目标程序运行的边覆盖率;forkserver调用fork运行目标程序,目标程序运行到分支时,通过全局的当前基本块的唯一编号与前一个基本块的唯一编号的异或作为边的标记,并在对应的数组位置加1,以此来实现分支路径的覆盖以及分支运行次数的统计,公式如下所示。

  cur_location = <COMPILE_TIME_RANDOM>;
  shared_mem[cur_location ^ prev_location]++; 
  prev_location = cur_location >> 1;

为什么后续要将cur_location>>1再放入到prev_location中,是为了避免路径A->B以及B->A二者值一致,也是为了避免A->A以及B->B出现的结果也是一致的情况。

记录的值有两个含义,初始化状态下每条边的值为0,当它存在值的时候说明该边已经运行过,值的多少说明该边在此次的样例运行个多少次。

前面说过每个基本块前插入的编号是随机值,随机值是从共享内存的大小64KB的空间内产生的,因此分支数量增加的时候,可能会出现分支得到的随机值是相同的情况(碰撞),总的来说,应用程序的分支路径在2k10k的数量级的随机值的碰撞情况还是可以接受的。

   Branch cnt | Colliding tuples | Example targets
  ------------+------------------+-----------------
        1,000 | 0.75%            | giflib, lzo
        2,000 | 1.5%             | zlib, tar, xz
        5,000 | 3.5%             | libpng, libwebp
       10,000 | 7%               | libxml
       20,000 | 14%              | sqlite
       50,000 | 30%              | -

forkserver

一般来说正常的基于反馈式的模糊测试工具的工作原理是对目标程序进行代码插桩,然后fuzzer通过调用execve来运行目标程序,记录运行过程中记录目标程序的运行状态,目标程序通过进程通信机制等将运行状态反馈给fuzzerfuzzer判断此次运行的样本是否为有效样本,如果有效则保存该样本,如果无效则丢弃。下一轮循环时再调用execve进行新一轮的目标程序模糊测试。

每次运行目标程序都需要调用一次execve性能消耗太大,execve花费在进程初始化(外部库的加载、符号的解析)是多余且耗时的,为了更高效的运行,afl设计实现了forkserver机制。

fork系统调用会继承父进程的状态,包括内存以及句柄等,因此可在通过插桩在目标程序初始化完成之后创建一个forkserver,由forkserver在循环中不断的调用fork去执行目标程序的主体功能,同时与fuzzer进行通信,从而减少了execve初始化过程中的性能消耗。

具体来说fuzzer先创建两个管道st_pipe以及ctl_pipest_pipe用于forkserverfuzzer传递状态,ctl_pipe用于fuzzerforkserver传递控制信息。fuzzer调用fork函数创建子进程调用execve启动目标程序,目标程序经过插桩后,在main函数执行的时候会形成forkserverforkserver通过ctl_pipest_pipefuzzer进行通信。根据fuzzer的指令,forkerver再循环调用fork去执行目标程序,并记录相应的反馈信息。

源码分析

下面从源码的角度详细阐述上面两个过程。

反馈信息记录

afl-as对汇编代码插桩的过程中我们已分析过,afl会在相应的分支位置插入插桩代码。插入的代码如下所示,可以看到会调用R(MAP_SIZE)生成路径编号随机值,通过格式化字符串的形式将trampoline_fmt_64trampoline_fmt_32插入到代码。

// afl-as.c: 270
fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32,
              R(MAP_SIZE));

// types.h: 82
#  define R(x) (random() % (x))

// config.h: 328
#define MAP_SIZE_POW2       16
#define MAP_SIZE            (1 << MAP_SIZE_POW2)

后面通过64位的代码(trampoline_fmt_64以及main_payload_64)来学习相应的插桩代码,先看trampoline_fmt_64,如下所示:

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";

代码开辟新的栈空间,然后保存rdxrcx以及rax三个后续会被破坏的寄存器(scratch register),同时将该代码块的编号(随机值)保存到rcx中(R(MAP_SIZE)的值);后调用__afl__maybe_log函数记录分支,函数调用完成后恢复现场并继续程序原有功能的运行。

跟进去__afl_maybe_log函数,在main_payload_64中,如下所示,功能是:

lahf指令将标志寄存器的低八位送入AH寄存器,即将标志寄存器FLAGS中SFZFAFPFCF五个标志位分别传送到AH的对应位(八位中有三位是无效的);seto指令将溢出寄存器OF保存到al中,前两条指令的作用是保存标志寄存器到ax中。

然后查看__afl_area_ptr全局变量中的值是否为空。如果是空,说明forkserver未初始化,共享内存指针没有值,需要跳到__afl_setup处去初始化forkserver;如果不为空则可以直接在共享内存将此次的分支运行次数加1

此处先介绍记录分支(__afl_store)的过程,forkserver初始化部分后面再进行介绍。

上条分支的编号值在__afl_prev_loc全局变量中,与当前分支的编号值rcx进行异或,保存在rcx寄存器中的值;将异或后的rcx值再与__afl_prev_loc全局变量异或保存在__afl_prev_loc全局变量中,实现将当前变量的编号保存在__afl_prev_loc中;而后将该变量值右移一位完成prev_location = cur_location >> 1步骤;将异或后的rcx加上共享内存指针__afl_area_ptr,并将该处的值加1实现分支信息的记录,完成shared_mem[cur_location ^ prev_location]++步骤。

addb $127, %al恢复溢出寄存器标识位(前面如果被置位的话,这里加127会溢出,实现置位),sahf指令恢复其余的标志位寄存器,恢复现场,__afl_maybe_log结束,分支记录完成。

static const u8* main_payload_64 = 

  "\n"
  "/* --- AFL MAIN PAYLOAD (64-BIT) --- */\n"
  "\n"
  ".text\n"
  ".att_syntax\n"
  ".code64\n"
  ".align 8\n"
  "\n"
  "__afl_maybe_log:\n"
  "\n"
#if defined(__OpenBSD__)  || (defined(__FreeBSD__) && (__FreeBSD__ < 9))
  "  .byte 0x9f /* lahf */\n"
#else
  "  lahf\n"
#endif /* ^__OpenBSD__, etc */
  "  seto  %al\n"
  "\n"
  "  /* Check if SHM region is already mapped. */\n"
  "\n"
  "  movq  __afl_area_ptr(%rip), %rdx\n"
  "  testq %rdx, %rdx\n"
  "  je    __afl_setup\n"
  "\n"
  "__afl_store:\n"
  "\n"
  "  /* Calculate and store hit for the code location specified in rcx. */\n"
  "\n"
#ifndef COVERAGE_ONLY
  "  xorq __afl_prev_loc(%rip), %rcx\n"
  "  xorq %rcx, __afl_prev_loc(%rip)\n"
  "  shrq $1, __afl_prev_loc(%rip)\n"
#endif /* ^!COVERAGE_ONLY */
  "\n"
#ifdef SKIP_COUNTS
  "  orb  $1, (%rdx, %rcx, 1)\n"
#else
  "  incb (%rdx, %rcx, 1)\n"
#endif /* ^SKIP_COUNTS */
  "\n"
  "__afl_return:\n"
  "\n"
  "  addb $127, %al\n"
#if defined(__OpenBSD__)  || (defined(__FreeBSD__) && (__FreeBSD__ < 9))
  "  .byte 0x9e /* sahf */\n"
#else
  "  sahf\n"
#endif /* ^__OpenBSD__, etc */
  "  ret\n"
  "\n"
  ".align 8\n"
  "\n"

forkserver

下面来看forkserver的创建过程。

forkserver的创建主要包括fuzzer初始化共享内存,调用fork运行目标程序,在目标程序中初始化forkserver

共享内存的初始化是afl-fuzzmain函数中的setup_shm函数实现的,如下所示。可以看到用SYSTEM V共享内存机制创建了64KB大小的共享内存,并将id存放到了环境变量SHM_ENV_VAR中。

/* Configure shared memory and virgin_bits. This is called at startup. */

EXP_ST void setup_shm(void) {

  u8* shm_str;

  if (!in_bitmap) memset(virgin_bits, 255, MAP_SIZE);

  memset(virgin_tmout, 255, MAP_SIZE);
  memset(virgin_crash, 255, 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);

  /* If somebody is asking us to fuzz instrumented binaries in dumb mode,
     we don't want them to detect instrumentation, since we won't be sending
     fork server commands. This should be replaced with better auto-detection
     later on, perhaps? */

  if (!dumb_mode) 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");

}

初始化forkserver的函数则是init_forkserver,由calibrate_case函数调用,当forksrv_pid没有值的时候,说明forkserver尚未初始化,调用init_forkserver

// afl-fuzz.c: 2567
static u8 calibrate_case(char** argv, struct queue_entry* q, u8* use_mem,
                         u32 handicap, u8 from_queue) {

  ...
    // afl-fuzz.c: 2601
  if (dumb_mode != 1 && !no_forkserver && !forksrv_pid)
    init_forkserver(argv);

跟进去init_forkserver,如下所示。先调用pipe函数创建了状态传输管道st_pipe以及控制传输管道ctl_pipe

// 
EXP_ST void init_forkserver(char** argv) {

  ...

  if (pipe(st_pipe) || pipe(ctl_pipe)) PFATAL("pipe() failed");

  forksrv_pid = fork();

在子进程中调用setrlimit函数为子进程设定相应的资源空间,具体包括申请足够的文件句柄(因为子进程中需要用FORKSRV_FD来传输)、创建足够的内存空间(-m参数指定的)、设置内存转储文件大小为0(崩溃后转储会影响性能,因此设置它为0)。

调用setsid让子进程成为独立的进程,不受父进程影响。

将子进程的标准输出以及标准错误句柄重定向到null句柄,如果目标程序的输入是来自于文件的话,则将子进程的标准输入也重定向到null句柄(因为无需从标准输入获取数据,所以就没用了),如果目标程序的输入来源于标准输入,则将标准输入重定向到out_fdout_fd是变异后的样例文件的路径。

将控制管道的读句柄(ctl_pipe[0])重定向到FORKSRV_FD,用于读取fuzzer传递过来的控制信息;将状态管道的写句柄(st_pipe[1])重定向到FORKSRV_FD + 1,用于写运行样例后返回的状态,传递给fuzzer

然后关掉不需要的句柄,并适当设置一些环境变量。

最后调用execve运行目标程序,因为execve不会返回,如果返回就意味着出错了,所以也在execve后面加上*(u32*)trace_bits = EXEC_FAIL_SIG;,父进程如果发现trace_bits的值是EXEC_FAIL_SIG,则说明目标程序没有运行起来,出现了错误。

    // afl-fuzz.c: 2020
    if (!forksrv_pid) {

    struct rlimit r;

    /* Umpf. On OpenBSD, the default fd limit for root users is set to
       soft 128. Let's try to fix that... */

    if (!getrlimit(RLIMIT_NOFILE, &r) && r.rlim_cur < FORKSRV_FD + 2) {

      r.rlim_cur = FORKSRV_FD + 2;
      setrlimit(RLIMIT_NOFILE, &r); /* Ignore errors */

    }

    if (mem_limit) {

      r.rlim_max = r.rlim_cur = ((rlim_t)mem_limit) << 20;

#ifdef RLIMIT_AS

      setrlimit(RLIMIT_AS, &r); /* Ignore errors */

#else

      /* This takes care of OpenBSD, which doesn't have RLIMIT_AS, but
         according to reliable sources, RLIMIT_DATA covers anonymous
         maps - so we should be getting good protection against OOM bugs. */

      setrlimit(RLIMIT_DATA, &r); /* Ignore errors */

#endif /* ^RLIMIT_AS */


    }

    /* Dumping cores is slow and can lead to anomalies if SIGKILL is delivered
       before the dump is complete. */

    r.rlim_max = r.rlim_cur = 0;

    setrlimit(RLIMIT_CORE, &r); /* Ignore errors */

    /* Isolate the process and configure standard descriptors. If out_file is
       specified, stdin is /dev/null; otherwise, out_fd is cloned instead. */

    setsid();

    dup2(dev_null_fd, 1);
    dup2(dev_null_fd, 2);

    if (out_file) {

      dup2(dev_null_fd, 0);

    } else {

      dup2(out_fd, 0);
      close(out_fd);

    }

    /* Set up control and status pipes, close the unneeded original fds. */

    if (dup2(ctl_pipe[0], FORKSRV_FD) < 0) PFATAL("dup2() failed");
    if (dup2(st_pipe[1], FORKSRV_FD + 1) < 0) PFATAL("dup2() failed");

    close(ctl_pipe[0]);
    close(ctl_pipe[1]);
    close(st_pipe[0]);
    close(st_pipe[1]);

    close(out_dir_fd);
    close(dev_null_fd);
    close(dev_urandom_fd);
    close(fileno(plot_file));

    /* This should improve performance a bit, since it stops the linker from
       doing extra work post-fork(). */

    if (!getenv("LD_BIND_LAZY")) setenv("LD_BIND_NOW", "1", 0);

    /* Set sane defaults for ASAN if nothing else specified. */

    setenv("ASAN_OPTIONS", "abort_on_error=1:"
                           "detect_leaks=0:"
                           "symbolize=0:"
                           "allocator_may_return_null=1", 0);

    /* MSAN is tricky, because it doesn't support abort_on_error=1 at this
       point. So, we do this in a very hacky way. */

    setenv("MSAN_OPTIONS", "exit_code=" STRINGIFY(MSAN_ERROR) ":"
                           "symbolize=0:"
                           "abort_on_error=1:"
                           "allocator_may_return_null=1:"
                           "msan_track_origins=0", 0);

    execv(target_path, argv);

    /* Use a distinctive bitmap signature to tell the parent about execv()
       falling through. */

    *(u32*)trace_bits = EXEC_FAIL_SIG;
    exit(0);

  }

看完了子进程,我们再来看看父进程干了啥,代码如下所示。

先关闭ctl_pipe[0]st_pipe[1],因为这是子进程用到的句柄,父进程不需要。将控制管道的写句柄(ctl_pipe[1])保存到全局变量fsrv_ctl_fd,将状态管道的读句柄(st_pipe[0])保存到全局变量fsrv_st_fd中。然后调用setitimer函数设置超时时限,调用read(fsrv_st_fd, &status, 4)函数等待状态管道传回数据(前面子进程起来以后,会传回4字节的hello消息),接收到该信息后(rlen == 4)表明forkserver已经正常启动了;否则说明forkserver启动失败,通过子进程返回的消息以及看trace_bits是否是EXEC_FAIL_SIG去看失败的原因。

    // afl-fuzz.c: 2127
    /* Close the unneeded endpoints. */

  close(ctl_pipe[0]);
  close(st_pipe[1]);

  fsrv_ctl_fd = ctl_pipe[1];
  fsrv_st_fd  = st_pipe[0];

  /* Wait for the fork server to come up, but don't wait too long. */

  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);

  it.it_value.tv_sec = 0;
  it.it_value.tv_usec = 0;

  setitimer(ITIMER_REAL, &it, NULL);

  /* If we have a four-byte "hello" message from the server, we're all set.
     Otherwise, try to figure out what went wrong. */

  if (rlen == 4) {
    OKF("All right - fork server is up.");
    return;
  }

  if (child_timed_out)
    FATAL("Timeout while initializing fork server (adjusting -t may help)");

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

  if (WIFSIGNALED(status)) {
        ...
  }
    if (*(u32*)trace_bits == EXEC_FAIL_SIG)
    ...
  }
    FATAL("Fork server handshake failed");

分析完成以后我们再看子进程启动以后目标程序干了什么,在前面反馈信息记录中我们已经分析过插桩代码在判断全局变量共享内存指针__afl_area_ptr为空的时候,会跳到__afl_setup去初始化forkserver,跟进去该标签代码。

开始判断是否已经初始化失败(__afl_setup_failure1)过一次了,如果是的话则直接返回;查看全局变量__afl_global_area_ptr是否有值,如果有的话,说明已经初始化过了可直接跳到__afl_store,否则去__afl_setup_first标签处进行初始化。

  // afl-as.h: 442
    "__afl_setup:\n"
  "\n"
  "  /* Do not retry setup if we had previous failures. */\n"
  "\n"
  "  cmpb $0, __afl_setup_failure(%rip)\n"
  "  jne __afl_return\n"
  "\n"
  "  /* Check out if we have a global pointer on file. */\n"
  "\n"
#ifndef __APPLE__
  "  movq  __afl_global_area_ptr@GOTPCREL(%rip), %rdx\n"
  "  movq  (%rdx), %rdx\n"
#else
  "  movq  __afl_global_area_ptr(%rip), %rdx\n"
#endif /* !^__APPLE__ */
  "  testq %rdx, %rdx\n"
  "  je    __afl_setup_first\n"
  "\n"
  "  movq %rdx, __afl_area_ptr(%rip)\n"
  "  jmp  __afl_store\n" 
  "\n"

来看__afl_setup_first处的代码,如下所示。可以看到一开始是开辟新的栈空间,然后保存所有的寄存器,这一步主要是保存现场环境,避免初始化破坏了程序原有的运行环境。

  // afl-as.h: 463
    "__afl_setup_first:\n"
  "\n"
  "  /* Save everything that is not yet saved and that may be touched by\n"
  "     getenv() and several other libcalls we'll be relying on. */\n"
  "\n"
  "  leaq -352(%rsp), %rsp\n"
  "\n"
  "  movq %rax,   0(%rsp)\n"
  "  movq %rcx,   8(%rsp)\n"
  "  movq %rdi,  16(%rsp)\n"
  "  movq %rsi,  32(%rsp)\n"
  "  movq %r8,   40(%rsp)\n"
  "  movq %r9,   48(%rsp)\n"
  "  movq %r10,  56(%rsp)\n"
  "  movq %r11,  64(%rsp)\n"
  "\n"
  "  movq %xmm0,  96(%rsp)\n"
  "  movq %xmm1,  112(%rsp)\n"
  "  movq %xmm2,  128(%rsp)\n"
  "  movq %xmm3,  144(%rsp)\n"
  "  movq %xmm4,  160(%rsp)\n"
  "  movq %xmm5,  176(%rsp)\n"
  "  movq %xmm6,  192(%rsp)\n"
  "  movq %xmm7,  208(%rsp)\n"
  "  movq %xmm8,  224(%rsp)\n"
  "  movq %xmm9,  240(%rsp)\n"
  "  movq %xmm10, 256(%rsp)\n"
  "  movq %xmm11, 272(%rsp)\n"
  "  movq %xmm12, 288(%rsp)\n"
  "  movq %xmm13, 304(%rsp)\n"
  "  movq %xmm14, 320(%rsp)\n"
  "  movq %xmm15, 336(%rsp)\n"
  "\n"

然后是调用getenv函数从环境变量中SHM_ENV_VAR获取之前setup_shm函数存放的共享内存的id,然后调用函数shmat获取共享内存的地址,并将其存放到__afl_area_ptr变量中。

  // afl-as.h: 496
    "  /* Map SHM, jumping to __afl_setup_abort if something goes wrong. */\n"
  "\n"
  "  /* The 64-bit ABI requires 16-byte stack alignment. We'll keep the\n"
  "     original stack ptr in the callee-saved r12. */\n"
  "\n"
  "  pushq %r12\n"
  "  movq  %rsp, %r12\n"
  "  subq  $16, %rsp\n"
  "  andq  $0xfffffffffffffff0, %rsp\n"
  "\n"
  "  leaq .AFL_SHM_ENV(%rip), %rdi\n"
  CALL_L64("getenv")
  "\n"
  "  testq %rax, %rax\n"
  "  je    __afl_setup_abort\n"
  "\n"
  "  movq  %rax, %rdi\n"
  CALL_L64("atoi")
  "\n"
  "  xorq %rdx, %rdx   /* shmat flags    */\n"
  "  xorq %rsi, %rsi   /* requested addr */\n"
  "  movq %rax, %rdi   /* SHM ID         */\n"
  CALL_L64("shmat")
  "\n"
  "  cmpq $-1, %rax\n"
  "  je   __afl_setup_abort\n"
  "\n"
  "  /* Store the address of the SHM region. */\n"
  "\n"
  "  movq %rax, %rdx\n"
  "  movq %rax, __afl_area_ptr(%rip)\n"
  "\n"
#ifdef __APPLE__
  "  movq %rax, __afl_global_area_ptr(%rip)\n"
#else
  "  movq __afl_global_area_ptr@GOTPCREL(%rip), %rdx\n"
  "  movq %rax, (%rdx)\n"
#endif /* ^__APPLE__ */
  "  movq %rax, %rdx\n"
  "\n"

获取了共享内存以后,目标程序已经获取了必要的运行基础,已经有了记录分支的必要条件。接下来就是向fuzzer发送信息看状态管道以及控制管道的通信是否正常,调用writeFORKSRV_FD + 1(状态管道的写句柄)写入了4字节的hello消息。这里可以和上面父进程等待子进程4字节的消息呼应起来,证明forkserver初始化成功。

  // afl-as.h: 536
    "__afl_forkserver:\n"
  "\n"
  "  /* Enter the fork server mode to avoid the overhead of execve() calls. We\n"
  "     push rdx (area ptr) twice to keep stack alignment neat. */\n"
  "\n"
  "  pushq %rdx\n"
  "  pushq %rdx\n"
  "\n"
  "  /* Phone home and tell the parent that we're OK. (Note that signals with\n"
  "     no SA_RESTART will mess it up). If this fails, assume that the fd is\n"
  "     closed because we were execve()d from an instrumented binary, or because\n"
  "     the parent doesn't want to use the fork server. */\n"
  "\n"
  "  movq $4, %rdx               /* length    */\n"
  "  leaq __afl_temp(%rip), %rsi /* data      */\n"
  "  movq $" STRINGIFY((FORKSRV_FD + 1)) ", %rdi       /* file desc */\n"
  CALL_L64("write")
  "\n"
  "  cmpq $4, %rax\n"
  "  jne  __afl_fork_resume\n"
  "\n"

初始化成功后,来看forkserver的具体工作机制是如何实现的,前面说过是forkserver在循环中每次调用fork来运行目标程序的功能。

在循环中先调用read函数从FORKSRV_FD(控制管道读句柄)中接收4字节信息,每次从FORKSRV_FD接收到4字节信息说明fuzzer要进行新一轮的运行。

  // afl-as.h: 557
  "__afl_fork_wait_loop:\n"
  "\n"
  "  /* Wait for parent by reading from the pipe. Abort if read fails. */\n"
  "\n"
  "  movq $4, %rdx               /* length    */\n"
  "  leaq __afl_temp(%rip), %rsi /* data      */\n"
  "  movq $" STRINGIFY(FORKSRV_FD) ", %rdi             /* file desc */\n"
  CALL_L64("read")
  "  cmpq $4, %rax\n"
  "  jne  __afl_die\n"
  "\n"

当新的一轮运行开始以后forkserver调用fork函数启动运行。

  "  /* Once woken up, create a clone of our process. This is an excellent use\n"
  "     case for syscall(__NR_clone, 0, CLONE_PARENT), but glibc boneheadedly\n"
  "     caches getpid() results and offers no way to update the value, breaking\n"
  "     abort(), raise(), and a bunch of other things :-( */\n"
  "\n"
  CALL_L64("fork")
  "  cmpq $0, %rax\n"
  "  jl   __afl_die\n"
  "  je   __afl_fork_resume\n"
  "\n"

子进程对应去到__afl_fork_resume标签,调用close关闭相应的控制及状态管道(因为用不到),然后恢复栈以及寄存器环境,最后跳到__afl_store去记录分支信息,该标签在前面已经说过,不需要再说了。

  "__afl_fork_resume:\n"
  "\n"
  "  /* In child process: close fds, resume execution. */\n"
  "\n"
  "  movq $" STRINGIFY(FORKSRV_FD) ", %rdi\n"
  CALL_L64("close")
  "\n"
  "  movq $" STRINGIFY((FORKSRV_FD + 1)) ", %rdi\n"
  CALL_L64("close")
  "\n"
  "  popq %rdx\n"
  "  popq %rdx\n"
  "\n"
  "  movq %r12, %rsp\n"
  "  popq %r12\n"
  "\n"
  "  movq  0(%rsp), %rax\n"
  "  movq  8(%rsp), %rcx\n"
  "  movq 16(%rsp), %rdi\n"
  "  movq 32(%rsp), %rsi\n"
  "  movq 40(%rsp), %r8\n"
  "  movq 48(%rsp), %r9\n"
  "  movq 56(%rsp), %r10\n"
  "  movq 64(%rsp), %r11\n"
  "\n"
  "  movq  96(%rsp), %xmm0\n"
  "  movq 112(%rsp), %xmm1\n"
  "  movq 128(%rsp), %xmm2\n"
  "  movq 144(%rsp), %xmm3\n"
  "  movq 160(%rsp), %xmm4\n"
  "  movq 176(%rsp), %xmm5\n"
  "  movq 192(%rsp), %xmm6\n"
  "  movq 208(%rsp), %xmm7\n"
  "  movq 224(%rsp), %xmm8\n"
  "  movq 240(%rsp), %xmm9\n"
  "  movq 256(%rsp), %xmm10\n"
  "  movq 272(%rsp), %xmm11\n"
  "  movq 288(%rsp), %xmm12\n"
  "  movq 304(%rsp), %xmm13\n"
  "  movq 320(%rsp), %xmm14\n"
  "  movq 336(%rsp), %xmm15\n"
  "\n"
  "  leaq 352(%rsp), %rsp\n"
  "\n"
  "  jmp  __afl_store\n"
  "\n"

父进程则是将子进程运行的pid通过状态管道的写句柄传回给fuzzer;然后调用waitpid等待子进程运行结束(子进程运行结束,表明该轮的模糊测试完成),将子进程返回的状态码status返回给fuzzer,以判断程序的运行结果(崩溃、超时等)。

最后跳回到__afl_fork_wait_loop标签等待下一次运行。

  // afl-as.h: 578
    "  /* In parent process: write PID to pipe, then wait for child. */\n"
  "\n"
  "  movl %eax, __afl_fork_pid(%rip)\n"
  "\n"
  "  movq $4, %rdx                   /* length    */\n"
  "  leaq __afl_fork_pid(%rip), %rsi /* data      */\n"
  "  movq $" STRINGIFY((FORKSRV_FD + 1)) ", %rdi             /* file desc */\n"
  CALL_L64("write")
  "\n"
  "  movq $0, %rdx                   /* no flags  */\n"
  "  leaq __afl_temp(%rip), %rsi     /* status    */\n"
  "  movq __afl_fork_pid(%rip), %rdi /* PID       */\n"
  CALL_L64("waitpid")
  "  cmpq $0, %rax\n"
  "  jle  __afl_die\n"
  "\n"
  "  /* Relay wait status to pipe, then loop back. */\n"
  "\n"
  "  movq $4, %rdx               /* length    */\n"
  "  leaq __afl_temp(%rip), %rsi /* data      */\n"
  "  movq $" STRINGIFY((FORKSRV_FD + 1)) ", %rdi         /* file desc */\n"
  CALL_L64("write")
  "\n"
  "  jmp  __afl_fork_wait_loop\n"
  "\n"

总结

afl的反馈过程总的来说使用了两个技术:一个是共享内存技术用来记录分支的运行信息,使得可以有效的获取程序的运行状态;一个是forkserver技术来避免进程初始化所占用的不必要的性能消耗,使得可以更高效的对目标程序进行模糊测试。

通过状态以及控制管道来实现fuzzerforkserver之间的通信也是非常的巧妙。

貌似现在挺多的fuzzer都是用的这一套机制来进行模糊测试,前段时间看的fuzzilli也是用这一套共享内存以及管道来实现的模糊测试。

参考

  1. Technical "whitepaper" for afl-fuzz

评论

f0cus77

STILL YOUNG

随机分类

二进制安全 文章:77 篇
密码学 文章:13 篇
Python安全 文章:13 篇
SQL注入 文章:39 篇
XSS 文章:34 篇

扫码关注公众号

WeChat Offical Account QRCode

最新评论

路人甲@@

https://github.com/ice-doom/CodeQLRule 代

S

shungli923

师傅,催更啊!!!

1

1ue1uekin8

6

素十八

F

f0zz

给大佬点赞

目录