CyberFix Note
セキュアコーディング

ファジング入門。脆弱性を出荷前に自分で見つけるテストの仕組みと始め方

対象の目安: 自分のコードの脆弱性を先に見つけたい開発者 / 実務

リク編集長 / セキュリティ全般・戦略
・ 約30分で読めます
ファジング入門。脆弱性を出荷前に自分で見つけるテストの仕組みと始め方

自分が書いたパーサやデコーダに、想定していない入力が来たらどうなるか。単体テストで書けるのは、開発者が思いついた入力だけです。思いつかなかった入力こそが後で脆弱性として報告されるという構図は、バッファオーバーフローの時代から変わっていません。

ファジング(fuzzing)は、この「思いつかなかった入力」を機械に作らせるテスト手法です。OWASPのコミュニティページは、ファジングを「予期しない入力、不正な入力、あるいは半ば不正な入力をプログラムへ自動的に与えることで、バグや脆弱性や想定外の挙動を特定することを狙ったソフトウェアテスト技法」と説明し、その利点として「テストの設計が極めて単純で、システムの挙動に対する先入観がない」ことを挙げています。人間のレビューが前提を共有してしまう場所こそ、機械のランダム性が効きます。

この記事は、自分のコードの欠陥を出荷前に見つけたい開発者に向けて、LLVMのlibFuzzerドキュメント、Go公式のFuzzingドキュメント、GoogleのOSS-Fuzzとgoogle/fuzzingのガイド、Clangのサニタイザドキュメント、NIST SP 800-218という一次情報をもとに、機構と始め方を整理します。ファジングを実行するのは自分が管理するコードとビルド環境に限ってください。他者が運用するサービスへ明示的な許可なく大量の異常入力を送ると、利用規約への違反やサービス障害を招き、法的な責任を問われるおそれがあります。第三者の環境を対象にする場合は、書面での許可と対象範囲の合意を先に取ってください。記述は執筆時点(2026年9月7日)に確認できた内容です。

ファジングが自動化しているのは入力の生成と選別

ファジングという言葉が指す範囲は広く、単に乱数バイト列を投げるだけの実装も含まれます。ただし現在の実務で成果を出しているのは、そこへ2つの仕掛けを足した形です。1つは、テスト対象を1本の関数へ切り出して繰り返し呼ぶこと。もう1つは、実行のたびにコード網羅率を計測し、それを手がかりに入力を変えていくことです。

前者を担うのがfuzz targetと呼ばれる関数です。google/fuzzingのガイドは「fuzz targetとは、バイト配列を受け取り、そのバイト列を使ってテスト対象のAPIで何か興味深いことをする関数」と定義し、libFuzzer由来の名前を持つものの、特定のファジングエンジンから独立していると説明しています。プロセスの起動をやり直さずに何万回も呼べるため、1秒あたりの実行回数が桁違いに増えます。同ガイドは「良いfuzz targetはCPU1コアあたり平均で毎秒1000回程度の実行になる。軽量な対象なら毎秒10000回以上」という目安を示し、「毎秒10回未満なら、たぶん何か間違っている」と書いています。

後者がカバレッジガイドです。LLVMのドキュメントはlibFuzzerを「in-process, coverage-guided, evolutionary fuzzing engine(プロセス内で動く、カバレッジ誘導型の、進化的ファジングエンジン)」と説明しています。進化的というのは、生成した入力のうち新しい分岐に到達したものを保存し、それを次の変異の親として使い続けるという意味です。

なお、fuzz targetの呼び出し方はエンジンごとに違います。libFuzzerやAtherisは同一プロセス内で呼び続けますが、AFL++は既定でforkserverを使い、persistent modeを選んだときにプロセス内実行へ切り替わります。プロセス内実行は速度を稼ぐための実行モデルの1つであって、カバレッジ誘導そのものの前提条件ではありません。

