背景
Go 1.18除了引入泛型(generics)這個重大設計之外,Go官方團隊在Go 1.18工具鏈裡還引入了fuzzing模糊測試。
Go fuzzing的主要開發者是Katie Hockman, Jay Conrod和Roland Shoemaker。
編者注:Katie Hockman已於2022.02.19從Google離職,Jay Conrod也於2021年10月離開Google。
什麼是Fuzzing
Fuzzing中文含義是模糊測試,是一種自動化測試技術,可以隨機生成測試資料集,然後呼叫要測試的功能程式碼來檢查功能是否符合預期。
模糊測試(fuzz test)是對單元測試(unit test)的補充,並不是要替代單元測試。
單元測試是檢查指定的輸入得到的結果是否和預期的輸出結果一致,測試資料集比較有限。
模糊測試可以生成隨機測試資料,找出單元測試覆蓋不到的場景,進而發現程式的潛在bug和安全漏洞。
Go Fuzzing怎麼使用
Fuzzing在Go語言裡並不是一個全新的概念,在Go官方團隊釋出Go Fuzzing之前,GitHub上已經有了類似的模糊測試工具go-fuzz。
Go官方團隊的Fuzzing實現借鑑了go-fuzz的設計思想。
Go 1.18把Fuzzing整合到了go test
工具鏈和testing
包裡。
示例
下面舉個例子說明下Fuzzing如何使用。
對於如下的字串反轉函式Reverse
,大家可以思考下這段程式碼有什麼潛在問題?
// main.go
package fuzz
func Reverse(s string) string {
bs := []byte(s)
length := len(bs)
for i := 0; i < length/2; i++ {
bs[i], bs[length-i-1] = bs[length-i-1], bs[i]
}
return string(bs)
}
編寫Fuzzing模糊測試
如果沒有發現上面程式碼的bug,我們不妨寫一個Fuzzing模糊測試函式,來發現上面程式碼的潛在問題。
Go Fuzzing模糊測試函式的語法如下所示:
- 模糊測試函式定義在
xxx_test.go
檔案裡,這點和Go已有的單元測試(unit test)和效能測試(benchmark test)一樣。 - 函式名以
Fuzz
開頭,引數是* testing.F
型別,testing.F
型別有2個重要方法Add
和Fuzz
。 Add
方法是用於新增種子語料(seed corpus)資料,Fuzzing底層可以根據種子語料資料自動生成隨機測試資料。Fuzz
方法接收一個函式型別的變數作為引數,該函式型別的第一個引數必須是*testing.T
型別,其餘的引數型別和Add
方法裡傳入的實參型別保持一致。比如下面的例子裡,f.Add(5, "hello")
傳入的第一個實參是5
,第二個實參是hello
,對應的是i int
和s string
。
- Go Fuzzing底層會根據
Add
裡指定的種子語料,隨機生成測試資料,執行模糊測試。比如上圖的例子裡,會根據Add
裡指定的5
和hello
,隨機生產新的測試資料,賦值給i
和s
,然後不斷呼叫作為f.Fuzz
方法的實參,也就是func(t *testing.T, i int, s string){...}
這個函式。
知道了上述規則後,我們來給Reverse
函式編寫一個如下的模糊測試函式。
// fuzz_test.go
package fuzz
import (
"testing"
"unicode/utf8"
)
func FuzzReverse(f *testing.F) {
str_slice := []string{"abc", "bb"}
for _, v := range str_slice {
f.Add(v)
}
f.Fuzz(func(t *testing.T, str string) {
rev_str1 := Reverse(str)
rev_str2 := Reverse(rev_str1)
if str != rev_str2 {
t.Errorf("fuzz test failed. str:%s, rev_str1:%s, rev_str2:%s", str, rev_str1, rev_str2)
}
if utf8.ValidString(str) && !utf8.ValidString(rev_str1) {
t.Errorf("reverse result is not utf8. str:%s, len: %d, rev_str1:%s", str, len(str), rev_str1)
}
})
}
執行Fuzzing測試
使用的Go版本要求是go 1.18beta 1
或以上版本,執行如下命令可以進行Fuzzing測試,結果如下:
$ go1.18beta1 test -v -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/111 completed
fuzz: minimizing 60-byte failing input file
fuzz: elapsed: 0s, gathering baseline coverage: 5/111 completed
--- FAIL: FuzzReverse (0.04s)
--- FAIL: FuzzReverse (0.00s)
fuzz_test.go:20: reverse result is not utf8. str:æ, len: 2, rev_str1:��
Failing input written to testdata/fuzz/FuzzReverse/ce9e8c80e2c2de2c96ab9e63b1a8cf18cea932b7d8c6c9c207d5978e0f19027a
To re-run:
go test -run=FuzzReverse/ce9e8c80e2c2de2c96ab9e63b1a8cf18cea932b7d8c6c9c207d5978e0f19027a
FAIL
exit status 1
FAIL example/fuzz 0.179s
重點看fuzz_test.go:20: reverse result is not utf8. str:æ, len: 2, rev_str1:��
這個例子裡,隨機生成了一個字串æ
,這是由2個位元組組成的一個UTF-8字串,按照Reverse
函式進行反轉後,得到了一個非UTF-8的字串��
。
所以我們之前實現的按照位元組進行字串反轉的函式Reverse
是有bug的,該函式對於ASCII碼裡的字元組成的字串是可以正確反轉的,但是對於非ASCII碼裡的字元,如果簡單按照位元組進行反轉,得到的可能是一個非法的字串。
感興趣的朋友,可以看看如果對字串"吃",呼叫Reverse
函式,會得到怎樣的結果。
注意:如果Go Fuzzing執行過程中發現了你的bug,會把對應的輸入資料寫到testdata/fuzz/FuzzXXX
目錄下。比如上面的例子裡,go1.18beta1 test -v -fuzz=Fuzz
的輸出結果裡列印瞭如下內容:Failing input written to testdata/fuzz/FuzzReverse/ce9e8c80e2c2de2c96ab9e63b1a8cf18cea932b7d8c6c9c207d5978e0f19027a
,這就表示把這個測試輸入寫到了testdata/fuzz/FuzzReverse/xxx
這個語料檔案裡。
Go Fuzzing的底層機制
go test
執行的時候,會為每個被測試的package先編譯生成一個可執行檔案,然後執行這個可執行檔案得到對應package的TestXXX
和BenchmarkXXX
的測試結果。Go Fuzzing執行的模式和這個類似,但是也有一點區別。
當go test
執行的時候如果有-fuzz
標記,go test
會結合覆蓋率工具來編譯生成用於模糊測試的可執行檔案。大部分的Fuzzing邏輯都實現在internal/fuzz。
當go test
編譯生成了可執行檔案後,該可執行檔案就會執行起來,這個執行起來的程式叫做協調程式(coordinator process)。協調程式的啟動引數裡有go test
命令的大部分標記,包括-fuzz=pattern
這個標記,-fuzz=pattern
用來識別對哪個模糊測試函式(fuzz test)進行Fuzzing測試。
目前,對於每一個go test -fuzz=pattern
呼叫,只支援匹配一個模糊測試函式。如果go test -fuzz=pattern
可以匹配多個FuzzXXX
函式,就會報如下錯誤:
$ go1.18beta1 test -v -fuzz=Fuzz
testing: will not fuzz, -fuzz matches more than one fuzz test: [FuzzReverse FuzzReverse2]
FAIL
exit status 1
FAIL example/fuzz 0.752s
協調程式啟動後,主要的程式邏輯都在fuzz.CoordinateFuzzing
。fuzz.CoordinateFuzzing
會初始化fuzzing系統,開啟coordinator事件迴圈。
coordinator程式會啟動多個worker程式,每個worker程式和coordinator程式執行相同的可執行程式,真正的fuzzing模糊測試由worker程式來完成。worker程式啟動時帶有一個標記引數-test.fuzzworker
,表明這是一個worker程式。啟動的worker程式數量等於GOMAXPROCS。
這裡我給了一個示例,大家可以在執行go test -fuzz=pattern
的過程中,執行ps aux | grep fuzz
來檢視當前fuzzing相關的程式。
$ ps aux | grep fuzz
xxx 13913 84.3 1.0 5219184 85124 s001 R+ 10:12下午 0:03.90 /var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build1953131131/b001/fuzz.test -test.fuzzworker -test.paniconexit0 -test.fuzzcachedir=/Users/xxx/Library/Caches/go-build/fuzz/example/fuzz -test.timeout=10m0s -test.fuzz=Fuzz
xxx 13910 81.9 1.0 5221180 86200 s001 R+ 10:12下午 0:03.94 /var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build1953131131/b001/fuzz.test -test.fuzzworker -test.paniconexit0 -test.fuzzcachedir=/Users/xxx/Library/Caches/go-build/fuzz/example/fuzz -test.timeout=10m0s -test.fuzz=Fuzz
xxx 13912 78.3 1.0 5219964 84984 s001 R+ 10:12下午 0:03.86 /var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build1953131131/b001/fuzz.test -test.fuzzworker -test.paniconexit0 -test.fuzzcachedir=/Users/xxx/Library/Caches/go-build/fuzz/example/fuzz -test.timeout=10m0s -test.fuzz=Fuzz
xxx 13911 74.5 1.0 5219184 85132 s001 R+ 10:12下午 0:03.76 /var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build1953131131/b001/fuzz.test -test.fuzzworker -test.paniconexit0 -test.fuzzcachedir=/Users/xxx/Library/Caches/go-build/fuzz/example/fuzz -test.timeout=10m0s -test.fuzz=Fuzz
xxx 13907 43.3 2.3 5944576 191172 s001 R+ 10:12下午 0:01.90 /var/folders/pv/_x849j6n22x37xxd9cstgwkr0000gn/T/go-build1953131131/b001/fuzz.test -test.paniconexit0 -test.fuzzcachedir=/Users/xxx/Library/Caches/go-build/fuzz/example/fuzz -test.timeout=10m0s -test.fuzz=Fuzz
xxx 13923 0.0 0.0 4268176 420 s000 R+ 10:12下午 0:00.00 grep fuzz
xxx 13891 0.0 0.2 5014396 16868 s001 S+ 10:12下午 0:00.52 /Users/xxx/sdk/go1.18beta2/bin/go test -fuzz=Fuzz
xxx 13890 0.0 0.0 4989312 4008 s001 S+ 10:12下午 0:00.01 go1.18beta2 test -fuzz=Fuzz
worker程式在執行模糊測試(fuzzing)的時候如果crash了,coordinator程式可以記錄導致worker程式crash的測試資料。如果直接交給coordinator程式執行fuzzing,在遇到了會導致程式crash的輸入時,coordinator程式本身就會crash,就沒有辦法記錄導致程式crash的輸入了(Failing input)。Go Fuzzing執行的模型如下所示:
coordinator程式和worker程式通過一對管道進行通訊,使用基於JSON的RPC通訊協議。這個協議非常精簡,因為我們並不需要gRPC一樣複雜的RPC協議,我們也不希望給Go標準庫引入任何新的依賴。
每個worker程式在mmap檔案裡儲存自己的狀態,這個mmap檔案和coordinator程式共享。大多數情況下,mmap裡記錄的只是迭代次數和隨機數生成器的狀態。如果worker程式crash了,那coordinator程式就可以從共享記憶體裡恢復其狀態,而不需要worker程式通過管道傳送訊息。
整個Fuzzing過程分為3個階段:
階段1:Baseline coverage
coordinator程式啟動時,會拉起worker程式。coordinator程式會給worker程式傳送種子語料(包括f.Add
裡新增的測試資料以及testdata/fuzz
目錄下的測試輸入)和fuzzing快取語料(cache corpus,位於$GOCACHE
的子目錄下)。
每個worker程式執行指定的輸入,然後給coordinator程式報告其覆蓋率計數器的快照,coordinator會將收集到的worker的覆蓋率資料合併為一個覆蓋率陣列。
這個階段叫基線覆蓋率收集階段,worker只會執行coordinator傳送給它們的指定輸入,不會生成隨機測試資料。
階段2:Fuzzing模糊測試
這個階段,coordinator程式會再次傳送種子語料(seed corpus)和快取語料(cache corpus)給worker程式,用於真正的fuzzing。
每個worker程式會收到一個coordinator傳送的輸入資料和基線覆蓋率陣列的拷貝。然後worker程式會隨機對這個指定的輸入做變異來得到新的測試資料。變異的方式有多種,可能是對bit位做反轉,0改為1,1改為0,也可能是刪除或者新增位元組,等等。然後再把變異後的資料作為引數給到fuzz target函式去執行。
為了減少coordinator程式和worker程式的通訊開銷,每個worker程式可以在100ms內一直變異拿到新的測試資料,然後呼叫fuzz target函式,而不需要coordinator程式的進一步輸入。
每次對生成的隨機資料呼叫fuzz target函式後,worker程式會檢查2種場景:
- 和基線覆蓋率陣列相比,是否找到了新的覆蓋率資料。
- 是否有error產生,也就是程式碼裡執行了
T.Fail
或T.FailNow
。注意:T.Error
、T.Errorf
會自動呼叫T.Fail
,T.Fatal
和T.Fatalf
會自動呼叫T.FailNow
。
如果二者滿足其一,worker程式就會把輸入資料立即傳送給coordinator程式。
階段3:Minimization最小化
如果coordinator程式收到了worker程式傳送過來的輸入資料是場景1,也就是收到了會產生新覆蓋率的輸入,coordinator會把這個worker的覆蓋率資料和當前組合的覆蓋率陣列做比較。
因為有可能其它worker已經發現了會提供相同覆蓋率的輸入,如果是這樣的話,那coordinator會直接ignore這個輸入。如果這個新的輸入的確提供了新的覆蓋率,那coordinator會把這個輸入傳送給一個worker(很可能是不同的worker)用於最小化(minimization)。
最小化有點像fuzzing,但是worker會通過隨機變異來建立一個仍然會產生新覆蓋率的更小輸入。更小的輸入通常會讓fuzzing執行更快,因此值得在前面花時間讓fuzzing處理過程更快。worker程式完成最小化後會報告給coordinator,即使它未能找到更小的輸入。coordinator程式會把這個最小化的輸入新增到快取語料庫(cache corpus)並繼續執行Fuzzing。後續,coordinator可能會把這個最小化的輸入傳送給所有worker用於進一步fuzzing。這就是fuzzing系統如何自動調節找到新的覆蓋率。
如果coordinator程式收到了worker程式傳送過來的輸入資料是場景2:也就是引發error的輸入
,coordinator程式會把這個輸入再次傳送給worker進行最小化。在這種場景下,worker會試圖找到一個會引發error的更小輸入,儘管不一定是同一個error。在輸入資料被最小化後,coordinator程式會把最小化後的資料儲存到testdata/fuzz/$FuzzTarget
,優雅關閉所有worker程式,然後以非0狀態(non-zero status)退出。
如果worker程式在fuzzing過程中crash了,那coordinator程式可以使用傳送給worker的輸入、worker的RNG狀態和迭代次數(留在共享記憶體中)來恢復導致worker程式crash的輸入。crash的輸入通常沒有被最小化,因為最小化是一個高度狀態化的過程,而每次crash都會破壞這個狀態。對導致crash的輸入做最小化在理論上是可行的,但是目前還沒能實現。
Fuzzing通常遇到以下場景才會結束執行,否則會一直執行:
- Fuzzing找到了error,也就是觸發了你模糊測試函式裡的error條件
- 使用者按Ctrl-C來中斷程式
- 執行時間達到了
-fuzztime
設定的時間
fuzzing引擎會優雅處理中斷,不管中斷是被髮送給了coordinator程式還是worker程式。舉個例子,如果worker程式在最小化輸入的時候遇到了中斷,coordinator程式會儲存沒有被最小化的輸入。
注意事項
FuzzXXX
的實現也是放在以_test.go
結尾的go檔案裡。- seed corpus(種子語料):既包含通過
f.Add
指定的輸入,也包括testdata/fuzz/$FuzzTarget
目錄下的檔案裡面的輸入。 go test
不帶-fuzz
標記會預設執行TestXXX
和FuzzXXX
開頭的函式,對於FuzzXXX
只會使用種子語料庫裡的輸入,而不會生成隨機資料。如果需要生成隨機輸入,要使用go test -fuzz=pattern
。
開源地址
文章和示例程式碼開源在GitHub: Go語言初級、中級和高階教程。
公眾號:coding進階。關注公眾號可以獲取最新Go面試題和技術棧。
個人網站:Jincheng's Blog。
References
- Internals of Go's New Fuzzing System: https://jayconrod.com/posts/1...
- Fuzzing介紹:https://go.dev/doc/fuzz/
- Fuzzing Design Draft: https://go.googlesource.com/p...
- Fuzzing提案:https://github.com/golang/go/...
- Fuzzing教程:https://go.dev/doc/tutorial/fuzz
- tesing.F說明文件:https://pkg.go.dev/testing@go...
- Fuzzing Tesing in Go in 8 Minutes: https://www.youtube.com/watch...
- GitHub開源工具go-fuzz: https://github.com/dvyukov/go...
- Go fuzzing找bug示例:https://julien.ponge.org/blog...