AFL Training Challenge 1 - libxml2

学一下 fuzzing. 暂时不是很想啃基础知识, 先来学学使用 AFL++. 找到个 afl-traning, 感觉挺新手友好的, 决定做它的 challenges. 给自己定的目标不是找到漏洞就完事, 而是尽量去分析甚至利用.

有个非常好用的工具 afl-utils, 集成了一些 fuzz 功能. 安装时需要切换到 experimental 分支.

由于我装的 docker afl++, 其中 python 是 3.10, 直接编译 libxml2 会有问题, 需要打一下 这个 patch

首先用 afl-clang-fast 去编译, 设置一下编译器:

1
2
export CC=afl-clang-fast
export CXX=afl-clang-fast++

然后使用 libxml2 的 ./autogen.sh 生成 Makefile, 最后 make 编译. 编译时可以使用 ASAN 和 UBSAN, 去对地址和未定义行为监测, 使得之后的测试更快触发漏洞而崩溃.

这里直接用 AFL_USE_ASANAFL_USE_UBSAN 环境变量, 即可向编译选项中添加 -fsanitize

1
2
3
4
export AFL_USE_ASAN=1
export AFL_USE_UBSAN=1
./autogen.sh
make -j16

编译完后库文件在 .libs 下, 头文件在 include 中.

libxml2 是用来解析 xml 的库, fuzz 的目标也是解析 xml 时的漏洞. 所以可以编写一个调用解析的 harness. 参考 libxml2 的 example, 比如 parse1.cparse3.c.

很容易写出下面的 harness:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include "libxml/parser.h"
#include "libxml/tree.h"

int main(int argc, char *argv[]) {
    if (argc != 2)
        return 0;
    xmlDocPtr doc = xmlReadFile(argv[1], NULL, 0);
    if (doc != NULL) {
        xmlFreeDoc(doc);
    }
    xmlCleanupParser();
    return 0;
}

编译然后开始 fuzzing:

1
2
3
4
afl-clang-fast ./harness.c -I libxml2/include libxml2/.libs/libxml2.a -lz -lm -o fuzzer
mkdir ./in
echo "<Wings>Hello World</Wings>" > in/a
afl-fuzz -i ./in -o ./out -- ./fuzzer @@

./in 中存放 seed 文件, 这里直接随便写一个符合格式的 xml. @@ 会被替换成 seed 文件, 即 fuzzer 的参数是 seed 文件路径.

可以多核跑, afl-fuzz 加参数 -M name 指定主程序, -S name 指定从程序.

(然后开了四个线程跑了一个小时才跑出二十几个 crash)

有没有什么方法加速 fuzzing 呢? 答案是肯定的.

引用
All professional fuzzing uses this mode.

afl-clang 有一个叫 persistent 的模式, 阅读一下 官方的介绍, 大概可以知道是用一些小技巧来减少启动的开销, 从而缩短时间, 更专注于 fuzzing. 具体内容看文档即可, 不再赘述.

使用 persistent mode 编写 harness:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <unistd.h>
#include <libxml/parser.h>
#include <libxml/tree.h>

__AFL_FUZZ_INIT();

int main() {
#ifdef __AFL_HAVE_MANUAL_CONTROL
    __AFL_INIT();
#endif
    unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;
    while (__AFL_LOOP(1000)) {
        int len = __AFL_FUZZ_TESTCASE_LEN;
        xmlDocPtr doc = xmlReadMemory(buf, len, "noname.xml", NULL, 0);
        if (doc != NULL)
            xmlFreeDoc(doc);
    }
    xmlCleanupParser();
    return 0;
}

但是对于 xml 解析器来说, 只有一些有意义的 xml 字段才会被解析, 而无意义的数据只会让程序结束. 在 fuzzing 时, 可以让 AFL 使用预设的字典, 从而生成更加结构化, 有意义的数据. ALF 仓库中就包含了一系列的字典, 在 ./dictionaries 文件夹下, 可以找到 xml.dict.

afl-fuzz 用 -x path/to/dict 参数来指定字典.

输入命令启动 fuzz:

1
afl-fuzz -i in -o out -x /AFLplusplus/dictionaries/xml.dict ./fuzzer

afl-utils 中的 afl-multicore 可以方便地使用多核. 对着文档写一个配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "fuzzer": "afl-fuzz",
  "input": "./in",
  "output": "./out",
  "target": "./fuzzer",
  "cmdline": "",
  "dict": "/AFLplusplus/dictionaries/xml.dict",
  "session": "SESSION",
  "interactive": true,
  "dirty": true,
  "environment": []
}

然后在 screen 中启动 (就是想看 afl 的 ui):

1
afl-multicore -c ./libxml2-multicore.conf -s 1 start 8

-s 参数指定启动 afl-fuzz 的延迟, 如果不设置, 有些进程可能会启动失败 (不知道为什么).

下面是开八线程跑的结果:

libxml2 AFL UI
libxml2 AFL UI

