一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第4天,点击查看活动详情。
本文翻译自:
Tutorial: Getting started with fuzzing - The Go Programming Language
指南:开始模糊测试
该指南介绍了使用 Go 模糊测试的基础。使用模糊测试,可将随机数据运行于测试用于试图发现脆弱点或造成崩溃的输入。
一些可用模糊测试发现的脆弱点示例是 SQL 注入、缓存溢出、服务拒绝和跨站点脚本攻击。
在该指南中,你会编写一个针对简单函数的模糊测试,运行 go 命令,然后调试和修复代码中的问题。
对于该指南中出现的术语需要帮助的话,查看 Go Fuzzing glossary 。
你会通过以下几部分来完成:
注意: 其它指南请查看 Tutorials 。
注意: Go 模糊测试现在支持内建类型的子集,在 Go Fuzzing docs列出了相关内容,在将来会添加更多内建类型的支持。
前提
- 安装 Go 1.18 或更高版本。 安装说明查看 Installing Go。
- 编辑代码的工具。 平时使用的任何文本编辑器都可以。
- 一个命令终端 Go 在 Liunx 或 Mac 的任何终端上在 Windows 上的 PowerShell 或 cmd 上都能正常使用。
- 支持模糊测试的环境。 带覆盖率指令的 Go 模糊测试现在仅在 AMD64 和 ARM64 架构上可用。
为代码创建文件夹
首先,为要编写的代码创建文件夹。
-
打开终端命令行,进入到 home 目录。
在 Linux 或 Mac 上:
$ cd在 Windows 上:
C:> cd %HOMEPATH%下面的内容会显示 $ 作为提示符。你使用的命令在 Windows 上也能正常工作。
-
在命令行,为代码创建名为 fuzz 的目录。
$ mkdir fuzz $ cd fuzz -
创建模块管理代码。
运行
go mod init命令,设置为新代码的模块路径。$ go mod init example/fuzz go: creating new go.mod: module example/fuzz注意: 对于生产环境的代码,你会指定一个更符合自己需求的模块路径。更多信息查看 Managing dependencies。
接下来,你会添加一些简单的代码用于反转一个字符串,后面会对它进行模糊测试。
添加要测试的代码
在该步骤中,你会添加一个函数用于返回一个字符串。
编写代码
-
使用你的文本编辑器,在 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) }该函数接收一个
string,每次循环一个byte,最后返回反转后的字符串。注意: 该代码基于 golang.org/x/example 里的
stringutil.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 again: %q\n", doubleRev) }该函数会运行几次
Reverse操作,然后在命令行打印输出。这对于实践中查看代码很有帮助,对于调试也有潜在的帮助。 -
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 again: "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.013s
接下来,你会将单元测试变为模糊测试。
添加模糊测试
单元测试有限制,换句话说,每个输入都需要开发者添加到测试中。使用模糊测试的一个好处是它为代码提供输入,然后会识别你的测试用例可能不会达到的边界情况。
在该部分中,你会将单元测试转换为模糊测试,所以你可以用更少的工作生成更多的输入!
注意你可以在同一个 *_test.go 文件中保留单元测试、性能测试和模糊测试,但是对于本示例你会将单元测试转换为模糊测试。
编写代码
在你的代码编辑器中,使用下面的模糊测试替换 reverse_test.go 中的单元测试。
func FuzzReverse(f *testing.F) {
testcases := []string{"Hello, world", " ", "!12345"}
for _, tc := range testcases {
f.Add(tc) // 使用 f.Add 添加种子用例库
}
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)
}
})
}
模糊测试也有一些限制。在你的单元测试中,可以预知 Reverse 函数的期望输出,然后验证实际输出是否符合这些期望值。
例如,在测试用户 Reverse("Hello, world") 中,单元测试指定了 "dlrow ,olleH" 为返回值。
模糊测试时,你无法预知期望的输出,因为你无法控制输入。
尽管如此,Reverse 的一些属性可在模糊测试中验证。在该模糊测试中检验的两个属性是:
- 反转两次字符串维持原始值。
- 反转的字符串作为有效的 UTF-8 字符维持它的状态。
注意单元测试和模糊测试之间的语法区别:
- 函数名以 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 0.013s如果文件中有其它的测试,然后你只想运行模糊测试时,也可以运行
go test -run=FuzzReverse。 -
模糊测试模式运行
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: 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/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a FAIL exit status 1 FAIL example/fuzz 0.030s模糊测试时发生了一个错误,导致问题的输入写入到了种子用例库文件中,当下次调用
go test会运行该文件,即使不带-fuzz标志。 要查看导致错误的输入,在文本编辑器中打开写入到 testdata/fuzz/FuzzReverse 下的用例库文件。你的种子用例库文件可能会包含不同的字符串,但是格式是相同的。go test fuzz v1 string("泃")用例库文件的第一行标示编码版本。之后的每一行代表创建用例库实体的各种类型值。 由于模糊测试目标只有一个输入,所以版本下面只有一个值。
-
不带
-fuzz标志再次运行go test;会使用刚刚失败的种子用例实体:$ go test --- FAIL: FuzzReverse (0.00s) --- FAIL: FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a (0.00s) reverse_test.go:20: Reverse produced invalid string FAIL exit status 1 FAIL example/fuzz 0.016s因为我们的测试失败了,所以要开始调试。
修复非法字符错误
在该部分中,你会调试失败的地方,然后修复 bug 。
可随意花些时间考虑这个问题,然后你可以在继续之前自己修复这个问题。
诊断错误
你可以用一些不同的方式调试该错误。如果你在使用 VSCode 作为你的文本编辑器,可以 安装调试器 来诊断。
在该指南中,我们会将有用的调试日志输出到终端。
首先考虑 utf8.ValidString 的文档。
ValidString reports whether s consists entirely of valid UTF-8-encoded runes.
现在的 Reverse 函数按字节地反转字符串,然后这里就出现了我们的问题。
为了保持原始字符串用 UTF-8 编码后的码点,我们必须替换掉按字节反转字符串的处理。
要验证为何反转时,该输入(该例中是汉字 泃)导致 '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)
}
})
发生错误或者使用 -v 标志执行测试时,t.Logf 会将其打印到命令行。这可以帮助你调试特定的问题。
运行代码
使用 go test 运行测试。
$ 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"
FAIL
exit status 1
FAIL example/fuzz 0.598s
整个种子用例库使用每个字符都是单字节的字符串。尽管如此,字符如 泃 会需要几个字节。因此,按字节反转字符串会使多字节字符变成无效字符。
注意: 如果你对 go 处理字符串很好奇,可阅读博客 go 中的字符串、字节、rune和字符 加深理解。
对 bug 有了更好的理解之后,修改 Reverse 函数中的错误。
修复错误
要改正 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 现在遍历字符串中的每个 rune ,而不是每个 byte 。
运行程序
-
使用
go test运行测试$ go test PASS ok example/fuzz 0.016s测试现在可以通过了!
-
用
go test -fuzz再次进行模糊测试,看一下有没有新的 bug 。$ go test -fuzz=Fuzz fuzz: elapsed: 0s, gathering baseline coverage: 0/37 completed fuzz: 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/1ffc28f7538e29d79fce69fef20ce5ea72648529a9ca10bea392bcff28cd015c FAIL exit status 1 FAIL example/fuzz 0.032s我们可以看到字符串在反转两次之后和原始字符串不同。 这次的输入本身是无效的 unicode 字符。我们用字符串进行模糊测试时这怎么可能呢? 我们再调试一次。
修复两次反转的错误
在该部分中,你会调试两次反转的失败并修复该 bug 。
可随意花些时间考虑该问题,在继续之前你可以尝试自己修复该问题。
诊断错误
像前面一样,你可以用一些不同的方式调试该失败。在该情况中,使用 调试器 会是一个很好的途径。
在该指南中,我们会在 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)/2; i, j = i+1, j-1 { r[i], r[j] = r[j], r[i] } return string(r) }这会帮助我们理解将字符串转换成 rune 切片时发生了什么错误。
运行代码
这一次,我们只想运行失败的测试以检查日志。
要做这一点,我们会使用 go test -run 。
$ go test -run=FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0
input: "\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: "�"
FAIL
exit status 1
FAIL example/fuzz 0.145s
要在 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("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 }如果输入字符串包含非有效 UTF-8 字符时,该改动会返回错误。
-
由于 Reverse 函数现在返回错误,修改
main函数丢弃额外的错误值。使用下面的代码替换现有的main函数。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) }这些
Reverse的调用会返回一个 nil 错误,这是因为输入的字符串是有效的 UTF-8 字符串。 -
你会需要导入 errors 和 unicode/utf8 包。main.go 中的导入语句看上去就该如下。
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) // 使用 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.019s -
使用
go test -fuzz=Fuzz对其进行模糊测试,然后过几秒钟之后,用ctrl-C停止模糊测试。$ go test -fuzz=Fuzz fuzz: elapsed: 0s, gathering baseline coverage: 0/38 completed fuzz: elapsed: 0s, gathering baseline coverage: 38/38 completed, now fuzzing with 4 workers fuzz: 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) PASS ok example/fuzz 228.000s直到出现失败模糊测试会一直运行,除非你传递了
-fuzztime标志。默认情况下,如果没有失败发生,会一直运行下去,进程可用ctrl-C中断。 -
使用
go test -fuzz=Fuzz -fuzztime 30s进行模糊测试,没有发现失败的话,它会运行模糊测试30秒钟。$ go test -fuzz=Fuzz -fuzztime 30s fuzz: elapsed: 0s, gathering baseline coverage: 0/5 completed fuzz: elapsed: 0s, gathering baseline coverage: 5/5 completed, now fuzzing with 4 workers fuzz: 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) PASS ok example/fuzz 31.025s模糊测试通过!
除了
-fuzz之外,还有一些标志可用于go test,可在 文档 中查看。
结束语
干得漂亮!你已经在 go 中引入了模糊测试。
下一步是选择代码中想要进行模糊测试的函数,然后尝试一下!如果模糊测试在你的代码中发现了 bug ,考虑将添加到 战利品用例 中。
如果你遭遇了某个问题或者有某种特性的想法,可 提出一个 issue 。
要讨论和反馈该特性,可以在参与 Gophers Slack 的 #模糊测试频道。
查看 go.dev/doc/fuzz 文档进一步了解。
完整代码
— main.go —
package main
import (
"errors"
"fmt"
"unicode/utf8"
)
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)
}
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_test.go —
package main
import (
"testing"
"unicode/utf8"
)
func FuzzReverse(f *testing.F) {
testcases := []string{"Hello, world", " ", "!12345"}
for _, tc := range testcases {
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)
}
})
}