GraphFuzz使用详解|工具分析
2023-7-14 15:9:16 Author: mp.weixin.qq.com(查看原文) 阅读量:5 收藏

一、概述

GraphFuzz是一个用于构建结构感知、库API模糊器的实验性框架,其设计思路详见论文《GraphFuzz: Library API Fuzzing with Lifetime-aware Dataflow Graphs》。GraphFuzz工具目前已经开源,开源链接为https://github.com/hgarrereyn/GraphFuzz。GraphFuzz工具主要包括两个部分:一个是gfuzz工具,是一个由Python编写的命令行工具,用来合成harness、精简测试用例等;另一个是libgraphfuzz运行时库,是一个与libfuzzer结合使用的库,提供运行时图变异、函数端点执行等操作。

二、工作流程

GraphFuzz必须要使用者提供/编写一个模式(schema)文件来描述目标库的API,该模式文件使用YAML语言格式,并包含一系列需要进行模糊测试的函数、类和结构体。这个模式文件可以由使用者手动编写,也可以基于doxygen导出的一系列接口信息文件来生成。需要注意的是,根据doxygen导出的信息产生的模式文件一般不会包括接口之间的依赖关系,因此使用者还需要在该模式文件的基础上进行调整。

接下来,我们以一个模式文件简要阐述GraphFuzz的工作流程。假设给定的schema.yaml文件内容如下:

Foo:    methods:    - Foo()    - ~Foo()    - void foo(int x, int y, float z)Bar:    methods:    - Bar(Foo *, int)    - ~Bar()    - void bar(int x)

在运行时,GraphFuzz将使用不同顺序和不同的参数调用库的API以生成测试用例(调用序列)。具体来说,GraphFuzz将API构造为遵守彼此之间依赖关系的图,然后使用基于图的变异来生成新的调用序列。最重要的是,GraphFuzz将明确跟踪目标的生命周期,并确保所有测试用例都遵守由模式文件定义的API规范。GraphFuzz根据上述模式文件提供的信息,构造符合规范的调用序列,如下所示:

// Example 1    Foo *v0 = new Foo();    v0->foo(340.5);    Bar *v1 = new Bar(v0, 1000);    v1->bar(123);    del v1;    del v0;}// Example 2    Foo *v0 = new Foo();    v0->foo(340.5);    v0->foo(100.5);    v0->foo(040.5);    del v0;}// Example 3    Foo *v0 = new Foo();    v0->foo(340.5);    Bar *v1 = new Bar(v0, 1000);    Bar *v2 = new Bar(v0, 0);    del v2;    del v1;    del v0;}

上述示例中展示的调用序列使用了C++风格的源代码表示。然而,在GraphFuzz内部,每个调用序列实际上被表示为一个数据流图,其中图的顶点表示函数(端点),而边表示对象之间的依赖关系。通过这种方式,GraphFuzz可以在无需进行代码分析或重新编译的情况下执行调用序列,只需动态遍历图中的每个顶点并调用其对应的函数即可。下图展示了GraphFuzz所抽象出的数据流图:

三、GraphFuzz的安装

Step 1   将源码下载到本地,并安装相关依赖

git clone https://github.com/ForAllSecure/GraphFuzz.gitsudo apt-get install libprotobuf-dev protobuf-compiler python3-venv python3-pipcurl -sSL https://install.python-poetry.org | python3 -export PATH=$PATH: ** the root path to poetry ** (e.g. /home/chan/.local/bin)

Step 2   构建libgraphfuzz库

libgraphfuzz是链接到你的模糊器harness的一个运行时图变异库,其用C++编写并使用标准的CMake进行构建:

cd GraphFuzzmkdir buildcd buildcmake ..makesudo make install

Step 3   构建gfuzz工具

gfuzz是一个python命令行工具,用来构建harness文件并执行各种各样其他的功能(如图最小化)。其使用Python编写,并使用Poetry来构建系统:

cd .. && cd clipoetry buildpoetry export > dist/requirements.txtpython3 -m pip install -r dist/requirements.txtpython3 -m pip install ./dist/gfuzz-*.whl

四、基本用法

首先我们需要构建实验测试环境:

sudo apt-get install docker-ce docker-ce-cli containerd.iocd .. && cd experimentssudo ./build basesudo ./build hello_graphfuzzsudo ./run hello_graphfuzz

其中,hello_graphfuzz是一个简单测试项目,其中包含一个简单的C++头文件lib.h和一个模式配置文件schema.yaml。lib.h头文件内容如下:

