关于golang:Fuzzing-一文读懂Go-Fuzzing使用和原理

7次阅读

共计 8757 个字符,预计需要花费 22 分钟才能阅读完成。

背景

Go 1.18 除了引入泛型 (generics) 这个重大设计之外,Go 官网团队在 Go 1.18 工具链里还引入了 fuzzing 含糊测试。

Go fuzzing 的次要开发者是 Katie Hockman, Jay Conrod 和 Roland Shoemaker。

编者注:Katie Hockman 已于 2022.02.19 从 Google 到职,Jay Conrod 也于 2021 年 10 月来到 Google。

什么是 Fuzzing

Fuzzing 中文含意是含糊测试,是一种自动化测试技术,能够随机生成测试数据集,而后调用要测试的性能代码来查看性能是否合乎预期。

含糊测试 (fuzz test) 是对单元测试 (unit test) 的补充,并不是要代替单元测试。

单元测试是查看指定的输出失去的后果是否和预期的输入后果统一,测试数据集比拟无限。

含糊测试能够生成随机测试数据,找出单元测试笼罩不到的场景,进而发现程序的潜在 bug 和安全漏洞。

Go Fuzzing 怎么应用

Fuzzing 在 Go 语言里并不是一个全新的概念,在 Go 官网团队公布 Go Fuzzing 之前,GitHub 上曾经有了相似的含糊测试工具 go-fuzz。

Go 官网团队的 Fuzzing 实现借鉴了 go-fuzz 的设计思维。

Go 1.18 把 Fuzzing 整合到了 go test 工具链和 testing 包里。

示例

上面举个例子阐明下 Fuzzing 如何应用。

对于如下的字符串反转函数Reverse,大家能够思考下这段代码有什么潜在问题?

// main.go
package fuzz

func Reverse(s string) string {bs := []byte(s)
    length := len(bs)
    for i := 0; i < length/2; i++ {bs[i], bs[length-i-1] = bs[length-i-1], bs[i]
    }
    return string(bs)
}

编写 Fuzzing 含糊测试

如果没有发现下面代码的 bug,咱们无妨写一个 Fuzzing 含糊测试函数,来发现下面代码的潜在问题。

Go Fuzzing 含糊测试函数的语法如下所示:

  • 含糊测试函数定义在 xxx_test.go 文件里,这点和 Go 已有的单元测试 (unit test) 和性能测试 (benchmark test) 一样。
  • 函数名以 Fuzz 结尾,参数是 * testing.F 类型,testing.F类型有 2 个重要办法 AddFuzz
  • Add办法是用于增加种子语料 (seed corpus) 数据,Fuzzing 底层能够依据种子语料数据主动生成随机测试数据。
  • Fuzz办法接管一个函数类型的变量作为参数,该函数类型的第一个参数必须是 *testing.T 类型,其余的参数类型和 Add 办法里传入的实参类型保持一致。比方上面的例子里,f.Add(5, "hello")传入的第一个实参是 5,第二个实参是hello,对应的是i ints string

  • Go Fuzzing 底层会依据 Add 里指定的种子语料,随机生成测试数据,执行含糊测试。比方上图的例子里,会依据 Add 里指定的 5hello,随机生产新的测试数据,赋值给 is,而后一直调用作为 f.Fuzz 办法的实参,也就是 func(t *testing.T, i int, s string){...} 这个函数。

晓得了上述规定后,咱们来给 Reverse 函数编写一个如下的含糊测试函数。

// fuzz_test.go
package fuzz

import (
    "testing"
    "unicode/utf8"
)

func FuzzReverse(f *testing.F) {str_slice := []string{"abc", "bb"}
    for _, v := range str_slice {f.Add(v)
    }
    f.Fuzz(func(t *testing.T, str string) {rev_str1 := Reverse(str)
        rev_str2 := Reverse(rev_str1)
        if str != rev_str2 {t.Errorf("fuzz test failed. str:%s, rev_str1:%s, rev_str2:%s", str, rev_str1, rev_str2)
        }
        if utf8.ValidString(str) && !utf8.ValidString(rev_str1) {t.Errorf("reverse result is not utf8. str:%s, len: %d, rev_str1:%s", str, len(str), rev_str1)
        }
    })
}

运行 Fuzzing 测试

应用的 Go 版本要求是 go 1.18beta 1 或以上版本,执行如下命令能够进行 Fuzzing 测试,后果如下:

$ go1.18beta1 test -v -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/111 completed
fuzz: minimizing 60-byte failing input file
fuzz: elapsed: 0s, gathering baseline coverage: 5/111 completed
--- FAIL: FuzzReverse (0.04s)
    --- FAIL: FuzzReverse (0.00s)
        fuzz_test.go:20: reverse result is not utf8. str:æ, len: 2, rev_str1:��
    
    Failing input written to testdata/fuzz/FuzzReverse/ce9e8c80e2c2de2c96ab9e63b1a8cf18cea932b7d8c6c9c207d5978e0f19027a
    To re-run:
    go test -run=FuzzReverse/ce9e8c80e2c2de2c96ab9e63b1a8cf18cea932b7d8c6c9c207d5978e0f19027a
FAIL
exit status 1
FAIL    example/fuzz    0.179s

重点看fuzz_test.go:20: reverse result is not utf8. str:æ, len: 2, rev_str1:��

这个例子里,随机生成了一个字符串 æ,这是由 2 个字节组成的一个 UTF- 8 字符串,依照Reverse 函数进行反转后,失去了一个非 UTF- 8 的字符串��

所以咱们之前实现的依照字节进行字符串反转的函数 Reverse 是有 bug 的,该函数对于 ASCII 码里的字符组成的字符串是能够正确反转的,然而对于非 ASCII 码里的字符,如果简略依照字节进行反转,失去的可能是一个非法的字符串。

感兴趣的敌人,能够看看如果对字符串 ” 吃 ”,调用Reverse 函数,会失去怎么的后果。

留神 :如果 Go Fuzzing 运行过程中发现了你的 bug,会把对应的输出数据写到testdata/fuzz/FuzzXXX 目录下。比方下面的例子里,go1.18beta1 test -v -fuzz=Fuzz的输入后果里打印了如下内容:Failing input written to testdata/fuzz/FuzzReverse/ce9e8c80e2c2de2c96ab9e63b1a8cf18cea932b7d8c6c9c207d5978e0f19027a,这就示意把这个测试输出写到了 testdata/fuzz/FuzzReverse/xxx 这个语料文件里。

Go Fuzzing 的底层机制

go test 执行的时候,会为每个被测试的 package 先编译生成一个可执行文件,而后运行这个可执行文件失去对应 package 的 TestXXXBenchmarkXXX的测试后果。Go Fuzzing 运行的模式和这个相似,然而也有一点区别。

go test 执行的时候如果有 -fuzz 标记,go test会联合覆盖率工具来编译生成用于含糊测试的可执行文件。大部分的 Fuzzing 逻辑都实现在 internal/fuzz。

go test 编译生成了可执行文件后,该可执行文件就会运行起来,这个运行起来的过程叫做协调过程 (coordinator process)。协调过程的启动参数里有go test 命令的大部分标记,包含 -fuzz=pattern 这个标记,-fuzz=pattern用来辨认对哪个含糊测试函数 (fuzz test) 进行 Fuzzing 测试。

目前,对于每一个 go test -fuzz=pattern 调用,只反对匹配一个含糊测试函数。如果 go test -fuzz=pattern 能够匹配多个 FuzzXXX 函数,就会报如下谬误:

$ go1.18beta1 test -v -fuzz=Fuzz
testing: will not fuzz, -fuzz matches more than one fuzz test: [FuzzReverse FuzzReverse2]
FAIL
exit status 1
FAIL    example/fuzz    0.752s

协调过程启动后,次要的程序逻辑都在 fuzz.CoordinateFuzzingfuzz.CoordinateFuzzing 会初始化 fuzzing 零碎,开启 coordinator 事件循环。

coordinator 过程会启动多个 worker 过程,每个 worker 过程和 coordinator 过程运行雷同的可执行程序,真正的 fuzzing 含糊测试由 worker 过程来实现。worker 过程启动时带有一个标记参数-test.fuzzworker,表明这是一个 worker 过程。启动的 worker 过程数量等于 GOMAXPROCS。

这里我给了一个示例,大家能够在执行 go test -fuzz=pattern 的过程中,运行 ps aux | grep fuzz 来查看以后 fuzzing 相干的过程。