用 persistent mode + 字典, 几分钟就能跑出第一个 crash

至少等跑了 1 个 cycle 再停止.

afl-utils 中的 afl-collect 可以收集多线程运行的结果, 并用 gdb 插件 exploitable 分类 crash.

1
UBSAN_OPTIONS="abort_on_error=1:symbolize=0" ASAN_OPTIONS="abort_on_error=1:symbolize=0" afl-collect -d crashes.db -e gdb_script -r -rr ./out ./collection -j 16 -- ./fuzzer

由于我们使用 ASAN 和 UBSAN 来编译的程序, 有些地址越界什么的会被 san 返回 0, 从而被认为是没有 crash, 这里让 abort 返回非零值.

输出如下,

afl-collect
afl-collect

使用 afl-cmin 最小化输入数量, 有些输入能够覆盖的路径是一样的, 只需要保存一个就行. 可以减少样本.

使用 afl-tmin 最小化输入大小. 有些输入是冗余的, 删除部分也可以造成 crash, 所以可以将输入精简, 以便后续分析.

事实上这一步在建立语料库时也可以用. 假如在 fuzzing 前从某个地方找来了一些输入, 可以使用 afl-cmin 和 afl-tmin 缩减语料库, 以提高 fuzzing 的效率.

这里针对 collect 后的 crash 输入进行 afl-cmin 以及 afl-tmin, 可以将 300 多个输入减少到 77 个. 同时还发现有些输入是重复的, 再进行一次 afl-cmin, 就只有 20 个了.

1
afl-cmin -i in -o out -- ./fuzzer

afl-tmin 只能对一个输入文件进行, 所以写个脚本多线程 + 批量执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#!/bin/bash

cores=$1
inputdir=$2
outputdir=$3
target=$4
pids=""
total=`ls $inputdir | wc -l`

for k in `seq 1 $cores $total`
do
  for i in `seq 0 $(expr $cores - 1)`
  do
    file=`ls -Sr $inputdir | sed $(expr $i + $k)"q;d"`
    echo $file
    afl-tmin -i $inputdir/$file -o $outputdir/$file -- $target &
  done

  wait
done

这个脚本没有考虑 harness 传参的情况.

1
afl-qtmin 8 ./in ./out ./fuzzer

(其实上面 20 个也完全能分析, 这里不过是继续体验一下 AFL 的其他功能)

afl 自身也有一个寻找可利用的 crash 输入的方法, 称为 crash exploration mode. 它可以将现有 crash 作为输入, 然后快速枚举程序中可以到达的所有代码路径, 找出不同路径的 crash. 使用方法也很简单, 只需要加 -C 参数, 然后把输入设置为 crashes 目录即可.

libxml2 AFL UI crash exploration
libxml2 AFL UI crash exploration

他能很快找出路径不同的 crash. 只不过执行速度比较慢, 普遍在 50/sec 以下, 还经常 zzzz. 跑到 10min 就都 zzzz 了…

15min 左右 master 跑了一个 cycle, 给他停了不跑了. 看了眼输入感觉长得和之前的也差不多qaq

将上述文件整合起来, 再跑一下 cmin 和 tmin, 一共得到 29 个 crashes.

总览一下数据, 一共可以分为三种.

第一种是 <?xml 开头, 加一个空白字符, 然后 encoding=" 再跟几个字母 (某种 encoding), 最后跟一个不可见字符, 比如

1
2
3
00000000  3c 3f 78 6d 6c 0a 65 6e  63 6f 64 69 6e 67 3d 22  |<?xml.encoding="|
00000010  55 73 b8                                          |Us.|
00000013

第二种是 <?xml 前面还有几个字符, 至少有一个不可见字符, 然后之后的都是可见字符, 比如

1
2
3
4
00000000  ff fe 30 30 30 30 30 30  30 d9 3c 3f 78 6d 6c 20  |..0000000.<?xml |
00000010  20 20 30 30 30 30 30 30  30 30 30 30 30 30 30 30  |  00000000000000|
00000020  30 30 30 30 30                                    |00000|
00000025

ASAN 检测到和上面的一样的地方堆溢出.

第三种是带有 ":" 的内容, 比如:

1
2
3
4
5
6
7
8
00000000  3c 50 30 30 30 30 30 30  30 30 30 30 30 30 30 30  |<P00000000000000|
00000010  30 30 30 30 30 30 30 30  30 30 30 30 30 30 30 30  |0000000000000000|
*
00000250  30 30 30 30 30 30 30 30  30 30 30 30 3a 50 30 30  |000000000000:P00|
00000260  30 30 30 30 30 30 30 30  30 30 30 30 30 30 30 30  |0000000000000000|
*
000003b0  30 30 30 30 30 30 30 30  30 30                    |0000000000|
000003ba

第一类输入找到的就是这个 training 的目标, CVE-2015-8317. 所以先来分析这个. ASAN 检测如下:

libxml2 heap overflow
libxml2 heap overflow

根据 backtrace, 找到越界的代码 parse.c:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
void
xmlParseXMLDecl(xmlParserCtxtPtr ctxt) {
    // ...
    if ((RAW == '?') && (NXT(1) == '>')) {
        SKIP(2);
    } else if (RAW == '>') {
        /* Deprecated old WD ... */
    xmlFatalErr(ctxt, XML_ERR_XMLDECL_NOT_FINISHED, NULL);
    NEXT;
    } else {
    xmlFatalErr(ctxt, XML_ERR_XMLDECL_NOT_FINISHED, NULL);
    MOVETO_ENDTAG(CUR_PTR);
    NEXT;
    }
}

这个函数处理 <?xml version="" encoding=""?> 这种声明. 当没有 ?> 闭合的时候, 就会走到 MOVETO_ENDTAG(CUR_PTR) 这一行. 这一行是个宏, 整个拆开是这样的:

1
    while ((*ctxt->input->cur) && (*(ctxt->input->cur) != '>')) (ctxt->input->cur)++

ctxt 是解析用的结构体, 其中的 input 是处理所要解析的字符串. cur 是当前已经处理到的位置. 调试一下, 运行到这句话时, ctxt->input 长这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
pwndbg> p *ctxt->input
$41 = {
  buf = 0x606000000080,
  filename = 0x602000000110 "noname.xml",
  directory = 0x0,
  base = 0x621000001500 "",
  cur = 0x621000001512 '\276' <repeats 200 times>...,
  end = 0x621000001500 "",
  length = 0,
  line = 1,
  col = 19,
  consumed = 0,
  free = 0x0,
  encoding = 0x6020000001b0 "Us",
  version = 0x0,
  standalone = -2,
  id = 1
}

很明显, 这里的 cur 有问题, 它比 end 要大. 而且内容是一堆 \x276 (0xbe). 那么这个 while 会一只找下去, 直到越界.

这里可能是 ASAN 填充了堆, 才导致它恰好能够一直 while 下去. 不开 ASAN 编译一个 libxml2 并调试, 到 while 这里时, input 长这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
pwndbg> p *ctxt->input
$1 = {
  buf = 0x558f4f2b0ec0,
  filename = 0x558f4f2af890 "noname.xml",
  directory = 0x0,
  base = 0x558f4f2c49e0 "",
  cur = 0x558f4f2c49f2 "",
  end = 0x558f4f2c49e0 "",
  length = 0,
  line = 1,
  col = 19,
  consumed = 0,
  free = 0x0,
  encoding = 0x558f4f2b3560 "Us",
  version = 0x0,
  standalone = -2,
  id = 1
}

虽然 cur 大于 end, 但是 *cur = '\0', 所以并不会一直搜下去然后堆溢出.

单步调试发现, 这个 ctxt->input 是一个类似于缓冲区的东西, 其中 baseend 之间存的是需要解析的字符串, cur 是当前解析到哪个位置. 进入 xmlParseXMLDecl() 函数时, 缓冲区中是传入的输入.

xmlParseXMLDecl() 依次处理 "<?xml" 后跟的 version 和 encoding, 不过即使有格式上的错误也不会退出. 比如 fuzzing 出的这个输入就完全没有 version 字段, encoding 的引号也没有闭合.

是不是没有闭合引号导致的呢? 最后加一个引号上去, 甚至补完 "?>", 结果还是一样会 crash.

然而 NVD 的描述是

CVE-2015-8317
The xmlParseXMLDecl function in parser.c in libxml2 before 2.9.3 allows context-dependent attackers to obtain sensitive information via an (1) unterminated encoding value or (2) incomplete XML declaration in XML data, which triggers an out-of-bounds heap read.

并且标题还是 heap oob, 于是乎找到了当年的 讨论, 一看好家伙确实是 ASAN 编译 AFL fuzzing 出来的, 然后就说这是 heap oob read. 但爷分析了一会貌似并不是这样, 或者说重点不是这里.

刚进入 xmlParseXMLDecl() 函数时, input 长这样:

在执行到下面这句话之前, 缓冲区还是原来的那个

1
2
3
4
5
6
void
xmlParseXMLDecl(xmlParserCtxtPtr ctxt) {
    // ...
    xmlParseEncodingDecl(ctxt);
    // ...
}

而这个函数结束后, 缓冲区就变了. 很显然就是这里出了问题. 审计代码:

 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
