メインコンテンツへスキップ

Electron と V8 メモリケージ

7 分で読めます

Electron 21 以降では、V8 メモリケージが有効になり、一部のネイティブモジュールに影響があります。


更新 (2022/11/01)

Electron 21 以降でのネイティブモジュールの使用に関する継続的な議論を追跡するには、electron/electron#35801 を参照してください。

Electron 21 では、Chrome 103 で同じことを行うという決定に従い、Electron でV8 サンドボックスポインタを有効にします。これは、ネイティブモジュールにいくつかの影響を与えます。また、以前に Electron 14 で関連技術であるポインタ圧縮を有効にしました。そのときはあまり詳しく説明しませんでしたが、ポインタ圧縮は V8 ヒープの最大サイズに影響があります。

これらの2つの技術は、有効にすると、セキュリティ、パフォーマンス、メモリ使用量に非常に有益です。ただし、有効にすることにはいくつかの欠点もあります。

サンドボックスポインタを有効にする主な欠点は、外部(「オフヒープ」)メモリを指す ArrayBuffer が許可されなくなることです。これは、V8 のこの機能に依存するネイティブモジュールは、Electron 20 以降で引き続き動作するようにリファクタリングする必要があることを意味します。

ポインタ圧縮を有効にする主な欠点は、V8 ヒープが最大 4GB のサイズに制限されることです。この正確な詳細は少し複雑です。たとえば、ArrayBuffer は V8 ヒープの残りの部分とは別にカウントされますが、独自の制限があります。

Electron アップグレードワーキンググループは、ポインタ圧縮と V8 メモリケージの利点が欠点を上回ると考えています。そうする主な理由は3つあります。

  1. Electron を Chromium に近づけることができます。V8 の構成などの複雑な内部詳細で Electron が Chromium から乖離するほど、バグやセキュリティ脆弱性を誤って導入する可能性が低くなります。Chromium のセキュリティチームは手ごわく、彼らの作業を活用できるようにしたいと考えています。さらに、バグが Chromium で使用されていない構成にのみ影響する場合、それを修正することは Chromium チームにとって優先事項になる可能性は低いでしょう。
  2. パフォーマンスが向上します。ポインタ圧縮により、V8 ヒープサイズが最大 40% 削減され、CPU および GC パフォーマンスが 5%〜10% 向上します。4GB のヒープサイズ制限に達することなく、外部バッファを必要とするネイティブモジュールを使用しないほとんどの Electron アプリケーションにとって、これらは大幅なパフォーマンス向上となります。
  3. より安全になります。一部の Electron アプリは信頼されていない JavaScript を実行します(私たちのセキュリティ推奨事項に従うことを願っています!)。それらのアプリでは、V8 メモリケージを有効にすることで、多数の厄介な V8 脆弱性から保護されます。

最後に、本当に大きなヒープサイズが必要なアプリには回避策があります。たとえば、ポインタ圧縮を無効にしてビルドされた Node.js のコピーをアプリに含め、メモリ負荷の高い作業を子プロセスに移動することができます。やや複雑ですが、特定のユースケースで別のトレードオフが必要な場合は、ポインタ圧縮を無効にしたカスタムバージョンの Electron をビルドすることも可能です。最後に、それほど遠くない将来に、wasm64を使用すると、WebAssembly で構築された Web および Electron のアプリは、4GB を大幅に超えるメモリを使用できるようになります。


よくある質問

この変更によってアプリが影響を受けるかどうかをどのように判断すればよいですか?

外部メモリを ArrayBuffer でラップしようとすると、Electron 20 以降で実行時にクラッシュします。

アプリでネイティブ Node モジュールを使用していない場合は安全です。純粋な JS からこのクラッシュをトリガーする方法はありません。この変更は、V8 ヒープの外部でメモリを割り当て(例:malloc または new を使用)、外部メモリを ArrayBuffer でラップするネイティブ Node モジュールにのみ影響します。これはかなりまれなユースケースですが、一部のモジュールはこの手法を使用しており、そのようなモジュールは Electron 20 以降と互換性を持つようにリファクタリングする必要があります。

アプリが 4GB 制限に近いかどうかを知るために、アプリが使用している V8 ヒープメモリの量をどのように測定できますか?

