Skip to content

メモリ安全性の検出

第III部で見たように、GC を持つエンジンには use-after-free の一群が潜みます。ルート漏れ・ダングリングポインタ・スイープ済みの値の格納といったバグは、いずれも「GC がちょうどその危険な瞬間に走ったときだけ」症状を出します。厄介なのは、こうしたバグを含んだままでも普通に動いてしまうことです。準拠性テストを満点で通過しても、差分テストが意味の食い違いをすべて否定しても、この種のバグは表に出てきません。症状の有無が実行タイミングという偶然に依存するからです。この章では、そのタイミング依存性を人為的に取り除き、隠れた use-after-free を確定的に・その発生箇所で炙り出すための手法 — GC ストレス、解放オブジェクトのポイズニング/隔離、外部サニタイザ、そして書き込み時に危険な操作を封じる lint ゲート — を扱います。

なぜこの種のバグは隠れるのか

第III部「ネイティブコードとの相互作用と GC 安全性」で扱ったバグを思い出してください。組み込み関数が結果オブジェクトを確保し、まだどこにもリンクしないうちにコールバックや型強制を通じて JS へ呼び戻すと、その先の確保が GC を誘発し、未リンクの結果が回収されえます。回収後にその領域へ書き込めば use-after-free です。

このバグが「ある」ことと「症状が出る」ことは別物です。症状が出るには、次の条件がすべて重なる必要があります。

  • 保護漏れのある特定のコードパスが実行される。
  • そのパスの、まさに脆弱な区間(コールバックや型強制の最中)で GC が走る。
  • 回収された領域が別の生きたオブジェクトに再利用され、ダングリング参照が観測可能な破壊を起こす。

通常の実行では、二つ目の条件がまず成立しません。ほとんどの GC は、ヒープが一定量まで埋まったとき(割付しきい値の超過)に初めて回収を試みます。脆弱な区間はたいてい数回の確保しか行わないため、そこでしきい値をまたぐ確率は低く、GC は「危険な瞬間」を素通りします。結果として、バグは大多数の実行で沈黙します。

さらに悪いことに、たまに症状が出たときも、原因箇所を指し示してくれません。第III部のパターン3(スイープ済みの値を後から到達可能オブジェクトへ格納する)のように、無効ポインタを格納した瞬間には何も起きず、後続の GC がそのフィールドをたどったときに初めて破綻する場合、クラッシュのスタックトレースは格納箇所ではなく、まったく無関係な後続コードを指します。原因と症状が時間的にも空間的にも離れる、典型的なハイゼンバグです。観測しようと実行を変える(デバッガを挟む、ログを足す)とタイミングがずれて再現しなくなる、という性質もこの名にふさわしいものです。

したがって検出の戦略は明快です。症状の発生をタイミングという偶然に任せず、決定論的に強制すること。以下の手法はどれも、この一点に向けられています。

GC ストレスモード

第一の手法は、GC が走る条件そのものを変えてしまうことです。GC ストレスモードでは、割付しきい値を無視し、(ほぼ)すべての割付点で強制的に回収を走らせます。

確保する(size):
    if GC ストレスが有効:
        フル GC を走らせる        # しきい値に関係なく毎回
    ヒープから size を切り出して返す

こうすると、脆弱な区間の中で確保が一度でも起きれば、その場で必ず GC が走ります。第III部で見た危険なパターン — コールバック・getter・ToPrimitiveProxy トラップ・イテレータの next の内側で行われる確保 — は、通常実行では「たまに」GC を挟むだけでしたが、ストレスモードでは「毎回」挟みます。保護漏れがあれば、対象のコードパスを一度実行するだけで決まって回収が起き、バグが再現します。「稀にしか成立しない二つ目の条件」を、常に成立させてしまうわけです。