const xmlChar *
xmlParseEncodingDecl(xmlParserCtxtPtr ctxt) {
    xmlChar *encoding = NULL;

    SKIP_BLANKS;
    if (CMP8(CUR_PTR, 'e', 'n', 'c', 'o', 'd', 'i', 'n', 'g')) {
    SKIP(8);
    SKIP_BLANKS;
    if (RAW != '=') {
        xmlFatalErr(ctxt, XML_ERR_EQUAL_REQUIRED, NULL);
        return(NULL);
        }
    NEXT;
    SKIP_BLANKS;
    if (RAW == '"') {
        NEXT;
        encoding = xmlParseEncName(ctxt);
        if (RAW != '"') {
        xmlFatalErr(ctxt, XML_ERR_STRING_NOT_CLOSED, NULL);
        } else
            NEXT;
    } else if (RAW == '\''){
        NEXT;
        encoding = xmlParseEncName(ctxt);
        if (RAW != '\'') {
        xmlFatalErr(ctxt, XML_ERR_STRING_NOT_CLOSED, NULL);
        } else
            NEXT;
    } else {
        xmlFatalErr(ctxt, XML_ERR_STRING_NOT_STARTED, NULL);
    }
    // ...
}

这一部分是在解析 encoding=" 或者 encoding=', 找到编码的名字, 然后进入 xmlParseEncName() 继续解析.

 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
/**
 * xmlParseEncName:
 * @ctxt:  an XML parser context
 *
 * parse the XML encoding name
 *
 * [81] EncName ::= [A-Za-z] ([A-Za-z0-9._] | '-')*
 *
 * Returns the encoding name value or NULL
 */
xmlChar *
xmlParseEncName(xmlParserCtxtPtr ctxt) {
    xmlChar *buf = NULL;
    int len = 0;
    int size = 10;
    xmlChar cur;

    cur = CUR;
    if (((cur >= 'a') && (cur <= 'z')) ||
        ((cur >= 'A') && (cur <= 'Z'))) {
    buf = (xmlChar *) xmlMallocAtomic(size * sizeof(xmlChar));
    if (buf == NULL) {
        xmlErrMemory(ctxt, NULL);
        return(NULL);
    }

    buf[len++] = cur;
    NEXT;
    cur = CUR;
    while (((cur >= 'a') && (cur <= 'z')) ||
           ((cur >= 'A') && (cur <= 'Z')) ||
           ((cur >= '0') && (cur <= '9')) ||
           (cur == '.') || (cur == '_') ||
           (cur == '-')) {
        if (len + 1 >= size) {
            xmlChar *tmp;

        size *= 2;
        tmp = (xmlChar *) xmlRealloc(buf, size * sizeof(xmlChar));
        if (tmp == NULL) {
            xmlErrMemory(ctxt, NULL);
            xmlFree(buf);
            return(NULL);
        }
        buf = tmp;
        }
        buf[len++] = cur;
        NEXT;
        cur = CUR;
        if (cur == 0) {
            SHRINK;
        GROW;
        cur = CUR;
        }
        }
    buf[len] = 0;
    } else {
    xmlFatalErr(ctxt, XML_ERR_ENCODING_NAME, NULL);
    }
    return(buf);
}

这里再解析编码的名字, 与其说是名字, 不如说是正则匹配. 匹配失败后返回匹配到的字符串, 也就是 encoding.

 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
const xmlChar *
xmlParseEncodingDecl(xmlParserCtxtPtr ctxt) {
    // ...
    /*
     * UTF-16 encoding stwich has already taken place at this stage,
     * more over the little-endian/big-endian selection is already done
     */
        if ((encoding != NULL) &&
        ((!xmlStrcasecmp(encoding, BAD_CAST "UTF-16")) ||
         (!xmlStrcasecmp(encoding, BAD_CAST "UTF16")))) {
        /*
         * If no encoding was passed to the parser, that we are
         * using UTF-16 and no decoder is present i.e. the
         * document is apparently UTF-8 compatible, then raise an
         * encoding mismatch fatal error
         */
        if ((ctxt->encoding == NULL) &&
            (ctxt->input->buf != NULL) &&
            (ctxt->input->buf->encoder == NULL)) {
        xmlFatalErrMsg(ctxt, XML_ERR_INVALID_ENCODING,
          "Document labelled UTF-16 but has UTF-8 content\n");
        }
        if (ctxt->encoding != NULL)
        xmlFree((xmlChar *) ctxt->encoding);
        ctxt->encoding = encoding;
    }
    /*
     * UTF-8 encoding is handled natively
     */
        else if ((encoding != NULL) &&
        ((!xmlStrcasecmp(encoding, BAD_CAST "UTF-8")) ||
         (!xmlStrcasecmp(encoding, BAD_CAST "UTF8")))) {
        if (ctxt->encoding != NULL)
        xmlFree((xmlChar *) ctxt->encoding);
        ctxt->encoding = encoding;
    }
    else if (encoding != NULL) {
        xmlCharEncodingHandlerPtr handler;

        if (ctxt->input->encoding != NULL)
        xmlFree((xmlChar *) ctxt->input->encoding);
        ctxt->input->encoding = encoding;

            handler = xmlFindCharEncodingHandler((const char *) encoding);
        if (handler != NULL) {
        xmlSwitchToEncoding(ctxt, handler);
        } else {
        xmlFatalErrMsgStr(ctxt, XML_ERR_UNSUPPORTED_ENCODING,
            "Unsupported encoding %s\n", encoding);
        return(NULL);
        }
    }
    // ...
    return(encoding);
}

之后判断一下 UTF16 和 UTF8 的两个特例, 不是这两个的话进入最后一个 if 语句, 执行 xmlSwitchToEncoding() 进行编码转换.

 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
int
xmlSwitchToEncoding(xmlParserCtxtPtr ctxt, xmlCharEncodingHandlerPtr handler)
{
    return (xmlSwitchToEncodingInt(ctxt, handler, -1));
}

static int
xmlSwitchToEncodingInt(xmlParserCtxtPtr ctxt,
                       xmlCharEncodingHandlerPtr handler, int len) {
    int ret = 0;

    if (handler != NULL) {
        if (ctxt->input != NULL) {
        ret = xmlSwitchInputEncodingInt(ctxt, ctxt->input, handler, len);
    } else {
        xmlErrInternal(ctxt, "xmlSwitchToEncoding : no input\n",
                       NULL);
        return(-1);
    }
    /*
     * The parsing is now done in UTF8 natively
     */
    ctxt->charset = XML_CHAR_ENCODING_UTF8;
    } else
    return(-1);
    return(ret);
}

经过一些封装, 到 xmlSwitchInputEncodingInt() 函数. 其中 input 参数是 ctxt->input 缓冲区.

 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
static int
xmlSwitchInputEncodingInt(xmlParserCtxtPtr ctxt, xmlParserInputPtr input,
                          xmlCharEncodingHandlerPtr handler, int len)
{
    int nbchars;

    if (handler == NULL)
        return (-1);
    if (input == NULL)
        return (-1);
    if (input->buf != NULL) {
        if (input->buf->encoder != NULL) {
            /*
             * Check in case the auto encoding detetection triggered
             * in already.
             */
            if (input->buf->encoder == handler)
                return (0);

            /*
             * "UTF-16" can be used for both LE and BE
             if ((!xmlStrncmp(BAD_CAST input->buf->encoder->name,
             BAD_CAST "UTF-16", 6)) &&
             (!xmlStrncmp(BAD_CAST handler->name,
             BAD_CAST "UTF-16", 6))) {
             return(0);
             }
             */

            /*
             * Note: this is a bit dangerous, but that's what it
             * takes to use nearly compatible signature for different
             * encodings.
             */
            xmlCharEncCloseFunc(input->buf->encoder);
            input->buf->encoder = handler;
            return (0);
        }
        input->buf->encoder = handler;
        // ...

首先检查是否已经有 encoder 了 (就是编码器函数指针, 负责编码), 没有的话就给他赋值为新传进来的这个 handler. 这里是第一次进行编码, 所以 encoder == NULL.

 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

        /*
         * Is there already some content down the pipe to convert ?
         */
        if (xmlBufIsEmpty(input->buf->buffer) == 0) {
            int processed;
        unsigned int use;

            /*
             * Specific handling of the Byte Order Mark for
             * UTF-16
             */
            if ((handler->name != NULL) &&
                (!strcmp(handler->name, "UTF-16LE") ||
                 !strcmp(handler->name, "UTF-16")) &&
                (input->cur[0] == 0xFF) && (input->cur[1] == 0xFE)) {
                input->cur += 2;
            }
            if ((handler->name != NULL) &&
                (!strcmp(handler->name, "UTF-16BE")) &&
                (input->cur[0] == 0xFE) && (input->cur[1] == 0xFF)) {
                input->cur += 2;
            }
            /*
             * Errata on XML-1.0 June 20 2001
             * Specific handling of the Byte Order Mark for
             * UTF-8
             */
            if ((handler->name != NULL) &&
                (!strcmp(handler->name, "UTF-8")) &&
                (input->cur[0] == 0xEF) &&
                (input->cur[1] == 0xBB) && (input->cur[2] == 0xBF)) {
                input->cur += 3;
            }

            /*
             * Shrink the current input buffer.
             * Move it as the raw buffer and create a new input buffer
             */
            processed = input->cur - input->base;
            xmlBufShrink(input->buf->buffer, processed);
            input->buf->raw = input->buf->buffer;
            input->buf->buffer = xmlBufCreate();
        input->buf->rawconsumed = processed;
        use = xmlBufUse(input->buf->raw);

然后判断缓冲区中是否还有输入, 有的话得处理这里面的编码先. 前面一部分是对 UTF16 和 UTF8 的特判, 可以不管他, 主要看后面一部分. 这里用 xmlBufShrink() 将缓冲区调整到未处理的地方, 然后让 raw 指向原来的 buffer, 用 xmlBufCreate() 申请新的内存来存放编码后的内容. 跟进一下 xmlBufCreate():

 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
/**
 * xmlBufCreate:
 *
 * routine to create an XML buffer.
 * returns the new structure.
 */
xmlBufPtr
xmlBufCreate(void) {
    xmlBufPtr ret;

    ret = (xmlBufPtr) xmlMalloc(sizeof(xmlBuf));
    if (ret == NULL) {
    xmlBufMemoryError(NULL, "creating buffer");
        return(NULL);
    }
    ret->compat_use = 0;
    ret->use = 0;
    ret->error = 0;
    ret->buffer = NULL;
    ret->size = xmlDefaultBufferSize;
    ret->compat_size = xmlDefaultBufferSize;
    ret->alloc = xmlBufferAllocScheme;
    ret->content = (xmlChar *) xmlMallocAtomic(ret->size * sizeof(xmlChar));
    if (ret->content == NULL) {
    xmlBufMemoryError(ret, "creating buffer");
    xmlFree(ret);
        return(NULL);
    }
    ret->content[0] = 0;
    ret->contentIO = NULL;
    return(ret);
}

buffer 的 content 是申请分配了 4096 (xmlDefaultBufferSize) 个字节的堆块指针. 这个 xmlMallocAtomic() 是对 malloc() 的一层封装.

是的, 就是这里导致缓冲区重新分配了. 继续看后面的处理:

 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
            // ...
            if (ctxt->html) {
                /*
                 * convert as much as possible of the buffer
                 */
                nbchars = xmlCharEncInput(input->buf, 1);
            } else {
                /*
                 * convert just enough to get
                 * '<?xml version="1.0" encoding="xxx"?>'
                 * parsed with the autodetected encoding
                 * into the parser reading buffer.
                 */
                nbchars = xmlCharEncFirstLineInput(input->buf, len);
            }
            if (nbchars < 0) {
                xmlErrInternal(ctxt,
                               "switching encoding: encoder error\n",
                               NULL);
                return (-1);
            }
        input->buf->rawconsumed += use - xmlBufUse(input->buf->raw);
            xmlBufResetInput(input->buf->buffer, input);
        }
        return (0);
    } else if (input->length == 0) {
    /*
     * When parsing a static memory array one must know the
     * size to be able to convert the buffer.
     */
    xmlErrInternal(ctxt, "switching encoding : no input\n", NULL);
    return (-1);
    }
    return (0);
}

调试一下 ctxt->html 是 0, 所以会进入 else 分支. 根据注释, 是要用新的编码解析第一行 — xml 定义语句. 之后是对缓冲区的调整. xmlCharEncFirstLineInput() 如下:

  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
int
xmlCharEncFirstLineInput(xmlParserInputBufferPtr input, int len)
{
    int ret = -2;
    size_t written;
    size_t toconv;
    int c_in;
    int c_out;
    xmlBufPtr in;
    xmlBufPtr out;

    if ((input == NULL) || (input->encoder == NULL) ||
        (input->buffer == NULL) || (input->raw == NULL))
        return (-1);
    out = input->buffer;
    in = input->raw;

    toconv = xmlBufUse(in);
    if (toconv == 0)
        return (0);
    written = xmlBufAvail(out) - 1; /* count '\0' */
    /*
     * echo '<?xml version="1.0" encoding="UCS4"?>' | wc -c => 38
     * 45 chars should be sufficient to reach the end of the encoding
     * declaration without going too far inside the document content.
     * on UTF-16 this means 90bytes, on UCS4 this means 180
     * The actual value depending on guessed encoding is passed as @len
     * if provided
     */
    if (len >= 0) {
        if (toconv > (unsigned int) len)
            toconv = len;
    } else {
        if (toconv > 180)
            toconv = 180;
    }
    if (toconv * 2 >= written) {
        xmlBufGrow(out, toconv * 2);
        written = xmlBufAvail(out) - 1;
    }
    if (written > 360)
        written = 360;

    c_in = toconv;
    c_out = written;
    if (input->encoder->input != NULL) {
        ret = input->encoder->input(xmlBufEnd(out), &c_out,
                                    xmlBufContent(in), &c_in);
        xmlBufShrink(in, c_in);
        xmlBufAddLen(out, c_out);
    }
#ifdef LIBXML_ICONV_ENABLED
    else if (input->encoder->iconv_in != NULL) {
        ret = xmlIconvWrapper(input->encoder->iconv_in, xmlBufEnd(out),
                              &c_out, xmlBufContent(in), &c_in);
        xmlBufShrink(in, c_in);
        xmlBufAddLen(out, c_out);
        if (ret == -1)
            ret = -3;
    }
#endif /* LIBXML_ICONV_ENABLED */
#ifdef LIBXML_ICU_ENABLED
    else if (input->encoder->uconv_in != NULL) {
        ret = xmlUconvWrapper(input->encoder->uconv_in, 1, xmlBufEnd(out),
                              &c_out, xmlBufContent(in), &c_in);
        xmlBufShrink(in, c_in);
        xmlBufAddLen(out, c_out);
        if (ret == -1)
            ret = -3;
    }
#endif /* LIBXML_ICU_ENABLED */
    switch (ret) {
        case 0:
#ifdef DEBUG_ENCODING
            xmlGenericError(xmlGenericErrorContext,
                            "converted %d bytes to %d bytes of input\n",
                            c_in, c_out);
#endif
            break;
        case -1:
#ifdef DEBUG_ENCODING
            xmlGenericError(xmlGenericErrorContext,
                         "converted %d bytes to %d bytes of input, %d left\n",
                            c_in, c_out, (int)xmlBufUse(in));
#endif
            break;
        case -3:
#ifdef DEBUG_ENCODING
            xmlGenericError(xmlGenericErrorContext,
                        "converted %d bytes to %d bytes of input, %d left\n",
                            c_in, c_out, (int)xmlBufUse(in));
#endif
            break;
        case -2: {
            char buf[50];
            const xmlChar *content = xmlBufContent(in);

        snprintf(&buf[0], 49, "0x%02X 0x%02X 0x%02X 0x%02X",
             content[0], content[1],
             content[2], content[3]);
        buf[49] = 0;
        xmlEncodingErr(XML_I18N_CONV_FAILED,
            "input conversion failed due to input error, bytes %s\n",
                   buf);
        }
    }
    /*
     * Ignore when input buffer is not on a boundary
     */
    if (ret == -3) ret = 0;
    if (ret == -1) ret = 0;
    return(ret);
}

大致看一下就是根据 encoder, 将 input->raw 编码输出到 input->buffer. 调试一下, 如果将要转换的字符不符合编码的规则, 则 content 不会有任何字符串被拷贝或者编码上去. 否则 content 是编码之后的字符串.

回到 xmlParseXMLDecl()

 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
void
xmlParseXMLDecl(xmlParserCtxtPtr ctxt) {
    // ...
    xmlParseEncodingDecl(ctxt);
    if (ctxt->errNo == XML_ERR_UNSUPPORTED_ENCODING) {
    /*
     * The XML REC instructs us to stop parsing right here
     */
        return;
    }

    /*
     * We may have the standalone status.
     */
    if ((ctxt->input->encoding != NULL) && (!IS_BLANK_CH(RAW))) {
        if ((RAW == '?') && (NXT(1) == '>')) {
        SKIP(2);
        return;
    }
    xmlFatalErrMsg(ctxt, XML_ERR_SPACE_REQUIRED, "Blank needed here\n");
    }

    /*
     * We can grow the input buffer freely at that point
     */
    GROW;
    // ...
}

这里虽然编码失败, 但是不是 unsupported encoding, 也没有跟 ?>, 而是一个不可见字符, 所以这里还不会退出, 而是执行下的 GROW 宏:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#define GROW if ((ctxt->progressive == 0) &&                \
         (ctxt->input->end - ctxt->input->cur < INPUT_CHUNK))   \
    xmlGROW (ctxt);

