fuzz入门到入土(一)

由于一直对fuzz有一定的兴趣购买了hollk师傅的文章来学习fuzz

专题一:源码阅读

阅读大型C/C++程序源码相关准备——明确目的

afl-gcc.c

借助gemin2.5pro给出的回答,仅供参考

带着以下几个具体问题去阅读:

  • main 函数是如何解析我们输入的命令行参数的?
  • 代码是如何判断应该在背后调用 gcc 还是 clang 的?
  • 关键函数 find_as 是如何找到 afl-as 这个程序的具体路径的?
  • **(核心中的核心)**edit_params 函数到底对我们输入的参数做了哪些修改?它是如何通过添加 -B 参数来告诉 gcc 去指定的目录寻找 afl-as 的?
  • 除了替换汇编器,它还添加了哪些额外的参数?(提示:寻找与 afl-rt.o 相关的代码,这是AFL的运行时库)。

在阅读工程中需要注意哪些重点

  • main 函数的整体逻辑:不要一开始就陷入细节,先看 main 函数的骨架,理解“找到 afl-as -> 修改参数 -> 执行真实编译器”这个大流程。
  • find_as() 函数:注意它对环境变量 AFL_PATH 的处理。这有助于你理解 AFL 的安装和环境配置。
  • edit_params() 函数:这是最重要的部分。请重点关注代码中为 cc_params 数组添加新成员的部分。特别是对 -B 参数的处理,你需要去理解 gcc 的 -B 选项的含义:“向 GCC 提供一个搜索路径,用于查找编译器执行程序、库和数据文件”。AFL 正是利用这一点,让 gcc 优先在 afl-as 所在的目录找到了汇编器。
  • execvp() 函数:这是代码的最后一步,它用修改后的参数启动了真正的编译器。理解了它,整个 afl-gcc.c 的生命周期就闭环了

main()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/* Main entry point */

int main(int argc, char** argv) {

if (isatty(2) && !getenv("AFL_QUIET")) {
SAYF(cCYA "afl-cc " cBRI VERSION cRST " by <lcamtuf@google.com>\n");

} else be_quiet = 1;

if (argc < 2) {

SAYF("\n"
"This is a helper application for afl-fuzz. It serves as a drop-in replacement\n"
"for gcc or clang, letting you recompile third-party code with the required\n"
"runtime instrumentation. A common use pattern would be one of the following:\n\n"

" CC=%s/afl-gcc ./configure\n"
" CXX=%s/afl-g++ ./configure\n\n"

"You can specify custom next-stage toolchain via AFL_CC, AFL_CXX, and AFL_AS.\n"
"Setting AFL_HARDEN enables hardening optimizations in the compiled code.\n\n",
BIN_PATH, BIN_PATH);

exit(1);

}

find_as(argv[0]); //argv[0]一定是程序的名称,并且包含了程序所在的完整路径

edit_params(argc, argv);/*argc 是什么: argument count(参数计数),一个整数,表示命令行参数的总个数(包括程序名本身)。
argv 是什么: argument vector(参数向量),一个字符串数组,包含了所有的命令行参数。
这是 afl-gcc 的核心功能。它会遍历原始的 argv 数组,然后创建一个新的参数数组,
这个新的数组就是全局变量 cc_params。
所以这个cc_params需要重点关注*/

execvp(cc_params[0], (char**)cc_params);
//终止 afl-gcc 进程,并将其完全替换为一个新进程(即真正的 gcc 或 clang),同时让这个新进程带着经过特殊构造的参数列表来运行。
FATAL("Oops, failed to execute '%s' - check your PATH", cc_params[0]);

return 0;

}

总结一下只有三个重要的函数还有一个是系统函数

find_as(argv[0])函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/* Try to find our "fake" GNU assembler in AFL_PATH or at the location derived
from argv[0]. If that fails, abort. */

static void find_as(u8* argv0) {

u8 *afl_path = getenv("AFL_PATH");//假设为 ./afl
u8 *slash, *tmp;

if (afl_path) {

tmp = alloc_printf("%s/as", afl_path);// 功能如其名,返回值是堆块指针

if (!access(tmp, X_OK)) {//检查当前程序是否有权限访问指定的文件或目录。X_OK执行权限。返回值:有:0;无:1
as_path = afl_path;
ck_free(tmp);
return; // ---> 第一个返回点,如果./afl/as存在
}

ck_free(tmp);

}// 获取AFL_PATH路径。检查路径是否可以访问

slash = strrchr(argv0, '/');// 从右往左查找第一个'/'--> 结果/afl

if (slash) {

u8 *dir;

*slash = 0;// 将'/'替换为字符串结束符,截断字符串
dir = ck_strdup(argv0);// 复制截断后的字符串
*slash = '/';// 恢复原始字符串

tmp = alloc_printf("%s/afl-as", dir);// 拼接路径--> afl/afl-as

if (!access(tmp, X_OK)) {// 检查路径是否可以访问
as_path = dir;
ck_free(tmp);
return; // ---> 第二个返回点,如果./afl/afl-as存在
}

ck_free(tmp);
ck_free(dir);

}

if (!access(AFL_PATH "/as", X_OK)) {// 检查默认路径是否可以访问
as_path = AFL_PATH;
return; //第三个返回点,(默认)标准安装路径 /usr/local/lib/afl/as
}
// 如果都不行,报错退出
FATAL("Unable to find AFL wrapper binary for 'as'. Please set AFL_PATH");

}//走完这个函数最终更新了 as_path 中的内容

edit_params()函数

函数功能就是将执行的命令,如:afl-gcc -O3 -o my_app my_app.c

最终得到的cc_params[]数组如:["gcc", "-O3", "-o", "my_app", "my_app.c", "-B", as_path, "-g", "-O3", "-funroll-loops", "-D__AFL_COMPILER=1", "-DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION=1", "NULL"]

image-20250918170111499

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
/* Copy argv to cc_params, making the necessary edits. */

static void edit_params(u32 argc, char** argv) {

u8 fortify_set = 0, asan_set = 0; // 设置cc参数
u8 *name; //指针

#if defined(__FreeBSD__) && defined(__x86_64__)
u8 m32_set = 0;
#endif

cc_params = ck_alloc((argc + 128) * sizeof(u8*)); //为cc_params分配内存
/*假设你在一台64位的电脑上执行命令:afl-gcc -O3 -o my_app my_app.c
计算 argc:
这个命令共有5个部分 (afl-gcc, -O3, -o, my_app, my_app.c),所以 argc = 5。
计算argc+128总数:
argc + 128 = 5 + 128 = 133。
我们需要一个能容纳 133 个指针的数组。
计算每个“格子”的大小:
因为是64位系统,sizeof(u8*) = 8 字节。
计算总内存大小:
总字节数 = 133 * 8 = 1064 字节*/

name = strrchr(argv[0], '/'); // 从右往左查找第一个'/'(后的编译器名称),使name指向'/'字符,如./afl-clang++ -o test test.cpp则argv[0]是./afl-clang
if (!name) name = argv[0]; else name++;// 如果没有'/',直接使用argv[0].否则让name指向'/'后的第一个字符

if (!strncmp(name, "afl-clang", 9)) {// 如果是afl-clang则使用clang编译器

clang_mode = 1;// 设置clang_mode为1

setenv(CLANG_ENV_VAR, "1", 1);

if (!strcmp(name, "afl-clang++")) {// 如果是afl-clang++,则使用clang++编译器
u8* alt_cxx = getenv("AFL_CXX");// 获取环境变量AFL_CXX
cc_params[0] = alt_cxx ? alt_cxx : (u8*)"clang++";// 如果AFL_CXX存在则使用AFL_CXX,否则使用clang++
} else {
u8* alt_cc = getenv("AFL_CC");// 获取环境变量AFL_CC
cc_params[0] = alt_cc ? alt_cc : (u8*)"clang";// 如果AFL_CC存在则使用AFL_CC,否则使用clang
}// cc_params[]是保存编译参数的数组

} else {

/* With GCJ and Eclipse installed, you can actually compile Java! The
instrumentation will work (amazingly). Alas, unhandled exceptions do
not call abort(), so afl-fuzz would need to be modified to equate
non-zero exit codes with crash conditions when working with Java
binaries. Meh. */

#ifdef __APPLE__//如果不是以afl_clang开头,而是在苹果系统上,就会进入这个分支

if (!strcmp(name, "afl-g++")) cc_params[0] = getenv("AFL_CXX");
else if (!strcmp(name, "afl-gcj")) cc_params[0] = getenv("AFL_GCJ");
else cc_params[0] = getenv("AFL_CC");

if (!cc_params[0]) {

SAYF("\n" cLRD "[-] " cRST
"On Apple systems, 'gcc' is usually just a wrapper for clang. Please use the\n"
" 'afl-clang' utility instead of 'afl-gcc'. If you really have GCC installed,\n"
" set AFL_CC or AFL_CXX to specify the correct path to that compiler.\n");

FATAL("AFL_CC or AFL_CXX required on MacOS X");

}

#else// 不是apple系统

if (!strcmp(name, "afl-g++")) {// 如果是afl-g++
u8* alt_cxx = getenv("AFL_CXX");// 获取环境变量AFL_CXX
cc_params[0] = alt_cxx ? alt_cxx : (u8*)"g++";// 如果AFL_CXX存在则直接将环境变量赋值给cc_params[0],否则使用g++
} else if (!strcmp(name, "afl-gcj")) {// 如果是afl-gcj
u8* alt_cc = getenv("AFL_GCJ");// 获取环境变量AFL_GCJ
cc_params[0] = alt_cc ? alt_cc : (u8*)"gcj";// 如果AFL_GCJ存在则直接将环境变量赋值给cc_params[0],否则使用gcj
} else {// 其他情况
u8* alt_cc = getenv("AFL_CC");// 获取环境变量AFL_CC
cc_params[0] = alt_cc ? alt_cc : (u8*)"gcc";// 如果AFL_CC存在则直接将环境变量赋值给cc_params[0],否则使用gcc
}

#endif /* __APPLE__ */

}

while (--argc) {//循环遍历参数“清洗”和“分析”用户输入的编译参数,参考:共有5个部分 (afl-gcc, -O3, -o, my_app, my_app.c),所以 argc = 5
u8* cur = *(++argv);//获取参数

if (!strncmp(cur, "-B", 2)) {//如果当前参数为 -B

if (!be_quiet) WARNF("-B is already set, overriding");//判断静默模式是否关闭,如果关闭提示 -B 参数已经被设置

if (!cur[2] && argc > 1) { argc--; argv++; }// ... 跳过 -B 和它可能附带的路径 ...
continue;

}

if (!strcmp(cur, "-integrated-as")) continue;// 当前参数为 -integrated-as,则跳过本次循环

if (!strcmp(cur, "-pipe")) continue;// 当前参数为 -pipe,则跳过本次循环

#if defined(__FreeBSD__) && defined(__x86_64__)//判断是否是FreeBSD系统或者x86_64系统上
if (!strcmp(cur, "-m32")) m32_set = 1;// 如果当前参数为 -m32,则设置m32_set为1
#endif

if (!strcmp(cur, "-fsanitize=address") ||
!strcmp(cur, "-fsanitize=memory")) asan_set = 1;

if (strstr(cur, "FORTIFY_SOURCE")) fortify_set = 1;

cc_params[cc_par_cnt++] = cur;//给cc_params赋值,cc_par_cnt全局变量初始值为1

}

cc_params[cc_par_cnt++] = "-B";
cc_params[cc_par_cnt++] = as_path;//取出find_as函数中找到的as_path路径,组成 -B as_path

if (clang_mode)// 如果clang模式是1
cc_params[cc_par_cnt++] = "-no-integrated-as";//赋值cc_params追加参数 -no-integrated-as

if (getenv("AFL_HARDEN")) {// 获取环境变量AFL_HARDEN,存在进入分支

cc_params[cc_par_cnt++] = "-fstack-protector-all";

if (!fortify_set)//检查是否已经设置了fortify参数,如果没有,进入分支
cc_params[cc_par_cnt++] = "-D_FORTIFY_SOURCE=2";//cc_params追加参数 -D_FORTIFY_SOURCE=2

}

if (asan_set) {//判断是否检查内存,如果已经设置为1(第一次修改line-208)

/* Pass this on to afl-as to adjust map density. */

setenv("AFL_USE_ASAN", "1", 1);// 设置环境变量AFL_USE_ASAN为1

} else if (getenv("AFL_USE_ASAN")) {// 获取环境变量AFL_USE_ASAN(已经被设置为1),存在进入分支

if (getenv("AFL_USE_MSAN"))// 获取环境变量AFL_USE_MSAN,存在报错退出
FATAL("ASAN and MSAN are mutually exclusive");//提示ASAN和MSAN互斥

if (getenv("AFL_HARDEN"))// 获取环境变量AFL_HARDEN,存在则进入分支
FATAL("ASAN and AFL_HARDEN are mutually exclusive");//提示ASAN和AFL_HARDEN互斥

cc_params[cc_par_cnt++] = "-U_FORTIFY_SOURCE";
cc_params[cc_par_cnt++] = "-fsanitize=address";//如果上述两个环境变量都没有设置,则cc_params追加参数 -fsanitize=address

} else if (getenv("AFL_USE_MSAN")) {// 获取环境变量AFL_USE_MSAN,存在进入分支

if (getenv("AFL_USE_ASAN"))// 获取环境变量AFL_USE_ASAN,存在报错退出
FATAL("ASAN and MSAN are mutually exclusive");//提示ASAN和MSAN互斥

if (getenv("AFL_HARDEN"))// 获取环境变量AFL_HARDEN,存在则进入分支
FATAL("MSAN and AFL_HARDEN are mutually exclusive");//提示MSAN和AFL_HARDEN互斥

cc_params[cc_par_cnt++] = "-U_FORTIFY_SOURCE";
cc_params[cc_par_cnt++] = "-fsanitize=memory";//如果上述两个环境变量都没有设置,则cc_params追加参数 -fsanitize=memory


}

if (!getenv("AFL_DONT_OPTIMIZE")) {// 获取环境变量AFL_DONT_OPTIMIZE,失败则进入分支

#if defined(__FreeBSD__) && defined(__x86_64__)//如果是FreeBSD系统或者x86_64系统,进入分支

/* On 64-bit FreeBSD systems, clang -g -m32 is broken, but -m32 itself
works OK. This has nothing to do with us, but let's avoid triggering
that bug. */

if (!clang_mode || !m32_set)// 如果没有设置clang模式或者没有设置-m32参数,进入分支
cc_params[cc_par_cnt++] = "-g";

#else// 不是FreeBSD系统或者x86_64系统

cc_params[cc_par_cnt++] = "-g";// cc_params追加参数 -g

#endif

cc_params[cc_par_cnt++] = "-O3";// cc_params追加参数 -O3
cc_params[cc_par_cnt++] = "-funroll-loops";

/* Two indicators that you're building for fuzzing; one of them is
AFL-specific, the other is shared with libfuzzer. */

cc_params[cc_par_cnt++] = "-D__AFL_COMPILER=1";
cc_params[cc_par_cnt++] = "-DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION=1";// cc_params追加参数 -D__AFL_COMPILER=1和 -DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION=1

}

if (getenv("AFL_NO_BUILTIN")) {//如果设置了AFL_NO_BUILTIN环境变量,进入分支

cc_params[cc_par_cnt++] = "-fno-builtin-strcmp";
cc_params[cc_par_cnt++] = "-fno-builtin-strncmp";
cc_params[cc_par_cnt++] = "-fno-builtin-strcasecmp";
cc_params[cc_par_cnt++] = "-fno-builtin-strncasecmp";
cc_params[cc_par_cnt++] = "-fno-builtin-memcmp";
cc_params[cc_par_cnt++] = "-fno-builtin-strstr";
cc_params[cc_par_cnt++] = "-fno-builtin-strcasestr";// cc_params追加参数 -fno-builtin-*

}

cc_params[cc_par_cnt] = NULL;// cc_params最后一个参数设置为NULL,表示数组结束

}

