[官文翻译]golang模糊测试

127 阅读11分钟

本文翻译自:
Go Fuzzing - The Go Programming Language


Go 模糊测试

文章内容

从 go 1.18 开始,go 在它的标准工具链中支持模糊测试。 原生的 go 模糊测试由 supported by OSS-Fuzz 支持。

尝试一下 go 指南: 使用 go 模糊测试中文

概览

模糊测试是自动测试的一种类型,它可以不间断地操作程序的输入数据来发现 bug 。
go 模糊测试使用分支覆盖向导来智能地覆盖要模糊测试的代码以查找错误并向用户报告。
因为它可以覆盖人工常会漏掉的边界值情况,所以模糊测试对于发现安全漏洞和脆弱性非常有价值。

下面是一个 模糊测试 的例子,重点表现了它的主要组件。

示例代码展现了大体的模糊测试,带有一个模糊测试目标在里面。
在模糊测试之前用 f.Add 添加的语料库,模糊测试目标的参数突出显示为模糊测试参数。

Seed corpus addition : 种子语料库的添加
Fuzzing arguments : 模糊测试参数

编写模糊测试

必需前提

以下是模糊测试必须遵循的规则。

  • 模糊测试的函数名必须是如 FuzzXxx 这样命名,它只接收一个参数 *testing.F ,没有返回值。

  • 模糊测试必须在 *_test.go 文件中才能运行。

  • 模糊测试目标 必须是调用 (*testing.F).Fuzz 的方法 ,它接收 *testing.T 作为第一个参数,接着是模糊测试的参数。没有返回值。

  • 每个模糊测试必须明确有一个模糊测试目标。

  • 所有的种子语料库 实体必须有模糊测试参数同样的类型,并且是同样的顺序。 调用模糊测试的 (*testing.F).Add 和 testdata/fuzz 目录下的任意语料库文件都要这样。

  • 模糊测试参数只有以下类型:

    • string[]byte
    • intint8int16int32/runeint64
    • uintuint8/byteuint16uint32uint64
    • float32float64
    • bool

建议

下面是帮助你充分利用模糊测试的建议。

  • 模糊测试目标应该是快速和明确的,这样测试引擎可以高效运转,然后新的测试失败和代码覆盖也能轻易再现。
  • 因为模糊测试目标在并行交叉的多个运行过程中以不确定的顺序调用,模糊测试目标的状态不应在每次调用结束后继续保持,并且模糊测试目标不应该依赖全局状态。

运行模糊测试

有两种模式运行模糊测试:
作为单元测试(默认模式 gotest),或者使用模糊测试(go test -fuzz=FuzzTestName)。

默认模式下,模糊测试运行起来很像单元测试。
每个种子语料库实体 会对模糊测试目标进行测试,在退出前报告所有失败的测试。

要使模糊测试可用,运行 go test 带上 -fuzz 标志,提供了一个正则表达式匹配单个模糊测试。
默认情况下,包中所有其它的测试会在模糊测试前面运行。
这能确保模糊测试不会报告已在现有的测试中捕获的问题。

注意决定模糊测试运行多长时间取决于你。
很有可能的是,如果模糊测试没有发现任何错误它会永远执行下去。
将来会支持使用如 OSS-Fuzz 的工作来持续地运行这些模糊测试,查看Issue #50192

注意:
模糊测试应该在支持覆盖率指令的平台(现在是 AMD64 和 ARM64)上运行,这样在模糊测试时语料库可在运行时有意义地增长,更多的代码也可被覆盖。

命令行输出

模糊测试运行过程中,模糊测试引擎 生成新的输入并针对提供的模糊测试目标运行它们。 默认情况下,它会一直运行直到遇到发现一个失败的输入、或者用户取消测试进程(例如用 Ctrl^C)。

输出内容看上去像下面这些:

~ go test -fuzz FuzzFoo
fuzz: elapsed: 0s, gathering baseline coverage: 0/192 completed
fuzz: elapsed: 0s, gathering baseline coverage: 192/192 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 325017 (108336/sec), new interesting: 11 (total: 202)
fuzz: elapsed: 6s, execs: 680218 (118402/sec), new interesting: 12 (total: 203)
fuzz: elapsed: 9s, execs: 1039901 (119895/sec), new interesting: 19 (total: 210)
fuzz: elapsed: 12s, execs: 1386684 (115594/sec), new interesting: 21 (total: 212)
PASS
ok      foo 12.692s

第一行指示模糊测试前收集的 “基准覆盖率” 。

要收集基准覆盖率,模糊测试引擎会同时执行 种子语料库 和 生成的种子语料库,来确保没有错误发生,并且理解现有的语料库已经提供的代码覆盖率。

接下来的几行提供了活动中的模糊测试执行的信息:

  • 过去时间:从测试进程开始已经过去的时间数。
  • 执行:针对模糊测试目标已经运行过的输入总数(日志的最后会带有 过去时间/sec 的平均值)。
  • 新的着重点:在模糊测试期间已经添加到生成语料库中的着重点输入的总数(带有整个语料库的总大小)

要将一个输入变成 “着重点”,它必须扩展代码覆盖率超过现有生成的语料库可达到的值。 典型的是新的着重点输入会在开始快速增长最后变慢,会偶尔在新的分支发现时突然爆发。

你应该期待看到 ‘新的着重点’ 数量逐渐新少,因为语料库中的输入开始覆盖更多的代码行,当模糊测试引擎发现新的代码路径时会突然爆发。

(使测试)失败的输入

模糊测试时错误会有几种产生原因:

  • 在代码或测试中发生了一个 panic 。
  • 模糊测试目标调用了 t.Fail ,通常是直接调用或通过方法调用,如 t.Error 或 t.Fatal
  • 发生了不可恢复的错误,例如 os.Exit 或 栈溢出。
  • 模糊测试的完成花费了过长时间。
    现在执行一个模糊测试目标的超时时间是 1 秒。
    这可能因为死锁或无限循环或代码中不可预知的行为失败。
    这是为什么建议模糊测试目标应该是快速的的原因。

如果发生了错误,模糊测试引擎会尝试将输入尽可能最小化并转换为对于开发人员最有可读性的值,轮换后的值仍然会产生错误。

要进行相关配置,查看 自定义设置 部分。

一旦最小化完成,错误信息就会记录在日志中,输出的最后是像下面这样的内容:

    Failing input written to testdata/fuzz/FuzzFoo/a878c3134fe0404d44eb1e662e5d8d4a24beb05c3d68354903670ff65513ff49
    To re-run:
    go test -run=FuzzFoo/a878c3134fe0404d44eb1e662e5d8d4a24beb05c3d68354903670ff65513ff49
FAIL
exit status 1
FAIL    foo 0.839s

模糊测试引擎将该 (使测试)失败的输入 写入到种子语料库后用于模糊测试,然后现在它会默认使用 go test 运行,一旦 bug 被修复,它会作为回退测试提供服务。

下一步,你会诊断问题、修复 bug ,通过重新运行 go test 验证修复,然后向回退测试用的新测试数据文件提交补丁。

自定义设定

默认的 go 命令设定应该能对应绝大多数的模糊测试使用场景。 所以典型的模糊测试执行的命令行看下去如下:

$ go test -fuzz={FuzzTestName}

尽管如此, go 命令还是提供了为数不多的模糊测试运行所需的设定。 它们在cmd/go 包文档中有记述。

要突出的一些:

  • -fuzztime
    退出之前模糊测试目标执行的总时间或迭代的次数,默认是永远。
  • -fuzzminimizetime:
    模糊测试目标在每个最小化尝试时要花费的时间或迭代的次数,默认是60秒。
    可以在模糊测试时设置 -fuzzminimizetime 0 来完全禁用最小化。
  • -parallel
    模糊测试一次运行的进程数,默认是 $GOMAXPROCS
    在模糊测试过程中设置 -cpu 不起作用。