google/fuzzingの「What makes a good fuzz target」は、fuzz targetが満たすべき条件を列挙しています。ファジングエンジンは同一プロセス内で異なる入力を使って何度も実行する、どんな入力(空、巨大、不正)にも耐えなければならない、どんな入力でも exit()abort() してはならない(するならそれはバグ)、できる限り決定的でなければならない、速くなければならない、理想的にはグローバル状態を変更しない、そして「たいていの場合、対象は狭いほどよい。複数のデータ形式を解析できる対象なら、形式ごとに分割する」と書かれています。なお、このgoogle/fuzzingリポジトリは2025年12月27日にアーカイブされ、読み取り専用になりました。記載内容は現在も参照できますが、以後の更新は入らない前提で読んでください。

カバレッジガイド型が入力を育てる機構

動きを分解すると次の順になります。コンパイル時に各分岐へ計測用のコードが埋め込まれ、実行のたびにどのエッジを通ったかが記録されます。エンジンはコーパス(保存済み入力の集合)から1つ選び、ビット反転や部分の削除や別の入力との結合といった変異を加えて新しい入力を作り、fuzz targetへ渡します。実行後、これまで踏んだことのないエッジが記録されていれば、その入力を「興味深い」としてコーパスへ追加します。何も新しくなければ捨てます。

この選別があるため、深いところにある条件分岐へ段階的に到達できます。たとえばヘッダのマジックナンバーを1バイトずつ検査するパーサなら、一致が1バイト進むたびに新しい分岐へ到達するため、変異が正解へ向かって累積します。

ただし4バイトをまとめて比較する実装では、途中まで一致しても通る分岐が変わらないため、カバレッジだけでは手がかりが出ません。libFuzzerはこの場合に備えて比較命令のトレース(-fsanitize=fuzzer に既定で含まれる -fsanitize-coverage=trace-cmp)で比較対象の値を変異へ反映し、-use_value_profile=1 を付けると比較の一致度そのものを新しいカバレッジとして扱います。辞書でマジック値を直接与える手もあります。いずれにしても、乱数バイト列を投げるだけの手法との差はここです。

一方で、この機構が効かない場面もあります。google/fuzzingのガイドは、暗号化された入力を受け取るAPIを例に挙げ、良いシードコーパスがあってもカバレッジは出るが「入力をどう変異させても早い段階で壊れたものとして拒否される」ため、エンジンが新しい経路を発見できないと説明しています。チェックサムや暗号化や圧縮のように、1ビット変えると全体が無効になる入り口を持つ対象は、そのままでは探索が進みません。対処は後述します。

クラッシュ以外を捕まえるには判定条件が要る

ファジングが観測できるのは、実行が「失敗した」という事実だけです。何もしなくても失敗として数えられるのは、プロセスのクラッシュ、言語ランタイムのpanicや未捕捉例外、アサーション失敗、サニタイザの検知、タイムアウト、メモリ使用量の超過です。Go公式ドキュメントも失敗の条件として、panicの発生、t.Fail(t.Errort.Fatal を経由する場合を含む)の呼び出し、os.Exit やスタックオーバーフローのような回復不能なエラー、実行時間の超過(現状のタイムアウトは1回の実行あたり1秒)を挙げています。

裏を返せば、これらのどれにも当たらずに正常終了する欠陥は、判定条件をfuzz targetの中へ自分で書き足さない限り検知できません。「エンコードしてデコードすると元に戻る」のように期待される性質を書ければ、クラッシュしない不整合も失敗として扱えます(後述のプロパティ検査)。

一方で、期待値そのものがテスト側から自明でない欠陥は、この方法でも拾えません。認可の判定を1か所書き忘れた、権限のないユーザーに他人のデータを返した、といった論理的な欠陥は、何が正解かを外から与える必要があるため、アクセス制御のテストや脅威モデリングで扱う領域になります。

あわせて読みたい

脅威モデリング入門。STRIDEで設計段階に脅威を洗い出す

