メインコンテンツまでスキップ

V8サンドボックス

· 約18分
サミュエル・グロース

約3年前の初期設計ドキュメントと、それまでの間に作成された数百のCLにより、V8のインプロセス・サンドボックス — V8の軽量なインプロセスサンドボックス — は実験的なセキュリティ機能とみなされなくなる段階に達しました。本日より、V8サンドボックスはChromeの脆弱性報奨プログラム (VRP) に含まれるようになりました。強力なセキュリティ境界を形成するにはまだいくつかの問題を解決する必要がありますが、VRPへの含有はその方向性への重要な一歩です。したがって、Chrome 123はある種の「ベータ」リリースと見なされるでしょう。このブログ記事では、サンドボックスの背景にある動機を説明し、それがV8内のメモリ破壊がホストプロセス全体に広がることをどのように防ぐかを示し、最終的に、なぜこれがメモリ安全性への必要不可欠なステップであるかを説明します。

メモリ安全性は依然として関連する問題です: 過去3年間(2021年~2023年)に野生で捕捉されたすべてのChromeエクスプロイトは、リモートコード実行(RCE)に悪用されたChromeのレンダラープロセス内でのメモリ破壊脆弱性から始まっています。これらのうち、60%はV8内の脆弱性によるものでした。しかし、ここに1つの問題があります: V8の脆弱性は「古典的な」メモリ破壊バグ(Use-After-Free、範囲外アクセスなど)であることはほとんどなく、それに代わってメモリを損傷するために利用される微妙なロジック問題が主原因です。そのため、既存のメモリ安全性ソリューションの多くはV8には適用できません。特に、Rustなどのようなメモリ安全な言語への移行や、メモリタグ付けなどの現在または将来のハードウェアメモリ安全性機能の使用は、現在V8が直面しているセキュリティ課題にはあまり役立たないでしょう。

その理由を理解するために、非常に簡略化された仮想的なJavaScriptエンジンの脆弱性を考えてみましょう: 配列内の3で割り切れる値を「fizz」、5で割り切れる値を「buzz」、3と5の両方で割り切れる値を「fizzbuzz」に置き換えるJSArray::fizzbuzz()の実装です。以下はその関数をC++で実装したものです。JSArray::buffer_JSValue*、つまりJavaScript値の配列へのポインタとして考えることができ、JSArray::length_にはそのバッファーの現在のサイズが含まれています。

 1. for (int index = 0; index < length_; index++) {
2. JSValue js_value = buffer_[index];
3. int value = ToNumber(js_value).int_value();
4. if (value % 15 == 0)
5. buffer_[index] = JSString("fizzbuzz");
6. else if (value % 5 == 0)
7. buffer_[index] = JSString("buzz");
8. else if (value % 3 == 0)
9. buffer_[index] = JSString("fizz");
10. }

簡単そうに思えるでしょうか?しかし、このコードには微妙なバグがあります: 行3のToNumber変換に副作用があり、ユーザー定義のJavaScriptコールバックを呼び出す可能性があります。このようなコールバックは配列を縮小する可能性があり、その結果その後の範囲外書き込みが発生します。次のJavaScriptコードは、おそらくメモリ損傷を引き起こします:

let array = new Array(100);
let evil = { [Symbol.toPrimitive]() { array.length = 1; return 15; } };
array.push(evil);
// インデックス100で、|evil|の@@toPrimitiveコールバックが上記の
// 行3で呼び出され、配列が長さ1に縮小されバックバッファが再割り当てされます。
// その後の書き込み(行5)は範囲外アクセスとなります。
array.fizzbuzz();

この脆弱性は、手書きのランタイムコード(上記の例のように)でも、最適化されたJITコンパイラによってランタイム時に生成されるマシンコード(もしその関数がJavaScriptで実装されていた場合)でも発生する可能性があります。前者の場合、プログラマーはストア操作のための明示的な境界チェックが不要だと結論づけるでしょう。ただそのインデックスが直前にアクセスされているからです。後者の場合、コンパイラはその1つの最適化パス(例えば冗長性除去境界チェック除去)中にToNumber()の副作用を正しくモデル化しないため、同じ誤った結論を引き出す可能性があります。

