Goにおけるファジング
原文は Ben Hoyt により に公開されました。 このブログを購読する
ファジングは、ユーザー入力を受け付けるコードにおける問題のあるエッジケースやセキュリティ上の問題を見つけるために、ランダム化された入力を用いるテスト手法である。Goのパッケージ開発者は、Dmitry Vyukov氏による人気のgo-fuzzツールを自身のコードのファズテストに利用できる。このツールはGoの標準ライブラリだけでなくサードパーティ製パッケージにおいても何百件もの難解なバグを発見してきた。しかし、このツールは標準で組み込まれているわけではなく、使い勝手も本来あり得るほど単純ではない。この問題に対処するため、GoチームのメンバーであるKatie Hockman氏が最近、標準のgo testコマンドの第一級機能としてファズテストを追加することを提案するドラフトデザインを公開した。
ランダムなテスト入力を使ってバグを見つける手法の歴史は、パンチカードの時代にまで遡る。著者であり長年のプログラマーでもあるGerald Weinberg氏は次のように回想している。
1950年代当時はファジングとは呼んでいなかったが、ゴミ箱から拾ってきたパンチカードの束を入力してプログラムをテストするのは我々の標準的な手法だった。乱数のパンチカードの束も使っていた。当時はネットワークにつながっていなかったのでセキュリティをそれほど心配する必要はなかったが、ランダムやゴミ箱由来のカード束によって好ましくない挙動がしばしば明らかになった。
より近年では、american fuzzy lop(AFL)やVyukov氏がGoで開発したsyzkallerといったツールを用いて、BashやlibjpegからLinuxカーネルに至るまで、無数のバグや注目すべきセキュリティ上の問題がファズテストによって発見されてきた。
ファズテストの基本的な考え方は、関数に対してランダムな入力を生成し、クラッシュしたり関数のAPIに含まれない例外を発生させたりしないかを確認することにある。しかし、単純な方法でランダムな入力を生成するのでは極めて時間がかかり、エッジケースを効率的に見つけることもできない。そのため、最新のファジングツールの多くは「カバレッジガイド型ファジング」を用いてテストを駆動し、新たに生成された入力が新しいコードパスを実行しているかどうかを判断している。Vyukov氏が共同執筆した提案書には、この手法がどのように動作するかが簡潔に説明されている。
start with some (potentially empty) corpus of inputs
for {
choose a random input from the corpus
mutate the input
execute the mutated input and collect code coverage
if the input gives new coverage, add it to the corpus
}コードカバレッジのデータを収集し、ある入力が「新たなカバレッジをもたらした」ことを検出するのは容易ではない。コードにカバレッジ記録用の特別な呼び出しを埋め込む(インストルメントする)ためのツールが必要になる。インストルメントされたコードが実行されると、ファジングフレームワークは以前のテスト入力によるカバレッジと新しい入力によるカバレッジを比較し、異なるコードブロックが実行されていれば、その新しい入力をコーパスに追加する。もちろんこれは多くの詳細を省略した説明であり、入力をどのようにミューテートするのか、カバレッジ計測の仕組みが具体的にどうなっているのかなどは省かれている。しかし、この基本的な手法は有効である。AFLは多くのCやC++プログラムでこの手法を採用しており、そのウェブページには発見・修正された膨大な数のバグを列挙したセクションがある。
go-fuzzツール
AFLは優れたツールだが、C、C++、あるいはObjective-Cで書かれ、GCCやClangでコンパイルする必要があるプログラムにしか対応していない。Vyukov氏のgo-fuzzツールはAFLと同様に動作するが、Go専用に作られている。Goプログラムにカバレッジ記録を追加するには、開発者はまず(go buildの代わりに)go-fuzz-buildコマンドを実行する。このコマンドは組み込みのastパッケージを使ってソースコードの各ブロックにインストルメンテーションを追加し、その結果を通常のGoコンパイラに通す。インストルメントされたバイナリがビルドされると、go-fuzzコマンドが複数のCPUコア上でランダムに変異させた入力を与えながら何度も実行し、その過程でクラッシュ(スタックトレースやクラッシュを引き起こした入力とともに)を記録していく。
Damian Gryski氏はgo-fuzzツールの使い方をより詳しく解説するチュートリアルを執筆している。前述のとおり、go-fuzzのREADMEには同ツールが発見した多数のバグが列挙されているが、そこに記載されていないサードパーティ製パッケージにも、ほぼ確実にさらに多くのバグが存在する。筆者自身もGoAWKでgo-fuzzを使用したところ、いくつかの「クラッシャー」が見つかった。
第一級機能への道のり
Goには、プロジェクトのテスト(および、必要に応じてベンチマーク)を自動的に検出して実行する組み込みコマンドgo testがある。ファジングもテストの一種だが、組み込みのツールサポートがなければセットアップはやや面倒である。2017年2月にさかのぼると、Vyukov氏とKonstantin Serebryany氏を代表して、GoのGitHubリポジトリにissueが登録され、goツールが「今日のテストやベンチマーク、レース検出と同様に、ファジングをネイティブにサポートする」ことが提案された。このissueでは「go-fuzzは存在するが、テストやベンチマークを書いてgo test -raceを実行するほど簡単ではない」と指摘されている。このissueは膨大な支持と多くのコメントを集めた。
その後、Vyukov氏らは、この統合がどのようなものになるかを示す動機付け文書とAPIおよびツールの提案書を追加した。GoのテックリードであるRuss Cox氏は、「新しいgo testのファズモードに求めるものを正確に」示すプロトタイプを求めた。2019年1月、「thepudds」氏がまさにそれを公開した。元の提案の大部分を別のツールとして実装したfzgoというツールである。当時は好評を博したが、正式なものにはならなかったようだ。
しかし最近になって、Goチームがこのアイデアを再び取り上げ、Hockman氏が第一級ファジングのための新たなドラフトデザインを執筆した。目標は同様に、標準のgo testツールでファズテストを簡単に実行できるようにすることだが、初期コーパスをプログラム的にシードできるようにし、バイト文字列(Goでいう「バイトのスライス」すなわち[]byte)以外の入力型もサポートするために、提案されているAPIはやや複雑になっている。
現在、開発者は*_test.goというソースファイルにTestFoo(t *testing.T)というシグネチャを持つテスト関数を書くことができ、go testがそれらの関数をユニットテストとして自動的に実行する。既存のtesting.T型は、テストを制御し失敗を記録するためにテスト関数に渡される。新しいドラフトデザインでは、同様の方法でFuzzFoo(f *testing.F)というファズテストを書き、go test -fuzzのような単純なコマンドで実行できる機能が追加される。提案されているtesting.F型は、シードコーパスに入力を追加したり、ファズテスト自体を実装したりする(入れ子になった無名関数を用いる)ために使われる。以下は、電卓ライブラリのcalc_test.goに含まれる可能性のある例である。
func FuzzEval(f *testing.F) {
// Seed the initial corpus
f.Add("1+2")
f.Add("1+2*3")
f.Add("(1+2)*3")
// Run the fuzz test
f.Fuzz(func(t *testing.T, expr string) {
t.Parallel() // allow parallel execution
_, _ = Eval(expr) // function under test (discard result and error)
})
}このわずか数行のコードだけで、電卓ライブラリのEval()関数をランダム化された入力で実行し、クラッシュ(Goの用語では「パニック」)を記録する基本的なファズテストが構成される。パニックの例としては、配列の範囲外アクセス、nilポインタのデリファレンス、ゼロ除算などが挙げられる。より手の込んだファズテストでは、結果を別のライブラリ(この例ではcalclibと呼ぶ)と比較することもある。
...
// Run the fuzz test
f.Fuzz(func(t *testing.T, expr string) {
t.Parallel()
r1, err := Eval(expr)
if err != nil {
t.Skip() // got parse error, skip rest of test
}
// Compare result against calclib
r2, err := calclib.Eval(expr)
if err != nil {
t.Errorf("Eval succeeded but calclib had error: %v", err)
}
if r1 != r2 {
t.Errorf("Eval got %d, calclib got %d", r1, r2)
}
})
}ファジング関数や新しいtesting.F型の説明に加え、Hockman氏のドラフトデザインでは、「コンパイラによるインストルメンテーションを用いてカバレッジ情報を把握し、ミューテーターでテスト引数を生成し、コーパスを維持する役割を担う」新たなカバレッジガイド型ファジングエンジンを構築することも提案されている。Hockman氏は、これが新しい実装になることを明言しているが、既存の成果(go-fuzzやfzgo)に大きく依拠することになる。ミューテーターは既存の入力から新たなランダム化された入力(「生成されたコーパス」)を生成し、組み込み型や組み込み型から構成される構造体に対しては自動的に機能する。その他の型についても、既存のBinaryUnmarshalerやTextUnmarshalerインターフェースを実装していればサポートされる。
デフォルトでは、エンジンはファズテストを無期限に実行し、最初のクラッシュが見つかった時点で特定のテスト実行を停止する。ユーザーは、コマンドラインの-fuzztimeフラグで一定時間だけ実行するように指示したり(継続的インテグレーションのスクリプトでの利用を想定)、-keepfuzzingフラグでクラッシュ後も実行を継続するように指示したりできる。クラッシュレポートはtestdataディレクトリ内のファイルに書き込まれ、クラッシュを引き起こした入力とともにエラーメッセージやスタックトレースが含まれる。
議論と今後の展望
ファイルシステムとファイル埋め込みに関する最近のドラフトデザインと同様に、このデザインに関する公式な議論はRedditのスレッドで行われ、全体としてフィードバックは好意的だった。
testing.Fインターフェースについても議論があった。David Crawshaw氏は、testing.Tや(ベンチマークに用いられる)testing.Bとの一貫性を保つため、既存のtesting.TBインターフェースを実装すべきだと提案した。Hockman氏はこれに同意し、デザインをそれに合わせて更新した。「etherealflaim」氏の提案に基づき、Hockman氏はトップレベルとファズ関数の両方でtesting.Fを再利用しないようにデザインを更新した。コマンドの綴りをgo test -fuzzにすべきかgo fuzzにすべきかという些末な議論もあった。etherealflaim氏は、go testの再利用は「歴史があり、多くの人がタイムアウトなどを設定している」ため得策ではないと提案した。
Jeremy Bowers氏は、ミューテーションエンジンをプラガブルにすべきだと推奨した。
ファズエンジンはプラガブルである必要があると思う。もちろんデフォルトのものは同梱すればよいし、プラガビリティは「バージョン2」に回しても構わないが、計画には入れておくべきだ。ファジングはほとんどの場合に通用するやり方ではあるが、より専門的なものが必要になる場面は必ず出てくる。
しかしHockman氏は、この機能を追加するのにプラガビリティは必須ではなく、設計段階の後半で「後で検討されるかもしれない」と返答した。
ドラフトデザインは冒頭で「このドラフトデザインを回覧する目的は、最終的に意図される提案を形作るためのフィードバックを集めることにある」と述べており、正確な次のステップが何で、いつ行われるのかを断定するのは難しい。しかし、Goチームから公式に力が注がれているのは良いことだ。Vyukov氏の当初の提案に対するCox氏のフィードバックに基づけば、更新された提案のプロトタイプがブランチ上で開発されるか、fzgoのように開発者が実行できる別のツールとして開発されるのではないかと筆者は推測している。
Redditのスレッドでの議論は現在も続いており、これほど大規模な機能について正式な提案と実装が、2020年11月に訪れるGo 1.16のリリースフリーズまでに間に合う可能性は低い。2021年8月にリリース予定のGo 1.17での採用の方が可能性は高いだろう。
記事をランダムに読む
コメント
ログインしてコメントする