afl-as.c

afl-as.c 是 AFL 实现插桩的核心部分,代码相对复杂一些。它的目标是在汇编代码中插入“探针”,也就是桩代码,用于追踪程序的执行路径。

首先,要记住 afl-as 的根本任务是什么。它是一个“中间人”,站在编译器 (gcc) 和真正的汇编器 (as) 之间,它的使命是:
在不破坏原有汇编代码逻辑的前提下,悄悄地在代码中植入用于路径覆盖率跟踪的“探针”(桩代码)。

阅读 afl-as.c 的核心目的:搞清楚 AFL 是如何识别插桩点,以及它到底插入了什么样的代码来实现路径跟踪的。

带着以下几个具体问题去阅读:

  • main 函数是如何处理输入和输出文件的?它如何调用真正的 as?
  • **(核心中的核心)**add_instrumentation() 函数是如何工作的?
  • 它是如何一行行读取汇编代码的?
  • 它是通过哪些特征(比如标签 L…:、函数开头 .LFB…)来判断“这是一个基本块的开始,我应该在这里插桩”的?
  • 插桩时,cur_location = R(MAP_SIZE) 这行代码的作用是什么?(提示:它为每个桩点分配一个随机ID)。这个ID和AFL的共享内存 (shared_mem) 有什么关系?
  • AFL 到底插入了什么汇编代码?这些代码定义在哪里?(提示:寻找 #include “afl-as.h”,然后去阅读 afl-as.h 文件中的 TRAMPOLINE 宏定义)。
  • 插入的桩代码(trampoline)是如何调用 __afl_maybe_log 这个C函数的?这个函数的作用是什么?

我在阅读工程中需要注意哪些重点:

  • main 函数中的 fork() / execvp() 模式:理解 afl-as 如何通过创建子进程来调用真正的 as,而父进程则负责处理汇编代码的修改。
  • add_instrumentation() 函数:这是你 90% 的精力应该投入的地方。
  • 状态机思想: 注意代码中 instr_ok、skip_instr 这些标志位,它们构成了一个简单的状态机,用于判断当前行是否适合插桩。
  • 识别逻辑: 重点关注对 line 缓冲区的字符串比较,例如 strncmp(line, “.L”, 2)、strstr(line, “call”) 等。这些是识别插桩点的关键。
  • 桩代码模板: 必须打开 afl-as.h 文件,对照 fprintf(outf, …) 和 TRAMPOLINE 宏来看。你需要理解桩代码的通用流程:“保存寄存器 -> 计算共享内存地址 -> 更新计数值 -> 恢复寄存器”。
  • __afl_maybe_log 函数:虽然这个函数的实现在 afl-rt.c 中,但你要在 afl-as.c 的阅读中建立起一个概念:桩代码最终会调用这个函数,这个函数是插桩代码与 afl-fuzz 主进程进行通信的桥梁。

main()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/* Main entry point */

int main(int argc, char** argv) {

s32 pid;
u32 rand_seed;
int status;
u8* inst_ratio_str = getenv("AFL_INST_RATIO");// 该环境变量主要检测每个分支的概率,取值为0到100%,设置为0时只检测函数入口的跳转,而不会检测函数分支的跳转

struct timeval tv;
struct timezone tz;

clang_mode = !!getenv(CLANG_ENV_VAR);

if (isatty(2) && !getenv("AFL_QUIET")) {

SAYF(cCYA "afl-as " cBRI VERSION cRST " by <lcamtuf@google.com>\n");

} else be_quiet = 1;

if (argc < 2) {

SAYF("\n"
"This is a helper application for afl-fuzz. It is a wrapper around GNU 'as',\n"
"executed by the toolchain whenever using afl-gcc or afl-clang. You probably\n"
"don't want to run this program directly.\n\n"

"Rarely, when dealing with extremely complex projects, it may be advisable to\n"
"set AFL_INST_RATIO to a value less than 100 in order to reduce the odds of\n"
"instrumenting every discovered branch.\n\n");

exit(1);

}

gettimeofday(&tv, &tz);//获取当前精确时间

rand_seed = tv.tv_sec ^ tv.tv_usec ^ getpid();//通过当前时间与进程pid进行异或处理

srandom(rand_seed);//获得随机化种子

edit_params(argc, argv);//检查并修改参数传递给as。文件名是以gcc传递的最后一个参数为准,此函数主要设置变量as_params的值,以及use_64bit/modified_file的值

if (inst_ratio_str) {//如果环境变量AFL_INST_RATIO存在则进入该分支

if (sscanf(inst_ratio_str, "%u", &inst_ratio) != 1 || inst_ratio > 100) //如果没有将覆盖率写入inst_ratio变量或者inst_ratio中的值超过100的话,则进入分支抛出异常
FATAL("Bad value of AFL_INST_RATIO (must be between 0 and 100)");

}

if (getenv(AS_LOOP_ENV_VAR))//如果环境变量AS_LOOP_ENV_VAR存在则进入该分支
FATAL("Endless loop when calling 'as' (remove '.' from your PATH)");//抛出异常

setenv(AS_LOOP_ENV_VAR, "1", 1);//设置环境变量AS_LOOP_ENV_VAR的值为1

/* When compiling with ASAN, we don't have a particularly elegant way to skip
ASAN-specific branches. But we can probabilistically compensate for
that... */

if (getenv("AFL_USE_ASAN") || getenv("AFL_USE_MSAN")) {//如果环境变量AFL_USE_ASAN或者AFL_USE_MSAN存在则进入该分支
sanitizer = 1;//设置变量sanitizer的值为1
inst_ratio /= 3;//将inst_ratio的值除以3
}

if (!just_version) add_instrumentation();//如果不是只查询version,那么就会进入add_instrumentation函数,该函数主要处理输入文件,生成modified_file,将桩插入适当的位置

if (!(pid = fork())) {//调用fork()创建子进程。在执行execvp()函数执行时

execvp(as_params[0], (char**)as_params);//执行命令和参数
FATAL("Oops, failed to execute '%s' - check your PATH", as_params[0]);//不成功抛出异常

}

if (pid < 0) PFATAL("fork() failed");//如果创建子进程失败,抛出异常

if (waitpid(pid, &status, 0) <= 0) PFATAL("waitpid() failed");//等待子进程结束

if (!getenv("AFL_KEEP_ASSEMBLY")) unlink(modified_file);//读取环境变量AFL_KEEP_ASSEMBLY失败,则unlink掉modified_file文件
//设置该环境变量主要是为了防止afl-as删掉插桩后的汇编文件,设置为1插桩文件
exit(WEXITSTATUS(status));

}

edit_params()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
/* Examine and modify parameters to pass to 'as'. Note that the file name
is always the last parameter passed by GCC, so we exploit this property
to keep the code simple. */

static void edit_params(int argc, char** argv) {//假设参数[as, --64, -o, test.o, /tmp/test.s]

u8 *tmp_dir = getenv("TMPDIR"), *afl_as = getenv("AFL_AS");//获取环境变量TMPDIR和AFL_AS的值
u32 i;

#ifdef __APPLE__//如果是苹果系统

u8 use_clang_as = 0;

/* On MacOS X, the Xcode cctool 'as' driver is a bit stale and does not work
with the code generated by newer versions of clang that are hand-built
by the user. See the thread here: http://goo.gl/HBWDtn.

To work around this, when using clang and running without AFL_AS
specified, we will actually call 'clang -c' instead of 'as -q' to
compile the assembly file.

The tools aren't cmdline-compatible, but at least for now, we can
seemingly get away with this by making only very minor tweaks. Thanks
to Nico Weber for the idea. */

if (clang_mode && !afl_as) {//如果是clang模式并且环境变量AFL_AS不存在 则进入该分支

use_clang_as = 1;//设置变量use_clang_as的值为1

afl_as = getenv("AFL_CC");
if (!afl_as) afl_as = getenv("AFL_CXX");
if (!afl_as) afl_as = "clang";//将afl_as赋值为AFL_CC、AFL_CXX或者clang中的一种

}

#endif /* __APPLE__ */

/* Although this is not documented, GCC also uses TEMP and TMP when TMPDIR
is not set. We need to check these non-standard variables to properly
handle the pass_thru logic later on. */

if (!tmp_dir) tmp_dir = getenv("TEMP");
if (!tmp_dir) tmp_dir = getenv("TMP");
if (!tmp_dir) tmp_dir = "/tmp";//为tmp_dir赋值为环境变量TMPDIR、TEMP、TMP或者/tmp中的一种

as_params = ck_alloc((argc + 32) * sizeof(u8*));//为as_params分配内存

as_params[0] = afl_as ? afl_as : (u8*)"as";//为as_params[0]赋值为环境变量AFL_AS的值或者as

as_params[argc] = 0;//设置最后一个参数为0

for (i = 1; i < argc - 1; i++) {//从第二个参数开始遍历,也就是--64,argc = 5

if (!strcmp(argv[i], "--64")) use_64bit = 1;//如果遍历到--64则将use_64bit设置为1
else if (!strcmp(argv[i], "--32")) use_64bit = 0;//如果遍历到--32则将use_64bit设置为0

#ifdef __APPLE__

/* The Apple case is a bit different... */

if (!strcmp(argv[i], "-arch") && i + 1 < argc) {//如果遍历到-arch并且i+1小于argc则进入该分支

if (!strcmp(argv[i + 1], "x86_64")) use_64bit = 1;//如果是-arch x86_64则将use_64bit设置为1
else if (!strcmp(argv[i + 1], "i386"))//如果是-arch i386则报错
FATAL("Sorry, 32-bit Apple platforms are not supported.");

}

/* Strip options that set the preference for a particular upstream
assembler in Xcode. */

if (clang_mode && (!strcmp(argv[i], "-q") || !strcmp(argv[i], "-Q")))
continue;//如果是clang模式并且遍历到-q或者-Q则跳过该参数

#endif /* __APPLE__ */

as_params[as_par_cnt++] = argv[i];//[as, --64, -o, test.o]

}

#ifdef __APPLE__

/* When calling clang as the upstream assembler, append -c -x assembler
and hope for the best. */

if (use_clang_as) {

as_params[as_par_cnt++] = "-c";
as_params[as_par_cnt++] = "-x";
as_params[as_par_cnt++] = "assembler";//如果用的clang_as则添加-c -x assembler参数

}

#endif /* __APPLE__ */

input_file = argv[argc - 1];//将最后一个参数赋值给input_file

if (input_file[0] == '-') {//如果input_file是以-开头的

if (!strcmp(input_file + 1, "-version")) {//如果是-versiom则进入该分支
just_version = 1;//设置just_version的值为1
modified_file = input_file;//将modified_file赋值为-version
goto wrap_things_up;//跳转到参数组合结尾
}

if (input_file[1]) FATAL("Incorrect use (not called through afl-gcc?)");//如果input_file不是-version则抛出异常
else input_file = NULL;

} else {//如果首字母不是-

/* Check if this looks like a standard invocation as a part of an attempt
to compile a program, rather than using gcc on an ad-hoc .s file in
a format we may not understand. This works around an issue compiling
NSS. */

if (strncmp(input_file, tmp_dir, strlen(tmp_dir)) &&
strncmp(input_file, "/var/tmp/", 9) &&
strncmp(input_file, "/tmp/", 5)) pass_thru = 1;
//则比对input_file的前strlen(tmp_dir)、9、5个字符是否分别和tmp_dir、/var/tmp/、/tmp/相同,如果都不相同则将pass_thru设置为1
}

modified_file = alloc_printf("%s/.afl-%u-%u.s", tmp_dir, getpid(),
(u32)time(NULL));//设modified_file为类似的tmp_dir/.afl-pid-time.s格式的字符串

wrap_things_up:

as_params[as_par_cnt++] = modified_file;//接收modified_file的最后一个参数,[as, --64, -o, test.o, /tmp/.afl-pid-time.s]
as_params[as_par_cnt] = NULL;//[as, --64, -o, test.o, /tmp/.afl-pid-time.s, NULL]

}

add_instrumentation()

这里根据gemini 2.5pro给出以下代码进行插桩,问题和插桩后的代码放在末尾了

源代码test.s :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
	.file	"test.c"
.intel_syntax noprefix
# -- some intel syntax code here --
# it should be skipped
.att_syntax
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
endbr64
mov DWORD PTR [rbp-4], edi
cmp DWORD PTR [rbp-4], 10
jg .L2
#APP
# This is user's inline assembly
# It should also be skipped
#NO_APP
mov eax, DWORD PTR [rbp-4]
add eax, 1
jmp .L3
.L2:
mov eax, DWORD PTR [rbp-4]
sub eax, 1
.L3:
nop
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.section .rodata
.LC0:
.string "Hello"
.ident "GCC: (Ubuntu 11.2.0-19ubuntu1) 11.2.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.word 0
.long 0
.long 0
4:

源代码注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
/* Process input file, generate modified_file. Insert instrumentation in all
the appropriate places. */

static void add_instrumentation(void) {//处理输入文件,生成modified_file,将桩插入所有适当的位置
/*-------------------初始化-------------------------*/
static u8 line[MAX_LINE];

FILE* inf;
FILE* outf;
s32 outfd;
u32 ins_lines = 0;

u8 instr_ok = 0, skip_csect = 0, skip_next_label = 0,
skip_intel = 0, skip_app = 0, instrument_next = 0;//定义一些flag变量(默认值)

#ifdef __APPLE__

u8* colon_pos;

#endif /* __APPLE__ */

if (input_file) {//如果存在输入的文件名称

inf = fopen(input_file, "r");//尝试打开input_file文件
if (!inf) PFATAL("Unable to read '%s'", input_file);//如果打开失败则抛出异常

} else inf = stdin;//如果input_file不存在则将inf设置为标准输入

outfd = open(modified_file, O_WRONLY | O_EXCL | O_CREAT, 0600);//以写的方式打开modified_file,如果文件已存在就直接打开,如果没有就创建一个

if (outfd < 0) PFATAL("Unable to write to '%s'", modified_file);//如果没有写的权限则抛出异常

outf = fdopen(outfd, "w");//尝试打开

if (!outf) PFATAL("fdopen() failed");//打不开抛出异常
/*------------------------插桩核心代码-----------------------------*/
while (fgets(line, MAX_LINE, inf)) {//循环读取inf指向的文件的每一行到line数组中,每行最多MAX_LINE(8192 bytes),这个值包括’\0’,所以实际读取的有内容的字节数是MAX_LINE-1个字节。

/* In some cases, we want to defer writing the instrumentation trampoline
until after all the labels, macros, comments, etc. If we're in this
mode, and if the line starts with a tab followed by a character, dump
the trampoline now. */

if (!pass_thru && !skip_intel && !skip_app && !skip_csect && instr_ok &&//236行的flag变量默认为0
instrument_next && line[0] == '\t' && isalpha(line[1])) {//根据下面的判断,是否为defered mode(延迟模式)即第一次肯定是不能执行这个if的,但是经过后文的处理下一轮循环可以进行插桩

fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32,
R(MAP_SIZE));//插桩

instrument_next = 0;//instrument_next重置为0,也可以理解为下一轮插桩
ins_lines++;//插桩计数器加1

}

/* Output the actual line, call it a day in pass-thru mode. */

fputs(line, outf);

if (pass_thru) continue;//如果 pass_thru 标志被设置,意味着 afl-as 认为这个文件不应该被插桩。它会立即 continue,变成一个纯粹的文件复制程序

/* All right, this is where the actual fun begins. For one, we only want to
instrument the .text section. So, let's keep track of that in processed
files - and let's set instr_ok accordingly. */
//首先我们只想对.text段进行插桩,所以我们需要跟踪处理过的文件,并相应地设置instr_ok。

if (line[0] == '\t' && line[1] == '.') {

/* OpenBSD puts jump tables directly inline with the code, which is
a bit annoying. They use a specific format of p2align directives
around them, so we use that as a signal. */
//OpenBSD将跳转表直接内联到代码中,这有点烦人。他们在跳转表周围使用特定格式的p2align指令,因此我们将其用作信号。

if (!clang_mode && instr_ok && !strncmp(line + 2, "p2align ", 8) &&
isdigit(line[10]) && line[11] == '\n') skip_next_label = 1;//检查是否为p2align指令,如果是则将skip_next_label设置为1
//instr_ok是一个flag变量,如果当前在.text段中则为1,否则为0
if (!strncmp(line + 2, "text\n", 5) ||
!strncmp(line + 2, "section\t.text", 13) ||
!strncmp(line + 2, "section\t__TEXT,__text", 21) ||
!strncmp(line + 2, "section __TEXT,__text", 21)) {
instr_ok = 1;// 找到了text段,授予插桩许可
continue; //匹配"text\n"、"section\t.text"、"section\t__TEXT,__text"、"section __TEXT,__text"中的任意一个则将instr_ok设置为1,表示当前在.text段中
}

if (!strncmp(line + 2, "section\t", 8) ||
!strncmp(line + 2, "section ", 8) ||
!strncmp(line + 2, "bss\n", 4) ||
!strncmp(line + 2, "data\n", 5)) {
instr_ok = 0;// 离开了代码段,撤销插桩许可
continue;//匹配"section\t"、"section "、"bss\n"、"data\n"中的任意一个如果成功则将instr_ok设置为0,表示当前不在.text段中
}

}

/* Detect off-flavor assembly (rare, happens in gdb). When this is
encountered, we set skip_csect until the opposite directive is
seen, and we do not instrument. */

if (strstr(line, ".code")) {//判断架构

if (strstr(line, ".code32")) skip_csect = use_64bit;
if (strstr(line, ".code64")) skip_csect = !use_64bit;

}

/* Detect syntax changes, as could happen with hand-written assembly.
Skip Intel blocks, resume instrumentation when back to AT&T. */

if (strstr(line, ".intel_syntax")) skip_intel = 1;//判断是否为intel语法
if (strstr(line, ".att_syntax")) skip_intel = 0;//判断是否为att语法

/* Detect and skip ad-hoc __asm__ blocks, likewise skipping them. */

if (line[0] == '#' || line[1] == '#') {//_ad_hoc __asm__ 块是否跳过

if (strstr(line, "#APP")) skip_app = 1;//进入用户内敛汇编块
if (strstr(line, "#NO_APP")) skip_app = 0;//离开

}

/* If we're in the right mood for instrumenting, check for function
names or conditional labels. This is a bit messy, but in essence,
we want to catch: 插装时终端关注对象

^main: - function entry point (always instrumented); main函数
^.L0: - GCC branch label; gcc下的分支标记
^.LBB0_0: - clang branch label (but only in clang mode);clang下的分支标记
^\tjnz foo - conditional branches; 条件跳转分支标记

...but not:

^# BB#0: - clang comments
^ # BB#0: - ditto
^.Ltmp0: - clang non-branch labels
^.LC0 - GCC non-branch labels
^.LBB0_0: - ditto (when in GCC mode)
^\tjmp foo - non-conditional jumps

Additionally, clang and GCC on MacOS X follow a different convention
with no leading dots on labels, hence the weird maze of #ifdefs
later on.

*/

if (skip_intel || skip_app || skip_csect || !instr_ok ||
line[0] == '#' || line[0] == ' ') continue;
/*------------------------------插桩策略--------------------------------------*/
/* Conditional branch instruction (jnz, etc). We append the instrumentation
right after the branch (to instrument the not-taken path) and at the
branch destination label (handled later on). */
//条件转移指令jnz等。我们在分支后面附加插桩(以检测未采用的路径)和分支目标标签(稍后处理)。

if (line[0] == '\t') {
/*这段代码专门处理以制表符开头的指令。它识别所有条件跳转指令(j开头但不是jmp),并根据 inst_ratio(插桩率)进行概率判断。
如果决定插桩,它会立刻调用 fprintf 将 trampoline(桩代码模板)写入输出文件*/
if (line[1] == 'j' && line[2] != 'm' && R(100) < inst_ratio) {//对于行如\tj[^m].格式的指令,即条件跳转指令,且R()函数创建的随机数小于插桩密度inst_ratio

fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32,
R(MAP_SIZE));//判断是否为64位程序,使用fprintf函数将桩插在outf指向的文件的\n\tj[^m].指令位置,插入R()函数创建的小于MAP_SIZE的随机数

ins_lines++;//插桩计数器加1,跳出循环进行下一次遍历

}//这里直接进行了插桩,并没有将instrument_next设置为1,因为条件跳转指令本身就有两个分支路径,插桩在这里已经覆盖了未采取路径

continue;

}

/* Label of some sort. This may be a branch destination, but we need to
tread carefully and account for several different formatting
conventions. */

#ifdef __APPLE__

/* Apple: L<whatever><digit>: */

if ((colon_pos = strstr(line, ":"))) {//检查line中是否存在:

if (line[0] == 'L' && isdigit(*(colon_pos - 1))) {//检查是否以L开头并且:前面是数字

#else

/* Everybody else: .L<whatever>: */

if (strstr(line, ":")) {//当代码识别出一个标签(以 : 结尾)时,它知道这是一个基本块的入口,是理想的插桩点

if (line[0] == '.') {//检查是否以.开头

#endif /* __APPLE__ */

/* .L0: or LBB0_0: style jump destination */

#ifdef __APPLE__

/* Apple: L<num> / LBB<num> */

if ((isdigit(line[1]) || (clang_mode && !strncmp(line, "LBB", 3)))
&& R(100) < inst_ratio) {//检查是否以L开头并且后面是数字,或者是clang模式并且以LBB开头,且R()函数创建的随机数小于插桩密度inst_ratio

#else

/* Apple: .L<num> / .LBB<num> */

if ((isdigit(line[2]) || (clang_mode && !strncmp(line + 1, "LBB", 3)))
&& R(100) < inst_ratio) {//检查是否以.L开头并且后面是数字,或者是clang模式并且以.LBB开头,且R()函数创建的随机数小于插桩密度inst_ratio

#endif /* __APPLE__ */

/* An optimization is possible here by adding the code only if the
label is mentioned in the code in contexts other than call / jmp.
That said, this complicates the code by requiring two-pass
processing (messy with stdin), and results in a speed gain
typically under 10%, because compilers are generally pretty good
about not generating spurious intra-function jumps.

We use deferred output chiefly to avoid disrupting
.Lfunc_begin0-style exception handling calculations (a problem on
MacOS X). */

if (!skip_next_label) instrument_next = 1; else skip_next_label = 0;//设置instrument_next为1,在下一轮循环开头插桩

}

} else {

/* Function label (always instrumented, deferred mode). */
//不立即插桩,而是设置“预约”标志————即在下一次循环开头
instrument_next = 1;//否则代表这是一个function label,插桩^func,设置instrument_next为1(defered mode)
//但它不立即行动,而是将 instrument_next 标志设为 1。真正的 fprintf 操作被推迟到下一次循环的开始,并且只有当下一行是真正的指令时(以\t和字母开头)才会执行。
}

}

}
/*-------------------------------收尾--------------------------------------*/
if (ins_lines)//如果插桩计数器不为零
fputs(use_64bit ? main_payload_64 : main_payload_32, outf);//向outf指向的文件中写入main_payload_64或者main_payload_32

if (input_file) fclose(inf);//关闭文件
fclose(outf);//关闭文件

if (!be_quiet) {//如果使用的不是静默模式

if (!ins_lines) WARNF("No instrumentation targets found%s.",//如果插桩计数器为空,抛出异常
pass_thru ? " (pass-thru mode)" : "");
else OKF("Instrumented %u locations (%s-bit, %s mode, ratio %u%%).",//插桩成功输出
ins_lines, use_64bit ? "64" : "32",
getenv("AFL_HARDEN") ? "hardened" :
(sanitizer ? "ASAN/MSAN" : "non-hardened"),
inst_ratio);

}

}

好的,看看 add_instrumentation 函数会如何处理你提供的那段汇编代码。

下面是针对每一个“考点”的详细答案,以及最终生成的 modified_file 的内容。


“考点”答案详解

1. 段落切换

  • 问题:代码如何通过 .textinstr_ok 设置为 1
  • 答案:当循环读到 \t.text\n 这一行时,if (line[0] == '\t' && line[1] == '.') 条件成立。内部的 if (!strncmp(line + 2, "text\n", 5)) 也成立,因此 instr_ok 被设置为 1
  • 问题:代码如何通过 .section .rodatainstr_ok 重新设置为 0
  • 答案:当读到 \t.section\t.rodata 这一行时,if (line[0] == '\t' && line[1] == '.') 条件成立。内部的 if (!strncmp(line + 2, "section\t", 8) ...) 成立,因此 instr_ok 被设置为 0,后续所有行都不会再被插桩。

2. 特殊语法块跳过

  • 问题skip_intel 标志是如何因为 .intel_syntax.att_syntax 而变化的?
  • 答案:当读到 .intel_syntax 时,if (strstr(line, ".intel_syntax")) 成立,skip_intel 被设为 1。在它和 .att_syntax 之间的所有行,都会因为 if (skip_intel || ...) 这个总安全检查而直接 continue,跳过所有插桩分析。当读到 .att_syntax 时,if (strstr(line, ".att_syntax")) 成立,skip_intel 被恢复为 0
  • 问题skip_app 标志是如何因为 #APP#NO_APP 而变化的?
  • 答案:与上面类似。当读到 #APP 时,skip_app 被设为 1#NO_APP 将其恢复为 0。之间的行同样会被总安全检查跳过。

3. 函数标签 (延迟插桩)

  • 问题:当读到 main: 这一行时,程序会做什么?
  • 答案:程序会识别出这是一个标签 (strstr(line, ":") 成立),但它不以 . 开头,所以会进入 else 分支,被判断为函数标签。它不会立即插桩,而是将 instrument_next 设置为 1
  • 问题:真正的插桩发生在哪一行之前
  • 答案:插桩发生在 endbr64 这一行之前。因为 main:.LFB0:.cfi_startproc 都不是以 \t 和字母开头的“真实指令”,循环会继续。直到 endbr64 这一行,循环开头的 if (... instrument_next ...) 条件终于满足,此时程序会先 fprintf 写入桩代码,然后再 fputs 写入 endbr64 这一行。

4. 条件跳转 (即时插桩)

  • 问题:当读到 jg .L2 这一行时,程序会做什么?
  • 答案:程序会识别出这是一条以 \t 开头的指令。内部的 if (line[1] == 'j' && line[2] != 'm') 条件成立(因为 jg 符合)。于是程序立刻 fprintf 写入一个桩代码,然后 continue。这个桩代码被插在了 jg .L2 这一行之后

5. 分支标签 (延迟插桩)

  • 问题:当读到 .L2:.L3: 时,程序分别会做什么?
  • 答案:对于 .L2:.L3:,程序都会识别出它们是标签,且以 . 开头。内部的 if ((isdigit(line[2]) ...)) 条件成立(因为 L 之后是数字)。于是,它们都会将 instrument_next 设置为 1
  • 问题:针对这两个标签的插桩,又分别发生在哪一行之前
  • 答案
    • .L2: 的桩代码,插在了它后面的 \tmov eax, DWORD PTR [rbp-4] 这一行之前
    • .L3: 的桩代码,插在了它后面的 \tnop 这一行之前

6. 非插桩目标

  • 问题:为什么 .LFB0:, .LFE0:, .LC0:, 0:, 1:, 4: 这些标签不会被插桩?
  • 答案
    • .LFB0:.LFE0::虽然它们是标签,但它们的第三个字符不是数字,strncmp(line+1, "LBB", 3) 也不成立,所以 if 条件不满足,不会设置 instrument_next。AFL 认为它们是编译器用于元数据(函数开始/结束)的标签,而不是真正的分支目标。
    • .LC0::同样,第三个字符 C 不是数字,也不是 LBB
    • .rodata 段的标签:当代码读到 .section .rodata 时,instr_ok 已经被设为 0,所以后续所有行都会被总安全检查跳过,根本不会进入标签分析的逻辑。
  • 问题:为什么无条件跳转 jmp .L3 不会被插桩?
  • 答案if (line[1] == 'j' && line[2] != 'm') 这个条件专门排除了 jmp(因为 line[2] 等于 'm')。因为无条件跳转只有一条路径,没有“不跳转”的分支,所以在这里插桩没有意义,反而会增加开销。这条边的覆盖会在目标标签 .L3 那里被捕捉到。

最终 modified_file 的内容

这里只需要注意插桩位置,装代码无需注意。

总结:插桩位置主要有标签和条件跳转指令后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
	.file	"test.c"
.intel_syntax noprefix
# -- some intel syntax code here --
# it should be skipped
.att_syntax
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
# ============ 桩代码 for main: (Intel Syntax) ============
push rax
push rcx
push rdx
mov rdx, qword ptr [rel __afl_area_ptr]
mov ecx, dword ptr [rel __afl_prev_loc]
mov eax, R(MAP_SIZE) ; cur_loc = a random value
xor eax, ecx ; eax = cur_loc ^ prev_loc
add rdx, rax
inc byte ptr [rdx]
mov eax, R(MAP_SIZE_AGAIN) ; MUST be the same random value
shr eax, 1
mov dword ptr [rel __afl_prev_loc], eax ; prev_loc = cur_loc >> 1
pop rdx
pop rcx
pop rax
# ==========================================================
endbr64
mov DWORD PTR [rbp-4], edi
cmp DWORD PTR [rbp-4], 10
jg .L2
# ============ 桩代码 for jg (not-taken): (Intel Syntax) ============
push rax
push rcx
push rdx
mov rdx, qword ptr [rel __afl_area_ptr]
mov ecx, dword ptr [rel __afl_prev_loc]
mov eax, R(MAP_SIZE)
xor eax, ecx
add rdx, rax
inc byte ptr [rdx]
mov eax, R(MAP_SIZE_AGAIN)
shr eax, 1
mov dword ptr [rel __afl_prev_loc], eax
pop rdx
pop rcx
pop rax
# ====================================================================
#APP
# This is user's inline assembly
# It should also be skipped
#NO_APP
mov eax, DWORD PTR [rbp-4]
add eax, 1
jmp .L3
.L2:
# ============ 桩代码 for .L2: (Intel Syntax) ============
push rax
push rcx
push rdx
mov rdx, qword ptr [rel __afl_area_ptr]
mov ecx, dword ptr [rel __afl_prev_loc]
mov eax, R(MAP_SIZE)
xor eax, ecx
add rdx, rax
inc byte ptr [rdx]
mov eax, R(MAP_SIZE_AGAIN)
shr eax, 1
mov dword ptr [rel __afl_prev_loc], eax
pop rdx
pop rcx
pop rax
# ========================================================
mov eax, DWORD PTR [rbp-4]
sub eax, 1
.L3:
# ============ 桩代码 for .L3: (Intel Syntax) ============
push rax
push rcx
push rdx
mov rdx, qword ptr [rel __afl_area_ptr]
mov ecx, dword ptr [rel __afl_prev_loc]
mov eax, R(MAP_SIZE)
xor eax, ecx
add rdx, rax
inc byte ptr [rdx]
mov eax, R(MAP_SIZE_AGAIN)
shr eax, 1
mov dword ptr [rel __afl_prev_loc], eax
pop rdx
pop rcx
pop rax
# ========================================================
nop
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.section .rodata
.LC0:
.string "Hello"
.ident "GCC: (Ubuntu 11.2.0-19ubuntu1) 11.2.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.word 0
.long 0
.long 0
4:

# ============ main_payload at the end (Intel Syntax) ============
# These are references to be resolved by the linker with afl-rt.o
__afl_area_ptr:
.quad __afl_area_ptr
__afl_prev_loc:
.long 0
# ====================================================================

(注:为了清晰,我用注释标出了桩代码的位置,并简化了桩代码内容。实际桩代码会根据 use_64bit 的值从 trampoline_fmt_64/32 宏生成)

专题二:fuzzing101学习

github项目:fuzzing101

笔者复现所有环境:ubuntu 22.04(wsl2)、AFL++

Exercise 1:Xpdf—- CVE-2019-13288

目标环境配置

这里选用的是比较旧版本的一个 xpdf:

1
2
wget https://dl.xpdfreader.com/old/xpdf-3.02.tar.gz
tar -xvzf xpdf-3.02.tar.gz

xpdf-3.02/目录下可以看到configure文件,用编辑器打开它可以看见这是一个生成makefile的程序

image-20250925194927464

编译程序默认指定的是gcc,由于我们需要在编译的时候进行插桩,所以将编译命令指定为afl-clang-fast

1
2
le0n:xpdf-3.02/ $ export CC=/home/le0n/tools/AFLplusplus/afl-clang-fast                                   
le0n:xpdf-3.02/ $ export CXX=/home/le0n/tools/AFLplusplus/afl-clang-fast++

编译程序,并指定将编译好的程序放在/home/le0n/fuzzing/fuzzing_xpdf/install 指定目录中

1
2
3
le0n:xpdf-3.02/ $ ./configure --prefix=/home/le0n/fuzzing/fuzzing_xpdf/install  
make
make install

在 make 过程中可以看到 afl-clang-fast 编译器的相关输出信息:

image-20250925200621502

编译好后,我们需要确认插桩成功了,因此我们进入到install目录中,由于我们使用的是afl-clang-fast进行插桩,所以需要查看插桩关键字:__sanitizer_cov

1
le0n:bin/ $ strings ./pdftotext | grep __sanitizer_cov  

插桩成功效果如下

image-20250925201555386

FUZZ阶段

实验环境部署好了,接下来要开始fuzz

fuzz前期准备

在正式执行fuzz之前需要准备一些fuzz的种子,也就是说需要输入的测试用例,并且要为这些文件创建一个目录:

1
2
3
4
le0n:fuzzing_xpdf/ $ mkdir pdf_examples && cd pdf_examples   
le0n:pdf_examples/ $ wget https://github.com/mozilla/pdf.js-sample-files/raw/master/helloworld.pdf
le0n:pdf_examples/ $ wget http://www.africau.edu/images/default/sample.pdf
le0n:pdf_examples/ $ wget https://www.melbpc.org.au/wp-content/uploads/2017/10/small-example-pdf-file.pdf

这里需要检测一下我们下载下来的文件是否可用,可以调用/fuzzing_xpdf/install/bin/pdfinfo的 info 来查看helloworld.pdf的基本信息。

成功效果如下:

image-20250925202823186

与此同时还要关闭系统下的核心转储,确保fuzz过程中出现crash也不会使程序中断

1
2
3
sudo su
echo core >/proc/sys/kernel/core_pattern
exit

开始fuzz

1
le0n:fuzzing_xpdf/ $ /home/le0n/tools/AFLplusplus/afl-fuzz -i ./pdf_examples -o ./out -M fuzzer1 -- ./install/bin/pdftotext @@ ./output

这里说明一下使用的参数:

  • -i:指定输入文件夹,里面是准备好的种子
  • -o:指定输出文件夹,存放fuzz过程中出现的生成的queue、crash、hang等
  • -M::可以选用主从多开fuzzer(其它fuzzer用-S指定,需要注意的是输出路径保持一致)
  • –:分隔符,后加测试目标
  • @@:指代文件,如果不加@@就是标准输入

那么使用-S指定多开fuzzer就可以同时进行多个fuzzer1~4

1
/home/le0n/tools/AFLplusplus/afl-fuzz -i ./pdf_examples -o ./out -S fuzzer2 -- ./install/bin/pdftotext @@ ./output

image-20250925204442069

这里开了4个fuzzer运行,运行了预计40min在fuzzer1中共计发现了3个crash,在out目录中如下:

image-20250926094910645

gdb验证fuzz结果

我们需要对 crash 进行分析,这里可以是静态分析,也可以是动态分析。静态分析的话就是常规的关键点分析,动态分析可以编译出一个带符号的程序,然后使用 gdb 去动态调试,分析崩溃现场和函数执行流。我们先看下动态调试,静态分析会在后面的例子中结合 memory sanitizer 这个工具去使用。

带符号编译目标程序

我们要先删掉由afl-clang-fast插桩编译的install目录下的内容,重新使用gcc编译

1
2
3
4
5
6
le0n:fuzzing_xpdf/ $ rm -r ./install 
le0n:fuzzing_xpdf/ $ cd xpdf-3.02
le0n:xpdf-3.02/ $ make clean
CFLAGS="-g -O0" CXXFLAGS="-g -O0" ./configure --prefix=/home/le0n/fuzzing/fuzzing_xpdf/install
make
make install

gdb动态调试

现在可以使用gdb指定pdftotext二进制程序运行fuzz结果文件:

1
2
3
le0n:xpdf-3.02/ $ cd ../ 
le0n:fuzzing_xpdf/ $ cd ./install/bin
le0n:bin/ $ gdb --args ./pdftotext $HOME/fuzzing/fuzzing_xpdf/out/fuzzer1/crashes/id:000000,sig:11,src:000716,time:381871,execs:329775,op:havoc,rep:2 $HOME/fuzzing/fuzzing_xpdf/output

加载后,直接r运行,程序断掉后用bt来回溯执行过的函数(这里要Crtl+c),效果如下:

image-20250926101210649

动态调试结果显示Parser:getObj函数在不断递归调用,这验证了CVE-2019-13288漏洞中的描述,无限递归调用造成拒绝服务

后续的跟进追流研究参考文章

,我们成功还原了递归链条:

  1. main 经过一些过程之后,调用 displaySlice 输出一些文本
  2. displaySlice 调用contents.fetch(xref, &obj) ,其中 contents 是一个 objRef,共用体 ref 二元组为 (num=7, gen=0)
  3. xref->fetch(ref.num, ref.gen, obj) 被调用,实际上 call 了 xref->fetch(7, 0, obj)
  4. xref->fetch 过程中,检测到这条 entry 是未被压缩的,调用 parser->getObj(obj, fileKey=NULL, encAlgorithm=<RC4>, keyLength0, num=7, gen=0),以获取 num=7, gen=0 这个 pdf object
  5. Parser::getObj 过程中,首先通过 obj->initDict(xref)objobjNone 初始化成一个 objDict,调用 makeStream(obj, fileKey=NULL, encAlgorithm=<RC4>, keyLength=0, objNum=7, objGen=0) 生成一个 Stream
  6. Parser::makeStream 过程中,调用 obj->dictLookup("Length", &newobj),意图是从现在已经是 objDictobj 里面取 key 为 "Length" 的键值对,把 value 给 newobj
  7. 上述 dictLookup 是一个简单封装,调用 obj->dict->lookup("Length", &newobj)
  8. 上述 lookupobj->dict 这个 dictionary 里面寻找到 key 为 "Length" 的 entry e: (key="Length", val=<objRef>),且这里的这个类型为 objRefvalref 二元组为 (num=7, gen=0)。调用 val.fetch(xref, &newobj)
  9. 上述 val.fetch(xref, &newobj)由于 valref 二元组为 (num=7, gen=0),所以会调用 xref->fetch(7, 0, &newobj),这个 call 与第 3 条分析的 call 作用相同,至此进入无限递归。

Exercise 2:Libexif

Libexif

项目地址:https://github.com/libexif/libexif

libexif是一个用于解析、编辑和保存EXIF数据的库,支持解析、编辑和保存EXIF数据

可交换图像文件格式(英语:Exchangeable image file format, 官方简称 Exif),是专门为数码相机的照片设定的,可以记录数码照片的属性信息和拍摄数据。

本次练习选用 libexif 0.6.14 版本,该版本存在 CVE-2009-3895 和 CVE-2012-2836 漏洞:

  • CVE-2009-3895 - 一种基于堆溢出的缓冲区溢出漏洞,可以通过无效的 EXIF 图像触发。可利用该漏洞在使用该库的应用程序中执行任意代码: CVE-2009-3895 详情
  • CVE-2012-2836 - 一种基于越界读取漏洞,可以通过特构造的 EXIF 标签的图像触发。 - 可利用该漏洞允许攻击者进行拒绝服务或可能从程序内获取潜在的敏感信息: CVE-2012-2836 详情

libexif库安装

由于libexif为本次练习的主要目标,所以还是需要进行插桩编译的,老样子创建一个干净的目录

将libexif-0.6.14源码的包下载下来并安装一些必要的软件和依赖

1
2
3
le0n:fuzzing_libexif/ $ sudo apt-get install autopoint libtool gettext libpopt-dev   
le0n:fuzzing_libexif/ $ wget https://github.com/libexif/libexif/archive/refs/tags/libexif-0_6_14-release.tar.gz
le0n:fuzzing_libexif/ $ tar -xzvf libexif-0_6_14-release.tar.gz

构建并安装libexif

1
2
le0n:fuzzing_libexif/ $ cd libexif-libexif-0_6_14-release 
le0n:libexif-libexif-0_6_14-release/ $ autoreconf -fvi

这个时候就可以在当前目录下生成一个configure,是创建makefile的程序,所以还要设置CC和·CXX环境变量

1
2
le0n:fuzzing_libexif/ $ export CC=/home/le0n/tools/AFLplusplus/afl-clang-fast                             
le0n:fuzzing_libexif/ $ export CXX=/home/le0n/tools/AFLplusplus/afl-clang-fast++

然后这里直接指定编译好的程序放在fuzzing_libexif/install目录下

1
2
3
le0n:libexif-libexif-0_6_14-release/ $ ./configure --enable-shared=no --prefix=/home/le0n/fuzzing/fuzzing_libexif/install
le0n:libexif-libexif-0_6_14-release/ $ make
le0n:libexif-libexif-0_6_14-release/ $ make install

有报错的话丢给ai问问有问题没。然后进入install/lib目录下检查一下插桩是否成功

1
le0n:lib/ $ strings libexif.a | grep __sanitizer_cov

image-20250927143354760

编译能调用libexif的程序

这里需要注意的是由于libexif是一个链接库,是无法直接运行的,所以还是需要一个能够调用libexif库的程序。libexif官网其实就有提供fuzz白的text文件https://github.com/libexif/libexif/blob/master/test/test-fuzzer-persistent.c

同时Exercise2中也提供了一个能够调用libexif库的程序,直接用就好了

1
2
le0n:fuzzing_libexif/ $ wget https://github.com/libexif/exif/archive/refs/tags/exif-0_6_15-release.tar.gz 
le0n:fuzzing_libexif/ $ tar -xzvf exif-0_6_15-release.tar.gz

后续安装过程和libexif一样

1
2
3
4
5
le0n:fuzzing_libexif/ $ cd exif-exif-0_6_15-release/
le0n:exif-exif-0_6_15-release/ $ autoreconf -fvi
le0n:exif-exif-0_6_15-release/ $ ./configure --enable-shared=no --prefix=/home/le0n/fuzzing/fuzzing_libexif/install/ PKG_CONFIG_PATH=$HOME/fuzzing/fuzzing_libexif/install/lib/pkgconfig
le0n:exif-exif-0_6_15-release/ $ make
le0n:exif-exif-0_6_15-release/ $ make install

这里唯一的区别是要用PKG_CONFIG_PATH指定一下安装的链接库的位置,同样是在install目录下的bin里面可以找到程序

image-20250927144233004

对于这个文件也可以查看一下插桩是否成功

image-20250927144308435

FUZZ阶段

环境已部署好,接下来开始fuzz

准备种子

因为程序本身的功能是解析EXIF文件,这里直接用Exercise 2提供的种子链接:https://github.com/ianare/exif-samples/archive/refs/heads/master.zip

1
2
le0n:fuzzing_libexif/ $ wget https://github.com/ianare/exif-samples/archive/refs/heads/master.zip 
le0n:fuzzing_libexif/ $ unzip master.zip

开始fuzz

现在所有的条件准备好了,下面就开始fuzz

1
/home/hollk/AFLplusplus/afl-fuzz -i /home/hollk/fuzzing_libexif/exif-samples-master/jpg/ -o /home/hollk/fuzzing_libexif/out/ -s 123 -- /home/hollk/fuzzing_libexif/install/bin/exif @@

image-20250927153225557

同理,我们删去插桩过的install目录,然后仍然带符号重新编译:

1
2
3
4
5
6
7
8
9
10
11
12
rm install
cd libexif-libexif-0_6_15-release
make clean
CFLAGS="-g -O0" CXXFLAGS="-g -O0" ./configure --enable-shared=no --prefix=/home/le0n/fuzzing/fuzzing_libexif/install
make -j$(nproc)
make install

cd exif-exif-0_6_15-release
make clean
CFLAGS="-g -O0" CXXFLAGS="-g -O0" ./configure --enable-shared=no --prefix=/home/le0n/fuzzing/fuzzing_libexif/install/ PKG_CONFIG_PATH=$HOME/fuzzing/fuzzing_libexif/install/lib/pkgconfi
make -j$(nproc)
make install

然后使用 gdb 进行动态调试,参数为 crashes 目录下的文件:

1
gdb --args ./install/bin/exif ./out/default/crashes/id:000001,sig:11,src:000010,time:115911,execs:80465,op:havoc,rep:1

最终这些所有的crashs有两种情况(符合漏洞描述的中断有好几个,hangs中还有一个无限递归吧,不符合的也有目前水平有限暂不分析),如下:

image-20250927203433134

image-20250927203444106

  显然,这些 crash 可以分为两类:

  • 调用 exif_data_load_data_thumbnail 时崩溃。
  • 调用 exif_get_sshort 时崩溃。

图一中可以看到在 #1调用 exif_data_load_data_thumbnail中的size = 4294966565 这显然是不合理的,这里是为了验证应该是CVE-2009-3895 - 一种基于堆溢出的缓冲区溢出漏洞,可以通过无效的 EXIF 图像触发。

图二的 #1 用 exif_data_load_data 时,成功通过了第一类 crash 的崩溃地点 exif_data_load_data_content,然而在接下来调用 exif_get_short(d + 6 + offset, data->priv->order) 读取一个 short 时崩溃。程序试图去读取地址 0x5556557b8c85 里的数据,但这个地址是非法的、不可访问的CVE-2012-2836 - 一种基于越界读取漏洞,可以通过特构造的 EXIF 标签的图像触发。

详细漏洞流程跟踪参考文章

Exercise 3:tcpdump

目标环境配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#download libpcap-1.8.0
~$ cd fuzzing_tcpdump/
~/fuzzing_tcpdump$ wget https://github.com/the-tcpdump-group/libpcap/archive/refs/tags/libpcap-1.8.0.tar.gz
~/fuzzing_tcpdump$ tar -xzvf libpcap-1.8.0.tar.gz
#改名,否则tcpdump无法识别到
~/fuzzing_tcpdump$ mv libpcap-libpcap-1.8.0/ libpcap-1.8.0
#install libpcap-1.8.0
~/fuzzing_tcpdump/$ cd libpcap-1.8.0
./configure --enable-shared=no --prefix=/home/le0n/fuzzing/fuzzing_tcpdump/install/
~/fuzzing_tcpdump/libpcap-1.8.0$ AFL_USE_ASAN=1 make
~/fuzzing_tcpdump/libpcap-1.8.0$ AFL_USE_ASAN=1 make install
#download tcpdump
~$ cd $HOME/fuzzing_tcpdump/
$ wget https://github.com/the-tcpdump-group/tcpdump/archive/refs/tags/tcpdump-4.9.2.tar.gz
$ tar -xzvf tcpdump-4.9.2.tar.gz
#install tcpdump
cd tcpdump-tcpdump-4.9.2/
AFL_USE_ASAN=1 ./configure --prefix="$HOME/fuzzing_tcpdump/install/"
AFL_USE_ASAN=1 make
AFL_USE_ASAN=1 make install

编译好后返回fuzzing_tcpdump/install/sbin目录下查看插桩是否成功

image-20250927210414191

再查看一下编译好的程序是否能正常运行

image-20250928111750871

FUZZ阶段

准备种子

在下载tcpdump的目录下存在一个tests目录,该目录下有很多例子可以使用这些例子作为种子

可以随便找个例子试一下:

1
le0n:tcpdump-tcpdump-4.9.2/ $ ./tcpdump -vvvvXX -ee -nn -r ./tests/geneve.pcap 

image-20250928112211412

开始fuzz

指定崩溃处理:

1
2
3
sudo su
echo core >/proc/sys/kernel/core_pattern
exit

准备就绪开始fuzz(我看了几篇博客有的可能需要16h+,我的在这里开了4个第三个在5min时就发现了crash)

但是从 fuzz 这项技术本身的角度来看,这种结果是还可以的,毕竟 fuzz 不到东西才是常态

1
2
3
/home/le0n/tools/AFLplusplus/afl-fuzz -m none -i ./tcpdump-tcpdump-4.9.2/tests/ -o ./out -s 123 -M master -- ./install/sbin/tcpdump -vvvvXX -ee -nn -r @@

/home/le0n/tools/AFLplusplus/afl-fuzz -m none -i ./tcpdump-tcpdump-4.9.2/tests/ -o ./out -s 123 -S fuzzer1 -- ./install/sbin/tcpdump -vvvvXX -ee -nn -r @@

参数解释:

  • -i:输入目录
  • -o:输出目录
  • -m:内存限制
  • -s:固定编译原始数据
  • -M:主fuzzer 命名
  • -S:从fuzzer
  • @@:指代文件
  • –:间隔符

这里使用 -m none 是因为在ASAN模式下对内存消耗非常大,所以指定禁用内存限制

image-20250927212632108

crash分析

在使用 ASan 之前,我们会使用 gdb 来把目标程序附带 crashes 文件夹中的数据作为参数来进行动态调试,从而可以看到其函数调用栈、内存数据等信息。而有了 ASan 之后,我们不必再编译程序,直接把 crash 文件作为参数传入即可,ASan 会给出我们崩溃的相关信息:

1
le0n:fuzzing_tcpdump/ $ ./install/sbin/tcpdump -vvvvXX -ee -nn -r ./out/fuzzer2/crashes/id:000000,sig:06,src:000236,time:188180,execs:74793,op:havoc,rep:2

image-20250928111035935

ASan 会给出造成问题的原因、函数的栈回溯、出现问题的具体数据点,结合这些信息,可以快速帮助我们重现 crash,确认到 crash 的 root case。

总结

在这个例子里,我们添加了 ASan 这个工具,它对于我们的 fuzz 其实是一个负面的作用,因为它的存在,在进行插桩时会造成内存的严重消耗。但是它的主要作用是在 crash 分析上,我们可以无需经过动态调试,就可以得到程序崩溃的相关信息和数据,可以更快地帮助我们找到造成 crash 的原因。

需要注意的是,因为 ASan 的大内存消耗,所以需要根据情况来决定要不要使用该工具

扩展

前面介绍的 AFL 针对的都是 PC 端的软件,确切地说都是 Linux 平台下的传统的端侧软件。如果我们想在 IoT 领域使用该工具,应该如何切入呢?IoT 领域的一些系统中,也会有诸多处理文件的软件,我们是否可以直接套用现有的思路去 fuzz 这些文件处理软件呢?比如 xml、json的处理。因为这些文件都是在前端进行组织,然后发送到后端去进行处理,如果我们能在这个阶段加入 fuzz 流程,就可以倒推 payload 的组织,从而实现漏洞利用。我们在后续会慢慢切入。

Exercise 4:libtiff

本文主要用到了fuzzing101、AFLplusplus、libtiff、LCOV

代码覆盖率是一种软件指标,表达了每行代码被触发的次数。在进行模糊测试的过程中,我们需要知道我们的 fuzzer 执行的效果怎么样,这个时候就可以使用上代码覆盖率。通过使用代码覆盖率,我们可以了解 fuzzer 已经到达了代码的哪些部分,并可视化 fuzzing 过程。

在这里我们使用 lcov 来展示代码覆盖率工具的使用。

lcov 是 gcc 测试覆盖率的前端图形展示工具。它通过收集多个源文件的 行、函数和分支的代码覆盖信息(程序执行之后生成gcda、gcno文件,上面的链接有讲) 并且将收集后的信息生成HTML页面。生成HTML需要使用genhtml命令。

环境部署

libtiff安装

首先将libtiff下载并解压出来

1
2
le0n:fuzzing_libtiff/ $ wget https://download.osgeo.org/libtiff/tiff-4.0.4.tar.gz 
le0n:fuzzing_libtiff/ $ tar -xzvf tiff-4.0.4.tar.gz

接着在启动ASAN的情况下编译libtiff(这里需要指定CC和CXX,每次重启后都要命令在下面)

1
2
3
le0n:tiff-4.0.4/ $ ./configure --prefix=/home/le0n/fuzzing/fuzzing_libtiff/install --disable-shared
le0n:tiff-4.0.4/ $ AFL_USE_ASAN=1 make
le0n:tiff-4.0.4/ $ AFL_USE_ASAN=1 make install

编译后还是在./install/bin目录中检查插桩是否成功,

image-20251002120430908

显然我这次没结果就是插桩失败了,查询后是没有用afl++的编译器而是用的gcc、g++

那就rm -rf installmake clean再来一遍

1
2
3
4
5
le0n:fuzzing_libexif/ $ export CC=/home/le0n/tools/AFLplusplus/afl-clang-fast                             
le0n:fuzzing_libexif/ $ export CXX=/home/le0n/tools/AFLplusplus/afl-clang-fast++
le0n:tiff-4.0.4/ $ ./configure --prefix=/home/le0n/fuzzing/fuzzing_libtiff/install --disable-shared
le0n:tiff-4.0.4/ $ AFL_USE_ASAN=1 make -j$(nproc)
le0n:tiff-4.0.4/ $ AFL_USE_ASAN=1 make install

再次检查是否插桩成功,显然有东西了就说明插桩成功,编译好的程序也可正常运行

image-20251002121147452

LCOV安装

1
sudo apt install lcov

检查是否安装成功

image-20251002121337564

FUZZ阶段

准备种子

在下载的tiff源码包中的test/images文件夹中包含的有测试例子

image-20251002121520618

随便找个例子试一下

1
le0n:fuzzing_libtiff/ $ ./install/bin/tiffinfo -D -j -c -r -s -w ./tiff-4.0.4/test/images/palette-1c-1b.tiff 

image-20251002121731871

开始fuzz

指定崩溃处理

1
2
3
le0n:fuzzing_libtiff/ $ sudo su                                                                           
root:fuzzing_libtiff/ # echo core >/proc/sys/kernel/core_pattern
root:fuzzing_libtiff/ # exit

一切主备就绪开始fuzz

1
le0n:fuzzing_libtiff/ $ /home/le0n/tools/AFLplusplus/afl-fuzz -m none -i ./tiff-4.0.4/test/images/ -o ./out -s 123 -M master -- ./install/bin/tiffinfo -D -j -c -r -s -w @@

image-20251002123302108

几分钟就好几个速度挺不错的

调用tiffinfo运行其中一个POC查看ASAN提示:

1
le0n:fuzzing_libtiff/ $ ./install/bin/tiffinfo -D -j -c -r -s -w ./out/master/crashes/id:000004,sig:06,src:000000,time:64582,execs:39103,op:havoc,rep:1 

image-20251002123532283

查看POC代码覆盖率

前面已经安装过LCOV,那么这里就需要用--coverage标志重建libtiff,所以直接删掉install目录并清理make

1
2
3
le0n:fuzzing_libtiff/ $ rm -rf install
le0n:fuzzing_libtiff/ $ cd tiff-4.0.4
le0n:tiff-4.0.4/ $ make clean

设置一下编译所需的环境变量CFLAGS和·LDFLAGS

1
2
le0n:tiff-4.0.4/ $ export LDFLAGS="--coverage" 
le0n:tiff-4.0.4/ $ export CFLAGS="--coverage"

接下来还是老步骤创建makefile并进行编译

1
2
3
le0n:tiff-4.0.4/ $ ./configure --prefix=/home/le0n/fuzzing/fuzzing_libtiff/install --disable-shared  
le0n:tiff-4.0.4/ $ make -j$(nproc)
le0n:tiff-4.0.4/ $ make install

然后通过下面的代码收集代码覆盖率数据:

1
2
3
4
5
6
7
8
le0n:tiff-4.0.4/ $ lcov --zerocounters --directory ./ 
# 重置以前的计数器
le0n:tiff-4.0.4/ $ lcov --capture --initial --directory ./ --output-file app.info
# 返回包含每条检测线的零覆盖率的“基线”覆盖率数据文件,这里用file app.info检查一下,有问题看下一个方案
le0n:tiff-4.0.4/ $ ../install/bin/tiffinfo -D -j -c -r -s -w ../out/master/crashes/id:000004,sig:06,src:000000,time:64582,execs:39103,op:havoc,rep:1
# 让程序运行crash中的其中一个输出文件,可以多选几个运行
le0n:fuzzing_libtiff/ $ lcov --no-checksum --directory ./ --capture --output-file app2.info
# 将当前覆盖状态保存到 app2.info 文件中

保存之后就可以用gethtml将app2.info中的覆盖率数据转换成html可视化状态了

1
genhtml --highlight --legend -output-directory ./html-coverage/ ./app2.info

但是在这里肯因为版本问题导致生成的app.infp app2.info和最后的html文件全部失败,解决方案如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#clean
rm app.info app2.info
rm -rf hrml-coverage
make clean

#restart ubuntu22.01 wsl2
# 设置使用 gcc-11 和正确的覆盖率标志
export CC=gcc-11
export CXX=g++-11
export CFLAGS="--coverage -g -O0"
export CXXFLAGS="--coverage -g -O0"

# 重新配置和编译
./configure --prefix=/home/le0n/fuzzing/fuzzing_libtiff/install
make
make install

#reuse
lcov --zerocounters --directory ./
lcov --capture --initial --directory ./ --output-file app.info
../install/bin/tiffinfo -D -j -c -r -s -w ../out/master/crashes/id:000004,sig:06,src:000000,time:64582,execs:39103,op:havoc,rep:1
lcov --no-checksum --directory ./ --capture --output-file app2.info

#gethtml
genhtml --highlight --legend -output-directory ./html-coverage/ ./app2.info

其中运行过./configure时信息如下

image-20251002132445973

整体上看,这个覆盖率还是比较低的。

image-20251002134324156

image-20251002134644128

但是我们的主要目标 tif_dirinfo.c 文件的覆盖率效果还是可以接受的,由于我们的 fuzz 的时间并不是很长,如果继续下去,应该是可以覆盖到更多的代码的。对于文件执行到的具体代码,可以进入到文件里面再查看详细信息。

现在可以在重现 crash 的前提下,再加上 lcov 提供的代码覆盖率,就可以更轻松地去定位漏洞触发的原因了。

总结

在这个练习中,引入了代码覆盖率的概念和工具。除了本例使用的 lcov 之外,还有一个常见的 GCC 的 gcov,但是其图形化信息不如 lcov 丰富。代码覆盖率对于 AFL 这种基于覆盖引导的 fuzzer 来说,意义重大,判定 fuzzer 效果好坏的关键因素之一就是看其代码覆盖率的高低。在对 fuzzer 进行优化和改进时,往往也是朝着可以提升代码覆盖率的方向去更改,毕竟执行越多的代码,越有可能发现更多的问题。

但是根据我们前面的测试也可以发现,在使用编译器编译时,只能使用 gcc 系,这样会造成一定程度上的速度损耗,这其中的衡量就需要使用者根据实际情况来进行取舍了。

Exercise 5:libxml2

环境部署

libxml2安装

首次安装一下必须依赖和环境变量

1
2
3
4
le0n:fuzzing_libxml2/ $  sudo apt-get install python-dev-is-python3
le0n:fuzzing_libxml2/ $ export CFLAGS="-fsanitize=address"
le0n:fuzzing_libxml2/ $ export CXXLAGS="-fsanitize=address"
le0n:fuzzing_libxml2/ $ export LDLAGS="-fsanitize=address"

下载libxml2-2.9.4源码并解压

1
2
le0n:fuzzing_libxml2/ $ wget https://github.com/GNOME/libxml2/archive/refs/tags/v2.9.4.tar.gz -O libxml2-2.9.4.tar.gz
le0n:fuzzing_libxml2/ $ tar xvf libxml2-2.9.4.tar.gz && cd libxml2-2.9.4/

接下来创建makefile并进行编译

1
2
3
4
5
6
7
le0n:libxml2-2.9.4/ $ export CC=/home/le0n/tools/AFLplusplus/afl-clang-fast 
le0n:libxml2-2.9.4/ $ export CXX=/home/le0n/tools/AFLplusplus/afl-clang-fast++
#生成configure
le0n:libxml2-2.9.4/ $ ./autogen.sh
le0n:libxml2-2.9.4/ $ AFL_USE_ASAN=1 ./configure --prefix=/home/le0n/fuzzing/fuzzing_libxml2/libxml2-2.9.4/install --disable-shared --without-debug --without-ftp --without-http --without-legacy --without-python LIBS='-ldl'
le0n:libxml2-2.9.4/ $ AFL_USE_ASAN=1 make -j$(nproc)
le0n:libxml2-2.9.4/ $ AFL_USE_ASAN=1 make install

依旧检查插桩是否成功

image-20251002142427305

在运行一下看看是否编译成功,在源码包中test目录下可以找到一些测试例子

1
le0n:bin/ $ ./xmllint --memory /home/le0n/fuzzing/fuzzing_libxml2/libxml2-2.9.4/test/wml.xml 

image-20251002152627797

FUZZ阶段

准备种子

在这个Exercise 5中已经准备了一个测试用例SampleInput.xml,所以为种子创建一个输入文件夹将它copy过来

1
2
3
4
5
6
le0n:fuzzing/ $ git clone https://github.com/antonio-morales/Fuzzing101.git
le0n:fuzzing/ $ cd fuzzing_libxml2
le0n:fuzzing_libxml2/ $ mkdir afl_in && cd afl_in
le0n:fuzzing/ $ ls
Fuzzing101 fuzzing_libexif fuzzing_libtiff fuzzing_libxml2 fuzzing_tcpdump fuzzing_tiff fuzzing_xpdf
le0n:fuzzing/ $ cp ./Fuzzing101/Exercise\ 5/SampleInput.xml ./fuzzing_libxml2/afl_in

这次练习还需要字典文件辅助本次fuzzing,AFLplusplus项目中提供了一个字典的子目录,里面包含了很多字典,所以直接将xml的字典下载下来即可(直接去github下就行)

1
2
le0n:fuzzing_libxml2/ $ mkdir dictionaries && cd dictionaries
hollk@ubuntu:~/fuzzing_libxml2/dictionaries$ wget https://github.com/AFLplusplus/AFLplusplus/tree/stable/dictionaries/xml.dict

开始fuzz

因为 libxml2 库较大,所以这里使用 Master-Slave 模式进行 fuzz

1
2
3
4
le0n:fuzzing_libxml2/ $ /home/le0n/tools/AFLplusplus/afl-fuzz -m none -i ./afl_in -o ./afl_out -s 123 -x ./dictionaries/xml.dict -D -M master -- ./install/bin/xmllint --memory --noenc --nocdata --dtdattr --loaddtd --valid --xinclude @@
le0n:fuzzing_libxml2/ $ /home/le0n/tools/AFLplusplus/afl-fuzz -m none -i ./afl_in -o ./afl_out -s 123 -x ./dictionaries/xml.dict -D -S fuzzer1 -- ./install/bin/xmllint --memory --noenc --nocdata --dtdattr --loaddtd --valid --xinclude @@
le0n:fuzzing_libxml2/ $ /home/le0n/tools/AFLplusplus/afl-fuzz -m none -i ./afl_in -o ./afl_out -s 123 -x ./dictionaries/xml.dict -D -S fuzzer2 -- ./install/bin/xmllint --memory --noenc --nocdata --dtdattr --loaddtd --valid --xinclude @@
le0n:fuzzing_libxml2/ $ /home/le0n/tools/AFLplusplus/afl-fuzz -m none -i ./afl_in -o ./afl_out -s 123 -x ./dictionaries/xml.dict -D -S fuzzer3 -- ./install/bin/xmllint --memory --noenc --nocdata --dtdattr --loaddtd --valid --xinclude @@

image-20251002154805625

emmm…四个小时没东西,我看有的几分钟出了,有的1d+才出

image-20251002194249184

总结

当我们想要模糊复杂的基于文本的文件格式(例如 XML)时,为模糊器提供包含基本语法标记列表的字典非常有用。

就 AFL 而言,这样的字典只是一组单词或值,AFL 使用它来将更改应用到当前内存中的文件。具体来说,AFL 使用字典中提供的值执行以下更改:

覆盖:用 n 个字节替换特定位置,其中 n 是字典条目的长度。
插入:在当前文件位置插入字典条目,强制所有字符向下移动 n 个位置并增加文件大小。
模糊器根据 -x 参数是文件还是目录自动选择适当的模式。

AFL++提供了很多字典:https://github.com/AFLplusplus/AFLplusplus/tree/stable/dictionaries

在这个例子里我们主要关注的是 AFL 中的 dictionary 的概念,在面对格式复杂、体量较大的基于文本的文件的 fuzz 时,如果只是单纯使用 AFL 内置的各种变异策略进行种子变异,无疑会浪费很多资源,因为会产生大量的无效输入,比如根本不符合目标程序输入要求的文件内容。所以在这种情况下,最好能够有一个模版性质的东西先将输入进行一次过滤或者限制变异的方向和内容,这就是 dictionary的作用。其实到这里为止,有一点结构化 fuzz 的含义在里面,不再是单纯的无意义的变异。可能功能本身现在看起来没有什么特殊之处,但是这里面蕴含的哲学思想还是值得我们去思考学习的:化繁为简,结构拼合。

过半总结

项目 小总结
Exercise 1: xpdf 熟悉fuzz的整体过程和使用gdb对崩溃进行分析
Exercise 2: libexif 加速fuzz的整体过程使用afl-clang-lto ,这是一种无碰撞检测工具,比afl-clang-fast 速度更快,并且能提供更好的结果
Exercise 3: tcpdump 什么是ASan (Address Sanitizer),一种运行时内存错误检测工具 如何使用 ASAN 模糊测试目标 使用 ASan 对崩溃进行分类有多容易
Exercise 4:libtiff 如何使用 LCOV 测量代码覆盖率 如何使用代码覆盖率数据来提高模糊测试的有效性
Exercise 5: libxml2 使用自定义字典帮助模糊器找到新的执行路径

Exercise 6: GIMP

环境部署

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mkdir fuzzing_gimp && cd fuzzing_gimp
# install dependencies
sudo apt install build-essential libatk1.0-dev libfontconfig1-dev libcairo2-dev libgudev-1.0-0 libdbus-1-dev libdbus-glib-1-dev libexif-dev libxfixes-dev libgtk2.0-dev python2.7-dev libpango1.0-dev libglib2.0-dev zlib1g-dev intltool libbabl-dev

# download and uncompress
wget https://download.gimp.org/pub/gegl/0.2/gegl-0.2.0.tar.bz2
tar xvf gegl-0.2.0.tar.bz2 && cd gegl-0.2.0

# modify the source code
sed -i 's/CODEC_CAP_TRUNCATED/AV_CODEC_CAP_TRUNCATED/g' ./operations/external/ff-load.c
sed -i 's/CODEC_FLAG_TRUNCATED/AV_CODEC_FLAG_TRUNCATED/g' ./operations/external/ff-load.c

# build and install
./configure --enable-debug --disable-glibtest --without-vala --without-cairo --without-pango --without-pangocairo --without-gdk-pixbuf --without-lensfun --without-libjpeg --without-libpng --without-librsvg --without-openexr --without-sdl --without-libopenraw --without-jasper --without-graphviz --without-lua --without-libavformat --without-libv4l --without-libspiro --without-exiv2 --without-umfpack
make -j$(nproc)
sudo make install

这里对于 GEGL 这个图形库的编译安装我们不做过多介绍,这不是我们的重点,可以明确告知的是上面的库在编译时大概率会编译报错,导致一些库文件编译失败。所以,对于Ubuntu 20.04以上版本(我使用的是22.04)可以直接 sudo apt install libgegl-0.4-0 来安装这个0.4版本的库。(尽量不在非fuzz阶段浪费时间)

环境配不好,无语了。。。

然后,下载 GIMP 2.8.16,并进行编译安装:

1
2
3
4
5
6
7
8
9
# download 
cd ..
wget https://mirror.klaus-uwe.me/gimp/pub/gimp/v2.8/gimp-2.8.16.tar.bz2
tar xvf gimp-2.8.16.tar.bz2 && cd gimp-2.8.16/

# build and install
CC=afl-clang-lto CXX=afl-clang-lto++ PKG_CONFIG_PATH=$PKG_CONFIG_PATH:/usr/local/lib/pkgconfig CFLAGS="-fsanitize=address" CXXFLAGS="-fsanitize=address" LDFLAGS="-fsanitize=address" ./configure --disable-gtktest --disable-glibtest --disable-alsatest --disable-nls --without-libtiff --without-libjpeg --without-bzip2 --without-gs --without-libpng --without-libmng --without-libexif --without-aa --without-libxpm --without-webkit --without-librsvg --without-print --without-poppler --without-cairo-pdf --without-gvfs --without-libcurl --without-wmf --without-libjasper --without-alsa --without-gudev --disable-python --enable-gimp-console --without-mac-twain --without-script-fu --without-gudev --without-dbus --disable-mp --without-linux-input --without-xvfb-run --with-gif-compression=none --without-xmc --with-shm=none --enable-debug --prefix="$HOME/Desktop/Fuzz/training/fuzzing_gimp/gimp-2.8.16/install"
AFL_USE_ASAN=1 make -j$(nproc)
AFL_USE_ASAN=1 make install

这里的编译选项有点多,第一次的时候尽可能保持一致,避免出错,如果要进行优化和改进,可根据实际需求来增删编译选项。

编译完成后检查软件是否可以正常运行,命令行和图形界面都检查一下。

Exercise 8:Adobe Reader

之前我们都是针对有源码的程序进行 fuzz,这个例子将对纯二进制文件进行fuzz,需要使用到 AFL 的 qemu-mode。对于 AFL 的qemu-mode的详细介绍,可以查看 AFL 的官方文档的 qemu-mode 的部分。

目标环境配置

构建afl-qemu-trace

1
2
3
sudo apt install ninja-build libc6-dev-i386
cd ~/Desktop/v4ler1an/Fuzz/AFLplusplus/qemu_mode/
CPU_TARGET=i386 ./build_qemu_support.sh

无语了又有一些环境问题(可能是wsl2不兼容吧),hollk师傅的文章有个qemu和AFL++的闭源测试会记录在后门文章

这个项目实现的参考文章

专题三:Address Sanitizer(ASan)各类溢出demo分析

前言

前面的文章基本上都是在 Fuzzing 101 项目的基础上进行详细的记录,也就是说大部分的内容都是以环境变装、使用技巧为主,没有过多的描述 fuzz 出 crash 后应该如何分析。因此特意写了这篇文章,通过一些漏洞类型的 demo 分析一下 Address Sanitizer (ASan) 的报告。

Address Sanitizer (ASan) 介绍

Address Sanitizer 又名 ASan,是一个快速的 C/C++ 代码存储错误检测器。该工具由一个编译器检测模块和一个提供内存操作函数(如 malloc/free)替代项的 run-time 库。该工具适用于 x86、ARM、MIPS、PowerPC64 架构,支持的操作系统有 Linux、Darwin (OS X 和 iOS 模拟器)、FreeBSD、Android。

ASan 可以检测到:UAFHeap buffer overflowStack buffer overflowGlobal buffer overflowUse after returnUse after scopeInitialization order bugsMemory leak

文章后半部分会针对上述几个漏洞类型的 demo 进行分析。

详细了解 ASan

各类漏洞 demo 分析

接下来会针对 UAF、Heap buffer overflow、Stack buffer overflow、Global buffer overflow、Use after return、Use after scope、Initialization order bugs、Memory leak 分别举例进行分析,重点是如何看 ASan 显示的结果。

stack overflow

源C++ demo

1
2
3
4
5
6
int main(int argc, char **argv) {
int arr[100];
arr[1] = 0;
return arr[argc + 100]; // overflow argc default 1
}
//clang -O -g -fsanitize=address demo.cpp -o demo

创建哟个arr[100]的数组,int 型共400字节,最后返回arr[argc + 100]。这里出现溢出,返回位置超出栈空间大小

运行一下编译的程序,ASan就会显示出检测日志

image-20251003170338280

上面就是ASan输出的全部日志,下面开始分析

image-20251003170549263

上半部分主要显示的是漏洞类型和漏洞位置

  • 红色框部分:==中间表示的是检测到问题的进程号22836,ERROR后面为漏洞类型及检测到的漏洞stack-buffer-overflow所在地址0x7ffd8a8539f4 ,并记录此时的pc寄存器0x584d2326a08d ,sp寄存器0x7ffd8a853828,bp寄存器0x7ffd8a853830
  • 蓝色框部分:主要描述了采用READ的操作,在线程T0的0x7ffd8a8539f4栈地址处读取了大小为4的数据,下面的#123是函数栈
  • 绿色框部分:表示漏洞地址0x7ffd8a8539f4位于线程T0的栈中的帧偏移量436处,并指定被溢出的变量
  • 其余部分为总结和警告

image-20251003173250917image-20251003173333695

下半部分为 shadow 的字节图,其中一个字节表示程序内存中存储的 8 个字节。可以再图中看到一些符号,例如 00、f1、f3,继续向下翻可以看到每种符号对应表示的含义。

ASan 会对程序中的内存空间在侧或者添加两个可写区块 redzone,上图的 f1 即为左侧增加区域,f3 为右侧增加区域,其中加中括号的f3即为漏洞发生点。中间的 00 节点,可以看到 shadow 中的 50 个字节对应内存中的就是 50 x 8 = 400 个字节,正好是 int arr[100] 所释放的空间大小(int 型变量没有添加任何的参数,所以 argc 为 1(demo 也计入参数),所以这里只溢出了 4 个字节(arr[101] 占 4 个字节)。那么在 shadow 中就会看到以下三点关键消息:

  • 被溢出变量:arr
  • 溢出点所在位置:0x7ffd8a8539f4
  • 溢出点距离变量起始点偏移:436
  • 缓冲区溢出情况

上述三点信息可以溢出位置进行定位。

global-buffer-overflow

源demo.cpp

1
2
3
4
5
int hollk[100] = {-1};
int main(int argc, char **argv) {
return hollk[argc + 100]; // overflow
}
//clang -O -g -fsanitize=address hollk.cpp -o hollk_cpp

溢出原理和第一个相同,造成了往下多读了4个字节

image-20251004144909908

可以看到一处点位在0x5df8a0363cf4,此时的pc、bp、sp寄存器分别为0x5df8a032dedc,0x7ffe52e39a70 0x7ffe52e39a68,下面可以看到此函数的栈,并提示了溢出的全局变量为hollk。

在shadow中f9为全局变量右侧添加的redzone部分,由于溢出仅4字节,在第一个f9处加上了中括号

stack-use-after-scope

stack-use-after-scope指的是局部变量在脱离作用域后再次使用,源demo.cpp

1
2
3
4
5
6
7
8
9
10
11
volatile int *p = 0;

int main() {
{
int var = 0;
p = &var;
}
*p = 5;
return 0;
}
//clang -O -g -fsanitize=address hollk.cpp -o hollk_cpp

可以看到使用 volatile 关键字,创建 int 指针 p,并且赋值为 0。这是 volatile 的关键字是为了提醒编译器这里所创建的指针 p 是会发生变化的,编译后每一次读取或存储该指针,都将会重新进行读取操作。

举一个例子,比如说 A、B 两个线程同时读取 p 指针 0x7ffffab0 中的值 0。如果 A 线程对 p 指针修改成 0x7ffffbc0 并将其中的值修改为 2,那么 B 线程如果对 p 指针做操作时,将会重新读取 p 的地址 0x7ffffab0,并且其中的值会变成 1。

继续向下看,在 main 函数中创建了一个 int 型的变量 var,这里需要注意 var 是局部变量,并赋值为 0;此时 p 指向的地址就是 var 变量所在地址。main 函数结束后,修改 p 指针指向位置的值为 5,这里就出现了 stack-use-after-scope 漏洞,因为 hollk 本身是一个局部变量,超出 main 函数作用范围后,var 中的值发生了改变。

image-20251004150336314

查看ASan的日志,可以看到stack-use-after-scope点位在0x7fffc39cc760,此时的pc、bp、sp寄存器分别为0x5a6b55bb8fbe,0x7fffc39cc730 0x7fffc39cc728,下面可以看到此函数的栈,并提示了溢出的全局变量为var。接下来展示的是函数栈的情况及偏移量,在shadow图中 f8 为漏洞出现的位置。

heap-buffer-overflow

这里需要用到堆结构体的知识来分析ASan的日志,demo

1
2
3
4
5
6
7
8
int main(int argc, char **argv) {
int *buf = new int[100];
buf[0] = 0;
int res = buf[argc + 100]; // BOOM
delete [] buf;
return res;
}
//g++ -O -g -fsanitize=address demo.cpp -o demo

在C++中使用new来创建堆块,底层也是调用malloc实现的,并且delete释放堆块底层就是free,回到代码中,首先开票一个存放整数的空间,并指定该int类型最终返回一个指向该存储空间的地址,相当于buf = malloc(100)。接下来将堆块中的第一个四字节赋值为0,后续将argc+100处的内容赋值给res变量,最后释放掉堆块并返回res变量中的值。这里的argc+100其实是超出了堆块400字节的内存空间的(实际上用malloc分配这个内存时,在有相邻的两个堆块时,会出现空间复用的情况那么这4个字节并不是真正的溢出,还是这个指针指向的chunk可以用到地址)

image-20251004152815786

首先看上面的数据部分依然是漏洞出现位置以及此时寄存器、bp 寄存器存储地址,下方的函数栈以及漏出的位置范围。这边着重想讲述的是 shadow 图表示的情况,首先红色的 fa 就是 hollk 块旁边的 redzone 空间,结构体的大小就是 400 个字节。在右侧第一个 fa redzone 区域加上了中括号,和前面代码分析部分得出的结论是一致的,溢出了 argc 个字节。

对于 64 位程序来讲,如果连续申请两个地址相邻的堆块,那么在实际内存中两个地址相邻的堆块,低地址后面相邻的高地址块的 prev size 可以临时充当存储空间使用(就是前面的空间复用),因此如果一个 400 字节的空间,实际上可以在堆中写满 408 个字节,那么 shadow 位图中出现的日志效果就会不太准确。

use-after-free

use-after-free是一种常见的堆漏洞,因为在代码编写过程中释放的堆块没有置空,或已释放的指针再次进行调用,这就造成了UAF,源demo.cpp

1
2
3
4
5
6
int main(int argc, char **argv) {
int *buf = new int[100];
delete [] buf;
return buf[argc]; // UAF
}
//g++ -O -g -fsanitize=address demo.cpp -o demo

demo的思路很清晰。首先new了一个400字节堆块并将指针赋值给buf,然后释放了这个buf,最后返回堆块中对应的下标造成了UAF。

image-20251004153554309

看一下 ASan 的检测日志,出现 UAF 漏洞的堆块地址为 0x514000000044,此时 pcbpsp 寄存器中地址分别为 0x6534e7ca821f0x7ffc282312800x7ffc28231270。下方为函数调用栈,并且指明漏洞存在堆块的地址范围。最后是内存创建释放函数调用栈。

接下来看看 shadow 图,紫色的 fd 代表着被释放堆块区域,return 返回时被释放堆块的第 argc 数组中的内容,所以中括号中括号包含了第一个紫色 fd

memory-leak

内存泄露表现方式有很多,但是中心思想是创建内存空间之后没有释放,导致数据在内存空间中没有消除,那么通过一些手段可以读取这段数据中的内容,demo.c

1
2
3
4
5
6
7
8
9
10
11
#include <stdlib.h>

void *buf;

int main() {
buf = malloc(8);
buf = 0; // leak
return 0;
}
//clang -g -fsanitize=address deno.c -o demo
//如果想要检测溢出问题,就不要加-O选项

这里创建了一个大小为8字节的堆块,接下来在没有释放堆块的情况下,直接将堆块的malloc指针置空。这就意味着这段8字节的内存空间只要进程不结束,就会一直停留在堆区

image-20251004154517609

这个日志就比较简单了,展示了泄露时的栈帧和泄露的字节长度

initialization-order-fiasco

这个漏洞主要是由于变量初始化顺序导致的,静态初始化顺序失败是 C++ 程序中与构造全局对象的顺序有关的常见问题(当一个全局对象(或静态对象)的初始化依赖于另一个全局对象, 而这两个对象定义在不同的翻译单元中时,程序行为是未定义的)。未指定不同源文件中全局对象的构造函数的运行顺序,看一下两个 demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// a.cc
#include <stdio.h>

extern int extern_global;
int __attribute__ ((__noinline__)) read_extern_global() {
return extern_global;
}

int x = read_extern_global() + 1;

int main() {
printf("%d\n", x);
return 0;
}
1
2
3
// b.cc
int foo() { return 42; }
extern int extern_global = foo();

这个 demo 分为两个部分,一个是 a.cc 另一个是 b.cc。首先看 a.cc 的执行流程,extern 定义了一个 int 类型全局变量 extern_global,这里的 extern 的作用是扩展变量的作用域,即使 extern_globala.cc 中进行定义的,但是如果 a.ccb.cc 也可以用 extern_global 变量。接下来定义了一个非内联函数 read_extern_global(),里面会返回 extern_global 的值。在 main 函数中打印 x 的值。

这里需要注意的是,虽然 main 函数中没有直接调用 read_extern_global() 函数,但是是在编译时会优先初始化 read_extern_global() 的返回值,并计算出 x 的值等传给 main 函数进行调用。

接下来看b.cc,这里就是先定义了一个foo()函数,函数内直接返回42,定义extern_global变量为foo()函数的返回值。

那么接下来做两个编译尝试

1
2
clang++ a.cc b.cc -o a_b
clang++ b.cc a.cc -o b_a

可以看到在编译的时候调换了a.cc和b.cc的顺序,优先编译a.cc程序名a_b,优先编译b.cc程序名b_a。分别运行一些看看结果(在编译的时候编译器其实就已经警告了):

image-20251004160413174

问题的关键在于 a.cc 中的 x 和 b.cc 中的 extern_global 这两个全局变量,哪一个先被初始化。它们的初始化依赖于彼此。

  1. 第一种情况:clang++ a.cc b.cc -o a_b
    • 在这个链接顺序下,编译器可能会先处理 a.cc 中的全局变量初始化,然后再处理 b.cc 中的。
    • 初始化 x(在 a.cc 中):
      • 程序需要调用 read_extern_global()。
      • read_extern_global() 读取 extern_global 的值。
      • 因为 b.cc 中的 extern_global 此时尚未被初始化,所以它持有默认值 0。
      • 因此 read_extern_global() 返回 0。
      • x 被初始化为 0 + 1,所以 x 的值是 1。
    • 初始化 extern_global(在 b.cc 中):
      • 程序调用 foo(),返回 42。
      • extern_global 被初始化为 42。但这发生在 x 初始化之后,为时已晚。
    • 执行 main 函数:
      • printf 打印出 x 的当前值,也就是 1。
  2. 第二种情况:clang++ b.cc a.cc -o b_a
    • 在这个链接顺序下,编译器可能会先处理 b.cc 中的全局变量初始化。
    • 初始化 extern_global(在 b.cc 中):
      • 程序调用 foo(),返回 42。
      • extern_global 被初始化为 42。
    • 初始化 x(在 a.cc 中):
      • 程序需要调用 read_extern_global()。
      • read_extern_global() 读取 extern_global 的值。
      • 由于 extern_global 此时已经被初始化,它的值是 42。
      • 因此 read_extern_global() 返回 42。
      • x 被初始化为 42 + 1,所以 x 的值是 43。
    • 执行 main 函数:
      • printf 打印出 x 的当前值,也就是 43。

如何避免这类问题?

一种常见的解决办法是使用“Construct on First Use”模式,也就是将全局变量封装在一个函数中,并以静态局部变量的形式存在。这样可以保证它在第一次被访问时才进行初始化。

例如,可以将 b.cc 改写成:

1
2
3
4
5
6
int foo() { return 42; }

int& get_extern_global() {
static int extern_global = foo();
return extern_global;
}

在上述 demo 的基础上举出一个漏斗场景,如果两次编译出的程序,这行后的值一次为 8,一次为 -1。那么如果结果作为其他程序中的 read 函数的第三参数使用,那么实际上中由于 整型溢出导致函数接收到 read(0, buf, 0xffffffff),如果 buf 的存储空间没有那么大,就会造成 堆溢出或栈溢出

回到 ASan 方面,这里我们在编译 a.ccb.cc 的时候插上 ASan 检测参数,在执行的时候需要开启该漏洞检测参数。

1
2
clang++ -fsanitize=address -g a.cc b.cc -o demo
ASAN_OPTIONS=check_initialization_order=true ./demo

image-20251004161213044

日志中显示漏洞出现的位置0x5c45df3b29a0,此时pc、bp、sp分别是 0x5c45de9a765a 0x7ffd76cab800 0x7ffd76cab7f8。下方是函数调用栈以及漏洞变量的名称