Goの公式チュートリアルをやってみる【ファジング】

前回

Goの公式チュートリアルをやってみる【RESTful API】

はじめに

前回はRESTfulなAPIを作成することができました。今回はソフトウェアテスト手法の一つであるファジングテストをGoで実行していく方法を学んでいきます。

ファジングとは、ランダムなデータをテストに対して実行して、脆弱性やクラッシュの原因となる入力を見つけようとします。ランダムなデータを入力として与えることで、予期しないエッジケースの不具合や脆弱性を見つけるのに有効な手段となります。

今回のチュートリアルではファジングに関する用語がたくさん出てきます。用語についてはこちらを参考にしてください。

テスト対象となる関数の用意

まずは作業フォルダにfuzzというフォルダを作成しましょう。その後コマンドプロンプトでfuzzディレクトリに移動し、go mod init example/fuzzコマンドを実行してモジュールの初期化をしましょう。

モジュールの初期化ができたらmain.goファイルを新しく作成して以下のコードを追加しましょう。

main.go
package 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)
}

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)
}

Reverse関数は文字列を受け取り、1バイトずつループして反転させ、最後に反転した文字列を返す処理です。
main関数では文字列を初期化してから反転・再反転させ、結果を出力しています。

コードの準備ができましたら、コマンドプロンプトfuzzディレクトリに移動してgo run .を実行します。以下の内容が出力されたら作成完了です。

$ 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"

単体テストの追加

続いて先ほど作成した関数をテストするメソッドを作成していきます。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.251s

ファジングテストの追加

先ほど単体テストを追加しました。ですが、開発者がこの単体テストに入力をひたすら追加しなければならないので、ヒューマンエラー等や量の問題で限界があります。ファジングテストを利用すると、開発者が考え出したテストケースが到達しなかったエッジケースを特定することがあります。

今回は先ほど作成した単体テストをファジングテストに変換して、多くの入力を生成するようにします。

まずはreverse_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関数の性質はいくつかあります。それは「文字列を二回反転すると元の値が保持される」、「反転された文字列はUTF-8の状態を保持する」という点です。上記ファジングテストはこの2点を確認しています。

ファジングテストの構文は単体テストとは異なります。以下の点に注意しましょう。

  • 関数名はTestXxxではなくFazzXxxの様にFazzで始まる
  • 関数の引数は*testing.Tではなく*testing.F
  • t.Runのような期待値チェックは*testing.Tとテスト対象となる型を引数にした関数をf.Fuzz関数に渡して使用する
  • 入力はf.Addを使用してシードコーパスの入力とする

それでは再度go testを実行して確認しましょう。以下の様にテストが通ればファジングテストの追加は完了となります。

$ go test
PASS
ok      example/fuzz     0.238s

続いて、FuzzReverseをファジング付きでテストを実行します。ランダムに生成された文字列入力が失敗を引き起こすかどうかを確認してみましょう。go test -fuzz=Fuzzを実行して再度実行しましょう。

ここでは指定していませんが-fuzztimeフラグを使用することでファジングにかかる時間を制限することができます。ほかにもフラグはありますのでこちらのTesting flagsセクションを参照してください。

$ 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 22 workers
fuzz: elapsed: 0s, execs: 382 (7023/sec), new interesting: 2 (total: 5)
--- FAIL: FuzzReverse (0.07s)
    --- FAIL: FuzzReverse (0.00s)
        reverse_test.go:36: Reverse produced invalid UTF-8 string "\x88\xdf00000"

    Failing input written to testdata\fuzz\FuzzReverse\d4ec9bd884b46c47
    To re-run:
    go test -run=FuzzReverse/d4ec9bd884b46c47
FAIL
exit status 1
FAIL    example/fuzz     0.355s

ファジング中に失敗が発生し、その原因となった入力が修正後のテストコードで再発されないように、testdata/fuzz/FuzzReverseディレクトリにあるシードコーパスファイルに書き込まれます。中身を確認すると文字列は異なりますが、書式が同じデータを確認できます。

testdata\fuzz\FuzzReverse\d4ec9bd884b46c47
go test fuzz v1
string("00000߈")

このファイルの一行目には『エンコーディングのバージョン』が、二行目以降には『コーパス・エントリを校正する型と値』を表します。ファズ・ターゲットは一つの入力しか受け取らないため、バージョンのあとには一つの値しかありません。

失敗したシード・コーパスのエントリーが使用されることを確認するため、-fuzzフラグを外して再度go testを実行してみましょう。

$ go test
--- FAIL: FuzzReverse (0.00s)
    --- FAIL: FuzzReverse/d4ec9bd884b46c47 (0.00s)
        reverse_test.go:36: Reverse produced invalid UTF-8 string "\x88\xdf00000"
