LibAFL模糊测试库

AFL++ logo

作者: Andrea Fioraldi 和 Dominik Maier

欢迎来到 LibAFL,一个高级模糊测试库,本书是对该库的一个简单介绍。

此版本的 LibAFL Book 是与该库的 1.0 测试版结合在一起的。

本文件仍在进行中,并不完整。这里解释的结构和概念在未来的修订中可能会有变化,因为 LibAFL 的结构本身也会有变化。

本书的 HTML 版本可在 https://aflplus.plus/libafl-book/ 在线获取,也可从 LibAFL 仓库中的 docs/ 文件夹中离线获取。

使用这个文件夹中的 mdbook build 来构建它,或者运行 mdbook serve 来查看该书。

本书的简体中文版本由 zu1k 翻译(借助DeepL翻译引擎),HTML 版本可在 https://libafl-book-zh.zu1k.com 在线获取,仓库 zu1k/libafl-book-zh,当前查看版本对应英文原版 4f6f76e

简介

模糊器是安全研究人员和开发人员的重要工具。 一系列最先进的工具,如 AFL++libFuzzerhonggfuzz 都可供用户使用。它们以一种非常有效的方式完成它们的工作,发现成千上万的bug。

然而,从一个高级用户的角度来看,这些工具是有限的,它们的设计并没有把可扩展性作为第一等公民。 通常情况下,模糊器开发者可以选择 Fork 这些现有的工具,或者从头开始创建一个新的模糊器。在这些情况下,研究人员最终得到有大量的模糊器,它们之间互不兼容,其先进的部分进能够被其自己使用。 在这个过程中,需要一遍又一遍的重新发明轮子,而这些过程完全可以被避免。

为了解决这个问题,我们创建了LibAFL,这是一个库,它不只是一个模糊器,而是一个可重复使用的个体模糊器的集合。 LibAFL是用Rust编写的,它可以帮助你开发一个为你的特定需求而定制的模糊器。 无论是一个特定的目标,一个特定的插桩后端,还是一个自定义的突变器,你都可以利用现有的碎片来制作你能想到的最快、最有效的模糊器。

为什么使用 LibAFL?

LibAFL为你提供了许多现成的模糊器的优点,同时又是完全可定制的。

目前的一些亮点功能包括:

  • 多平台: LibAFL 几乎可以在任何 Rust编译器 支持的地平台工作,我们已经在 WindowsAndroidMacOSLinux 上使用,在 x86_64aarch64 上使用
  • 移植性: LibAFL 可以在 no_std 模式下构建,这意味着它不需要依赖特定的操作系统。通过定义一个分配器和映射页面的方法,你可以把LibAFL注入到其他目标平台,如嵌入式设备、管理程序,甚至可能是WebAssembly
  • 适应性: 鉴于多年来对 AFLplusplus 的开发经验和我们对模糊测试的学术背景,我们可以将最近的模糊测试发展趋势融入到LibAFL的设计中,使其面向未来。 举个例子,相对于老式的模糊器, BytesInput 只是输入的潜在形式之一,你可以自由地使用和修改抽象语法树,以实现结构化的模糊处理。
  • 扩展性: 作为LibAFL的一部分,我们开发了 低水平消息传递,简称 LLMP,它允许LibAFL在核心上几乎线性扩展。也就是说,如果你选择使用这个功能--毕竟这是你的模糊器。 使用LLMP的 broker2broker 功能,通过TCP扩展到多台机器也是可能的
  • 快速: 我们在编译时尽一切努力,使运行时的开销尽可能小
  • 任何目标: 我们支持纯二进制模式,如 QEMU-ModeFrida-ModeASANCmpLog,以及基于资源的插桩的多个编译通道。当然,我们也支持自定义插桩,正如你在基于谷歌Atheris的Python例子中看到的那样
  • 可用性: 这个问题由你来决定。尽情发挥吧!

入门

要开始使用LibAFL,有一些初始步骤要做。

在本章中,我们将讨论如何使用 Rust 的 cargo 命令下载和构建 LibAFL。 我们还描述了LibAFL的组件结构,即所谓的 crate,以及每个单独 crate 的目的。

设置

第一步是下载 LibAFL 和所有没有被 cargo 自动安装的依赖项。

命令行符号

在本章和全书中,我们展示了一些终端内容。终端输入的行都以$开头,但你不需要输入$字符; 它表示每个命令的开始。不以"$"开头的行通常显示的是 > 前一条命令的输出。 此外,PowerShell特定的例子将使用>而不是$

虽然技术上你不需要安装 LibAFL,而是可以直接使用 crates.io 的版本,但我们还是建议下载或克隆 GitHub版本,这样你就可以得到示例模糊器、额外的实用程序和最新的补丁。

最简单的方法是使用 git:

$ git clone git@github.com:AFLplusplus/LibAFL.git

你也可以在 类UNIX 的机器上,下载一个压缩的存档,然后用以下方法解压:

$ wget https://github.com/AFLplusplus/LibAFL/archive/main.tar.gz
$ tar xvf LibAFL-main.tar.gz
$ rm LibAFL-main.tar.gz
$ ls LibAFL-main # this is the extracted folder

安装 Clang

LibAFL 的外部依赖之一是 Clang C/C++ 编译器。 虽然大部分代码都是纯Rust语言编写,但我们仍然需要一个C语言编译器,因为稳定的Rust仍然不支持LibAFL某些部分可能需要的功能,比如弱连接以及LLVM内置链接。 对于这些部分,我们使用C语言来向我们的Rust代码库暴露缺少的功能。

此外,如果你想对 C/C++ 应用程序进行源码级模糊测试,你可能需要Clang及其插桩选项来编译被测程序。

在Linux上,你可以使用你的发行版的软件包管理器来获取Clang,但这些软件包并不总是最新的。 相反,我们建议使用来自 LLVM 的 Debian/Ubuntu 预编译包,这些包可以通过他们的 官方仓库 获得。

对于Microsoft Windows,你可以下载 LLVM 定期生成的 安装包

尽管 Clang 是 MacOS 上的默认C编译器,但我们不鼓励使用苹果公司提供的编译器,而鼓励使用从 Homebrew 安装的版本: brew install llvm

另外,你也可以下载LLVM源码并自行构建,按照 这里 的方法。

安装 Rust

如果你没有安装 Rust,你可以很容易地按照 这里 描述的步骤来安装它。

请注意,Linux 发行版中的 Rust 版本可能已经过时,LibAFL 总是使用最新的稳定版本,可通过 rustup upgrade 获得。

我们建议先安装 Clang 和 LLVM,然后再安装 Rust。

构建 LibAFL

LibAFL 和大多数 Rust 项目一样,可以使用 cargo 从项目的根目录下构建:

$ cargo build --release

请注意,--release在开发测试时是可选项,但使用Debug版本进行模糊测试可能会有10倍以上的性能损失,正式使用时你需要添加 --release 标志才能获得正常的速度。

LibAFL资源库是由多个板块组成的。 顶层的 Cargo.toml 是将这些板块分组的工作区文件。 从根目录调用 cargo build 将编译工作区中的所有板块。

构建示例模糊器

对于有经验的 rustaceans 来说,最好的起点是阅读并改编示例模糊器。

我们将这些模糊程序放在 LibAFL 资源库的 ./fuzzers 目录下,该目录包含一组不属于工作区的crates。