C/C++では、この制約がもっと厄介な形で現れます。配列の範囲外への1バイト書き込みは、多くの場合その場ではクラッシュせず、隣接する領域を静かに壊して処理を続けます。ファジングだけを回しても異常終了しないため、エンジンは「問題なし」と判断します。ここでサニタイザが要ります。AddressSanitizer(ASan)はコンパイル時にメモリアクセスへ検査を挿入し、ヒープやスタックやグローバル変数への範囲外アクセス、use-after-free、use-after-return、use-after-scope、二重解放と不正な解放を、発生した瞬間に検出して停止します。Clangのドキュメントは典型的な速度低下を2倍と記載しています。ただしuse-after-returnの検出は実行時に ASAN_OPTIONS=detect_stack_use_after_return=1 を指定する必要があり(Linuxでは既定で有効)、use-after-scopeはビルド時の -fsanitize-address-use-after-scope が要ります。UndefinedBehaviorSanitizer(UBSan)は「高速な未定義動作検出器」として、静的に境界が決まる配列添字の範囲外、データ型に対して範囲外のビットシフト、ミスアラインやNULLポインタの参照解決、符号付き整数のオーバーフローなどを検知します。

2つは役割が分かれており、UBSanはASanの代わりになりません。ヒープの範囲外アクセスやuse-after-freeのようなメモリ安全性の違反はASanが受け持ち、UBSanは規格上の未定義動作を受け持ちます。C/C++では、LLVMのドキュメントがlibFuzzerをASanやUBSan、あるいはその両方と組み合わせることを挙げ、OSS-Fuzzも既定のサニタイザとして addressundefined を挙げています。両方を有効にするか、サニタイザごとにビルドを分けるかは、対象に合わせて決めます。

サニタイザは、静かに壊れる欠陥を「クラッシュ」という観測可能な事象へ変換する装置です。ファジングとの併用が定石になっているのはこのためで、OSS-Fuzzのドキュメントも「libFuzzer、AFL++、Honggfuzz、Centipedeの各ファジングエンジンをサニタイザと組み合わせて提供している」と書いています。

あわせて読みたい

バッファオーバーフローの仕組みと対策。境界を越えた書き込みがコード実行に至る理由

Goの標準ツールチェーンで書く最初のfuzz target

始めやすさではGoが群を抜きます。Go公式のドキュメントは「Goは1.18から標準ツールチェーンでファジングをサポートしている」と記載しており、追加のツールをインストールせずに go test から実行できます。

題材として、パーセントエンコーディングを復号する短い関数を用意します。よくある書き方ですが、% が末尾に来た場合の境界検査がありません。

package urlesc

// Decode は %XX 形式のエスケープを含む文字列を復号します。
func Decode(s string) string {
	out := make([]byte, 0, len(s))
	for i := 0; i < len(s); i++ {
		if s[i] == '%' {
			out = append(out, unhex(s[i+1])<<4|unhex(s[i+2]))
			i += 2
			continue
		}
		out = append(out, s[i])
	}
	return string(out)
}

func unhex(c byte) byte {
	switch {
	case '0' <= c && c <= '9':
		return c - '0'
	case 'a' <= c && c <= 'f':
		return c - 'a' + 10
	case 'A' <= c && c <= 'F':
		return c - 'A' + 10
	}
	return 0
}

fuzz testはこれだけです。FuzzXxx という名前で *testing.F だけを受け取り、f.Add でシードを与え、f.Fuzz に本体を渡します。

package urlesc

import "testing"

func FuzzDecode(f *testing.F) {
	f.Add("hello%20world")
	f.Add("%2e%2e%2f")
	f.Add("plain-text")
	f.Fuzz(func(t *testing.T, s string) {
		Decode(s) // panic しないことだけを確かめる
	})
}

go test -fuzz=FuzzDecode -fuzztime=20s で実行すると、手元のGo 1.25.6では1秒未満で失敗しました。出力の要点だけを抜き出します。

fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 16 workers
fuzz: minimizing 36-byte failing input file
--- FAIL: FuzzDecode (0.25s)
    panic: runtime error: index out of range [1] with length 1
    Failing input written to testdata/fuzz/FuzzDecode/14d3f119d0301140
    To re-run:
    go test -run=FuzzDecode/14d3f119d0301140

注目したいのは3点です。最初にシードコーパスでベースラインのカバレッジを取ってから探索に入ること。36バイトあった失敗入力を自動で縮小し、保存されたファイルの中身が string("%") という最小形になっていること。そして失敗入力が testdata/fuzz/FuzzDecode/ へ書き出され、以後は -fuzz を付けない通常の go test でも実行されることです。Go公式ドキュメントはこの挙動を「ファジングエンジンはこの失敗入力をそのfuzz testのシードコーパスへ書き込み、以後は go test でデフォルトに実行され、バグを直した後は回帰テストとして機能する」と説明しています。

メモ

Goのfuzzing引数に使える型は string []byte int int8 int16 int32/rune int64 uint uint8/byte uint16 uint32 uint64 float32 float64 bool に限られます。構造体を直接受け取ることはできないため、複数の引数に分解するか、バイト列から自前で組み立てます。またカバレッジ計測に対応したプラットフォーム(執筆時点でAMD64とARM64)で実行する必要があります。

libFuzzerのfuzz targetとサニタイザの組み合わせ

C/C++の代表的な入口がLLVMのlibFuzzerです。ここで1点、選定時に知っておく前提があります。LLVMのドキュメントは「libFuzzerの原作者は活発な開発をやめ、別のファジングエンジンであるCentipedeの開発へ移った。libFuzzerは重要なバグが修正されるという意味では今も完全にサポートされているが、バグ修正以外の大きな新機能やコードレビューは期待しないでほしい」と明記しています。既存資産が多く学習コストも低いため今も現役の選択肢ですが、新規に選ぶならAFL++も並べて比べる価値があります。なおCentipedeは単独のリポジトリが2023年10月11日にアーカイブされ、READMEに「ファジングの開発を集約するためFuzzTestへ統合された」と記載されているため、検討する際はgoogle/fuzztest側を見ることになります。

fuzz targetの形は決まっており、google/fuzzingのガイドが示す署名は次のものです。この署名は特定のエンジンに固有ではないため、libFuzzer形式で書いておけば他のエンジンへ載せ替えやすくなります。

// fuzz_target.cc
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) {
  DoSomethingInterestingWithMyAPI(Data, Size);
  return 0;  // 0 は受理。-1 を返すとこの入力はコーパスへ追加されない
}

戻り値の意味は覚えておく価値があります。LLVMのドキュメントは「fuzz targetが与えられた入力に対して -1 を返した場合、libFuzzerはその入力を、どんなカバレッジを引き起こしたかにかかわらずコーパスへ追加しない」と説明しています。テスト対象として扱いたくない入力を自分で弾く用途に使えます。0 と -1 以外の戻り値は将来の用途に予約されているため、返してはいけません。

インターフェースがC ABIであるため、C互換の呼び出し規約を持つ言語なら同じ形で書けます。ビルドはコンパイルとリンクの両方に -fsanitize=fuzzer を付け、サニタイザを併記します。LLVMのドキュメントが示す例は clang -g -O1 -fsanitize=fuzzer,address mytarget.c です。ここで address を外すと、前節で述べたとおり静かなメモリ破壊を取りこぼします。

実行するとlibFuzzerは指定したコーパスディレクトリを読み込み、そこへ新しく見つけた入力を追加していきます。バグを見つけた時点で停止し、LLVMのドキュメントによれば「そのバグを引き起こした入力がディスクへ書き出される(通常は crash-<sha1>leak-<sha1>timeout-<sha1> という名前)」ため、そのファイルを引数に渡し直せば単体で再現できます。

