Go语言精进之路:从新手到高手的编程思想、方法和技巧(2)
上QQ阅读APP看书,第一时间看更新

45.4 使用go-fuzz建立模糊测试的示例

gocmpp(https://github.com/bigwhite/gocmpp)是一个中国移动cmpp短信协议库的Go实现,这里我们就用为该项目添加模糊测试作为示例。

gocmpp中的每种协议包都实现了Packer接口,其中的Unpack尤其适合做模糊测试。由于协议包众多,我们在gocmpp下专门建立了fuzztest目录,用于存放模糊测试的代码,将各个协议包的模糊测试分到各个子目录中:

github.com/bigwhite/gocmpp/fuzztest$tree
.
├── fwd
│   ├── corpus
│   │   └── 0
│   ├── fuzz.go
│   └── gen
│       └── main.go
└── submit
       ├── corpus
       │   ├── 0
       ├── fuzz.go
       └── gen
           └── main.go

先说说每个模糊测试单元(比如fwd或submit)下的gen/main.go,这是一个用于生成初始语料的可执行程序。以submit/gen/main.go为例:

// submit/gen/main.go
package main

import (
    "github.com/dvyukov/go-fuzz/gen"
)

func main() {
    data := []byte{
        0x00, 0x00, 0x00, 0x17, 0x00, 0x00, 0x00, 0x00,
        ...
          0x6d, 0x00, 0x69, 0x00, 0x74, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    }

    gen.Emit(data, nil, true)
}

在这个main.go源文件中,我们借用submit包的单元测试中的数据作为模糊测试的初始语料数据,通过go-fuzz提供的gen包将数据输出到文件中:

$cd submit/gen
$go run main.go -out ../corpus/
$ls -l ../corpus/
-rw-r--r--  1 tony  staff  181 12  7 22:00 0
...

该程序在corpus目录下生成了一个名为“0”的文件作为submit包模糊测试的初始语料。

接下来看看submit/fuzz.go:

// +build gofuzz

package cmppfuzz

import (
    "github.com/bigwhite/gocmpp"
)

func Fuzz(data []byte) int {
    p := &cmpp.Cmpp2SubmitReqPkt{}
    if err := p.Unpack(data); err != nil {
        return 0
    }
    return 1
}

这是最为简单的Fuzz函数实现了。根据作者对Fuzz的规约,Fuzz的返回值是有重要含义的:

  • 如果此次输入的数据在某种程度上是很有意义的,go-fuzz会给予这类输入更高的优先级,Fuzz应该返回1;
  • 如果明确这些输入绝对不能放入corpus,那么让Fuzz返回-1;
  • 至于其他情况,则返回0。

接下来就该go-fuzz-build和go-fuzz登场了。这与前面的介绍差不多,我们先用go-fuzz-build构建go-fuzz使用的带有代码覆盖率统计桩代码的二进制文件:

$cd submit
$go-fuzz-build github.com/bigwhite/gocmpp/fuzztest/submit
$ls
cmppfuzz-fuzz.zip    corpus/            fuzz.go            gen/

然后在submit目录下执行go-fuzz:

$go-fuzz -bin=./cmppfuzz-fuzz.zip -workdir=./
2019/12/07 22:05:02 workers: 4, corpus: 1 (3s ago), crashers: 0, restarts: 1/0, execs: 0 (0/sec), cover: 0, uptime: 3s
2019/12/07 22:05:05 workers: 4, corpus: 3 (0s ago), crashers: 0, restarts: 1/0, execs: 0 (0/sec), cover: 32, uptime: 6s
2019/12/07 22:05:08 workers: 4, corpus: 7 (1s ago), crashers: 0, restarts: 1/5424, execs: 65098 (7231/sec), cover: 131, uptime: 9s
2019/12/07 22:05:11 workers: 4, corpus: 9 (0s ago), crashers: 0, restarts: 1/5424, execs: 65098 (5424/sec), cover: 146, uptime: 12s
...
2019/12/07 22:09:11 workers: 4, corpus: 9 (4m0s ago), crashers: 0, restarts: 1/9860, execs: 4033002 (16002/sec), cover: 146, uptime: 4m12s
^C2019/12/07 22:09:13 shutting down...

这个测试执行非常耗CPU资源,一小会儿工夫,我的Mac Pro的风扇就开始呼呼转动起来了。不过submit包的Unpack函数并未在这次短暂运行的模糊测试中发现问题,crashers后面的数值一直是0。

为了演示被测代码在模糊测试中崩溃的情况,这里再举一个例子(例子代码改编自https://github.com/fuzzitdev/example-go)。在这个示例用,被测代码如下:

// chapter8/sources/fuzz-test-demo/parse_complex.go
package parser

func ParseComplex(data [] byte) bool {
    if len(data) == 5 {
        if data[0] == 'F' && data[1] == 'U' &&
            data[2] == 'Z' && data[3] == 'Z' &&
            data[4] == 'I' && data[5] == 'T' {
            return true
        }
    }
    return false
}

为上述被测目标建立模糊测试:

// chapter8/sources/fuzz-test-demo/parse_complex_fuzz.go

// +build gofuzz
package parser

func Fuzz(data []byte) int {
    ParseComplex(data)
    return 0
}

接下来按照套路,使用go-fuzz-build构建go-fuzz使用的二进制zip文件并运行go-fuzz:

$go-fuzz-build github.com/bigwhite/fuzz-test-demo
$go-fuzz -bin=./parser-fuzz.zip -workdir=./
2020/05/07 16:10:00 workers: 8, corpus: 6 (2s ago), crashers: 1, restarts: 1/0, execs: 0 (0/sec), cover: 0, uptime: 3s
2020/05/07 16:10:03 workers: 8, corpus: 6 (5s ago), crashers: 1, restarts: 1/0, execs: 0 (0/sec), cover: 10, uptime: 6s
2020/05/07 16:10:06 workers: 8, corpus: 6 (8s ago), crashers: 1, restarts: 1/5219, execs: 198330 (22034/sec), cover: 10, uptime: 9s
2020/05/07 16:10:09 workers: 8, corpus: 6 (11s ago), crashers: 1, restarts: 1/5051, execs: 383950 (31993/sec), cover: 10, uptime: 12s
2020/05/07 16:10:12 workers: 8, corpus: 6 (14s ago), crashers: 1, restarts: 1/5132, execs: 523514 (34898/sec), cover: 10, uptime: 15s
2020/05/07 16:10:15 workers: 8, corpus: 6 (17s ago), crashers: 1, restarts: 1/4930, execs: 631139 (35061/sec), cover: 10, uptime: 18s
^C2020/05/07 16:10:16 shutting down...

我们看到,在这次模糊测试执行的输出中,crashers的计数不再是0,而是1,这表明模糊测试引发了一次被测目标的崩溃。停掉模糊测试后,我们看到在测试执行的工作目录下出现了crashers和suppressions这两个目录:

$tree
.
├── corpus
│  ├── 1b7c3c5fec431a18fdebaa415d1f89a8f7a325bd-4
...
├── crashers
│  ├── df779ced6b712c5fca247e465de2de474d1d23b9
│  ├── df779ced6b712c5fca247e465de2de474d1d23b9.output
│  └── df779ced6b712c5fca247e465de2de474d1d23b9.quoted
...
├── go.mod
├── parse_complex.go
├── parse_complex_fuzz.go
├── parser-fuzz.zip
└── suppressions
  └── 4db970443bac2de13454771685ab603e779152b4

我们分别看看crashers和suppressions这两个目录下的内容:

// suppressions目录下的文件内容
$ cat suppressions/4db970443bac2de13454771685ab603e779152b4
panic: runtime error: index out of range [5] with length 5
github.com/bigwhite/fuzz-test-demo.ParseComplex.func5
github.com/bigwhite/fuzz-test-demo.ParseComplex
github.com/bigwhite/fuzz-test-demo.Fuzz
go-fuzz-dep.Main
main.main

// crashers目录下的文件内容

$cat crashers/df779ced6b712c5fca247e465de2de474d1d23b9
FUZZI

$cat crashers/df779ced6b712c5fca247e465de2de474d1d23b9.quoted
    "FUZZI"

cat crashers/df779ced6b712c5fca247e465de2de474d1d23b9.output
panic: runtime error: index out of range [5] with length 5

goroutine 1 [running]:
github.com/bigwhite/fuzz-test-demo.ParseComplex.func5(...)
    chapter8/sources/fuzz-test-demo/parse_complex.go:5
github.com/bigwhite/fuzz-test-demo.ParseComplex(0x28a21000, 0x5, 0x5,
    0x3bd-475a562627)
    chapter8/sources/fuzz-test-demo/parse_complex.go:5 +0x1be
github.com/bigwhite/fuzz-test-demo.Fuzz(0x28a21000, 0x5, 0x5, 0x3)
    chapter8/sources/fuzz-test-demo/parse_complex_fuzz.go:6 +0x57
go-fuzz-dep.Main(0xc000104f70, 0x1, 0x1)
    go-fuzz-dep/main.go:36 +0x1ad
main.main()
    github.com/bigwhite/fuzz-test-demo/go.fuzz.main/main.go:15 +0x52
exit status 2

从crashers/xxx.quoted中我们可以看到,引发此次崩溃的输入数据为"FUZZI"这个字符串;从crashers/xxx.output或suppressions/4db970443bac2de13454771685ab603e779152b4我们可以看到,导致崩溃的直接原因为“下标越界”。这些信息足以让我们快速定位到bug的位置:

data[5] == 'T'

接下来,我们可以修复该bug(可以将if len(data) == 5改为if len(data) == 6),并在该包的单元测试文件中添加一个针对该崩溃的用例,这里就不再赘述了。