ファジング¶
準拠性テストや差分テストは、あらかじめ人間が用意した入力に対して答え合わせをします。しかし、エンジンを壊す入力の多くは人間が思いつかない形をしています。深く入れ子になった構文、極端な数値、副作用を持つ型強制が重なった式 — こうした組み合わせは無数にあり、手で書き尽くすことはできません。ファジングは、入力を大量に自動生成してエンジンに食わせ、クラッシュや(判定器があれば)誤った結果を見つける技法です。この章では、代表的な二つの生成方式とそのトレードオフ、壊れやすい形へ生成を偏らせる勘所、失敗入力を最小化する手法、そして見つけた不具合を回帰防止に育てる運用を扱います。
ファジングとは何か¶
ファジングの発想は単純です。プログラムに大量の自動生成入力を与え、異常な挙動 — クラッシュ・ハング・アサーション違反、あるいは間違った出力 — が起きないかを観察します。人間が想定した正常系・異常系の外側にある入力を機械的に踏み抜くことで、通常のテストがすり抜けるバグを掘り出します。
ファジングで「異常」を判定する仕組みをオラクル(判定器)と呼びます。もっとも安直なオラクルは「プロセスが落ちたか」です。クラッシュやアサーション違反は明確な異常なので、追加の正解データがなくても検出できます。一方、誤った結果(落ちないが答えが違う)を捕まえるには、正解を知る別の基準が要ります。ここで前章の差分テストが効きます。生成した入力を参照実装や別エンジンにも食わせ、出力が食い違えば異常とみなすわけです。クラッシュ検出のファザに差分オラクルを組み合わせると、「落ちるバグ」だけでなく「黙って間違えるバグ」まで射程に入ります。
JavaScript エンジンにとっての「入力」はソースコードです。したがってファザは、エンジンに与えるプログラムテキストをどう作るかで性格が大きく分かれます。大別すると、文法から正しいプログラムを組み立てる文法ベース生成と、既存の入力を機械的に変異させるカバレッジ誘導(変異)ファジングの二つです。
方式1: 文法ベース生成¶
文法ベース生成は、言語や対象機能領域の文法を定義し、そこから構文的に妥当なプログラムを生成します。「文 → 式 → 演算子と被演算子 → …」という生成規則を再帰的に展開し、ランダムに(ただし文法に従って)プログラムを組み立てます。
この方式の強みは、生成物のほとんどがパースを通り、実際に実行されることです。エンジンのパイプライン(字句解析 → 構文解析 → コンパイル → VM 実行)のうち、パーサだけでなくその奥にあるランタイムや VM の深いコードにまで、効率よく到達できます。構文エラーで弾かれる入力はまれなので、生成した一つ一つが「エンジンを深く動かす」仕事をします。
- 利点: 生成物が実行まで届くため、ランタイム・VM・GC・組み込み関数といった深い層のバグに効率よく到達する。文法を機能領域(配列操作・型強制・正規表現など)に絞れば、狙った領域を集中的に攻められる。
- 欠点: 文法を書く手間がかかる。文法に表れない不変条件(たとえば「宣言前の変数を使わない」)を守らせようとすると生成器が複雑になる。文法が想定した範囲の外にあるバグは踏みにくい。
文法ベース生成は、差分オラクルと組み合わせたときに真価を発揮します。生成物が確実に実行されるので、参照実装との出力比較がそのまま意味バグの検出につながります。
方式2: カバレッジ誘導(変異)ファジング¶
カバレッジ誘導ファジングは、種(シード)となる入力を機械的に変異させ、新しいコードカバレッジに到達した入力だけを残すという進化的な戦略をとります。ビットの反転・バイトの挿入や削除・断片の結合といった変異を加え、実行してみて今まで通らなかった経路を通したものを「有望な種」として保持し、さらに変異させていきます。エンジンをカバレッジ計測付きでビルドしておき、その信号でファザを誘導します。
この方式はソースを「任意のバイト列」として扱います。そのため、壊れた入力・不正なバイト列に対するパーサやコンパイラのクラッシュを見つけるのが得意です。エンコーディング境界の処理、深い入れ子でのスタック溢れ、エラー回復経路の破綻など、字句解析・構文解析まわりの堅牢性を厳しく突きます。
一方で、ランダムなバイト列の大半は構文エラーになり、VM まで到達しません。深いランタイムのバグに届きにくいのはこのためです。この弱点を補うのが種(シードコーパス)です。実際に妥当な JavaScript の断片、過去にクラッシュを起こした入力(known crashers)、機能を網羅する小さなプログラム群を種に与えておくと、変異の出発点が「意味のある形」になり、深い経路へ届きやすくなります。良い種は良い結果に直結します。
- 利点: 文法を書かなくても始められる。カバレッジ信号が自動で入力を有望な方向へ誘導する。不正入力に対するパーサ/コンパイラの堅牢性を厳しく検査できる。
- 欠点: ランダムなバイト列の多くが構文エラーとなり、VM など深い層に届きにくい。種コーパスの質に結果が大きく左右される。誤った結果(意味バグ)を単体で見つけるにはやはりオラクルが要る。
二つの方式の比較¶
| 観点 | 文法ベース生成 | カバレッジ誘導(変異) |
|---|---|---|
| 生成物の性質 | 構文的に妥当なプログラム | 種を変異させた任意バイト列 |
| 主な到達先 | ランタイム・VM・GC など深い層 | パーサ・コンパイラなど前段 |
| パース通過率 | 高い(ほぼ実行される) | 低い(多くは構文エラー) |
| 誤った結果の検出 | 差分オラクルと相性が良い | オラクルが別途必要 |
| 準備コスト | 文法の記述が必要 | 種コーパスの整備が必要 |
| 得意なバグ | 深い意味バグ・GC 関連 | 不正入力でのクラッシュ・堅牢性 |
| 誘導の仕組み | 文法規則 + 生成の偏らせ | カバレッジ信号 |
どちらか一方が優れているわけではなく、狙う層が違います。パイプラインの前段(堅牢性)はカバレッジ誘導、奥の層(意味バグ・GC)は文法ベース、と補い合う構成が現実的です。
壊れやすい形へ生成を偏らせる¶
文法から一様ランダムにプログラムを生成すると、その多くは当たり障りのないコードになり、バグを踏みません。エンジンが歴史的に壊れやすいのは、特定の「危険な形」に偏った入力です。生成をそうした形へ意図的に偏らせると、通常のテストでは出会えないクラスのバグが表面化します。狙う価値のある形には、次のようなものがあります。
- 確保が多発するコールバック:
map・sort・forEachなどのコールバックや getter の内部で、大量のオブジェクト・文字列・配列を確保するコードを生成する。これは都合の悪い瞬間に GC を誘発し、ネイティブ境界での use-after-free(第III部「ネイティブコードとの相互作用と GC 安全性」)を炙り出す。 - 副作用を持つ型強制:
toString/valueOfを上書きしたオブジェクトを、演算の被演算子・配列インデックス・プロパティキーなど「エンジンが内部で型強制する場所」に置く。型強制の途中で任意の JS が走り、対象の書き換えや再入が起きて、保持中の値を失う経路を突く。 - 極端な数値:
Infinity・NaN・-0・巨大な有限値を、インデックス・長さ・カウントを取る引数(Arrayのlength・sliceの範囲・繰り返し回数など)に流し込む。境界の飽和処理・符号処理・整数への変換の綻びを突く。 - 非常に大きなリテラル: 極端に長い文字列・巨大な配列/オブジェクトリテラル・深い入れ子を生成する。バッファ拡張・再確保・再帰深度の限界まわりを揺さぶる。
これらは「エンジンが内部で余計な仕事をさせられる形」です。副作用を持つ型強制と確保多発コールバックを組み合わせ、さらにそれを GC ストレスビルドや後述のメモリ安全性検出ビルドの上で走らせると、GC のタイミングに依存する潜在バグが確定的に顕在化します。単なる正常系のプログラムをいくら生成しても届かない領域に、意図的な偏りで踏み込むわけです。
最小化(デルタデバッギング)¶
ファザが見つける失敗入力は、たいてい無関係なコードを大量に含んだ巨大なものです。そのままでは原因の特定が困難なので、失敗を保ったまま入力を機械的に削って最小の再現ケースへ縮める工程が要ります。これが最小化です。
代表的な手法がデルタデバッギングです。入力を断片に分割し、一部を取り除いても失敗が再現するかを試し、再現するなら取り除いた版を採用する、を繰り返します。文・式・要素といった単位で削っていき、これ以上どこを削っても失敗しなくなった状態を最小再現ケースとします。数百行の生成物が、原因を示す数行にまで縮むことも珍しくありません。
最小化は人手の解析コストを劇的に下げるだけでなく、後述の回帰コーパスに登録する形としても最適です。小さく本質だけを残した再現ケースは、原因の理解・修正・回帰監視のすべてを容易にします。ファザ・オラクル・最小化器の三点セットで初めて、ファジングは実務的な不具合発見ループとして回ります。
flowchart LR
GEN["入力生成<br/>(文法ベース/変異)"] --> RUN["エンジンで実行"]
RUN --> ORACLE["オラクル判定<br/>(クラッシュ/差分)"]
ORACLE -. "異常なし" .-> GEN
ORACLE -. "異常あり" .-> MIN["最小化<br/>(デルタデバッギング)"]
MIN --> CORPUS["コーパスへ登録<br/>(回帰・種)"]
CORPUS --> GEN
ファジングは生成・実行・オラクル判定・最小化・コーパス登録が環をなし、コーパスが次の生成へ還流することで継続的な不具合発見ループとして回る。
コーパスと回帰防止¶
ファジングは一度きりのイベントではなく、継続的に回す仕組みにして初めて価値が出ます。その中核がコーパスです。
- カバレッジ誘導の種コーパス: 有望な種(妥当な断片・known crashers・機能網羅の小プログラム)を蓄積し、次回以降の変異の出発点にする。カバレッジを広げた入力を自動で追加していくと、コーパスが育つほど到達範囲が広がる。
- 回帰コーパス: 見つけて修正した不具合の最小再現ケースを永続的に保存し、CI などで常時再実行する。一度直したバグが別の変更で再発するのを防ぐ、恒久的な回帰テスト群になる。
見つけた不具合は「直して終わり」にせず、最小化した再現ケースを回帰コーパスへ登録して残します。ファジングで得た知見が回帰テストとして蓄積され、エンジンの品質が後戻りしない土台になります。準拠性テストや差分テストの固定ケースを補完する、動的に育つテスト資産と位置づけられます。
実装上の罠・知見¶
意図的な非互換で偽陽性の差分を出さない¶
差分オラクルを使うとき最大の落とし穴は、本物のバグでない食い違い(偽陽性)に埋もれることです。JavaScript には仕様上「実装依存」とされる挙動や、エンジンが意図的に選んだ非互換が存在します。オブジェクトの列挙順の一部・エラーメッセージの文言・スタックトレースの形式・Date のローカル時刻・国際化(本エンジンでは非目標)・浮動小数点の文字列化の桁など、正解が一意でない、あるいは実装ごとに正当に異なる領域です。
これらを素朴に比較すると、無害な差分が大量に上がり、その山の中に埋もれた本物の一件を見逃します。対策は、比較の前にこうした領域を正規化するか、生成側でそもそも実装依存の構文を避けることです。具体的には、非決定的な出力(時刻・乱数・オブジェクトアドレス由来の値)を生成しない、エラーは型だけを比較し文言は無視する、といった線引きを明示します。「何を差分とみなさないか」を意図的に定義することが、差分ファジングを実用にする鍵です。偽陽性を放置したファザは、やがて誰も結果を見なくなって死にます。
壊れやすい形を狙う価値¶
一様ランダムな生成は、コード量あたりのバグ発見効率が低いというのが実務上の一貫した知見です。エンジンのバグは特定の危険な形 — GC に厳しいコールバック、副作用を持つ型強制、極端な入力 — に偏って潜んでいます。生成をこれらの形へ意図的に寄せ、さらにメモリ安全性を検出するビルド(次章)の上で走らせると、通常のテストや準拠性スイートをすり抜けていた実バグが表面化します。そこには、黙って誤った結果を返すもの(silent wrong result)や、解放済みメモリを触るもの(use-after-free)が含まれます。前者は差分オラクルが、後者はメモリ検出ビルドが、それぞれ「落ちない/たまにしか落ちない」バグを確定的な検出へ変えます。ファザは「たくさん生成する」だけでなく「危険な方向へ生成する」ことで初めて費用対効果が跳ね上がります。
決定性と再現性を確保する¶
ファザは乱数で入力を作るため、同じバグを二度出せないと解析できません。生成の乱数シードを記録し、シードから入力を再生成できるようにしておくと、クラッシュを確実に再現できます。エンジン側の非決定的要素(GC の起動タイミング・ハッシュのランダム化・アドレス由来の値)も、検証時には固定できるようにしておくと、最小化と原因追跡が安定します。再現できないバグ報告は、事実上ないのと同じです。
まとめ¶
- ファジングは大量の自動生成入力でクラッシュや誤った結果を見つける技法で、異常を判定するオラクルが要る。誤った結果の検出には差分オラクルを組み合わせる。
- 文法ベース生成は構文的に妥当なプログラムを作るためランタイム・VM の深い層に効率よく届き、差分オラクルと相性が良い。カバレッジ誘導(変異)ファジングは任意バイト列を食わせパーサ/コンパイラの堅牢性を突くのが得意で、種コーパスの質が結果を左右する。
- 一様ランダムは効率が低い。確保多発コールバック・副作用を持つ型強制・極端な数値・巨大リテラルといった壊れやすい形へ生成を偏らせると、通常のテストが逃すクラスのバグが表面化する。
- 失敗入力はデルタデバッギングで最小再現ケースへ縮め、解析と回帰登録を容易にする。
- 最小化した不具合を回帰コーパスとして永続化し常時再実行することで、修正済みバグの再発を防ぐ動的なテスト資産になる。
- 危険な形を狙い、メモリ安全性検出ビルドの上で走らせると、silent wrong result や use-after-free といった、準拠性テストがすり抜ける実バグが確定的に見つかる。偽陽性の差分を出さない正規化と、乱数シードによる再現性の確保が実用の前提になる。
参考文献¶
- libFuzzer (LLVM ドキュメント) — カバレッジ誘導ファジングの代表的実装(AFL と並ぶ)の一次資料。