主に使うオプションは多くありません。-max_total_time で実行時間の上限、-runs で実行回数の上限、-max_len で入力の最大長、-dict で辞書、-jobs-workers で並列実行、-merge=1 でコーパスの最小化です。

言語ごとの入口

主要な言語には、それぞれ標準的な入口が用意されています。いずれも公式ドキュメントで確認できる範囲を並べます。

言語ツール覚えておく点
C / C++libFuzzer(LLVM)、AFL++libFuzzerはプロセス内実行。AFL++はREADMEで「Google製AFLより優れた(superior)フォーク」と自称し、QEMUモードでバイナリのみの対象も扱える(執筆時点のリリースは5.03c)
Go標準ツールチェーン(go test -fuzz)1.18から同梱。シードは testdata/fuzz/、探索中の生成コーパスは $GOCACHE/fuzz
Rustcargo-fuzzRust Fuzz Bookは「cargo-fuzz自体はファザーではなく、ファザーを起動するツール。現在サポートするのはlibFuzzerのみ(libfuzzer-sysクレート経由)」と説明。-Z フラグを使うためnightlyが必要
PythonAtheris「カバレッジ誘導型のPythonファジングエンジン」でlibFuzzerがベース。CPython向けのネイティブ拡張もASanやUBSanと併用して検査できる
Java / JVMJazzerOWASPのファジングのページがオープンソースツールとして掲載。OSS-FuzzもJava/JVMを対象言語に含む

OSS-Fuzzのドキュメントのトップページが挙げる対応言語はC/C++、Rust、Go、Python、Java/JVM、JavaScript、Luaです。これに加えて新規プロジェクト向けのガイドは project.yamllanguageswift を受け付け、Swiftプロジェクトの統合手順も公開されています(トップページの一覧には反映されていません)。自分のプロジェクトがオープンソースであれば、そこへ乗せるという選択肢もあります。

シードコーパスと辞書で初速を上げる

探索の効率は、最初に与える入力の質でかなり変わります。シードコーパスは、対象APIにとって代表的な入力を集めたディレクトリで、1ファイルが1入力です。google/fuzzingのガイドは「これらの入力を合わせるとテスト対象APIの大部分をカバーし、理想的には100%のカバレッジに達するべき」としつつ、「同じカバレッジが得られるなら小さい入力を選び、大きなシード入力は避ける」とも書いています。実行が速いほど試行回数が稼げるためです。

集め方は難しくありません。既存の単体テストが使っているサンプルファイル、仕様書に載っている例示データ、それらを組み合わせて作った合成データ。これらをディレクトリへ置くだけです。バグを直したときや機能を追加したときにシードを足す運用も、同ガイドが推奨しています。

注意

本番トラフィックをそのままシードへ流用するのは避けてください。コーパスはリポジトリやCIの成果物として保存され、外部のCIサービスへ送られることもあるため、認証トークンやアクセスキーや個人情報がそこに残り続けます。変異で作られた派生入力にも元の断片が残りえます。原則は合成データとし、どうしても本番由来のデータを使う場合は、識別子やトークンの不可逆な置換、投入前の秘密情報スキャン、保存期間とアクセス権の設定、社外のCIサービスへ送ってよいかの確認を先に済ませてください。

シードが増えると今度は実行が遅くなるため、コーパス最小化を挟みます。libFuzzerでは ./my_fuzzer -merge=1 NEW_CORPUS_DIR FULL_CORPUS_DIR の形で、カバレッジを保ったまま入力の数を絞れます。CIで毎回全件を回すのではなく、最小化した集合を回帰用としてリポジトリに置く運用が現実的です。

辞書は、入力形式が特定のキーワードやマジック値に依存する場合に効きます。libFuzzerでは -dict=DICTIONARY_FILE で与え、LLVMのドキュメントによれば書式はAFLの -x オプションのものに類似しています。JSONなら { } null true、HTTPならメソッド名やヘッダ名、独自プロトコルならコマンド語を並べます。変異でこれらの語をまるごと挿入できるようになるため、偶然の一致を待つ必要がなくなります。

