チュートリアル:ファジング入門
このチュートリアルでは、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
を受け取り、一度に1byte
ずつループ処理し、最後に反転された文字列を返します。注記:このコードは、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
次に、単体テストをファズテストに変更します。
ファズテストを追加
単体テストには限界があります。つまり、各入力を開発者がテストに追加する必要があるということです。ファジングの利点の1つは、コードの入力を生成し、作成したテストケースでは到達しなかったエッジケースを特定できることです。
このセクションでは、より少ない作業でより多くの入力を生成できるように、単体テストをファズテストに変換します。
単体テスト、ベンチマーク、ファズテストを同じ*_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
関数のいくつかのプロパティをファズテストで検証できます。このファズテストでチェックされている2つのプロパティは次のとおりです。
- 文字列を2回反転すると、元の値が保持されます
- 反転された文字列は、有効なUTF-8として状態を保持します。
単体テストとファズテストの構文の違いに注意してください
- 関数はTestXxxではなくFuzzXxxで始まり、
*testing.T
ではなく*testing.F
を受け取ります。 t.Run
実行を期待する場所で、代わりにf.Fuzz
が表示されます。これは、パラメーターが*testing.T
とファズするタイプであるファズターゲット関数を受け取ります。単体テストからの入力は、f.Add
を使用してシードコーパス入力として提供されます。
新しいパッケージunicode/utf8
がインポートされていることを確認してください。
package main
import (
"testing"
"unicode/utf8"
)
単体テストをファズテストに変換したので、テストを再度実行します。
コードを実行する
-
ファジングせずに
FuzzReverse
を実行して、シード入力がパスすることを確認します。$ go test PASS ok example/fuzz 0.013s
ファイルに他のテストがあり、ファズテストのみを実行する場合は、
go test -run=FuzzReverse
を実行することもできます。 -
ファジングを使用して
FuzzReverse
を実行し、ランダムに生成された文字列入力が失敗を引き起こすかどうかを確認します。これは、新しいフラグ-fuzz
をパラメーターFuzz
に設定してgo test
を使用して実行されます。以下のコマンドをコピーします。$ go test -fuzz=Fuzz
もう1つの便利なフラグは
-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つだけです。
-
-fuzz
フラグなしでgo test
を再度実行します。新しい失敗したシードコーパスエントリが使用されます$ 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
使用されたシードコーパス全体では、すべての文字が1バイトの文字列を使用していました。しかし、泃のような文字は複数のバイトを必要とする場合があります。そのため、バイト単位で文字列を反転すると、マルチバイト文字が無効になります。
注記: Goが文字列をどのように処理するかに興味がある場合は、ブログ記事Goにおける文字列、バイト、rune、文字を読んで、より深く理解してください。
バグをよりよく理解した上で、Reverse
関数のエラーを修正してください。
エラーを修正してください。
Reverse
関数を修正するには、バイト単位ではなく、rune単位で文字列を走査しましょう。
コードを記述する
テキストエディタで、既存の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
が文字列の各byte
ではなく、各rune
を反復処理するようになったことです。
コードを実行する
-
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
2回反転させた後、文字列が元の文字列と異なることがわかります。今回は入力自体が無効なUnicodeです。文字列でファジングしているのに、なぜこれが可能なのでしょうか?
再度デバッグしましょう。
二重反転エラーを修正してください。
このセクションでは、二重反転の失敗をデバッグし、バグを修正します。
先に進む前に、自分でこの問題について考え、解決策を試してみてください。
エラーの診断
これまでと同様に、この失敗をデバッグするにはいくつかの方法があります。この場合は、デバッガを使用するのが良い方法です。
このチュートリアルでは、Reverse
関数に役立つデバッグ情報をログに出力します。
反転された文字列をよく見て、エラーを見つけましょう。Goでは、文字列はバイトの読み取り専用スライスであり、有効なUTF-8ではないバイトを含むことができます。元の文字列は1バイト'\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) }
これにより、文字列をruneのスライスに変換する際に何が間違っているのかを理解するのに役立ちます。
コードを実行する
今回は、ログを検査するために、失敗したテストだけを実行します。そのため、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) }
入力文字列が有効なUTF-8であるため、これらの
Reverse
への呼び出しはnilエラーを返すはずです。 -
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ファジングを参照してください。「新しい興味深い」とは、既存のファズテストコーパスのコードカバレッジを拡大する入力を指します。「新しい興味深い」入力の数は、ファジング開始時に急激に増加し、新しいコードパスが発見されると数回スパイクし、その後時間とともに減少することが予想されます。
結論
素晴らしい!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)
}
})
}