このバグは人工的に簡略化されたものです(この特定のバグパターンは、ファジングツールの改善、開発者の認識、研究者の関心の高まりによりほぼ絶滅しましたが)、現代のJavaScriptエンジンにおける脆弱性がいかに汎用的な方法で緩和するのが難しいかを理解することには依然として価値があります。例えば、Rustのようなメモリ安全な言語を使用するアプローチを考えてみてください。この場合、コンパイラはメモリの安全性を保証する責任を負っています。上記の例では、メモリ安全な言語はインタプリタが使用する手書きのランタイムコード中でこのバグを回避する可能性があります。しかしながら、もしバグがJITコンパイラ内にある場合、それは論理的な問題であり、「典型的な」メモリ破損の脆弱性ではありません。その場合、コンパイラによって生成されたコードだけがメモリ破損を引き起こします。根本的な問題は、コンパイラが直接攻撃対象となる場合、コンパイラによるメモリ安全性の保証は不可能になるという点にあります。

同様に、JITコンパイラを無効化することも部分的な解決策にすぎません。歴史的には、V8で発見・利用されたバグの約半分がコンパイラに影響を及ぼし、残りはランタイム関数、インタプリタ、ガベージコレクタ、またはパーサなど、他のコンポーネントに存在していました。これらのコンポーネントにメモリ安全な言語を使用し、JITコンパイラを削除することは可能ですが、エンジンの性能を大幅に低下させます。(負荷の種類にもよりますが、計算集約型タスクの場合1.5~10倍以上遅くなる可能性があります。)

次に、人気のあるハードウェアセキュリティメカニズム、特にメモリタグ付けを検討してください。メモリタグ付けが同様に効果的な解決策ではない理由がいくつかあります。例えば、CPUサイドチャネル(JavaScriptから簡単に利用可能)を悪用してタグ値を漏洩させることで攻撃者が緩和策を回避する可能性があります。さらに、ポインタ圧縮のため、V8のポインタにはタグビットのスペースが現在ありません。このため、ヒープ全体を同じタグでタグ付けする必要があり、オブジェクト間の破損を検出することが不可能になります。このように、メモリタグ付けが特定の攻撃表面上で非常に効果的である場合はありますが、JavaScriptエンジンの場合に攻撃者にとって大きな障害とはならない可能性があります。

まとめると、現代のJavaScriptエンジンは強力な攻撃方法を提供する複雑な二次的な論理バグを含む傾向があります。これらは典型的なメモリ破損脆弱性に使用される同じ技術では効果的に保護できません。ただし、今日V8で発見・利用されるほぼ全ての脆弱性には、最終的なメモリ破損がV8のヒープ内部で必然的に発生するという共通点があります。というのも、コンパイラとランタイムが(ほぼ)V8のHeapObjectインスタンス上でのみ動作するからです。ここでサンドボックスの概念が登場します。

V8 (Heap) サンドボックス

サンドボックスの基本的な考え方は、V8の(ヒープ)メモリを隔離し、その中で発生するメモリ破損がプロセスの他の部分に「広がらない」ようにすることです。

サンドボックス設計の動機付けの例として、現代のオペレーティングシステムにおけるユーザースペースとカーネルスペースの分離を考慮してください。歴史的には、すべてのアプリケーションとオペレーティングシステムのカーネルは同じ(物理的な)メモリアドレス空間を共有していました。このため、ユーザーアプリケーションでのメモリエラーは、例えばカーネルメモリを破損させることでシステム全体を崩壊させる可能性がありました。一方、現代のオペレーティングシステムでは、各ユーザースペースアプリケーションは専用の(仮想的な)アドレス空間を持ちます。このため、メモリエラーはアプリケーション自体に限定され、システムの残りは保護されます。言い換えれば、フォールトが発生したアプリケーションはクラッシュしますが、システムの残りには影響を与えません。同様に、V8サンドボックスは、V8が実行する信頼できないJavaScript/WebAssemblyコードを隔離し、V8にバグがあってもホスティングプロセスの残りには影響を与えないようにすることを目指します。

基本的に、サンドボックスはハードウェアサポートを伴って実装することができます。例えば、ユーザーランドとカーネルの分離のように、V8はサンドボックス化されたコードに出入りするときにモード切り替え命令を実行し、それによってCPUがサンドボックス外のメモリにアクセスできなくなります。ただし、現時点で適切なハードウェア機能は利用可能ではなく、現在のサンドボックスは完全にソフトウェアで実装されています。

