开始使用模糊测试
文章目录
一、介绍
这篇指导介绍Go中基础的模糊测试。通过模糊测试,随机数据会针对您的测试运行,以尝试找出漏洞或导致崩溃的输入。例如,通过模糊测试能够发现SQL注入的漏洞,缓冲区溢出,拒绝服务或者跨域攻击。
在这篇指导中,你将为一个简单的函数写一个模糊测试,运行Go命令行,调试并修复代码中的问题。
你将通过下面的部分:
- 为你的代码创建一个目录;
- 添加代码用于测试;
- 添加单元测试;
- 添加模糊测试;
- 修复两个bug;
- 探索更多的资源;
二、准备
- 安装Go1.18 或以上版本;
- 一个用于编写代码的工具;
- 命令行工具;
- 一个支持模糊测试的环境。目前仅在 AMD64 和 ARM64 架构上使用覆盖检测进行模糊测试。
三、实践
3.1 为你的代码创建一个目录
-
打开命令行,进入到工作目录
从为你将要写的代码创建目录开始。
$ cd $ -
通过命令行,创建一个名称为
fuzz的目录$ mkdir fuzz $ cd fuzz/ $ -
为你的代码创建一个模块
运行
go mod init命令,并给它一个新的代码模块路径$ go mod init example/fuzz go: creating new go.mod: module example/fuzz $
接下来,你将添加一些简单代码去反转字符串,它将用于模糊。
3.2 添加代码用于测试
在这一步,将添加函数用于反转字符串。
写代码
-
使用文本编辑器,在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"
$
你能看到原始数据,反转的结果数据,然后对结果再次旋转。它和原始数据相等。
现在代码正在运行,是时间去测试它了。
3.3 添加单元测试
在这一步,你将为Reverse函数写基础的单元测试。
写代码
-
使用你的文本编辑器,在fuzz目录中,创建一个文件叫做
reverse_test.go -
粘贴下面的代码到
reverse_test.gopackage 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
$
接下来,你将改变单元测试变成模糊测试。
3.4 添加模糊测试
单元测试拥有局限性,即每次输入必须被开发者添加到测试。一个模糊测试的好处是它能为你的代码创建输入,并且能确认边缘化的用例,那些你没有想象出来的用例。
在这一部分你将转换单元测试变成模糊测试,以便于你能通过少量的工作产生更多输入。
注意,你将保留单元测试、基准测试和模糊测试在相同的*_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测试中进行验证。其中两个能在模糊测试中验证的是:
- 反转字符串两次,保留原始值;
- 反转的字符串将其状态保留在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 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 $
因为我们的测试失败了,是时候进行调试了。
3.5 修复无效的字符串错误
在这一部分,你将调试错误,并修复错误。
在继续之前,请随意花一些时间思考这个问题并尝试自己解决问题。
诊断错误
这儿有一些不同的方法用来调试错误。如果你使用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 $我们能够看到,经过两次反转的结果是不同于原始结果。这次输入是一个无效的字符串。如果我们使用字符串进行模糊测试,这怎么可能?
让我们再次调试。
3.6 修复两次反转的错误
在这一部分,我们将调试两次反转的错误,并修复这个错误。
在继续之前,自由的好一些时间去思考这个问题,并修复这个问题。
诊断错误
就像之前,有若干个方法调试错误,在这种场景下,使用断点是一个好的方法。
在这个教程中,我们将在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 进一步阅读。
本文详细介绍了如何在Go中进行模糊测试,包括创建测试目录、编写代码、添加单元测试和模糊测试,以及如何诊断和修复由于字符串处理错误导致的问题。通过模糊测试,可以发现代码中的漏洞和崩溃输入,确保程序的健壮性。
854

被折叠的 条评论
为什么被折叠?