構造化入力にはカスタムミューテータかプロパティ検査

前述した「1ビット変えると全体が無効になる」対象への対処が、構造を意識したファジングです。google/fuzzingのStructure-Aware Fuzzingは、Zlib圧縮データを受け取る対象を例に、通常の変異ではほぼ全ての生成入力が uncompress の段階で弾かれてしまうと説明しています。

解決策として同ドキュメントが挙げるのがカスタムミューテータです。LLVMFuzzerCustomMutator という決まった署名の関数を用意し、入力を文法に従って解析(この例では展開)し、解析後の表現を変異させ、再び直列化(圧縮)して返します。変異はエンジンに任せつつ、その前後を自分で包む形です。チェックサム付きのフォーマットや、Protocol Buffersのようなスキーマを持つ形式でも同じ構図が使えます。

もう1つの道が、クラッシュ以外の失敗条件を自分で書くプロパティ検査です。Goのfuzz targetの中で「エンコードしてデコードすると元に戻る」「2つの実装が同じ入力に同じ結果を返す」といった性質を確かめ、破れたら t.Errorf で落とします。ファジングを差分テストの入力生成器として使う形で、パーサや直列化の実装には効きます。

CIへの組み込みと時間配分

ファジングは、1回走らせて終わりの検査ではなく、コードが変わるたびに時間を投じ続ける探索です。CIへの組み込みでは、プルリクエストの待ち時間と探索の深さが正面からぶつかります。

参考になるのがOSS-FuzzのCIFuzzです。プルリクエストでfuzz targetを走らせるGitHub Actionで、ドキュメントは実行時間の既定値を600秒とし、「この値は最低でも600秒とし、プロジェクトの規模に応じて増やすべき」と書いています。あわせて、OSS-Fuzzが持つ30日前の回帰用入力とコーパスを使い、クラッシュを報告するのは「そのクラッシュが再現可能で、かつ古いOSS-Fuzzのビルドでは発生しない」場合に限る、という条件も示されています。既存の欠陥で毎回赤くなる状態を避けるための設計です。オープンソース以外の環境では、同じ考え方を自前のCIで動かすClusterFuzzLiteが使えます。

  1. 1

    プルリクエストでは短時間の実行にとどめます。回帰用の最小化コーパスを全件流し、そのうえで新規探索を数分だけ回します。ここで見つかるのは、その変更が壊した既知の入力と、ごく浅い新規のバグです

  2. 2

    夜間かリリース前のパイプラインで、数十分から数時間の探索を回します。コーパスは実行のたびに成長するため、成果物として保存し次回へ引き継ぎます

  3. 3

    コーパスは定期的に -merge=1 などで最小化し、肥大化を抑えます。最小化した集合だけをリポジトリの回帰用として置きます

  4. 4

    新しく見つかった失敗入力は、必ず回帰テストのシードへ加えます。Goの場合は testdata/fuzz/ へ自動で書き出されるため、そのままコミットします

  5. 5

    カバレッジレポートを定期的に確認し、伸びが止まっている対象は対象の分割や辞書の追加やシードの見直しを検討します

NIST SP 800-218のPW.8.2は、実装例のExample 3として「以前に報告された脆弱性のテストをプロジェクトのテストスイートへ組み込み、誤りが再導入されないようにする」を挙げています。脆弱性として確認された失敗入力を回帰テスト化する運用は、この例と整合します。ただしExample 3が求めているのは報告済みの脆弱性の再発防止であり、全ての失敗入力やコーパスをリポジトリへ保存することまでを規定してはいません。

クラッシュのトリアージと再現手順

見つかった失敗をどう扱うかは、探索の設定よりも運用の質を左右します。手順は次の形に落ち着きます。

