Go語言隨機測試工具go-fuzz
在Go 1.5發布時,前Intel Black Belt級工程師,現Google工程師Dmitry Vyukov同時發布了Go語言隨機測試工具 go-fuzz 。在 GopherCon2015 大會上,Dmitry Vyukov在其名為“[Go Dynamic Tools]”的presentation中著重介紹了 go-fuzz 。
go-fuzz是一款隨機測試(Random testing)工具。對于隨機測試想必很多人都比較陌生,我也不例外。至少在接觸go-fuzz之前,我從未在golang或其他編程語言中使用過類似的測試工具(c/c++開發者可以使用 afl-fuzz )。按照 維基百科 的說法:隨機測試就是指半自動或自動地為程序提供非法的、非預期、隨機的數據,并監控程序在這些輸入數據 下的crash、內置斷言、內存泄露等情況。隨機測試的研究始于1988年的 Barton Miller ,到目前為止已經有許多理論支撐,不過這里不會涉及,有興趣的、想深入的朋友可以跟隨維基百科中的鏈接自行學習。
在開始go-fuzz之前,我們需要認識到隨機測試的位置和意義:
* 首先它是軟件測試技術的一個重要分支,與單元測試等互為補充;
* 其次隨機測試不是什么銀彈,它有其適用的范圍。隨機測試最適合那些處理復雜輸入數據的程序,比如文件格式解析、網絡協議解析、人機交互界面入口等。
* 最后,并非所有編程語言都有類似的工具支撐,gopher很幸運,Dmitry Vyukov為我們帶來了go-fuzz。
接下來就讓我們回到go-fuzz這個正題上來。
一、Why go-fuzz
go-fuzz之所以吸引眼球,源于Dmitry Vyukov在使用go-fuzz對go標準庫以及其他第三方開源庫進行測試后的“驚人的戰果”。Dmitry在其slide中展示了這些戰果:
60 tests 137 bugs in std lib (70 fixed) 165 elsewhere (47 in gccgo, 30 in golang.org/x, 42 in freetype-go, protobuf, http2, bson)
Dmitry Vyukov的go-fuzz實際上也是基于前面提到的 afl-fuzz 的邏輯 的基礎上設計和實現的。不同的是在使用的時候,afl-fuzz對于每個input case都會fork一個process,而go-fuzz則是通過將input case中的data傳給一個Fuzz函數:
func Fuzz(data []byte) int
這樣就無需反復重啟程序。
go-fuzz進一步完善了go開發測試工具集,很多一線公司(比如cloudflare)已經開始使用go-fuzz來測試自己的產品,提高產品質量了。
二、原理
Dmitry在其slide中將go-fuzz的工作流程歸納如下:
-> 生成隨機數據 -> 輸入給程序 -> 觀察是否有crash -> 如果發現crash,則獲益 之后開發者根據crash的結果,嘗試fix bug,并 添加針對這個bug的單元測試case。
go-fuzz一旦運行起來,將會是一個infinite loop(一種遺傳算法),該loop的偽代碼在slide也有給出:
Instrument program for code coverage Collect initial corpus of inputs //收集初始輸入數據語料(位于workdir的corpus目錄下) for { //從corpus中讀取語料并隨機變化 Randomly mutate an input from the corpus //執行Fuzz,收集覆蓋范圍 Execute and collect coverage //如果輸入數據提供了新的coverage,則將該數據存入語料庫(corpus) If the input gives new coverage, add it to corpus }
go-fuzz內部實現了多種對初始語料庫中輸入數據的mutation策略:
* Insert/remove/duplicate/copy a random range of random bytes. * Bit flip. * Swap 2 bytes. * Set a byte to a random value. * Add/subtract from a byte/uint16/uint32/uint64 (le/be). * Replace a byte/uint16/uint32 with an interesting value (le/be). * Replace an ascii digit/number with another digit/number. * Splice another input. * Insert a part of another input. * Insert a string/int literal. * Replace with string/int literal.
三、使用方法
1、安裝go-fuzz
使用go-fuzz需要安裝兩個重要工具:go-fuzz-build和go-fuzz,通過標準go get就可以安裝它們:
$ go get github.com/dvyukov/go-fuzz/go-fuzz $ go get github.com/dvyukov/go-fuzz/go-fuzz-build
對于國內用戶而言,由于go-fuzz并未使用go 1.5引入的vendor機制, 而其依賴的一些包卻在墻外,因此可能會遇到些麻煩。
go get自動安裝兩個工具到$GOROOT/bin或$GOPATH/bin,因此你需要確保你的Path環境變量下包含了這兩個路徑。
2、帶有fuzz test的項目組織
假設我們的待測試的go包名為foo,路徑為$GOPATH/src/github.com/bigwhite/fuzzexamples/foo。為了應用go- fuzz,我們一般會在foo下創建fuzz.go源文件,其內容模板如下:
// +build gofuzz package foo func Fuzz(data []byte) int { ... ... }
go-fuzz在構建用于執行fuzz test的驅動binary文件時,會搜索帶有”+build gofuzz” directive的源文件以及其中的Fuzz函數。如果foo包下沒有該文件,你在執行go-fuzz-build時,會得到類似如下的錯誤日志:
$go-fuzz-build github.com/bigwhite/fuzzexamples/foo failed to execute go build: exit status 2 # go-fuzz-main /var/folders/2h/xr2tmnxx6qxc4w4w13m01fsh0000gn/T/go-fuzz-build641745751/src/go-fuzz-main/main.go:10: undefined: foo.Fuzz
有些時候待測試包內功能很多,一個Fuzz函數不夠,我們可以參考go-fuzz中example中的目錄組織形式來應對:
github.com/bigwhite/fuzzexamples/foo/fuzztest]$tree . ├── fuzz1 │ ├── corpus │ ├── fuzz.go │ └── gen │ └── main.go └── fuzz2 ├── corpus ├── fuzz.go └── gen └── main.go ... ...
這其中的fuzz1、fuzz2…. fuzzN各自為一個go-fuzz單元,如果要應用go-fuzz,則可像下面這樣執行:
$ cd fuzz1 $ go-fuzz-build github.com/bigwhite/fuzzexamples/foo/fuzztest/fuzz1 $ go-fuzz -bin=./foo-fuzz.zip -workdir=./ .. ... $ cd fuzz2 $ go-fuzz-build github.com/bigwhite/fuzzexamples/foo/fuzztest/fuzz2 $ go-fuzz -bin=./foo-fuzz.zip -workdir=./
每個go-fuzz單元下有一套”固定”目錄組合:
├── fuzz1 │ ├── corpus │ ├── fuzz.go │ └── gen │ └── main.go
corpus為存放輸入數據語料的目錄,在go-fuzz執行之前,可放入初始語料;
fuzz.go為包含Fuzz函數的源碼文件;
gen目錄中包含手工生成初始語料的main.go代碼。
在后續的示例中,我們會展示細節。
3、go-fuzz-build
go-fuzz-build會根據Fuzz函數構建一個用于go-fuzz執行的zip包(PACKAGENAME-fuzz.zip),包里包含了用途不同的三 個文件:
-rw-r--r-- 1 tony staff 3902136 12 31 1979 cover.exe -rw-r--r-- 1 tony staff 3211816 12 31 1979 metadata -rw-r--r-- 1 tony staff 5031496 12 31 1979 sonar.exe
按照作者slide中的說法,各個二進制程序的功能如下:
cover.exe – coverage instrumented binary
sonar.exe – sonar instrumented binary
metadata – coverage and sonar metadata, int and string literals
不過對于使用者來說,我們不必過于關心它們,點到為止。
4、執行go-fuzz
一旦生成了foo-fuzz.zip,我們就可以執行針對fuzz1的fuzz test。
$ cd fuzz1 $ go-fuzz -bin=./foo-fuzz.zip -workdir=./ 2015/12/08 17:51:48 slaves: 4, corpus: 8 (1s ago), crashers: 0, restarts: 1/0, execs: 0 (0/sec), cover: 0, uptime: 3s 2015/12/08 17:51:51 slaves: 4, corpus: 9 (2s ago), crashers: 0, restarts: 1/3851, execs: 11553 (1924/sec), cover: 143, uptime: 6s 2015/12/08 17:51:54 slaves: 4, corpus: 9 (5s ago), crashers: 0, restarts: 1/3979, execs: 47756 (5305/sec), cover: 143, uptime: 9s ... ...
如果corpus中沒有初始語料數據,那么go-fuzz也會自行生成相關數據傳遞給Fuzz函數,并且采用遺傳算法,不斷基于corpus中的語料生成新的輸入語料。go-fuzz作者建議corpus初始時放入的語料越多越好,而且要有足夠的多樣性,這樣基于這些初始語料施展遺傳算法,效果才會更加。go-fuzz會將一些語料持久化成文件放在corpus中,以供下次restart使用。
前面說過,go-fuzz是一個infinite loop,上面的測試需要手工停下來。go-fuzz會在workdir中創建另外兩個目錄:crashers和suppressions。顧名思義,crashers中存放的是代碼crash時的相關數據,包括引起crash的case的輸入二進制數據、輸入的數據的字符串形式 (xxx.quoted)以及基于這個數據的輸出數據(xxx.output)。suppressions中保存著crash時的stack trace信息。
四、一個簡單示例
gocmpp 是一個cmpp協議庫的go實現,這里打算用其中的unpack做一個最簡單的fuzz test demo。
gocmpp中的每種協議包都實現了Packer接口,其中的Unpack尤其適合fuzz test。由于協議包眾多,我們在gocmpp下專門建立fuzztest目錄,用于存放fuzz test的代碼,將各個協議包的fuzz test分到各個子目錄中:
github.com/bigwhite/gocmpp/fuzztest]$tree . ├── fwd │ ├── corpus │ │ └── 0 │ ├── fuzz.go │ └── gen │ └── main.go └── submit ├── corpus │ ├── 0 ├── fuzz.go └── gen └── main.go
先說說每個fuzz test單元(比如fwd或submit)下的gen/main.go,這是一個用于生成初始語料的可執行程序,我們以submit/gen/main.go為例:
package main import ( "github.com/dvyukov/go-fuzz/gen" ) func main() { data := []byte{ 0x00, 0x00, 0x00, 0x17, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x01, 0x01, 0x74, 0x65, 0x73, 0x74, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x31, 0x33, 0x35, 0x30, 0x30, 0x30, 0x30, 0x32, 0x36, 0x39, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x39, 0x30, 0x30, 0x30, 0x30, 0x31, 0x30, 0x32, 0x31, 0x30, 0x00, 0x00, 0x00, 0x00, 0x31, 0x35, 0x31, 0x31, 0x30, 0x35, 0x31, 0x33, 0x31, 0x35, 0x35, 0x35, 0x31, 0x30, 0x31, 0x2b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x39, 0x30, 0x30, 0x30, 0x30, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x31, 0x33, 0x35, 0x30, 0x30, 0x30, 0x30, 0x32, 0x36, 0x39, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1e, 0x6d, 0x4b, 0x8b, 0xd5, 0x00, 0x67, 0x00, 0x6f, 0x00, 0x63, 0x00, 0x6d, 0x00, 0x70, 0x00, 0x70, 0x00, 0x20, 0x00, 0x73, 0x00, 0x75, 0x00, 0x62, 0x00, 0x6d, 0x00, 0x69, 0x00, 0x74, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, } gen.Emit(data, nil, true) }
在這個main.go中,我們借用submit包的單元測試中的數據作為fuzz test的初始語料數據,通過go-fuzz提供的gen包將數據輸出到文件中:
$cd submit/gen $go run main.go -out ../corpus/ $ll ../corpus/ total 8 drwxr-xr-x 3 tony staff 102 12 7 22:00 ./ drwxr-xr-x 5 tony staff 170 12 7 21:42 ../ -rw-r--r-- 1 tony staff 181 12 7 22:00 0
該程序在corpus下生成了一個文件“0”,作為submit fuzz test的初始語料。
接下來我們看看submit/fuzz.go:
// +build gofuzz package cmppfuzz import ( "github.com/bigwhite/gocmpp" ) func Fuzz(data []byte) int { p := &cmpp.Cmpp2SubmitReqPkt{} if err := p.Unpack(data); err != nil { return 0 } return 1 }
這是一個“最簡單”的Fuzz函數實現了,根據作者對Fuzz的規約,Fuzz的返回值是有重要含義的:
如果此次輸入的數據在某種程度上是很有意義的,go-fuzz會給予這類輸入更多的優先級,Fuzz應該返回1; 如果明確這些輸入絕對不能放入corpus,那讓Fuzz返回-1; 至于其他情況,返回0。
接下來就是go-fuzz-build和go-fuzz登場了,這與前面的介紹差不多:
$cd submit $go-fuzz-build github.com/bigwhite/gocmpp/fuzztest/submit $ls cmppfuzz-fuzz.zip corpus/ fuzz.go gen/
在submit目錄下執行go-fuzz:
$go-fuzz -bin=./cmppfuzz-fuzz.zip -workdir=./ 2015/12/07 22:05:02 slaves: 4, corpus: 1 (3s ago), crashers: 0, restarts: 1/0, execs: 0 (0/sec), cover: 0, uptime: 3s 2015/12/07 22:05:05 slaves: 4, corpus: 3 (0s ago), crashers: 0, restarts: 1/0, execs: 0 (0/sec), cover: 32, uptime: 6s 2015/12/07 22:05:08 slaves: 4, corpus: 7 (1s ago), crashers: 0, restarts: 1/5424, execs: 65098 (7231/sec), cover: 131, uptime: 9s 2015/12/07 22:05:11 slaves: 4, corpus: 9 (0s ago), crashers: 0, restarts: 1/5424, execs: 65098 (5424/sec), cover: 146, uptime: 12s ... ... 2015/12/07 22:09:11 slaves: 4, corpus: 9 (4m0s ago), crashers: 0, restarts: 1/9860, execs: 4033002 (16002/sec), cover: 146, uptime: 4m12s ^C2015/12/07 22:09:13 shutting down...
這個測試非常耗cpu啊!一小會兒功夫,我的Mac Air的風扇就開始呼呼轉起來了。不過我的Unpack函數并未在fuzz test中發現問題,crashers后面的數值一直是0。
go-fuzz目前似乎還不支持vendor機制,因此如果你的包像gocmpp一樣使用了vendor,那需要在go-fuzz-build 和go-fuzz前面加上一個GO15VENDOREXPERIMENT=”0″(如果你之前開啟了GO15VENDOREXPERIMENT),就像這樣:
$ GO15VENDOREXPERIMENT="0" go-fuzz-build github.com/bigwhite/gocmpp/fuzztest/submit
如果不關閉vendor,你可能會得到類似如下的 錯誤 :
can't find imported package golang.org/x/text/transform
? 2015,bigwhite. 版權所有.