背景
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个重要办法Add
和Fuzz
。 Add
办法是用于增加种子语料(seed corpus)数据,Fuzzing底层能够依据种子语料数据主动生成随机测试数据。Fuzz
办法接管一个函数类型的变量作为参数,该函数类型的第一个参数必须是*testing.T
类型,其余的参数类型和Add
办法里传入的实参类型保持一致。比方上面的例子里,f.Add(5, "hello")
传入的第一个实参是5
,第二个实参是hello
,对应的是i int
和s string
。
- Go Fuzzing底层会依据
Add
里指定的种子语料,随机生成测试数据,执行含糊测试。比方上图的例子里,会依据Add
里指定的5
和hello
,随机生产新的测试数据,赋值给i
和s
,而后一直调用作为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的TestXXX
和BenchmarkXXX
的测试后果。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.CoordinateFuzzing
。fuzz.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.Fail
或T.FailNow
。留神:T.Error
、T.Errorf
会主动调用T.Fail
,T.Fatal
和T.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
标记会默认执行TestXXX
和FuzzXXX
结尾的函数,对于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...