利用模糊测试在周末赚取20万美元:第一部分

本文详细介绍了如何通过应用模糊测试技术发现rBPF虚拟机中的漏洞,包括基础模糊测试器构建、语法感知测试方法以及差异测试实现,最终获得超过20万美元的漏洞赏金。

利用模糊测试在周末赚取20万美元:第一部分

通过将知名的模糊测试技术应用于热门目标,我发现了多个漏洞,总共获得了超过20万美元的赏金。在本文中,我将展示当模糊测试应用于尚未经过充分测试的软件时,其威力有多大。

如果你只是为了漏洞披露而来,请参阅第二部分,但我鼓励所有人,即使是尚未尝试过模糊测试的人,都阅读本文。

背景

我和几个朋友运行了一个小型Discord服务器(现在是Matrix空间),我们在其中讨论安全和漏洞研究技术。我们在服务器中运行的一个机器人会发布每一个新出现的CVE。是的,我阅读了很多CVE。

有一天,机器人发布的内容引起了我的注意:

这标志着我们时间线的开始:1月28日。我特别注意到这个CVE有两个原因:

  • 它是BPF,我认为这是一个非常酷的概念,因为它用于Linux内核(内核中的JIT编译器!!!什么!!!)
  • 它是一个用Rust编写的JIT编译器

这个CVE几乎是在我为自己的Rust软件(具体来说,是一个用于验证仓库番解决方案的crate,我观察到类似问题并认为"看起来很熟悉")开发了一些相对密集的模糊测试之后立即出现的。

根据我从模糊测试自己软件的经验中学到的知识,以及知道使用cargo fuzz和arbitrary组合可以相当容易地发现Rust程序中的错误,我想:“嘿,为什么不试试呢?”

目标及测试方法

Solana,正如你们许多人可能知道的那样,“是一个去中心化的区块链,旨在为世界提供可扩展、用户友好的应用程序”。他们主要以他们的加密货币SOL而闻名,但也是一个运行任何形式智能合约的区块链。

特别是rBPF,自称是"用于eBPF程序的Rust虚拟机和JIT编译器"。值得注意的是,它实现了BPF程序的解释器和JIT编译器。换句话说:同一程序的两种不同实现,理论上在执行时表现出相同的行为。

我很幸运在大学里选修了软件测试课程,并且是一个进行模糊测试的研究小组的成员(诚然,我们是在模糊测试硬件,而不是软件,但概念是相通的)。我特别坚持的一个概念是测试预言(test oracle)的想法——一种区分被测设计中"正确"行为和"不正确"行为的方法。

特别是,rBPF中同时存在解释器和JIT编译器这一点让我印象深刻,因为我们实际上有一个完美的伪预言;正如维基百科所说:

一个单独编写的程序,可以接受与被测程序或系统相同的输入,以便可以比较它们的输出,以了解是否存在需要调查的问题。

那些在模糊测试方面有更多经验的人会认识到这个概念是差分模糊测试(differential fuzzing),但我认为我们经常忽视差分模糊测试只是伪预言的另一个方面。

在这个特定情况下,我们可以执行解释器(rBPF的一种实现),然后使用相同的输入(即内存状态、入口点、代码等)执行JIT编译版本(另一种实现),并查看它们的输出是否不同。如果不同,根据rBPF crate的描述,其中一方必然是不正确的:两种实现的行为完全相同。

编写模糊测试器

首先,让我们尝试向其抛出一堆输入,而不进行任何特定的调整。这使我们能够理智检查我们的基本模糊测试实现是否按预期工作。

简单模糊测试器

首先,我们需要弄清楚如何执行解释器。幸运的是,在各种测试中有几个现成的例子。我参考了ubpf_execution.rs中存在的test_interpreter_and_jit宏,作为我所谓的"简单"模糊测试器执行的基础。

我提供了一系列组件,你可以在查看整个模糊测试器之前一次查看一个块。只需点击下拉菜单即可查看与该步骤相关的代码。你不一定需要理解这篇文章的重点。

步骤1:定义我们的输入

我们必须定义我们的输入,使其对我们的模糊测试器真正有用。幸运的是,arbitrary使得从原始字节派生输入变得几乎微不足道。

1
2
3
4
5
6
#[derive(arbitrary::Arbitrary, Debug)]
struct DumbFuzzData {
    template: ConfigTemplate,
    prog: Vec<u8>,
    mem: Vec<u8>,
}