GC ストレスの効きを最大化するには、回収の対象をできるだけ広げるのが有効です。世代別 GC を持つエンジンなら、ストレス時はマイナー GC ではなくフル(メジャー)GC を走らせ、古い世代に逃げたオブジェクトも回収候補に含めます。移動型 GC なら、ストレス時に必ず compaction を行ってオブジェクトを移動させると、更新し忘れた生ポインタ(移動後に古いアドレスを触るバグ)も同時に炙り出せます。

トレードオフは速度です。毎回フル GC を走らせれば、実行はしばしば数十〜数百倍遅くなります。これは常用できる速度ではないため、GC ストレスは専用のテストモードとしてビルドオプションの背後に置き、通常ビルドには一切影響させないのが定石です。回すのは、ネイティブ境界のコードを追加・変更したとき、ファザや準拠性スイートを一晩かけて走らせるとき、といった検証パスに限ります。

なお、毎割付でのフル GC が遅すぎて現実的なスイート全体を回せない場合は、「N 回に 1 回」や「割付回数がある閾値を超えたら」といった間引きも実用的です。決定論性は下がりますが、桁違いに速くなるため、粗いストレスで広く回し、疑わしい箇所を毎割付ストレスで詰める、という二段構えが取れます。

解放オブジェクトのポイズニングと隔離

GC ストレスで「危険な瞬間に必ず GC が走る」状況を作っても、それだけでは検出は確定しません。回収された領域のメモリが、そのまま次の確保に再利用されてしまうからです。

ダングリングポインタがたどる先には、もう別の生きたオブジェクトが載っているかもしれません。すると解放済み領域を読んでももっともらしいゴミ(plausible garbage)が返り、書き込んでも(いまはまだ)クラッシュしません。破綻は、再利用した側のオブジェクトが後で不整合を起こしたときに、別の場所で顕在化します。GC ストレスでタイミングは決定論的にできても、これでは原因箇所での即時検出になりません。

これを埋めるのが、スイープ時のポイズニング隔離(quarantine)です。

  • ポイズニング: スイープしたオブジェクトのメモリを、既知の不正なビットパターン(ポイズン値)で上書きする。ポイズン値は、ポインタとして参照すればまず不正アクセスになるアドレス(たとえばマップされていない領域を指す値)や、数値・タグとして解釈すれば明らかに異常とわかる値を選ぶ。
  • 隔離: スイープしたオブジェクトをすぐには解放・再利用せず、「死んでいる(dead)」と印を付けたまましばらくマップされた状態で残す。物理的に生かしておくが、論理的には死んでいる状態にする。

そのうえで、オブジェクトを間接参照するすべての箇所で「このオブジェクトは dead でないか」をアサートするようにします。

deref(obj):
    assert(obj.header != POISON)     # ポイズンパターンを踏んでいないか
    assert(not obj.is_dead)          # 隔離された死んだオブジェクトでないか
    return obj.fields

こうすると、ダングリング参照はその領域が再利用される前に踏み抜かれ、まさにその参照箇所でアサート失敗としてトラップします。再利用によって「読めてしまう」ために破綻が先送りされる、という逃げ道を塞ぐわけです。ポイズニングは「無効な値を読ませて即座に落とす」、隔離は「死んだと分かる状態で生かしておき、触った瞬間に落とす」という、同じ狙いの二つの実現方法です。両者は併用でき、隔離で領域を保持しつつヘッダをポイズンして dead フラグ代わりにする、といった形をとることもあります。

GC ストレスと組み合わせると効果は最大化します。ストレスが「危険な瞬間に必ず GC を走らせて対象を回収させ」、ポイズニング/隔離が「回収された対象への参照を、再利用される前にその場で落とす」。この二つがそろって初めて、タイミング依存のハイゼンバグが「特定のネイティブ操作を実行すると毎回・その行で落ちる」確定的で局所化されたクラッシュに変わります。原因箇所が症状箇所と一致するため、修正すべきピン留めの欠落をそのまま特定できます。

