这篇指导介绍Go中基础的模糊测试。通过模糊测试,随机数据会针对您的测试运行,以尝试找出漏洞或导致崩溃的输入。例如,通过模糊测试能够发现SQL注入的漏洞,缓冲区溢出,拒绝服务或者跨域攻击。
在这篇指导中,你将为一个简单的函数写一个模糊测试,运行Go命令行,调试并修复代码中的问题。
你将通过下面的部分:
打开命令行,进入到工作目录
从为你将要写的代码创建目录开始。
$ cd
$
通过命令行,创建一个名称为fuzz
的目录
$ mkdir fuzz
$ cd fuzz/
$
为你的代码创建一个模块
运行go mod init
命令,并给它一个新的代码模块路径
$ go mod init example/fuzz
go: creating new go.mod: module example/fuzz
$
接下来,你将添加一些简单代码去反转字符串,它将用于模糊。
在这一步,将添加函数用于反转字符串。
使用文本编辑器,在fuzz目录中,创建一个叫main.go
的文件。
进入main.go
文件,在文件的顶部,粘贴下面的代码声明。
package main
一个独立的程序(和标准库相反),总是在main包中。
在包声明的下面,粘贴下面的函数声明
func Reverse(s string) string {
b := []byte(s)
for i,j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1{
b[i], b[j] = b[j], b[i]
}
return string(b)
}
函数接受一个字符串,同时循环byte,最后返回反转的字符串。
注意:这个代码基于golang.org/x/example
的stringUtils.Reverse
函数。
在main.go
文件的顶部,在包声明的下面,粘贴下面的main函数去初始化一个字符串,反转它,打印输出,并重复这样做
func main() {
input := "The quick brown fox jumped over the lazy dog"
rev := Reverse(input)
doubleRev := Reverse(rev)
fmt.Printf("original: %q\n", input)
fmt.Printf("reversed: %q\n", rev)
fmt.Printf("reversed agained: %q\n", doubleRev)
}
这个函数将运行Reverse
操作,然后打印命令行的输出。这个是有用的,在实践中查看代码,也是有用的对于发现潜在的debug。
main
函数使用了fmt
包,因此,你将需要导入它。
第一行代码看起来像这样:
package main
import "fmt"
在包含main.go
文件目录的命令行下,运行代码:
$ go run .
original: "The quick brown fox jumped over the lazy dog"
reversed: "god yzal eht revo depmuj xof nworb kciuq ehT"
reversed agained: "The quick brown fox jumped over the lazy dog"
$
你能看到原始数据,反转的结果数据,然后对结果再次旋转。它和原始数据相等。
现在代码正在运行,是时间去测试它了。
在这一步,你将为Reverse函数写基础的单元测试。
使用你的文本编辑器,在fuzz目录中,创建一个文件叫做reverse_test.go
粘贴下面的代码到reverse_test.go
package main
import (
"testing"
)
func TestReverse(t *testing.T) {
testcases := []struct {
in, want string
}{
{"Hello, world", "dlrow ,olleH"},
{" ", " "},
{"!12345", "54321!"},
}
for _, tc := range testcases {
rev := Reverse(tc.in)
if rev != tc.want {
t.Errorf("Reverse: %q, want %q", rev, tc.want)
}
}
}
这个简单的函数将断言,那一列输入的字符串将被正确的反转。
使用go test
运行单元测试。
$ go test
PASS
ok example/fuzz 0.325s
$ go test -v
=== RUN TestReverse
--- PASS: TestReverse (0.00s)
PASS
ok example/fuzz 0.253s
$
接下来,你将改变单元测试变成模糊测试。
单元测试拥有局限性,即每次输入必须被开发者添加到测试。一个模糊测试的好处是它能为你的代码创建输入,并且能确认边缘化的用例,那些你没有想象出来的用例。
在这一部分你将转换单元测试变成模糊测试,以便于你能通过少量的工作产生更多输入。
注意,你将保留单元测试、基准测试和模糊测试在相同的*_test.go
文件下面。但是对于这个案例,你将转换单元测试成为模糊测试。
在你的文本编译器中,使用下面的模糊测试替换单元测试。
func FuzzReverse(f *testing.F) {
testcase := []string{"Hello World", " ", "!12345"}
for _, tc := range testcase {
f.Add(tc)
}
f.Fuzz(func(t *testing.T, orig string) {
rev := Reverse(orig)
doubleRev := Reverse(rev)
if orig != doubleRev {
t.Errorf("Before: %q, after: %q", orig, doubleRev)
}
if utf8.ValidString(orig) && !utf8.ValidString(rev) {
t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
}
})
}
Fuzzing也有一些局限性。在你的单元测试中,你能预测Reverse
函数期待的输出,并且验证实际的输出满足预期。
例如,在测试用例Reverse("Hello, world")
单元测试明确返回dlrow ,olleH
。
使用模糊查询,你不能预测期待的输出,因为你不能控制输入。
可是,这里有一些Reverse
函数的属性,你能在fuzz测试中进行验证。其中两个能在模糊测试中验证的是:
注意在单元测试和模糊测试中的语法不同。
FuzzXxx
开头代替TestXxx
,并且获取*testing.F
代替*testing.T
;t.Run
执行的地方,你替换成f.Fuzz
,在那儿你获取一个模糊测试的函数,它的参数是*testing.T
和一个用于模糊执行的类型。单元测试的输入使用 f.Add 作为种子语料库输入提供。确保新的包unicode/utf8
是被导入。
package main
import (
"testing"
"unicode/utf8"
)
使用模糊测试覆盖单元测试,同时,再次运行测试。
运行模糊测试,不带模糊属性,确保种子输入能够通过
$ go test
PASS
ok example/fuzz 1.089s
$
带上模糊,运行FuzzReverse
,去看是否任意的随机产生的字符串输入将产生错误。这次执行使用go test
带上一个新的标识,-fuzz
。
$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 8 workers
fuzz: elapsed: 0s, execs: 780 (19533/sec), new interesting: 5 (total: 8)
--- FAIL: FuzzReverse (0.04s)
--- FAIL: FuzzReverse (0.00s)
reverse_test.go:20: Reverse produced invalid UTF-8 string "0\x86\xcb"
Failing input written to testdata/fuzz/FuzzReverse/51fd8412ffb50aa7caa0c705c8d9b9f44bd34af7973d0f5940d60a7b24e6ff34
To re-run:
go test -run=FuzzReverse/51fd8412ffb50aa7caa0c705c8d9b9f44bd34af7973d0f5940d60a7b24e6ff34
FAIL
exit status 1
FAIL example/fuzz 1.106s
$
使用模糊测试的时候一个错误产生了,并且引起问题的输入是被写到了一个种子语料库文件。它将在下次go test
被调用的时候运行,即便不带-fuzz
标识。为了展示引起错误的输入,在编译器中打开被写到testdata/fuzz/FuzzReverse
目录下的语料库文件。你的语料库文件可能包含不同的字符串,但是格式是相同的。
$ cat testdata/fuzz/FuzzReverse/51fd8412ffb50aa7caa0c705c8d9b9f44bd34af7973d0f5940d60a7b24e6ff34
go test fuzz v1
string("ˆ0")
$
语料库文件第一行包含了编码版本。以下每一行代表构成语料库条目的每种类型的值。因为模糊测试的目标仅获取一个值,在版本后面仅仅有一个值。
再次不带-fuzz
运行go test
,将使用新的失败种子语料库条目。
$ go test
--- FAIL: FuzzReverse (0.00s)
--- FAIL: FuzzReverse/51fd8412ffb50aa7caa0c705c8d9b9f44bd34af7973d0f5940d60a7b24e6ff34 (0.00s)
reverse_test.go:20: Reverse produced invalid UTF-8 string "0\x86\xcb"
FAIL
exit status 1
FAIL example/fuzz 1.076s
$
因为我们的测试失败了,是时候进行调试了。
在这一部分,你将调试错误,并修复错误。
在继续之前,请随意花一些时间思考这个问题并尝试自己解决问题。
这儿有一些不同的方法用来调试错误。如果你使用VS Code作为你的编译器,你能设置断点进行调试。
在这个教程中,你将打印有用的调试信息到你的控制台。
首先,思考对于utf8.ValidString
的文档。
ValidString 报告 s 是否完全由有效的 UTF-8 编码符文组成。
当前的Reverse
函数,一个字节一个字节的反转字符串,这就是我们的问题。为了保留原始字符串的 UTF-8 编码符文,我们必须逐个符文反转字符串。
为了检查当反转的时候为什么那个输入(在这种情况下,字符串ˆ0
)造成了Reverse
产生了一个无效的字符串,你能检查反转字符串符文的数量。
在文本编译器中,使用下面的代码替换FuzzReverse
下的模糊目标。
f.Fuzz(func(t *testing.T, orig string){
rev := Reverse(orig)
doubleRev := Reverse(rev)
t.Logf("Number of runes: orig=%d, rev=%d, doubleRev=%d", utf8.RuneCountInString(orig), utf8.RuneCountInString(rev), utf8.RuneCountInString(doubleRev))
if orig != doubleRev {
t.Errorf("Before: %q, after: %q", orig, doubleRev)
}
if utf8.ValidString(orig) && !utf8.ValidString(rev) {
t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
}
})
这个t.Logf
行将打印到命令行,如果一个错误发生。获取如果执行测试带上-v
,它能帮助你调试特定的议题。
使用go test
运行测试
$ go test
--- FAIL: FuzzReverse (0.00s)
--- FAIL: FuzzReverse/51fd8412ffb50aa7caa0c705c8d9b9f44bd34af7973d0f5940d60a7b24e6ff34 (0.00s)
reverse_test.go:16: Number of runes: orig=2, rev=3, doubleRev=2
reverse_test.go:21: Reverse produced invalid UTF-8 string "0\x86\xcb"
FAIL
exit status 1
FAIL example/fuzz 1.098s
$
整个种子语料库使用字符串,其中每个字符都是一个字节。可是,字符像^
需要若干个字节。因此,一个字节一个字节的反转字符串是无效的对于多字节字符。
注意,如果你好奇关于Go处理字符串,阅读博客Strings, bytes, runes and characters in Go进行更深度的理解。
为了更好的理解错误,更正反向功能的错误。
为了修复Reverse
函数,让我们按照符文遍历字符,代替通过字节。
在文本编译器中,使用下面的代码替换存在的Reverse()
函数。
func Reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i<len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
关键的不同是,Reverse
现在是遍历字符串中的每个符文,而不是每一个字符。
使用go test
运行测试
$ go test
PASS
ok example/fuzz 1.207s
$
再次使用go test -fuzz=Fuzz
进行模糊测试,去看现在是否有新的问题。
$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/9 completed
fuzz: minimizing 31-byte failing input file
fuzz: elapsed: 0s, gathering baseline coverage: 4/9 completed
--- FAIL: FuzzReverse (0.03s)
--- FAIL: FuzzReverse (0.00s)
reverse_test.go:16: Number of runes: orig=1, rev=1, doubleRev=1
reverse_test.go:18: Before: "\xa0", after: "�"
Failing input written to testdata/fuzz/FuzzReverse/1d81da97512e2e81bf08cdb8b161334d7d9639f5c5b18255c920ad25370ff2aa
To re-run:
go test -run=FuzzReverse/1d81da97512e2e81bf08cdb8b161334d7d9639f5c5b18255c920ad25370ff2aa
FAIL
exit status 1
FAIL example/fuzz 1.081s
$
我们能够看到,经过两次反转的结果是不同于原始结果。这次输入是一个无效的字符串。如果我们使用字符串进行模糊测试,这怎么可能?
让我们再次调试。
在这一部分,我们将调试两次反转的错误,并修复这个错误。
在继续之前,自由的好一些时间去思考这个问题,并修复这个问题。
就像之前,有若干个方法调试错误,在这种场景下,使用断点是一个好的方法。
在这个教程中,我们将在Reverse函数中打印有用的调试信息。
仔细查看反转的字符串以发现错误。在Go中,一个字符串实际上是一个字符切片。并且能够包含非有效的UTF-8字节。原始字符串是一个字节切片,有一个字节,‘\x91’。 当输入字符串设置为 []rune 时,Go 将字节切片编码为 UTF-8,并将字节替换为 UTF-8 字符 �。 当我们将替换的 UTF-8 字符与输入字节切片进行比较时,它们显然不相等。
在文本编辑器中,使用下面的代码替换Reverse
函数
func Reverse(s string) string {
fmt.Printf("input: %q\n", s)
r := []rune(s)
fmt.Printf("runes: %q\n", r)
for i, j := 0, len(r)-1; i<len(r)-1; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
这将帮助我们理解发生了什么错误,当字符串转化成符文切片。
这个时候,我们仅仅想运行失败的测试为了检查日志。为了做这个,我们将使用go test -run
。
$ go test -run FuzzReverse/1d81da97512e2e81bf08cdb8b161334d7d9639f5c5b18255c920ad25370ff2aa
input: "\xa0"
runes: ['�']
input: "�"
runes: ['�']
--- FAIL: FuzzReverse (0.00s)
--- FAIL: FuzzReverse/1d81da97512e2e81bf08cdb8b161334d7d9639f5c5b18255c920ad25370ff2aa (0.00s)
reverse_test.go:16: Number of runes: orig=1, rev=1, doubleRev=1
reverse_test.go:18: Before: "\xa0", after: "�"
FAIL
exit status 1
FAIL example/fuzz 0.435s
$
为了运行包含在FuzzXxx/testdata
中特定语料库,你能提供{FuzzTestName}/filename
到-run
。这是有用的在调试的时候。
知道了输入是无效的unicode
,让我们修复在Reverse
函数的错误。
为了修复这个问题,如果Reverse
输入不是一个有效的的UTF-8,让我们返回一个错误。
在你的编译器中,使用下面的函数替换存在的Reverse
函数
func Reverse(s string) (string, error) {
if !utf8.ValidString(s) {
return s, errors.New("输入是一个无效的UTF-8")
}
fmt.Printf("input: %q\n", s)
r := []rune(s)
fmt.Printf("runes: %q\n", r)
for i, j := 0, len(r)-1; i < len(r)-1; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r), nil
}
这次改变,当输入的字符串是一个无效的UTF-8时,将返回一个错误。
因为Reverse
函数现在返回了一个错误,修复main函数去丢弃额外的错误值。使用下面的代码替换已经存在的main
函数。
func main() {
input := "The quick brown fox jumped over the lazy dog"
rev, revErr := Reverse(input)
doubleRev, doubleErr := Reverse(rev)
fmt.Printf("original: %q\n", input)
fmt.Printf("reversed: %q, err: %v\n", rev, revErr)
fmt.Printf("reversed agained: %q, err: %v\n", doubleRev, doubleErr)
}
这些调用将会返回一个nil
错误,因为输入是有效的UTF-8。
你需要导入errors
和unicode/utf8
包。在main.go
中的导入语句看起来像这样:
import (
"errors"
"fmt"
"unicode/utf8"
)
修改reverse_test.go
文件,检查错误并跳过测试,如果错误是被返回值产生。
func FuzzReverse(f *testing.F) {
testcase := []string{"Hello World", " ", "!12345"}
for _, tc := range testcase {
f.Add(tc) // 使用 f.Add 提供种子语料库
}
f.Fuzz(func(t *testing.T, orig string) {
rev, err1 := Reverse(orig)
if err1 != nil {
return
}
doubleRev, err2 := Reverse(rev)
if err2 != nil {
return
}
if orig != doubleRev {
t.Errorf("Before: %q, after: %q", orig, doubleRev)
}
if utf8.ValidString(orig) && !utf8.ValidString(rev) {
t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
}
})
}
除了返回,您还可以调用 t.Skip() 来停止执行该模糊输入。
使用 go test
运行测试
$ go test
PASS
ok example/fuzz 0.491s
$
使用带有go test -fuzz=Fuzz
进行模糊化,然后若干秒后通过,使用ctrl-C
停止模糊测试
$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/9 completed
fuzz: elapsed: 0s, gathering baseline coverage: 9/9 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 306930 (102280/sec), new interesting: 32 (total: 41)
fuzz: elapsed: 6s, execs: 870767 (187915/sec), new interesting: 33 (total: 42)
fuzz: elapsed: 9s, execs: 1293839 (141029/sec), new interesting: 34 (total: 43)
......
fuzz: elapsed: 1m18s, execs: 10520829 (142569/sec), new interesting: 36 (total: 45)
fuzz: elapsed: 1m21s, execs: 10909615 (129590/sec), new interesting: 37 (total: 46)
fuzz: elapsed: 1m24s, execs: 11289776 (126692/sec), new interesting: 37 (total: 46)
fuzz: elapsed: 1m27s, execs: 11689172 (133198/sec), new interesting: 37 (total: 46)
fuzz: elapsed: 1m30s, execs: 12068776 (126454/sec), new interesting: 37 (total: 46)
fuzz: elapsed: 1m33s, execs: 12461830 (131088/sec), new interesting: 37 (total: 46)
fuzz: elapsed: 1m36s, execs: 12843006 (127040/sec), new interesting: 37 (total: 46)
^Cfuzz: elapsed: 1m38s, execs: 13067321 (134596/sec), new interesting: 37 (total: 46)
PASS
ok example/fuzz 98.729s
lifeideMacBook-Pro:fuzz lifei$
除非您通过 -fuzztime 标志,否则模糊测试将一直运行,直到遇到失败的输入。如果没有发生故障,默认是永远运行,并且可以使用 ctrl-C 中断该过程。
使用go test -fuzz=Fuzz -fuzztime 30s
进行模糊化,如果没有发现失败,它将在退出之前模糊 30 秒。
$ go test -fuzz=Fuzz -fuzztime 30s
fuzz: elapsed: 0s, gathering baseline coverage: 0/46 completed
fuzz: elapsed: 0s, gathering baseline coverage: 46/46 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 432881 (144195/sec), new interesting: 3 (total: 49)
fuzz: elapsed: 6s, execs: 881372 (149498/sec), new interesting: 3 (total: 49)
fuzz: elapsed: 9s, execs: 1320812 (146554/sec), new interesting: 3 (total: 49)
fuzz: elapsed: 12s, execs: 1735749 (138323/sec), new interesting: 3 (total: 49)
fuzz: elapsed: 15s, execs: 2140404 (134860/sec), new interesting: 3 (total: 49)
fuzz: elapsed: 18s, execs: 2553033 (137558/sec), new interesting: 3 (total: 49)
fuzz: elapsed: 21s, execs: 2934209 (127072/sec), new interesting: 3 (total: 49)
fuzz: elapsed: 24s, execs: 3289376 (118391/sec), new interesting: 3 (total: 49)
fuzz: elapsed: 27s, execs: 3670350 (126988/sec), new interesting: 4 (total: 50)
fuzz: elapsed: 30s, execs: 4071006 (133510/sec), new interesting: 4 (total: 50)
fuzz: elapsed: 30s, execs: 4071006 (0/sec), new interesting: 4 (total: 50)
PASS
ok example/fuzz 30.994s
$
模糊化测试通过。
除了添加-fuzz
标识,若干个新的标识能够添加到go test
上,在文档中能够看到:custom-settings 。
点击文档,go.dev/doc/fuzz 进一步阅读。