AFL 源码阅读——part 2 afl-tmin、afl-showmap 和 afl-analyze

通过这一部分代码可以看到,共享内存里面存放的 bitmap 数据是怎么被用来统计覆盖率的。

这是一个用来精简输入样例的工具 (trimming)。

  • 为什么需要精简输入

大的输入执行起来速度比较慢,而且如果文件太大了,那么编译更可能发生在不重要的部分。

有两种模式来进行精简,Non-Crashing Input Mode 能够在路径完全相同的情况下尽量减少输入大小,而 Crashing Input Mode 可以精简能够制造 crash 的 poc 代码。

Crashing Input Mode Non-Crashing Input Mode
目的 找到可以稳定复现崩溃的文件,不在乎路径是否变化 找到 保持执行路径完全相同的更小文件
例子 将 10 MB 的崩溃 PoC 文件精简为 100 字节,仍能触发同一漏洞 将 1 MB 的 JPEG 测试用例精简为 50 KB,且仍覆盖相同的图像解析逻辑
是否需要插桩 不用插桩 需要

不仅仅是 afl-tmin,afl-fuzz 也内置了精简器:以一些固定长度尝试删除数据块,如果删除之后的路径 checksum 保持不变,就认为可以删减。

tmin 采用四阶段算法来删减数据,四个阶段删减数据的粒度从大到小,从删减粗粒度数据,到删减极细的字节大小的粒度,达到 trimming 的效果。

  1. 粗粒度归零

大刀阔斧简化数据,使用大块步长将数据归零,这是为了减少后续细粒度操作次数。

  1. 逐级细化删除

递减的块长和步长进行块删除,像「二分查找」一样,不断缩小切割范围,直到无法再切。

  1. 字母表归一化

统计唯一字符,尝试批量替换为 '0'(ASCII 字符 0x30),'0' 在文本格式中(如配置文件、日志)通常不会破坏结构

  1. 字节级归一化

对非零字节逐个替换为 0

除了输入输出文件的参数,还有一些跟模式相关的参数。这里就考虑最简单的仅仅指定了输入输出文件的 Non-Crashing Input Mode 模式就好。

main 函数去除参数解析和一些批注、log 可以留下一下代码。首先初始化了一下环境,然后跑了一遍(run target)来检测有没有崩溃或者超时,然后之后才进行 minimize

c

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

  use_argv = argv + optind;

  exact_mode = !!getenv("AFL_TMIN_EXACT");

  read_initial_file();

  run_target(use_argv, in_data, in_len, 1);

  if (child_timed_out)
    FATAL("Target binary times out (adjusting -t may help).");

  minimize(use_argv);

  unlink(prog_in);
  prog_in = NULL;

  close(write_to_file(out_file, in_data, in_len));

下面就主要看 run_targetminimize 两个部分

c

/* Execute target application. Returns 0 if the changes are a dud, or
   1 if they should be kept. */