flowchart TB
    A["脆弱区間内で確保が起きる<br/>(コールバック・型強制の最中)"] --> B["GC ストレスが毎回フル GC を強制"]
    B --> C["未リンクの対象が回収される"]
    C --> D["ポイズニング/隔離で死印・不正パターンを書き込む"]
    D --> E["ダングリング参照が対象を間接参照"]
    E --> F["参照箇所の dead アサートがその場で失敗(確定・局所化)"]

GC ストレスが危険な瞬間の回収を強制し、ポイズニング/隔離が再利用前の参照をその場で失敗させることで、タイミング依存の use-after-free が発生箇所での確定的なクラッシュに変わる。

この手法には本質的なコストがあります。隔離はメモリを解放しないため、設計上メモリをリーク・保持し続けます。ポイズニングも、アサートのオーバーヘッドと、回収済み領域の書き潰しコストを伴います。したがってこれもビルドオプションの背後に置き、通常ビルドや本番ビルドには決して含めません。あくまで「メモリを浪費してでもバグを確定させる」検証専用のモードです。

外部サニタイザとデバッグアロケータ

GC ストレスとポイズニングは、エンジンが管理する JS ヒープの内側のバグを狙う、エンジン自製の仕組みです。一方で、エンジンの実装言語のレイヤ — ネイティブ側の生の確保・解放、バッファのはみ出し、初期化前アクセス — には、ツールチェインが提供する外部の検出器を併用します。

  • アドレスサニタイザ: コンパイラが確保領域の周囲に検知帯(redzone)を挿入し、解放済み領域を隔離することで、境界外アクセス・use-after-free・二重解放を実行時に検出する。落ちた地点と確保/解放の履歴を対にして報告するため、原因の特定が速い。
  • リーク検出器: プロセス終了時に、到達可能などのルートからも指されていない確保済み領域を報告する。ネイティブ側での解放漏れや、永続ハンドルの解放し忘れを見つけるのに有効。
  • デバッグアロケータ: 確保器自体を、ガードページ・ポイズンフィル・二重解放検知を行うデバッグ版に差し替える。サニタイザが使えない環境でも、use-after-free やはみ出しの一部を捕まえられる。

これらは JS ヒープの内側の論理的な use-after-free(GC が回収したオブジェクトを触るバグ)を直接は捕らえないことがあります。エンジンから見れば、それは「自分が管理する一枚の大きな確保領域の内側での読み書き」であり、ネイティブの確保器の単位では解放が起きていないからです。だからこそ、JS ヒープ内側は GC ストレス+ポイズニングで、ネイティブレイヤは外部サニタイザで、と守備範囲を分けて併用する必要があります。

運用上の勘所は、これらを単発で走らせて終わりにしないことです。準拠性スイートとファザ(前章「ファジング」)を、サニタイザを有効にしたビルドと GC ストレス+ポイズンを有効にしたビルドの両方で回すことで、既知のテストケースが踏む経路と、ファザが自動生成する未知の経路の双方に検出網をかけられます。サニタイザ有効ビルドは通常の数倍遅くなるため、これもリリースビルドとは別建てにします。

書き込み時に封じる: unsafe 操作の lint ゲート

ここまでは「バグを実行時に見つける」手法でしたが、一部のバグクラスは書く時点で防ぐ方が確実です。危険な操作の使用そのものを禁じ、安全なヘルパ経由に限定すれば、その操作に起因するバグは最初から入りません。

典型例が、浮動小数点数から整数への生のキャストです。多くのシステムプログラミング言語では、非有限値(NaN・無限大)や、整数型の表現範囲を超える値を整数へ生キャストすると、未定義動作やトラップ、あるいはプラットフォーム依存の不定値になります。JavaScript の数値はすべて倍精度で、length の計算・配列インデックス・ビット演算・型付き配列の書き込みなど、随所で「浮動小数点を整数にする」処理が現れます。ここで生キャストを使うと、スクリプトから容易に非有限値や巨大値を流し込まれ、クラッシュや未定義動作を引き起こせます。

