Fuzzing: 一文读懂Go Fuzzing使用和原理

背景

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运行的模型如下所示:

Fuzzing: 一文读懂Go Fuzzing使用和原理_第1张图片

coordinator进程和worker进程通过一对管道进行通信,使用基于JSON的RPC通信协议。这个协议非常精简,因为我们并不需要gRPC一样复杂的RPC协议,我们也不希望给Go标准库引入任何新的依赖。

每个worker进程在mmap文件里保存自己的状态,这个mmap文件和coordinator进程共享。大多数情况下,mmap里记录的只是迭代次数和随机数生成器的状态。如果worker进程crash了,那coordinator进程就可以从共享内存里恢复其状态,而不需要worker进程通过管道发送消息。

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

Fuzzing: 一文读懂Go Fuzzing使用和原理_第2张图片

阶段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

你可能感兴趣的:(Fuzzing: 一文读懂Go Fuzzing使用和原理)