ネイティブコードとの相互作用と GC 安全性¶
エンジンの組み込み関数やホストコールバックは、エンジンの実装言語(ネイティブコード)で書かれ、その内側から JS ヒープ上のオブジェクトを直接操作します。配列や文字列を作り、プロパティを読み書きし、ときには JS へ呼び戻します。ところが、これらの操作の途中でガベージコレクタが動きうる点に落とし穴があります。前章「ルート集合と到達可能性」で見たように、GC はルート集合から到達できないオブジェクトを回収します。ネイティブコードのローカル変数だけが参照しているオブジェクトは、通常このルート集合に含まれません。すると、操作の途中で GC が走った瞬間にそのオブジェクトが回収され、ネイティブコードは解放済みメモリを触ってしまいます。この章では、この境界で生じる use-after-free の典型パターンと、その根本対策、そして目に見えにくいこの種のバグを確実に炙り出す方法を扱います。
なぜネイティブ境界が危ういのか¶
GC がオブジェクトを生かしておく根拠は「ルートから到達できること」です。JS コードの実行中は、スタック上の一時値・ローカル変数・グローバルなどがルートとして扱われるよう、エンジンが仕組みを用意しています。ところがネイティブコードの世界では事情が違います。
組み込み関数が 新しい配列を確保する と、その配列への参照はまずネイティブ関数のローカル変数(実装言語のスタック変数やレジスタ)に入ります。この段階では、配列はまだどこの到達可能なオブジェクトからもリンクされていません。エンジンの GC がネイティブスタックを走査してルートに含める作りになっていなければ、この配列は「誰からも到達できないゴミ」に見えます。
問題は、確保直後に別の確保が起きると顕在化します。多くの GC は割付点、すなわちメモリを確保しようとした瞬間に「足りなければ回収する」という戦略をとります。したがって次のような単純な流れでも危険が潜みます。
result = 新しい配列を確保 // ネイティブローカルにのみ存在
tmp = 新しい文字列を確保 // ここで GC が走りうる → result が回収される
result に tmp を書き込む // 解放済みメモリへの書き込み(use-after-free)
result はまだ到達可能なオブジェクトにつながっていないため、二つ目の確保が引き起こした GC のスイープ対象になります。回収後に result へ書き込めば、解放済み(あるいは別用途に再利用された)メモリを破壊します。
flowchart TB
A["result を確保(ネイティブのローカル変数のみが指す)"] --> B["tmp を確保しようとする"]
B --> C["割付点で GC が走る"]
C --> D["result はどのルートからも到達不能 → 回収される"]
D --> E["result に tmp を書き込む → 解放済みメモリを破壊"]
tmp の確保が誘発した GC が、まだどこにもリンクされていない result を回収し、その後の書き込みが解放済みメモリを破壊する時系列。
GC はどこで走るか — セーフポイント¶
この種のバグを見つける鍵は、「この二行の間で GC は走りうるか」を常に問うことです。GC はどこでも無制限に走るわけではなく、セーフポイントと呼ばれる、エンジンが自身の状態を一貫させられる地点でのみ走ります。代表的なセーフポイントは次の二つです。
- 割付点: オブジェクト・文字列・配列など、ヒープに何かを確保しようとする箇所。確保に先立って(あるいは確保が失敗したときに)回収を試みる。
- インタプリタループのバックエッジ: バイトコード実行ループが命令の境界に戻る箇所。長いループの途中でも定期的に回収や割り込みを受け付けられるよう、ここもセーフポイントにすることが多い。
したがってネイティブコードでは、「オブジェクトを確保する呼び出し」と「JS インタプリタへ再入する呼び出し」の二つが、GC を誘発しうる地点だと考えれば大きく外しません。逆に、純粋にビット演算や既に取得済みの生ポインタを触るだけの区間では GC は走りません。この区別を意識すると、危険なローカル変数がどれで、どの区間で保護が要るかが自然に絞り込めます。
use-after-free の典型パターン¶
ネイティブ境界の use-after-free は、いくつかの決まった形をとります。いずれも「到達可能なオブジェクトにまだつながっていない値」または「一時的にしか到達可能でない値」を、GC を挟んで触ってしまう構図です。
パターン1: 未リンクの結果オブジェクトをコールバック越しに失う¶
もっとも多いのがこれです。組み込み関数が結果オブジェクトを確保し、まだどこにもリンクしないうちに JS へ呼び戻し、その先で確保が起きて GC が走ります。
build = 新しいオブジェクトを確保 // build はネイティブローカルにのみ存在
value = ユーザ関数を呼ぶ(...) // コールバック内で確保 → GC → build が回収
build に value を設定する // 解放済みの build を破壊
JS への「呼び戻し」は明示的なコールバックだけではありません。次のような暗黙の再入がすべて該当します。
- プロパティ読み取りが getter を起動する。
- 値の型強制(
ToString/ToPrimitiveなどの過程で呼ばれるtoString/valueOf)がユーザコードを走らせる。 Proxyのトラップが起動する。- イテレーション中に
nextメソッドが呼ばれる。 - コンパレータ・コールバック関数(
sort・map・forEachなど)が呼ばれる。
これらはどれも「ネイティブコードから見れば単なる一行」でありながら、内部では任意の JS が走り、任意の量の確保が起きうる点が要注意です。
パターン2: 走査対象そのものを失う¶
反復系の組み込み関数は、走査している対象(ソート中の配列、列挙中のオブジェクトなど)を生ポインタで保持したまま各要素にコールバックを適用します。その対象が一時的にしか到達可能でない場合、コールバック内の確保で対象ごと回収されえます。
arr = 受け取った配列(ネイティブローカルの生ポインタ)
各要素 e について:
cmp(e, ...) を呼ぶ // ここで GC → arr 自体が回収されうる
arr の内部バッファを読み書きする // 解放済みバッファへのアクセス
要素を触るためにキャッシュしておいた「配列の内部バッファへの生ポインタ」も同じ理由で危険です。GC が移動型なら回収されずとも移動でポインタが無効になり、非移動型でも回収されればダングリングになります。コールバックをまたいで生ポインタをキャッシュしないのが原則です。
パターン3: スイープ済みの値を後から書き込む¶
三つ目は時間軸のずれによるものです。ある値を到達可能なオブジェクトへ格納するより前に GC が走り、その GC が既に当該値をスイープしていた場合、格納されるのは無効なポインタ(stale pointer)です。
このパターンが厄介なのは、格納した瞬間には何も起こらないことです。無効ポインタは到達可能オブジェクト内に潜み、後続の GC でそのフィールドをたどったとき、あるいは別のコードがそのフィールドを参照したときに初めて破綻します。原因(格納)と症状(参照)が時間的にも空間的にも離れるため、追跡が難しくなります。
対策: 危険な値をピン留めする¶
根本対策は共通しています。再入(コールバックや確保)をまたいで生かしておきたい値を、GC から見える場所に登録しておくことです。ネイティブローカルは GC から見えないので、見える場所へ「別ルートを一本追加する」わけです。
もっとも直接的な手法がピン留めです。エンジン内部に「ピン留めされた値」の集合を持ち、マークフェーズは常にこの集合を追加のルートとして走査します。危険な区間の入口で値をこの集合へ登録(ピン)し、区間を抜けるときに解除(アンピン)します。
pin(build) // build を GC 可視のルート集合へ登録
value = ユーザ関数を呼ぶ(...) // この間に GC が走っても build は生存
build に value を設定する
unpin(build) // 保護を解除
公開された組み込み境界では、個別のピン/アンピンよりハンドルスコープの形にまとめると扱いやすくなります。ネイティブ関数の入口でスコープを開き、その関数内で得た値はすべてスコープ経由のハンドル(GC 可視の間接参照)として扱い、関数を抜けるときにスコープごと閉じて一括解除します。これにより「アンピンし忘れ」を構造的に防げます。ピンとハンドルスコープは、GC 可視のルートを一時的に増やすという点で本質的に同じ仕組みであり、粒度と寿命管理が違うだけです。
対策を考えるうえで有用なのが、どの操作が再入(=GC 誘発)を招くかの一覧です。
| ネイティブから呼ぶ操作 | 再入・確保が起きるか | 保護が要る対象 |
|---|---|---|
| ヒープへの確保(オブジェクト/文字列/配列) | 起きる(割付点) | 直前に確保した未リンクの値 |
| プロパティ読み取り | getter があれば起きる | receiver・結果を組み立て中の値 |
値の型強制(toString/valueOf) |
ユーザ定義なら起きる | 強制前後で保持中の値 |
Proxy 操作 |
トラップで起きる | 対象・ハンドラ経由で触る値 |
イテレータの next |
常に起きうる | 反復対象・蓄積中の結果 |
| コールバック関数の呼び出し | 常に起きうる | 反復対象・結果・キャッシュした生ポインタ |
| 純粋なビット演算・取得済みポインタ参照 | 起きない | 不要 |
埋め込み API の契約¶
同じ問題は、エンジンをホストアプリケーションに組み込む境界でも生じます。ホスト側が JS の値を受け取り、それを GC の周期をまたいで保持したいなら、その値もルートとして登録しておかなければなりません。ここで用いるのが永続ハンドルです。
- ローカルハンドル(ハンドルスコープ内): 一つのネイティブ呼び出しの間だけ値を生かす。スコープを閉じると解除される。短命な一時値向け。
- 永続ハンドル: ホストが明示的に解放するまで値を生かす。GC の周期をまたいでホスト側が値を保持する場合に使う。解放し忘れるとリークになる。
埋め込み API を設計する側は、「どのハンドルがいつまで値を生かすのか」「値の所有権はホストとエンジンのどちらにあるのか」を契約として明文化する責任があります。ホストが値を保持し続けるなら、ホストがそれを根付かせる(root する)。これを曖昧にすると、ホスト側コードが一見正しく書かれていても、GC のタイミング次第で解放済みの値を触る不具合が生まれます。
| 手法 | 生存期間 | 主な用途 | 解除 |
|---|---|---|---|
| ピン留め | ピンからアンピンまでの区間 | 組み込み内部の再入区間の保護 | 明示的にアンピン |
| ハンドルスコープ | スコープを開いてから閉じるまで | 公開境界の一括保護 | スコープを閉じる |
| 永続ハンドル | ホストが解放するまで | GC 周期をまたぐホスト保持 | ホストが明示的に解放 |
実装上の罠: 見えないバグを確定検出する¶
この種のバグの最大の難しさは、通常の実行ではまず表面化しないことです。GC が「ちょうどその危険な瞬間」に走る確率は低く、多くの場合オブジェクトは回収されずに済んでしまいます。結果として、バグを含んだままでも普通に動き、広範な準拠性テスト(test262 のようなスイートを含む)を通過してしまうことすらあります。原因と症状が時間的に離れるパターン3では、たまに落ちるクラッシュのスタックトレースが真犯人をまったく指さない、典型的なハイゼンバグになります。
確定的に炙り出すには、二つの手段を組み合わせます。
- GC ストレスモード: ほぼすべての割付点で強制的に回収を走らせる特別モード。こうすると、コールバックや型強制の最中に必ず GC が走るため、保護漏れがあれば決まって回収が起き、バグが再現します。「たまに」ではなく「毎回」危険な瞬間に GC が来る状況を作るわけです。
- 解放オブジェクトのポイズニング/隔離: 回収したオブジェクトのメモリを既知の不正なビットパターンで埋める(ポイズン)、あるいはしばらく再利用せず隔離する。こうすると、ダングリングポインタをたどった瞬間にその場でフォールト(不正アクセス)します。ポイズンなしでは解放済み領域が別の生きたオブジェクトに再利用され、「読めてしまう」ために破綻が別の場所へ先送りされますが、ポイズンがあればまさにその参照箇所で落ちます。
この二つを併用すると、稀にしか起きないタイミング依存のハイゼンバグが、「特定のネイティブ操作を実行すると毎回・その行で落ちる」確定的で局所化されたクラッシュに変わります。原因箇所が症状箇所と一致するため、修正すべきピン留めの欠落をそのまま特定できます。GC ストレスは実行を大幅に遅くするため常用はできませんが、ネイティブ境界のコードを追加・変更したときの検証パスとして回す価値があります。この検出手法は第VI部「メモリ安全性の検出」でさらに掘り下げます。
もう一つの実務的な罠は、「安全そうに見える一行」の裏に再入が隠れていることです。プロパティ一つの読み取り、値一つの文字列化、要素一つの取り出しが、実は任意の JS を起動しうる。ネイティブ境界のコードをレビューするときは、行ごとに「ここで GC は走りうるか」を機械的に問い、走りうる行の前後で生きているべきローカル値がすべて GC 可視になっているかを確認するのが、もっとも確実な予防策です。
まとめ¶
- ネイティブコードのローカル変数だけが参照する JS オブジェクトはルート集合に含まれず、操作の途中で回収されうる。
- GC は割付点とインタプリタループのバックエッジといったセーフポイントで走る。「この二行の間で GC は走りうるか」を問うことがバグ発見の基本になる。
- use-after-free の典型は、(1) 未リンクの結果をコールバック越しに失う、(2) 走査対象そのものを失う、(3) スイープ済みの値を後から格納する、の三つ。コールバックだけでなく getter・型強制・
Proxy・イテレータといった暗黙の再入がすべて引き金になる。 - 対策は、再入をまたいで生かしたい値を GC 可視の集合へ登録すること。区間単位のピン留め、公開境界のハンドルスコープ、ホスト保持のための永続ハンドルを使い分ける。
- 埋め込み API は値の生存期間と所有権を契約として明文化し、ホストが保持する値はホストが根付かせる。
- これらのバグはタイミング依存で通常の実行や準拠性テストをすり抜ける。GC ストレスと解放オブジェクトのポイズニングを併用すると、ハイゼンバグを確定的で局所化されたクラッシュに変えられる。
参考文献¶
- ECMA-262: ToPrimitive — 型強制がユーザ定義の
toString/valueOfを起動する経路の一次資料。