主要结构如下:(来自 AFL源码阅读(三):afl-tmin blog 写的真的好好哇…

c

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

😏在子进程里面重定向了输入输出流,限制内存、fd 等资源使用。

🧐在父进程中检查了子进程的 shared map 变化,和原始的版本比较:

c

  cksum = hash32(trace_bits, MAP_SIZE, HASH_CONST);

  if (first_run) orig_cksum = cksum;

  if (orig_cksum == cksum) return 1;
  
  missed_paths++;
  return 0;

可以概括成下面这个代码框架,minimize 函数包含 4 个步骤,上文有解释过(四阶段算法),在每一个阶段都重复运行 run_target 函数来检查每一次删减有没有影响结果。

c

void minimize(char** argv) {
    // 阶段 1: 块归一化
    while (set_pos < in_len) {
        if (run_target(argv, tmp_buf, in_len, 0)) {
            // 替换块并记录更改
        }
        set_pos += set_len;
    }

    // 阶段 2: 块删除
    while (del_len > 0) {
        while (del_pos < in_len) {
            if (run_target(argv, tmp_buf, del_pos + tail_len, 0)) {
                // 删除块并更新输入
            } else {
                del_pos += del_len;
            }
        }
        del_len /= 2; // 减小块大小
    }

    // 阶段 3: 字符集最小化
    for (i = 0; i < 256; i++) {
        if (run_target(argv, tmp_buf, in_len, 0)) {
            // 替换字符并记录更改
        }
    }

    // 阶段 4: 字符最小化
    for (i = 0; i < in_len; i++) {
        if (run_target(argv, tmp_buf, in_len, 0)) {
            // 替换字符并记录更改
        }
    }
}

这一部分可以简化成:

c

set_len = next_p2(in_len / TMIN_SET_STEPS); // 计算初始块大小
if (set_len < TMIN_SET_MIN_SIZE) set_len = TMIN_SET_MIN_SIZE;

while (set_pos < in_len) {
    memcpy(tmp_buf, in_data, in_len);               // 复制原始数据
    memset(tmp_buf + set_pos, '0', use_len);        // 将当前块归一化为 '0'
    if (run_target(argv, tmp_buf, in_len, 0)) {     // 测试归一化后的输入
        memset(in_data + set_pos, '0', use_len);    // 如果有效,更新原始数据
        alpha_del0 += use_len;                      // 记录替换的字节数
    }
    set_pos += set_len;                             // 移动到下一个块
}

花了一张图来解释这一部分是通过,从后往前覆盖长度为 set_len 的块,看看改成 0 之后路径有没有区别。如果 run_target 结果为 1 则说明没有区别,那就把设置为 0 的区域扩大 set_len 长度。 ![[others/fuzz/Untitled Diagram 1.svg]]

而 set_len 是怎么计算出来的呢?涉及如下的代码,理想的分割粒度是 TMIN_SET_STEPS,最小的块大小是 TMIN_SET_MIN_SIZE

c

#define TMIN_SET_MIN_SIZE   4
#define TMIN_SET_STEPS      128

  set_len    = next_p2(in_len / TMIN_SET_STEPS); // next power of 2
  if (set_len < TMIN_SET_MIN_SIZE) set_len = TMIN_SET_MIN_SIZE;

这个部分是从中间砍掉块,保留头和尾。多次使用不同的 block size 删减数据,每一次的 block size 是前一轮删减的 block size 的二分之一。最初的 del_len 通过最好切割成 16 块计算得到的。

![[others/fuzz/Untitled Diagram 2.svg]]

这一段的逻辑可以概括成:

c

del_len = next_p2(in_len / TRIM_START_STEPS); // 初始化块大小
while (del_len > 0) {
    del_pos = 0;
    while (del_pos < in_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;
        }

        // 复制头部和尾部,删除当前块
        memcpy(tmp_buf, in_data, del_pos);
        memcpy(tmp_buf + del_pos, in_data + del_pos + del_len, tail_len);

        if (run_target(argv, tmp_buf, del_pos + tail_len, 0)) {
            // 如果删除有效,更新输入数据
            memcpy(in_data, tmp_buf, del_pos + tail_len);
            in_len = del_pos + tail_len;
            prev_del = 1;
        } else {
            del_pos += del_len; // 否则跳到下一个块
        }
    }
    del_len /= 2; // 减小块大小
}

如果上一个块没有被删除,还判断了一下当前块和上一个块一不一样,直接认为当前的块如果执行也不会被删除。

这个部分遍历每一种符号出现的次数(一共是 256 种),从 ‘/x00’ 到 ‘/xff’ 遍历替换成 ‘0’。

c

// 初始化符号统计
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]]++;
}

// 遍历每个符号,尝试替换为 '0'
for (i = 0; i < 256; i++) {
    if (i == '0' || !alpha_map[i]) continue;

    memcpy(tmp_buf, in_data, in_len);
    for (r = 0; r < in_len; r++) {
        if (tmp_buf[r] == i) tmp_buf[r] = '0';
    }

    if (run_target(argv, tmp_buf, in_len, 0)) {
        memcpy(in_data, tmp_buf, in_len);
        syms_removed++;
        alpha_del1 += alpha_map[i];
        changed_any = 1;
    }
}

对每一个位置的 alphabet 都替换成 ‘0’ 试试。

c

for (i = 0; i < in_len; i++) {
    u8 orig = tmp_buf[i];
    if (orig == '0') continue; // 跳过已归一化的字符

    tmp_buf[i] = '0'; // 尝试将字符替换为 '0'

    if (run_target(argv, tmp_buf, in_len, 0)) {
        in_data[i] = '0'; // 替换成功,更新原始数据
        alpha_del2++;
        changed_any = 1;
    } else {
        tmp_buf[i] = orig; // 替换失败,恢复原始字符
    }
}

showmap

showmap 的结果类似这种格式:

bash

>./afl-showmap -o trace -- ./try < in
	afl-showmap 2.57b by <lcamtuf@google.com>
	[*] Executing './try'...
	-- Program output begins --
	-- Program output ends --
	[+] Captured 5 tuples in 'trace'.
