AFLNET源码分析
2023-11-27 17:55:15 Author: mp.weixin.qq.com(查看原文) 阅读量:20 收藏

AFLNET源自一篇论文:
论文下载https://link.zhihu.com/?target=https%3A//mboehme.github.io/paper/ICST20.AFLNet.pdf
它是一种针对网络协议的灰盒模糊测试器,通过状态反馈来指导模糊测试,在模糊测试的过程中根据协议的反馈不断完善有限状态自动机并据此指导模糊测试框架的数据变异和状态选择。
以往针对协议的黑盒模糊测试框架boofuzz由于缺少代码插桩和覆盖率引导,所以测试数据往往以字典为主,无法主动变异出字典以外的数据。AFLNET以经典框架AFL为基础,改进了测试端和被测端的通信方式,使其能够适配网络协议,接下来这个系列将对AFLNET的源码进行分析。
首先分析的是AFLNET如何实现的测试框架和被测服务端的通信,我们知道在AFL中,框架首先用execv来初始化forkserver,后续的程序启动则通过管道向forkserver发送消息不断fork子进程来进行,输入多为执行参数中直接指定某个文件作为输入,但是对于网络协议来说输入需要以数据包的形式发送到server端,来看看AFLNET是如何实现。
首先对于forkserver的初始化阶段AFL与AFLNET没有任何区别,区别主要体现在forkserver初始化以后的fork操作中,首先关注直接涉及到运行程序的函数run_target。
AFL:
  if (dumb_mode == 1 || no_forkserver) {
    if (waitpid(child_pid, &status, 0) <= 0) PFATAL("waitpid() failed");
  } else {
    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?)");
    }
  }
AFLNET:
  if (dumb_mode == 1 || no_forkserver) {
    if (use_net) send_over_network();
    if (waitpid(child_pid, &status, 0) <= 0) PFATAL("waitpid() failed");
  } else {
    if (use_net) send_over_network();
    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?)");
    }
  }
二者的主要区别在于AFLNET多了一个send_over_network函数。
在forkserver已经初始化并且是灰盒模式的情况下,AFL会直接读取状态管道内容,即等待程序执行完毕。而AFLNET会先调用send_over_network,然后再等待程序执行。这个函数起到了测试端向服务端喂数据的功能,无论是种子数据或者经过变异后的数据,都经过这个函数发送给server进行后续代码执行。
来看具体实现
首先是定义一些变量以及执行一些初始化操作:
◆likely_buggy 用来标志服务端是否可能出现crash
◆serv_addr 服务端地址套接字地址
◆local_serv_addr 本地地址套接字地址
判断是否存在清除脚本,如果存在则执行,一般用于清除执行服务端所产生的痕迹。
sleep一定的时间用来等待服务端初始化。
清除缓冲区并重置缓冲区大小。
创建TCP/UDP套接字。
  int n;
  u8 likely_buggy = 0;
  struct sockaddr_in serv_addr;
  struct sockaddr_in local_serv_addr;
  //Clean up the server if needed
  if (cleanup_script) system(cleanup_script);
  //Wait a bit for the server initialization
  usleep(server_wait_usecs);
  //Clear the response buffer and reset the response buffer size
  if (response_buf) {
    ck_free(response_buf);
    response_buf = NULL;
    response_buf_size = 0;
  }
  if (response_bytes) {
    ck_free(response_bytes);
    response_bytes = NULL;
  }
int sockfd = -1;
  if (net_protocol == PRO_TCP)
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
  else if (net_protocol == PRO_UDP)
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
  if (sockfd < 0) {
    PFATAL("Cannot create a socket");
  }
接下来是设置超时时间以及建立服务端套接字。
//Set timeout for socket data sending/receiving -- otherwise it causes a big delay
  //if the server is still alive after processing all the requests
  struct timeval timeout;
  timeout.tv_sec = 0;
  timeout.tv_usec = socket_timeout_usecs;
  setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, (char *)&timeout, sizeof(timeout));
  memset(&serv_addr, '0', sizeof(serv_addr));
  serv_addr.sin_family = AF_INET;
  serv_addr.sin_port = htons(net_port);
  serv_addr.sin_addr.s_addr = inet_addr(net_ip);
