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#
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();
这个宏即可。
此外,虽然通过 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
最后找到的 crash 用例其实数量挺多的,其中也确实有 CVE 所描述的 unterminated encoding value。
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 有关。
Vulnerability#
通过 xxd
命令可以 16 进制格式输出文件数据(据说 xxd 更强大的功能是支持将修改后的数据 dump 到文件里)。
- 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 进制的字符表示前加上等号,用三个字符来编码。
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;
}
在寻找到产生 crash 的用例之后,用 tmin 进行精简:
0000000000000000000000=
0000000000000000000000000000000000000000=
00000000=
000000
Vulnerability#
实际导致 crash 的问题很简单,在 mime7to8
函数中,buf 和 obuf 分别是读和写的缓冲区,该函数在写 obuf 时没有对指针位置进行校验,导致指向下一个写位置的 obp “出界”。
在 mime7to8
中,canary 就是因为 obp 出界,导致其内容被覆盖了。若是没有 canary,那么 obp 最终会移动到 buf 的范围内,导致本应读的数据受到 “污染”。
sendmail#1305#
仓库中提供了 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 了。。。
date#
由于不知道 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);
Vulnerability#
Savannah Git Hosting - gnulib.git/commit 给出的解释是环境变量 TZ 的长度超过了 ABBR_SIZE_MIN (119)
on x86_64,导致 extend_abbr
函数中会出现堆内存溢出的情况。
我用 GDB 跟踪了一下,确实是 extend_abbr
函数中,试图在 tzalloc
分配的 tz->abbrs
内存之后添加新的字符串,导致指针越界。
ntpq#
漏洞主要存在于 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);
从代码覆盖率的角度看, 4.2.2 版本下能够触发新路径的用例,覆盖了 cookedprint
函数,但是不能覆盖 4.2.8p10 版本下的代码(##### 代表没有覆盖)。
Summary#
AFL 及其配套工具确实将模糊测试实现成了几乎 “开箱即用” 的程度,要 fuzz 一个开源程序,只需要在编译时进行插桩,然后编写恰当的 harness 程序,剩下就可以完全 “撒手” 交给 AFL。
但是我在 fuzz 时也遇到了一些难题问题:
- 如何千方百计提高 fuzz 的效率。在用例的变异、选择、调度、执行过程中,还存在很大的效率优化空间。我在尝试 AFL-training 中的各个 challenge 时,虽然只有一个 challenge 在经过了十几个小时之后依然没有发现 crash(sendmail#1305),其他程序都能在较短的时间内发现或多或少的 crash,但是这是建立在目标函数和 crash 都较为简单的前提下的,因为漏洞的位置已知,所以可以把存在漏洞的函数单独拿出来编写 harness。在漏洞未知的实际情况下,需要进一步提高效率。
- 如何分析 fuzz 过程中发现的 crash。AFL-training 中大部分 CVE 都是和内存溢出(overflow)相关的。对溢出最直白的理解就是指针指向了超出其应该指向的区域,但是为何会产生 “越界” 的情况,需要结合程序自身的内存分配、访问模式进行分析。对于不熟悉的程序,需要借助 GDB 等工具单步调试。