前言
Go 1.18在go工具链里引入了fuzzing含糊测试,能够帮忙咱们发现Go代码里的破绽或者可能导致程序解体的输出。Go官网团队也在官网公布了fuzzing入门教程,帮忙大家疾速上手。
自己对Go官网教程在翻译的根底上做了一些表述上的优化,以飨读者。
留神:fuzzing含糊测试和Go已有的单元测试以及性能测试框架是互为补充的,并不是代替关系。
教程内容
这篇教程会介绍Go fuzzing的入门基础知识。fuzzing能够结构随机数据来找出代码里的破绽或者可能导致程序解体的输出。通过fuzzing能够找出的破绽包含SQL注入、缓冲区溢出、拒绝服务(Denial of Service)攻打和XSS(cross-site scripting)攻打等。
在这个教程里,你会给一个函数写一段fuzz test(含糊测试)程序,而后运行go命令来发现代码里的问题,最初通过调试来修复问题。
本文里波及的专业术语,能够参考 Go Fuzzing glossary。
接下来会依照如下章节介绍:
- 为你的代码创立一个目录
- 实现一个函数
- 减少单元测试
- 减少含糊测试
- 修复2个bug
- 总结
筹备工作
- 装置Go 1.18 Beta 1或者更新的版本。装置指引能够参考上面的介绍。
- 有一个代码编辑工具。任何文本编辑器都能够。
- 有一个命令行终端。Go能够运行在Linux,Mac上的任何命令行终端,也能够运行在Windows的PowerShell或者cmd之上。
- 有一个反对fuzzing的环境。目前Go fuzzing只反对AMD64和ARM64架构。
装置和应用beta版本
这个教程须要应用Go 1.18 Beta 1或以上版本的泛型性能。应用如下步骤,装置beta版本
应用上面的命令装置beta版本
$ go install golang.org/dl/go1.18beta1@latest
运行如下命令来下载更新
$ go1.18beta1 download
留神:如果在MAC或者Linux上执行
go1.18beta1
提醒command not found
,须要设置bash
或者zsh
对应的profile环境变量文件。bash
设置在~/.bash_profile
文件里,内容为:export GOROOT=/usr/local/opt/go/libexecexport GOPATH=$HOME/goexport PATH=$PATH:$GOROOT/bin:$GOPATH/bin
GOROOT
和GOPATH
的值能够通过go env
命令查看,设置完后执行source ~/.bash_profile
让设置失效,再执行go1.18beta1
就不报错了。应用beta版本的go命令,不要去应用release版本的go命令
你能够通过间接应用
go1.18beta1
命令或者给go1.18beta1
起一个简略的别名间接应用
go1.18beta1
命令$ go1.18beta1 version
给
go1.18beta1
命令起一个别名$ alias go=go1.18beta1$ go version
上面的教程都假如你曾经把
go1.18beta1
命令设置了别名go
。
为你的代码创立一个目录
首先创立一个目录用于寄存你写的代码。
关上一个命令行终端,切换到你的
home
目录在Linux或者Mac上执行如下命令(Linux或者Mac上只须要执行
cd
就能够进入到home
目录)cd
在Windows上执行如下命令
C:\> cd %HOMEPATH%
在命令行终端,创立一个名为
fuzz
的目录,并进入该目录$ mkdir fuzz$ cd fuzz
创立一个go module
运行
go mod init
命令,来给你的我的项目设置module门路$ go mod init example/fuzz
留神:对于生产代码,你能够依据我的项目理论状况来指定module门路,如果想理解更多,能够参考Go Module依赖治理。
接下来,咱们来应用map写一些简略的代码来做字符串的反转,而后应用fuzzing来做含糊测试。
实现一个函数
在这个章节,你须要实现一个函数来对字符串做反转。
编写代码
- 关上你的文本编辑器,在fuzz目录下创立一个
main.go
源文件。 在
main.go
里编写如下代码:// maing.gopackage mainimport "fmt"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)}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 again: %q\n", doubleRev)}
运行代码
在main.go
所在目录执行命令go run .
来运行代码,后果如下:
$ go run .original: "The quick brown fox jumped over the lazy dog"reversed: "god yzal eht revo depmuj xof nworb kciuq ehT"reversed again: "The quick brown fox jumped over the lazy dog"
减少单元测试
在这个章节,你会给Reverse
函数编写单元测试代码。
编写单元测试
- 在fuzz目录下创立文件
reverse_test.go
。 在
reverse_test.go
里编写如下代码:package mainimport ( "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 testPASSok example/fuzz 0.013s
接下来,咱们给Reverse
函数减少含糊测试(fuzz test)代码。
减少含糊测试
单元测试有局限性,每个测试输出必须由开发者指定加到单元测试的测试用例里。
fuzzing的长处之一是能够基于开发者代码里指定的测试输出作为根底数据,进一步主动生成新的随机测试数据,用来发现指定测试输出没有笼罩到的边界状况。
在这个章节,咱们会把单元测试转换成含糊测试,这样能够更轻松地生成更多的测试输出。
留神:你能够把单元测试、性能测试和含糊测试放在同一个*_test.go
文件里。
编写含糊测试
在文本编辑器里把reverse_test.go
里的单元测试代码TestReverse
替换成如下的含糊测试代码FuzzReverse
。
func FuzzReverse(f *testing.F) { testcases := []string{"Hello, world", " ", "!12345"} for _, tc := range testcases { f.Add(tc) // Use f.Add to provide a seed corpus } 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
的执行后果是否和预期相符。例如,对于测试用例Reverse("Hello, world")
,单元测试预期的后果是 "dlrow ,olleH"
。
然而应用fuzzing时,咱们没方法预期输入后果是什么,因为测试的输出除了咱们代码里指定的用例之外,还有fuzzing随机生成的。对于随机生成的测试输出,咱们当然没方法提前晓得输入后果是什么。
尽管如此,本文里的Reverse
函数有几个个性咱们还是能够在含糊测试里做验证。
- 对一个字符串做2次反转,失去的后果和源字符串雷同
- 反转后的字符串也依然是一个无效的UTF-8编码的字符串
留神:fuzzing含糊测试和Go已有的单元测试以及性能测试框架是互为补充的,并不是代替关系。
比方咱们实现的Reverse
函数如果是一个谬误的版本,间接return返回输出的字符串,是齐全能够通过下面的含糊测试的,然而没法通过咱们后面编写的单元测试。因而单元测试和含糊测试是互为补充的,不是代替关系。
Go含糊测试和单元测试在语法上有如下差别:
- Go含糊测试函数以FuzzXxx结尾,单元测试函数以TestXxx结尾
- Go含糊测试函数以
*testing.F
作为入参,单元测试函数以*testing.T
作为入参 Go含糊测试会调用
f.Add
函数和f.Fuzz
函数。f.Add
函数把指定输出作为含糊测试的种子语料库(seed corpus),fuzzing基于种子语料库生成随机输出。f.Fuzz
函数接管一个fuzz target函数作为入参。fuzz target函数有多个参数,第一个参数是*testing.T
,其它参数是被含糊的类型(留神:被含糊的类型目前只反对局部内置类型, 列在 Go Fuzzing docs,将来会反对更多的内置类型)。
下面的FuzzReverse
函数里用到了utf8
这个package,因而要在reverse_test.go
结尾import这个package,参考如下代码:
package mainimport ( "testing" "unicode/utf8")
运行含糊测试
执行如下命令来运行含糊测试。
这个形式只会应用种子语料库,而不会生成随机测试数据。通过这种形式能够用来验证种子语料库的测试数据是否能够测试通过。(fuzz test without fuzzing)
$ go testPASSok example/fuzz 0.013s
如果
reverse_test.go
文件里有其它单元测试函数或者含糊测试函数,然而只想运行FuzzReverse
含糊测试函数,咱们能够执行go test -run=FuzzReverse
命令。留神:
go test
默认会执行所有以TestXxx
结尾的单元测试函数和以FuzzXxx
结尾的含糊测试函数,默认不运行以BenchmarkXxx
结尾的性能测试函数,如果咱们想运行 benchmark用例,则须要加上-bench
参数。如果要基于种子语料库生成随机测试数据用于含糊测试,须要给
go test
命令减少-fuzz
参数。(fuzz test with fuzzing)$ go test -fuzz=Fuzzfuzz: elapsed: 0s, gathering baseline coverage: 0/3 completedfuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 8 workersfuzz: minimizing 38-byte failing input file...--- FAIL: FuzzReverse (0.01s) --- FAIL: FuzzReverse (0.00s) reverse_test.go:20: Reverse produced invalid UTF-8 string "\x9c\xdd" Failing input written to testdata/fuzz/FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a To re-run: go test -run=FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217aFAILexit status 1FAIL example/fuzz 0.030s
下面的fuzzing测试后果是
FAIL
,引起FAIL
的输出数据被写到了一个语料库文件里。下次运行go test
命令的时候,即便没有-fuzz
参数,这个语料库文件里的测试数据也会被用到。能够用文本编辑器关上
testdata/fuzz/FuzzReverse
目录下的文件,看看引起Fuzzing测试失败的测试数据长什么样。上面是一个示例文件,你那边运行后失去的测试数据可能和这个不一样,但文件里的内容格局会是一样的。go test fuzz v1string("泃")
语料库文件里的第1行标识的是编码版本(encoding version,说直白点,就是这个种子语料库文件里内容格局的版本),尽管目前只有v1这1个版本,然而Fuzzing设计者思考到将来可能引入新的编码版本,于是加了编码版本的概念。
从第2行开始,每一行数据对应的是语料库的每条测试数据(corpus entry)的其中一个参数,依照参数先后顺序排列。
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 %q", orig, rev) }})
本文的
FuzzReverse
里的fuzz target函数func(t *testing.T, orig string)
只有orig
这1个参数作为真正的测试输出,也就是每条测试数据其实就1个输出,因而在下面示例的testdata/fuzz/FuzzReverse
目录下的文件里只有string("泃")
这一行。如果每条测试数据有N个参数,那fuzzing找出的导致fuzz test失败的每条测试数据在
testdata
目录下的文件里会有N行,第i
行对应第i
个参数。再次运行
go test
命令,这次不带-fuzz
参数。咱们会发现尽管没有
-fuzz
参数,然而含糊测试的时候依然用到了下面第2步找到的测试数据。$ go test--- FAIL: FuzzReverse (0.00s) --- FAIL: FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a (0.00s) reverse_test.go:20: Reverse produced invalid stringFAILexit status 1FAIL example/fuzz 0.016s
既然Go fuzzing测试没通过,那就须要咱们调试代码来找出问题所在了。
修复2个bug
在这个章节,咱们会调试程序,修复Go fuzzing测进去的bug。
你能够本人花一些工夫思考下,先尝试本人解决问题。
定位问题
你能够应用不同的办法来调试下面发现的bug。
如果你应用的是VS Code,那能够在VS Code里设置你的Debug调试器来加断点进行调试。
本文里,咱们会应用打印日志的形式进行调试。
运行含糊测试时的报错信息为:reverse_test.go:20: Reverse produced invalid UTF-8 string "\x9c\xdd"
基于这个报错,咱们来看看文档里对于 utf8.ValidString
的形容。
ValidString reports whether s consists entirely of valid UTF-8-encoded runes.
咱们实现的Reverse
函数是依照字节(byte)为维度进行字符串反转,这就是问题所在。
比方中文里的字符 泃
其实是由3个字节组成的,如果依照字节反转,反转后失去的就是一个有效的字符串了。
因而为了保障字符串反转后失去的依然是一个无效的UTF-8编码的字符串,咱们要依照rune
进行字符串反转。
为了更好中央便大家了解中文里的字符 泃
依照rune
为维度有多少个rune
,以及依照byte反转后失去的后果长什么样,咱们对代码做一些批改。
编写代码
依照如下形式批改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) }})
运行代码
$ go test--- FAIL: FuzzReverse (0.00s) --- FAIL: FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0 (0.00s) reverse_test.go:16: Number of runes: orig=1, rev=3, doubleRev=1 reverse_test.go:21: Reverse produced invalid UTF-8 string "\x83\xb3\xe6"FAILexit status 1FAIL example/fuzz 0.598s
咱们的种子语料库里每个符号都是单个字节。然而像 泃
这样的中文符号由多个字节组成,如果以字节为维度进行反转,就会失去有效的后果。
留神:如果你对于Go如何解决字符串感兴趣,能够浏览官网博客里的这篇文章 Strings, bytes, runes and characters in Go 来加深了解。
既然咱们明确了问题,那咱们就能够修复这个bug了。
修复问题
以rune
为维度进行字符串反转。
编写代码
批改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)}
运行代码
运行命令:
go test
$ go testPASSok example/fuzz 0.016s
测试通过啦!(别快乐太早,这个只是通过了种子语料库和之前)
再次运行
go test -fuzz
,看看咱们是否会发现新的bug$ go test -fuzz=Fuzzfuzz: elapsed: 0s, gathering baseline coverage: 0/37 completedfuzz: minimizing 506-byte failing input file...fuzz: elapsed: 0s, gathering baseline coverage: 5/37 completed--- FAIL: FuzzReverse (0.02s) --- FAIL: FuzzReverse (0.00s) reverse_test.go:33: Before: "\x91", after: "�" Failing input written to testdata/fuzz/FuzzReverse/1ffc28f7538e29d79fce69fef20ce5ea72648529a9ca10bea392bcff28cd015c To re-run: go test -run=FuzzReverse/1ffc28f7538e29d79fce69fef20ce5ea72648529a9ca10bea392bcff28cd015cFAILexit status 1FAIL example/fuzz 0.032s
通过下面的报错,咱们发现对一个字符串做了2次反转后失去的和原字符串不一样。
这次测试输出自身是非法的unicode,然而为什么会2次反转后失去的字符串还不一样呢?
咱们持续调试。
修复2次字符串反转的bug
定位问题
对于这个问题,加断点调试会很好定位。为了不便解说,本文应用加日志的形式进行调试。
咱们能够仔细观察原字符串第一次反转后失去的后果来定位问题。
编写代码
批改
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)/2; i, j = i+1, j-1 { r[i], r[j] = r[j], r[i] } return string(r)}
这能够帮忙咱们了解把原字符串转成
rune
切片后产生了什么。
运行代码
这一次,咱们只运行让fuzz test失败的测试数据,应用 go test -run
命令。
运行FuzzXxx/testdata目录下指定的语料库测试数据,能够给-run
参数指定值 {FuzzTestName}/{filename} ,这能够让咱们聚焦在让fuzz test失败的测试数据上。
$ go test -run=FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0input: "\x91"runes: ['�']input: "�"runes: ['�']--- FAIL: FuzzReverse (0.00s) --- FAIL: FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0 (0.00s) reverse_test.go:16: Number of runes: orig=1, rev=1, doubleRev=1 reverse_test.go:18: Before: "\x91", after: "�"FAILexit status 1FAIL example/fuzz 0.145s
首先咱们要理解:在Go语言里,字符串是只读的字节切片(In Go, a string is a read only slice of bytes),字节切片里的每个字节不肯定都是无效的UTF-8编码的字节,详情能够参考a string is a read only slice of bytes。
下面的例子里,输出的字符串是只有1个byte的字节切片,这1个byte是\x91
。
当咱们把这个输出的字符串转成[]rune
时,Go会把字节切片编码为UTF-8,于是就把\x91
替换成了'�','�'饭庄后还是'�',一次就导致原字符串\x91
反转后失去的字符串是'�'了。
当初问题明确了,是因为输出的数据是非法的unicode。那接下来咱们就能够修改Reverse
函数的实现了。
修复问题
修复形式为:在Reverse
里查看输出是否为非法的UTF-8编码字符串,如果非法,就返回eror。
编写代码
批改
Reverse
实现如下:func Reverse(s string) (string, error) { if !utf8.ValidString(s) { return s, errors.New("input is not valid UTF-8") } 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), nil}
因为
Reverse
函数当初会返回error,因而要批改main.go
里的对应代码,批改如下:func main() { input := "The quick brown fox jumped over the lazy dog" rev, revErr := Reverse(input) doubleRev, doubleRevErr := Reverse(rev) fmt.Printf("original: %q\n", input) fmt.Printf("reversed: %q, err: %v\n", rev, revErr) fmt.Printf("reversed again: %q, err: %v\n", doubleRev, doubleRevErr)}
因为
main
函数里都是无效的UTF-8编码字符串,所以对Reverse
的调用会返回一个值为nil的error。因为
Reverse
函数用到了errors
和utf8
这2个package,因而在main.go
的结尾要import这2个package。import ( "errors" "fmt" "unicode/utf8")
同样,咱们须要批改
reverse_test.go
文件,对于非法的字符串输出,能够间接跳过测试。func FuzzReverse(f *testing.F) { testcases := []string {"Hello, world", " ", "!12345"} for _, tc := range testcases { f.Add(tc) // Use f.Add to provide a seed corpus } 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) } })}
除了应用return,你还能够调用
t.Skip()
来跳过以后的测试输出,持续下一轮测试输出。
运行代码
运行测试代码
$ go testPASSok example/fuzz 0.019s
运行含糊测试
go test -fuzz=Fuzz
,执行几秒后,应用ctrl-C
完结测试。$ go test -fuzz=Fuzzfuzz: elapsed: 0s, gathering baseline coverage: 0/38 completedfuzz: elapsed: 0s, gathering baseline coverage: 38/38 completed, now fuzzing with 4 workersfuzz: elapsed: 3s, execs: 86342 (28778/sec), new interesting: 2 (total: 35)fuzz: elapsed: 6s, execs: 193490 (35714/sec), new interesting: 4 (total: 37)fuzz: elapsed: 9s, execs: 304390 (36961/sec), new interesting: 4 (total: 37)...fuzz: elapsed: 3m45s, execs: 7246222 (32357/sec), new interesting: 8 (total: 41)^Cfuzz: elapsed: 3m48s, execs: 7335316 (31648/sec), new interesting: 8 (total: 41)PASSok example/fuzz 228.000s
fuzz test如果没有遇到谬误,默认会始终运行上来,须要应用
ctrl-C
完结测试。也能够传递
-fuzztime
参数来指定测试工夫,这样就不必ctrl-C
了。指定测试工夫。
go test -fuzz=Fuzz -fuzztime 30s
如果没有遇到谬误会执行30s后主动完结。$ go test -fuzz=Fuzz -fuzztime 30sfuzz: elapsed: 0s, gathering baseline coverage: 0/5 completedfuzz: elapsed: 0s, gathering baseline coverage: 5/5 completed, now fuzzing with 4 workersfuzz: elapsed: 3s, execs: 80290 (26763/sec), new interesting: 12 (total: 12)fuzz: elapsed: 6s, execs: 210803 (43501/sec), new interesting: 14 (total: 14)fuzz: elapsed: 9s, execs: 292882 (27360/sec), new interesting: 14 (total: 14)fuzz: elapsed: 12s, execs: 371872 (26329/sec), new interesting: 14 (total: 14)fuzz: elapsed: 15s, execs: 517169 (48433/sec), new interesting: 15 (total: 15)fuzz: elapsed: 18s, execs: 663276 (48699/sec), new interesting: 15 (total: 15)fuzz: elapsed: 21s, execs: 771698 (36143/sec), new interesting: 15 (total: 15)fuzz: elapsed: 24s, execs: 924768 (50990/sec), new interesting: 16 (total: 16)fuzz: elapsed: 27s, execs: 1082025 (52427/sec), new interesting: 17 (total: 17)fuzz: elapsed: 30s, execs: 1172817 (30281/sec), new interesting: 17 (total: 17)fuzz: elapsed: 31s, execs: 1172817 (0/sec), new interesting: 17 (total: 17)PASSok example/fuzz 31.025s
Fuzzing测试通过!
除了
-fuzz
参数外,有几个新的参数也被引入到了go test
命令,具体能够参考 documentation。
总结
目前你曾经学会了Go fuzzing的应用办法。
接下来,你能够在本人写过的代码里,尝试应用fuzzing来发现代码里的bug。
如果你真的发现了bug,请思考把案例提交到了trophy case。
如果你发现了Go fuzzing的任何问题或者想提feature,能够在这里反馈file an issue。
查看文档 go.dev/doc/fuzz理解更多Go Fuzzing的常识。
本文的残缺代码参考Go Fuzzing示例代码。
开源地址
文章和示例代码开源在GitHub: Go语言高级、中级和高级教程。
公众号:coding进阶。关注公众号能够获取最新Go面试题和技术栈。
集体网站:Jincheng's Blog。
知乎:无忌。
References
- Fuzzing教程:https://go.dev/doc/tutorial/fuzz
- Fuzzing提案:https://github.com/golang/go/...
- Fuzzing介绍:https://go.dev/doc/fuzz/