然后是检查local_port是否大于0,这里的local_port是由-l 参数指定的,从注释中也能理解到,有一些协议的响应包只会发给特定的端口,所以框架提供了-l 参数指定接收端口,并将sockfd用bind函数绑定到指定端口上,如果没有指定则无需bind。
//This piece of code is only used for targets that send responses to a specific port number
  //The Kamailio SIP server is an example. After running this code, the intialized sockfd
  //will be bound to the given local port
  if(local_port > 0) {
    local_serv_addr.sin_family = AF_INET;
    local_serv_addr.sin_addr.s_addr = INADDR_ANY;
    local_serv_addr.sin_port = htons(local_port);
    local_serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    if (bind(sockfd, (struct sockaddr*) &local_serv_addr, sizeof(struct sockaddr_in)))  {
      FATAL("Unable to bind socket on local source port");
    }
  }
连接服务端,如果没有连上则等待一定时间之后继续尝试。
if(connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
    //If it cannot connect to the server under test
    //try it again as the server initial startup time is varied
    for (n=0; n < 1000; n++) {
      if (connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == 0) break;
      usleep(1000);
    }
    if (n== 1000) {
      close(sockfd);
      return 1;
    }
  }
接下来就是正式发送数据的逻辑:
首先进行一次接收,这里的net_recv后面说,这里只需要知道如果正常接收的话会返回0即可,第一次接收是为了处理刚连接上服务端的时候服务端发来的一些先行消息。如果接收出现问题则会提前跳转到HANDLE_RESPONSES部分
然后初始化messages_sent,这个变量表示已发送了几次消息,然后kl_messages链表,发送每个状态对应的数据并进行接收。
net_recv函数每次执行都会更新response_buf和response_buf_size,每发送完一个状态的数据后就将当前buf_size记录到prev_buf_size中,然后接收本次发送的数据所获得的响应数据。
更新response_bytes,它是一个全局变量,用于记录协议走到每个状态的接收缓冲区大小。
如果buf_size在执行完net_recv后没有任何变化说明可能出现了crash,所以将likely_buggy置1。
//retrieve early server response if needed
  if (net_recv(sockfd, timeout, poll_wait_msecs, &response_buf, &response_buf_size)) goto HANDLE_RESPONSES;
  //write the request messages
  kliter_t(lms) *it;
  messages_sent = 0;
  for (it = kl_begin(kl_messages); it != kl_end(kl_messages); it = kl_next(it)) {
    n = net_send(sockfd, timeout, kl_val(it)->mdata, kl_val(it)->msize);
    messages_sent++;
    //Allocate memory to store new accumulated response buffer size
    response_bytes = (u32 *) ck_realloc(response_bytes, messages_sent * sizeof(u32));
    //Jump out if something wrong leading to incomplete message sent
    if (n != kl_val(it)->msize) {
      goto HANDLE_RESPONSES;
    }
    //retrieve server response
    u32 prev_buf_size = response_buf_size;
    if (net_recv(sockfd, timeout, poll_wait_msecs, &response_buf, &response_buf_size)) {
      goto HANDLE_RESPONSES;
    }
    //Update accumulated response buffer size
    response_bytes[messages_sent - 1] = response_buf_size;
    //set likely_buggy flag if AFLNet does not receive any feedback from the server
    //it could be a signal of a potentiall server crash, like the case of CVE-2019-7314
    if (prev_buf_size == response_buf_size) likely_buggy = 1;
    else likely_buggy = 0;
  }