这些示例模糊器中的每一个都使用了 LibAFL 的特定功能,有时与不同的插桩后端相结合 (例如 SanitizerCoverage, Frida, ...) 。

你可以使用这些 crates 作为例子,并作为具有类似功能集的自定义模糊器的骨架。 每个模糊器的目录中都有一个 README.md 文件,描述模糊器及其特性。

要建立一个例子的模糊器,你必须从其各自的文件夹 (fuzzers/[FUZZER_NAME]) 调用 cargo build --release

Crates

LibAFL 是由不同的 crate 组成的。 crate 是 Rust 的 Cargo 构建系统中的一个独立的库,你可以通过把它添加到你的项目的 Cargo.toml 文件中来使用,比如:

[dependencies]
libafl = { version = "*" }

对于 LibAFL 来说,每个 crate 都有其独立的用途,用户可能不需要在其项目中使用所有的 crate。

按照项目根目录下的文件夹的命名惯例,它们是:

libafl

这是 主crate,包含了构建模糊器所需的所有组件。

这个板块有许多 Feature标志,可以启用和禁用 LibAFL 的某些方面。 这些特性可以在 LibAFL's Cargo.toml "[features]"下找到,并且通常在那里有注释说明。 一些值得注意的特性是。

  • std: 启用代码中使用Rust标准库的部分。如果没有这个标志,LibAFL是 no_std 兼容的,这将禁用一系列功能,但允许我们在嵌入式环境中使用LibAFL,阅读 no_std`部分 了解更多细节
  • derive: 可以使用 LibAFL 的 libafl_derive 中定义的 derive(...)
  • rand_trait: 允许你在需要与Rust的 rand crate 兼容的地方使用LibAFL的非常快速 (但不安全!) 的随机数发生器
  • llmp_bind_public: 使 LibAFL 的 LLMP 绑定到一个公共的TCP端口,其他的fuzzers节点可以通过这个端口与这个实例通信
  • introspection: 为LibAFL添加性能统计

你可以通过在你的 Cargo.toml 中为 LibAFL 使用 features = ["feature1", "feature2", ...] 来选择特性。 在这个列表中,默认情况下,stdderiverand_trait 已经被设置。你可以通过在你的 Cargo.toml 中设置 default-features = false 来选择禁用它们。

libafl_sugar

sugar crate 抽离了 LibAFL API 的大部分复杂性。 它的目标不是高灵活性,而是高层次和易于使用。 它不像从每个单独的组件中缝合你的模糊器那样灵活,但允许你用最少的代码行建立一个模糊器。 要看它的运行情况,请看一下libfuzzer_stb_image_sugar示例模糊器

libafl_derive

这是一个与 libafl crate 配对的 proc-macro 板块。

目前,它只是暴露了 derive(SerdeAny) 宏,可以用来定义 Metadata 结构,详见关于 Metadata 的部分。

libafl_targets

这个板块提供了与目标交互的代码,并对其进行检测。 为了在编译时启用和禁用功能,使用功能标志来启用和禁用这些功能。

目前,支持的标志有:

  • pcguard_edges: 定义了 SanitizerCoverage trace-pc-guard 钩子来跟踪 map 中的执行边
  • pcguard_hitcounts: 定义了 SanitizerCoverage trace-pc-guard 钩子,以追踪 map 中已执行的边和hitcounts (如AFL)
  • libfuzzer: 提供了一个 libFuzzer 风格的兼容层
  • value_profile: 定义了 SanitizerCoverage trace-cmp 钩子,以跟踪 map 中每个比较的匹配位

libafl_cc

这是一个提供实用程序的库,用于包装编译器和创建源码级模糊器。

目前,只有Clang编译器被支持。 为了更深入地了解它,请看一下教程和例子。

libafl_frida

这个库将 LibAFL 与 Frida 作为插桩分析的后端连接起来。

有了这个库,你就可以对 Linux/MacOS/Windows/Android 上的目标进行覆盖率采集。

此外,它还支持 CmpLog 和 AddressSanitizer 插桩和 aarch64 的运行时。

libafl_qemu

这个库将 LibAFL 与 QEMU 用户模式连接起来,以模糊 ELF 跨平台二进制文件。

它可以在 Linux 上工作,并且可以在没有碰撞的情况下收集边缘覆盖率! 它还支持大量的钩子和插桩选项。

一个简单的LibAFL模糊器

本章讨论了一个使用 LibAFL API 构建的极其简单的模糊器。 你将学习基本的实体,如 StateObserverExecutor。 虽然下面的章节会详细讨论 LibAFL 的组件,但在这里我们介绍基本原理。

我们将对一个简单的 Rust 函数进行模糊处理,该函数在某个条件下会出现panic。这个模糊器将是单线程的,并在崩溃后停止,就像libFuzzer通常做的那样。

你可以在 fuzzers/baby_fuzzer 中找到本教程的完整版本,作为一个模糊器的例子。

警告

这个示例模糊器对于任何现实世界的使用来说都是太天真了。 它的目的仅仅是为了展示库的主要组件,如果想更深入地了解如何构建一个自定义的模糊器,请直接阅读 Tutorial chapter

创建一个项目

我们使用 cargo 创建一个新的Rust项目,将 LibAFL 作为一个依赖项。

$ cargo new baby_fuzzer
$ cd baby_fuzzer

生成的 Cargo.toml 看起来像下面这样:

[package]
name = "baby_fuzzer"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

为了使用 LibAFl,我们必须在 [dependencies] 下增添其依赖 libafl = { path = "path/to/libafl/" }。 如果你愿意,你可以使用 crates.io 的 LibAFL 版本,在这种情况下,你必须使用 libafl = "*" 来获取最新的版本 (或者将其设置为当前版本) 。

由于我们要对Rust代码进行模糊处理,我们希望崩溃不会简单地导致程序退出,而是引发一个 abort,然后可以被模糊器捕获。 为此,我们在 profiles 中指定 panic = "abort"

除了这个设置之外,我们还为在发布模式下的编译添加了一些优化标志,最终的 Cargo.toml 应该类似于下面的样子:

[package]
name = "baby_fuzzer"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
libafl = { path = "path/to/libafl/" }

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"
lto = true
codegen-units = 1
opt-level = 3
debug = true

被测试的函数

打开 src/main.rs,我们有一个空的 main 函数。 首先,我们创建一个我们想要模糊处理的闭包。它接受一个缓冲区作为输入,如果它以 abc 开头,就会引起崩溃:


#![allow(unused)]
fn main() {
extern crate libafl;
use libafl::inputs::{BytesInput, HasTargetBytes};

let mut harness = |input: &BytesInput| {
    let target = input.target_bytes();
    let buf = target.as_slice();
    if buf.len() > 0 && buf[0] == 'a' as u8 {
        if buf.len() > 1 && buf[1] == 'b' as u8 {
            if buf.len() > 2 && buf[2] == 'c' as u8 {
                panic!("=)");
            }
        }
    }
};
// To test the panic:
// let input = BytesInput::new("abc".as_bytes());
// harness(&input);
}

生成和运行一些测试

基于 LibAFL 的模糊测试器使用的主要组件之一是状态,这是一个在模糊测试过程中演变的数据容器。 包括所有的状态,如输入的语料库,当前的rng状态,以及测试案例和运行的潜在 Metadata。 在我们的 main 中,我们创建了一个基本的 State 实例,如下所示。

// create a State from scratch
let mut state = StdState::new(
    // RNG
    StdRand::with_seed(current_nanos()),
    // Corpus that will be evolved, we keep it in memory for performance
    InMemoryCorpus::new(),
    // Corpus in which we store solutions (crashes in this example),
    // on disk so the user can get them after stopping the fuzzer
    OnDiskCorpus::new(PathBuf::from("./crashes")).unwrap(),
    (),
);

它需要一个随机数发生器,这是模糊器状态的一部分,在这种情况下,我们使用默认的 StdRand,但你可以选择一个不同的。我们用当前的纳秒数作为种子。

作为第二个参数,它需要一个实现语料库特性的实例,本例中是 InMemoryCorpus。语料库是由模糊器演化出的测试案例的容器,在这种情况下,我们把它全部放在内存中。

我们将在后面讨论最后一个参数。第三个参数是另一个语料库,在这种情况下,用来存储被视为模糊器 "solutions" 的测试案例。对于我们的目的,solutions是触发崩溃的输入。在这种情况下,我们想把它存储在磁盘的 crashes 目录下,这样我们就可以检查它。

另一个必要的组件是 EventManager。它处理一些事件,如在模糊处理过程中向语料库添加测试案例。对于我们的目的,我们使用最简单的一个,它只是用一个 Monitor 实例向用户显示这些事件的信息。

// The Monitor trait defines how the fuzzer stats are displayed to the user
let mon = SimpleMonitor::new(|s| println!("{}", s));

// The event manager handle the various events generated during the fuzzing loop
// such as the notification of the addition of a new item to the corpus
let mut mgr = SimpleEventManager::new(mon);

此外,我们还有 Fuzzer,一个包含一些改变状态的行动的实体。其中一个动作是使用 CorpusScheduler 为模糊器调度测试案例。 我们将其创建为 QueueCorpusScheduler,一个以先进先出方式向模糊器提供测试案例的调度器。

// A queue policy to get testcasess from the corpus
let scheduler = QueueCorpusScheduler::new();

// A fuzzer with feedbacks and a corpus scheduler
let mut fuzzer = StdFuzzer::new(scheduler, (), ());

最后,我们需要一个 Executor,它是负责运行我们被测试程序的实体。在这个例子中,我们想在进程中运行 harness 函数 (例如,不 fork 出一个子程序),因此我们使用 InProcessExecutor

// Create the executor for an in-process function
let mut executor = InProcessExecutor::new(
    &mut harness,
    (),
    &mut fuzzer,
    &mut state,
    &mut mgr,
)
.expect("Failed to create the Executor");

它需要一个 harnessstate 和 事件管理器 的引用。我们将在后面讨论第二个参数。 由于执行器期望约束函数返回一个 ExitKind 对象,我们在 harness 函数中添加 ExitKind::Ok

现在我们有4个主要的实体,可以运行我们的测试,但我们仍然不能生成测试案例。

为此,我们使用一个生成器,RandPrintablesGenerator,它可以生成一串可打印的字节。

use libafl::generators::RandPrintablesGenerator;

// Generator of printable bytearrays of max size 32
let mut generator = RandPrintablesGenerator::new(32);

// Generate 8 initial inputs
state
    .generate_initial_inputs(&mut fuzzer, &mut executor, &mut generator, &mut mgr, 8)
    .expect("Failed to generate the initial corpus".into());

现在你可以在你的 main.rs 中添加必要的 use 指令,并编译模糊器。


#![allow(unused)]
fn main() {
extern crate libafl;

use std::path::PathBuf;
use libafl::{
    bolts::{current_nanos, rands::StdRand},
    corpus::{InMemoryCorpus, OnDiskCorpus, QueueCorpusScheduler},
    events::SimpleEventManager,
    executors::{inprocess::InProcessExecutor, ExitKind},
    fuzzer::StdFuzzer,
    generators::RandPrintablesGenerator,
    inputs::{BytesInput, HasTargetBytes},
    monitors::SimpleMonitor,
    state::StdState,
};
}

运行时,你应该看到类似的东西:

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.04s
     Running `target/debug/baby_fuzzer`
[LOG Debug]: Loaded 0 over 8 initial testcases

用反馈来进化语料库

现在你只是运行了8个随机生成的测试案例,但其中没有一个被存储在语料库中。如果你非常幸运,也许你偶然触发了崩溃,但你在 crashes 中没有看到任何保存的文件。

现在我们想把我们的简单模糊器变成一个基于反馈的模糊器,增加产生正确的输入来触发崩溃的机会。我们将根据达到崩溃所需的3个条件来实现一个简单的反馈。

要做到这一点,我们需要一种方法来跟踪一个条件是否被满足。为模糊器提供模糊运行属性信息的组件,即我们案例中的满足条件,是观察者。我们使用 StdMapObserver,这是一个默认的观察者,它使用一个 map 来跟踪覆盖的元素。在我们的模糊器中,每个条件都被映射到这种 map 的一个条目。

我们将这样的 map 表示为一个 static mut 变量。 由于我们不依赖于任何插桩引擎,我们必须手动跟踪 map 中被测试函数的满足条件。


#![allow(unused)]
fn main() {
extern crate libafl;
use libafl::{
    inputs::{BytesInput, HasTargetBytes},
    executors::ExitKind,
};

// Coverage map with explicit assignments due to the lack of instrumentation
static mut SIGNALS: [u8; 16] = [0; 16];

fn signals_set(idx: usize) {
    unsafe { SIGNALS[idx] = 1 };
}

// The closure that we want to fuzz
let mut harness = |input: &BytesInput| {
    let target = input.target_bytes();
    let buf = target.as_slice();
    signals_set(0);
    if buf.len() > 0 && buf[0] == 'a' as u8 {
        signals_set(1);
        if buf.len() > 1 && buf[1] == 'b' as u8 {
            signals_set(2);
            if buf.len() > 2 && buf[2] == 'c' as u8 {
                panic!("=)");
            }
        }
    }
    ExitKind::Ok
};
}

观察者可以直接从 SIGNALS map 中创建,方法如下:

// Create an observation channel using the signals map
let observer = StdMapObserver::new("signals", unsafe { &mut SIGNALS });

观察者通常被保存在相应的执行器中,因为它们所记录的信息只对一次运行有效。然后我们必须修改我们的 InProcessExecutor 创建,以包括观察者,如下所示:

// Create the executor for an in-process function with just one observer
let mut executor = InProcessExecutor::new(
    &mut harness,
    tuple_list!(observer),
    &mut fuzzer,
    &mut state,
    &mut mgr,
)
.expect("Failed to create the Executor".into());

既然模糊器可以观察到哪个条件被满足,我们就需要一种方法,根据这种观察来评定一个输入是否有趣 (即值得添加到语料库中) 。这里有一个反馈的概念,反馈是状态的一部分,它提供了一种将输入及其相应的执行评为有趣的方式,在观察者中寻找信息。反馈可以在一个所谓的 FeedbackState 实例中保持到目前为止所看到的信息的累积状态,在我们的例子中,它保持了在以前的运行中满足的条件的集合。

我们使用 MaxMapFeedback,这个反馈在 MapObserver 的 map 上实现了新奇的搜索。基本上,如果观察者的 map 中有一个值大于迄今为止为同一条目记录的最大值,它就会将该输入评为有趣的输入,并更新其状态。

反馈也被用来决定一个输入是否是一个 "solutions"。做到这一点的反馈被称为目标反馈,当它将一个输入评为有趣时,它不会被保存到语料库中,而是被保存到解决方案中,在我们的例子中被写在 crash 文件夹中。我们使用 CrashFeedback 来告诉模糊器,如果一个输入导致程序崩溃,那就是我们的解决方案。

我们需要更新我们的状态创建,包括反馈状态和模糊器,包括反馈和目标。

extern crate libafl;
use libafl::{
    bolts::{current_nanos, rands::StdRand, tuples::tuple_list},
    corpus::{InMemoryCorpus, OnDiskCorpus},
    feedbacks::{MapFeedbackState, MaxMapFeedback, CrashFeedback},
    fuzzer::StdFuzzer,
    state::StdState,
    observers::StdMapObserver,
};

// The state of the edges feedback.
let feedback_state = MapFeedbackState::with_observer(&observer);

// Feedback to rate the interestingness of an input
let feedback = MaxMapFeedback::new(&feedback_state, &observer);

// A feedback to choose if an input is a solution or not
let objective = CrashFeedback::new();

// create a State from scratch
let mut state = StdState::new(
    // RNG
    StdRand::with_seed(current_nanos()),
    // Corpus that will be evolved, we keep it in memory for performance
    InMemoryCorpus::new(),
    // Corpus in which we store solutions (crashes in this example),
    // on disk so the user can get them after stopping the fuzzer
    OnDiskCorpus::new(PathBuf::from("./crashes")).unwrap(),
    // States of the feedbacks.
    // They are the data related to the feedbacks that you want to persist in the State.
    tuple_list!(feedback_state),
);

// ...

// A fuzzer with feedbacks and a corpus scheduler
let mut fuzzer = StdFuzzer::new(scheduler, feedback, objective);

实际的模糊处理

现在,在包括正确的 use 之后,我们可以运行这个程序了,但结果与之前的并没有什么不同,因为随机生成器并没有考虑到我们在语料库中保存的有趣内容。要做到这一点,我们需要插入一个 Mutator

LibAFL 的另一个核心组件是状态,它是对来自语料库的单个输入所做的动作。例如,MutationalStage 对输入进行突变,并多次执行。

作为最后一步,我们创建了一个突变状态,它使用了一个受 AFL 的 havoc 突变器启发的突变器。

use libafl::{
    mutators::scheduled::{havoc_mutations, StdScheduledMutator},
    stages::mutational::StdMutationalStage,
    fuzzer::Fuzzer,
};

// ...

// Setup a mutational stage with a basic bytes mutator
let mutator = StdScheduledMutator::new(havoc_mutations());
let mut stages = tuple_list!(StdMutationalStage::new(mutator));

fuzzer
    .fuzz_loop(&mut stages, &mut executor, &mut state, &mut mgr)
    .expect("Error in the fuzzing loop");

fuzz_loop 将使用调度器为每个迭代向模糊器请求一个测试案例,然后它将调用状态。

加入这段代码后,我们就有了一个合适的模糊器,它可以在一秒钟内找到让函数崩溃的输入。

$ cargo run
   Compiling baby_fuzzer v0.1.0 (/home/andrea/Desktop/baby_fuzzer)
    Finished dev [unoptimized + debuginfo] target(s) in 1.56s
     Running `target/debug/baby_fuzzer`
[New Testcase] clients: 1, corpus: 2, objectives: 0, executions: 1, exec/sec: 0
[LOG Debug]: Loaded 1 over 8 initial testcases
[New Testcase] clients: 1, corpus: 3, objectives: 0, executions: 804, exec/sec: 0
[New Testcase] clients: 1, corpus: 4, objectives: 0, executions: 1408, exec/sec: 0
thread 'main' panicked at '=)', src/main.rs:35:21
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Crashed with SIGABRT
Child crashed!
[Objective] clients: 1, corpus: 4, objectives: 1, executions: 1408, exec/sec: 0
Waiting for broker...
Bye!

正如你所看到的,在崩溃信息之后,日志的 objectives 计数增加 1,你会在 crashes/ 中找到崩溃的输入。

核心概念

LibAFL是围绕一些核心概念设计的,我们认为这些概念可以有效地抽象出大多数其他的模糊器设计。

在这里,我们讨论这些概念,并提供一些与其他模糊器相关的例子。

观察者 Oberrver

观察者(Oberrver),或称观察通道,是一个实体,它向模糊器提供在被测程序执行期间观察到的信息。

观察者所包含的信息在不同的执行过程中是不被保留的。

例如,在执行过程中填充的覆盖率共享 map,以报告由 AFL 和 HonggFuzz 等模糊器使用的执行边缘,可以被认为是一个观察通道。 这种信息在不同的运行中并不保留,它是对程序的动态属性的观察。

就代码而言,在库中,这个实体由 Oberrver 特性描述。

除了保存与目标的最后一次执行有关的易失性数据外,实现这一特性的结构可以定义一些执行钩子,在每个模糊情况前后执行。在这个钩子中,观察者可以修改模糊器的状态。

执行器 Executor

在不同的fuzzers中,这种执行被测程序的概念意味着每次运行都是一样的。 例如,对于像 libFuzzer 这样的内存模糊器来说,执行是对一个被测试函数的调用,而对于像 kAFL 这样的基于管理程序的模糊器来说,每次运行都会从一个快照启动整个操作系统。

在我们的模型中,执行者(Executor) 是一个实体,它不仅定义了如何执行目标,还定义了所有与目标的单一运行有关的易失性操作。

因此,执行者负责告知程序模糊器要在运行中使用的输入,例如写到一个内存位置或作为参数传递给约束函数。

在我们的模型中,它还可以持有一组与每个执行程序相关的观察者。

在 Rust 中,我们将这个概念与 Executor trait 绑定。实现这个特性的结构如果想持有一组观察者,也必须实现 HasObservers

默认情况下,我们实现了一些常用的执行器,如 InProcessExecutor,其目标是提供进程中崩溃检测的线程函数。另一个执行器是 ForkserverExecutor,它实现了一个类似于 AFL 的机制,用来生成子进程进行模糊处理。

在创建执行器时,一个常见的模式是包装现有的执行器,例如 TimeoutExecutor 包装了一个执行器,并在调用被包装的执行器的原始运行函数之前安装一个超时回调。

InProcessExecutor

让我们从基本情况开始: InProcessExecutor。 这个执行器使用 SanitizerCoverage 作为其后端,你可以在 libafl_targets/src/sancov_pcguards 中找到相关代码。在这里,我们分配了一个名为 EDGES_MAP 的 map,然后我们的编译器包装器编译约束函数,将覆盖率写入这个 map 中。 当你想尽可能快地执行约束函数时,你很可能想使用 InprocessExecutor

这里需要注意的是,当你的约束函数有可能出现堆损坏的问题时,你要使用另一个分配器,这样损坏的堆就不会影响到模糊器本身。(例如,我们在一些模糊器中采用 MiMalloc) 。另外,你也可以启用 AddressSanitizer 来编译你的约束函数,以确保你能捕捉到这些堆的错误。

ForkserverExecutor

接下来,我们来看看 ForkserverExecutor。在这种情况下,是 afl-cc (来自AFLplus/AFLplus) 在编译约束函数代码,因此,我们不能再使用 EDGES_MAP。希望我们有 a way 告诉 forkserver 哪个 map 来记录覆盖率。

你可以从 forkserver 的例子中看到:

//Coverage map shared between observer and executor
let mut shmem = StdShMemProvider::new().unwrap().new_shmem(MAP_SIZE).unwrap();
//let the forkserver know the shmid
shmem.write_to_env("__AFL_SHM_ID").unwrap();
let mut shmem_buf = shmem.as_mut_slice();

这里我们建立一个共享内存区域: shmem,并将其写入环境变量 __AFL_SHM_ID。然后,被检测的二进制文件或 forkerver 会找到这个共享内存区域 (来自上述环境变量) 来记录其覆盖范围。在你的fuzzer方面,你可以将这个shmem map传递给你的 Observer,以获得与任何 Feedback 相结合的覆盖率反馈。

ForkserverExecutor 的另一个特点是共享内存测试案例。在正常情况下,突变的输入是通过 .cur_input 文件在 forkerver 和被测二进制之间传递。你可以通过用共享内存传递输入来提高你的 forkserver 模糊器的性能。 参见 AFL++ 的 documentationforkserver_simple/src/program.c 中的fuzzer例子以供参考。

这很简单,当你调用 ForkserverExecutor::new() 并将 use_shmem_testcase 设为true时,ForkserverExecutor 会将事情设置好,你的约束函数就可以从 __AFL_FUZZ_TESTCASE_BUF 获取输入。

InprocessForkExecutor

最后,我们来谈谈 InProcessForkExecutorInProcessForkExecutorInprocessExecutor 只有一个区别: 它在运行约束函数之前进行 fork,仅此而已。 但为什么我们要这样做呢?好吧,在某些情况下,你可能会发现你的约束函数非常不稳定,或者你的约束函数对全局状态造成了破坏。在这种情况下,你想在子进程中执行约束函数运行之前将其 fork,这样就不会破坏事情。 然而,我们必须照顾到共享内存,是子进程在运行约束函数代码,并将覆盖范围写到 map 上。 我们必须使 map 在父进程和子进程之间共享,所以我们将再次使用共享内存。你应该用 pointer_maps (用于 libafl_targes) 功能来编译你的约束函数,这样,我们可以有一个指针: EDGES_MAP_PTR,可以指向任何覆盖图。 在你的fuzzer方面,你可以分配一个共享内存区域,让 EDGES_MAP_PTR 指向你的共享内存。

let mut shmem;
unsafe{
    shmem = StdShMemProvider::new().unwrap().new_shmem(MAX_EDGES_NUM).unwrap();
}
let shmem_buf = shmem.as_mut_slice();
unsafe{
    EDGES_PTR = shmem_buf.as_ptr();
}

同样,你可以把这个 shmem map 传递给你的 ObserverFeedback 以获得覆盖率反馈。

反馈 Feedback

反馈(Feedback) 是一个将被测程序的执行结果分类为有趣或不有趣的实体。 通常情况下,如果一个执行是有趣的,那么用于输入目标程序的相应输入就会被添加到一个语料库中。

大多数时候,反馈的概念与观察者有很深的联系,但它们是不同的概念。

反馈,在大多数情况下,处理一个或多个观察者报告的信息,以决定执行是否有趣。 有趣 的概念是抽象的,但通常它与新颖性搜索有关 (即有趣的输入是那些达到控制流图中以前未见过的边缘的输入) 。

举个例子,给定一个报告所有内存分配大小的观察者,可以用一个最大化反馈来最大化这些大小,以运动内存消耗方面的病态输入。

在代码方面,该库提供了 FeedbackFeedbackState 特性。 第一个用于实现一个算子,在给定最后一次执行的观察者的状态时,告诉他们这次执行是否有趣。第二个是与 反馈 联系在一起的,它是反馈希望在模糊器的状态中坚持的数据的状态,例如,在基于边缘覆盖率的反馈的情况下,持有到目前为止看到的所有边缘的累积图。

多个反馈可以结合成布尔公式,例如,如果一个执行触发了新的代码路径,或者执行时间比平均执行时间短,就可以认为它是有趣的。feedback_or.

TODO目标反馈和快速反馈逻辑运算符

输入 Input

从形式上看,程序的输入(Input)是指从外部来源获取的影响程序行为的数据。

在我们的抽象模糊器模型中,我们将输入定义为程序输入 (或其一部分) 的内部表示。

在直接的情况下,程序的输入是一个字节数组,在AFL这样的模糊器中,我们正是存储和操作这些字节数组。

但情况并不总是这样。一个程序可能期望的输入不是字节数组 (例如一连串的系统调用),而模糊器并不以程序消耗输入的相同方式来表示。

以语法模糊器为例,输入通常是抽象语法树,因为它是一种数据结构,在保持有效性的同时可以很容易地进行操作,但程序期望输入是一个字节数组,所以在执行之前,树被序列化为一个字节的顺序。

在Rust代码中,Input 是一个 trait,只能由可序列化的结构来实现,并且只有拥有的数据作为字段。

语料库 Corpus

语料库(Corpus) 是存储测试案例的地方。我们将一个测试案例定义为一个输入和一组相关的元数据,例如,执行时间。

语料库可以用不同的方式存储测试用例,例如在磁盘上,或在内存中,或实现缓存以加快磁盘存储。

通常,当一个测试案例被认为是有趣的时候,它就会被添加到语料库中,但是语料库也被用来存储实现目标的测试案例 (比如说,测试程序崩溃) 。

与语料库相关的是,模糊检验器应要求从语料库中挑选下一个测试案例进行模糊检验。LibAFL中的分类法是CorpusScheduler,该实体代表了从语料库中提取测试案例的策略,例如FIFO。

谈到代码,CorpusCorpusScheduler 是 trait。

突变器 Mutator

突变器(Mutator) 是一个接受一个或多个输入并生成一个新的派生输入的实体。

突变器可以被组成,它们通常与特定的输入类型相联系。

例如,可以有一个 Mutator 在输入上应用不止一种类型的突变。考虑一个字节流的通用突变器,比特翻转只是可能的突变之一,但不是唯一的突变,还有,例如,随机替换一个字节的块的拷贝。

在LibAFL中,Mutator是一个 trait

生成器 Generator

生成器(Generator) 是一个旨在从头生成输入的组件。

通常情况下,随机发生器被用来生成随机输入。

生成器在传统上较少用于反馈驱动的模糊测试,但也有例外,如 Nautilus,它使用语法生成器来创建初始语料库,并使用子树生成器作为其语法突变器的突变。

在代码中,Generator是一个 trait

阶段 Stage

阶段(Stage) 是一个对 从语料库中得到的单一输入 进行操作的实体。

例如,一个突变阶段,给定一个语料库的输入,应用一个突变器并执行一次或多次生成的输入。例如,AFL使用输入的性能分数来选择应该调用多少次破坏性的突变。这也可以取决于其他参数,例如,如果我们想只是应用一个连续的比特翻转,那么输入的长度也可以是一个固定的值。

一个阶段也可以是一个分析阶段,例如,Redqueen的着色阶段旨在为测试案例引入更多的熵,或者AFL的修剪阶段旨在减少测试案例的大小。

在LibAFL代码库中,有几个实现 Stage 特性的阶段。

设计

在这一章中,我们讨论了我们是如何在考虑到核心概念的情况下设计这个库的,同时考虑代码重用和可扩展性。

架构

LibAFL 的架构是围绕着一些实体建立的,以允许代码重用和低成本的抽象。

最初,我们开始考虑用一种面向对象的语言来实现 LibAFL,比如 C++。当我们了解到Rust时,我们立即改变了想法,因为我们意识到,虽然Rust允许某种OOP模式,但我们可以用一种更理智的方法来构建这个库,就像本博文 中描述的关于Rust中的游戏设计。

LibAFL 的代码重用方式是基于组件而不是子类的,但库中仍有一些OOP模式。

考虑到类似的模糊器,你可以观察到大多数时候被修改的数据结构是与测试案例和模糊器全局状态有关的。

除了之前描述的实体外,我们引入了 TestcaseState 实体。测试案例是存储在语料库中的输入及其元数据的容器 (因此,在实现中,语料库存储测试案例),状态包含在运行模糊器时演变的所有元数据,包括语料库。

在实现中,状态只包含可序列化的自有对象,它本身也是可序列化的。有些模糊器可能希望在暂停时序列化其状态,或者在进行进程内模糊处理时,在崩溃时序列化,并在新进程中反序列化,以继续模糊处理,并保留所有元数据。

此外,我们将 actions 的实体,如 CorpusScheduler 和 Feedbacks,归入一个共同的地方,即 `Fuzzer'

