如何 fuzz argv:以 DISCOUNT 为例

  我们知道,AFL++ 一般是针对目标程序的特定 argv 进行 fuzz。在此情况下,我们需要人工选择特定的命令行参数,以覆盖尽可能多的目标程序代码。然而,这样做有两个问题:

  • 需要研究者比较了解目标程序,以选出最好的参数
  • 如果不同的参数组合会导致不同的处理方式,则无论选取什么 argv,都无法完整覆盖程序

  因此,我们希望把 argv 也纳入 fuzz。也就是说,每次生成用例时,不仅是生成一个字节流让目标程序处理,还生成其命令行参数。这样,我们就可以覆盖各种各样的参数组合了。

  AFL 框架并未支持「每次执行使用不同的命令行参数」。我们为了达成这个目的,必须去写 harness。一个最朴素的想法是:直接利用输入文件的前 $n$ 个字节,解析出参数列表,去覆盖 argv[]。这样,我们无需修改程序中 getopt() 等逻辑,对目标程序的影响很小。但它有几个缺陷:

  • 各个参数的分界难以确定。使用空格来分隔是不行的,因为完全可以通过 ./prog -c "hello world" 来提供内部有空格的参数。
  • 合法性验证。这种方法很可能产生对目标程序而言非法的命令行参数,所以我们需要在程序入口处理时排除掉这种参数。这会导致资源(包括处理耗时和每秒实验次数)的大量浪费。

  所以,我们需要找到一种编码参数的方法,尽可能让所有输入串都是合法的,且与真实程序的参数一一对应。这样,我们不会浪费任何一次实验。很容易想到类似于网络协议的编码方式——譬如,TCP 的 flag 字段编码了几个 bool 选项。思路可以参考这篇文章:

Fuzzing software: common challenges and potential solutions (Part 1)
This is the first part of a two-part series about common challenges you usually face in your fuzzing work.

  接下来,我们对一个真实世界中的程序进行 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 字节。

💡
我们用两个字节存放 10 个 bit,浪费了 6 个 bit 的空间。但这并不会浪费程序执行次数,因为所有的输入都是有效的,只不过「文件的前 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 的方案显然十分有效。