LoginSignup
14
11

More than 1 year has passed since last update.

Go ファジング入門

Last updated at Posted at 2021-12-17

QualiArts Advent Calendar 2021、17日目担当のs9iです。
昨年も17日目を担当し、Goのリフレクションについて書きました。
今回はGo1.18で導入されるファジングについて、基本的な使い方や使用イメージを掴んでいただけるように書いていきたいと思います。

ファジングとは

ファジングは、プログラムへの入力を継続的に操作して、バグや脆弱性を見つけるソフトウェアテスト手法の一つです。セミランダムな入力を与えることで、予期しないエッジケースの不具合を見つけるのに有効な手段となります。

GoではGo1.18で標準機能として提供される予定です。(2021/9/21にmasterブランチにマージされました

Goへの導入背景

ファジングは、他の手法では気づかないようなカバレッジの発見に非常に有効な手法であり、Goの標準パッケージや様々なOSSで数百を超えるバグを発見した実績があります。しかし、現行のGoでは標準の機能としての提供はしておらず、dvyukov/go-fuzzgoogle/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に記述されているサンプルコードをそのまま使用します。

fuzz_test.go
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.Ttesting.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(ファズターゲット)を実行します。(anyinterface{}のエイリアスで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を定義します。

fuzz_test.go
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はジェネリクスのイメージが強いかもしれませんが、ファジングも非常に便利な機能です。
積極的に利用して、不具合の少ない堅牢なプログラムを作成していきましょう。
最後までお読みいただきありがとうございました。

14
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
11