元数据

LibAFL 中的元数据是一个自包含的结构,持有与状态或测试案例相关的数据。

在代码方面,元数据可以被定义为一个在 SerdeAny 寄存器中注册的Rust结构。


#![allow(unused)]
fn main() {
extern crate libafl;
extern crate serde;

use libafl::SerdeAny;
use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize, SerdeAny)]
pub struct MyMetadata {
    //...
}
}

这个结构必须是静态的,所以它不能持有对借用对象的引用。

作为 libafl_derive 中的 proc-macro,用户可以使用 libafl::impl_serdeany!(MyMetadata); 来替代 derive(SerdeAny)

用法

元数据对象主要用于 SerdeAnyMapNamedSerdeAnyMap 中。

通过这些 map,用户可以按类型 (和名称) 检索实例。在内部,这些实例被存储为SerdeAny trait 对象。

想拥有一套元数据的结构必须实现 HasMetadata trait。

默认情况下,Testcase 和 State实现了它,并持有一个 SerdeAnyMap 测试案例。

序列化和反序列化

我们对存储状态的元数据感兴趣,以便在崩溃或模糊器停止的情况下不会丢失它们。要做到这一点,它们必须使用 Serde 进行序列化和非序列化。

由于元数据是作为 trait 对象存储在 SerdeAnyMap 中的,所以默认情况下它们不能用 Serde 进行反序列化。