レンダラープロセスでは、performance.memory.usedJSHeapSizeを使用できます。これは、V8 ヒープの使用量をバイト単位で返します。メインプロセスでは、process.memoryUsage().heapUsedを使用できます。これは比較可能です。

V8 メモリケージとは何ですか?

一部のドキュメントでは「V8 サンドボックス」と呼んでいますが、この用語は Chromium で発生する他の種類のサンドボックスと混同しやすいため、「メモリケージ」という用語を使用します。

次のような、かなり一般的な種類の V8 エクスプロイトがあります。

  1. V8 の JIT エンジンでバグを見つけます。JIT エンジンは、遅いランタイム型チェックを省略し、高速な機械コードを生成できるように、コードを分析します。ロジックエラーが発生すると、この分析が間違っている場合があり、実際には必要な型チェックを省略します。たとえば、x が文字列であると考えていますが、実際にはオブジェクトです。
  2. この混乱を利用して、V8ヒープ内のメモリの一部、例えばArrayBufferの先頭へのポインタを上書きします。
  3. これで、好きな場所を指すArrayBufferを手に入れたことになります。つまり、プロセス内のあらゆるメモリ、通常V8がアクセスできないメモリでさえも読み書きできます。

V8メモリケージは、この種の攻撃を根本的に防ぐために設計された技術です。これは、V8ヒープ内にポインタを一切格納しないことによって実現されます。代わりに、V8ヒープ内の他のメモリへの参照はすべて、予約された領域の先頭からのオフセットとして格納されます。そのため、たとえ攻撃者がV8の型混同エラーなどを悪用してArrayBufferのベースアドレスを破損させたとしても、最悪の場合でもケージ内のメモリを読み書きする程度で済みます。これは、おそらく最初からできていたことです。V8メモリケージの仕組みについては、他にも多くの情報が公開されているので、ここでは詳細には触れません。まずはChromiumチームによる高レベル設計ドキュメントから読み始めるのが良いでしょう。

Nodeネイティブモジュールをリファクタリングして、Electron 21+をサポートしたいのですが、どうすればよいですか?

ネイティブモジュールをリファクタリングしてV8メモリケージと互換性を持たせる方法は2つあります。1つ目は、外部で作成されたバッファをJavaScriptに渡す前に、V8メモリケージにコピーすることです。これは一般的に簡単なリファクタリングですが、バッファが大きい場合は遅くなる可能性があります。もう1つの方法は、最終的にJavaScriptに渡す予定のメモリをV8のメモリアロケータを使用して割り当てることです。こちらは少し手間がかかりますが、コピーを回避できるため、大きなバッファの場合はパフォーマンスが向上します。

これをより具体的にするために、外部配列バッファを使用するN-APIモジュールの例を以下に示します。

// Create some externally-allocated buffer.
// |create_external_resource| allocates memory via malloc().
size_t length = 0;
void* data = create_external_resource(&length);
// Wrap it in a Buffer--will fail if the memory cage is enabled!
napi_value result;
napi_create_external_buffer(
env, length, data,
finalize_external_resource, NULL, &result);

これは、データがケージ外に割り当てられているため、メモリケージが有効になっているとクラッシュします。代わりにデータをケージ内にコピーするようにリファクタリングすると、以下のようになります。

size_t length = 0;
void* data = create_external_resource(&length);
// Create a new Buffer by copying the data into V8-allocated memory
napi_value result;
void* copied_data = NULL;
napi_create_buffer_copy(env, length, data, &copied_data, &result);
// If you need to access the new copy, |copied_data| is a pointer
// to it!

これにより、データはV8メモリケージ内にある新たに割り当てられたメモリ領域にコピーされます。オプションで、N-APIは新しくコピーされたデータへのポインタも提供できます。後で変更したり参照したりする必要がある場合に便利です。

V8のメモリアロケータを使用するようにリファクタリングするのは、少し複雑になります。これは、create_external_resource関数を、mallocを使用するのではなくV8によって割り当てられたメモリを使用するように変更する必要があるためです。create_external_resourceの定義を制御しているかどうかによって、これが実現可能かどうかは異なります。考え方は、まずV8を使用してバッファを作成し(例:napi_create_bufferを使用)、次にV8によって割り当てられたメモリにリソースを初期化することです。リソースの寿命の間、Bufferオブジェクトへのnapi_refを保持することが重要です。そうしないと、V8がBufferをガベージコレクションし、use-after-freeエラーが発生する可能性があります。