static void xmlGROW (xmlParserCtxtPtr ctxt) {
    unsigned long curEnd = ctxt->input->end - ctxt->input->cur;
    unsigned long curBase = ctxt->input->cur - ctxt->input->base;

    if (((curEnd > (unsigned long) XML_MAX_LOOKUP_LIMIT) ||
         (curBase > (unsigned long) XML_MAX_LOOKUP_LIMIT)) &&
         ((ctxt->input->buf) && (ctxt->input->buf->readcallback != (xmlInputReadCallback) xmlNop)) &&
        ((ctxt->options & XML_PARSE_HUGE) == 0)) {
        xmlFatalErr(ctxt, XML_ERR_INTERNAL_ERROR, "Huge input lookup");
        ctxt->instate = XML_PARSER_EOF;
    }
    xmlParserInputGrow(ctxt->input, INPUT_CHUNK);
    if ((ctxt->input->cur != NULL) && (*ctxt->input->cur == 0) &&
        (xmlParserInputGrow(ctxt->input, INPUT_CHUNK) <= 0))
        xmlPopInput(ctxt);
}

执行到下面的 xmlParserInputGrow(), 这个函数前半部分的检查都能通过, 直接看最最关键的部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
int
xmlParserInputGrow(xmlParserInputPtr in, int len) {
    // ...
    content = xmlBufContent(in->buf->buffer);
    if (in->base != content) {
        /*
     * the buffer has been reallocated
     */
    indx = in->cur - in->base;
    in->base = content;
    in->cur = &content[indx];
    }
    in->end = xmlBufEnd(in->buf->buffer);

    CHECK_BUFFER(in);

    return(ret);
}