ソフトウェアベースのサンドボックスの基本的な考え方は、サンドボックス外のメモリにアクセスできる可能性のあるすべてのデータ型を「サンドボックス対応」の代替品に置き換えることです。特に、V8ヒープ上または他のメモリ内のオブジェクトへのポインタや64ビットサイズをすべて削除する必要があります。これらが攻撃者により改ざんされることで、後にプロセス内の他のメモリにアクセスできる可能性があるからです。これにより、スタックのようなメモリ領域はハードウェアやOSの制約によりポインタ(例えば、戻りアドレス)を保持する必要があるため、サンドボックス内には含まれません。このため、ソフトウェアベースのサンドボックス内に含まれるのはV8ヒープのみであり、全体の構造はWebAssemblyが使用するサンドボックスモデルに似ています。

これが実際にどのように機能するかを理解するには、メモリが破損した後にエクスプロイトが実行しなければならないステップを確認することが役立ちます。RCEエクスプロイトの目標は通常、シェルコードを実行したり、リターン指向プログラミング(ROP)スタイルの攻撃を実行したりすることで権限昇格攻撃を行うことです。これらのいずれかを実行するために、エクスプロイトはまずプロセス内の任意のメモリを読み書きできる能力を必要とします。たとえば、関数ポインターを破損させたり、メモリ内のどこかにROPペイロードを配置してそれに移行したりするためです。V8のヒープ上でメモリを破損するバグがある場合、攻撃者は以下のようなオブジェクトを探します。

class JSArrayBuffer: public JSObject {
private:
byte* buffer_;
size_t size_;
};

これを基に、攻撃者は任意の読み書きプリミティブを構築するためにバッファポインターまたはサイズの値を破損させます。これがサンドボックスが防止しようとするステップです。特に、サンドボックスが有効になり、参照されているバッファがサンドボックス内にあると仮定すると、上記のオブジェクトは次のようになります。

class JSArrayBuffer: public JSObject {
private:
sandbox_ptr_t buffer_;
sandbox_size_t size_;
};

sandbox_ptr_tはサンドボックスのベースからの40ビットのオフセット(1TBのサンドボックスの場合)です。同様に、sandbox_size_tは「サンドボックス互換」のサイズであり、現在32GBに制限されています。 また、参照されるバッファがサンドボックス外にある場合、オブジェクトは次のようになります。

class JSArrayBuffer: public JSObject {
private:
external_ptr_t buffer_;
};

external_ptr_tはポインタテーブルで間接参照を通じてバッファ(そのサイズ)を参照し、メモリ安全性を保証します(ユニックスカーネルのファイル記述子テーブルWebAssembly.Tableに似ています)。

どちらの場合でも、攻撃者はサンドボックスから「手を伸ばして」アドレス空間の他の部分に到達することができません。その代わりに、追加の脆弱性、つまりV8サンドボックスバイパスが必要になります。以下の画像は高レベルのデザインをまとめており、興味のある読者はsrc/sandbox/README.mdからリンクされている設計文書でサンドボックスに関する技術的な詳細を見つけることができます。

サンドボックス設計の高レベルな図

ポインターとサイズを別の表現に変換するだけでは、V8のように複雑なアプリケーションでは完全には十分ではありません。また、修正が必要ないくつかの他の問題もあります。たとえば、サンドボックスの導入により、次のようなコードが突然問題になる場合があります。

