教學課程:開始使用模糊測試
本教學課程將介紹 Go 中模糊測試的基本知識。透過模糊測試,會針對您的測試執行隨機資料,嘗試找出漏洞或導致當機的輸入。模糊測試可以找出的一些漏洞範例包括 SQL 注入、緩衝區溢位、阻斷服務和跨網站指令碼攻擊。
在本教學課程中,您將為一個簡單函式撰寫模糊測試、執行 go 指令,並偵錯和修正程式碼中的問題。
如需了解本教學課程中術語的說明,請參閱 Go Fuzzing 字彙表。
您將會依序完成以下各節
注意:如需其他教學課程,請參閱 教學課程。
注意:Go fuzzing 目前支援 Go Fuzzing 文件 中所列的內建類型子集,未來將會加入支援更多內建類型。
先決條件
- 安裝 Go 1.18 或更新版本。如需安裝說明,請參閱 安裝 Go。
- 一個用來編輯程式碼的工具。任何您擁有的文字編輯器都能正常運作。
- 一個命令終端機。Go 能夠在 Linux 和 Mac 上使用任何終端機正常運作,並在 Windows 上使用 PowerShell 或 cmd。
- 一個支援模糊測試的環境。目前,只有 AMD64 和 ARM64 架構可以使用涵蓋率儀器的 Go 模糊測試。
為您的程式碼建立一個資料夾
首先,為您要撰寫的程式碼建立一個資料夾。
-
開啟命令提示字元,並變更至您的主目錄。
在 Linux 或 Mac 上
$ cd
在 Windows 上
C:\> cd %HOMEPATH%
本教學課程的其餘部分會顯示 $ 作為提示字元。您所使用的命令也能在 Windows 上執行。
-
在命令提示字元中,為您的程式碼建立一個名為 fuzz 的目錄。
$ mkdir fuzz $ cd fuzz
-
建立一個模組來儲存您的程式碼。
執行
go mod init
命令,並提供您的新程式碼的模組路徑。$ go mod init example/fuzz go: creating new go.mod: module example/fuzz
注意:對於製作程式碼,您會指定一個更符合您自身需求的模組路徑。如需進一步了解,請務必參閱 管理相依性。
接下來,您將加入一些簡單的程式碼來反轉字串,我們稍後會對其進行模糊測試。
新增程式碼進行測試
在這個步驟中,您將新增一個函式來反轉字串。
撰寫程式碼
-
使用您的文字編輯器,在 fuzz 目錄中建立一個名為 main.go 的檔案。
-
在 main.go 中,貼上以下套件宣告至檔案頂端。
package main
獨立程式(相對於函式庫)總是會在套件
main
中。 -
在套件宣告下方,貼上以下函式宣告。
func Reverse(s string) string { b := []byte(s) for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 { b[i], b[j] = b[j], b[i] } return string(b) }
此函式會接受一個
字串
,一次以一個位元組
迴圈處理,並在最後傳回反轉的字串。注意:此程式碼是根據 golang.org/x/example 中的
stringutil.Reverse
函式所撰寫。 -
在 main.go 頂端,在套件宣告下方,貼上以下
main
函式來初始化字串、反轉字串、列印輸出,然後重複。func main() { input := "The quick brown fox jumped over the lazy dog" rev := Reverse(input) doubleRev := Reverse(rev) fmt.Printf("original: %q\n", input) fmt.Printf("reversed: %q\n", rev) fmt.Printf("reversed again: %q\n", doubleRev) }
此函式會執行一些
Reverse
作業,然後將輸出列印至命令列。這有助於查看程式碼實際執行的情況,並可能有助於除錯。 -
main
函式使用 fmt 套件,因此您需要匯入它。程式碼的第一行應如下所示
package main import "fmt"
執行程式碼
在包含 main.go 的目錄中,從命令列執行程式碼。
$ go run .
original: "The quick brown fox jumped over the lazy dog"
reversed: "god yzal eht revo depmuj xof nworb kciuq ehT"
reversed again: "The quick brown fox jumped over the lazy dog"
您可以看到原始字串、反轉後的結果,以及再次反轉後的結果,等於原始字串。
現在程式碼已經執行,是時候進行測試了。
新增單元測試
在這個步驟中,您將為 Reverse
函式撰寫一個基本的單元測試。
撰寫程式碼
-
使用文字編輯器,在 fuzz 目錄中建立一個名為 reverse_test.go 的檔案。
-
貼上以下程式碼至 reverse_test.go。
package main import ( "testing" ) func TestReverse(t *testing.T) { testcases := []struct { in, want string }{ {"Hello, world", "dlrow ,olleH"}, {" ", " "}, {"!12345", "54321!"}, } for _, tc := range testcases { rev := Reverse(tc.in) if rev != tc.want { t.Errorf("Reverse: %q, want %q", rev, tc.want) } } }
這個簡單的測試會斷言所列的輸入字串會正確地反轉。
執行程式碼
使用 go test
執行單元測試
$ go test
PASS
ok example/fuzz 0.013s
接下來,你將把單元測試變更為模糊測試。
加入模糊測試
單元測試有其限制,也就是每個輸入都必須由開發人員加入測試中。模糊測試的一個好處是,它會為你的程式碼提出輸入,並且可能會找出你所想出的測試案例所未涵蓋的邊界案例。
在本節中,你將把單元測試轉換為模糊測試,以便你能夠用更少的工作產生更多輸入!
請注意,你可以將單元測試、基準測試和模糊測試保留在同一個 *_test.go 檔案中,但對於這個範例,你將把單元測試轉換為模糊測試。
撰寫程式碼
在你的文字編輯器中,用以下模糊測試取代 reverse_test.go 中的單元測試。
func FuzzReverse(f *testing.F) {
testcases := []string{"Hello, world", " ", "!12345"}
for _, tc := range testcases {
f.Add(tc) // Use f.Add to provide a seed corpus
}
f.Fuzz(func(t *testing.T, orig string) {
rev := Reverse(orig)
doubleRev := Reverse(rev)
if orig != doubleRev {
t.Errorf("Before: %q, after: %q", orig, doubleRev)
}
if utf8.ValidString(orig) && !utf8.ValidString(rev) {
t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
}
})
}
模糊測試也有一些限制。在你的單元測試中,你可以預測 Reverse
函式的預期輸出,並驗證實際輸出是否符合這些預期。
例如,在測試案例 Reverse("Hello, world")
中,單元測試指定回傳值為 "dlrow ,olleH"
。
在進行模糊測試時,你無法預測預期輸出,因為你無法控制輸入。
不過,有幾個 Reverse
函式的屬性可以在模糊測試中驗證。此模糊測試中檢查的兩個屬性為
- 反轉字串兩次會保留原始值
- 反轉的字串保留其狀態為有效的 UTF-8。
請注意單元測試和模糊測試之間的語法差異
- 函數以 FuzzXxx 開頭,而不是 TestXxx,並採用
*testing.F
而不是*testing.T
- 在您預期會看到
t.Run
執行的地方,您會看到f.Fuzz
,它採用模糊目標函數,其參數為*testing.T
和要模糊化的類型。單元測試的輸入會使用f.Add
提供為種子語料庫輸入。
確保已匯入新的套件 unicode/utf8
。
package main
import (
"testing"
"unicode/utf8"
)
將單元測試轉換為模糊測試後,現在該再次執行測試了。
執行程式碼
-
在不模糊化的情況下執行模糊測試,以確保種子輸入通過。
$ go test PASS ok example/fuzz 0.013s
如果您在該檔案中還有其他測試,並且您只希望執行模糊測試,您也可以執行
go test -run=FuzzReverse
。 -
使用模糊化執行
FuzzReverse
,以查看是否有任何隨機產生的字串輸入會導致失敗。這是使用go test
和新的旗標-fuzz
執行的,該旗標設定為參數Fuzz
。複製以下命令。$ go test -fuzz=Fuzz
另一個有用的旗標是
-fuzztime
,它限制模糊化花費的時間。例如,在以下測試中指定-fuzztime 10s
表示,只要沒有更早發生失敗,測試就會在經過 10 秒後預設結束。請參閱 cmd/go 文件的 此部分,以查看其他測試旗標。現在,執行您剛剛複製的命令。
$ go test -fuzz=Fuzz fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 8 workers fuzz: minimizing 38-byte failing input file... --- FAIL: FuzzReverse (0.01s) --- FAIL: FuzzReverse (0.00s) reverse_test.go:20: Reverse produced invalid UTF-8 string "\x9c\xdd" Failing input written to testdata/fuzz/FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a To re-run: go test -run=FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a FAIL exit status 1 FAIL example/fuzz 0.030s
在執行模糊測試時發生錯誤,而導致問題的輸入會寫入種子語料庫檔案,下次呼叫
go test
時會執行該檔案,即使沒有-fuzz
旗標。若要檢視導致錯誤的輸入,請在文字編輯器中開啟寫入至 testdata/fuzz/FuzzReverse 目錄的語料庫檔案。您的種子語料庫檔案可能包含不同的字串,但格式會相同。go test fuzz v1 string("泃")
語料庫檔案的第一行表示編碼版本。每一行代表組成語料庫條目的每個類型的值。由於模糊測試目標只接受 1 個輸入,因此版本後只有一個值。
-
再次執行
go test
,但不要使用-fuzz
旗標;將會使用新的失敗種子語料庫條目$ go test --- FAIL: FuzzReverse (0.00s) --- FAIL: FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a (0.00s) reverse_test.go:20: Reverse produced invalid string FAIL exit status 1 FAIL example/fuzz 0.016s
由於我們的測試失敗了,因此是時候進行除錯。
修正無效字串錯誤
在本節中,您將除錯失敗並修正錯誤。
在繼續之前,請花點時間思考這個問題,並嘗試自行修正。
診斷錯誤
您可以使用幾種不同的方式來除錯此錯誤。如果您使用 VS Code 作為文字編輯器,則可以 設定除錯器 來進行調查。
在本教學課程中,我們會將有用的除錯資訊記錄到您的終端機。
首先,考慮 utf8.ValidString
的文件。
ValidString reports whether s consists entirely of valid UTF-8-encoded runes.
目前的 Reverse
函式逐位元組反轉字串,而這就是我們的問題所在。為了保留原始字串的 UTF-8 編碼符號,我們必須逐符號反轉字串。
若要檢查輸入(在本例中為中文字元 泃
)為何會導致 Reverse
在反轉時產生無效字串,您可以檢查反轉字串中的符號數量。
撰寫程式碼
在你的文字編輯器中,以以下內容取代 FuzzReverse
中的模糊目標。
f.Fuzz(func(t *testing.T, orig string) {
rev := Reverse(orig)
doubleRev := Reverse(rev)
t.Logf("Number of runes: orig=%d, rev=%d, doubleRev=%d", utf8.RuneCountInString(orig), utf8.RuneCountInString(rev), utf8.RuneCountInString(doubleRev))
if orig != doubleRev {
t.Errorf("Before: %q, after: %q", orig, doubleRev)
}
if utf8.ValidString(orig) && !utf8.ValidString(rev) {
t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
}
})
如果發生錯誤,或使用 -v
執行測試,此 t.Logf
行會列印至命令列,這有助於你偵錯此特定問題。
執行程式碼
使用 go test 執行測試
$ go test
--- FAIL: FuzzReverse (0.00s)
--- FAIL: FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0 (0.00s)
reverse_test.go:16: Number of runes: orig=1, rev=3, doubleRev=1
reverse_test.go:21: Reverse produced invalid UTF-8 string "\x83\xb3\xe6"
FAIL
exit status 1
FAIL example/fuzz 0.598s
整個種子語料庫使用每個字元都是單一位元組的字串。但是,諸如 泃 等字元可能需要幾個位元組。因此,逐位元組反轉字串會使多位元組字元失效。
注意:如果你好奇 Go 如何處理字串,請閱讀部落格文章 Go 中的字串、位元組、符文和字元 以深入了解。
更了解錯誤後,修正 Reverse
函式中的錯誤。
修正錯誤
若要修正 Reverse
函式,我們以符文而非位元組來遍歷字串。
撰寫程式碼
在你的文字編輯器中,以以下內容取代現有的 Reverse() 函式。
func Reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
主要差異在於 Reverse
現在會迭代字串中的每個 rune
,而非每個 byte
。
執行程式碼
-
使用
go test
執行測試$ go test PASS ok example/fuzz 0.016s
測試現在通過了!
-
再次使用
go test -fuzz
進行模糊測試,以查看是否有任何新錯誤。$ go test -fuzz=Fuzz fuzz: elapsed: 0s, gathering baseline coverage: 0/37 completed fuzz: minimizing 506-byte failing input file... fuzz: elapsed: 0s, gathering baseline coverage: 5/37 completed --- FAIL: FuzzReverse (0.02s) --- FAIL: FuzzReverse (0.00s) reverse_test.go:33: Before: "\x91", after: "�" Failing input written to testdata/fuzz/FuzzReverse/1ffc28f7538e29d79fce69fef20ce5ea72648529a9ca10bea392bcff28cd015c To re-run: go test -run=FuzzReverse/1ffc28f7538e29d79fce69fef20ce5ea72648529a9ca10bea392bcff28cd015c FAIL exit status 1 FAIL example/fuzz 0.032s
我們可以看到,在反轉兩次後,字串與原始字串不同。這次輸入本身是無效的 Unicode。如果我們使用字串進行模糊測試,這怎麼可能發生?
讓我們再次偵錯。
修正雙重反轉錯誤
在本節中,你將偵錯雙重反轉失敗並修正錯誤。
在繼續之前,請花點時間思考這個問題,並嘗試自行修正。
診斷錯誤
與之前相同,你可以使用多種方式偵錯此失敗。在此情況下,使用 偵錯器 會是很棒的方法。
在本教學課程中,我們將在 Reverse
函式中記錄有用的偵錯資訊。
仔細觀察反轉的字串以找出錯誤。在 Go 中,字串是唯讀的位元組切片,且可能包含無效的 UTF-8 位元組。原始字串是包含一個位元組 '\x91'
的位元組切片。當輸入字串設定為 []rune
時,Go 會將位元組切片編碼為 UTF-8,並將位元組替換為 UTF-8 字元 �。當我們將替換的 UTF-8 字元與輸入位元組切片進行比較時,它們明顯不相等。
撰寫程式碼
-
在文字編輯器中,將
Reverse
函式替換為下列內容。func Reverse(s string) string { fmt.Printf("input: %q\n", s) r := []rune(s) fmt.Printf("runes: %q\n", r) for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 { r[i], r[j] = r[j], r[i] } return string(r) }
這將有助於我們了解在將字串轉換為符文切片時出了什麼問題。
執行程式碼
這一次,我們只想執行失敗的測試以檢查記錄。為此,我們將使用 go test -run
。
若要執行 FuzzXxx/testdata 中的特定語料庫項目,你可以提供 {FuzzTestName}/{filename} 給 -run
。這在除錯時會很有幫助。在此情況下,將 -run
旗標設定為失敗測試的確切雜湊值。複製並貼上終端的唯一雜湊值;它將與下列內容不同。
$ go test -run=FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0
input: "\x91"
runes: ['�']
input: "�"
runes: ['�']
--- FAIL: FuzzReverse (0.00s)
--- FAIL: FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0 (0.00s)
reverse_test.go:16: Number of runes: orig=1, rev=1, doubleRev=1
reverse_test.go:18: Before: "\x91", after: "�"
FAIL
exit status 1
FAIL example/fuzz 0.145s
知道輸入是無效的 unicode 後,讓我們修正 Reverse
函式中的錯誤。
修正錯誤
若要修正此問題,讓我們在 Reverse
的輸入無效 UTF-8 時傳回錯誤。
撰寫程式碼
-
在文字編輯器中,將現有的
Reverse
函式替換為下列內容。func Reverse(s string) (string, error) { if !utf8.ValidString(s) { return s, errors.New("input is not valid UTF-8") } r := []rune(s) for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 { r[i], r[j] = r[j], r[i] } return string(r), nil }
此變更將在輸入字串包含無效 UTF-8 字元時傳回錯誤。
-
由於 Reverse 函式現在傳回錯誤,請修改
main
函式以捨棄多餘的錯誤值。將現有的main
函式替換為下列內容。func main() { input := "The quick brown fox jumped over the lazy dog" rev, revErr := Reverse(input) doubleRev, doubleRevErr := Reverse(rev) fmt.Printf("original: %q\n", input) fmt.Printf("reversed: %q, err: %v\n", rev, revErr) fmt.Printf("reversed again: %q, err: %v\n", doubleRev, doubleRevErr) }
這些呼叫
Reverse
應該會傳回一個 nil 錯誤,因為輸入字串是有效的 UTF-8。 -
您需要匯入 errors 和 unicode/utf8 套件。main.go 中的 import 陳述式應如下所示。
import ( "errors" "fmt" "unicode/utf8" )
-
修改 reverse_test.go 檔案以檢查錯誤,如果錯誤是由 returning 產生,則略過測試。
func FuzzReverse(f *testing.F) { testcases := []string {"Hello, world", " ", "!12345"} for _, tc := range testcases { f.Add(tc) // Use f.Add to provide a seed corpus } f.Fuzz(func(t *testing.T, orig string) { rev, err1 := Reverse(orig) if err1 != nil { return } doubleRev, err2 := Reverse(rev) if err2 != nil { return } if orig != doubleRev { t.Errorf("Before: %q, after: %q", orig, doubleRev) } if utf8.ValidString(orig) && !utf8.ValidString(rev) { t.Errorf("Reverse produced invalid UTF-8 string %q", rev) } }) }
除了 returning 之外,您也可以呼叫
t.Skip()
以停止執行該模糊輸入。
執行程式碼
-
使用 go test 執行測試
$ go test PASS ok example/fuzz 0.019s
-
使用
go test -fuzz=Fuzz
對其進行模糊處理,然後在經過幾秒鐘後,使用ctrl-C
停止模糊處理。模糊測試將執行,直到遇到失敗的輸入,除非您傳遞-fuzztime
旗標。如果沒有發生失敗,預設會永遠執行,並且可以使用ctrl-C
中斷處理程序。
$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/38 completed
fuzz: elapsed: 0s, gathering baseline coverage: 38/38 completed, now fuzzing with 4 workers
fuzz: elapsed: 3s, execs: 86342 (28778/sec), new interesting: 2 (total: 35)
fuzz: elapsed: 6s, execs: 193490 (35714/sec), new interesting: 4 (total: 37)
fuzz: elapsed: 9s, execs: 304390 (36961/sec), new interesting: 4 (total: 37)
...
fuzz: elapsed: 3m45s, execs: 7246222 (32357/sec), new interesting: 8 (total: 41)
^Cfuzz: elapsed: 3m48s, execs: 7335316 (31648/sec), new interesting: 8 (total: 41)
PASS
ok example/fuzz 228.000s
-
使用
go test -fuzz=Fuzz -fuzztime 30s
對其進行模糊處理,如果沒有找到失敗,它將在 30 秒後退出。$ go test -fuzz=Fuzz -fuzztime 30s fuzz: elapsed: 0s, gathering baseline coverage: 0/5 completed fuzz: elapsed: 0s, gathering baseline coverage: 5/5 completed, now fuzzing with 4 workers fuzz: elapsed: 3s, execs: 80290 (26763/sec), new interesting: 12 (total: 12) fuzz: elapsed: 6s, execs: 210803 (43501/sec), new interesting: 14 (total: 14) fuzz: elapsed: 9s, execs: 292882 (27360/sec), new interesting: 14 (total: 14) fuzz: elapsed: 12s, execs: 371872 (26329/sec), new interesting: 14 (total: 14) fuzz: elapsed: 15s, execs: 517169 (48433/sec), new interesting: 15 (total: 15) fuzz: elapsed: 18s, execs: 663276 (48699/sec), new interesting: 15 (total: 15) fuzz: elapsed: 21s, execs: 771698 (36143/sec), new interesting: 15 (total: 15) fuzz: elapsed: 24s, execs: 924768 (50990/sec), new interesting: 16 (total: 16) fuzz: elapsed: 27s, execs: 1082025 (52427/sec), new interesting: 17 (total: 17) fuzz: elapsed: 30s, execs: 1172817 (30281/sec), new interesting: 17 (total: 17) fuzz: elapsed: 31s, execs: 1172817 (0/sec), new interesting: 17 (total: 17) PASS ok example/fuzz 31.025s
模糊處理已通過!
除了
-fuzz
旗標之外,還新增了幾個新旗標到go test
,可以在 文件 中查看。請參閱 Go Fuzzing 以取得有關模糊處理輸出中所使用術語的更多資訊。例如,「新的有趣」是指擴展現有模糊測試語料庫的程式碼覆蓋範圍的輸入。隨著模糊處理的開始,預計「新的有趣」輸入數量會急遽增加,隨著發現新的程式碼路徑而激增數次,然後隨著時間推移而逐漸減少。
結論
做得很好!您剛剛在 Go 中體驗了模糊處理。
下一步是在您的程式碼中選擇一個您想要模糊處理的函式,然後試用看看!如果模糊處理在您的程式碼中找到錯誤,請考慮將其新增到 獎盃櫃。
如果您遇到任何問題或對功能有任何想法,提交問題。
對於有關此功能的討論和一般回饋,您也可以參與 Gophers Slack 中的 #fuzzing 頻道。
請參閱 go.dev/security/fuzz 中的文件,以進一步閱讀。
已完成的程式碼
— main.go —
package main
import (
"errors"
"fmt"
"unicode/utf8"
)
func main() {
input := "The quick brown fox jumped over the lazy dog"
rev, revErr := Reverse(input)
doubleRev, doubleRevErr := Reverse(rev)
fmt.Printf("original: %q\n", input)
fmt.Printf("reversed: %q, err: %v\n", rev, revErr)
fmt.Printf("reversed again: %q, err: %v\n", doubleRev, doubleRevErr)
}
func Reverse(s string) (string, error) {
if !utf8.ValidString(s) {
return s, errors.New("input is not valid UTF-8")
}
r := []rune(s)
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r), nil
}
— reverse_test.go —
package main
import (
"testing"
"unicode/utf8"
)
func FuzzReverse(f *testing.F) {
testcases := []string{"Hello, world", " ", "!12345"}
for _, tc := range testcases {
f.Add(tc) // Use f.Add to provide a seed corpus
}
f.Fuzz(func(t *testing.T, orig string) {
rev, err1 := Reverse(orig)
if err1 != nil {
return
}
doubleRev, err2 := Reverse(rev)
if err2 != nil {
return
}
if orig != doubleRev {
t.Errorf("Before: %q, after: %q", orig, doubleRev)
}
if utf8.ValidString(orig) && !utf8.ValidString(rev) {
t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
}
})
}