就是这个地方, 将 ctxt->input 的缓冲区换到了之前申请的 ctxt->input->buf->buffer->content 上, 同时处理了 curend. 而这个 cur 是拿之前的 cur - base 算的, end 却是新申请的 buffer 算的:

1
2
3
4
5
6
7
8
9
xmlChar *
xmlBufEnd(xmlBufPtr buf)
{
    if ((!buf) || (buf->error))
        return NULL;
    CHECK_COMPAT(buf)

    return(&buf->content[buf->use]);
}

这里 buffer 因为处理不了输入的奇怪字符, 所以缓冲区中没有东西, use = 0. 所以实际上的结果就是 base = end = &content[0], 而 cur > end, 也就是之前看到的奇怪现象.

所以这个 GROW 将新申请的 buffer content 换到了 input 缓冲区, 但是这个 content 其实是错误的, 它除了最分配时写的一个 content[0] = '\0' 外, 其余的数据都是堆上残留的. 而这里的 cur 指针又是根据先前解析的字符来计算的, 所以后续的 MOVETO_ENDTAG 越界访问了堆数据.

这个越界读可能泄漏地址啥的, 得撞一个 “<”, 然后编码错误, 会输出后续 4 个字符. 如果是在没有 safe link 的 glibc 版本下, 应该能风水一下, $\frac{1}{16}$ 撞一个 tcache->next 的倒数第二字节为 “<”, 泄漏堆地址. 其他情况暂时没有想到.