std::vector<std::string> JSObject::GetPropertyNames() {
int num_properties = TotalNumberOfProperties();
std::vector<std::string> properties(num_properties);

for (int i = 0; i < NumberOfInObjectProperties(); i++) {
properties[i] = GetNameOfInObjectProperty(i);
}

// 他の種類のプロパティを処理
// ...

このコードは、JSObjectに直接格納されているプロパティの数がそのオブジェクトの全体的なプロパティの数より少ないはずだという(合理的な)仮定をしています。しかし、この数値が単にJSObjectのどこかに整数として格納されていると仮定すると、攻撃者がそれを破損してこの不変条件を破壊する可能性があります。その結果、(サンドボックス外の)std::vectorへのアクセスが境界外になります。たとえば、SBXCHECKを使用して明示的な境界チェックを追加するとこれを修正できます。

励みとなることで、これまでに発見されたほぼすべての「サンドボックス違反」はこのようなものです。境界チェックの欠如による範囲外アクセスや、use-after-freeなどのトリビアルな(1次の)メモリ破損バグです。典型的にはV8で見つかる2次の脆弱性とは対照的に、これらのサンドボックスバグは実際には前述のアプローチによって防止または緩和できる可能性があります。実際、上記の特定のバグは現在ではChromeのlibc++強化によりすでに緩和されています。このようにして、長期的には、サンドボックスがV8そのものよりも防御可能なセキュリティ境界になることが期待されています。現在利用可能なサンドボックスバグのデータセットは非常に限定的ですが、今日開始するVRP統合により、サンドボックス攻撃面で遭遇する脆弱性の種類に関するより明確な画像が生成されることが期待されています。

パフォーマンス

このアプローチの主な利点の1つは、基本的にコストが低いことです。サンドボックスによるオーバーヘッドは主に外部オブジェクトのポインタテーブル間接参照によって発生します(追加のメモリ読み込み1回分程度)およびポインターの代わりにオフセットを使用することによるオーバーヘッド(主にシフト+加算操作だけであり、非常に安価)にあります。そのため、サンドボックスの現在のオーバーヘッドは、SpeedometerおよびJetStreamベンチマークスイートを使用して測定した典型的なワークロードでは約1%以下です。これにより、V8サンドボックスは互換性のあるプラットフォームでデフォルトで有効にできます。

テスト

どのようなセキュリティ境界においても望ましい機能の1つがテスト可能性です。つまり、約束されたセキュリティ保証が実際に機能していることを手動および自動でテストできる能力です。これには明確な攻撃者モデル、攻撃者を「模倣」する方法、理想的にはセキュリティ境界が失敗した際に自動検出する方法が必要です。V8 Sandboxはこれらすべての要件を満たしています:

  1. 明確な攻撃者モデル: 攻撃者がV8 Sandbox内で自由に読み書きできるものと想定します。目標は、サンドボックス外でのメモリ破損を防ぐことです。
  2. 攻撃者を模倣する方法: V8はv8_enable_memory_corruption_api = trueフラグを有効にしてビルドする際に「メモリ破損API」を提供します。これにより、一般的なV8の脆弱性から得られるプライミティブを模倣し、特にサンドボックス内での完全な読み書きアクセスを提供します。
  3. 「サンドボックス違反」を検出する方法: V8は「サンドボックステスト」モード(--sandbox-testingまたは--sandbox-fuzzingで有効化)を提供します。このモードではシグナルハンドラをインストールし、SIGSEGVなどのシグナルがサンドボックスのセキュリティ保証違反を表しているかを判断します。

最終的には、この仕組みにより、サンドボックスをChromeのVRPプログラムに統合し、専門的なファジングツールによるファジングテストを可能にします。

使用法

V8 Sandboxは、v8_enable_sandboxビルドフラグを使用してビルド時に有効/無効にしなければなりません。(技術的な理由により)サンドボックスを実行時に有効/無効化することはできません。V8 Sandboxは大量の仮想アドレス空間(一現在1テラバイト)を確保する必要があるため、64ビットシステムを必要とします。

V8 Sandboxはすでに約2年間、Android、ChromeOS、Linux、macOS、Windows上の64ビット版(特にx64およびarm64)Chromeでデフォルトで有効化されています。サンドボックスが(現在も)完全な機能を持っていないにもかかわらず、これが行われたのは主に安定性の問題を引き起こさないことを確認し、実際のパフォーマンス統計を収集するためでした。その結果、最近のV8エクスプロイトはすでにサンドボックスを通過せざるを得ず、それによりセキュリティプロパティについての有益な早期フィードバックをもたらしました。

結論

V8 Sandboxは、新しいセキュリティメカニズムであり、V8内でのメモリ破損がプロセス内の他のメモリに影響を与えるのを防止することを目的としています。このサンドボックスは、現在のメモリ安全技術がJavaScriptエンジンの最適化にはほとんど適用できないという事実に動機付けられています。これらの技術がV8自体でのメモリ破損を防ぐことに失敗しても、V8 Sandboxの攻撃対象面を保護することは可能です。したがって、サンドボックスはメモリ安全性への必要なステップといえます。