Go語言隨機測試工具go-fuzz

jopen 9年前發布 | 19K 次閱讀 go-fuzz Google Go/Golang開發

 

在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. 版權所有.

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!