Fuzzing: 一文讀懂Go Fuzzing使用和原理

coding進階發表於2022-04-04

背景

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個重要方法AddFuzz
  • Add方法是用於新增種子語料(seed corpus)資料,Fuzzing底層可以根據種子語料資料自動生成隨機測試資料。
  • Fuzz方法接收一個函式型別的變數作為引數,該函式型別的第一個引數必須是*testing.T型別,其餘的引數型別和Add方法裡傳入的實參型別保持一致。比如下面的例子裡,f.Add(5, "hello")傳入的第一個實參是5,第二個實參是hello,對應的是i ints string

  • Go Fuzzing底層會根據Add裡指定的種子語料,隨機生成測試資料,執行模糊測試。比如上圖的例子裡,會根據Add裡指定的5hello,隨機生產新的測試資料,賦值給is,然後不斷呼叫作為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的TestXXXBenchmarkXXX的測試結果。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.CoordinateFuzzingfuzz.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執行的模型如下所示:

Diagram showing the relationship between fuzzing processes. At the top is a box showing "go test (cmd/go)". An arrow points downward to a box labelled "coordinator (test binary)". From that, three arrows point downward to three boxes labelled "worker (test binary)".

coordinator程式和worker程式通過一對管道進行通訊,使用基於JSON的RPC通訊協議。這個協議非常精簡,因為我們並不需要gRPC一樣複雜的RPC協議,我們也不希望給Go標準庫引入任何新的依賴。

每個worker程式在mmap檔案裡儲存自己的狀態,這個mmap檔案和coordinator程式共享。大多數情況下,mmap裡記錄的只是迭代次數和隨機數生成器的狀態。如果worker程式crash了,那coordinator程式就可以從共享記憶體裡恢復其狀態,而不需要worker程式通過管道傳送訊息。

整個Fuzzing過程分為3個階段:

Diagram showing communication between coordinator and worker. Two arrows point down: the left is labelled "coordinator", the right is labelled "worker". Three pairs of horizontal arrows point from the coordinator to the worker and back. The top pair is labelled "baseline coverage", the middle is labelled "fuzz", the bottom is labelled "minimize".

階段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.FailT.FailNow注意T.ErrorT.Errorf會自動呼叫T.Fail,T.FatalT.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標記會預設執行TestXXXFuzzXXX開頭的函式,對於FuzzXXX只會使用種子語料庫裡的輸入,而不會生成隨機資料。如果需要生成隨機輸入,要使用go test -fuzz=pattern

開源地址

文章和示例程式碼開源在GitHub: Go語言初級、中級和高階教程

公眾號:coding進階。關注公眾號可以獲取最新Go面試題和技術棧。

個人網站:Jincheng's Blog

References

相關文章