KendyZ

KendyZ

A good idealistic young man
twitter
github
bilibili

AFL-training 笔记

mykter/afl-training 包含能够帮助初学者快速掌握如何使用 afl++ 对开源软件进行模糊测试的一系列材料。

Setup#

考虑到将构建好的 ghcr.io/mykter/fuzz-training 直接 pull 下来所花的时间可能会更长,我选择在本地通过 Dockerfile 从头开始 build image,然后运行容器。

  • build:可以在 Dockerfile 中添加 ARG http_proxy,然后在 docker build 命令中添加 --build-arg "http_proxy=http://127.0.0.1:7890",同时配置参数 --network=host(和 docker run --net=host 不一样!),使得 build 过程能够连接网络代理。在 Dockerfile 中合适的位置添加 git 代理(git config --global http-proxy $http_proxy),加快 git pull 的速度。
  • run:由于访问外网和科学上网的代理程序都运行在 host 上,而我的容器在运行时始终无法连通 host 的 ip(应该是 172.27.0.1)和代理端口,于是只能选择让容器运行在 host 模式下(--net=host),免去考虑端口映射、host ip 等麻烦。

libxml2#

libxml2 是一个流行的 XML 处理库,使用 C 语言实现,其具有的以下特点使其非常适合用于练习 fuzz:

  • 无状态
  • 无网络通信和文件读写
  • 文档详细丰富,API 外显,无需额外分析内部的 fuzz 接口
  • 功能集中、简单(处理 XML),运行较快

这里考虑的 CVE(Common Vulnerabilities & Exposures)是 CVE-2015-8317:在 parser.c 中的 xmlParseXMLDecl 函数在处理 不完整的 encoding 以及 不完整的 XML 声明 时会触发 out-of-bounds heap read

/**
 * xmlParseXMLDecl:
 * @ctxt:  an XML parser context
 *
 * parse an XML declaration header
 *
 * [23] XMLDecl ::= '<?xml' VersionInfo EncodingDecl? SDDecl? S? '?>'
 */
void xmlParseXMLDecl(xmlParserCtxtPtr ctxt) { ... }

在 parser.c 中 xmlParseXMLDecl 函数就是负责解析 XML 声明部分的,XML 声明指的就是一般放在 XML 文档第一行的内容:

<?xml version="1.0" encoding="UTF-8"?>

思路#

虽说问题出在这个函数功能内部,但是倒也没必要仅测试该函数:

  • 我自我感觉,由不完整的字符串取值导致的 XML 声明解析操作的薄弱性,在 fuzz 过程中应该是不难发现的,生成仅包含声明的 XML 文档和完整的 XML 文档,分别进行 fuzz 的效率的差异应该不会过大。
  • xmlParseXMLDecl 函数的参数是 XML 解析过程的 context(xmlParserCtxtPtr),直接生成和变异此 context 是一件非常麻烦且低效的事情,此 context 应由更高级的 API 生成。

基于这两点考虑,我找到了和 xmlParserCtxtPtr 相关的示例程序,作为 fuzz 的 harness:

#include <stdio.h>
#include <libxml/parser.h>
#include <libxml/tree.h>

/**
 * exampleFunc:
 * @filename: a filename or an URL
 *
 * Parse and validate the resource and free the resulting tree
 */
static void exampleFunc(const char *filename) {
    xmlParserCtxtPtr ctxt; /* the parser context */
    xmlDocPtr doc; /* the resulting document tree */
    /* create a parser context */
    ctxt = xmlNewParserCtxt();
    if (ctxt == NULL) {
        fprintf(stderr, "Failed to allocate parser context\n");
    return;
    }
    /* parse the file, activating the DTD validation option */
    doc = xmlCtxtReadFile(ctxt, filename, NULL, XML_PARSE_DTDVALID);
    /* check if parsing succeeded */
    if (doc == NULL) {
        fprintf(stderr, "Failed to parse %s\n", filename);
    } else {
    /* check if validation succeeded */
        if (ctxt->valid == 0)
        fprintf(stderr, "Failed to validate %s\n", filename);
    /* free up the resulting document */
    xmlFreeDoc(doc);
    }
    /* free up the parser context */
    xmlFreeParserCtxt(ctxt);
}

