フューチャー技術ブログ

Go1.18から追加されたFuzzingとは

この記事はGo1.18連載の3記事目です。

伊藤真彦です、最近CSIGのVulsチームで働くようになりました。

crypto/elliptic (CVE-2022-23806)、math/big (CVE-2022-23772)、cmd/go (CVE-2022-23773)へのセキュリティFixを含むGo 1.17.7と1.16.14が先日リリースされました。急にセキュリティエンジニアっぽい事を言うようになるのは恐縮ですが忘れずアップデートしていきましょう。

脇道に逸れましたがこの記事ではFuzzingについて紹介します。

Go1.18から追加されたFuzzingとは

Go1.18からFuzzingという機能が追加されます、Genericsのインパクトが大きいですが、Go1.18ではこういった大きな変更も加わっています。

機能追加に伴いランディングページが作成されました、リリースノートfuzzing landing pageと紹介されています。今後もドキュメントと呼ぶよりランディングページと呼ぶ方が伝わりやすいかもしれません。

Fuzzingとはテスト手法の一つで、プログラムの機能が想定していない入力を与える事でバグを発見するアプローチの事を指します。想定していない入力とは具体的にはランダムな値を乱数を基に生成する事で生み出した値ですが、ランダムな値であること自体はFuzzingの本質ではありません。テスト入力に人力で用意したデータを追加する事もあります。ともかくテストケースの不足や実装の考慮漏れを炙り出すための工夫ということですね。

なおFuzzingはGo独自の概念ではありません。

Fuzzingの使い方

ランディングページに説明がありますが、端的に説明すると下記の通りです。

  1. Fuzzing機能を利用するテストを記載する
  2. -fuzzオプションをつけてテストを実行する

それほど難しいものではありません。

Fuzzingの書き方

テストの書き方もランディングページにある画像を見ればすぐに理解できると思います。

テストの書き方

func FuzzFoo(f *testing.F)のようにFuzzから始まり、Go1.18から新しく追加された*testing.Fを引数に取るテストコードを記載します。

f.Fuzzの中にテストの内容を記載します、テストコードのFuzzing argumentsはランダム生成された値になります。
下記の型のデータであれば任意の個数用意する事が可能です。

  • string, []byte
  • int, int8, int16, int32/rune, int64
  • uint, uint8/byte, uint16, uint32, uint64
  • float32, float64
  • bool

任意の値の組み合わせを必ず実行したい、という場合はf.Addで入力を指定できます。

Fuzzingの実行方法

テストコードが用意出来たら-fuzzオプションをつけてテストを実行します。

go test -fuzz=FuzzTestName

テスト名を指定しなくても実行する事は可能です。

go test -fuzz .

-fuzztimeのデフォルト値は無期限です、設定しないとCtrl + Cで中断するまで永遠にテストが続きますのでご注意ください。

また、下記のような内容がリリースノートに記載されています。

ファジングは大量のメモリを消費する可能性があり、実行中のマシンのパフォーマンスに影響を与える可能性があることに注意してください。現在、ファズキャッシュに書き込まれる可能性のあるファイルの数または合計バイト数に制限はないため、ファズキャッシュは大量のストレージ(場合によっては数GB)を占有する可能性があります。

実際に簡単なFuzzingテストコードを実行したところ一瞬でCPU使用率が100%になりました。
CPU利用率が高い様子

メモリ使用量も2GBほど増えました、実用上差し支える事例が出た場合は今後のアップデートで調整が入る、またはオプション項目が増えるかもしれません。

Fuzzingはどこで使うと良いのか

ランダムな値でテストを実行できることはわかったけど実際どのように使うと良いのだろうか、と疑問に思う方もいるかもしれません。

困ったら標準ライブラリを見ると良い、という事で執筆時点でのGoのmasterブランチを覗いてみました。

下記のパッケージでFuzzingが採用されていました。

archive/tar
archive/zip
compress/gzip
crypto/elliptic
encoding/json
image/gif
image/jpeg
image/png
net/netip
runtime/mgcpacer_test
runtime/debug_test
testing

主にエンコード、デコード、パースといった機能に相当する部分が多い印象です。

net/netipはそれ自体がGo1.18の新機能ですね、f.Addを有効活用する事例やFuzzingにおける高度な検証の手法としてとても参考になりますね。

参考までにお世話になる事が多いであろうencoding/jsonのテストコードを見てみましょう。

fuzz_test.go
func FuzzUnmarshalJSON(f *testing.F) {
f.Add([]byte(`{
"object": {
"slice": [
1,
2.0,
"3",
[4],
{5: {}}
]
},
"slice": [[]],
"string": ":)",
"int": 1e5,
"float": 3e-9"
}`))

f.Fuzz(func(t *testing.T, b []byte) {
for _, typ := range []func() interface{}{
func() interface{} { return new(interface{}) },
func() interface{} { return new(map[string]interface{}) },
func() interface{} { return new([]interface{}) },
} {
i := typ()
if err := Unmarshal(b, i); err != nil {
return
}

encoded, err := Marshal(i)
if err != nil {
t.Fatalf("failed to marshal: %s", err)
}

if err := Unmarshal(encoded, i); err != nil {
t.Fatalf("failed to roundtrip: %s", err)
}
}
})
}
  1. そもそもデコード(Unmarshal)できない入力は無視する
  2. デコードできた場合、それを正常にエンコード(Marshal)できる
  3. エンコードできた場合、それをまたデコードできる

というroudtripな性質をテストしているようです。このような考え方は様々な処理のテストで応用できそうですね。

runtime、testingのように既存のテストファイルにFuzzingのコードを書いても動作しますが、標準パッケージにおいてはfuzz_test.goというファイルを用意する形を取っているようです。

標準パッケージの流儀に必ず従う必要はありませんが是非参考にしてみてください。

まとめ

  • Go1.18からFuzzingが追加された
  • リリースに伴いランディングページが用意された
  • テストコードを追加し-fuzzオプション付きでテストするだけでFuzzingを利用できる
  • 標準パッケージではfuzz_test.goに切り分けているものが多い

どれほど真剣に取り組んでもバグは尽きぬものです、新機能を有効活用して安全に動くアプリケーションを目指していきたいですね。