まず再現します。libFuzzerなら保存された crash-<sha1> をバイナリの引数に渡し、Goなら出力に表示された go test -run=FuzzDecode/<ID> を実行します。どちらも入力が固定されるため、デバッガの下で何度でも同じ経路をたどれます。

次に最小化します。Goは失敗を見つけた時点で自動的に縮小し、その時間は -fuzzminimizetime で調整できます(既定は60秒)。libFuzzerには -minimize_crash があり、-runs-max_total_time で試行の上限を決められます。縮小した入力は原因の推定を助けます。先ほどの例で string("%") まで縮んだことで、境界検査の欠落だと即座に分かりました。

そのうえで重複を判定します。同じ根本原因から複数の入力が出てくるのは普通のことで、スタックトレースの先頭数フレームとサニタイザの種別でまとめるのが実務的です。

最後に深刻度を判断します。ASanのheap-buffer-overflow(書き込み)とGoのindex out of rangeでは、同じ「クラッシュ」でも影響が違います。前者はメモリ破壊としてコード実行につながりうる欠陥で、後者はプロセスの停止による可用性への影響が中心です。対象が外部からの入力を直接受け取る位置にあるかどうかも、優先度を決める材料になります。

適用しやすい対象と向かない対象

すべてのコードにファジングが向くわけではありません。

対象向き理由
パーサ、デコーダ、直列化処理向くバイト列を入力として受け取り、結果が決定的で、実行が速い
画像や音声や文書のフォーマット処理向く入力形式が明確でシードを集めやすく、境界の欠陥が出やすい
暗号や圧縮のライブラリ条件付き入り口の検証で変異が弾かれるため、カスタムミューテータかテスト用の検証無効化が要る
ネットワークプロトコルの状態機械条件付き1入力を複数メッセージへ分割する工夫が要る。google/fuzzingは入力分割の手法を別途まとめている
DBやファイルシステムへ書き込む処理向きにくい実行が遅く、グローバル状態が残るため決定性を保てない
認可やビジネスロジック向きにくい誤りがクラッシュとして現れない。テストの設計側で扱う

判断の起点は「バイト列から始まり、外部状態を持たず、速く終わるか」です。ここに当てはまる関数がプロジェクトに1つでもあれば、そこから始めるのが最短です。

SSDFのPW.8での位置づけ

社内で工数を確保するときに使える根拠も押さえておきます。NIST SP 800-218(SSDF v1.1、2022年2月発行)は、実践の1つとしてPW.8「Test Executable Code to Identify Vulnerabilities and Verify Compliance with Security Requirements(実行コードをテストして脆弱性を特定し、セキュリティ要求への適合を検証する)」を置き、その狙いを「脆弱性を、悪用を防ぐためにソフトウェアがリリースされる前に修正できるよう、特定を助ける」と説明しています。自動化された手法を使うことで、検出に必要な労力と資源が下がり、追跡可能性と再現性が上がる、とも書かれています。

タスクPW.8.2は「テストの範囲を定め、テストを設計し、テストを実施し、結果を文書化する。発見された全ての課題と推奨される是正策の記録とトリアージを、開発チームのワークフローまたは課題管理システムで行うことを含む」と定義され、その実装例のExample 5に「入力処理の問題を見つけるためにファズテストのツールを使う」が挙がっています。ファジングは、SSDFの中で明示的に名前が出ている技法です。

SP 800-218はSSDF Version 1.1として2022年2月に最終版が公開されています。生成AI向けのコミュニティプロファイルSP 800-218A(2024年7月26日)も最終版です。後継のSP 800-218 Rev.1(SSDF Version 1.2)は2025年12月17日に初期公開草案として公開され、執筆時点では草案の状態です。草案側でもPW.8.2のExample 5として「Use fuzz testing tools to find issues with input handling.」がそのまま記載されており、記述の方向は変わっていません。

規模の実例としてのOSS-Fuzz