FAIL
exit status 1
FAIL    example/fuzz     0.212s

無効な文字列エラーを修正

失敗を確認できました。先ほどバグを見つけたのでこのままバグを修正していきましょう。

まずはどんなエラーかをチェックしていきます。筆者はVSCodeを使用しているため、こちらの方法でデバッグするようにDelveをインストールしました。

まずはエラーが発生しているutf8.ValidStringのドキュメントを確認してみましょう。「ValidStringは、sがすべて有効なUTF-8エンコードされたルーン文字で構成されているかどうかを報告する。」とあります。

現在のReverse関数は文字列をバイト単位で反転させています。バイト単位で変換させると、元のUTF-8エンコードされたルーン(文字)が維持されません。反転してもUTF-8維持させるためには、ルーンごとに文字列を反転させる必要があります。

入力を反転させたときに反転した文字列のルーン数を調べることで、なぜReverseが無効な文字列を生成するのかを知ることができます。

ファジング・ターゲットのコードを以下のように変更して、go testでテストを実行しましょう。t.Log関数の実行を追加しています。

ログの追加

reverse_test.go
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)
    }
})
$ go test
--- FAIL: FuzzReverse (0.01s)
    --- FAIL: FuzzReverse/d4ec9bd884b46c47 (0.00s)
        reverse_test.go:32: Number of runes: orig=6, rev=7, doubleRev=6
        reverse_test.go:37: Reverse produced invalid UTF-8 string "\x88\xdf00000"
FAIL
exit status 1
FAIL    example/fuzz     0.436s

このt.Log関数はエラーが発生した場合、コマンドに-vを付けて実行した場合コマンドラインに実行されます。

シード・コーパス全体ではすべての文字が1バイトの文字列が使われていました。しかし今回の問題の様に複数バイトを必要とすることがあります。そのため、文字列をバイト単位で反転させると、複数バイトの文字が無効になってしまいます。

バイト単位ではなくルーン単位で文字列を処理するようにReverse関数を以下のように修正してみましょう。

修正

main.go
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)
}

b := []byte(s)からr := []rune(s)に変更して、文字列からバイトのスライスを宣言するのではなく、ルーンのスライスを宣言するように変更します。

変更したらgo testでテストを実行しましょう。

$ go test
PASS
ok      example/fuzz     0.405s

見つけたバグは修正できました。続いてgo test -fuzz=Fuzzで新しいバグがないかを探します。何度か実行して新しいバグがないかを探してみましょう。

$ go test -fuzz=Fuzz                                    
fuzz: elapsed: 0s, gathering baseline coverage: 0/6 completed
fuzz: minimizing 31-byte failing input file
fuzz: elapsed: 0s, gathering baseline coverage: 4/6 completed
--- FAIL: FuzzReverse (0.15s)
    --- FAIL: FuzzReverse (0.00s)
        reverse_test.go:32: Number of runes: orig=1, rev=1, doubleRev=1
        reverse_test.go:34: Before: "\xe0", after: "�"

    Failing input written to testdata\fuzz\FuzzReverse\828d5059791d0353
    To re-run:
    go test -run=FuzzReverse/828d5059791d0353
FAIL
exit status 1
FAIL    github.com/Yamase7/go-tutorial/fuzz     0.543s

新しくエラーが発生しました。続けて修正していきましょう。

再反転処理のエラーを修正

ログの追加

今回はデバッガを使用しながら修正していきます。反転した文字列をよく見てみましょう。GoではstringにはUTF-8ではないバイトを含めることもできます。今回は元の文字列が\xe0という1バイトのスライスになりました。入力文字列を[]runeに設定すると、GoはバイトスライスをUTF-8にエンコードして、そのバイトをUTF-8に置き換えます。置き換えた文字を入力のバイトスライスを比較すると異なることが分かります。

以下の様にログを追加してみましょう。

main.go
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)
}

コードを追加したらテストを実行します。今回はログを確認するだけなので失敗したテストだけを実行するだけで構いません。特定のコーパスエントリーを実行するには-runでファイル名を指定することで、指定したものが実行されます。

以下の様にパスを指定して失敗したテストだけ再実行してみましょう。

$ go test -run=FuzzReverse/828d5059791d0353             
input: "\xe0"
runes: ['�']
input: "�"
runes: ['�']
--- FAIL: FuzzReverse (0.00s)
    --- FAIL: FuzzReverse/828d5059791d0353 (0.00s)
        reverse_test.go:32: Number of runes: orig=1, rev=1, doubleRev=1
        reverse_test.go:34: Before: "\xe0", after: "�"
