あなたのスマートフォンは、本番環境でメモリ破損を検出できる。CIで実行する完全なインストルメンテーションを使うわけでも、すべてのアロケーションに適用するわけでもない。しかし、あなたのポケットの中のハードウェアはここ数年、必要なプリミティブを搭載して出荷されており、増え続ける本番アプリが静かにそれを有効にしている。
要約:AddressSanitizerは本番環境ではコストが高すぎる。ARM Memory Tagging Extensionはそうではない。まだMTEを頼れないなら、GWP-ASanはほぼオーバーヘッドなしで確率的なカバレッジを提供する。これらが合わさることで、かつては悲観的な答えしかなかった問題に答えを出せるようになった。
メモリ破損は、出荷してしまうバグだ
use-after-free、buffer overflow、heap corruptionは、あらゆるテストスイートを通過するバグだ。ネイティブコードに潜み、再現性が低く、実際の間違いから遠く離れた場所でクラッシュすることが多い。ユーザーがクラッシュを報告する頃には、最初の破損は既に発生しており、唯一の証拠は壊れたスタックトレースか、ランダムに見えるアドレスでのSIGSEGVだけだ。
従来のツールはこれらを早期に捉える。Valgrindは正確だが遅い。ASanはより高速だが、それでもおおよそ2〜3倍のメモリオーバーヘッドと2倍のCPUオーバーヘッドを追加する。ファジングやユニットテストなら問題ない。Instagramを動かすバッテリー駆動のデバイスには向かない。
本番環境は歴史的に盲点だった。バグをローカルで再現するか、推測するしかなかった。
ARM MTEがハードウェアでメモリにタグを付ける仕組み
ARMv8.5-Aで導入されたARM Memory Tagging Extensionは、CPUがメモリアクセスのたびにポインタの有効性を安価に検査できるようにする。16バイト粒度のメモリごとに4ビットのタグを割り当て、ポインタの最上位バイトに一致するタグを格納することで動作する。
すべてのloadやstoreで、CPUはポインタタグとメモリタグを比較する。異なれば、ハードウェアはフォルトを発生させる。これはソフトウェアチェックではない。メモリサブシステム内で発生し、オーバーヘッドは通常5%以下だ。
64ビットポインタの最上位バイトは、ハードウェアによって既に無視されていた。MTEはこれらのビットを再利用する。0xb7f0_0000_1234_5678のようなポインタはタグ0xb7を持つ。そのアドレスのメモリも同じタグを持たなければ、アクセスはフォルトする。
MTEはARMv8.5-A以降のデバイスで利用可能だ。Google Pixel 8以降、iPhone 15 Pro以降、および増え続けるミッドレンジAndroidデバイスが含まれる。普遍的ではないが、もはや珍しいものでもない。
カーネルはprctlフラグを通じてMTEを公開する。アプリケーションは同期または非同期の検査を要求できる。同期モードはタグ不一致時に即座にフォルトする。非同期モードはフォルトをキューイングして後で配信するため、コストは低いが検出がわずかに遅れる。
GWP-ASan:すべてにタグを付けられない場合
MTEは素晴らしいが、互換性のあるハードウェアと、コードベース全体でのタグ付きアロケーションを必要とする。古いデバイスに出荷する場合や、対象を絞った保護だけが必要な場合、GWP-ASanは現実的な代替案だ。
GWP-ASanはGuarded Write Protection AddressSanitizerの略だ。これはサンプリングアロケータで、ごく一部のヒープアロケーションを、毒入りredzoneに囲まれたガードページに配置する。use-after-freeやbuffer overflowがそのredzoneに触れれば、ハードウェアMMUが即座にフォルトを発生させる。
核心的な洞察は確率にある。GWP-ASanは1万件に1件のアロケーションをガードするかもしれない。バグのあるアプリはクラッシュする前に何千回もメモリを破損させることを考えると、無意味に聞こえるが、十分なユーザーセッションがあれば、0.01%のサンプリング率でも本番環境で実在のバグを捉える。
Googleは何年もChromeとAndroidシステムサービスでGWP-ASanを運用してきた。テストとファジングの両方から逃れたuse-after-freeのバグを数百件発見している。ガードされたパスを通るアロケーションはごく一部なので、CPUオーバーヘッドは無視できる。ガードプールは小さく、アロケーションは最終的にリサイクルされるため、メモリオーバーヘッドも制限される。
誰も口にしたくないトレードオフ
MTEとGWP-ASanは無料ではない。ただ、それだけの価値があるほど安いだけだ。
MTEはすべてのポインタの最上位バイトを必要とするため、コードベースは-fsanitize=memtagまたは-march=armv8.5-a+memtagでコンパイルする必要がある。ポインタをマスクしたり、ハッシュしたり、適切なタグ付けなしにJNIを通して渡すコードがあると、誤検出が出る。Android NDKや現代のlibc実装はこれを正しく処理するが、カスタムアロケータやポインタパッキング方式は破損する。
MTEは、明示的にタグ付けしたヒープ領域やスタック領域内のバグのみを捉える。タグ付けされていないmmap領域に飛んだwild pointerは検出されない。ないよりはマシだが、完全なカバレッジではない。
GWP-ASanは逆の問題を持つ。ガードプールに偶然配置されたアロケーションのみを捉える。ガードされていないアロケーションを破損させるバグは見落とされる。サンプリングされた集合に対して、アロケーションと解放のわずかなレイテンシーコストも発生し、クラッシュをテレメトリパイプラインで適切に処理する必要もある。
いずれのツールもCIパイプラインでのASanを置き換えるものではない。補完するものだ。ASanはテスト中に決定論的で高カバレッジの検出を提供する。MTEとGWP-ASanは現場で安全網を提供する。
最新のAndroidデバイスでMTEを有効にする
Pixel 8以降を持っていれば、今日からMTEをテストできる。Android NDKは、単一のコンパイラフラグでそれをサポートする。
まず、デバイスがMTEをサポートしているか確認する:
#include <sys/prctl.h>
#include <linux/prctl.h>
#include <sys/auxv.h>
#include <asm/hwcap.h>
bool mte_supported() {
unsigned long hwcap2 = getauxval(AT_HWCAP2);
return (hwcap2 & HWCAP2_MTE) != 0;
}
次に、CMakeLists.txtでMTEを有効にしてネイティブライブラリをビルドする:
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fsanitize=memtag")
本番環境で通常必要とされる同期フォールティングを希望する場合は、起動時に要求する:
#include <sys/prctl.h>
void enable_mte() {
if (mte_supported()) {
prctl(PR_SET_TAGGED_ADDR_CTRL,
PR_TAGGED_ADDR_ENABLE | PR_MTE_TCF_SYNC | (0xfffe << PR_MTE_TAG_SHIFT),
0, 0, 0);
}
}
PR_MTE_TCF_SYNCフラグは、カーネルにタグ不一致時に即座にフォルトするよう指示する。タグマスク0xfffeは、一部のシステムコードがまだ使用するゼロタグを除外する。
本番環境用にGWP-ASanを設定する
GWP-ASanは特別なハードウェアを必要としないため、有効化が容易だ。Androidでは、GWP-ASanアロケータラッパーにリンクするか、新しいAPIレベルでシステムアロケータを通じて有効にできる。
最小限の統合では、アロケータのエントリポイントをラップする:
extern "C" void* malloc(size_t size) {
if (__gwp_asan_sample()) {
return __gwp_asan_guarded_malloc(size);
}
return __libc_malloc(size);
}
実際には、Android GWP-ASanランタイムまたはLLVM compiler-rtの実装を使用する。重要な設定項目はサンプリング率だ。保守的に始める:
// 1万件に1件のガードアロケーション
__gwp_asan_set_sample_rate(10000);
結果として生じるクラッシュは、既存のテレメトリを通じて収集する。GWP-ASanのクラッシュは標準のSIGSEGVのように見えるが、フォールティングアドレスはガードページ内に配置される。クラッシュレポーターは、フォールティングアドレスがGWP-ASanプール内にあるかチェックすることでこれを検出できる。
実際にすべきこと
モバイルでネイティブコードを出荷しているなら、CIとファジングで既にASanを実行しているはずだ。問題は出荷後に何が起こるかだ。
サポート対象の最小デバイスにARMv8.5-Aチップが含まれるなら、同期モードでMTEを有効にする。オーバーヘッドはユーザーが気づかないほど低く、得られるクラッシュはランダムなヒープ破損ではなく、正確なタグ不一致情報を含むことになる。
古いデバイスをサポートする場合は、保守的なサンプリング率でGWP-ASanを有効にする。すべてのバグを捉えるわけではないが、他では捉えられないバグを捉える。数百万セッションを通じて、これは理論ではない。昨年、Chromeがイメージデコーダーでuse-after-freeを発見したのもこの方法だ。
あなたのスマートフォンのハードウェアは、最も恐れているメモリ安全性のバグを捉える能力を既に備えている。問題は、それをオンにしているかどうかだけだ。