第二类长这样的 \xff000<xml 000 数据貌似还和输入的长度有关, 感觉也是哪儿去给缓冲区分配了一下, 导致溢出读了. 分析不动了累了. 咕咕咕.

最后一类输入长这样:

1
2
3
4
5
6
7
8
00000000  3c 50 30 30 30 30 30 30  30 30 30 30 30 30 30 30  |<P00000000000000|
00000010  30 30 30 30 30 30 30 30  30 30 30 30 30 30 30 30  |0000000000000000|
*
00000250  30 30 30 30 30 30 30 30  30 30 30 30 3a 50 30 30  |000000000000:P00|
00000260  30 30 30 30 30 30 30 30  30 30 30 30 30 30 30 30  |0000000000000000|
*
000003b0  30 30 30 30 30 30 30 30  30 30                    |0000000000|
000003ba

ASAN 检测如下:

libxml2 heap overflow 2
libxml2 heap overflow 2

看一下出问题的地方:

 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
/*
 * xmlDictComputeFastQKey:
 *
 * Calculate a hash key for two strings using a fast hash function
 * that works well for low hash table fill.
 *
 * Neither of the two strings must be NULL.
 */
static unsigned long
xmlDictComputeFastQKey(const xmlChar *prefix, int plen,
                       const xmlChar *name, int len, int seed)
{
    unsigned long value = (unsigned long) seed;

    if (plen == 0)
    value += 30 * (unsigned long) ':';
    else
    value += 30 * (*prefix);

    if (len > 10) {
        value += name[len - (plen + 1 + 1)];
        len = 10;
    if (plen > 10)
        plen = 10;
    }
    // ...
}

