教程:开始使用模糊测试
本教程介绍了 Go 中模糊测试的基础知识。通过模糊测试,随机数据会针对您的测试运行,以尝试找出漏洞或导致崩溃的输入。可以通过模糊测试发现的一些漏洞示例包括 SQL 注入、缓冲区溢出、拒绝服务和跨站点脚本攻击.
在本教程中,您将为一个简单的函数编写一个模糊测试,运行 go 命令,并调试和修复代码中的问题.
有关本教程中术语的帮助,请参阅 Go Fuzzing 词汇表.
您将逐步完成以下部分:
注意: 有关其他教程,请参阅 教程.
注意: Go 模糊测试目前支持 Go 模糊测试文档, 中列出的内置类型的子集,并支持将来要添加更多内置类型.
先决条件
- Go 1.18 或更高版本的安装. 有关安装说明,请参阅 安装 Go.
- 用于编辑代码的工具. 您拥有的任何文本编辑器都可以正常工作.
- 一个命令终端. Go 在 Linux 和 Mac 上的任何终端以及 Windows 中的 PowerShell 或 cmd 上都能很好地工作.
- 支持模糊测试的环境. 目前仅在 AMD64 和 ARM64 架构上使用覆盖检测进行模糊测试.
为您的代码创建一个文件夹
首先,为您要编写的代码创建一个文件夹.
-
打开命令提示符并切换到您的主目录.
在 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
注意: 对于生产代码,您需要指定一个更符合您自己需求的模块路径。有关更多信息,请务必查看 管理依赖项.
接下来,您将添加一些简单的代码来反转字符串,稍后我们将对其进行模糊测试.
添加代码进行测试
在此步骤中,您将添加一个函数来反转字符串.
编写代码
-
使用您的文本编辑器,在 fuzz 目录中创建一个名为 main.go 的文件.
-
进入 main.go,在文件顶部,粘贴以下包声明.
package main
独立程序(与库相反)始终位于
main
package 中。 -
在包声明下,粘贴以下函数声明.
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) // 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)
}
})
}
模糊测试也有一些限制。在单元测试中,可以预测 Reverse
函数的预期输出,并验证实际输出是否满足这些预期.
例如,在测试用例 Reverse("Hello, world")
中,单元测试将返回值指定为"dlrow ,olleH"
.
模糊测试时,您无法预测预期输出,因为您无法控制输入.
但是,您可以在模糊测试中验证 Reverse
函数的一些属性。在此模糊测试中检查的两个属性是:
- 将字符串反转两次保留原始值
- 反转的字符串将其状态保留为有效的 UTF-8.
注意单元测试和模糊测试之间的语法差异:
- 该函数以 FuzzXxx 而不是 TestXxx 开头,取
*testing.F
而不是*testing.T
- 在你期望看到
t.Run
执行的地方,你看到的是f.Fuzz
,它采用一个 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
,以查看是否有任何随机生成的字符串输入会导致失败。这是使用带有新标志-fuzz
的go test
执行的.$ 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("泃")
语料库文件的第一行指示编码版本。以下每一行表示组成语料库条目的每个类型的值。由于模糊目标仅接受 1 个输入,因此版本后只有 1 个值.
-
再次运行
go test
,不带-fuzz
标志;将使用新的失败种子语料库条目:$ 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
由于我们的测试失败,是时候调试了.
修复无效字符串错误
在本节中,您将调试故障并修复错误.
在继续之前,请随意花一些时间思考这个问题并尝试自己解决问题.
诊断错误
有几种不同的方法可以调试此错误。如果您使用 VS Code 作为文本编辑器,则可以 设置调试器 进行观察.
在本教程中,我们会将有用的调试信息记录到您的终端.
首先,考虑
utf8.ValidString
的文档.
ValidString 报告 s 是否完全由有效的 UTF-8 编码符文组成.
当前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 中的字符串、字节、符文和字符 以更深入地了解.
随着对 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
再次模糊它,看看是否有任何新的错误.$ 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。如果我们使用字符串进行模糊测试,这怎么可能?
让我们再次调试.
修复两次反转错误
在本节中,您将调试两次反转故障并修复错误.
在继续之前,请随意花一些时间思考这个问题并尝试自己解决问题.
诊断错误
和以前一样,有几种方法可以调试此故障。在这种情况下,使用 调试器 将是一个很好的方法。
在本教程中,我们将在 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) }
这将帮助我们了解将字符串转换为符文切片时出了什么问题.
运行代码
这一次,我们只想运行失败的测试来检查日志。为此,我们将使用 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 语句应如下所示。
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) } }) }
除了返回之外,您还可以调用
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 中的模糊测试.
下一步是在代码中选择一个你想模糊的函数,然后尝试一下!如果模糊测试在代码中发现错误,请考虑将其添加到trophy case中。
如果您遇到任何问题或对某个功能有想法,请提交问题。
有关该功能的讨论和一般反馈,您还可以参与Gophers Slack中的#fuzzing 频道。
请查看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) // 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)
}
})
}