背景

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.gopackage fuzzfunc 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.gopackage fuzzimport (    "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=Fuzzfuzz: elapsed: 0s, gathering baseline coverage: 0/111 completedfuzz: minimizing 60-byte failing input filefuzz: 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/ce9e8c80e2c2de2c96ab9e63b1a8cf18cea932b7d8c6c9c207d5978e0f19027aFAILexit status 1FAIL    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=Fuzztesting: will not fuzz, -fuzz matches more than one fuzz test: [FuzzReverse FuzzReverse2]FAILexit status 1FAIL    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 fuzzxxx    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=Fuzzxxx    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=Fuzzxxx    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=Fuzzxxx    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=Fuzzxxx    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=Fuzzxxx    13923   0.0  0.0  4268176    420 s000  R+   10:12下午   0:00.00 grep fuzzxxx    13891   0.0  0.2  5014396  16868 s001  S+   10:12下午   0:00.52 /Users/xxx/sdk/go1.18beta2/bin/go test -fuzz=Fuzzxxx    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...