int main(int argc, char **argv) {
    if (argc != 2)
        return(1);
    /*
     * this initialize the library and check potential ABI mismatches
     * between the version it was compiled for and the actual shared
     * library used.
     */
    LIBXML_TEST_VERSION
    exampleFunc(argv[1]);
    /*
     * Cleanup function for the XML library.
     */
    xmlCleanupParser();
    /*
     * this is to debug memory for regression tests
     */
    xmlMemoryDump();
    return(0);
}

程序逻辑极为简单,先从命令行参数中读取一个 filename,构造一个全新的 xmlParserCtxtPtr 实例,然后从 filename 中去文本并解析,解析的过程由 xmlParserCtxtPtr 实例追踪。

接着编译 libxml2 的库和 harness 可执行程序:

cd libxml2
CC=afl-clang-fast ./autogen.sh
AFL_USE_ASAN=1 make -j 4
cd ..
AFL_USE_ASAN=1 afl-clang-fast ./harness.c -I libxml2/include libxml2/.libs/libxml2.a -lz -lm -o fuzzer

然后开始 fuzz:

AFL_SKIP_CPUFREQ=1 afl-fuzz -i inputs/ -o outputs/ ./fuzzer @@

Fuzz result#

image.png

image.png

Persistent Mode#

参考答案中给出的 harness 使用了 AFL-LLVM 的 Persistent Mode

在最基础的 AFL 中,fuzzer 主程序会 fork 一个子进程作为 fork_server。fuzz 过程不可避免多次执行目标(target)程序,为了避免多次 exec 装载目标程序带来的系统开销,AFL 让 fork_server 执行 fork 命令来产生新的 target

为了进一步降低 fork 带来的开销,persistent mode 允许 harness 代码中显式地循环调用被测 API,即在 harness 的 main 函数中以这样的形式进行:

while (__AFL_LOOP(1000)) {
	/* Setup function call, e.g. struct target *tmp = libtarget_init() */
    /* Call function to be fuzzed, e.g.: */
    target_function(buf, len);
    /* Reset state. e.g. libtarget_free(tmp) */
}

编译的 XML 文档由内存中产生,完全可以省去写入文件的操作,直接输入到 fuzzer 中。在 AFL 中,测试用例经过文件重定向到 STDIN,然后由 target 程序读取。AFL++ 提供了共享内存通道,实现 fuzzer 和 target 之间的测试用例传递。要利用这一特性,只需要声明 __AFL_FUZZ_INIT(); 这个宏即可。

image

此外,虽然通过 persistent mode 能够避免每次都执行 target 的初始化代码,但是这些初始化代码依然会在 fork_server fork target 进程时被执行。为了复用程序运行了这些初始化代码之后的状态,AFL++ 的方法是将 fork server 的初始化(从原本的程序开头)延迟到用户指定位置。

/* This one can be called from user code when deferred forkserver mode
    is enabled. */
void __afl_manual_init(void) {
  static u8 init_done;
  if (...) {...}
  if (!init_done) {
    __afl_start_forkserver();
    init_done = 1;
  }
}

结合上述三个特性:persistent mode,shared memory fuzzing 和 deferred initialization,最终组成了 libxml2 的 harness。其中 __AFL_FUZZ_TESTCASE_BUF__AFL_FUZZ_TESTCASE_LEN 这两个宏分别表示共享内存地址和用例所占内存大小。

#include "libxml/parser.h"
#include "libxml/tree.h"
#include <unistd.h>
__AFL_FUZZ_INIT();
int main(int argc, char **argv) {
    #ifdef __AFL_HAVE_MANUAL_CONTROL
        __AFL_INIT();
    #endif
    unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;  // must be after __AFL_INIT
    xmlInitParser();
    while (__AFL_LOOP(1000)) {
        int len = __AFL_FUZZ_TESTCASE_LEN;
        xmlDocPtr doc = xmlReadMemory((char *)buf, len, "https://mykter.com", NULL, 0);
        if (doc != NULL) {
            xmlFreeDoc(doc);
        }
    }
    xmlCleanupParser();
    return(0);
}

