Fuzzing in Go

Ben Hoyt

Go 中的模糊测试

原文由 Ben Hoyt 发布,订阅该博客

模糊测试是一种利用随机化输入的测试技术,用于在接受用户输入的代码中发现棘手的边界情况或安全问题。Go 包的开发者可以使用 Dmitry Vyukov 开发的热门 go-fuzz 工具来对代码进行模糊测试;该工具已在 Go 标准库以及众多第三方包中发现了数百个隐蔽的缺陷。然而,这个工具并非内置,使用起来也远没有达到应有的简便程度;为此,Go 团队成员 Katie Hockman 最近发布了一份草案设计,提议将模糊测试作为标准 go test 命令的一等公民特性。

利用随机测试输入来发现缺陷的做法可以追溯到打孔卡时代。作家、资深程序员 Gerald Weinberg 回忆道

在 20 世纪 50 年代我们还不叫它模糊测试,但用从垃圾堆里捡来的成叠打孔卡来测试程序是我们的标准做法。我们也会使用随机数打孔卡。那时还没有联网,所以我们不太担心安全问题,但那些随机/垃圾卡片常常会暴露出一些不符合预期的行为。

近年来,模糊测试已借助 american fuzzy lop (AFL) 和 Vyukov 基于 Go 开发的 syzkaller 等工具,在从 Bash 和 libjpegLinux 内核的各类软件中发现了无数缺陷以及一些值得关注的安全问题。

模糊测试的基本思路是为某个函数生成随机输入,观察它是否会崩溃或抛出不属于其 API 的异常。然而,用朴素的方法生成随机输入极其耗时,而且无法高效地发现边界情况。正因如此,大多数现代模糊测试工具都采用“覆盖率引导的模糊测试”来驱动测试,并判断新生成的输入是否执行了新的代码路径。Vyukov 参与撰写的一份提案对这一技术的工作原理做了简洁的描述:

start with some (potentially empty) corpus of inputs
for {
    choose a random input from the corpus
    mutate the input
    execute the mutated input and collect code coverage
    if the input gives new coverage, add it to the corpus
}

收集代码覆盖率数据并判断某个输入是否“带来了新的覆盖率”并非易事;这需要工具通过向覆盖率记录器插入特殊调用来对代码进行插桩。当经过插桩的代码运行时,模糊测试框架会将新输入的覆盖率与此前测试输入的覆盖率进行比较,如果执行了不同的代码块,就会将该新输入加入语料库。显然,这省略了大量细节,比如如何对输入进行变异、覆盖率插桩具体如何工作等等。但这一基本技术是有效的:AFL 已在众多 C 和 C++ 程序上采用了它,并在其网页上专门列出了已发现并修复的大量缺陷。

go-fuzz 工具

AFL 是一款出色的工具,但它只适用于用 C、C++ 或 Objective C 编写、且需使用 GCC 或 Clang 编译的程序。Vyukov 的 go-fuzz 工具运行方式与 AFL 类似,但专为 Go 设计。为了给 Go 程序添加覆盖率记录,开发者首先需要运行 go-fuzz-build 命令(而非 go build),该命令会利用内置的 ast 包为源代码中的每个代码块添加插桩,然后将结果送入常规的 Go 编译器进行编译。完成插桩后的二进制文件构建好后,go-fuzz 命令会在多个 CPU 核心上反复运行它,使用随机变异的输入,并在运行过程中记录任何崩溃(连同其堆栈跟踪和触发崩溃的输入)。

Damian Gryski 撰写了一篇教程,更详细地展示了如何使用 go-fuzz 工具。如前所述,go-fuzz 的 README 列出了它已发现的众多缺陷,然而,几乎可以肯定还有更多存在于第三方包中的缺陷并未在那里列出;我个人就曾在 GoAWK 上使用过 go-fuzz,它发现了好几个“崩溃用例”。

迈向一等公民

Go 内置了 go test 命令,可以自动查找并运行项目中的测试(以及可选的基准测试)。模糊测试也是一种测试,但在没有内置工具支持的情况下,配置起来有些繁琐。早在 2017 年 2 月,就有人在 Go 的 GitHub 仓库中代表 Vyukov 和 Konstantin Serebryany 提交了一个 issue,提议让 go 工具像现在支持测试、基准测试和竞态检测一样,原生支持模糊测试。该 issue 指出,go-fuzz 已经存在,但它远不如编写测试和基准测试然后运行 go test -race 那么简单。这个 issue 获得了大量的支持和评论。

在此期间,Vyukov 等人补充了一份动机说明文档以及关于这种集成形态的 API 和工具提案。Go 技术负责人 Russ Cox 敦促提供一个“你希望新的 go test 模糊测试模式究竟是什么样子”的原型版本。2019 年 1 月,“thepudds”分享了这样一个原型——一个名为 fzgo 的工具,它在独立工具中实现了原始提案的大部分内容。该工具当时广受好评,但似乎并未发展为官方方案。

