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

C++における時間的メモリ安全性のレトロフィット

· 約15分
Anton Bikineev, Michael Lippautz ([@mlippautz](https://twitter.com/mlippautz)), Hannes Payer ([@PayerHannes](https://twitter.com/PayerHannes))
注記

注: 本投稿は元々Google Security Blogに投稿された内容です。

Chromeにおけるメモリ安全性は、ユーザーを保護するために絶え間なく進行中の取り組みです。私たちは常に悪意のある行為者を一歩先んじるために、さまざまな技術を試験しています。その一環として、本投稿ではC++のメモリ安全性を向上させるためにヒープスキャンニング技術を使用した私たちの取り組みについて紹介します。

しかしながら、最初から始めましょう。アプリケーションのライフサイクルを通して、その状態は一般的にメモリに表現されます。時間的メモリ安全性とは、常に最新のその構造や型に関する情報に基づいてメモリにアクセスすることを保証する問題を指します。残念ながら、C++はそのような保証を提供していません。C++よりも強いメモリ安全性を提供する異なる言語への関心はあるものの、Chromiumのような大規模なコードベースでは、将来的にもC++が使用され続けるでしょう。

auto* foo = new Foo();
delete foo;
// fooが指すメモリ領域は、オブジェクトが削除(解放)されたため、
// もはやFooオブジェクトを表していません。
foo->Process();

上記の例では、fooは、そのメモリが基盤システムに戻された後に使用されています。この古いポインタはダングリングポインタと呼ばれ、そのポインタを通してアクセスすると、use-after-free (UAF)アクセスが発生します。最善の場合、このようなエラーは明確にクラッシュを引き起こし、最悪の場合には悪意ある行為者に悪用される可能性のある微妙な破損を引き起こします。

UAFは、オブジェクトの所有権がさまざまなコンポーネント間で転送される大規模なコードベースでは、しばしば発見が困難です。この一般的な問題は非常に広範囲に及び、そのため産業界と学術界の両方が定期的に緩和策を模索しています。その例は尽きることがありません。C++のさまざまな種類のスマートポインタは、アプリケーションレベルでの所有権をより良く定義し管理するために使用されます。コンパイラによる静的解析は、問題のあるコードが最初からコンパイルされないようにするために使用されます。静的解析が失敗する場合、動的ツール、例えばC++サニタイザなどがアクセスをインターセプトし、特定の実行時に問題を捕捉します。

ChromeのC++使用もここでは例外ではなく、深刻なセキュリティバグの大多数はUAF問題です。これらの問題がプロダクションに到達する前に捕捉するために、上述した技術すべてが使用されています。通常のテストに加えて、ファッザーが常に動的ツールで作業する新しい入力を提供することを保証しています。Chromeはさらに進んで、Oilpanと呼ばれるC++ガベージコレクターを採用しており、これは通常のC++のセマンティクスとは異なりますが、使用される範囲で時間的メモリ安全性を提供します。このような逸脱が妥当でない場合、新しい種類のスマートポインタであるMiraclePtrが最近導入され、使用時にダングリングポインタへのアクセスを決定論的にクラッシュさせるようになりました。Oilpan、MiraclePtr、およびスマートポインタベースのソリューションは、アプリケーションコードの大幅な変更を必要とします。

過去10年間、別のアプローチがある程度の成功を収めてきました。それはメモリ隔離です。この基本的なアイデアは、明示的に解放されたメモリを隔離し、特定の安全条件が満たされた場合にのみそれを利用可能にするというものです。Microsoftはこの緩和策のバージョンをブラウザに組み込みました: 2014年にはInternet ExplorerにMemoryProtectorを導入し、2015年には(pre-Chromium) Edgeにその後継であるMemGCを導入しました。Linuxカーネルでは、メモリが最終的にリサイクルされる確率的アプローチが使用されました。そしてこのアプローチは、近年学術界からMarkUs 論文として注目されてきました。本記事の残りの部分では、Chromeでの隔離とヒープスキャンニングについての取り組みをまとめています。

(この時点で、メモリタグ付けがこの仕組みにどう組み込まれるのか気になる方もいると思います——ぜひ続きを読んでください!)

隔離とヒープスキャンの基本

隔離とヒープスキャンによって時間的安全性を確保する背後にある主なアイデアは、(未解決の)ポインタで参照されていないことが証明されるまでメモリを再利用しないようにすることです。C++のユーザーコードやそのセマンティクスを変更せずに、newdeleteを提供するメモリアロケータをインターセプトします。

図1: 隔離の基本

deleteが呼び出されると、メモリは実際には隔離され、アプリケーションによる後続のnew呼び出しのために再利用できなくなります。ある時点でヒープスキャンがトリガーされ、ヒープ全体をスキャンして、隔離されたメモリブロックへの参照を探します。通常のアプリケーションメモリから参照のないブロックはアロケータに戻され、後続の割り当てに再利用されます。

次のような性能コストを伴うさまざまな強化オプションがあります:

  • 特別な値で隔離されたメモリを上書きする(例:ゼロ値);
  • スキャン時にすべてのアプリケーションスレッドを停止するか、並列スキャンを行う;
  • ポインタ更新を検出するためにメモリ書き込みをインターセプトする(例:ページ保護による);
  • 保守的処理として可能性のあるポインタをメモリワードごとにスキャンするか、正確な処理としてオブジェクトの記述子を提供する;
  • 安全なパーティションと非安全なパーティションにアプリケーションメモリを分離し、パフォーマンスに敏感なオブジェクトや安全性が静的に証明されたオブジェクトを除外する;
  • ヒープメモリのスキャンに加えて、実行スタックのスキャンを行う;

これらのアルゴリズムの異なるバージョンをまとめてStarScan [stɑː skæn]、または短く**Scan*と呼びます。

現実の確認

私たちはレンダラープロセスの管理されていない部分に*Scanを適用し、その性能影響を評価するためにSpeedometer2を使用しました。

私たちは*Scanの異なるバージョンを試しましたが、性能オーバーヘッドを可能な限り最小化するために、ヒープをスキャンするための専用スレッドを使用し、deleteで隔離されたメモリを早急に消去するのを避け、代わりに*Scan実行時に消去する設定を評価しました。最初の実装では簡単化のためnewで割り当てられたすべてのメモリを対象とし、割り当てサイトやタイプを区別しません。

図2: 専用スレッドによるスキャン

提案された*Scanのバージョンが完全ではないことに注意してください。具体的には、悪意のあるアクターがスキャンスレッドとの競合状態を悪用して、未スキャン領域からすでにスキャンされたメモリ領域へダングリングポインタを移動する可能性があります。この競合状態を解決するためには、すでにスキャンされたメモリブロックへの書き込みを追跡する必要があります。例えば、メモリ保護メカニズムを使用してこれらのアクセスをインターセプトするか、オブジェクトグラフ全体の変異を防ぐためにすべてのアプリケーションスレッドをセーフポイントで停止します。いずれにしても、この問題を解決すると性能コストが発生し、興味深い性能とセキュリティのトレードオフが浮上します。この種の攻撃は一般的ではなく、すべてのUAFに対して機能するわけではありません。例えば導入部分で示した問題は、この種の攻撃には影響されません。ダングリングポインタが転送されないためです。

セキュリティの利点はそのセーフポイントの粒度に依存し、可能な限り最速のバージョンを試したいので、セーフポイントを完全に無効にしました。

Speedometer2で基本バージョンを実行すると、総スコアが8%低下します。残念…

このオーバーヘッドはどこから来るのでしょうか?予想通り、ヒープスキャンはメモリ依存型であり、スキャンスレッドによって参照を検査するためにユーザーメモリ全体を歩き回らなければならないのでかなり高価です。

回帰を減らすために、生のスキャン速度を改善するさまざまな最適化を実装しました。自然に一番速い方法はスキャンをまったく行わないことであるため、ヒープを2つのクラスに分割しました:ポインタを含む可能性のあるメモリと、ポインタを含まないことを静的に証明できるメモリ(例:文字列)。ポインタを含む可能性がないメモリのスキャンを避けます。このようなメモリは隔離の一部ではありますが、スキャンされません。

この仕組みを拡張して、他のアロケータの基盤メモリとして機能する割り当てもカバーするようにしました。例えば、JavaScriptコンパイラ用にV8によって管理されるゾーンメモリです。このようなゾーンは常に一括廃棄され(地域ベースのメモリ管理を参照)、V8では別の手段によって時間的安全性が確立されています。

さらに、計算量をスピードアップし排除するためにいくつかのマイクロ最適化を適用しました。ポインタフィルタリング用のヘルパーテーブルを使用し、メモリ依存型スキャンループのためにSIMDを利用し、フェッチ数とロック接頭辞付き命令を最小化しました。

最初のスケジューリングアルゴリズムでは、一定の制限に達した際にヒープスキャンを開始するだけでしたが、アプリケーションコードの実行とスキャンに割り当てる時間の比率を調整することで改善しました(ガベージコレクションの文献におけるミューテーター利用率を参照)。

最終的に、このアルゴリズムは依然としてメモリに依存しており、スキャンは目立ってコストがかかる処理のままです。これらの最適化により、Speedometer2のパフォーマンス低下が8%から2%にまで減少しました。

生のスキャン時間を改善したものの、メモリが隔離されるため、プロセスの作業セット全体が増加します。このオーバーヘッドをさらに定量化するために、選択されたChromeの実際のブラウジングベンチマークを使用してメモリ消費量を測定しました。*レンダラープロセスでのスキャンはメモリ消費量を約12%退化させます。この作業セットの増加が、アプリケーションの高速処理パスで顕著に発生するメモリページの読み込みを引き起こします。

ハードウェアメモリタグ付けによる解決

MTE(Memory Tagging Extension)は、ARM v8.5Aアーキテクチャにおける新しい拡張機能で、ソフトウェアのメモリ使用エラーを検出するのに役立ちます。これらのエラーは、空間的エラー(例: 範囲外アクセス)または時間的エラー(過去の解放の使用)である可能性があります。拡張機能は以下のように動作します。メモリの各16バイトには4ビットのタグが割り当てられます。ポインタにも4ビットのタグが割り当てられます。アロケーターは、割り当てられたメモリと同じタグを持つポインタを返す責任を負います。ロードおよびストア命令は、ポインタとメモリタグが一致するかを検証します。メモリ位置のタグとポインタのタグが一致しない場合、ハードウェア例外が生成されます。

MTEは過去の解放の使用に対して決定的な保護を提供するわけではありません。タグビットの数が有限であるため、メモリとポインタのタグがオーバーフローによって一致する可能性があります。4ビットでは、タグが一致するには16回の再割り当てで十分です。悪意のあるアクターは、タグビットのオーバーフローを利用して、保留ポインタのタグが(再び)それが指しているメモリと一致するまで待つことで過去の解放の使用を達成する可能性があります。

*Scanはこの問題の隅々にわたるケースを修正するために使用できます。各delete呼び出しで、基盤となるメモリブロックのタグがMTEメカニズムによってインクリメントされます。ブロックはほとんどの場合再割り当て可能で、タグは4ビット範囲内でインクリメントできます。古いタグを参照している古いポインタは確実に参照時にクラッシュします。タグがオーバーフローした場合、オブジェクトは隔離され、*Scanによって処理されます。スキャンがこのメモリブロックに対する保留ポインタがもう存在しないことを確認した後、そのブロックはアロケーターに返されます。これによりスキャンの頻度とその付随するコストが約16倍減少します。

以下の図はこのメカニズムを示しています。fooへのポインタは最初に0x0Eのタグを持ち、再度インクリメントしてbarに割り当てることができます。bardeleteが呼び出されるとタグがオーバーフローし、メモリ実際は*Scanの隔離領域に入れられます。

図3: MTE

実際にMTEをサポートするハードウェアに触れ、レンダラープロセスにおける実験をやり直しました。結果は有望で、Speedometerの退化はノイズ範囲内であり、Chromeの実際のブラウジングストーリーではメモリフットプリントが約1%だけ増加しました。

これはいわゆるただの昼食でしょうか?実際にはMTEは既に支払われているコストと共に来ます。特にPartitionAllocは、Chromeの基盤アロケーターであり、全てのMTE対応デバイスでタグ管理操作をデフォルトで実行しています。また、セキュリティ上の理由から、メモリを積極的にゼロ化する必要があります。これらのコストを定量化するために、MTEを複数の構成でサポートする初期ハードウェアプロトタイプで実験を行いました:

A. MTEを無効化し、メモリをゼロ化しない場合; B. MTEを無効化し、メモリをゼロ化する場合; C. MTEを有効化し、*Scanを使わない場合; D. MTEを有効化し、*Scanを使う場合;

(また、同期型と非同期型のMTEもあり、それが決定的性と性能に影響を与えることを認識しています。この実験の目的では非同期モードを使用し続けました。)

図4: MTEの退化

結果は、MTEとメモリのゼロ化にはSpeedometer2で約2%のコストが伴うことを示しています。PartitionAllocやハードウェアがこれらのシナリオに最適化されていないことに注意してください。この実験は、MTEの上に*Scanを追加しても測定可能なコストが伴わないことも示しています。

結論