教程:模糊測試入門
本教程介紹了 Go 語言中模糊測試的基礎知識。透過模糊測試,將隨機資料執行到您的測試中,以嘗試發現漏洞或導致崩潰的輸入。模糊測試可以發現的一些漏洞示例包括 SQL 注入、緩衝區溢位、拒絕服務和跨站指令碼攻擊。
在本教程中,您將為一個簡單函式編寫模糊測試,執行 go 命令,並除錯和修復程式碼中的問題。
有關本教程中術語的幫助,請參閱Go 模糊測試詞彙表。
您將按以下部分進行學習:
注意: 有關其他教程,請參閱教程。
注意:Go 模糊測試目前支援Go 模糊測試文件中列出的內建型別的一個子集,未來將支援更多內建型別。
先決條件
- Go 1.18 或更高版本的安裝。有關安裝說明,請參閱安裝 Go。
- 一個程式碼編輯工具。 任何文字編輯器都可以。
- 命令終端。 Go 在 Linux 和 Mac 上的任何終端以及 Windows 上的 PowerShell 或 cmd 中都能很好地工作。
- 支援模糊測試的環境。目前,具有覆蓋率檢測的 Go 模糊測試僅適用於 AMD64 和 ARM64 架構。
為您的程式碼建立一個資料夾
首先,為您的程式碼建立一個資料夾。
-
開啟命令提示符並切換到您的主目錄。
在 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) }
此函式將接受一個 `string`,逐 `byte` 迴圈,並在末尾返回反轉的字串。
注意:此程式碼基於 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 個輸入,因此版本之後只有 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 中執行特定的語料庫條目,您可以向 `-run` 提供 {FuzzTestName}/{filename}。這在除錯時很有用。在這種情況下,將 `-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 ( "errors" "fmt" "unicode/utf8" )
-
修改 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, 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) } }) }
除了返回,您還可以呼叫 `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 模糊測試。例如,“new interesting”是指擴充套件現有模糊測試語料庫的程式碼覆蓋率的輸入。“new interesting”輸入的數量預計在模糊測試開始時急劇增加,在新程式碼路徑被發現時多次飆升,然後隨著時間的推移逐漸減少。
結論
幹得漂亮!您剛剛開始了 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)
}
})
}