対策は、生キャストを禁止し、範囲・非有限チェック込みのガード付きヘルパ(仕様の ToInt32 / ToUint32 / ToLength などに対応する、飽和やラップを明示した変換関数)への集約を強制することです。問題は、この規律を人手のレビューだけで維持しきれないことです。ネイティブコードは膨大で、新しい生キャストは日々混入しえます。

そこで有効なのがラチェット式(ratchet)の lint ゲートです。

  1. まず現状のコードベースに残る生キャストの箇所数を数え、ベースラインとして記録する。
  2. CI の lint が、変更後のカウントを再計測する。
  3. ベースラインを超えたら失敗させる(=新規の生キャストの追加を拒否)。
  4. 生キャストをヘルパ化してカウントが減ったら、ベースラインをその値まで引き下げる(逆戻り防止)。

このラチェットにより、既存の生キャスト(いずれ潰したい負債)を許容しつつ、新規の混入をゼロに保ち、掃除が進むたびに上限が下がって二度と増えない状態を作れます。全面禁止を一度に強制すると既存コードが通らずゲートを無効化されがちですが、ラチェットなら「今より悪くしない」という緩い制約から始められ、現実的に導入・維持できます。同じ発想は、生キャスト以外の「原則使ってはいけないが完全排除は段階的」なパターン(生ポインタのキャスト、チェックなしの添字アクセスなど)にも一般化できます。

検出手法の比較

各手法が主に何を捕らえ、どのコストを伴うかを整理します。守備範囲は重なりつつずれており、単独では穴が残るため、併用が前提です。

手法 主に捕らえるもの 主なコスト 位置づけ
GC ストレス ルート漏れ・スイープ済み値の格納など GC タイミング依存の use-after-free 実行が桁違いに遅い 危険な瞬間に必ず GC を走らせ再現を強制
ポイズニング/隔離 ダングリング参照(再利用前にその場で捕捉) メモリをリーク・保持、アサートのオーバーヘッド ストレスと併用し発生箇所で確定検出
アドレスサニタイザ ネイティブ側の境界外・use-after-free・二重解放 実行数倍・メモリ増 実装言語レイヤの守備
リーク検出器 ネイティブ確保・永続ハンドルの解放漏れ 終了時解析のコスト 到達不能な確保済み領域の報告
デバッグアロケータ 確保器単位の use-after-free・はみ出し 確保が重くなる サニタイザ非対応環境の代替
lint ゲート(ラチェット) 生 float→int キャスト等、書く時点で防げるバグクラス ほぼゼロ(CI 時間のみ) 混入の予防、逆戻り防止

実装上の罠・知見

GC ストレス単独では足りない

ストレスだけを有効にしても、回収された領域がすぐ再利用されると、ダングリング参照が「もっともらしいゴミ」を読んでしまい、破綻が別の場所へ先送りされます。ストレスは「危険な瞬間に必ず GC を走らせる」役割、ポイズニング/隔離は「回収された対象への参照を再利用前に踏み抜かせる」役割で、両者はセットで初めて確定検出になります。片方だけでは、再現はするが局所化できない、あるいは局所化はできるが再現しない、という中途半端な結果になりがちです。

アサートは「参照するすべての箇所」に置かないと漏れる

ポイズニング/隔離の効果は、dead 判定のアサートがオブジェクト間接参照の共通経路を通っていることに依存します。ホットパスの最適化のためにアサートを迂回する近道(生ポインタを直接デリファレンスする特別扱い)を作ると、まさにその近道を通るバグだけがすり抜けます。検出用ビルドでは、性能を犠牲にしてでもすべての参照を共通のチェック付き経路に通すのが要点です。

外部サニタイザは JS ヒープ内側の論理 use-after-free を見逃しうる

