ファジングとカバレッジ誘導テスト
ランダムな入力を投げるだけでは深いバグは出ない。カバレッジを報酬に変異を進化させる仕組みで、AFL や libFuzzer は人手では届かない経路までクラッシュを自動で掘り当てます。
- 1.ファジングは入力を変異させてプログラムに大量投入し、クラッシュやサニタイザ検出などの異常を探す手法。網羅ではなく確率的探索で、メモリ破壊や未処理例外を機械的にあぶり出す。
- 2.純粋なランダム生成は深いパスに到達しにくい。AFL や libFuzzer はコンパイル時に挿入した分岐計測でカバレッジを観測し、新しいエッジを開拓した入力だけをコーパスに残す遺伝的アルゴリズム的ループで探索を誘導する。
- 3.AddressSanitizer 等で潜在バグを観測可能なクラッシュに変換し、コーパス最小化とシードで再現性を担保する。継続的に回す継続的ファジング(OSS-Fuzz など)が前提。
ファジングとは何を狙う手法か
**ファジング(fuzzing)**は、半ばランダムに作った入力をプログラムへ大量に流し込み、クラッシュ・ハング・サニタイザ検出といった異常を観測する自動テスト手法です。テストの基礎で扱う単体テストが「想定した入力に正しい出力が返るか」を確かめるのに対し、ファジングは「想定外の入力で壊れないか」を確かめます。期待出力(オラクル)を人が用意できなくても、「異常終了しないこと」自体を弱いオラクルとして使えるのが強みです。
狙うのは主に堅牢性とメモリ安全性です。C/C++ ならバッファオーバーフロー、use-after-free、整数オーバーフロー由来の破壊。管理言語でも未処理例外、無限ループ、過大なメモリ確保(資源枯渇)が標的になります。パーサ・デコーダ・プロトコル実装・正規表現エンジンなど、外部からの非信頼入力を直接受ける境界が最も費用対効果が高い適用先です。
プロパティベーステストも乱数で入力を作りますが、こちらは「往復性」「不変条件」など意味的な性質を検査し、型に沿った構造化入力を生成します。ファジングは典型的に生のバイト列を変異させ、検査対象は「クラッシュしないか」という最小限のオラクルに寄ります。両者は連続的で、構造化ファジングは両者の中間に位置づけられます。
ブラックボックス・ファジングの限界
最も素朴なブラックボックス・ファジングは、対象を中身の見えない箱として扱い、ランダムなバイト列や既知入力の乱変異を投げ続けます。実装は簡単ですが、深いコードパスに到達できないという根本問題があります。
たとえば先頭4バイトが特定のマジックナンバー(50 4B 03 04 など)でなければ即座にエラー返却するパーサを考えます。ランダムな4バイトがその値に一致する確率はおよそ 1 / 2^32 で、その先のロジックには事実上到達できません。深くネストした分岐や、特定の長さ・チェックサムを要求する経路は、確率的にますます遠ざかります。入力空間が広大なのに、関心のある領域は薄く偏在している。当てずっぽうでは探索が表層で頭打ちになるわけです。
カバレッジ誘導という発想
この壁を破ったのが**カバレッジ誘導ファジング(coverage-guided fuzzing)です。鍵は、実行中にどのコードを通ったかを観測し、それを探索の報酬信号(フィードバック)**として使うことにあります。
ブラックボックス: 入力 → 実行 → クラッシュ?(通った経路は見ない)
カバレッジ誘導: 入力 → 実行 → どのエッジを通ったか観測
→ 新しいエッジを開いた入力だけ「価値あり」と判定し保存
ここでの被覆単位は行ではなく**エッジ(基本ブロック間の遷移)**です。A→B と A→C を別個に数えることで、分岐の真偽どちらに進んだかを区別できます。実装はコンパイル時の計装(instrumentation)で行います。AFL なら afl-cc、libFuzzer なら Clang の -fsanitize-coverage=trace-pc-guard(SanitizerCoverage)が、各エッジに「ここを通った」と記録する小コードを挿入します。AFL は古典的に 64KiB の共有メモリをビットマップとして使い、エッジIDをハッシュした位置のカウンタを更新します。
固定サイズのビットマップにエッジをハッシュで詰めるため、別々のエッジが同じ位置に当たるハッシュ衝突が起こり、被覆を取りこぼすことがあります。またヒット回数は厳密値ではなく 1,2,3,4-7,8-15,... のような対数的バケットに丸められます。ループ回数の微差をすべて「新規」と扱うと、コーパスが無意味に膨張するのを避ける設計です。大規模プログラムでは衝突低減のためビットマップ拡大やリンク時の正確なエッジ採番が用いられます。
遺伝的アルゴリズムとしての探索ループ
カバレッジ誘導ファザーの中核は、遺伝的アルゴリズム(GA)に酷似した進化ループです。GA の用語に対応づけると構造がはっきりします。
| 遺伝的アルゴリズムの概念 | ファザーでの対応 | 役割 |
|---|---|---|
| 個体(遺伝子) | コーパス内の1入力(バイト列) | 探索の単位 |
| 集団(population) | コーパス(保存済み入力の集合) | 現在の探索拠点 |
| 適応度(fitness) | 新規エッジを開いたか | 残すかどうかの判定 |
| 突然変異(mutation) | ビット反転・バイト挿入・算術加減 | 近傍の探索 |
| 交叉(crossover) | 2入力の断片を継ぎ合わせる(splice) | 発見の組み換え |
ループはこう回ります。コーパスから入力を1つ選び、変異を加えて実行し、それまで未踏のエッジを1つでも開けばその変異後入力をコーパスへ追加する。開かなければ捨てる。これを延々と繰り返します。
seed をコーパスへ投入
loop:
input = コーパスから選択(新規発見が多い入力を優先しがち)
mutant = mutate(input) # ビット反転・splice 等
cov = run_and_observe(mutant) # 通ったエッジ集合を計装で取得
if cov に新規エッジがある:
コーパスへ mutant を追加 # = 適応度が高い個体を残す
if クラッシュ または サニタイザ検出:
crash として保存(シード付き)
この「新規エッジを開いた入力だけ生き残る」選択圧が決定的です。マジックナンバーの例なら、変異の過程で4バイトのうち一部が偶然一致してわずかに先へ進んだ入力がコーパスに残り、それを足場にさらに変異が進む。部分的な進捗が保存・蓄積されるため、ブラックボックスでは天文学的だった深部到達が、エッジを階段状に登る現実的な確率に変わります。これは乱択アルゴリズムが乱数を使いつつ構造的なフィードバックで収束を早めるのと同じ発想です。
比較命令の「あと一歩」を埋めるため、近代のファザーは追加の計装を持ちます。-fsanitize-coverage=trace-cmp(libFuzzer の value profile)や AFL++ の CmpLog/RedQueen は、x == 0xDEADBEEF のような比較で実際に突き合わされた定数を捕捉し、その値を入力中の該当位置へ直接書き込みます。これにより総当たりが必要だった等値条件を、数回の試行で通せるようになります。
観測可能性とサニタイザ
ファジングの効果は「潜在的な誤りをどれだけ目に見えるクラッシュへ変換できるか」に強く依存します。配列の境界をわずかに越えるだけのバグは、たまたま隣接メモリを踏むだけで素のままでは何も起きず、ファザーは異常を検知できません。
そこで AddressSanitizer(ASan) などのサニタイザを併用します。ASan は確保領域の周囲にレッドゾーンを置き、解放済み領域を隔離して、不正アクセスの瞬間に確定的にプロセスを停止させます。仮想メモリ上のシャドウメモリで各バイトのアクセス可否を追跡する仕組みです。同様に UBSan(未定義動作)、MSan(未初期化読み出し)、TSan(データ競合)が、それぞれ無症状のバグを観測可能な失敗に変えます。サニタイザなしのファジングは、地雷の上を歩いても爆発しない状態に近いと考えてよいでしょう。
再現性・最小化・運用
クラッシュを見つけたら終わりではなく、再現と原因特定が要ります。ファザーはクラッシュを誘発した入力をファイルとして保存するので、それを単体で再投入すれば再現できます。乱数を使う以上シード管理が重要で、同じシードからは同じ変異列が再生されます。
巨大で雑然としたクラッシュ入力は、そのままではデバッグしづらい。そこで**最小化(minimization)**を行います。afl-tmin や libFuzzer の -minimize_crash は、クラッシュを保ったまま入力からバイトを削り、より小さく単純な再現入力へ縮約します。これはデバッグで触れる delta-debugging と同じ差分最小化の発想です。コーパス側も afl-cmin や -merge で、被覆を落とさず冗長な入力を間引いて軽くします。
ブラックボックスは深部到達が確率的に困難。カバレッジ誘導はコンパイル時計装でエッジ被覆を観測し、新規エッジを開いた入力だけ保存する遺伝的アルゴリズム的ループで探索を誘導する。被覆単位はエッジ、AFL は共有メモリのビットマップ+ヒット数の対数バケット化。サニタイザでバグを観測可能化し、最小化とシードで再現性を担保。これらを継続実行する継続的ファジングが現実の運用形態。
まとめ
ファジングは、変異させた入力を大量に投げてクラッシュやサニタイザ検出を探す確率的テストです。素朴なブラックボックスは入力空間の広さに阻まれ表層で頭打ちになりますが、カバレッジ誘導がこれを覆します。コンパイル時の計装でエッジ被覆を観測し、新しいエッジを開いた入力だけをコーパスに残す——個体・適応度・突然変異・交叉という遺伝的アルゴリズムの枠組みそのもので、部分的な進捗を足場に深部へ階段状に到達します。AddressSanitizer などが無症状のバグを確定的クラッシュへ変換し、最小化とシードが再現性を支えます。AFL/AFL++ と libFuzzer はこの原理の代表実装であり、OSS-Fuzz のように継続的に回し続けることで、人手のテストでは届かない経路のバグを地道に掘り当て続けます。
プログラミング Article
ファジングとカバレッジ誘導テストを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
テスト
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
純粋なランダム生成は深いパスに到達しにくい。AFL や libFuzzer はコンパイル時に挿入した分岐計測でカバレッジを観測し、新しいエッジを開拓した入力だけをコーパスに残す遺伝的アルゴリズム的ループで探索を誘導する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「テスト / ファジング」に近いか確認する。
- 強みである「ファジングは入力を変異させてプログラムに大量投入し、クラッシュやサニタイザ検出などの異常を探す手法。網羅ではなく確率的探索で、メモリ破壊や未処理例外を機械的にあぶり出す。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。