不过,最近 Go 团队重新拾起了这个想法,由 Hockman 撰写了最新的关于一等公民模糊测试的草案设计。目标是相似的,即让使用标准 go test 工具运行模糊测试变得简单,但所提议的 API 略为复杂,以便能够以编程方式为初始语料库提供种子,并支持除字节字符串(Go 中的“字节切片”即 []byte)之外的输入类型。

目前,开发者可以在 *_test.go 源文件中编写签名为 TestFoo(t *testing.T) 的测试函数,go test 会自动将这些函数作为单元测试运行。现有的 testing.T 类型会被传递给测试函数,用于控制测试和记录失败。新的草案设计增加了以类似方式编写 FuzzFoo(f *testing.F) 模糊测试的能力,然后使用诸如 go test -fuzz 这样简单的命令来运行。提议中的 testing.F 类型用于向种子语料库添加输入,并通过一个嵌套的匿名函数来实现模糊测试本身。下面是一个可能属于计算器库 calc_test.go 的示例:

    func FuzzEval(f *testing.F) {
        // Seed the initial corpus
        f.Add("1+2")
        f.Add("1+2*3")
        f.Add("(1+2)*3")

        // Run the fuzz test
        f.Fuzz(func(t *testing.T, expr string) {
            t.Parallel()      // allow parallel execution
            _, _ = Eval(expr) // function under test (discard result and error)
        })
    }

仅这几行代码就构成了一个基本的模糊测试,它会使用随机化输入来运行计算器库的 Eval() 函数,并记录任何崩溃(在 Go 术语中称为“panic”)。panic 的例子包括数组越界访问、对空指针解引用或除以零。一个更复杂的模糊测试可能会将结果与另一个库(本例中称为 calclib)进行对比:

        ...

        // Run the fuzz test
        f.Fuzz(func(t *testing.T, expr string) {
            t.Parallel()
            r1, err := Eval(expr)
            if err != nil {
                t.Skip() // got parse error, skip rest of test
            }

            // Compare result against calclib
            r2, err := calclib.Eval(expr)
            if err != nil {
                t.Errorf("Eval succeeded but calclib had error: %v", err)
            }
            if r1 != r2 {
                t.Errorf("Eval got %d, calclib got %d", r1, r2)
            }
        })
    }

除了描述模糊测试函数和新的 testing.F 类型外,Hockman 的草案设计还提议构建一个新的覆盖率引导的模糊测试引擎,它将负责利用编译器插桩来理解覆盖率信息、使用变异器生成测试参数,并维护语料库。Hockman 明确指出,这将是一个全新的实现,但会大量借鉴现有成果(go-fuzz 和 fzgo)。变异器会基于已有输入生成新的随机化输入(即“生成的语料库”),并且能够对内置类型或由内置类型组成的结构体自动生效。其他类型如果实现了现有的 BinaryUnmarshalerTextUnmarshaler 接口,也会得到支持。

默认情况下,引擎会无限期地运行模糊测试,并在发现第一个崩溃时停止该次测试运行。用户可以通过 -fuzztime 命令行标志让它运行指定的时长(用于持续集成脚本),并通过 -keepfuzzing 标志让它在崩溃后继续运行。崩溃报告将被写入 testdata 目录下的文件,其中包含导致崩溃的输入以及错误信息或堆栈跟踪。

讨论与后续

与近期关于文件系统和文件嵌入的草案设计一样,该设计的官方讨论是在一个 Reddit 帖子中进行的;总体而言,反馈是积极的。

讨论中有一部分涉及 testing.F 接口。David Crawshaw 建议,为了与 testing.Ttesting.B(用于基准测试)保持一致,它应该实现现有的 testing.TB 接口;Hockman 表示赞同,并更新了设计以体现这一点。根据 “etherealflaim” 的建议,Hockman 还更新了设计,以避免在顶层和模糊测试函数中重复使用 testing.F。此外,大家还就命令究竟该写成 go test -fuzz 还是 go fuzz 进行了一些细节争论;etherealflaim 认为重用 go test 不是个好主意,因为它有历史包袱,很多人已经为它配置了超时等设置

Jeremy Bowers 建议变异引擎应该是可插拔的:

我认为模糊测试引擎需要是可插拔的。当然可以先自带一个默认引擎,甚至可以把可插拔性推迟到“2.0 版本”,但我认为应该把它纳入计划。模糊测试可以做到开箱即用、覆盖大多数场景,但总会有需要更专业定制的需求。

不过,Hockman 回应称,可插拔性并非实现该功能所必需,但可能会在设计阶段的后期再考虑

草案设计在开头就声明,发布这份草案设计的目的是收集反馈,以形成最终的正式提案,因此很难确切地说下一步会是什么以及何时发生。不过,看到 Go 团队为此投入了官方精力是件好事。基于 Cox 对 Vyukov 原始提案的反馈,我猜测我们会看到一个基于分支开发的更新提案原型,或是一个类似 fzgo、可供开发者运行的独立工具。

Reddit 帖子上的讨论仍在进行中,因此,在 2020 年 11 月 Go 1.16 版本冻结到来之前,像这样庞大的特性不太可能已准备好正式提案和实现。更有可能被纳入的是定于 2021 年 8 月发布的 Go 1.17。

本文章由 muse-spark-1.2-contributor 进行翻译

评论