QualiArts Advent Calendar 2021、17日目担当のs9iです。
昨年も17日目を担当し、Goのリフレクションについて書きました。
今回はGo1.18で導入されるファジングについて、基本的な使い方や使用イメージを掴んでいただけるように書いていきたいと思います。
ファジングとは
ファジングは、プログラムへの入力を継続的に操作して、バグや脆弱性を見つけるソフトウェアテスト手法の一つです。セミランダムな入力を与えることで、予期しないエッジケースの不具合を見つけるのに有効な手段となります。
GoではGo1.18で標準機能として提供される予定です。(2021/9/21にmasterブランチにマージされました)
Goへの導入背景
ファジングは、他の手法では気づかないようなカバレッジの発見に非常に有効な手法であり、Goの標準パッケージや様々なOSSで数百を超えるバグを発見した実績があります。しかし、現行のGoでは標準の機能としての提供はしておらず、dvyukov/go-fuzzやgoogle/gofuzzといったサードパーティ製のパッケージを利用する必要があります。そのため導入のハードルが高く、Goの標準機能と連携できないといった課題があります。
標準機能としてファジングをサポートすることにより、ユニットテストやベンチマークと同様に、開発者が容易にファジングを行える環境を構築していくことを意図しています。
詳細はproposalをご覧ください。
試してみる
早速ファジングを試してみましょう。gotipを利用しても良いですが、今回は先日リリースされたgo1.18beta1を使用してみましょう。以下の手順でインストールします。
$ go install golang.org/dl/go1.18beta1@latest
$ go1.18beta1 download
以下、Go1.18リリース以降はgo1.18beta1
を適宜go
に置き換えて実行してください。
Fuzzing is Beta Ready - go.devに記述されているサンプルコードをそのまま使用します。
package fuzz
import (
"net/url"
"reflect"
"testing"
)
func FuzzParseQuery(f *testing.F) {
f.Add("x=1&y=2")
f.Fuzz(func(t *testing.T, queryStr string) {
query, err := url.ParseQuery(queryStr)
if err != nil {
t.Skip()
}
queryStr2 := query.Encode()
query2, err := url.ParseQuery(queryStr2)
if err != nil {
t.Fatalf("ParseQuery failed to decode a valid encoded query %s: %v", queryStr2, err)
}
if !reflect.DeepEqual(query, query2) {
t.Errorf("ParseQuery gave different query after being encoded\nbefore: %v\nafter: %v", query, query2)
}
})
}
上記のファイルを配置したディレクトリにて、以下のコマンドを実行します。
$ go1.18beta1 test -fuzz Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/1 completed
fuzz: elapsed: 0s, gathering baseline coverage: 1/1 completed, now fuzzing with 16 workers
fuzz: elapsed: 3s, execs: 577001 (192304/sec), new interesting: 154 (total: 154)
fuzz: elapsed: 6s, execs: 1137955 (186871/sec), new interesting: 169 (total: 169)
fuzz: elapsed: 9s, execs: 1641370 (167881/sec), new interesting: 176 (total: 176)
fuzz: elapsed: 12s, execs: 2146183 (168313/sec), new interesting: 181 (total: 181)
...
上記のように、ファジングの実行経過がログ出力されるはずです。
詳細を見ていきましょう。ドキュメントを見た方が早いよという方はこちらからどうぞ。
ファズテストの書き方
テストファイルに記述されたFuzzXxx
という命名で*testing.F
を引数に取る関数がファジング対象(ファズテスト)となります。
func FuzzXxx(f *testing.F)
testing.F
テストやベンチマークで使用されるtesting.T
やtesting.B
のように、ファジングではtesting.F
を使用します。
func FuzzParseQuery(f *testing.F) {
f.Add("x=1&y=2")
f.Fuzz(func(t *testing.T, queryStr string) {
// 処理
})
}
func (f *F) Fuzz(ff any)
Fuzz()
はファズテストのための関数ff(ファズターゲット)を実行します。(any
はinterface{}
のエイリアスでGo1.18で追加されます。)ファズターゲットは次のような関数です。
- 最初の引数が
* T
で、残りの引数がファジングされる型 - 戻り値はなし
f.Fuzz(func(t *testing.T, i int, s string) {
// 処理
})
ファジングされる型は、現在のところ以下に対応しています。
[]byte
string
bool
byte
rune
float32
float64
int
int8
int16
int32
int64
uint
uint8
uint16
uint32
uint64
シードコーパスの追加
シードコーパスを追加する方法は、ファズ関数内でf.Add()
を使用する方法と、testdata/fuzz
ディレクトリに予め格納しておく方法の2通りがあります。コーパスとは、引数で渡すデータセットのことです。
シードの入力は必須ではありませんが、カバレッジを満たすシードを入力しておくことで、ファジングエンジンがより効率的にバグを検出することができるようです。
func (f *F) Add(args ...any)
Add()
は引数で与えられた値をシードコーパスとして追加します。
f.Add(1, "xxx")
ファズターゲットの実行前に記述する必要があり、ファズターゲットの後、または内部で呼び出した場合は無視されます。なお、ファズターゲットと異なる引数を指定した場合はエラーになります。
func FuzzArgsMismatch(f *testing.F) {
f.Add(1) // ファズターゲットの引数と型や個数が異なる場合はエラー
f.Fuzz(func(t *testing.T, s string) {
})
}
$ go1.18beta1 test -fuzz FuzzArgsMismatch
--- FAIL: FuzzArgsMismatch (0.00s)
fuzz_test.go:37: mismatched types in corpus entry: [int], want [string]
FAIL
testdata/fuzz ディレクトリ
ファズテストを含むパッケージ内のtestdata/fuzz/${ファズテスト名}
にファイルを配置した場合も、ファジング実行時にシードコーパスとして自動で入力されます。
$ tree
.
├── fuzz_test.go
├── go.mod
└── testdata
└── fuzz
└── FuzzParseQuery
└── seedfile # シードを記述したファイル
ファジングの実行
先程試したように、go test -fuzz
コマンドを使用します。
-fuzz
で指定した正規表現に一致するファズテストが実行されます。
$ go1.18beta1 test -fuzz Fuzz
-fuzztime
オプションを指定して実行時間を制御することができます。(指定がなければ無制限)
# 時間指定で実行
$ go1.18beta1 test -fuzz Fuzz -fuzztime=10s
# Nxの形式で回数指定での実行(N回実行)も可能
$ go1.18beta1 test -fuzz Fuzz -fuzztime=100x
その他、-parallel
や-race
といった既存のテストで利用可能なオプションも使用できます。
通常のテストとしての実行
-fuzz
オプションを指定せずにgo1.18beta1 test
コマンドを実行することで、通常のテストとしてファズターゲットを実行することができます。この場合、各シードコーパスに対してテストを実行してくれます。
$ go1.18beta1 test -v -run=FuzzParseQuery
=== RUN FuzzParseQuery
=== RUN FuzzParseQuery/seed#0 # Add()で追加したシード
fuzz_test.go:13: x=1&y=2
=== RUN FuzzParseQuery/seedfile # testdata/fuzz/配下に格納したシード
fuzz_test.go:13: z=3
--- PASS: FuzzParseQuery (0.00s)
--- PASS: FuzzParseQuery/seed#0 (0.00s)
--- PASS: FuzzParseQuery/seedfile (0.00s)
PASS
ファジングの終了
ファジングは以下のいずれかの場合に終了します。
- ファズターゲットが失敗した場合
- panicが発生した
-
t.Fail()
やt.Fatal()
等が呼び出された
func FuzzFail(f *testing.F) {
f.Fuzz(func(t *testing.T, s string) {
t.Fatalf("failed. s = %s\n", s)
})
}
$ go1.18beta1 test -fuzz FuzzFail
fuzz: elapsed: 0s, gathering baseline coverage: 0/2 completed
failure while testing seed corpus entry: FuzzFail/771e938e4458e983a736261a702e27c7a414fd660a15b63034f290b146d2f217
fuzz: elapsed: 0s, gathering baseline coverage: 0/2 completed
--- FAIL: FuzzFail (0.04s)
--- FAIL: FuzzFail (0.00s)
fuzz_test.go:81: failed. s = 0
FAIL
-
-fuzztime
で指定した時間が経過した場合
$ go1.18beta1 test -fuzz FuzzCalc -fuzztime=3s
fuzz: elapsed: 0s, gathering baseline coverage: 0/7 completed
fuzz: elapsed: 0s, gathering baseline coverage: 7/7 completed, now fuzzing with 16 workers
fuzz: elapsed: 3s, execs: 871536 (290501/sec), new interesting: 0 (total: 4)
fuzz: elapsed: 3s, execs: 871536 (0/sec), new interesting: 0 (total: 4)
PASS
- シグナルによるテストプロセスが中断された場合
$ go1.18beta1 test -fuzz FuzzCalc
fuzz: elapsed: 0s, gathering baseline coverage: 0/7 completed
fuzz: elapsed: 0s, gathering baseline coverage: 7/7 completed, now fuzzing with 16 workers
fuzz: elapsed: 3s, execs: 870232 (290073/sec), new interesting: 0 (total: 4)
fuzz: elapsed: 6s, execs: 1693588 (274442/sec), new interesting: 0 (total: 4)
^Cfuzz: elapsed: 7s, execs: 1817068 (219698/sec), new interesting: 0 (total: 4)
PASS
不具合を検出してみる
それではファジングを利用して予期しない不具合を検出してみましょう。
以下のように、関数Calc
とそのファズテストFuzzCalc
を定義します。
func FuzzCalc(f *testing.F) {
f.Add(1, 2, "+")
f.Fuzz(func(t *testing.T, v1, v2 int, ope string) {
_, _ = Calc(v1, v2, ope)
})
}
func Calc(v1, v2 int, ope string) (int, error) {
switch ope {
case "+":
return v1 + v2, nil
case "-":
return v1 - v2, nil
case "*":
return v1 * v2, nil
case "/":
return v1 / v2, nil
}
return 0, errors.New("")
}
Calc
は2つの数値と演算子(+,-,*,/)を引数に取り、演算結果を返す関数です。
除算の場合に分母が0になることを考慮できていないので、v2 = 0
, ope = "/"
の場合にpanicが発生します。
ファジングを実行してみましょう。
$ go1.18beta1 test -fuzz FuzzCalc
fuzz: elapsed: 0s, gathering baseline coverage: 0/1 completed
fuzz: elapsed: 0s, gathering baseline coverage: 1/1 completed, now fuzzing with 16 workers
fuzz: minimizing 43-byte failing input file
fuzz: elapsed: 2s, minimizing
--- FAIL: FuzzCalc (1.74s)
--- FAIL: FuzzCalc (0.00s)
testing.go:1320: panic: runtime error: integer divide by zero
goroutine 29645 [running]:
runtime/debug.Stack()
/go1.18beta1/src/runtime/debug/stack.go:24 +0x90
testing.tRunner.func1()
/go1.18beta1/src/testing/testing.go:1320 +0x1f2
panic({0x119b8e0, 0x12e80c0})
/go1.18beta1/src/runtime/panic.go:838 +0x207
fuzzing-sample.Calc(...)
/go/src/fuzzing-sample/fuzz_test.go:59
fuzzing-sample.FuzzCalc.func1(0x5?, 0x0?, 0x0, {0xc00375dbb1, 0x1})
/go/src/fuzzing-sample/fuzz_test.go:46 +0x1c5
reflect.Value.call({0x119a4a0?, 0x11d08d0?, 0x13?}, {0x11c2c99, 0x4}, {0xc000093d40, 0x4, 0x4?})
/go1.18beta1/src/reflect/value.go:556 +0x845
reflect.Value.Call({0x119a4a0?, 0x11d08d0?, 0x10f9700?}, {0xc000093d40, 0x4, 0x4})
/go1.18beta1/src/reflect/value.go:339 +0xbf
testing.(*F).Fuzz.func1.1(0x0?)
/go1.18beta1/src/testing/fuzz.go:332 +0x20b
testing.tRunner(0xc0000b0ea0, 0xc0000b6240)
/go1.18beta1/src/testing/testing.go:1410 +0x102
created by testing.(*F).Fuzz.func1
/go1.18beta1/src/testing/fuzz.go:321 +0x5b8
Failing input written to testdata/fuzz/FuzzCalc/7223802c45128e5463a702874d4dbadf2199f422e8841f8dd9b0ac8ec5bd8505
To re-run:
go test -run=FuzzCalc/7223802c45128e5463a702874d4dbadf2199f422e8841f8dd9b0ac8ec5bd8505
FAIL
exit status 1
期待通りpanicが発生する入力パターンを検知しました。
実行結果に出力されたとおり、入力に使用された値はtestdata/fuzz/${ファズテスト名}
に記録されます。
$ cat testdata/fuzz/FuzzCalc/7223802c45128e5463a702874d4dbadf2199f422e8841f8dd9b0ac8ec5bd8505
go test fuzz v1
int(48)
int(0)
string("/")
前述のとおりtestdata/fuzz/${ファズテスト名}
に格納されたデータは、ファジング実行時にシードコーパスとして読み込むので、次回の実行からは失敗したパターンの入力もシードとして反映される仕組みです。
同じく実行結果に出力されているgo test -run
コマンドで、失敗時の入力を使用してテストできます。
$ go1.18beta1 test -run=FuzzCalc/7223802c45128e5463a702874d4dbadf2199f422e8841f8dd9b0ac8ec5bd8505
--- FAIL: FuzzCalc (0.00s)
--- PASS: FuzzCalc/7223802c45128e5463a702874d4dbadf2199f422e8841f8dd9b0ac8ec5bd8505 (0.00s)
PASS
プログラム修正後に該当のインプットに対応できたかを簡単に確認することができますね。
キャッシュ
ファジング実行中は、テストカバレッジを拡張する値を$GOCACHE/fuzz/
以下に書き込んでいきます。
# GOCACHEの設定を確認
$ go env GOCACHE
.../Caches/go-build
# $GOCACHE/fuzz/${パッケージパス}/${ファズテスト名}
$ ls .../Caches/go-build/fuzz/fuzzing-sample/FuzzCalc/
285f617d99eb8b29580dc377acb54199a84641017af6aa7bf957cdd1870c2144 5445a3a6dcb0d2a672e69761885d89865f5c84aa3a8c6033a81a4ff6738544fb
5367456e95a2dab73f975004fb0f25b17d41466f2c1e445a3b01635c748d8598
# まとめて表示
$ cat .../Caches/go-build/fuzz/fuzzing-sample/FuzzCalc/*
go test fuzz v1
int(1)
int(2)
string("/")
go test fuzz v1
int(1)
int(2)
string("0")
go test fuzz v1
int(24)
int(2)
string("*")
キャッシュの削除
キャッシュの注意点として、書き込みバイト数には制限がなく、大量のストレージを専有する可能性があります。go1.18beta1 clean -fuzzcache
コマンドを使用してキャッシュを削除することが可能です。
$ go1.18beta1 clean -fuzzcache
まとめ
本記事ではGoのファジングの基本的な使用方法について紹介しました。
最後にまとめです。
- Go1.18からGoの標準機能としてファジングが導入されます
- ファジングは予期しないエッジケースの問題を検出するのに有効な手法です
- テストやベンチマークに近い感覚で使用できます
Go1.18はジェネリクスのイメージが強いかもしれませんが、ファジングも非常に便利な機能です。
積極的に利用して、不具合の少ない堅牢なプログラムを作成していきましょう。
最後までお読みいただきありがとうございました。