> cat trace
	011842:1
	017641:1
	041039:4
	050976:1
	057857:1

结果 tuples 是边和数量级 的组合。afl-as 插入的每条桩代码都有一个 id,它在编译器随机生成。而「前一个 id」与「后一个 id」的组合,即可代表一条。走过某一条边的次数会被映射成 1-8 的整数来展示,个位数更方便阅读。映射关系如下:

c

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

};

主要的逻辑做了这些:

c

int main(int argc, char** argv) {
	// 省略一些初始化操作
	
    run_target(use_argv);                 // 执行目标程序
    u32 tcnt = write_results();           // 写入结果到输出文件
    
    exit(child_crashed * 2 + child_timed_out); // 根据程序状态退出
}

run_target 函数已经比较熟悉了,write_results 就是去遍历一遍 MAP_SIZE 展示结果。

analyze 这个工具用于推测输入的文件格式,可以区分 lenth、checksum、magic number 等字段,一个理想的输出是这个样子的:

![[Pasted image 20250414105618.png]]

在 analyze 工具种,会依次改变各个字节,依据改变导致的程序行为不同,来给各个字节打上标记。

c

/* Constants used for describing byte behavior. */

#define RESP_NONE       0x00          // 对路径没有影响
#define RESP_MINOR      0x01          // 部分改变对路径有影响
#define RESP_VARIABLE   0x02          // 改变对路径有不同影响
#define RESP_FIXED      0x03          // 改变一定会带来固定的影响

#define RESP_LEN        0x04          // 可能是长度
#define RESP_CKSUM      0x05          // 可能是 checksum
#define RESP_SUSPECT    0x06          /* Potential "suspect" blob          */

接下来去函数中看看 analyze 是怎么给各个字节分类的。关键的逻辑在 analyze 和 dump_hex 函数中。

首先进行 analyze 函数,只会区分一下四种类型的字节

c

#define RESP_NONE       0x00          // 对路径没有影响
#define RESP_MINOR      0x01          // 部分改变对路径有影响
#define RESP_VARIABLE   0x02          // 改变对路径有不同影响
#define RESP_FIXED      0x03          // 改变一定会带来固定的影响

对于每一个字节,按照 4 种方式改变当前的字节,用 run_target 函数计算出来 checksum。

c

    in_data[i] ^= 0xff;
    xor_ff = run_target(argv, in_data, in_len, 0);

    in_data[i] ^= 0xfe;
    xor_01 = run_target(argv, in_data, in_len, 0);

    in_data[i] = (in_data[i] ^ 0x01) - 0x10;
    sub_10 = run_target(argv, in_data, in_len, 0);

    in_data[i] += 0x20;
    add_10 = run_target(argv, in_data, in_len, 0);
    in_data[i] -= 0x10;

根据四种变化的 checksum 结果,打上标记

c

    if (xff_orig && x01_orig && s10_orig && a10_orig) {

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

      b_data[i] = RESP_FIXED;

    } else b_data[i] = RESP_VARIABLE;

如果四种变化的 checksum 都不同,需要反转一下 seq_byte,这是为了 checksum 做准备的。

text

    /* When all checksums change, flip most significant bit of 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;

粗略分析出来之后,可以进一步处理结果,然后打印了。dump_hex 函数细致划分了 RESP_FIXED 的类型,可以推测是 RESP_LENRESP_CKSUM 或是 RESP_SUSPECT

首先是分组,会向前查找 2 个字节或者 4 字节一组,然后划分。例如,如果找到连续 2 个字节都是 RESP_FIXED

c

        case 2: {

            u16 val = *(u16*)(in_data + i);

            /* Small integers may be length fields. */
			// 小的整数可能是 len
            if (val && (val <= in_len || SWAP16(val) <= in_len)) {
              rtype = RESP_LEN;
              break;
            }

            /* Uniform integers may be checksums. */
			// 当前字节和下一个字节之间的差值大于 32 可能是 checksum
            if (val && abs(in_data[i] - in_data[i + 1]) > 32) {
              rtype = RESP_CKSUM;
              break;
            }

            break;

          }
    // 其余都是 RESP_SUSPECT

四字节一组的情况也差不多。可以看到,checksum 很可能判断不出来,或者其他字段被误以为是 checksum。

不过,afl-analyze 以很小的运行代价,就能提示输入文件中的很多细节。它给出的「不重要」和「敏感」标签是尤其可信的,至于其他标签,也能很好地节省人工分析的时间。