借助 AFL 自带的 XML dictionary ,在获知一定语法的情况下进行输入文档的变异,能够进一步提升效率:

AFL_SKIP_CPUFREQ=1 afl-fuzz -i inputs/ -o outputs/ -x ~/AFLplusplus/dictionaries/xml.dict ./fuzzer

image

image.png

最后找到的 crash 用例其实数量挺多的,其中也确实有 CVE 所描述的 unterminated encoding value

image.png

Heartbleed#

CVE-2014-0160是曾经非常著名的 “心脏滴血”(heartbleed)漏洞,这个漏洞存在于 OpenSSL 1.0.1g 之前的 1.0.1 版本中。

为了加快 fuzz 进度,我在改造 handshake.cc 的过程中融入了 persistent mode、shared memory 和 deferred initialization。

#include <openssl/ssl.h>
#include <openssl/err.h>
#include <assert.h>
#include <stdint.h>
#include <stddef.h>
#include <unistd.h>
#ifndef CERT_PATH
	# define CERT_PATH
#endif

__AFL_FUZZ_INIT();

SSL_CTX *Init() {
  SSL_library_init();
  SSL_load_error_strings();
  ERR_load_BIO_strings();
  OpenSSL_add_all_algorithms();
  SSL_CTX *sctx;
  assert (sctx = SSL_CTX_new(TLSv1_method()));
  /* These two file were created with this command:
      openssl req -x509 -newkey rsa:512 -keyout server.key \
     -out server.pem -days 9999 -nodes -subj /CN=a/
  */
  assert(SSL_CTX_use_certificate_file(sctx, "server.pem",
                                      SSL_FILETYPE_PEM));
  assert(SSL_CTX_use_PrivateKey_file(sctx, "server.key",
                                     SSL_FILETYPE_PEM));
  return sctx;
}

int main() {
  SSL_CTX *sctx = Init();
  #ifdef __AFL_HAVE_MANUAL_CONTROL
    __AFL_INIT();
  #endif
  unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;
  while (__AFL_LOOP(1000))
  {
    SSL *server = SSL_new(sctx);
    BIO *sinbio = BIO_new(BIO_s_mem());
    BIO *soutbio = BIO_new(BIO_s_mem());
    SSL_set_bio(server, sinbio, soutbio);
    SSL_set_accept_state(server);
    /* TODO: To spoof one end of the handshake, we need to write data to sinbio
    * here */
    int len = __AFL_FUZZ_TESTCASE_LEN;
    BIO_write(sinbio, buf, len);
    SSL_do_handshake(server);
    SSL_free(server);
  }
  SSL_CTX_free(sctx);
  return 0;
}

fuzz 的结果如图所示,怀疑 stability 较低可能于复用 SSL Context 有关。

image.png

image.png

Vulnerability#

通过 xxd 命令可以 16 进制格式输出文件数据(据说 xxd 更强大的功能是支持将修改后的数据 dump 到文件里)。

image.png

image.png

  • byte 0:SSL 数据包类型,这里 18 对应 TLS1_RT_HEARTBEAT。
  • byte 1-2:SSL 版本,这里 0301 代表 TLS1.0,0302 代表 1.1,0303 代表 1.2,这里的 03f5 因该是 fuzzer 自动生成的版本号,可能没有实际的版本与之对应,但是 TLS server 由于一些 fallback 的逻辑使得其依然能够接受这样的版本号。
  • byte 3-4:TLS 完整数据包(包括头部)的长度,这里的 0005 应该也是 fuzzer 生成的,不合理,但是该漏洞的核心问题不是出在这里。
  • byte 5:TLS heartbeat 类型,1 对应 request,2 对应 response。这里是从客户端发送给服务端的,所以是 1。
  • byte 6:heartbeat payload 长度。
  • 其他:heart payload 数据,数据长度要符合 byte 6。

按照正常的处理逻辑,server 会回显(echo)request 的 payload length 和 data,简化后的代码逻辑为:

int
tls1_process_heartbeat(SSL *s) {
	unsigned char *p = &s->s3->rrec.data[0], *pl;
	/* Read type and payload length first */
	hbtype = *p++;
	n2s(p, payload);
	pl = p;
	if (hbtype == TLS1_HB_REQUEST) {
		/* Allocate memory for the response, size is 1 bytes
		 * message type, plus 2 bytes payload length, plus
		 * payload, plus padding
		 */
		buffer = OPENSSL_malloc(1 + 2 + payload + padding);
		bp = buffer;
		/* Enter response type, length and copy payload */
		*bp++ = TLS1_HB_RESPONSE;
		s2n(payload, bp);
		memcpy(bp, pl, payload);
	}
}

如果攻击者给定一个较大的 payload length,而没有给出足够的 payload data,那么 server 会根据 payload length 来开辟 response 的缓冲区,并且从 payload data 缓冲区拷贝 payload length 长度的数据到 response 缓冲区,结果就是 payload length 紧接着之后的内存中的数据会泄露到 response 中,发送给攻击者。

sendmail#1301#

需要测试的漏洞是 CVE-1999-0206。代码仓库中已经给出了 main.c 作为 harness 代码,测试的目标函数为 mime7to8。这个函数的作用是将 base64 编码或者 quoted-printable 编码的文本解析成实际数据。

  • base64:用 64 个字符(6bit)来表达二进制数据,也就是 3 byte 的数据需要用 4 个字符来编码。
  • quoted-printable:对于不可打印的 ASCII 字符,将其 16 进制的字符表示前加上等号,用三个字符来编码。

image

AFL-training 推荐在 fuzz 时使用 persistent mode 和 multicore 并行。由于 ENVELOPE 结构中貌似没有能够直接存放字符串的数据成员,而是必须要指定一个 temporary filename,因此这里应该是不能用 shared memory 直接传递测试用例的。

除了 shared memory,deferred initialization 似乎也无法起到太大作用,因为初始化部分代码看起来挺简单的,用不了多少时间,所以我只使用了 __AFL_LOOP

#include "my-sendmail.h"
#include <assert.h>

int main(int argc, char **argv)
{
     HDR *header;
     register ENVELOPE *e;
     FILE *temp;
     while (__AFL_LOOP(1000))
     {
          temp = fopen(argv[1], "r");
          assert(temp != NULL);
          header = (HDR *)malloc(sizeof(struct header));
          header->h_field = "Content-Transfer-Encoding";
          header->h_value = "quoted-printable";
          header->h_link = NULL;
          header->h_flags = 0;

          e = (ENVELOPE *)malloc(sizeof(struct envelope));
          e->e_id = "First Entry";
          e->e_dfp = temp;

          mime7to8(header, e);
          
          fclose(temp);
          free(e);
          free(header);
     }
     return 0;
}

image.png

在寻找到产生 crash 的用例之后,用 tmin 进行精简:

0000000000000000000000=
0000000000000000000000000000000000000000=
00000000=
000000

Vulnerability#

image.png

实际导致 crash 的问题很简单,在 mime7to8 函数中,buf 和 obuf 分别是读和写的缓冲区,该函数在写 obuf 时没有对指针位置进行校验,导致指向下一个写位置的 obp “出界”。

mime7to8 中,canary 就是因为 obp 出界,导致其内容被覆盖了。若是没有 canary,那么 obp 最终会移动到 buf 的范围内,导致本应读的数据受到 “污染”。

image.png

sendmail#1305#

CVE-2003-0161

仓库中提供了 harness,我借用 persistent mode 将其改写。

// ...
__AFL_FUZZ_INIT();

int main(){
    const int MAX_MESSAGE_SIZE = 1000;
    char special_char = '\377';  /* same char as 0xff.  this char will get interpreted as NOCHAR */
    int delim = '\0';
    static char **delimptr = NULL;
    char *addr;
    OperatorChars = NULL;
    ConfigLevel = 5;
    addr = (char *) malloc(sizeof(char) * MAX_MESSAGE_SIZE);
    CurEnv = (ENVELOPE *) malloc(sizeof(struct envelope));
    CurEnv->e_to = (char *) malloc(MAX_MESSAGE_SIZE * sizeof(char) + 1);
    #ifdef __AFL_HAVE_MANUAL_CONTROL
        __AFL_INIT();
    #endif
    unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;  // must be after __AFL_INIT
    while (__AFL_LOOP(1000)) {
        int len = __AFL_FUZZ_TESTCASE_LEN;
        strncpy(addr, buf, len);
        addr[len] = '\0';
        memcpy(CurEnv->e_to, addr, len);
        CurEnv->e_to[len] = '\0';
        parseaddr(addr, delim, delimptr);
    }
	return 0;
}