$ ps aux | grep fuzz
xxx    13913  84.3  1.0  5219184  85124 s001  R+   10:12 下午   0:03.90 /var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build1953131131/b001/fuzz.test -test.fuzzworker -test.paniconexit0 -test.fuzzcachedir=/Users/xxx/Library/Caches/go-build/fuzz/example/fuzz -test.timeout=10m0s -test.fuzz=Fuzz
xxx    13910  81.9  1.0  5221180  86200 s001  R+   10:12 下午   0:03.94 /var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build1953131131/b001/fuzz.test -test.fuzzworker -test.paniconexit0 -test.fuzzcachedir=/Users/xxx/Library/Caches/go-build/fuzz/example/fuzz -test.timeout=10m0s -test.fuzz=Fuzz
xxx    13912  78.3  1.0  5219964  84984 s001  R+   10:12 下午   0:03.86 /var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build1953131131/b001/fuzz.test -test.fuzzworker -test.paniconexit0 -test.fuzzcachedir=/Users/xxx/Library/Caches/go-build/fuzz/example/fuzz -test.timeout=10m0s -test.fuzz=Fuzz
xxx    13911  74.5  1.0  5219184  85132 s001  R+   10:12 下午   0:03.76 /var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build1953131131/b001/fuzz.test -test.fuzzworker -test.paniconexit0 -test.fuzzcachedir=/Users/xxx/Library/Caches/go-build/fuzz/example/fuzz -test.timeout=10m0s -test.fuzz=Fuzz
xxx    13907  43.3  2.3  5944576 191172 s001  R+   10:12 下午   0:01.90 /var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build1953131131/b001/fuzz.test -test.paniconexit0 -test.fuzzcachedir=/Users/xxx/Library/Caches/go-build/fuzz/example/fuzz -test.timeout=10m0s -test.fuzz=Fuzz
xxx    13923   0.0  0.0  4268176    420 s000  R+   10:12 下午   0:00.00 grep fuzz
xxx    13891   0.0  0.2  5014396  16868 s001  S+   10:12 下午   0:00.52 /Users/xxx/sdk/go1.18beta2/bin/go test -fuzz=Fuzz
xxx    13890   0.0  0.0  4989312   4008 s001  S+   10:12 下午   0:00.01 go1.18beta2 test -fuzz=Fuzz

worker 过程在运行含糊测试 (fuzzing) 的时候如果 crash 了,coordinator 过程能够记录导致 worker 过程 crash 的测试数据。如果间接交给 coordinator 过程执行 fuzzing,在遇到了会导致程序 crash 的输出时,coordinator 过程自身就会 crash,就没有方法记录导致程序 crash 的输出了(Failing input)。Go Fuzzing 运行的模型如下所示:

coordinator 过程和 worker 过程通过一对管道进行通信,应用基于 JSON 的 RPC 通信协议。这个协定十分精简,因为咱们并不需要 gRPC 一样简单的 RPC 协定,咱们也不心愿给 Go 规范库引入任何新的依赖。

每个 worker 过程在 mmap 文件里保留本人的状态,这个 mmap 文件和 coordinator 过程共享。大多数状况下,mmap 里记录的只是迭代次数和随机数生成器的状态。如果 worker 过程 crash 了,那 coordinator 过程就能够从共享内存里复原其状态,而不须要 worker 过程通过管道发送音讯。

整个 Fuzzing 过程分为 3 个阶段:

阶段 1:Baseline coverage

coordinator 过程启动时,会拉起 worker 过程。coordinator 过程会给 worker 过程发送种子语料 (包含f.Add 里增加的测试数据以及 testdata/fuzz 目录下的测试输出)和 fuzzing 缓存语料 (cache corpus,位于$GOCACHE 的子目录下)。

每个 worker 过程运行指定的输出,而后给 coordinator 过程报告其覆盖率计数器的快照,coordinator 会将收集到的 worker 的覆盖率数据合并为一个覆盖率数组。

这个阶段叫基线覆盖率收集阶段,worker 只会运行 coordinator 发送给它们的指定输出,不会生成随机测试数据。

阶段 2:Fuzzing 含糊测试

这个阶段,coordinator 过程会再次发送种子语料 (seed corpus) 和缓存语料 (cache corpus) 给 worker 过程,用于真正的 fuzzing。

每个 worker 过程会收到一个 coordinator 发送的输出数据和基线覆盖率数组的拷贝。而后 worker 过程会随机对这个指定的输出做变异来失去新的测试数据。变异的形式有多种,可能是对 bit 位做反转,0 改为 1,1 改为 0,也可能是删除或者新增字节,等等。而后再把变异后的数据作为参数给到 fuzz target 函数去运行。

为了缩小 coordinator 过程和 worker 过程的通信开销,每个 worker 过程能够在 100ms 内始终变异拿到新的测试数据,而后调用 fuzz target 函数,而不须要 coordinator 过程的进一步输出。