为了解决这个问题,在 LibAFL 中,每个 SerdeAny 结构都必须在一个全局注册表中注册,该注册表可以跟踪类型并允许对注册的类型进行 (反) 序列化。

通常情况下,impl_serdeany 宏为用户创建一个构造函数来填充注册表。然而,当在 no_std 模式下使用 LibAFL 时,这个操作必须在 main 函数的任何其他操作之前手动进行。

要做到这一点,开发者需要知道模糊器内部使用的每个元数据类型,并在 main 的开头为每个元数据调用 RegistryBuilder::register::<MyMetadata>()

消息传递

LibAFL 提供了一个标准的机制,用于在进程和机器上进行低开销的消息传递。 我们使用消息传递来通知其他连接的客户端/模糊器/节点关于新的测试案例、元数据和关于当前运行的统计数据。 根据个人需要,LibAFL 也可以将测试案例的内容写到磁盘上,同时仍然使用事件来通知其他模糊器,使用一个 OnDiskCorpus.

在我们的测试中,消息传递可以很好地在多个运行中的模糊器实例之间分享新的测试案例和元数据,以进行多核模糊处理。 具体来说,它比在共享语料库上使用内存锁要好得多,也比通过文件系统共享测试案例要好得多,就像 AFL 传统的做法。 用起来 htop所有核心都是绿色的,也就是说,没有内核交互。

