Android メモリアローケーター Scudo の脆弱性と攻撃手法
はじめに
朝、たった一杯のコーヒーで目を覚ますように、アプリケーションはメモリアロケーターで目を覚ます。
そして、堅牢で鍵のかかった金庫のように、メモリアロケーターはアプリケーションのデータを守る重要な役割を担っている。Android 11からScudoという名前の新しいメモリアロケーターが導入され、以前のjemallocに代わってAndroidのネイティブコードのデフォルトヒープ実装として採用された。
モダンなセキュリティ脆弱性の大半はヒープ関連であり、Androidにおいても、この種の脆弱性を悪用した攻撃が多数公開されている。Scudoはそのような脆弱性からの保護を強化するために設計された。
この記事では、Android上でのScudoセキュリティの調査結果を理解することを目指す。EPFLの研究チームによる分析から、Scudoのセキュリティ機構を突破して、攻撃者が選択した任意のアドレスにチャンクを割り当てさせる二つの攻撃手法について詳しく見ていく。これらの手法は、適切なメモリ破壊プリミティブがあれば、攻撃者が任意のメモリ書き込みを実現できることを示している。
動画のほうもあわせて確認すると、理解の助けになる(日本語字幕を必死に追えば)。
Scudoアロケーターの概要
Scudoは、Google社によって開発されたセキュリティ強化型メモリアロケーターである。Android 11以降、ネイティブコードのデフォルトアロケーターとして採用され、jemallocに代わってシステムのセキュリティを強化している。
Scudoを銀行の金庫室に例えるなら、以前のアロケーターは普通のロッカーのようなものだった。Scudoは明示的にヒープベースの攻撃の難易度と複雑さを高めるために設計されており、ゲームで言えば、難易度が「EASY」から「HARD」に変更されたようなものである。
Androidのオープンソースプロジェクトでは、ベンダーによって明示的に変更されない限り、アプリと特権の高いシステムサービスを含むすべてのユーザー空間プロセスがScudoを使用する。参考までに、主要デバイスにおけるScudoの採用状況を次の簡略な表に示す。
デバイス | リリース日 | アロケーター |
---|---|---|
Samsung S24 | 2024年2月 | Scudo |
Google Pixel 8 | 2024年2月 | Scudo |
Xiaomi Redmi Note 13 5G | 2024年2月 | jemalloc |
Oppo Reno 8 Pro | 2024年1月 | jemalloc |
この表から分かるように、主要ベンダーの中にはScudoを採用しているものとjemallocを継続使用しているものが混在している。市場では両方のアロケーターが実運用環境で使用されていることがわかる。
Scudoのセキュリティ防御メカニズム
Scudoにはセキュリティを強化するための4つの主要な防御メカニズムがある。これらのメカニズムはそれぞれが特定の攻撃ベクトルに対処し、連携して堅牢な防御ラインを形成している。
隔離(Isolation)
隔離機能は、要求されたサイズに基づいて、チャンクをプライマリまたはセカンダリに分類する。具体的には、サイズが64KB未満のチャンクはプライマリチャンクとして扱われ、それ以上のサイズのチャンクはセカンダリチャンクとして扱われる。それぞれのタイプのチャンクは専用のメモリ領域に配置され、ゼロパーミッションのガードページで区切られている。
プライマリチャンクの場合、サイズ範囲ごとに異なるクラスIDが割り当てられ、特定のサイズ範囲のチャンクはそれに対応するメモリ領域に配置される。
https://vectorize.re/blog/internals/introduction-to-scudo/
アロケーター内部のメタデータ(解放されたチャンクのリストやメモリ領域に関する情報など)は、チャンクとは別の保護されたメモリ領域に格納される。
この隔離機構によって、ヒープバッファオーバーフローが発生した場合でも、影響は同じサイズクラスのメモリ領域内に限定され、他のサイズクラスのチャンクやアロケーター内部のメタデータを直接上書きすることはできなくなる。
下記は典型的なScudoメモリマップの例で、各領域の隔離状況を示している。
size permission
...
0x00001000 --- [Secondary guard] ← セカンダリチャンク領域を守るガードページ
0x01001000 rw- [Class 0 secondary chunk] ← 実際のセカンダリチャンクデータ
0x00001000 --- [Secondary guard] ← 次の領域との境界ガード
...
0xa0006000 --- [Guard and reserve] ← 予約・ガード領域
0x00040000 rw- [Primary chunk free lists]← フリーリスト管理領域
0x2ffbf000 --- [Guard and reserve]
0x00040000 rw- [Class 1 region] ← クラス1サイズのチャンク専用領域
0x2ffc0000 --- [Guard and reserve]
0x00040000 rw- [Class 2 region] ← クラス2サイズのチャンク専用領域
0x0ffcb000 --- [Guard and reserve]
...
0x00044000 r-- libc.so ← 読み込み専用コードセクション
0x00094000 r-x libc.so ← 実行可能コードセクション
0x00004000 r-- libc.so ← 読み込み専用データ
0x00002000 rw- libc.so ← 読み書き可能データ
0x00452000 rw- [Allocator metadata] ← アロケーター内部メタデータ
...
ランダム化(Randomization)
ランダム化機能は、リージョン内のチャンクをランダムなオフセットに配置する。これはパスワードを総当たりで探す難しさに似ている。
リージョンが最初にマップされると、チャンクを配置できる複数のアドレスがTransferBatchと呼ばれるデータ構造に格納される。このTransferBatchからアドレスが返される順序は、アロケーターに保存されたシードを使用してランダム化される。
この仕組みによって、連続して割り当てられたチャンクのアドレスを予測することができなくなり、ヒープ操作の基盤となるヒープ配置テクニック(ヒープフェンシュイ/Heap Feng Shui)の効果を無効化する。例えば、同じ入力で同じプログラムを2回実行しても、チャンクのアドレスは異なる。
https://www.usenix.org/publications/loginonline/choose-one-android-performance-or-security
保護(Protection)
保護機能は、チャンクのメタデータの整合性を守る。これは金庫の暗証番号に似ている。チャンクが割り当てられると、Scudoはチャンクヘッダを返されたポインタ-0x10の位置に配置する。なぜ-0x10なのか?これは、ヘッダーの各フィールドを合計すると64ビット、つまり8バイトだが、アライメントを保つためにパディングされるためだ。
チャンクヘッダには以下のフィールドがある。
フィールド | ビット数 | 説明 |
---|---|---|
ClassId | 8 | チャンクのクラスID |
State | 2 | チャンクが使用中か解放済みか |
OriginOrWasZeroed | 2 | チャンクの生成元(mallocやnewなど) |
SizeOrUnusedBytes | 20 | 正確なチャンク・サイズ |
Offset | 16 | ゼロで埋められる |
Checksum | 16 | ヘッダの整合性を確保するためのチェックサム |
チャンクヘッダのメモリレイアウトは以下のようになっている。
[ブロック開始]
├─ 場合によってはBlockMarker (0x44554353 "SCUD") + オフセット
├─ チャンクヘッダー (8バイト) ← 返されるポインタ - 0x10
├─ 8バイトのパディング(アライメント用)
└─ ユーザーデータ ← malloc()が返すポインタ
このヘッダーを保護するために、Scudoはチャンクのアドレス、ヘッダー、そしてプログラム起動時にランダムに生成される32ビットのcookie値を用いて、CRC32チェックサムの切り詰め値をChecksumフィールドに格納する。Scudoがチャンクとやり取りするたびに、チェックサムを再計算して、Checksumフィールドの値と比較し、チャンクヘッダーの整合性を確保する。
この保護機構により、攻撃者がチャンクヘッダーを盲目的に上書きすると、チェックサムが一致せずにScudoが異常終了する。また、Stateフィールドの値をチェックすることで、二重解放攻撃を防止している。
分離(Separation)
分離機能は、インラインで格納されたポインタを保護する。セカンダリチャンクには、プライマリチャンクと同じチャンクヘッダーがあり、ClassIdフィールドは0に設定されている。さらに、セカンダリチャンクにはreturned pointer - 0x40から始まる拡張ヘッダがある。なぜ-0x40なのか?これは、拡張セカンダリチャンクヘッダーの全体サイズが56バイト(7つのフィールド × 8バイト)であり、64バイトアライメントを保つためにパディングが追加されるためだ。
この拡張セカンダリチャンクヘッダーには次のフィールドが含まれている。
フィールド | バイト数 | 説明 | チェックサム保護 |
---|---|---|---|
Prev | 0x8 | 前のセカンダリチャンクへのポインタ | ❌ |
Next | 0x8 | 次のセカンダリチャンクへのポインタ | ❌ |
CommitBase | 0x8 | セカンダリチャンクの実際の開始アドレス | ❌ |
CommitSize | 0x8 | 確保されたチャンクサイズ | ❌ |
MapBase | 0x8 | マッピング全体の開始アドレス | ❌ |
MapSize | 0x8 | マッピング全体のサイズ | ❌ |
Scudo Chunk header | 0x8 | 通常のチャンクヘッダー | ✅ |
この拡張ヘッダーは、セカンダリチャンクのメタデータを保持し、セカンダリチャンクが解放されたときに、次のセカンダリチャンクへのリンクを提供する。セカンダリチャンクは、プライマリチャンクとは異なるメモリ領域に配置され、ガードページで保護されている。
[メモリマッピング開始 - MapBase]
├─ ガードページ (PROT_NONE)
├─ [CommitBase開始]
│ ├─ セカンダリチャンクヘッダー (56バイト) ← 返されるポインタ - 0x40
│ ├─ 8バイトのパディング(64バイトアライメント用)
│ ├─ プライマリ形式のチャンクヘッダー (8バイト) ← 返されるポインタ - 0x10
│ ├─ 8バイトのパディング
│ └─ ユーザーデータ ← malloc()が返すポインタ
├─ [CommitBase終了]
└─ ガードページ (PROT_NONE)
[メモリマッピング終了]
重要なのは、セカンダリチャンクヘッダーはチェックサムによって保護されていないことだ。代わりに、Scudoは各セカンダリチャンクが独立したメモリ領域に配置され、その領域がガードページで保護されているという事実に依存して、拡張ヘッダーを保護している。チャンクヘッダーとセカンダリチャンクヘッダーは、Scudoがヒープにインラインでメタデータを格納する唯一のインスタンスである。
Androidのアーキテクチャによる弱点
Androidのアーキテクチャは、Scudoのセキュリティを大幅に弱体化させている。この状況はまるで、高度なセキュリティシステムを備えた家の裏口を開けっ放しにしているようなものだ。
Zygoteプロセスフォーキングメカニズム
Androidでは、すべてのアプリプロセスと複数のシステムサービスは単一のプロセス(Zygoteプロセス)からフォークされる。これはスタートアップ時間を短縮し、フレームワークコードやリソースに使用されるRAMページを共有することでメモリ消費を削減するための設計だが、セキュリティにとっては致命的なコストがかかる。
Zygoteのフォーキングプロセスを以下の図に示す。
https://www.usenix.org/publications/loginonline/choose-one-android-performance-or-security
結果として、ほとんどのAndroidユーザー空間プロセスは、Scudoリージョンを含む同じASLR(アドレス空間レイアウトランダム化)レイアウトを共有することになる。さらに、Zygoteプロセスはいくつかのチャンクを割り当ててScudoアロケーターを初期化する(cookieとTransferBatchランダム化シードを設定)。フォーク後、このアロケーター状態はすべて保持される。
セキュリティ対策の無効化
悪意のあるアプリは他のZygoteからフォークされたプロセスが割り当てるチャンクのある場所を正確に予測できるため、これは本質的に「Randomize」セキュリティ対策を破る。アロケーターをオラクルとして使用するだけで済む。
「Protect」セキュリティ対策を破るために、悪意のあるアプリはScudoのcookieを単純に読み出し、任意のチャンクヘッダに対して有効なチェックサムを偽造できる。
つまり、悪意のあるAndroidアプリが別のZygoteからフォークされたプロセスを攻撃するシナリオでは、「Randomize」と「Protect」のセキュリティ対策は完全に無効化されるのだ。これにより、Scudoは侵害の魅力的な標的となる。
この状況を表にまとめると次のようになる。
セキュリティ対策 | 通常の状態 | Zygoteフォーク後の状態 |
---|---|---|
Randomize(ランダム化) | チャンクアドレスは予測不可能 | 攻撃者にチャンクアドレスが予測可能 |
Protect(保護) | チャンクヘッダはチェックサムで保護 | 攻撃者がcookieを知ることでチェックサムを偽造可能 |
Isolate(隔離) | 異なるサイズのチャンクは隔離 | 引き続き有効 |
Separate(分離) | インラインポインタは保護 | 引き続き有効 |
Scudoに対する攻撃手法の詳細
「Randomize」と「Protect」セキュリティ対策が無効化された状態で、残りの「Isolate」と「Separate」対策を突破する攻撃手法を見ていこう。これはまるで、鉄壁のように見える城の隠し通路を発見したようなものだ。
偽装セカンダリチャンクの作成方法
古典的なptmallocヒープ攻撃では、任意の書き込みは通常インラインポインタを操作することで達成される。しかし、Scudoの「Separate」対策では、インラインポインタをヒープの残りの部分から分離している。Scudoがポインタをインラインで格納する唯一のインスタンスはセカンダリチャンクヘッダーであり、これはヒープの残りの部分から分離されたメモリ領域に格納されている。
ここで、標的チャンクのヘッダーを操作できるメモリ破壊プリミティブを持っていると仮定する。このプリミティブはScudoのプライマリヒープ領域に限定されている。このようなプリミティブの例として、後続の標的チャンクのチャンクヘッダーを上書きするヒープバッファオーバーフローがある。
「Protect」対策をバイパスした後、攻撃者は上書きされたチャンクヘッダーの任意のフィールドを自由に設定し、有効なチェックサムを計算できる。標的チャンクが解放されると、チェックサム検証は成功し、Scudoは攻撃者が制御するメタデータを解析する。
特に興味深いのは、ClassIdフィールドを操作することだ。プライマリチャンクのClassIdを0(セカンダリチャンクのクラスID)に変更することで、攻撃者は実質的にセカンダリチャンクヘッダーをヒープ上にインラインで配置し、「Separate」セキュリティ対策を無効化する。オーバーフローのシナリオでは、セカンダリチャンクヘッダーは攻撃者の完全な制御下にある。
この状況を図で表すと以下のようになる。
https://www.usenix.org/system/files/woot24-mao.pdf
オーバーフロー前のヘッダー情報は以下の通りだ。
- チェックサム: 0xb6fd
- 状態: 割り当て済み
- クラスID: 2(プライマリチャンク)
オーバーフロー後のヘッダー情報は次の通りだ。
- チェックサム: 0x3c4e
- 状態: 割り当て済み
- クラスID: 0(セカンダリチャンクに変更)
- セカンダリチャンクヘッダーフィールド(Prev、Next、CommitBase、CommitSize、MapBase、MapSize)はすべて攻撃者が制御
この偽装セカンダリチャンクを利用して、次の二つの攻撃手法によって「Isolate」セキュリティ対策を突破する。
Forged CommitBase手法
Forged CommitBase手法は、インラインセカンダリヘッダーのCommitBaseフィールドを操作して任意のメモリ書き込みを実現する。
セカンダリチャンクヘッダーのCommitBaseフィールドには、セカンダリチャンクの先頭(セカンダリチャンクヘッダーを含む)へのポインタが格納されている。セカンダリチャンクが解放された後、このCommitBaseはセカンダリチャンクのフリーリストに格納される。このセカンダリチャンクが割り当て要求に使用されると、Scudoはフリーリストに格納されているCommitBaseを使用して、このチャンクの場所を決定する。
偽装セカンダリヘッダーのCommitBaseを望みのターゲットアドレスに巧妙に設定することで、攻撃者はScudoに目的のアドレスにセカンダリチャンクを割り当てさせ、「Isolate」セキュリティ対策を破ることができる。
この攻撃の流れは以下のようになる。
https://www.usenix.org/system/files/woot24-mao.pdf
- 攻撃者はメモリ破壊プリミティブ(オーバーフローなど)を使用して、プライマリチャンクのヘッダーを上書きし、偽装セカンダリチャンクをヒープ上に配置する。CommitBaseはターゲットアドレスに設定される。
- 偽装セカンダリチャンクが解放され、CommitBaseアドレスがセカンダリチャンクフリーリストに配置される。
- セカンダリチャンクが要求されると、Scudoはセカンダリフリーリストからチャンクを提供する。Scudoはフリーリストに格納されたアドレスを使用するため、新たに割り当てられたチャンクは任意のアドレス(例えばスタック上)に配置される。
この攻撃が成功するためには、解放時に少なくとも1つのセカンダリチャンクが割り当てられている必要がある。そうでない場合、使用中のセカンダリチャンクのカウンターが-1になり、次のセカンダリチャンク割り当て時にScudoがクラッシュする。
Safe Unlink手法
Safe Unlink手法は、Forged CommitBase手法とは正反対のアプローチを取る攻撃手法だ。もし前者が「偽の住所を書いた配達指示書」を使って荷物を任意の場所に届けさせる手法だとすれば、Safe Unlink手法は「住所録そのものを書き換えて配達システム全体を乗っ取る」ような攻撃と言える。
この手法は、セカンダリチャンクが解放される際に行われるアンリンク処理(連結リストからの削除処理)を悪用して、最終的に任意のメモリ書き込みを実現する。
glibcのunsafe unlinkとの関係
この攻撃は、長年にわたってLinuxシステムで猛威を振るった「unsafe unlink」に似ている。従来の unsafe unlink 攻撃では、連結リストの整合性チェックが甘かったため、比較的簡単に任意のメモリ書き込みが可能だった。
しかし、現代のアロケーター(glibcの新しいバージョンやScudo)では、連結リストの操作時に厳格な整合性チェックが導入されている。そのため、攻撃者は単純な偽装では検出されてしまい、より巧妙な偽の連結リストを構築する必要がある。
攻撃の標的となるPerClass構造体
Safe Unlink手法の核心は PerClass構造体 と呼ばれるアロケーター内部のメタデータを標的にすることだ。この構造体は、まさにアロケーターの「住所録」のような役割を担っている。
struct PerClass {
short Count; // 現在管理している解放済みチャンク数
short MaxCount; // 最大管理可能数
void* Chunks[MaxCount]; // 解放済みチャンクへのポインタ配列
};
PerClass構造体は、特定のサイズクラス(ClassID)ごとに用意され、そのサイズの解放済みチャンクのアドレスを配列で管理している。新しいメモリ要求があると、アロケーターはこのリストから適切なチャンクを取り出して再利用する。
もし攻撃者がこの構造体を制御できれば Chunks配列 に任意のアドレスを挿入し、次回の malloc() で任意のアドレスにチャンクを配置させることができる。これこそが「任意のメモリ書き込み」への道筋である。
攻撃手順
https://www.usenix.org/system/files/woot24-mao.pdf
上図は、偽の連結リストを構築する巧妙なプロセスを示している。攻撃者は以下の手順で「PerClass構造体に偽チャンクのアドレスを2回登録」し、「偽チャンクからPerClass構造体への双方向リンク」を作り上げるようすを示している。
-
🔄 2回の偽チャンク登録(①②③④)
- ①③:任意のClassIdと「割り当て済み」状態を持つプライマリチャンクヘッダーを、偽のセカンダリヘッダーの開始アドレスに書き込む(この偽のプライマリチャンクヘッダーは、偽のセカンダリヘッダーのNextフィールドと重なる)
- ②④:それを解放してPerClass構造体に登録(ヘッダーのメモリレイアウトを理解しておく必要があるのだが、結論を述べるとセカンダリヘッダーのCommitBaseエントリのアドレスがfree関数に渡され、最終的にPerClassには、偽のセカンダリヘッダーのアドレスが追加される)
-
結果:
PerClass.Chunks[0] = &CHUNK
,PerClass.Chunks[1] = &CHUNK
-
🔗 双方向リンクの完成(⑤)
-
⑤:偽セカンダリヘッダーの設定
-
Prev:&PERCLASS+0x8
(PerClass.Chunks[0]を指す) -
Next:&PERCLASS+0x8
(PerClass.Chunks[1]を指す) -
ClassId:0
(セカンダリチャンクに偽装)
-
-
⑤:偽セカンダリヘッダーの設定
この時点で、偽の連結リストが完成する。
偽セカンダリチャンク (&CHUNK) ←→ PerClass構造体 (&PERCLASS)
Safe Unlinkの実行
https://www.usenix.org/system/files/woot24-mao.pdf
上図は、実際のアンリンク処理の前後を比較して、攻撃がどのように成功するかを示している。
BEFORE UNLINKING(アンリンク前 / 図の左側)
この時点では、偽装された連結リストが完璧に構築されている。
- 偽セカンダリチャンク(&CHUNK)のPrev/Nextが、両方ともPerClass構造体の特定位置(&PERCLASS+0x8)を指している
- PerClass構造体のChunks[0]とChunks[1]が、両方とも偽セカンダリチャンク(&CHUNK)を指している
攻撃者が巧妙に構築した偽の連結リストは、Scudoの整合性チェックを完璧にパスする。
AFTER UNLINKING(アンリンク後 / 図の右側)
整合性チェックをパスした後、Scudoは以下のアンリンク処理を実行する。
// アンリンク処理(ここで問題が発生)
Prev->Next = Next; // PERCLASS.Chunks[1] = &PERCLASS+0x8
Next->Prev = Prev; // PERCLASS.Chunks[0] = &PERCLASS+0x8
この処理の結果、PerClass構造体が自分自身を指すポインタで満たされることになる。
PerClass構造体 (&PERCLASS) 🚨 制御された!
├─ Chunks[0]: &PERCLASS+0x8 ← 自分自身の一部を指す
└─ Chunks[1]: &PERCLASS+0x8 ← 自分自身の一部を指す
この結果、PerClass構造体の自己参照を利用して、間接的に構造体の内容を操作し、最終的に任意のアドレスにチャンクを配置させる可能性が拓かれる。
対策と現状
Safe Unlink攻撃は、Android 14で修正済みである。
修正方法は、PerClass構造体には絶対アドレスではなく、プライマリチャンク領域に対する相対オフセットのみを格納するというものである。攻撃者が偽の連結リストを構築してアンリンク処理を悪用しても、PerClass構造体に任意の絶対アドレスを挿入することは不可能になった。
ただし、この修正はSafe Unlink攻撃にのみ有効であり、Forged CommitBase攻撃は依然として有効である点に注意が必要だ。
実践的な攻撃例
参考論文では、2015年に発見されたCVE-2015-1528(Binderデータデシリアライゼーションの脆弱性)を現代のAndroid 14に対して適用する実験について詳しく解説されている。ここでは割愛するが、約10年前の脆弱性を利用して最新のScudoアロケーターの防御を突破する手法が示されている。
まとめ
Scudoはセキュリティ強化のために設計された堅牢なメモリアロケーターだが、Androidのアーキテクチャ上の制約により、その防御機能の多くが無効化される可能性がある。
Safe Unlink手法はAndroid 14で修正されているが、Forged CommitBase手法は、Scudoがより大きなチャンクを処理する方法に基づいているため、引き続き適用可能だ。提案された対策はパフォーマンス上の懸念から取り入れられていないため、Scudoはこの手法に対して脆弱なままである。
今後のAndroidセキュリティにおいて、Scudoの防御機能を強化し、ZygoteフォークメカニズムによるASLRの共有を改善することが重要な課題となるだろう。特に、Zygoteプロセスからフォークされたプロセス間でのメモリアロケーター状態の共有を見直すことが、セキュリティ向上につながる可能性がある。
Discussion