継続的に回した場合に何が起きるかは、OSS-Fuzzが示しています。OSS-Fuzzのドキュメントによれば、このサービスは2016年、OpenSSLで発見されたHeartbleed脆弱性を受けて開始されました。同ドキュメントは、その脆弱性が「ほぼ全てのインターネット利用者に影響しうるものでありながら、原因は比較的単純なメモリバッファのオーバーフローのバグであり、ファジングによって検出できたはずのものだった」と書いています。当時はファジングが広く使われておらず、開発者にとって手作業の負担が大きかった、という説明が続きます。

なお、この「メモリバッファのオーバーフロー」という表現はOSS-Fuzz側のものです。欠陥の種別としては、OpenSSLのアドバイザリがCVE-2014-0160を「TLSのheartbeat拡張の処理における境界検査の欠落により、接続先のクライアントまたはサーバーへ最大64KBのメモリが露出しうる」と説明しているとおり、書き込みではなく範囲外読み取りです。ファジングで見つけられたはずという指摘の趣旨は変わりませんが、記憶違いが起きやすい箇所なので分けて押さえておきます。

成果の数字は、参照する資料で値が異なります。GitHubのREADMEは「2025年5月時点で、OSS-Fuzzは1,000のプロジェクトにわたり13,000件を超える脆弱性と50,000件を超えるバグの発見と修正に貢献した」と記載し、ドキュメントサイトのTrophies欄はより古い「2023年8月時点で、10,000件を超える脆弱性と36,000件を超えるバグ」という値のままです。新しい方の数字を使う場合も、時点を添えて引用するのが安全です。

対応するファジングエンジンの記載も両者で差があり、READMEはlibFuzzerとAFL++とHonggfuzzを挙げ、ドキュメントサイトはこれにCentipedeを加えています。

最初の一週間の進め方

導入は小さく始めるほど続きます。実際の着手順を並べます。

  • バイト列か文字列を入力として受け取り、外部状態に触れず、速く終わる関数を1つ選びます
  • その関数に対してfuzz targetを1本書きます。最初は「panicしないこと」だけを条件にします
  • 既存の単体テストで使っているサンプル入力を、そのままシードとして f.Add か corpus ディレクトリへ入れます
  • ローカルで数分だけ回して、失敗が出るかを確かめます。出たらまず直し、失敗入力を回帰コーパスへ入れます
  • C/C++の場合は -fsanitize=fuzzer,address でビルドし直し、サニタイザ有りでもう一度回します
  • CIへ組み込みます。プルリクエストでは回帰コーパス全件と短時間の探索、夜間に長時間の探索という2段構えにします
  • コーパスをCIの成果物として保存し、次回の実行へ引き継ぐ設定を入れます
  • 1か月後にカバレッジを確認し、伸びていなければ対象の分割や辞書の追加を検討します

最初の1本が動き、CIで回り始めれば、2本目からはビルド設定やCIの定義を流用できます。対象ごとに判定条件や辞書は考え直す必要がありますが、土台を作り直す手間はかかりません。パーサやデコーダを持つプロジェクトなら、投じた時間に対して返ってくるものが大きいテストになります。

あわせて読みたい

DevSecOps入門。CI/CDにセキュリティを組み込むSAST・DAST・SCA・シークレットスキャン

ファジングは単体テストの置き換えではありません。単体テストが仕様どおりの挙動を確かめるのに対し、ファジングは仕様の外側で何が起きるかを確かめます。両方が揃って初めて、想定内と想定外の双方に手が届きます。

出典・参考

この記事をシェア

関連する記事

セキュアコーディング

入力バリデーションと出力エスケープの原則。入口で検証し、出口で文脈別にエスケープする

セキュアコーディングの土台である入力バリデーションと出力エスケープを、それぞれの役割の違いから整理します。入口の検証は防御の一段目、出口の文脈別エスケープがXSSやインジェクションの根本対策である理由を、原理と実務の判断基準まで掘り下げます。