如果你想查看ConfigTemplate的定义,可以在common.rs中查看,但你需要知道的是,其目的是在各种不同的执行配置下测试解释器。理解模糊测试器的基本部分并不是特别重要。

步骤2:设置VM

接下来是设置模糊测试目标和VM。这将使我们不仅能够执行测试,而且以后还能实际检查行为是否正确。

 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
fuzz_target!(|data: DumbFuzzData| {
    let prog = data.prog;
    let config = data.template.into();
    if check(&prog, &config).is_err() {
        // 请验证
        return;
    }
    let mut mem = data.mem;
    let registry = SyscallRegistry::default();
    let mut bpf_functions = BTreeMap::new();
    register_bpf_function(&config, &mut bpf_functions, &registry, 0, "entrypoint").unwrap();
    let executable = Executable::<UserError, TestInstructionMeter>::from_text_bytes(
        &prog,
        None,
        config,
        SyscallRegistry::default(),
        bpf_functions,
    )
    .unwrap();
    let mem_region = MemoryRegion::new_writable(&mut mem, ebpf::MM_INPUT_START);
    let mut vm =
        EbpfVm::<UserError, TestInstructionMeter>::new(&executable, &mut [], vec![mem_region]).unwrap();

    // 步骤3中的TODO
});

你可以从Rust Fuzz Book中找到fuzz_target如何工作的详细信息,它比这里更适合的地方更详细地介绍了它的工作原理。

步骤3:执行我们的输入并比较输出

在这一步中,我们只是用提供的输入执行VM。在未来的迭代中,我们将比较解释器与JIT的输出,但在这个版本中,我们只是执行解释器,看看是否能引起崩溃。

1
2
3
4
5
6
7
fuzz_target!(|data: DumbFuzzData| {
    // 参见步骤2的这一部分

    drop(black_box(vm.execute_program_interpreted(
        &mut TestInstructionMeter { remaining: 1024 },
    )));
});

我在这里使用black_box,但我不完全相信这是必要的。我添加它是为了确保解释程序执行的结果不会被简单地丢弃,从而标记执行不必要,但我相当确定无论如何都不会。

注意,我们在这里不检查执行是否失败。如果BPF程序失败:我们不关心!我们只关心VM是否因任何原因崩溃。

步骤4:整合

以下是模糊测试器的最终代码,包括为了简洁而未在上面显示的所有部分。

 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
#![feature(bench_black_box)]
#![no_main]

use std::collections::BTreeMap;
use std::hint::black_box;

use libfuzzer_sys::fuzz_target;

use solana_rbpf::{
    ebpf,
    elf::{register_bpf_function, Executable},
    memory_region::MemoryRegion,
    user_error::UserError,
    verifier::check,
    vm::{EbpfVm, SyscallRegistry, TestInstructionMeter},
};

use crate::common::ConfigTemplate;

mod common;

#[derive(arbitrary::Arbitrary, Debug)]
struct DumbFuzzData {
    template: ConfigTemplate,
    prog: Vec<u8>,
    mem: Vec<u8>,
}

fuzz_target!(|data: DumbFuzzData| {
    let prog = data.prog;
    let config = data.template.into();
    if check(&prog, &config).is_err() {
        // 请验证
        return;
    }
    let mut mem = data.mem;
    let registry = SyscallRegistry::default();
    let mut bpf_functions = BTreeMap::new();
    register_bpf_function(&config, &mut bpf_functions, &registry, 0, "entrypoint").unwrap();
    let executable = Executable::<UserError, TestInstructionMeter>::from_text_bytes(
        &prog,
        None,
        config,
        SyscallRegistry::default(),
        bpf_functions,
    )
    .unwrap();
    let mem_region = MemoryRegion::new_writable(&mut mem, ebpf::MM_INPUT_START);
    let mut vm =
        EbpfVm::<UserError, TestInstructionMeter>::new(&executable, &mut [], vec![mem_region]).unwrap();

    drop(black_box(vm.execute_program_interpreted(
        &mut TestInstructionMeter { remaining: 1024 },
    )));
});

理论上,最新版本可在rBPF repo中找到。

评估