EventManager 接口用于使用 Low Level Message Passing 发送事件,这是一种通过共享内存或TCP的自定义消息传递机制。

低水平消息传递(LLMP)

LibAFL 有一个合理的无锁消息传递机制,可以很好地跨核扩展,使用其 broker2broker 机制,甚至可以通过TCP连接机器。 大多数模糊测试的例子都使用这种机制,如果你想在一个以上的核心上进行模糊测试,它是最好的 事件管理器。 在下文中,我们将描述 LLMP 的内部工作原理。

LLMP 有一个 broker 进程,可以将任何客户进程发送的消息转发给所有其他客户。 broker 也可以拦截和过滤它收到的消息,而不是转发它们。 broker 过滤的信息的一个常见用例是每个客户直接发送给 broker 的状态信息。 broker 用这些信息来绘制一个简单的用户界面,其中有所有客户的最新信息,然而其他客户不需要接收这些信息。

通过共享内存的快速本地消息

在整个 LibAFL 中,我们使用了一个围绕不同操作系统的共享 map 的包装器,称为 ShMem,它是 LLMP 的骨干。 每个client,通常是试图分享统计数据和新的测试案例的摸索者,都会映射一个输出的 ShMem map。 除了极少数的例外,只有这个客户写到这个 map 上,因此,我们不会在竞赛条件下运行,可以不用锁。 broker 从所有客户的 ShMem 映射中读取。 它定期检查所有传入的客户端映射,然后将新消息转发到由所有连接的客户端映射的 出站广播-`ShMem。

为了发送新消息,客户端在其共享内存的末端放置一个新消息,然后更新一个静态字段来通知代理。 一旦传出的映射已满,发送者使用各自的 ShMemProvider 分配一个新的 ShMem。 然后,它使用页面结束 (EOP) 消息发送所需信息,将连接进程中新分配的页面映射到旧的页面。 一旦接收者映射了新的页面,就把它标记为安全的,可以从发送进程中解除映射 (如果我们在短时间内有超过一个EOP,就可以避免竞赛条件),然后继续从新的 ShMem 中读取。

Client 对 broker 的映射模式如下:

[client0]        [client1]    ...    [clientN]
  |                  |                 /
[client0_out] [client1_out] ... [clientN_out]
  |                 /                /
  |________________/                /
  |________________________________/
 \|/
[broker]

broker 在所有传入的 map 上循环,并检查新的消息。 在 std 构建中,broker 会在循环后睡眠几毫秒,因为我们不需要消息立即到达。 在 broker 收到来自客户端N的新消息后,(clientN_out->current_id != last_message->message_id) broker 会将消息内容复制到自己的广播共享内存。

客户端定期地,例如在完成 n 次突变后,通过检查是否有新的消息进入 (current_broadcast_map->current_id != last_message->message_id) 。 虽然 broker 使用相同的 EOP 机制为其传出的 map 映射新的 ShMem,但它从不解除旧页面的映射。 这种额外的内存开销有一个很好的目的: 通过保留所有的广播页面,我们确保新的客户可以在以后的时间点加入到模糊测试活动中来,他们只需要从头到尾重新阅读所有广播的信息。

所以传出的消息在传出的广播 Shmem 上是这样流动的:

[broker]
  |
[current_broadcast_shmem]
  |
  |___________________________________
  |_________________                  \
  |                 \                  \
  |                  |                  |
 \|/                \|/                \|/
[client0]        [client1]    ...    [clientN]

要在 LibAFL 中使用 LLMP,你通常要使用 LlmpEventManager 或其重启的变体。 如果使用 LibAFL 的 Launcher,它们是默认的。

如果你想使用 LLMP 的原始形式,没有任何 LibAFL 的抽象,看看 ./libafl/examples 中的 llmp_test 例子。 你可以使用 cargo run --example llmp_test 以适当的模式运行这个例子,正如其帮助输出所指出的。 首先,你必须使用 LlmpBroker::new() 创建一个broker 。 然后,在其他线程中创建一些 LlmpClients,并使用 LlmpBroker::register_client 在主线程中注册它们。 最后,调用 LlmpBroker::loop_forever()

B2B: 通过TCP连接模糊器

对于 broker2broker 的通信,所有的广播信息都通过网络套接字转发。 为了方便起见,我们在 broker 中产生了一个额外的客户线程,它可以像其他客户那样读取广播共享内存。 对于 b2b 的通信,这个 b2b 客户端监听来自其他远程 broker 的 TCP 连接。 它在任何时候都保持一个开放的套接字池,用于连接其他远程的b2b broker。 当在本地 broker 共享内存中收到一个新消息时,b2b 客户端将通过 TCP 将其转发给所有连接的远程 broker。 另外,broker 可以从所有连接的 (远程) broker 那里接收消息,并通过客户端 ShMem 转发给本地broker。

作为附带说明,用于 b2b 通信的 tcp 监听器也用于新客户试图连接到本地 broker 时的初始握手,简单地交换初始 ShMem 描述。

派生实例

多个模糊器实例可以通过不同的方式产生。

手动,通过一个TCP端口

做多线程的直接方法是使用 LlmpRestartingEventManager,特别是使用 setup_restarting_mgr_std

它抽象化了所有讨厌的细节,如崩溃处理时的重启 (针对内存模糊器) 和多线程。 有了它,你手动启动的每个实例都会尝试连接到本地机器上的一个 TCP 端口。

如果这个端口还没有被绑定,这个实例就会成为代理,它自己会绑定到这个端口以等待新的客户。

如果该端口已经被绑定,EventManager 将尝试连接到它。 该实例成为客户端,现在可以与所有其他节点通信。

手动启动节点的好处是,你可以有多个具有不同配置的节点,比如客户端在有 ASAN 和没有 ASAN 的情况下进行模糊处理。

虽然它被称为 restarting 管理器,但它在Unix操作系统上使用 fork 作为优化,在 Windows 上只实际从头开始重启。

启动器 Launcher

启动器(Launcher) 是 lazy 模式启动多线程。

你可以使用 Launcher::builder 来创建一个产生多个节点的模糊器,所有这些节点都使用重新启动的事件管理器。

看一个例子:

    Launcher::builder()
        .configuration(EventConfig::from_name(&configuration))
        .shmem_provider(shmem_provider)
        .monitor(mon)
        .run_client(&mut run_client)
        .cores(cores)
        .broker_port(broker_port)
        .stdout_file(stdout_file)
        .remote_broker_addr(broker_addr)
        .build()
        .launch()

首先启动一个代理,然后根据传递给 cores 的值生成 n 个客户端。 这个值是一个字符串,表示要绑定的核心,例如, 0,2,50-3。 对于每个客户端,run_client 将被调用。 在Windows上,启动器将重新启动每个客户端,而在Unix上,它将使用fork

其他方式

LlmpEvenManager 系列是派生实例的最简单的方法,但对于不明显的目标,你可能需要想出其他的解决方案。

LLMP 甚至在理论上是 no_std 兼容的,甚至完全不同的 EventManagers 也可以用于消息传递。

如果你遇到这种情况,请阅读当前的实现或与我们联系。

配置 Configurations

单个模糊器节点的配置与多节点模糊计算有关。 本章介绍了如何在一个模糊测试集群中运行具有不同配置的节点 在一个模糊测试集群中运行不同配置的节点。 例如,这允许一个用 ASAN 编译的节点知道它需要为一个没有 ASAN 的节点重新运行新的测试案例,而同样的二进制/配置则不需要。

正在建设中!

本节正在建设中。 请稍后再来检查 (或打开PR) 。

教程

在本章中,我们将使用Rust中的 Lain 突变器构建一个自定义模糊器。

本教程将向你介绍如何编写LibAFL的扩展,如Feedbacks和Testcase的元数据。

介绍

正在建设中!

本节正在建设中。 请稍后再来检查 (或打开一个PR) 。

Advanced Features

除了摸索器的核心构建模块,LibAFL还具有更多高级/小众摸索技术的功能。

下面的章节专门介绍这些功能。

Concolic Tracing和混合模糊测试

LibAFL 支持基于 SymCC 插桩编译器的协程跟踪。

对于那些没有经验的人来说,下面将尝试用一个例子从头开始描述协程跟踪。 然后,我们将讨论 SymCC 和 LibAFL 协程跟踪的关系。 最后,我们将通过使用 LibAFL 构建一个基本的混合模糊器。

Concolic Tracing的例子

Concolic Tracing

Concolic 是单词concrete(具体)和symbolic(符号)的一个混合体

假设你想对以下程序进行模糊测试:


#![allow(unused)]
fn main() {
fn target(input: &[u8]) -> i32 {
    match &input {
        // fictitious crashing input
        &[1, 3, 3, 7] => 1337,
        // standard error handling code
        &[] => -1,
        // representative of normal execution
        _ => 0 
    }
}
}

一个简单的覆盖率最大化的模糊器在某种程度上随机地产生新的输入,将很难找到触发虚构的崩溃输入的一个输入。 许多技术被提出来,以使模糊处理不那么随机,而是更直接地试图改变输入,以翻转特定的分支,例如参与崩溃上述程序的分支。

并行追踪允许我们以 分析的方式而不是 随机的方式 (即猜测) 构建一个输入,以尝试程序中的一个新路径 (比如例子中的崩溃路径) 。 原则上,协程跟踪的工作方式是观察程序执行过程中所有依赖输入的执行指令。 为了理解这一点,我们将以上述程序为例进行说明。

首先,我们将程序简化为简单的 if-then-else-statements:


#![allow(unused)]
fn main() {
fn target(input: &[u8]) -> i32 {
    if input.len() == 4 {
        if input[0] == 1 {
            if input[1] == 3 {
                if input[2] == 3 {
                    if input[3] == 7 {
                        return 1337;
                    } else {
                        return 0;
                    }
                } else {
                    return 0;
                }
            } else {
                return 0;
            }
        } else {
            return 0;
        }
    } else {
        if input.len() == 0 {
            return -1;
        } else {
            return 0;
        }
    }
}
}

接下来我们跟踪输入 [].

跟踪会像下面这样:

Branch { // if input.len() == 4
    condition: Equals { 
        left: Variable { name: "input_len" }, 
        right: Integer { value: 4 } 
    }, 
    taken: false // This condition turned out to be false...
}
Branch { // if input.len() == 0
    condition: Equals { 
        left: Variable { name: "input_len" }, 
        right: Integer { value: 0 } 
    }, 
    taken: true // This condition turned out to be true!
}

利用这个跟踪,我们可以很容易地推断出,我们可以通过一个长度为4的输入或者一个非零长度的输入来迫使程序采取不同的路径。 我们通过否定每个分支条件并分析解决所产生的 expression 来做到这一点。 事实上,我们可以为任何计算创建这些表达式,并把它们交给 SMT-Solver,它将生成一个满足表达式的输入 (只要这种输入存在) 。

在混合模糊计算中,我们将这种追踪+求解的方法与更传统的模糊计算技术相结合。

LibAFL、SymCC和SymQEMU中的协程跟踪

LibAFL中的协程跟踪支持是通过 SymCC 实现的。 SymCC 是 clang 的一个编译器插件,可以用来替代普通的 C 或 C++ 编译器。 SymCC 将对编译后的代码进行回调,使之成为一个可以由用户提供的运行时。 这些回调允许运行时构建一个类似于前面例子的跟踪。

SymCC和它的运行时

SymCC有两个运行时:

  • 一个 "简单的 "运行时,它试图用 Z3 来解决它遇到的任何分支,以及
  • 一个基于 QSym 的运行时,它对表达式进行了更多的过滤,也使用Z3进行求解

然而,与LibAFL的集成需要你使用 symcc_runtime crate进行 BYORT (bring your own runtime) 。 这个工具箱允许你轻松地从内置的构建模块中建立一个自定义的运行时,或者以完全的灵活性创建全新的运行时。 查看 symcc_runtime 文档,了解更多关于如何建立你自己的运行时的信息。

SymQEMU

SymQEMU 是SymCC的一个兄弟项目。 它不是在编译时对目标进行检测,而是通过动态二进制翻译插入检测,建立在 QEMU 仿真栈之上。 这意味着使用SymQEMU,任何 (x86) 二进制文件都可以被追踪,而不需要提前建立插桩。 symcc_runtime 工具箱支持这种使用情况,用 symcc_runtime 构建的运行时也可用于SymQEMU。

LibAFL中的混合型模糊处理

LibAFL资源库中包含了一个混合模糊器实例

使用LibAFL构建一个混合型模糊器主要有三个步骤。

  1. 建立一个运行时间
  2. 选择一个工具化的方法和
  3. 构建模糊器

请注意,这些步骤的顺序是很重要的。 例如,在用SymCC进行插桩分析之前,我们需要先准备好运行时间。

建立一个运行时

使用 symcc_runtime 板块可以很容易地构建一个自定义运行时。 注意,自定义运行时是一个单独的共享对象文件,这意味着我们需要一个单独的crate用于我们的运行时。 请查看 混合模糊器的运行时间示例symcc_runtime docs 以获得灵感。

工具化

在LibAFL中,有两种主要的工具化方法来使用协程跟踪。

  • 使用编译时插桩化的目标与SymCC

这只有在目标的源代码可用,并且目标很容易使用SymCC编译器包装器构建的情况下才有效

  • 使用SymQEMU在**运行时动态地检测目标

这避免了一个单独的插桩化目标与协程跟踪插桩化,而且不需要源代码。 然而,应该注意的是,生成的表达式的 "质量 "可能会大大降低,而且SymQEMU通常比SymCC生成的表达式要多得多,而且明显更曲折。 因此,建议尽可能使用SymCC而不是SymQEMU。

使用 SymCC

在使用SymCC进行模糊测试之前,需要对目标进行检测。 具体如何做并不重要。 然而,SymCC编译器需要知道它应该检测的运行时间的位置。 这可以通过将 SYMCC_RUNTIME_DIR 环境变量设置为包含运行时的目录来实现 (通常是你的运行时板块的 target/(debug|release) 文件夹) 。

混合模糊器的例子在其 build.rs构建脚本 中检测目标。 它通过克隆和构建SymCC的副本来实现,然后使用这个版本来检测目标。 symcc_libafl crate 包含用于克隆和构建SymCC的辅助函数。

在尝试构建SymCC之前,请确保你满足SymCC的 构建要求

使用SymQEMU

根据 SymQEMU 的 构建说明 来构建它。 默认情况下,SymQEMU 会在一个同级目录下寻找运行时。 由于我们没有运行时,我们需要让它知道你的运行时的路径,将 --symcc-build 脚本的参数设置为你的运行时的路径。

构建模糊器

无论采用哪种方法,现在模糊器和被检测目标之间的接口应该是一致的。 使用SymCC和SymQEMU的唯一区别应该是代表目标的二进制文件。 在SymCC的情况下,它将是用插桩构建的二进制文件,而在SymQEMU的情况下,它将是模拟器的二进制文件 (例如: x86_64-linux-user/symqemu-x86_64),后面是你的非插桩化的目标二进制文件和论据。

你可以使用 CommandExecutor 来执行你的目标 (example) 。 在配置命令时,如果你的目标从文件中读取输入 (而不是标准输入),请确保传递 SYMCC_INPUT_FILE 环境变量的输入文件路径。

序列化和解算

虽然完全可以建立一个自定义的运行时,在目标进程的背景下执行混合模糊的求解步骤,但LibAFL协程跟踪支持的预期用途是使用 TracingRuntime 对 (过滤和预处理的) 分支条件进行序列化。 这个序列化的表述可以在模糊程序中被反序列化,以便使用 ConcolicObserver 包裹在 ConcolicTracingStage 中进行解决,它将在每个 TestCase 中附加一个 ConcolicMetadata

ConcolicMetadata'可以用来重放协程跟踪,并使用SMT-Solver进行解决。 然而,大多数涉及协程跟踪的用例都需要围绕他们想要解决的分支定义一些策略。 [SimpleConcolicMutationalStage`](https://docs.rs/libafl/0.6.0//libafl/stages/concolic/struct.SimpleConcolicMutationalStage.html) 可用于测试目的。 它将尝试解决所有分支,就像SymCC的原始简单后端一样,使用Z3。

示例

这个例子说明了如何使用 ConcolicTracingStageSimpleConcolicMutationalStage 来建立一个基本的混合模糊器。

no_std 环境中使用 LibAFL

可以在 no_std 环境中使用 LibAFL,例如自定义平台,如微控制器、内核、管理程序等等。

你可以简单地将 LibAFL 添加到你的 Cargo.toml 文件中:

libafl = { path = "path/to/libafl/", default-features = false}

然后构建你的项目,例如为 aarch64-unknown-none 使用:

cargo build --no-default-features --target aarch64-unknown-none

使用自定义时间戳

LibAFL 对 no_std 的最小输入量是一个单调增长的时间戳。 为此,在你项目的任何地方,你需要实现 external_current_millis 函数,它以毫秒为单位返回当前时间。

// 假设这是一个来自自定义stdlib的时钟源,你想使用它,它以秒为单位返回当前时间。

int my_real_seconds(void)
{
    return *CLOCK;
}

而在这里我们在 Rust 中使用它,external_current_millis 会被LibAFL调用。 注意,它需要是 no_mangle,以便在链接时被 LibAFL 接受。

#[no_mangle]
pub extern "C" fn external_current_millis() -> u64 {
    unsafe { my_real_seconds()*1000 }
}