#include <cstring>#include <vector>
class Foo {public:    Foo(): buffer(0) {}
    void write(char val) {        buffer.push_back(val);    }
    void check() {        if (buffer.size() >= 4 && \            buffer[0] == 'F' && \            buffer[1] == 'U' && \            buffer[2] == 'Z' && \            buffer[3] == 'Z'        ) {            __builtin_trap();        }    }private:    std::vector<char> buffer;};

基于上述头文件中类成员函数,我们可以构造如下用来测试API的模糊器:

int LLVMFuzzerTestOneInput(const char *Data, size_t Size) {    Foo foo;    for (int i = 0; i < Size; ++i) {        foo.write(Data[i]);    }    foo.check();}

请注意,在构造上述harness时,我们其实是使用了一些API的先验知识,即必须先调用若干次Foo::write()方法,然后才有可能触发Foo::check()方法中的设置的异常。我们进一步假设,如果一个bug需要在Foo::check()之后调用Foo::write()或多次调用Foo::check()才能触发,那该如何操作?因此上述基于标准LLVMFuzzerTestOneInput()函数设计模糊器的方式存在着局限性,其最主要的问题就是调用序列不变,因而不能触发更深层的漏洞。此外,随着API规模不断增大,函数的交互方式也呈现着指数级的增加,这将给有效harness的生成带来很大的挑战。

在GraphFuzz中,一个见解是使模糊器引擎根据覆盖率引导变异自行发现API使用模式:即通过定义一个模式文件来描述我们想要模糊测试的所有API函数(端点),并让模糊器自动构建API调用序列。

我们回到上述的例子中,按照GraphFuzz模式文件的编写规则,我们可以编写出符合lib.h的模式文件:

Foo:  type: struct             #表明Foo是一个结构体(类),这里也可以写为class  name: Foo                #结构体(类)名  headers: [lib.h]         #包含的头文件  methods:                 #包含的成员方法  - Foo()                  #构造函数  - ~Foo()                 #析构函数  - void write(char val)  #成员方法1  - void check()          #成员方法2

在模式文件中,如果仅给出函数签名,那么GraphFuzz尝试去推断语义(例如,Foo::Foo()是一个构造函数而不是一个方法调用等)。在大多数情况下,这些推断都是没有问题的,但当面临对参数有隐含约束的函数或非标准的API结构时,GraphFuzz的推断将不再有用。因此,GraphFuzz也额外提供了一种更粗粒度、更灵活的函数端点声明,称之为自定义端点。相关的使用说明详见GraphFuzz文档:https://hgarrereyn.github.io/

GraphFuzz/。

接下来,我们使用gfuzz工具来生成harness文件:

Usagegfuzz gen [lang] [schema] [output directory]gfuzz gen cpp schema.yaml .

运行完上述命令后,gfuzz会产生3个文件,分别是:

fuzz_exec.cpp:主模糊器harness文件

fuzz_write.cpp:一个辅助harness文件,用于将数据流图转化为C++风格的源代码

schema.json:GraphFuzz运行时所使用的类型元数据

最后,我们编译这两个harness文件:

clang++ -o fuzz_exec fuzz_exec.cpp -fsanitize=fuzzer -lprotobuf -lgraphfuzz  # 注意:这里我们链接了libgraphfuzz库clang++ -o fuzz_write fuzz_write.cpp -fsanitize=fuzzer -lprotobuf -lgraphfuzz

需要注意的是,这里GraphFuzz通过类似于hook的方式集合了libFuzzer,因此我们可以使用libFuzzer原生提供的功能,如user_value_profile, fork, dict等。

至此,我们已经生成了两个可执行模糊器:fuzz_exec(主模糊器)和fuzz_write(辅助模糊器)。然后我们运行主模糊器:

./fuzz_exec -use_value_profile=1

运行了一段时间后,libfuzzer返回deadly signal信号并终止了模糊器,这表明模糊器成功地生成了能够执行到设置的漏洞点的测试用例,如下图所示。

与此同时,fuzz_exec运行目录下会生成文件名为“crash-xxx”的文件,这是GraphFuzz生成了能够触发crash的测试用例。这个测试用例本质上是一个序列化的数据流图,由libprotobuf生成,因此该文件是人类不可读的:

为了使其可读,我们运行fuzz_write程序,从序列化的数据流图中反序列化得到源代码:

$ ./fuzz_write crash-402bbad640a94933571939f685ea1e9dc4b937f8#include "lib.h"
#define MAKE(t) static_cast<t *>(calloc(sizeof(t), 1))
struct GFUZZ_BUNDLE {public:    void *active;    void *inactive;    GFUZZ_BUNDLE(void *_active, void *_inactive): active(_active), inactive(_inactive) {}};
#define BUNDLE(a,b) new GFUZZ_BUNDLE((void *)a, (void *)b)
int main() {    Foo *var_0;    { // begin shim_0        var_0 = MAKE(Foo);        Foo ref = Foo();        *var_0 = ref;    } // end shim_0    Foo *var_1;    { // begin shim_2        var_0->write(70); // ascii 'F'        var_1 = var_0;    } // end shim_2    Foo *var_2;    { // begin shim_2        var_1->write(85); // ascii 'U'        var_2 = var_1;    } // end shim_2    Foo *var_3;    { // begin shim_2        var_2->write(90); // ascii 'Z'        var_3 = var_2;    } // end shim_2    Foo *var_4;    { // begin shim_2        var_3->write(90); // ascii 'Z'        var_4 = var_3;    } // end shim_2    Foo *var_5;    { // begin shim_3        var_4->check(); // __builtin_trap        var_5 = var_4;    } // end shim_3    Foo *var_6;    { // begin shim_2        var_5->write(5);        var_6 = var_5;    } // end shim_2    Foo *var_7;    { // begin shim_2        var_6->write(0);        var_7 = var_6;    } // end shim_2    Foo *var_8;    { // begin shim_2        var_7->write(0);        var_8 = var_7;    } // end shim_2    Foo *var_9;    { // begin shim_3        var_8->check();        var_9 = var_8;    } // end shim_3    { // begin shim_1        free(var_9);    } // end shim_1}

上述生成的触发crash的调用序列存在冗余,gfuzz工具提供了一个图敏感的流图最小化工具,使用方法如下:

# Usage: gfuzz min [fuzzer] [crash]$ gfuzz min ./fuzz_exec crash-402bbad640a94933571939f685ea1e9dc4b937f8

运行结果如下:

上述结果将最小化后的数据流图保存为运行目录下的“crash-xxx.min”文件,然后我们运行fuzz_write查看最小化的调用序列:

$ ./fuzz_write crash-402bbad640a94933571939f685ea1e9dc4b937f8.min#include "lib.h"

#define MAKE(t) static_cast<t *>(calloc(sizeof(t), 1))
struct GFUZZ_BUNDLE {public:    void *active;    void *inactive;    GFUZZ_BUNDLE(void *_active, void *_inactive): active(_active), inactive(_inactive) {}};
#define BUNDLE(a,b) new GFUZZ_BUNDLE((void *)a, (void *)b)
int main() {    Foo *var_0;    { // begin shim_0        var_0 = MAKE(Foo);        Foo ref = Foo();        *var_0 = ref;    } // end shim_0    Foo *var_1;    { // begin shim_2        var_0->write(70); // ascii 'F'        var_1 = var_0;    } // end shim_2    Foo *var_2;    { // begin shim_2        var_1->write(85); // ascii 'U'        var_2 = var_1;    } // end shim_2    Foo *var_3;    { // begin shim_2        var_2->write(90); // ascii 'Z'        var_3 = var_2;    } // end shim_2    Foo *var_4;    { // begin shim_2        var_3->write(90); // ascii 'Z'        var_4 = var_3;    } // end shim_2    Foo *var_5;    { // begin shim_3        var_4->check(); // __builtin_trap        var_5 = var_4;    } // end shim_3    { // begin shim_1        free(var_5);    } // end shim_1}

上述经过最小化处理的C++代码按照"FUZZ"字符串的顺序依次调用了Foo::write()函数并写入相应的字符,然后调用Foo::check()函数以触发其中设置的异常。与之前生成的结果相比,我们推测这个图敏感的流图精简工具是通过删除数据流图中相应的节点(函数端点)来判断是否仍然能够触发错误,并保留精简后具有最少节点的数据流图。

至此,我们通过一个简单的示例展示了GraphFuzz的基本用法。回顾上述的流程,我们不难发现,GraphFuzz工具的使用依赖于一个模式文件,换句话说,模式文件编写的好坏会直接影响到漏洞能否被发现。我们将在下一节中详细介绍模式文件中端点的编写语法。

五、端点

端点是一个GraphFuzz harness基本构建块(数据流图的顶点),本节主要探索完整的端点定义语法。下面的代码展示了用户自定义的一个端点myEndpoint。该端点将Foo和Bar的对象作为输入,将Bar的对象作为输出,此外还有两个可变异参数,并执行exec中的内容。

Foo:    ...    methods:    ...    # Endpoint name.    - myEndpoint:        # List of "live" inputs.        inputs: ['Foo', 'Bar']         # List of "live" outputs.        outputs: ['Bar']         # Additional fuzzable parameters.        args: ['int', 'char[10]']         # Endpoint code. (note: "exec: |" is YAML syntax for a multiline string)        exec: |            // Arbitrary C/C++ code here            for (int i = 0; i < 10; ++i) {                $a1[i] &= 0x7f;            }            $i1->doFunction($i0, $a0, $a1);            $o0 = $i1;

其中模板变量$i0, $i1, …表示第1, 2, …个输入,$o0, $o1, …表示第1, 2, …个输出,$a0, $a1, …表示第1, 2, …个参数(这里的参数指的是上下文相关的参数,即可进行变异)。除了基本数据结构,上述变量都以指针的形式表示。但在我们的实际使用中,我们发现args字段所定义的参数如果是数组,那么必须是定长的数组,也就是说必须为其分配固定大小的空间,而不能是任意的指针。

为了运行此代码,我们需要初始化多个对象,包括实时数据类型。运行此代码后,将剩下一个对象(Bar *),我们需要清理其他过程间的变量,而GraphFuzz引擎将通过调用类/结构体的构造函数和析构函数来维护对象的生命周期,并根据数据流图的信息自动识别是否需要调用相应的方法。此外还需要注意,在定义模式文件时,类的构造函数和析构函数不能是缺省的,否则GraphFuzz在运行时会报错。

我们在上一节中使用的模式文件是一个简化的模式文件,而GraphFuzz会进行简单的推断,从而生成一个完整的模式,如下所示:

Foo:  type: struct  name: Foo  headers: [lib.h]  methods:  - Foo():      outputs: ['Foo']      exec: |        $o0 = new Foo();  - ~Foo():      inputs: ['Foo']      exec: |        delete $i0;  - void write(char val):      inputs: ['Foo']      outputs: ['Foo']      args: ['char']      exec: |        $i0->write($a0);        $o0 = $i0;  - void check():      inputs: ['Foo']      outputs: ['Foo']      exec: |        $i0->check();        $o0 = $i0;

接下来,我们一一剖析这些端点

1. Foo::Foo()

- Foo():    outputs: ['Foo']    exec: |        $o0 = new Foo();

该端点无输入和上下文相关的参数(在模式文件中可以直接省略这些字段)。因为该端点是一个构造函数,其产生一个Foo类的对象作为输出。在outputs参数中,我们编写输出对象的类型名称;而在exec参数中,我们需要实际调用这个构造函数,即new一个该类的对象。因为我们指定了输出,所以我们可以访问模板变量$o0(第1个输出),该变量将填充为一个Foo *的指针。

2. Foo::~Foo()

- ~Foo():    inputs: ['Foo']    exec: |        delete $i0;

该端点是一个析构函数。为了调用该析构函数,我们需要一个对象的实例作为输入,在inputs参数中指定一个Foo类型的对象。因为我们指定了一个输入,所以我们可以访问模板变量$i0(第1个输入),即一个Foo *的指针,然后在exec参数中编写删除该对象的代码。

3. Foo::check()

- void check():    inputs: ['Foo']    outputs: ['Foo']    exec: |        $i0->check();        $o0 = $i0;

该端点是一个成员方法。为了调用该方法,我们需要这个对象的一个实例。在我们执行这个方法调用之后,该对象仍然存在并有效,因此其也是一个输出。因此,我们在inputs和outputs参数中指定Foo对象。在exec参数体内,我们可以访问模板变量$i0(第一个输入)和$o0(第一个输出),然后调用$i0(Foo对象实例)的成员方法check(),之后将输出$o0重新赋值为$i0。

4. Foo::write(char)

和Foo::check()方法类似,该端点也是一个成员方法。不同之处在于,该方法有一个额外的参数:一个char类型的变量,该变量将传递给端点方法。char类型是一个基本类型,在默认情况下,并不会作为数据流图的一部分进行跟踪。但是,如果在给定数据流图中的某个端点实例中包含了该参数,那么该参数就可以进行变异。这些上下文相关的参数被指定在args参数中。在这里,我们使用模板变量$a0(表示第1个参数)来引用char类型的变量。在exec参数体内的代码中,调用了$i0的write()方法,并将$a0作为write()方法的参数传入,然后将输出$o0重新赋值为$i0。


文章来源: https://mp.weixin.qq.com/s?__biz=MzU1NTEzODc3MQ==&mid=2247486052&idx=1&sn=09452da404e0b2c9352c22adb63e5575&chksm=fbd9a1d8ccae28ce88ae42a983e5d0f88843200cab0f1b9195b655ca6aafb1d7b4b560706bcc&scene=58&subscene=0#rd
如有侵权请联系:admin#unsafe.sh