以库的形式使用 LibFuzzer——使用 LibFuzzer 对 lava-M 的测试

Posted on Oct 2, 2021

半个多月没有更新博客了,主要还是因为逐渐不再以刷题来学习 pwn 了,少了很多可写的东西,再加上上个月特别的忙,先是军训,训完之后就连着上课,早八到晚八,弄的我心力憔悴,确实没学到什么东西,所以确实没什么可写的。

最近准备开始学点现代漏洞测试技术,正好创新实践课那里是搞 fuzz 的,学长给我布置了拿 LibFuzzer 和 AFLgo 测 lava-M 的任务,也算是熟悉一下工具的使用。这两天研究了一下怎么用 LibFuzzer 测,这里记录一下过程中碰到的问题和最后的结果。

准备测试集

首先下载测试集 http://panda.moyix.net/~moyix/lava_corpus.tar.xz,下载好后解压,进入 lava-M 中,可以看到 base64 md5sum uniq who 四个文件夹,对应四个常用的真实软件,lava-M 在其中插入了一些 bug,这里以 base64 为例。

进入 base64 文件夹,目录下的 validate.sh 会使用默认编译器(一般是 gcc)进行构建,同时还会自动检测 bug 是否都可以触发。

编译前先执行 sudo apt install libacl1-dev 安装依赖。

在 Ubuntu 16.04 下编译此工程基本不会出错,但是我在 Ubuntu 20.04 下编译时碰到了 Please port gnulib fseeko.c to your platform! 这样的错误,参考 https://github.com/fede2cr/slackware_riscv/issues/2 中的解决方案,在目录 lava_corpus/LAVA-M/base64/coreutils-8.24-lava-safe 中执行