明显越界. 看一下注释是在算哈希, backtrace 中看到调用它的函数为 xmlDictQLookup():

 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
/**
 * xmlDictQLookup:
 * @dict: the dictionnary
 * @prefix: the prefix
 * @name: the name
 *
 * Add the QName @prefix:@name to the hash @dict if not present.
 *
 * Returns the internal copy of the QName or NULL in case of internal error
 */
const xmlChar *
xmlDictQLookup(xmlDictPtr dict, const xmlChar *prefix, const xmlChar *name) {
    unsigned long okey, key, nbi = 0;
    xmlDictEntryPtr entry;
    xmlDictEntryPtr insert;
    const xmlChar *ret;
    unsigned int len, plen, l;

    if ((dict == NULL) || (name == NULL))
    return(NULL);
    if (prefix == NULL)
        return(xmlDictLookup(dict, name, -1));

    l = len = strlen((const char *) name);
    plen = strlen((const char *) prefix);
    len += 1 + plen;

    /*
     * Check for duplicate and insertion location.
     */
    okey = xmlDictComputeQKey(dict, prefix, plen, name, l);
    key = okey % dict->size;
    if (dict->dict[key].valid == 0) {
    insert = NULL;
    } else {
    for (insert = &(dict->dict[key]); insert->next != NULL;
         insert = insert->next) {
        if ((insert->okey == okey) && (insert->len == len) &&
            (xmlStrQEqual(prefix, name, insert->name)))
        return(insert->name);
        nbi++;
    }
    if ((insert->okey == okey) && (insert->len == len) &&
        (xmlStrQEqual(prefix, name, insert->name)))
        return(insert->name);
    }
    // ...
}

看注释大概是有个 QName 的东西, 由 prefix 和 name 中间加冒号组成, 然后算哈希, 用作存入字典的 key. 这里就是为什么输入有 : 的原因.

触发堆溢出的这句话 value += name[len - (plen + 1 + 1)]; 完全没有检查, 不过也只能算一个哈希, 甚至泄漏地址都很难做到. 但是看了一圈下来, 说是能造成程序崩溃然后拒绝服务. 想想也是, 啥也不检查的话直接给他溢出到非法地址, 程序就寄了. 虽然我拿不到 shell, 但是你们也别想正常使用