在 ANSWERS.md 中,作者提示 harness 的代码中用到了一些全局变量,这些变量可能会成为程序的状态,对 persistent mode 产生影响,但是根据我的观察,只有 CurEnv 是起实际用途且与程序上下文密切相关的变量,而且 parseaddr 函数也仅包含对该变量唯一一个字段 e_to 的读操作,所以我自己改写的 persistent mode 应该是可行的。

然而这次貌似找不到 crash testcase 了。。。

image.png

date#

CVE-2017-7476

由于不知道 date.c 的 main 函数是否存在全局变量,因此我不敢随意用 __AFL_LOOP 改写,考虑到后续调试时用 STDIN 也比较方便,且用 STDIN 传入用例和 shared memory 传入用例的效率差距不大(影响效率的大头在 fork 上面),所以按照 ANSWERS.md 将 date.c 改写为了 harness,在 getenv 之前添加了设置 env 的代码。

static char val[1024 * 16];
read(0, val, sizeof(val) - 1);
setenv("TZ", val, 1);

char const *tzstring = getenv ("TZ");
timezone_t tz = tzalloc (tzstring);

image.png

Vulnerability#

Savannah Git Hosting - gnulib.git/commit 给出的解释是环境变量 TZ 的长度超过了 ABBR_SIZE_MIN (119) on x86_64,导致 extend_abbr 函数中会出现堆内存溢出的情况。

image.png

我用 GDB 跟踪了一下,确实是 extend_abbr 函数中,试图在 tzalloc 分配的 tz->abbrs 内存之后添加新的字符串,导致指针越界。

ntpq#

CVE-2009-0159

漏洞主要存在于 cookedprint(datatype, length, data, status, stdout) 函数中。对于类似的 C/S 模式的程序,我原以为需要模拟正常程序交互,先启动 ntpd 之后再在 ntpq 端进行 fuzz,考虑 cookedprint 的程序逻辑。结果 ANSWERS.md 中直接对参数进行了语义无关的 fuzz,即变异一个大的字符串作为 STDIN,然后拆分成各个参数。

datatype=0;
status=0;
memset(data,0,1024*16);
read(0, &datatype, 1);
read(0, &status, 1);
length = read(0, data, 1024 * 16);
cookedprint(datatype, length, data, status, stdout);

image.png

从代码覆盖率的角度看, 4.2.2 版本下能够触发新路径的用例,覆盖了 cookedprint 函数,但是不能覆盖 4.2.8p10 版本下的代码(##### 代表没有覆盖)。

image.png

Summary#

AFL 及其配套工具确实将模糊测试实现成了几乎 “开箱即用” 的程度,要 fuzz 一个开源程序,只需要在编译时进行插桩,然后编写恰当的 harness 程序,剩下就可以完全 “撒手” 交给 AFL。

但是我在 fuzz 时也遇到了一些难题问题:

  • 如何千方百计提高 fuzz 的效率。在用例的变异、选择、调度、执行过程中,还存在很大的效率优化空间。我在尝试 AFL-training 中的各个 challenge 时,虽然只有一个 challenge 在经过了十几个小时之后依然没有发现 crash(sendmail#1305),其他程序都能在较短的时间内发现或多或少的 crash,但是这是建立在目标函数和 crash 都较为简单的前提下的,因为漏洞的位置已知,所以可以把存在漏洞的函数单独拿出来编写 harness。在漏洞未知的实际情况下,需要进一步提高效率。
  • 如何分析 fuzz 过程中发现的 crash。AFL-training 中大部分 CVE 都是和内存溢出(overflow)相关的。对溢出最直白的理解就是指针指向了超出其应该指向的区域,但是为何会产生 “越界” 的情况,需要结合程序自身的内存分配、访问模式进行分析。对于不熟悉的程序,需要借助 GDB 等工具单步调试。
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.