语料库文件格式

语料库文件用特殊的格式编码。 种子语料库 和 生成的语料库 都是用的相同的格式。

下面是一个语料库文件的示例:

go test fuzz v1
[]byte("hello\xbd\xb2=\xbc ⌘")
int64(572293)

第一行告知模糊测试引擎文件的编码版本。 尽管现在还没有计划编码格式的未来版本,设计上也必须支持这种可能性。

接下来的每一行都是拼装成语料库实体的值,在需要时可直接复制进 go 代码中。

在上面的示例中,有一个 int64[]byte 后面。 这些类型必需以同样的顺序精确匹配模糊测试参数。 这些类型的模糊测试目标看上去会如下:

f.Fuzz(func(*testing.T, []byte, int64) {})

指定你个人的种子语料库值最简单的方式是使用 (*testing.F).Add 方法。 在上面的示例中,看上去会如下:

f.Add([]byte("hello\xbd\xb2=\xbc ⌘"), int64(572293))

尽管这样,你可以会有巨大的二进制文件,你也并不想将它作为代码复制到测试到,取而代之的是保持其作为单独的种子语料库实体放在 testdata/fuzz/{FuzzTestName} 目录中。 golang.org/x/tools/cmd/file2fuzz 中的 file2fuzz 工具可用于将这些二进制文件转换成用于 []byte 编码的语料库文件。

如下使用该工具:

$ go install golang.org/x/tools/cmd/file2fuzz@latest
$ file2fuzz

资源

词汇表

corpus entry 语料库实体:  
语料库中的输入,在模糊测试时使用。它可以是特殊格式的文件或对 (*testing.F).Add 的调用。

coverage guidance 覆盖率向导:
模糊测试的一个方法,使用代码覆盖率的扩展来决定哪个语料库实体在将来的使用中有价值。

failing input (使测试)失败的输入:
(使测试)失败的输入是针对模糊测试目标运行模糊测试时可导致错误或 panic 的语料库实体。

fuzz target 模糊测试目标:
模糊测试的函数,当模糊测试时执行用于语料库实体和生成值。通过给 (*testing.F).Fuzz 传递函数提供给模糊测试。

fuzz test 模糊测试:
一个测试文件中形式为 func FuzzXxx(*testing.F) 的函数,它用于模糊。

fuzzing 模糊:
自动测试的类型,它会持续控制输入到程序中以发现问题,如 bug 或 代码中可能会受影响的漏洞

fuzzing arguments 模糊测试参数:
要传递给模糊测试目标的类型,通过 突变器 进行改变。

fuzzing engine 模糊测试引擎:
管理模糊测试的工具,包括维护语料库、调用突变器、标记新的覆盖、报告失败。

generated corpus 生成的语料库:
随时间推移,需要追踪模糊测试的进程时,模糊测试引擎维护的语料库。 它存储在 $GOCACHE/fuzz 目录下。 这些实体只在模糊时使用。

mutator 突变器:
模糊时使用的一个工具,它可在将语料库实体传递给模糊测试时进行随机控制。

package 包:
相同目录下一起编译的源代码文件集合。 查看 go 语言说明的包部分

seed corpus 种子语料库:
用户提供的用于测试的语料库,它可用来指导模糊测试引擎。 它由模糊测试内置的 f.Add 调用提供的语料库实体组成,文件是在包中的 testdata/fuzz/{FuzzTestName} 目录中。 这些实体默认用  go test 运行,不管是否是模糊测试。

test files 测试文件:
xxx_test.go 格式的文件,可能包含测试、性能、示例和模糊测试。

vulnerability 漏洞:   代码中的安全敏感的脆弱点,可能会被攻击者利用。

反馈

如果你有任何问题或某个特性的想法,请提出 issue.

对于特性的讨论和常见反馈,可以参与 Gophers Slack 的 #fuzzing 频道 。