エンジンが JS ヒープを一枚の大きな確保領域として自前管理している場合、その内側で回収済みオブジェクトを触っても、ネイティブの確保器の単位では解放が起きていないため、サニタイザは反応しません。「サニタイザを通したから GC 系のバグもない」と考えるのは誤りで、JS ヒープ内側は自製の GC ストレス+ポイズニングで守る、という分担を明確にする必要があります。

検出モードはビルドオプションで隔離し、本番に混ぜない

隔離は設計上メモリを保持し続け(=リークし)、ストレスは実行を桁違いに遅くします。これらは検証専用の性質であり、通常ビルドや本番ビルドに一片でも混ぜれば、性能とメモリを損なうだけです。逆に、検出モードを「たまにしか動かさない」状態にすると陳腐化して壊れるため、CI の定期ジョブに組み込み、常に動く状態を保つのが望ましいバランスです。

準拠性テストを検出ビルドで走らせることに独自の価値がある

これがもっとも実務的な知見です。準拠性スイートは、通常ビルドでは満点(全パス)であっても、それは「観測される結果が正しい」ことしか保証しません。同じスイートを GC ストレス+ポイズンを有効にしたビルドで走らせると、各テストが踏むネイティブ境界のコードパスで、通常ビルドでは沈黙していた保護漏れが決定論的に露出します。実際、通常ビルドでクリーンに通るテストが、GC ストレス+ポイズンビルドでは use-after-free として落ちる、というのはこの種のバグに固有の現れ方です。準拠性テストを「合否の判定器」としてだけでなく「多様なコードパスを網羅的に踏む駆動役」として再利用し、その裏でメモリ安全性を監視する、という二重の使い方が効きます。同様に、ファザが生成する未知のケースを検出ビルドで走らせれば、既知のテストが踏まない経路にも網が広がります。GC ストレス+ポイズニングを、ファザと準拠性スイートという二つの駆動役と組み合わせることが、このバグクラスを実際に捕らえる最も確実な布陣です。

まとめ

  • GC 由来の use-after-free は、危険な瞬間に GC が走るという偶然に症状が依存するため、準拠性テストが満点でも通常実行では隠れるハイゼンバグになる。検出の要は、この偶然を決定論に変えることである。
  • GC ストレスモードは、割付しきい値を無視して(ほぼ)全割付点で強制回収し、脆弱な区間で必ず GC を走らせて再現を強制する。桁違いに遅いため専用ビルドに隔離する。
  • 解放オブジェクトのポイズニング/隔離は、回収領域を不正パターンで埋めるか死印を付けて保持し、全参照箇所で dead を検査することで、ダングリング参照を再利用前にその場でトラップさせる。GC ストレスと併用して初めて確定・局所化された検出になる。設計上メモリを保持するためビルドオプションで隔離する。
  • 外部サニタイザ・リーク検出器・デバッグアロケータは、実装言語レイヤの境界外アクセス・解放漏れ・二重解放を守る。JS ヒープ内側の論理的 use-after-free は見逃しうるため、自製の GC ストレス+ポイズニングと守備範囲を分けて併用する。
  • 生の float→int キャストのような書く時点で防げるバグクラスは、ガード付きヘルパへの集約をラチェット式 lint ゲートで強制し、新規混入をゼロに保ちつつ既存負債を段階的に減らす。
  • 準拠性スイートとファザを検出ビルドの駆動役として再利用することが、このバグクラスを実際に捕らえる最も確実な布陣であり、通常ビルドでクリーンに通るケースが検出ビルドで use-after-free として落ちる、という形で真価を発揮する。

参考文献

  • AddressSanitizer (LLVM/Clang) — 境界外アクセス・use-after-free・二重解放を実行時に検出するサニタイザの一次資料。
  • Valgrind — メモリエラーとリークを検出する動的解析ツール群の一次資料。