1
2
3
4
5
6
7
8
$ cargo +nightly fuzz run dumb -- -max_total_time=300
... 省略 ...
#2902510	REDUCE cov: 1092 ft: 2147 corp: 724/58Kb lim: 4096 exec/s: 9675 rss: 355Mb L: 134/3126 MS: 3 ChangeBit-InsertByte-PersAutoDict- DE: "\x07\xff\xff3"-
#2902537	REDUCE cov: 1092 ft: 2147 corp: 724/58Kb lim: 4096 exec/s: 9675 rss: 355Mb L: 60/3126 MS: 2 ChangeBinInt-EraseBytes-
#2905608	REDUCE cov: 1092 ft: 2147 corp: 724/58Kb lim: 4096 exec/s: 9685 rss: 355Mb L: 101/3126 MS: 1 EraseBytes-
#2905770	NEW    cov: 1092 ft: 2155 corp: 725/58Kb lim: 4096 exec/s: 9685 rss: 355Mb L: 61/3126 MS: 2 ShuffleBytes-CrossOver-
#2906805	DONE   cov: 1092 ft: 2155 corp: 725/58Kb lim: 4096 exec/s: 9657 rss: 355Mb
Done 2906805 runs in 301 second(s)

执行模糊测试器后,我们可以通过检查在给定时间执行后的覆盖率来评估其发现有趣输入的有效性(注意使用了-max_total_time标志)。在这种情况下,我想确定它覆盖处理解释器执行的函数的程度如何。为此,我发出以下命令:

1
2
$ cargo +nightly fuzz coverage dumb
$ rust-cov show -Xdemangler=rustfilt fuzz/target/x86_64-unknown-linux-gnu/release/dumb -instr-profile=fuzz/coverage/dumb/coverage.profdata -show-line-counts-or-regions -name=execute_program_interpreted_inner

rust-cov的命令输出

如果你不熟悉llvm覆盖率输出,第一列是行号,第二列是该特定行被命中的次数,第三列是代码本身。

