Golang在1.18引入的第三个特性就是 Fuzzing 模糊测试:构造随机数据来找出代码里的漏洞或者可能导致程序崩溃的输入。
单元测试有局限性,每个测试输入必须由开发者指定加到单元测试的测试用例里。fuzzing的优点之一是可以基于开发者代码里指定的测试输入作为基础数据,进一步自动生成新的随机测试数据,用来发现指定测试输入没有覆盖到的边界情况。
通过fuzzing可以找出的漏洞包括SQL注入、缓冲区溢出、拒绝服务(Denial of Service)攻击和XSS(cross-site scripting)攻击等
这里通过编写反转字符串函数通过 fuzz test
来发现并修改问题
第一步:实现基本功能
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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) }
输出的结果是
1 2 3 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"
第二步:编写单元测试
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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) } } }
执行单元测试发现,一切正常
第三步:添加模糊测试
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 func FuzzReverse (f *testing.F) { testcases := []string {"Hello, world" , " " , "!12345" } for _, tc := range testcases { f.Add(tc) } 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) } }) }
注意点:
Reverse
函数如果是一个错误的版本(直接return返回输入的字符串),虽然可以通过上面的模糊测试,但没法通过第二步的单元测试,所以模糊测试与单元测试是互补的关系
go test
只会使用种子语料库,而不会生成随机测试数据 。通过这种方式可以用来验证种子语料库的测试数据是否可以测试通过
如果reverse_test.go
文件里有其它单元测试函数或者模糊测试函数,但只想运行FuzzReverse
模糊测试函数,我们可以执行go test -run=FuzzReverse
命令
如果要基于种子语料库生成随机测试数据用于模糊测试,需要给go test
命令增加 -fuzz
参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 root@13ce5bc74ac3:/code/fuzz# go test -fuzz . fuzz: elapsed: 0s, gathering baseline coverage: 0/7 completed fuzz: elapsed: 0s, gathering baseline coverage: 7/7 completed, now fuzzing with 2 workers fuzz: elapsed: 0s, execs: 648 (17784/sec), new interesting: 1 (total: 8) --- FAIL: FuzzReverse (0.04s) --- FAIL: FuzzReverse (0.00s) hello_test.go:32: Number of runes: orig=1, rev=2, doubleRev=1 hello_test.go:36: Reverse produced invalid UTF-8 string "\x9e\xdb" Failing input written to testdata/fuzz/FuzzReverse/d6a654c77ca8db001e0bbe2cbb5493efcd20e17777911523bf59bd30bd33e199 To re-run: go test -run=FuzzReverse/d6a654c77ca8db001e0bbe2cbb5493efcd20e17777911523bf59bd30bd33e199 FAIL exit status 1 FAIL example/fuzz 0.048s
运行之后会生成testdata的文件夹,那么下次即使没有带上 -fuzz
参数,也会使用该数据进行模糊测试
路径:./testdata/fuzz/FuzzReverse/d6a654c77ca8db001e0bbe2cbb5493efcd20e17777911523bf59bd30bd33e199
,内容是:
1 2 go test fuzz v1 string ("۞" )
从第2行开始,每一行数据对应的是语料库的每条测试数据(corpus entry)的其中一个参数,按照参数先后顺序排列,因为fuzz target函数func(t *testing.T, orig string)
只有orig
这1个参数作为真正的测试输入,也就是每条测试数据其实就1个输入,因此在上面示例的testdata/fuzz/FuzzReverse
目录下的文件里只有string(“۞”)这一行
第四步:修复Bug
模糊测试中得出的错误为 Reverse produced invalid UTF-8 string "\x9e\xdb"
Reverse
函数是按照字节(byte)为维度进行字符串反转,这就是问题所在。比如字符string("۞")
如果按照字节反转,反转后得到的就是一个无效的字符串了。因此为了保证字符串反转后得到的仍然是一个有效的UTF-8编码的字符串,需要按照rune
进行字符串反转。
1 2 3 4 5 6 7 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 test -fuzz
会出现新的错误
1 2 3 4 5 6 7 8 9 10 11 12 13 14 root@13 ce5bc74ac3:/code/fuzz# go test -fuzz . fuzz: elapsed: 0 s, gathering baseline coverage: 0 /9 completed fuzz: minimizing 38 -byte failing input file fuzz: elapsed: 0 s, gathering baseline coverage: 4 /9 completed --- FAIL: FuzzReverse (0.01 s) --- FAIL: FuzzReverse (0.00 s) hello_test.go :34 : Before: "\xe5" , after: "�" Failing input written to testdata/fuzz/FuzzReverse/91862839 dc552bd95b4e42be6576a6c198f0d4c8fc2884c953030d898573b014 To re-run: go test -run=FuzzReverse/91862839 dc552bd95b4e42be6576a6c198f0d4c8fc2884c953030d898573b014 FAIL exit status 1 FAIL example/fuzz 0.011 s
结构就是对一个字符串做了2次反转后得到的和原字符串不一样,这次测试输入本身是非法的unicode
1 2 3 4 5 6 7 8 9 10 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 }
修改对应的引用和单元测试后,通过测试
语法
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
,其它参数是被模糊的类型(注意 :被模糊的类型目前只支持部分内置类型,
string
, []byte
int
, int8
, int16
, int32
/rune
, int64
uint
, uint8
/byte
, uint16
, uint32
, uint64
float32
, float64
bool
参考链接
https://go.dev/doc/tutorial/fuzz
https://segmentfault.com/a/1190000041650681
https://segmentfault.com/a/1190000041467510
https://go.googlesource.com/proposal/+/master/design/draft-fuzzing.md