AFL 源码阅读——part 2 afl-tmin、afl-showmap 和 afl-analyze
tmin
通过这一部分代码可以看到,共享内存里面存放的 bitmap 数据是怎么被用来统计覆盖率的。
afl-tmin 的背景
这是一个用来精简输入样例的工具 (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 的效果。
- 粗粒度归零
大刀阔斧简化数据,使用大块步长将数据归零,这是为了减少后续细粒度操作次数。
- 逐级细化删除
以递减的块长和步长进行块删除,像「二分查找」一样,不断缩小切割范围,直到无法再切。
- 字母表归一化
统计唯一字符,尝试批量替换为 '0'
(ASCII 字符 0x30),'0'
在文本格式中(如配置文件、日志)通常不会破坏结构
- 字节级归一化
对非零字节逐个替换为 0
。
使用参数
除了输入输出文件的参数,还有一些跟模式相关的参数。这里就考虑最简单的仅仅指定了输入输出文件的 Non-Crashing Input Mode 模式就好。
tmin 流程
main 函数去除参数解析和一些批注、log 可以留下一下代码。首先初始化了一下环境,然后跑了一遍(run target)来检测有没有崩溃或者超时,然后之后才进行 minimize
。
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_target
和 minimize
两个部分
run_target
函数
/* Execute target application. Returns 0 if the changes are a dud, or
1 if they should be kept. */
主要结构如下:(来自 AFL源码阅读(三):afl-tmin blog 写的真的好好哇…
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 变化,和原始的版本比较:
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
函数
可以概括成下面这个代码框架,minimize 函数包含 4 个步骤,上文有解释过(四阶段算法),在每一个阶段都重复运行 run_target 函数来检查每一次删减有没有影响结果。
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)) {
// 替换字符并记录更改
}
}
}
block normalization
这一部分可以简化成:
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
。
#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 deletion
这个部分是从中间砍掉块,保留头和尾。多次使用不同的 block size 删减数据,每一次的 block size 是前一轮删减的 block size 的二分之一。最初的 del_len 通过最好切割成 16 块计算得到的。
![[others/fuzz/Untitled Diagram 2.svg]]
这一段的逻辑可以概括成:
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; // 减小块大小
}
如果上一个块没有被删除,还判断了一下当前块和上一个块一不一样,直接认为当前的块如果执行也不会被删除。
alphabet
这个部分遍历每一种符号出现的次数(一共是 256 种),从 ‘/x00’ 到 ‘/xff’ 遍历替换成 ‘0’。
// 初始化符号统计
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 minimization
对每一个位置的 alphabet 都替换成 ‘0’ 试试。
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
showmap 的结果类似这种格式:
>./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 的整数来展示,个位数更方便阅读。映射关系如下:
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
};
主要的逻辑做了这些:
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
analyze 这个工具用于推测输入的文件格式,可以区分 lenth、checksum、magic number 等字段,一个理想的输出是这个样子的:
![[Pasted image 20250414105618.png]]
在 analyze 工具种,会依次改变各个字节,依据改变导致的程序行为不同,来给各个字节打上标记。
/* 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
首先进行 analyze 函数,只会区分一下四种类型的字节
#define RESP_NONE 0x00 // 对路径没有影响
#define RESP_MINOR 0x01 // 部分改变对路径有影响
#define RESP_VARIABLE 0x02 // 改变对路径有不同影响
#define RESP_FIXED 0x03 // 改变一定会带来固定的影响
对于每一个字节,按照 4 种方式改变当前的字节,用 run_target 函数计算出来 checksum。
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 结果,打上标记
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 做准备的。
/* 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
粗略分析出来之后,可以进一步处理结果,然后打印了。dump_hex
函数细致划分了 RESP_FIXED
的类型,可以推测是 RESP_LEN
、RESP_CKSUM
或是 RESP_SUSPECT
。
首先是分组,会向前查找 2 个字节或者 4 字节一组,然后划分。例如,如果找到连续 2 个字节都是 RESP_FIXED
。
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 以很小的运行代价,就能提示输入文件中的很多细节。它给出的「不重要」和「敏感」标签是尤其可信的,至于其他标签,也能很好地节省人工分析的时间。