Go 的模糊測試
模糊測試是一種透過隨機輸入來尋找會接收使用者輸入的程式碼中棘手的邊界案例或安全性問題的測試技術。Go 套件開發者可以使用 Dmitry Vyukov 廣受歡迎的 go-fuzz 工具來對程式碼進行模糊測試;它已在 Go 標準函式庫以及第三方套件中找出了數百個隱蔽的錯誤。然而,這個工具並非內建,使用上也不如預期簡便;為了解決這個問題,Go 團隊成員 Katie Hockman 最近發表了一份草案設計,提議將模糊測試作為標準 go test 指令的一級功能。
使用隨機測試輸入來尋找錯誤的歷史可以追溯到打孔卡的時代。作家暨資深程式設計師 Gerald Weinberg 回憶道:
我們在 1950 年代還不叫它模糊測試,但用從垃圾桶撿來的打孔卡堆來測試程式是我們的標準做法。我們也會使用隨機數字的打孔卡堆。那個年代還沒有網路,所以我們不太擔心安全性問題,但那些隨機/廢棄的卡堆經常會暴露出非預期的行為。
近年來,模糊測試已被用來找出從 Bash 與 libjpeg 到 Linux 核心等軟體中無數的錯誤,以及一些重大的安全性問題,所使用的工具包括 american fuzzy lop (AFL) 以及 Vyukov 以 Go 打造的 syzkaller 工具。
模糊測試的基本概念是為某個函式產生隨機輸入,觀察它是否會當掉或拋出不屬於該函式 API 的例外。然而,用單純的方法產生隨機輸入極為耗時,也無法有效率地找到邊界案例。正因如此,大多數現代模糊測試工具都採用「覆蓋率導向模糊測試」來驅動測試,並判斷新產生的輸入是否執行到了新的程式碼路徑。Vyukov 共同撰寫了一份提案,其中簡潔地描述了這項技術的運作方式:
start with some (potentially empty) corpus of inputs
for {
choose a random input from the corpus
mutate the input
execute the mutated input and collect code coverage
if the input gives new coverage, add it to the corpus
}收集程式碼覆蓋率資料並判斷某個輸入是否「帶來新的覆蓋率」並非易事;這需要工具透過特殊的呼叫來插樁程式碼,以記錄覆蓋率。當經過插樁的程式碼執行時,模糊測試框架會比較先前測試輸入的覆蓋率與新輸入的覆蓋率,如果執行到了不同的程式碼區塊,就會將該新輸入加入語料庫。顯然這省略了許多細節,例如輸入如何變異、覆蓋率插樁究竟如何運作等等。但這項基本技術確實有效:AFL 已經被應用在許多 C 與 C++ 程式上,其網站上也有專區列出所發現並修復的大量錯誤。
go-fuzz 工具
AFL 是個優秀的工具,但它只適用於以 C、C++ 或 Objective C 撰寫、且需以 GCC 或 Clang 編譯的程式。Vyukov 的 go-fuzz 工具運作方式與 AFL 類似,但專為 Go 設計。為了替 Go 程式加入覆蓋率記錄,開發者首先執行 go-fuzz-build 指令(而非 go build),它會利用內建的 ast 套件為原始碼中的每個區塊加上插樁,再將結果送交一般的 Go 編譯器編譯。完成插樁後的二進位檔建置後,go-fuzz 指令會在多個 CPU 核心上反覆執行,並以隨機變異的輸入進行測試,同時記錄任何當機(及其堆疊追蹤與觸發該當機的輸入)。
Damian Gryski 撰寫了一篇教學,更詳細地說明如何使用 go-fuzz 工具。如前所述,go-fuzz 的 README 列出了它所發現的眾多錯誤,然而,幾乎可以肯定還有更多存在於未被列出的第三方套件中的錯誤;我個人就曾在 GoAWK 上使用過 go-fuzz,它也找到了好幾個「會造成當機的案例」。
邁向一級功能的歷程
Go 有一個內建指令 go test,會自動尋找並執行專案中的測試(以及選擇性的效能測試)。模糊測試也是一種測試,但在缺乏內建工具支援的情況下,設定起來相當繁瑣。早在 2017 年 2 月,就有人在 Go 的 GitHub 儲存庫上代表 Vyukov 與 Konstantin Serebryany 提交了一則議題,提議讓 go 工具「像現在支援測試、效能測試與競態檢測一樣,原生支援模糊測試
」。該議題指出「go-fuzz 已經存在,但它不像撰寫測試與效能測試然後執行 go test -race 那麼簡單
」。這個議題獲得了大量的支持與許多留言。
在某個時間點,Vyukov 等人補上了一份動機說明文件以及關於整合樣貌的 API 與工具提案。Go 技術負責人 Russ Cox 則要求提出一個「你希望新的 go test 模糊測試模式究竟是什麼樣子
」的原型。2019 年 1 月,使用者「thepudds」分享了這樣的原型——一個名為 fzgo 的工具,它在獨立的工具中實作了原始提案的大部分內容。當時這個工具頗受好評,但似乎並未成為正式功能。
不過,最近 Go 團隊重新拾起了這個想法,由 Hockman 撰寫了這份關於一級模糊測試的最新草案設計。目標大致相同,都是讓開發者能用標準的 go test 工具輕鬆執行模糊測試,但為了能以程式方式為初始語料庫提供種子資料,並支援除了位元組字串(在 Go 中即「byte 切片」或 []byte)以外的輸入型別,所提議的 API 稍微複雜一些。
目前,開發者可以在 *_test.go 原始檔中撰寫簽章為 TestFoo(t *testing.T) 的測試函式,go test 就會自動將這些函式作為單元測試執行。現有的 testing.T 型別會被傳入測試函式,用來控制測試並記錄失敗。新的草案設計則新增了以類似方式撰寫 FuzzFoo(f *testing.F) 模糊測試並透過 go test -fuzz 這類簡單指令來執行的能力。所提議的 testing.F 型別用於將輸入加入種子語料庫,並透過巢狀的匿名函式實作模糊測試本身。以下是可能出現在計算機函式庫 calc_test.go 中的範例:
func FuzzEval(f *testing.F) {
// Seed the initial corpus
f.Add("1+2")
f.Add("1+2*3")
f.Add("(1+2)*3")
// Run the fuzz test
f.Fuzz(func(t *testing.T, expr string) {
t.Parallel() // allow parallel execution
_, _ = Eval(expr) // function under test (discard result and error)
})
}僅僅這幾行程式碼就構成了一個基本的模糊測試,它會以隨機產生的輸入來執行計算機函式庫的 Eval() 函式,並記錄任何當機(在 Go 的術語中稱為「panic」)。panic 的例子包括陣列越界存取、對 nil 指標取值,或除以零。更複雜的模糊測試可能會將結果與另一個函式庫(在此範例中稱為 calclib)進行比較:
...
// Run the fuzz test
f.Fuzz(func(t *testing.T, expr string) {
t.Parallel()
r1, err := Eval(expr)
if err != nil {
t.Skip() // got parse error, skip rest of test
}
// Compare result against calclib
r2, err := calclib.Eval(expr)
if err != nil {
t.Errorf("Eval succeeded but calclib had error: %v", err)
}
if r1 != r2 {
t.Errorf("Eval got %d, calclib got %d", r1, r2)
}
})
}除了描述模糊測試函式與新的 testing.F 型別外,Hockman 的草案設計也提議打造一個新的覆蓋率導向模糊測試引擎,它「將負責利用編譯器插樁來了解覆蓋率資訊、以變異器產生測試參數,並維護語料庫
」。Hockman 明確指出這將會是一個全新的實作,但會大量借鑑現有成果(go-fuzz 與 fzgo)。變異器會從現有輸入中產生新的隨機輸入(即「產生的語料庫」),並可自動處理內建型別或由內建型別組成的結構體。其他型別若實作了現有的 BinaryUnmarshaler 或 TextUnmarshaler 介面,也會獲得支援。
預設情況下,引擎會無限期地執行模糊測試,直到某次測試執行中發現第一個當機為止。使用者可以透過 -fuzztime 命令列旗標指定執行一段時間(供持續整合指令碼使用),並透過 -keepfuzzing 旗標讓它在發生當機後繼續執行。當機報告將會寫入 testdata 目錄下的檔案中,內容包含造成當機的輸入以及錯誤訊息或堆疊追蹤。
討論與後續發展
如同近期關於檔案系統與檔案嵌入的草案設計,此設計的官方討論是透過一個 Reddit 討論串進行的;整體而言,回饋是正面的。
討論中有一部分圍繞著 testing.F 介面。David Crawshaw 建議它應該為了與 testing.T 和 testing.B(用於效能測試)保持一致,而實作現有的 testing.TB 介面;Hockman 同意了,並更新了設計以反映這一點。根據「etherealflaim」提出的建議,Hockman 也更新了設計,避免在最上層與模糊測試函式中重複使用 testing.F。此外,也有人針對指令應該寫成 go test -fuzz 還是 go fuzz 進行了一番爭論;etherealflaim 認為重複使用 go test 是個壞主意,因為「它有歷史包袱,而且很多人已經為它設定了逾時等組態
」。
Jeremy Bowers 建議變異引擎應該是可插拔的:
我認為模糊測試引擎需要是可插拔的。當然可以先附帶一個預設版本,甚至可以把可插拔性推遲到「第二版」,但我認為它應該在規劃之中。模糊測試可以做到適用於大多數情況,但總是會有需要更專門處理的需求。
然而,Hockman 回應指出,可插拔性並非加入此功能的前提,但或許可以在「設計階段的後期再考慮
」。
草案設計在開頭就說明「發布此草案設計的目的是為了收集回饋,以形塑最終預期的提案
」,因此很難說下一步確切會是什麼、又會在何時發生。不過,樂見 Go 團隊為此投入了官方的資源。根據 Cox 對 Vyukov 原始提案的回饋,我猜測接下來會看到在分支上、或以類似 fzgo 這種可供開發者執行的獨立工具形式,開發出更新後提案的原型。
Reddit 討論串上的討論仍在持續,因此對於如此大型的功能來說,要在 2020 年 11 月 Go 1.16 版本凍結之前準備好正式提案與實作,似乎不太可能。比較有可能的是納入預計於 2021 年 8 月發布的 Go 1.17。
隨機一篇部落格
留言
登入後參與討論