1
2
3
4
<solana_rbpf::vm::EbpfVm<solana_rbpf::user_error::UserError, solana_rbpf::vm::TestInstructionMeter>>::execute_program_interpreted_inner:
  709|    763|    fn execute_program_interpreted_inner(
  710|    763|        &mut self,
  ... (详细的覆盖率数据)

不幸的是,这个模糊测试器似乎没有达到我们预期的覆盖率。一些指令被遗漏了(注意匹配的某些分支的0覆盖率),并且没有跳转、调用或其他与控制流相关的指令。这主要是因为向任何解析器抛随机字节都不会有效;大多数东西会在验证阶段被捕获,很少会实际测试程序。

在继续之前,我们必须改进这一点,否则我们将永远等待我们的模糊测试器找到有用的错误。

此时,我们大约进行了两个小时的开发。

智能模糊测试器

eBPF是一个相当简单的指令集;你可以在几页内阅读整个定义。知道这一点:为什么我们不将输入限制为仅这些指令?这种方法通常被称为"语法感知"模糊测试,因为输入被限制为某种语法。作为一个概念,它非常强大,并用于测试各种具有严格解析规则的大型目标。

为了创建这个语法感知的模糊测试器,我检查了有帮助命名和提供的insn_builder.rs,它将允许我创建指令。现在,我需要做的就是表示所有不同的指令。通过交叉引用eBPF文档,我们可以在单个枚举中表示每个可能的操作。如果你愿意,可以在rBPF repo中查看整个grammar.rs,但下面提供了两个最相关的部分。

定义表示所有指令的枚举

 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
#[derive(arbitrary::Arbitrary, Debug, Eq, PartialEq)]
pub enum FuzzedOp {
    Add(Source),
    Sub(Source),
    Mul(Source),
    Div(Source),
    BitOr(Source),
    BitAnd(Source),
    LeftShift(Source),
    RightShift(Source),
    Negate,
    Modulo(Source),
    BitXor(Source),
    Mov(Source),
    SRS(Source),
    SwapBytes(Endian),
    Load(MemSize),
    LoadAbs(MemSize),
    LoadInd(MemSize),
    LoadX(MemSize),
    Store(MemSize),
    StoreX(MemSize),
    Jump,
    JumpC(Cond, Source),
    Call,
    Exit,
}

将FuzzedOps转换为BpfCode

 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
pub type FuzzProgram = Vec<FuzzedInstruction>;

pub fn make_program(prog: &FuzzProgram, arch: Arch) -> BpfCode {
    let mut code = BpfCode::default();
    for inst in prog {
        match inst.op {
            FuzzedOp::Add(src) => code
                .add(src, arch)
                .set_dst(inst.dst)
                .set_src(inst.src)
                .set_off(inst.off)
                .set_imm(inst.imm)
                .push(),
            FuzzedOp::Sub(src) => code
                .sub(src, arch)
                .set_dst(inst.dst)
                .set_src(inst.src)
                .set_off(inst.off)
                .set_imm(inst.imm)
                .push(),
            // ... 其他指令的转换
        };
    }
    code
}

你会在这里看到,我们的生成并不真正关心确保指令有效,只是它们格式正确。例如,我们不验证寄存器、地址、跳转目标等;我们只是把它们拼凑在一起,看看是否有效。这是为了防止过度专业化,我们尝试模糊测试的东西只产生"无聊"的输入,不测试通常被认为是无效的情况。

好的——让我们用这个制作一个模糊测试器。这里唯一真正的区别是我们的输入格式现在更改为使用新的FuzzProgram类型,而不是原始字节:

1
2
3
4
5
6
7
#[derive(arbitrary::Arbitrary, Debug)]
struct FuzzData {
    template: ConfigTemplate,
    prog: FuzzProgram,
    mem: Vec<u8>,
    arch: Arch,
}

整个模糊测试器,虽然真的没有那么不同

这个模糊测试器表达了开发的一个特定阶段。差分模糊测试器在几个关键方面有显著不同,将在后面讨论。

 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
#![feature(bench_black_box)]
#![no_main]

use std::collections::BTreeMap;
use std::hint::black_box;

use libfuzzer_sys::fuzz_target;

use grammar_aware::*;
use solana_rbpf::{
    elf::{register_bpf_function, Executable},
    insn_builder::{Arch, IntoBytes},
    memory_region::MemoryRegion,
    user_error::UserError,
    verifier::check,
    vm::{EbpfVm, SyscallRegistry, TestInstructionMeter},
};

use crate::common::ConfigTemplate;

mod common;
mod grammar_aware;

#[derive(arbitrary::Arbitrary, Debug)]
struct FuzzData {
    template: ConfigTemplate,
    prog: FuzzProgram,
    mem: Vec<u8>,
    arch: Arch,
}

fuzz_target!(|data: FuzzData| {
    let prog = make_program(&data.prog, data.arch);
    let config = data.template.into();
    if check(prog.into_bytes(), &config).is_err() {
        // 请验证
        return;
    }
    let mut mem = data.mem;
    let registry = SyscallRegistry::default();
    let mut bpf_functions = BTreeMap::new();
    register_bpf_function(&config, &mut bpf_functions, &registry, 0, "entrypoint").unwrap();
    let executable = Executable::<UserError, TestInstructionMeter>::from_text_bytes(
        prog.into_bytes(),
        None,
        config,
        SyscallRegistry::default(),
        bpf_functions,
    )
    .unwrap();
    let mem_region = MemoryRegion::new_writable(&mem, ebpf::MM_INPUT_START);
    let mut vm =
        EbpfVm::<UserError, TestInstructionMeter>::new(&executable, &mut [], vec![mem_region]).unwrap();

    drop(black_box(vm.execute_program_interpreted(
        &mut TestInstructionMeter { remaining: 1 << 16 },
    )));
});

评估

让我们看看这个版本现在覆盖我们的目标的程度如何。

1
2
3
4
5
6
7
$ cargo +nightly fuzz run smart -- -max_total_time=60
... 省略 ...
#1449846	REDUCE cov: 1730 ft: 6369 corp: 1019/168Kb lim: 4096 exec/s: 4832 rss: 358Mb L: 267/2963 MS: 1 EraseBytes-
#1450798	NEW    cov: 1730 ft: 6370 corp: 1020/168Kb lim: 4096 exec/s: 4835 rss: 358Mb L: 193/2963 MS: 2 InsertByte-InsertRepeatedBytes-
#1451609	NEW    cov: 1730 ft: 6371 corp: 1021/168Kb lim: 4096 exec/s: 4838 rss: 358Mb L: 108/2963 MS: 1 ChangeByte-
#1452095	NEW    cov: 1730 ft: 6372 corp: 1022/169Kb lim: 4096 exec/s: 4840 rss: 358Mb L: 108/2963 MS: 1 ChangeByte-
#1452830	DONE   cov: 1730 ft: 6372 corp:
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计