每次对生成的随机数据调用 fuzz target 函数后,worker 过程会查看 2 种场景:

  • 和基线覆盖率数组相比,是否找到了新的覆盖率数据。
  • 是否有 error 产生,也就是代码里执行了 T.FailT.FailNow留神 T.ErrorT.Errorf 会主动调用 T.Fail,T.FatalT.Fatalf会主动调用T.FailNow

如果二者满足其一,worker 过程就会把输出数据立刻发送给 coordinator 过程。

阶段 3:Minimization 最小化

如果 coordinator 过程收到了 worker 过程发送过去的输出数据是场景 1,也就是收到了会产生新覆盖率的输出,coordinator 会把这个 worker 的覆盖率数据和以后组合的覆盖率数组做比拟。

因为有可能其它 worker 曾经发现了会提供雷同覆盖率的输出,如果是这样的话,那 coordinator 会间接 ignore 这个输出。如果这个新的输出确实提供了新的覆盖率,那 coordinator 会把这个输出发送给一个 worker(很可能是不同的 worker)用于最小化(minimization)。

最小化有点像 fuzzing,然而 worker 会通过随机变异来创立一个依然会产生新覆盖率的更小输出。更小的输出通常会让 fuzzing 执行更快,因而值得在后面花工夫让 fuzzing 处理过程更快。worker 过程实现最小化后会报告给 coordinator,即便它将来找到更小的输出。coordinator 过程会把这个最小化的输出增加到缓存语料库 (cache corpus) 并继续执行 Fuzzing。后续,coordinator 可能会把这个最小化的输出发送给所有 worker 用于进一步 fuzzing。这就是 fuzzing 零碎如何主动调节找到新的覆盖率。

如果 coordinator 过程收到了 worker 过程发送过去的输出数据是场景 2:也就是 引发 error 的输出 ,coordinator 过程会把这个输出再次发送给 worker 进行最小化。在这种场景下,worker 会试图找到一个会引发 error 的更小输出,只管不肯定是同一个 error。在输出数据被最小化后,coordinator 过程会把最小化后的数据存储到testdata/fuzz/$FuzzTarget,优雅敞开所有 worker 过程,而后以非 0 状态(non-zero status) 退出。

如果 worker 过程在 fuzzing 过程中 crash 了,那 coordinator 过程能够应用发送给 worker 的输出、worker 的 RNG 状态和迭代次数 (留在共享内存中) 来复原导致 worker 过程 crash 的输出。crash 的输出通常没有被最小化,因为最小化是一个高度状态化的过程,而每次 crash 都会毁坏这个状态。这在实践上是可行的,然而目前还没能实现。

Fuzzing 通常遇到以下场景才会完结运行,否则会始终运行:

  • Fuzzing 找到了 error,也就是触发了你含糊测试函数里的 error 条件
  • 用户按 Ctrl- C 来中断程序
  • 运行工夫达到了 -fuzztime 设定的工夫

fuzzing 引擎会优雅解决中断,不论中断是被发送给了 coordinator 过程还是 worker 过程。举个例子,如果 worker 过程在最小化输出的时候遇到了中断,coordinator 过程会保留没有被最小化的输出。

注意事项

  • FuzzXXX的实现也是放在以 _test.go 结尾的 go 文件里。
  • seed corpus(种子语料):既蕴含通过 f.Add 指定的输出,也包含 testdata/fuzz/$FuzzTarget 目录下的文件外面的输出。
  • go test 不带 -fuzz 标记会默认执行 TestXXXFuzzXXX结尾的函数,对于 FuzzXXX 只会应用种子语料库里的输出,而不会生成随机数据。如果须要生成随机输出,要应用go test -fuzz=pattern

开源地址

文章和示例代码开源在 GitHub: Go 语言高级、中级和高级教程。

公众号:coding 进阶。关注公众号能够获取最新 Go 面试题和技术栈。

集体网站:Jincheng’s Blog。

References

  • Internals of Go’s New Fuzzing System: https://jayconrod.com/posts/1…
  • Fuzzing 介绍:https://go.dev/doc/fuzz/
  • Fuzzing Design Draft: https://go.googlesource.com/p…
  • Fuzzing 提案:https://github.com/golang/go/…
  • Fuzzing 教程:https://go.dev/doc/tutorial/fuzz
  • tesing.F 阐明文档:https://pkg.go.dev/testing@go…
  • Fuzzing Tesing in Go in 8 Minutes: https://www.youtube.com/watch…
  • GitHub 开源工具 go-fuzz: https://github.com/dvyukov/go…
  • Go fuzzing 找 bug 示例:https://julien.ponge.org/blog…
正文完
 0