在看HANDLE_RESPONSES部分之前先来简单看看net_recv是怎么写的:
简历一个1000的缓冲区,然后以缓冲区为单位调用原生的recv函数进行循环接收,每次接收的字节数n如果小于0则返回1,说明出现问题,否则就用realloc函数扩展response_buf,并更新response_buf_size的值。
int net_recv(int sockfd, struct timeval timeout, int poll_w, char **response_buf, unsigned int *len) {
  char temp_buf[1000];
  int n;
  struct pollfd pfd[1];
  pfd[0].fd = sockfd;
  pfd[0].events = POLLIN;
  int rv = poll(pfd, 1, poll_w);
  setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, (char *)&timeout, sizeof(timeout));
  // data received
  if (rv > 0) {
    if (pfd[0].revents & POLLIN) {
      n = recv(sockfd, temp_buf, sizeof(temp_buf), 0);
      if ((n < 0) && (errno != EAGAIN)) {
        return 1;
      }
      while (n > 0) {
        usleep(10);
        *response_buf = (unsigned char *)ck_realloc(*response_buf, *len + n + 1);
        memcpy(&(*response_buf)[*len], temp_buf, n);
        (*response_buf)[(*len) + n] = '\0';
        *len = *len + n;
        n = recv(sockfd, temp_buf, sizeof(temp_buf), 0);
        if ((n < 0) && (errno != EAGAIN)) {
          return 1;
        }
      }
    }
  } else
    if (rv < 0) // an error was returned
      return 1;
  // rv == 0 poll timeout or all data pending after poll has been received successfully
  return 0;
}
最后来看HANDLE_RESPONSES部分。
首先再进行一次接收,猜测是为了防止出现服务端反应较慢导致一些数据没有被接收到,然后更新最后一个状态的buf_size。
然后是一个等待服务端执行完毕的逻辑,但是实现的方法比较有趣,先将session_virgin_bits初始化为全1,然后不断执行has_new_bits函数,因为在上一步初始化为全1,所以has_new_bits会随着服务端程序的执行不断的覆盖到新的路径,所以会不停的返回2,如果返回值不是2,即有任何新的路径覆盖到,说明服务端本次执行已经结束了。
(这里的逻辑我认为其实还是有一些缺陷的,如果有些服务端有一些逻辑需要一定的时间才能到达的话其实会被框架漏掉,但是这种情况比较少并且如果改成连续多少次返回值不是2的话会牺牲掉一定的效率,并不值得。)
关闭sockfd,kill子进程并为了使其能够优雅的退出进行一定的等待。
HANDLE_RESPONSES:
  net_recv(sockfd, timeout, poll_wait_msecs, &response_buf, &response_buf_size);
  if (messages_sent > 0 && response_bytes != NULL) {
    response_bytes[messages_sent - 1] = response_buf_size;
  }
  //wait a bit letting the server to complete its remaining task(s)
  memset(session_virgin_bits, 255, MAP_SIZE);
  while(1) {
    if (has_new_bits(session_virgin_bits) != 2) break;
  }
  close(sockfd);
  if (likely_buggy && false_negative_reduction) return 0;
  if (terminate_child && (child_pid > 0)) kill(child_pid, SIGTERM);
  //give the server a bit more time to gracefully terminate
  while(1) {
    int status = kill(child_pid, 0);
    if ((status != 0) && (errno == ESRCH)) break;
  }
  return 0;
}
AFLNET的网络通信部分基本就是这些,后面会继续阅读有关状态图的逻辑,搞清楚是如何做到在状态图中进行转换并以此指导模糊测试数据变异的。
上文我们主要分析了send_over_network()函数,弄清楚了为什么AFL多用于文件处理类程序而AFLNET却可以应用于网络协议服务端的模糊测试。
接下来将着眼于模糊测试的整体流程。
首先从main函数入手,AFLNET与AFL在main函数中相同的部分这里就直接略过不讲了,网上分析AFL源码的文章有很多。
直接从main里的fuzz主循环开始看:
首先查看是否开启了状态感知模式,我们通过在命令中增加- E 选项就可以开启,如果不开启的话则会走AFL原有的循环逻辑。
if (state_aware_mode) {
    if (state_ids_count == 0) {
      PFATAL("No server states have been detected. Server responses are likely empty!");
    }
满足if条件则会进入整个fuzzing的主循环。
在主循环中首先初始化selected_seed为NULL,然后判断种子是否未选择以及选择的种子的region数量是否为0,满足任意一个则进入一个用于选择状态的循环。
在状态选择的循环中,执行如下逻辑:
◆调用choose_target_state选取目标状态(这里的state_selection_algo是一个枚举变量,表示选择哪种状态选择策略,默认是轮询选择)。
◆基于已选择的状态调用cull_queue精简队列。
◆更新一个状态被选取过的次数(这里用到的是khash)。
◆调用choose_seed选择种子,其中seed_selection_algo也是一个枚举变量,表示种子选择策略。
while (1) {
u8 skipped_fuzz;

struct queue_entry *selected_seed = NULL;
while(!selected_seed || selected_seed->region_count == 0) {
target_state_id = choose_target_state(state_selection_algo);

/* Update favorites based on the selected state */
cull_queue();

/* Update number of times a state has been selected for targeted fuzzing */
khint_t k = kh_get(hms, khms_states, target_state_id);
if (k != kh_end(khms_states)) {
kh_val(khms_states, k)->selected_times++;
}

selected_seed = choose_seed(target_state_id, seed_selection_algo);
}

具体AFLNET是如何选择状态和选择种子的我们等到主循环分析结束之后再来详细分析。
接着往下看,如果selected_seed不为NULL,就要在queue里寻找这个种子并将其设置为queue_cur。
if (selected_seed) {
        if (!queue_cur) {
            current_entry     = 0;
            cur_skipped_paths = 0;
            queue_cur         = queue;
            queue_cycle++;
        }
        while (queue_cur != selected_seed) {
          queue_cur = queue_cur->next;
          current_entry++;
          if (!queue_cur) {
            current_entry     = 0;
            cur_skipped_paths = 0;
            queue_cur         = queue;
            queue_cycle++;
          }
        }
      }
接下来调用fuzz_one函数进行一轮fuzz,然后根据三个值来判断是否要调用sync_fuzzers。
其中skipped_fuzz表示是否开启了IGNORE_FINDS模式,如果开启则fuzzer只会使用原始种子文件进行fuzz。
stop_soon则是由用户的ctrl+c操作进行设置,表示用户是否要停止fuzzing。
sync_id表示fuzzer 的id。
如果三个条件都满足,则将sync_interval_cnt加1模5并判断是否为0,为0则调用sync_fuzzers。即在一切正常的情况下每SYNC_INTERVAL(默认为5)次调用一次sync_fuzzers。
skipped_fuzz = fuzz_one(use_argv);
      if (!stop_soon && sync_id && !skipped_fuzz) {
        if (!(sync_interval_cnt++ % SYNC_INTERVAL))
          sync_fuzzers(use_argv);
      }
      if (!stop_soon && exit_1) stop_soon = 2;
      if (stop_soon) break;
这里注意到一点,上述代码和AFL中没有区别,少了两句:
queue_cur = queue_cur->next;
    current_entry++;
这是AFL中有而AFLNET中没有的,并且只是状态感知模式下没有,没开状态感知的话仍然是有这两句代码的。原因在于AFL的fuzzing队列是顺序往下走的,但是状态感知模式会在开头通过选择状态和种子来确定本次fuzzing所用的queue是什么。
后面的代码AFLNET和AFL就没有任何区别了,都是一些信息展示和退出的功能,这里就不再赘述。
接下来分析在开头暂时略过的一些函数。
choose_target_state:
函数接收一个单字节参数,表示选用哪种状态选择策略,默认为2轮询模式,不过官方给的例子用的是3偏好模式(也许翻译的有偏差)。
如果是第一种随机模式,则selected_state_index直接从UR(即random函数)中获取,范围用state_ids_count来限制。
如果是第二种轮询模式,则selected_state_index不断加一按顺序进行轮转,如果等于状态总数了就回到0继续下一轮。
如果是第三种偏好模式,在state_cycles小于5的时候会首先按照轮询的模式进行选择,因为需要先获取足够的状态信息,如果大于5则调用update_scores_and_select_next_state获取result。
unsigned int choose_target_state(u8 mode) {
  u32 result = 0;
  switch (mode) {
    case RANDOM_SELECTION: //Random state selection
      selected_state_index = UR(state_ids_count);
      result = state_ids[selected_state_index];
      break;
    case ROUND_ROBIN: //Round-robin state selection
      result = state_ids[selected_state_index];
      selected_state_index++;
      if (selected_state_index == state_ids_count) selected_state_index = 0;
      break;
    case FAVOR:
      /* Do ROUND_ROBIN for a few cycles to get enough statistical information*/
      if (state_cycles < 5) {
        result = state_ids[selected_state_index];
        selected_state_index++;
        if (selected_state_index == state_ids_count) {
          selected_state_index = 0;
          state_cycles++;
        }
        break;
      }
      result = update_scores_and_select_next_state(FAVOR);
      break;
    default:
      break;
  }
  return result;
}
继续深入update_scores_and_select_next_state看看其逻辑:
由于下面的代码中涉及到了一些state的属性,所以我们先来熟悉一下state结构(注释已进行标注)。
typedef struct {
  u32 id;                     /* 状态id */
  u8 is_covered;              /* 状态是否被覆盖 */
  u32 paths;                  /* 此状态经过的所有路径 */
  u32 paths_discovered;       /* 由此状态新发现的路径*/
  u32 selected_times;         /* 此状态被选择过的次数*/
  u32 fuzzs;                  /* fuzz总次数 */
  u32 score;                  /* 状态分数 */
  u32 selected_seed_index;    /* 最近选择过此状态的种子序号*/
  void **seeds;               /* 保存了所有能到达此状态的种子* */
  u32 seeds_count;            /* 能到达此状态的种子数量 */
} state_info_t;
如果state_ids_count为0则直接返回0,为state_scores申请内存,每个state申请4字节。
然后更新每个状态的分数,更新的时候会依据fuzz的次数,被选择过的次数以及发现路径的条数这些指标来计算分数,分数越高说明这个状态越好。
接下来的逻辑一眼看上去可能会有些奇怪,按照惯性思维我们应该将分数排序然后选取最高的,但是作者采用的方式是将得分累计起来,然后在所有分数之和的范围内获取一个随机数。
然后调用index_search来获取最终的idx,这个index_search的实现也较为简单,就是找到第一个大于上一步获取的随机数的分数,将它的序号作为最后选出的idx。
这么实现的道理是,保证了分数越高,被随机数命中的概率就越大,从而实现分数越高越容易选中,同时省去了排序操作效率更高,并且兼顾了随机性,还是比较巧妙的。
u32 update_scores_and_select_next_state(u8 mode) {
  u32 result = 0, i;
  if (state_ids_count == 0) return 0;
  u32 *state_scores = NULL;
  state_scores = (u32 *)ck_alloc(state_ids_count * sizeof(u32));
  if (!state_scores) PFATAL("Cannot allocate memory for state_scores");
  khint_t k;
  state_info_t *state;
  //Update the states' score
  for(i = 0; i < state_ids_count; i++) {
    u32 state_id = state_ids[i];
    k = kh_get(hms, khms_states, state_id);
    if (k != kh_end(khms_states)) {
      state = kh_val(khms_states, k);
      switch(mode) {
        case FAVOR:
          state->score = ceil(1000 * pow(2, -log10(log10(state->fuzzs + 1) * state->selected_times + 1)) * pow(2, log(state->paths_discovered + 1)));
          break;
        //other cases are reserved
      }
      if (i == 0) {
        state_scores[i] = state->score;
      } else {
        state_scores[i] = state_scores[i-1] + state->score;
      }
    }
  }
  u32 randV = UR(state_scores[state_ids_count - 1]);
  u32 idx = index_search(state_scores, state_ids_count, randV);
  result = state_ids[idx];
  if (state_scores) ck_free(state_scores);
  return result;
}
然后再来看看choose_seed,由于代码较长这里分开叙述。
首先通过传入的状态id将之前选出的状态找到:
khint_t k;
  state_info_t *state;
  struct queue_entry *result = NULL;
  k = kh_get(hms, khms_states, target_state_id);
  if (k != kh_end(khms_states)) {
    state = kh_val(khms_states, k);
    if (state->seeds_count == 0) return NULL;
随机模式与轮询模式
随机模式同样以种子数量为范围获取一个随机数然后赋给result,轮询模式也和之前状态选择策略差不多。
case RANDOM_SELECTION: //Random seed selection
        state->selected_seed_index = UR(state->seeds_count);
        result = state->seeds[state->selected_seed_index];
        break;
      case ROUND_ROBIN: //Round-robin seed selection
        result = state->seeds[state->selected_seed_index];
        state->selected_seed_index++;
        if (state->selected_seed_index == state->seeds_count) state->selected_seed_index = 0;
        break;
偏好模式:
如果种子数量小于10则按照轮询模式的逻辑来,如果大于是则进入以下逻辑。
以下逻辑总体来讲就是为不同条件的种子设置了不同的概率去选择。
if (state->seeds_count > 10) {
          //Do seed selection similar to AFL + take into account state-aware information
          //e.g., was_fuzzed information becomes state-aware
          u32 passed_cycles = 0;
          while (passed_cycles < 5) {
            result = state->seeds[state->selected_seed_index];
            if (state->selected_seed_index + 1 == state->seeds_count) {
              state->selected_seed_index = 0;
              passed_cycles++;
            } else state->selected_seed_index++;
            //Skip this seed with high probability if it is neither an initial seed nor a seed generated while the
            //current target_state_id was targeted
            if (result->generating_state_id != target_state_id && !result->is_initial_seed && UR(100) < 90) continue;
            u32 target_state_index = get_state_index(target_state_id);
            if (pending_favored) {
              /* If we have any favored, non-fuzzed new arrivals in the queue,
                 possibly skip to them at the expense of already-fuzzed or non-favored
                 cases. */
              if (((was_fuzzed_map[target_state_index][result->index] == 1) || !result->favored) && UR(100) < SKIP_TO_NEW_PROB) continue;
              /* Otherwise, this seed is selected */
              break;
            } else if (!result->favored && queued_paths > 10) {
              /* Otherwise, still possibly skip non-favored cases, albeit less often.
                 The odds of skipping stuff are higher for already-fuzzed inputs and
                 lower for never-fuzzed entries. */
              if (queue_cycle > 1 && (was_fuzzed_map[target_state_index][result->index] == 0)) {
                if (UR(100) < SKIP_NFAV_NEW_PROB) continue;
              } else {
                if (UR(100) < SKIP_NFAV_OLD_PROB) continue;
              }
              /* Otherwise, this seed is selected */
              break;
            }
          }
        }

看雪ID:Ayakaaa

https://bbs.kanxue.com/user-home-954038.htm

*本文为看雪论坛精华文章,由 Ayakaaa 原创,转载请注明来自看雪社区

# 往期推荐

1、2023 SDC 议题回顾 | 芯片安全和无线电安全底层渗透技术

2、SWPUCTF 2021 新生赛-老鼠走迷宫

3、OWASP 实战分析 level 1

4、【远控木马】银狐组织最新木马样本-分析

5、自研Unidbg trace工具实战ollvm反混淆

6、2023 SDC 议题回顾 | 深入 Android 可信应用漏洞挖掘

球分享

球点赞

球在看


文章来源: https://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458529508&idx=1&sn=d20f80d3e2aab017590328644114ab9e&chksm=b18d1e6e86fa97787e42f2d2de13117f521cdb9960a9a6ac4f0d134e76f038b386b7bf6cd790&scene=58&subscene=0#rd
如有侵权请联系:admin#unsafe.sh