FAIL
exit status 1
FAIL    github.com/Yamase7/go-tutorial/fuzz     0.405s

ログを確認することができました。問題が確認出来たらそのまま修正していきます。

修正

入力する文字列にUTF-8ではない文字列が含まれる場合はエラーを返すように、エラー処理を追加しましょう。

その際使用するパッケージが増えるのでimport文で対象のパッケージを追加します。また、エラーを返すように戻り値も増やしたので、main関数にも変更を加えています。

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
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)
        }
    })
}

コードの変更が終わったら実行確認をしてみましょう。まずはgo testで修正できたかを確認していきます。

$ go test
PASS
ok      github.com/Yamase7/go-tutorial/fuzz     0.410s

続いて新しくエラーが発生しないかをgo test -fazz=Fazz確認していきます。ファズ・テストは失敗する入力が見つかるまで繰り返されます。デフォルトでは失敗しなければ永遠に実行されてしまうので、数秒経過したらctrl+Cで処理を中断しましょう。

$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/6 completed
fuzz: elapsed: 0s, gathering baseline coverage: 6/6 completed, now fuzzing with 22 workers
fuzz: elapsed: 3s, execs: 917816 (304872/sec), new interesting: 34 (total: 40)
fuzz: elapsed: 6s, execs: 2181917 (422455/sec), new interesting: 36 (total: 42)
fuzz: elapsed: 9s, execs: 3762398 (526440/sec), new interesting: 36 (total: 42)
fuzz: elapsed: 10s, execs: 4161332 (489941/sec), new interesting: 36 (total: 42)
PASS
ok      github.com/Yamase7/go-tutorial/fuzz     10.324s

失敗されずに続いたので中断することができました。-fuzztimeを使用すると指定時間経過後、自動的に停止させることも可能です。go test -fuzz=Fuzz -fuzztime 30sを実行して30秒待機してみましょう。

$ go test -fuzz=Fuzz -fuzztime 30s
fuzz: elapsed: 0s, gathering baseline coverage: 0/42 completed
fuzz: elapsed: 0s, gathering baseline coverage: 42/42 completed, now fuzzing with 22 workers
fuzz: elapsed: 3s, execs: 1231558 (409251/sec), new interesting: 3 (total: 45)
fuzz: elapsed: 6s, execs: 2226952 (332333/sec), new interesting: 3 (total: 45)
fuzz: elapsed: 9s, execs: 3642961 (470944/sec), new interesting: 4 (total: 46)
fuzz: elapsed: 12s, execs: 5192335 (518124/sec), new interesting: 4 (total: 46)
fuzz: elapsed: 15s, execs: 6726389 (511227/sec), new interesting: 4 (total: 46)
fuzz: elapsed: 18s, execs: 8183006 (485361/sec), new interesting: 4 (total: 46)
fuzz: elapsed: 21s, execs: 9650622 (488543/sec), new interesting: 4 (total: 46)
fuzz: elapsed: 24s, execs: 11111394 (488066/sec), new interesting: 4 (total: 46)
fuzz: elapsed: 27s, execs: 12557149 (480219/sec), new interesting: 4 (total: 46)
fuzz: elapsed: 30s, execs: 14042048 (496737/sec), new interesting: 4 (total: 46)
fuzz: elapsed: 30s, execs: 14042048 (0/sec), new interesting: 4 (total: 46)
PASS
ok      github.com/Yamase7/go-tutorial/fuzz     30.683s

自動的に止まったことを確認できたでしょうか。ほかにも様々なフラグが用意されているので、詳細はドキュメントで確認してください。

ファジング・テストに関する用語はこちらのドキュメントを確認しましょう。今回発生したnew interestingはカバレッジの範囲が広がったことを表します。基本的には最初に広がり、収束していくような傾向がみられます。

まとめ

  • ファジング・テストとはランダムな入力を自動的に生成・入力するテスト。
  • 想定外のエッジケースのバグを見つけ出すのが目的で利用されることが多い。
  • テストの書き方。
    • 関数名はTestXxxではなくFazzXxxの様にFazzで始まる。
    • 関数の引数は*testing.Tではなく*testing.F
    • t.Runのような期待値チェックは*testing.Tとテスト対象となる型を引数にした関数をf.Fuzz関数に渡して使用する。
    • 入力はf.Addを使用してシードコーパスの入力とする。
  • ファジング・テストで見つかったエラーが出るシードコーパスはtestdata/fuzz/FuzzReverseのシードコーパスファイルに出力される。
  • 基本的にはエラーが出るまで継続されるのでテスト時間を設定する。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です