sed -i 's/IO_ftrylockfile/IO_EOF_SEEN/' lib/*.c
echo "#define _IO_IN_BACKUP 0x100" >> lib/stdio-impl.h

即可。

然后又碰到 “undefined reference makedev” 的错误,参考 https://www.raspberrypi.org/forums/viewtopic.php?f=28&t=250220&sid=12da7cda801cf05318f74aaaac372664 的解决方案,在 lava_corpus/LAVA-M/base64/coreutils-8.24-lava-safe/lib/mountlist.c 的头部加入

#include <sys/sysmacros.h>

即可。

此时应该就可以用 gcc 编译通过。

只要最后执行 validate.sh

Checking if buggy base64 succeeds on non-trigger input...
Success: base64 -d inputs/utmp.b64 returned 0
Validating bugs...
Validated 44 / 44 bugs
You can see validated.txt for the exit code of each buggy version.

出现 Validated 44 / 44 bugs 测试集基本没有问题了。

安装 LibFuzzer

LibFuzzer 是 llvm 项目中的一个部分,是一个 a coverage-guided in-process fuzzing engine,基本的使用可以参照 Google 写的教程,比较友好,这里不再多说。该模糊器与 clang 已经整合了,版本稍微高一点的 clang 都是内置的,一个基本的使用方式是在要被测试的代码中实现下面这个函数

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size)
{
    testMyApiWithGivenData(...);
    return 0;
}

使用给予的数据对被测函数进行测试。

但是在像上面的 base64 这样的 makefile 工程中这样做比较困难,如果仅仅实现 LLVMFuzzerTestOneInput,编译时使用 -fsanitize=fuzzer 参数,会因为有多个 main 而报错,此时如果仅仅删去 base64.c 中的 main,则会由于找不到 main 而报错。也许可以通过修改 makefile 来避免这样的错误,但是在文档中,提到了 Using libFuzzer as a library,这样只需要简单地修改源码,在工程的 main 中调用 LLVMFuzzerRunDriver,并把 LLVMFuzzerTestOneInput 以回调函数的形式传入,就可以开始测试了。

LLVMFuzzerRunDriver 函数的原型为

extern "C" int LLVMFuzzerRunDriver(int *argc, char ***argv,
                  int (*UserCb)(const uint8_t *Data, size_t Size));

在调用这个函数之前也可以先进行需要的初始化工作,可谓非常方便了。只需要在最后的链接阶段把静态库与目标文件链接即可。

这里需要注意的是,这个特性是在去年才引入的,所以较旧版本的 clang 是不支持的,至少在 Ubuntu 20.04 上用 apt 安装的 clang 是不支持的,建议自己到 release 页面上挑一个较新的版本安装(这个错误让我浪费了半天时间)。

使用 LibFuzzer

使用 clang 编译 base64

修改源码

首先我们需要对 base64/coreutils-8.24-lava-safe/src/base64.c 进行修改,在 main 函数中

int
main (int argc, char **argv)
{
  FuzzerInit();
  LLVMFuzzerRunDriver(&argc, &argv, LLVMFuzzerTestOneInput);

添加初始化操作和对 LLVMFuzzerRunDriver 的调用。这里的初始化主要是初始化一些缓冲区,也就是在代码中加入

char* str_buf;
size_t buf_size;
int runtimes = 0;
FILE* str_file;

const int MAX_LEN = 4096 + 10;

void FuzzerInit()
{
    str_buf = malloc(MAX_LEN);
    buf_size = MAX_LEN;
    str_file = fopen("./dummy_file", "w+");
    setvbuf(str_file, NULL, _IOFBF, MAX_LEN);
}

这里我开了一个文件,因为被测函数需要接收一个 FILE* 类型的输入,为了避免较慢的磁盘操作,所以我给他设置了缓冲区,并且设置为缓冲区满才刷新。

然后实现 LLVMFuzzerTestOneInput

int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) 
{
    fseek(str_file, 0, SEEK_SET);

    fputs(Data, str_file);
    fseek(str_file, 0, SEEK_SET);

    do_decode (str_file, stdout, false);

    return 0;
}

这里不需要手动把 Data 转为合法的 base64 串,因为函数对不合法的输入进行的操作也在我们的测试范围内。

在这个函数中我们调用 do_decode 函数,这是 base64 -d 实际调用的函数。

最后,由于 LibFuzzer 的每次测试是函数调用,所以被测函数不能直接直接结束程序。为了满足这一点,需要修改 do_decode 函数,去除其中所有对 error 函数的调用(同时注意其附近的 if,要记得用封号封闭掉,不然编译就会报错了)。

构建

首先先清理一下,在 lava_corpus/LAVA-M/base64/coreutils-8.24-lava-safe 中执行

make clean

然后 configure,由于这里需要链接 libclang_rt.fuzzer_no_main-<architecture>.a 这个静态库,其具体位置可以参考文档

On Linux installations, this is typically located at:

/usr/lib/<llvm-version>/lib/clang/<clang-version>/lib/linux/libclang_rt.fuzzer_no_main-<architecture>.a

If building libFuzzer from source, this is located at the following path in the build output directory:

lib/linux/libclang_rt.fuzzer_no_main-<architecture>.a

把它拷贝到 lava_corpus/LAVA-M/base64/coreutils-8.24-lava-safe 这个目录下(当然也可以不拷贝,这样 configure 的时候就需要把 LIBS 中 -L 参数后面的路径修改为静态库所在的目录,否则 ld 将无法找到静态库而报错)。

然后在 lava_corpus/LAVA-M/base64/coreutils-8.24-lava-safe 执行(根据机器的 libclang_rt.fuzzer_no_main-<architecture>.a 位置需要修改 -L 参数后面的路径)

CC=clang CFLAGS="-fsanitize=fuzzer-no-link,address -g -O1" ./configure \
    --prefix=`pwd`/lava-install LIBS="-lacl -L. -lclang_rt.fuzzer_no_main-x86_64"

然后

make -j$(nporc)

到这里我有时会卡在 GEN 上面,但是没有关系,只要 CCLD src/base64 正常执行就可以了

然后到 ./src 中就能找到 base64,运行一下,瞬间就可以出 crash,说实话吓我一跳,之前用 AFL 测几分钟才能跑出 crash,还以为是有什么地方错了。

很快就可以出 crash

进行测试

LibFuzzer 进行一次测试仅需要调用一次函数,和 AFL 相比,少了程序启动退出的开销,速度上面大幅提升(当然提升应该不仅仅由此造成),代价就是只要出现类似于段错误这样的不可恢复错误就会导致测试直接结束,也就是一次测试只能发现一个 bug。LibFuzzer 提供了 jobs 参数来自动化地多次、多线程地执行测试任务,执行

base64 -jobs=100

即可,100 指执行 100 次,我使用 -jobs=1000,跑完大概花了两分钟不到,并获得了大量 crash 和 log,命令为

base64 -jobs=1000 -workers=12 corpus

手工统计比较麻烦,随便写了个脚本来统计获得的 bug 数量

#!/bin/bash

filelist=`ls .`

for file in $filelist
do
    if [[ "$file" == fuzz* ]]; then
        echo $file
        uniq $file $file.uniq
        rm -rf $file
    fi
done

cat ./fuzz* | grep -a -o 'Successfully triggered bug [0-9][0-9]*' | grep -o '[0-9][0-9]*' > bugs
sort -n bugs | uniq > bugs.uniq
mv bugs.uniq bugs

这里统计的是 fuzzer 的 log 中出现的 Successfully triggered bug XXX, crashing now! 语句,最后把找到的 bug 的编号输出到了 bugs 中

获得的 bug

3 分钟就找出了 13/44 个 bug,性能还是相当不错的,但是有一个主要的问题,每个 fuzzer 基本都是找到同样的 bug 而退出,做许多无用功。

错误分析

上面开启 -workers 选项,其实是不正确的,因为我实现的 LLVMFuzzerTestOneInput 在多线程情况下写文件时会出现竞争,所以会出现奇奇怪怪的错误。就该测试模式,应当指定 workers 为 1。