我们知道,AFL++ 一般是针对目标程序的特定 argv
进行 fuzz。在此情况下,我们需要人工选择特定的命令行参数,以覆盖尽可能多的目标程序代码。然而,这样做有两个问题:
- 需要研究者比较了解目标程序,以选出最好的参数
- 如果不同的参数组合会导致不同的处理方式,则无论选取什么
argv
,都无法完整覆盖程序
因此,我们希望把 argv
也纳入 fuzz。也就是说,每次生成用例时,不仅是生成一个字节流让目标程序处理,还生成其命令行参数。这样,我们就可以覆盖各种各样的参数组合了。
AFL 框架并未支持「每次执行使用不同的命令行参数」。我们为了达成这个目的,必须去写 harness。一个最朴素的想法是:直接利用输入文件的前 $n$ 个字节,解析出参数列表,去覆盖 argv[]
。这样,我们无需修改程序中 getopt()
等逻辑,对目标程序的影响很小。但它有几个缺陷:
- 各个参数的分界难以确定。使用空格来分隔是不行的,因为完全可以通过
./prog -c "hello world"
来提供内部有空格的参数。 - 合法性验证。这种方法很可能产生对目标程序而言非法的命令行参数,所以我们需要在程序入口处理时排除掉这种参数。这会导致资源(包括处理耗时和每秒实验次数)的大量浪费。
所以,我们需要找到一种编码参数的方法,尽可能让所有输入串都是合法的,且与真实程序的参数一一对应。这样,我们不会浪费任何一次实验。很容易想到类似于网络协议的编码方式——譬如,TCP 的 flag 字段编码了几个 bool 选项。思路可以参考这篇文章:
接下来,我们对一个真实世界中的程序进行 fuzz。
DISCOUNT markdown 解析器
DISCOUNT 是 Debian Packages 中的一个程序,提供了 markdown 转 html 的功能。看一眼它的用法:
> ./markdown --help
usage: markdown [options] [file]
options:
-html5 -5 recognise html5 block elements
-base -b [url-base] URL prefix
-debug -d debugging
-version -V show version info
-E [flags] url flags
-F [bitmap] set/show hex flags
-f [{+-}flags] set/show named flags
-G github flavoured markdown
-n don't write generated html
-s [text] format `text`
-style -S output <style> blocks
-t [text] format `text` with mkd_line()
-toc -T output a TOC
-C [prefix] prefix for markdown extra footnotes
-o [file] write output to file
-squash -x squash toc labels to be more like github
-codefmt -X [command] use an external code formatter
-help -? print a detailed usage message
它不仅有几个 bool 型的开关,还有 -F/-f
参数用来指定 flag。这些 flag 包含「是否遵循 markdown 1.0 标准」、「是否解析图片」等选项,都是 bool 型。一共有 31 个 flag,能保存在 uint32 中。用户可以使用 -F
选项提供这个 flag 序列的十进制、十六进制值。于是,分析这所有的参数:
- bool 开关,包含
-5, -d, -V, -G, -n, -S, -T, -x, -?
- flag,是一个 uint32,用
-F
提供(放弃使用-f
选项,因为它们效果一样) - 字符串,包含
-b, -E, -s, -t, -C, -o, -X
首先来看最难处理的字符串。这里 -s, -t
两个选项的用途都是「直接通过命令行给出 markdown 字符串」,而我们 fuzz 过程要读 markdown 文件,所以直接放弃使用这两个选项。-o
是指定输出文件,我们不希望在 fuzz 过程中写入文件,故也放弃。而 -X
选项是指定一个外部程序以供调用,但我们只关心 DISCOUNT 本身的代码漏洞,所以这个选项我们也不使用。
于是,字符串类型的选项还剩下 -b, -E, -C
。粗看一眼,这几个选项似乎都只是在输出时起效,并不影响 parse 过程。因此,我们也把它们当作 bool 开关处理:例如,如果选择打开 -b
,那我们就使用 -b BASE
。这里使用固定的串而非变异出的串,固然存在降低覆盖度的风险;但如果我们相信这些串并未参与到最重要的 parse 逻辑中,则这个风险是可以接受的。
接下来,考虑 bool 开关。要把 -V, -?
排除掉,因为它们只是让程序输出 usage 等信息之后立即退出,不太可能有漏洞。所以现在只剩下这几个 bool 开关:-5, -d, -G, -n, -S, -T, -x
,连同我们按照 bool 方式处理的 -b, -E, -C
,一共是 10 个 bit。我们将它存储在程序的前 2 个字节中。至于 flag 这个 uint32 值,将其存放在第 3~6 字节。
因此,编码方案如图所示:
harness 实现
既然我们要把文件的前 6 个字节挪作他用,让目标程序从第 7 个字节开始处理,那就得防止发生 fseek()
,以免目标程序看到不该看到的东西。机缘巧合,DISCOUNT 的 parser 是逐字节从文件流中读取的,因此我们无需考虑这个问题。
来看原程序的 main
函数:
int main(int argc, char **argv) {
int rc;
int debug = 0;
int toc = 0;
int content = 1;
int version = 0;
int with_html5 = 0;
int styles = 0;
int use_mkd_line = 0;
int use_e_codefmt = 0;
int github_flavoured = 0;
int squash = 0;
char *extra_footnote_prefix = 0;
char *urlflags = 0;
char *text = 0;
char *ofile = 0;
char *urlbase = 0;
char *q;
MMIOT *doc;
struct h_context blob;
struct h_opt *opt;
mkd_flag_t *flags = mkd_flags();
if (!flags)
perror("new_flags");
hoptset(&blob, argc, argv);
hopterr(&blob, 1);
pgm = basename(argv[0]);
if (q = getenv("MARKDOWN_FLAGS"))
mkd_set_flag_bitmap(flags, strtol(q, 0, 0));
while (opt = gethopt(&blob, opts, NROPTS)) {
if (opt == HOPTERR) {
hoptusage(pgm, opts, NROPTS, "[file]");
exit(1);
}
switch (opt->optchar) {
case '5':
with_html5 = 1;
break;
case 'b':
urlbase = hoptarg(&blob);
break;
case 'd':
debug = 1;
break;
case 'V':
version++;
break;
case 'E':
urlflags = hoptarg(&blob);
break;
case 'f':
q = hoptarg(&blob);
if (strcmp(q, "?") == 0) {
show_flags(1, version, 0);
exit(0);
} else if (strcmp(q, "??") == 0) {
show_flags(1, version, flags);
exit(0);
} else if (q = mkd_set_flag_string(flags, hoptarg(&blob)))
complain("unknown option <%s>", q);
break;
case 'F':
q = hoptarg(&blob);
if (strcmp(q, "?") == 0) {
show_flags(0, 0, 0);
exit(0);
} else if (strcmp(q, "??") == 0) {
show_flags(0, version, flags);
exit(0);
} else
mkd_set_flag_bitmap(flags, strtol(q, 0, 0));
break;
case 'G':
github_flavoured = 1;
break;
case 'n':
content = 0;
break;
case 's':
text = hoptarg(&blob);
break;
case 'S':
styles = 1;
break;
case 't':
text = hoptarg(&blob);
use_mkd_line = 1;
break;
case 'T':
mkd_set_flag_num(flags, MKD_TOC);
toc = 1;
break;
case 'C':
extra_footnote_prefix = hoptarg(&blob);
break;
case 'o':
if (ofile) {
complain("Too many -o options");
exit(1);
}
if (!freopen(ofile = hoptarg(&blob), "w", stdout)) {
perror(ofile);
exit(1);
}
break;
case 'x':
squash = 1;
break;
case 'X':
use_e_codefmt = 1;
mkd_set_flag_num(flags, MKD_FENCEDCODE);
external_formatter = hoptarg(&blob);
fprintf(stderr, "selected external formatter (%s)\n", external_formatter);
break;
case '?':
hoptdescribe(pgm, opts, NROPTS, "[file]", 1);
return 0;
}
}
if (version) {
printf("%s: discount %s%s", pgm, markdown_version,
with_html5 ? " +html5" : "");
if (version == 2)
mkd_flags_are(stdout, flags, 0);
putchar('\n');
exit(0);
}
argc -= hoptind(&blob);
argv += hoptind(&blob);
if (with_html5)
mkd_with_html5_tags();
if (use_mkd_line)
rc = mkd_generateline(text, strlen(text), stdout, flags);
else {
if (text) {
doc = github_flavoured ? gfm_string(text, strlen(text), flags)
: mkd_string(text, strlen(text), flags);
if (!doc) {
perror(text);
exit(1);
}
} else {
if (argc && !freopen(argv[0], "r", stdin)) {
perror(argv[0]);
exit(1);
}
doc = github_flavoured ? gfm_in(stdin, flags) : mkd_in(stdin, flags);
if (!doc) {
perror(argc ? argv[0] : "stdin");
exit(1);
}
}
if (urlbase)
mkd_basename(doc, urlbase);
if (urlflags) {
mkd_e_data(doc, urlflags);
mkd_e_flags(doc, e_flags);
}
if (squash)
mkd_e_anchor(doc, (mkd_callback_t)anchor_format);
if (use_e_codefmt)
mkd_e_code_format(doc, (mkd_callback_t)external_codefmt);
if (use_e_codefmt || squash)
mkd_e_free(doc, free_it);
if (extra_footnote_prefix)
mkd_ref_prefix(doc, extra_footnote_prefix);
if (debug)
rc = mkd_dump(doc, stdout, flags, argc ? basename(argv[0]) : "stdin");
else {
rc = 1;
if (mkd_compile(doc, flags)) {
rc = 0;
if (styles)
mkd_generatecss(doc, stdout);
if (toc)
mkd_generatetoc(doc, stdout);
if (content)
mkd_generatehtml(doc, stdout);
}
}
mkd_cleanup(doc);
}
mkd_deallocate_tags();
mkd_free_flags(flags);
adump();
exit((rc == 0) ? 0 : errno);
}
我们要做两个改动:
- 原程序是把输入文件 freopen 到 stdin,再从 stdin 读取。我们要改成直接从文件中读取。
- 删去
argv
处理逻辑,用上文提到的方案确定各个参数。
修改完之后的版本如下:
int main(int argc, char **argv) {
int rc;
int debug = 0;
int toc = 0;
int content = 1;
int version = 0;
int with_html5 = 0;
int styles = 0;
int use_mkd_line = 0;
int use_e_codefmt = 0;
int github_flavoured = 0;
int squash = 0;
char *extra_footnote_prefix = 0;
char *urlflags = 0;
char *text = 0;
char *ofile = 0;
char *urlbase = 0;
char *q;
MMIOT *doc;
struct h_context blob;
struct h_opt *opt;
mkd_flag_t *flags = mkd_flags();
FILE *fp;
uint32_t our_flag;
uint16_t our_opt;
fp = fopen(argv[1], "r");
// 这个 fp 是我们自己打开的,需要自己关掉
hoptset(&blob, argc, argv);
hopterr(&blob, 1);
pgm = basename(argv[0]);
our_flag = 0;
fread((void *)(&our_flag), 4, 1, fp);
printf("flag = 0x%x\n", our_flag);
mkd_set_flag_bitmap(flags, our_flag);
// bool 类:
// with_html5, debug, github_flavoured, content, styles, toc, squash
our_opt = 0;
fread((void *)(&our_opt), 2, 1, fp);
printf("opt = 0x%x\n", our_opt);
if(our_opt & (1 << 0))
with_html5 = 1;
if(our_opt & (1 << 1))
debug = 1;
if(our_opt & (1 << 2))
github_flavoured = 1;
if(our_opt & (1 << 3))
content = 0;
if(our_opt & (1 << 4))
styles = 1;
if(our_opt & (1 << 5)) {
toc = 1;
mkd_set_flag_num(flags, MKD_TOC);
}
if(our_opt & (1 << 6))
squash = 1;
if(our_opt & (1 << 7))
urlbase = "BASE";
if(our_opt & (1 << 8))
urlflags = "urlf";
if(our_opt & (1 << 9))
extra_footnote_prefix = "foot";
if (with_html5)
mkd_with_html5_tags();
doc = github_flavoured ? gfm_in(fp, flags) : mkd_in(fp, flags);
if (!doc) {
abort();
exit(1);
}
if (urlbase)
mkd_basename(doc, urlbase);
if (urlflags) {
mkd_e_data(doc, urlflags);
mkd_e_flags(doc, e_flags);
}
if (squash)
mkd_e_anchor(doc, (mkd_callback_t)anchor_format);
if (use_e_codefmt)
mkd_e_code_format(doc, (mkd_callback_t)external_codefmt);
if (use_e_codefmt || squash)
mkd_e_free(doc, free_it);
if (extra_footnote_prefix)
mkd_ref_prefix(doc, extra_footnote_prefix);
if (debug)
rc = mkd_dump(doc, stdout, flags, "stdin");
else {
rc = 1;
if (mkd_compile(doc, flags)) {
rc = 0;
if (styles)
mkd_generatecss(doc, stdout);
if (toc)
mkd_generatetoc(doc, stdout);
if (content)
mkd_generatehtml(doc, stdout);
}
}
mkd_cleanup(doc);
fclose(fp);
mkd_deallocate_tags();
mkd_free_flags(flags);
adump();
exit((rc == 0) ? 0 : errno);
}
这个 harness 写得很粗糙,甚至没有使用 persistant mode。不过至少够用了。接下来开始 fuzz。
fuzz
几秒钟之后就爆出了第一个 crash。一分多钟后的情况:
这里面包含两条 crash 路径。继续 fuzz 一晚上,以找到更多的 crash 路径。次日,一共收到 17248 个 crash 用例,有 4 条本质不同的 crash 路径。
复现
这些用例只是让我们的 harness 程序 crash 了,还得在原程序中复现。因此,写个脚本用来还原 argv:
import os
from sys import argv
a = open(argv[1], 'rb').read()
flag = a[:4]
opt = a[4:6]
a = a[6:]
opt = int.from_bytes(opt, 'little')
cmd = []
if opt & (1<<0):
cmd.append('-5')
if opt & (1<<1):
cmd.append('-d')
if opt & (1<<2):
cmd.append('-G')
if opt & (1<<3):
cmd.append('-n')
if opt & (1<<4):
cmd.append('-S')
if opt & (1<<5):
cmd.append('-T')
if opt & (1<<6):
cmd.append('-x')
if opt & (1<<7):
cmd.append('-b BASE')
if opt & (1<<8):
cmd.append('-E urlf')
if opt & (1<<9):
cmd.append('-C foot')
with open(argv[2], 'wb') as f:
f.write(a)
print(f'../markdown {" ".join(cmd)} -F {hex(int.from_bytes(flag, "little"))} {argv[2]}')
现在,我们拿到了让原程序崩溃的用例。这些 bug 分别是:
- issue #276:如果用
-x -E
运行程序,它会尝试free
一个位于argv
中的字符串。 - issue #277:如果用
-T
运行程序,且输入一大串#
,则程序会在某处调用strcpy()
越界写入,导致堆溢出。 - issue #278:如果用
-d
运行程序,且输入空串,则程序因 null pointer dereference 而崩溃。 - issue #279:如果用
-F 0x03000000
运行程序,且输入一个特殊的字符串,则程序会越界读取。
可见,这些漏洞都是在特定的命令行参数下才会触发。我们 fuzz argv 的方案显然十分有效。