
CVE-2024-34740 の解説とエクスプロイト、Android の BinaryXmlSerializer における整数オーバーフローを利用して system_server へのファイル書き込み、その後通常のインストール済みアプリから system_server でのコード実行に至る

ここに記載された問題の修正は、CVE-2024-34740 / A-307288067 として公開されました:
Android system_server 内では、多くのサービスが再起動をまたいだ状態を XML ファイルに保存しています```
$ adb shell su 0 find /data/system -name '*.xml' | sort
/data/system/appops_accesses.xml
/data/system/cachequota.xml
/data/system/device_policies.xml
/data/system/device_policy_state.xml
/data/system/display-manager-state.xml
/data/system/input-manager-state.xml
/data/system/inputmethod/subtypes.xml
/data/system/install_sessions.xml
/data/system/job/jobs_1000.xml
/data/system/job/jobs_10131.xml
/data/system/log-files.xml
/data/system/netpolicy.xml
/data/system/notification_policy.xml
/data/system/overlays.xml
/data/system/packages.xml
/data/system/package-watchdog.xml
/data/system/sensor_privacy_impl.xml
/data/system/sensor_privacy.xml
/data/system/shortcut_service.xml
/data/system/users/0/app_idle_stats.xml
/data/system/users/0/appwidgets.xml
/data/system/users/0/package-restrictions.xml
/data/system/users/0/settings_global.xml
/data/system/users/0/settings_secure.xml
/data/system/users/0/settings_system.xml
/data/system/users/0/wallpaper_info.xml
/data/system/users/0.xml
/data/system/users/userlist.xml
/data/system/watchlist_settings.xml
歴史的には、これらはインデント付きのプレーンテキストXMLファイルであり、開発者が簡単に読めるようになっていました。しかし、[Android 12ではそのフォーマットの新しいバイナリ版が導入され、`system_server`が費やす時間の1.5%がこれらのXML操作に使われていると報告されています](https://android.googlesource.com/platform/frameworks/base/+/4ccea8796991d678ead4399130ec31edf63ff4fa%5E%21/)
このフォーマットはシステム内部でのみ使用され、マジック値 `"ABX\x00"` を持つファイルであることに注意が必要です。これは、APK内部で使用される[フォーマット](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/libs/androidfw/ResourceTypes.cpp;l=1770;drc=d4e49e63519397789d284a03aea5fafc119cb1b0)(`AndroidManifest.xml`、`res/xml/*.xml`、`res/layout/*.xml`など)とは異なります。後者には明示的な「マジック値」はありませんが、通常は `0300 0800` で始まります(これは `type=RES_XML_TYPE` かつ `headerSize=8` の[ヘッダー](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/libs/androidfw/include/androidfw/ResourceTypes.h;l=608;drc=d4e49e63519397789d284a03aea5fafc119cb1b0)です)。
システムがこれらの内部状態XMLファイルのいずれかを読み取る際、ファイル内のマジック値 `"ABX\0"` を使用して、Binary XMLファイル用のパーサーと通常のXMLパーサーのどちらかを選択します([参照](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/util/Xml.java;l=188-192;drc=97a370a95275e79c69e79d7ead11aa38934a5575))。これらのファイルがBinary XMLとして保存されるかどうかは[システムプロパティ](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/util/Xml.java;drc=97a370a95275e79c69e79d7ead11aa38934a5575;l=74?q=Xml.java)によって制御され、デフォルトで有効になっています。
Binary XMLファイルが使用されている場合、例えば `adb shell su 0 abx2xml /data/system/packages.xml -` を通じてその内容を読み取ることができます。
このバイナリフォーマットの利点の一つは、型付きアクセサを提供することです。シリアライザは `attributeInt(String namespace, String name, int value)` メソッドを提供し、値をバイナリ整数として書き込みます。これにより、文字列へのラウンドトリップ(新しい割り当てとその後のガベージコレクション対象となるオブジェクトの生成)を回避します。
直接シリアライズできる別の型はバイト配列です。```java
@Override
public XmlSerializer attributeBytesBase64(String namespace, String name, byte[] value)
throws IOException {
if (namespace != null && !namespace.isEmpty()) throw illegalNamespace();
mOut.writeByte(ATTRIBUTE | TYPE_BYTES_BASE64);
mOut.writeInternedUTF(name);
mOut.writeShort(value.length);
mOut.write(value);
return this;
}
類似のメソッド attributeBytesHex も存在し、TYPE_* タグの記述のみが異なります。このタグは abx2xml ツールによってバイト配列を適切な文字列表現に変換するために使用されます。
mOut は FastDataOutput のインスタンスであり、Java の DataOutputStream の機能を提供します。writeByte/writeShort/writeInt/writeUTF/write は標準の DataOutputStream と同じフォーマットを使用します。
Parcel と同様に、書き込み/読み取り中に何かが一致しない場合、その後読み取られるデータは誤ったオフセットから取得されます。ただし、Parcel とは異なり、BinaryXmlSerializer/BinaryXmlPullParser の使用方法の誤りによって、攻撃者が読み取りデータを任意に改ざんできる能力は与えられません(その場合、攻撃者は新しいタグ名/属性名/値を導入できません)。
しかし、BinaryXmlSerializer クラス自体または FastDataOutput 内での誤りは、そうした改ざんを可能にします。
上記のメソッドで、長さが 65536 のバイト配列を書き込もうとした場合、writeShort() を使用して長さを書き込むと、実際には 0 が書き込まれ、その後実際の配列の内容が書き込まれます。
この不一致を悪用するには、attributeBytesBase64 または attributeBytesHex に任意のバイト配列を注入できるファイルを選択し、かつそのファイルの改ざんが攻撃者にとって価値があるものを選ぶ必要があります。
PackageInstaller クラスは、インストール用のパッケージを準備する機能を提供します。特別な権限は不要で、どのアプリでも一時ディレクトリにインストールする新しいAPKを書き込むことができます。インストールに必要なすべてのものが書き込まれたら、インストールアプリは commit() PackageInstaller.Session を実行できます。これは、インストールファイルにそれ以上変更を加えることができなくなり、Session がユーザーの承認または実際のインストールの準備ができたことを意味します。
これらの操作の状態は /data/system/install_sessions.xml に保存されます。インストーラーアプリは、例えば、Package Manager Service が作成した一時ディレクトリに大規模なAPKの半分をダウンロードし、再起動後にダウンロードを再開して残りの半分を書き込み、インストールをコミットすることができます。
可能性の1つは、install_sessions.xml にデータを書き込んでセッションを staged(次の起動後にインストールされる) とマークすることです。
もう1つ、ここで紹介するのは、インストールファイルが準備される一時ディレクトリへのパスを変更することです。openWrite()/openRead() は パストラバーサルがない限り任意の有効なファイル名を受け入れ、そのファイルを stageDir フィールドが指すディレクトリに配置します。このフィールドは XMLから読み取られます。
次に、実際に制御下のバイト配列を attributeBytesBase64() に渡す必要があります。
PackageInstaller.Session は setChecksums() メソッドを提供しています。
system_server 側では、提供された Checksum は必要に応じて呼び出し元の署名に対して検証され、その後 mChecksums に格納されます。
install_sessions.xml が書き込まれる際、checksum.getValue() が writeByteArrayAttribute に渡され、それがさらに attributeBytesBase64() に渡されます。
install_sessions.xml の書き込みをトリガーするイベントはいくつかありますが、その1つは 新しい Session の作成です。したがって、このエクスプロイトでは、あるセッションに Checksum を設定した後、新しい Session を作成して最初のセッションがファイルに保存されることを保証します。
次に、長さが 65536 のバイト配列を書き込みます。その後、それが読み取られると、そのサイズはゼロとして解釈され、配列の内容は BinaryXmlPullParser がパースする生データになります。
属性の数は指定されておらず、各エントリには token を含むタグバイトがあります。下位ニブルには、XmlPullParser で定義されているイベントタイプの1つ(START_TAG、END_TAG、END_DOCUMENT など)が含まれます。これらのタイプに加えて、特別な ATTRIBUTE タイプがあります。これは next() では報告されませんが、START_TAG トークンを見た後、パーサーは非 ATTRIBUTE トークンを見るまで次のトークンを覗き見します。
属性の数が指定されていないため、END_TAG トークンを使用してすぐに現在の要素を閉じることができます。次に、</session> も閉じます。なぜなら、すべての興味深い要素属性は <session> 開始タグにあるからですが、そのポイントは過ぎています。しかし、ここで新しい <session> 要素を開き、そこに属性を設定できます。
前述のように、FastDataInput は Java の DataInputStream と互換性がありますが、追加の readInternedUTF() メソッドがあり、過去の文字列を参照できます。以前にインターンされた文字列がわからないため、常に以前に見たことのない文字列が書き込まれたと指定します。これにより、新しく読み取った文字列がプールに追加され、インジェクションポイント以降に書き込まれたデータの読み取りに問題が生じる可能性があります。ただし、インジェクションの一部として、すべての終了タグと END_DOCUMENT トークンを挿入するため、インジェクション後はそのファイルからそれ以上何も読み取られなくなります。
stageDir を使用した PackageInstaller.Session の利用システムが変更された install_sessions.xml を読み取ると、stageDir が制御下の値に設定された PackageInstallerSession オブジェクトが得られます。
私の最初のアイデアは、stageDir を /proc/self に設定し、maps を読み取り、mem に書き込むことでしたが、これらは機能しませんでした。
openRead() を使用して /proc/self/maps を開こうとしたところ、system_server はファイルを正常に開きましたが、そのファイルを Binder 経由で untrusted_app に渡すことは SELinux によってブロックされました。
ただし、書き込みは生のファイルディスクリプタを別のプロセスに渡すのではなく、system_server を介してプロキシされます。これは、system_server がセッションがコミットされたら書き込みアクセスを取り消す必要があるためです。これは、/proc/self/mem に書き込めることを意味しますか?結局のところ、system_server はそのファイルを開くことができますが、書き込み前にそのファイルに対して Os.chmod() を呼び出します。これは /proc/self/mem では実行できません。したがって、ここでの悪用にはこれを使用できませんが、それ以外では system_server はそのファイルを開き、指定されたオフセットで書き込みを実行でき、そのファイルはコードページの上書きを許可するため、直接コード実行が可能になります。
それが選択肢にならない場合、次のアイデアとして、/data/system/packages.xml の内容を置き換えることを試みました。このファイルには PackageManagerService の状態、特にインストールされているアプリとそれらに割り当てられた UID が含まれています。
system_server はそのファイルに直接書き込むことが許可されていないようです。代わりに、システムがそのファイルを書き込むときは常に一時ファイルに最初に書き込み、その後 packages.xml をその一時ファイルで置き換え、保護を有効にします。
ただし、/data/system/packages.xml を読み取るとき、システムは最初に /data/system/packages-backup.xml ファイルが存在するかどうかを確認し、存在する場合は主要な packages.xml が破損していると見なしてバックアップを読み取ります。通常の動作中、/data/system/packages-backup.xml ファイルは存在せず、stageDir を /data/system に設定した細工された PackageInstallerSession を使用して作成できます。
また、openRead() を使用するときに system_server は /data/system/packages.xml の読み取り専用ファイルディスクリプタを送信することが許可されているため、以前の内容を破損せずに変更のみを含むパッチファイルを簡単に作成できます。
sharedUserId="android.uid.system" アクセスの許可packages.xml には、インストールされたアプリケーションの定義が次のように登録されています。```xml
<package name="com.android.settings" codePath="/system_ext/priv-app/Settings" ... sharedUserId="1000" ...>
`/data/app` に新しいAPKを書き込み(別の `PackageInstallerSession` を使用して)、`packages.xml` に新しい `<package>` 要素を追加して、その方法でインストールすることは可能でしょうか?
はい、ただし `<cert>` 内に新しくインストールしたAPKの有効な署名を提供する必要があり、システムは起動時にAPKファイルに対してそれをチェックします。
`userId`(`AndroidManifest.xml` で `<manifest android:sharedUserId>` 属性を持たないAPKを示すための `sharedUserId` の代わり)属性を任意の値に設定することは可能でしょうか?
はい、ただし他のパッケージや `sharedUserId` ですでに使用されている値を使用してはいけません。
アプリに `sharedUserId="1000"` を設定することは可能でしょうか?
もしそうした場合、起動時にシステムは [`canJoinSharedUserId()`](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/services/core/java/com/android/server/pm/PackageManagerServiceUtils.java;l=658-750;drc=7ec13b04c3bbaeac99cbbc4db9f9f80492c508fe) を通じてその設定を検証します。
特にそのメソッドは [`checkCapability()`](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/content/pm/SigningDetails.java;l=613-637;drc=97a370a95275e79c69e79d7ead11aa38934a5575) を使用して、署名が完全に一致するか、または一方の署名が他方の過去の署名のいずれかと一致するかをチェックします。
これらの「過去の署名」は `packages.xml` から取得されます。特に `<sigs>` 要素に `<cert>` がある場合、 `<sigs>` の下に `<pastSigs>` 要素を追加して、[`SigningDetails.mPastSigningCertificates`](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/content/pm/SigningDetails.java;l=74-87;drc=97a370a95275e79c69e79d7ead11aa38934a5575) に新しいエントリを追加できます。
最終的に、改変された `<shared-user>` 要素は次のようになります:```xml
<shared-user name="android.uid.system" userId="1000">
<sigs count="1" schemeVersion="3">
<cert index="3" />
<pastSigs count="2" schemeVersion="3">
<cert index="19" flags="2" />
<cert index="19" flags="2" />
</pastSigs>
</sigs>
</shared-user>
<pastSigs> 内の <cert> 要素は、最後の過去署名が現在のものと見なされ、そのため考慮されないため、2回挿入されます。
flags="2" は、その証明書が sharedUserId に対して許可されていることを意味します
また、<package sharedUserId="1000"> の登録は、マニフェストで android:sharedUserId="android.uid.system" を宣言するアプリに適用されなければならないため、それは悪用を実行するアプリとは別のAPKでなければなりません。
android:sharedUserId="android.uid.system" に対して信頼される新しい証明書を登録できた一方で、通常、その証明書で署名され、マニフェストで sharedUserId のみを宣言するアプリは起動できません。起動時に logcat に次のメッセージが表示されます。```
signal 6 (SIGABRT), code -1 (SI_QUEUE), fault addr --------
Abort message: 'JNI FatalError called: (com.example.abxoverflow.droppedapk) frameworks/base/core/jni/com_android_internal_os_Zygote.cpp:1976: selinux_android_setcontext(1000, 0, "default:privapp:targetSdkVersion=33:complete", "com.example.abxoverflow.droppedapk") failed'
これは、[`seapp_contexts` ファイル](https://cs.android.com/android/platform/superproject/main/+/main:system/sepolicy/private/seapp_contexts)内の定義が1つも一致しなかったためです
そのファイルの `user=` ルールは [`uid`](https://cs.android.com/android/platform/superproject/main/+/main:external/selinux/libselinux/src/android/android_seapp.c;l=819-833;drc=530165a996d8ca5ab5959c33bc040c78951bcb59) からマッピングされます(`selinux_android_setcontext()` の第1引数)。私たちのアプリでは `user=system` となり、通常のアプリでは `user=_app` です。
もう1つの照合対象は `seinfo=` ルールで、これは `selinux_android_setcontext()` の第3引数から最初のコロンまでが取得されます。元々その値は、起動されたアプリの署名と `/system/etc/selinux/plat_mac_permissions.xml` で定義された署名との比較から得られます。
結局、私たちのアプリは `user=system seinfo=default` に一致しようとしますが、`seapp_contexts` にはそのようなルールはありません。
ただし、新しい `android:sharedUserId="android.uid.system"` アプリのプロセスは開始できませんが、[`android:process` 属性](https://developer.android.com/guide/topics/manifest/application-element#proc) を指定することで、既存のプロセスにアプリをロードすることは可能です。特に、`android.uid.system` で動作するアプリは `android:process="system"` を指定することで `system_server` にロードできます。
# システムのクラッシュ
一般的に、[`system_server` のクラッシュを引き起こすアプリは無視できるセキュリティ影響のバグと見なされます](https://bughunters.google.com/learn/invalid-reports/android-platform/5148417640366080/bugs-with-negligible-security-impact#triggering-a-local-temporary-denial-of-service)。ここでは、2回の `system_server` 再起動を必要とするエクスプロイトチェーンの一部であるため、言及する価値があります。
ともあれ、`Parcelable` チェーンがあります:
* [`IAlarmManager.set()` AIDL メソッドは `AlarmManager.AlarmClockInfo` を受け入れます](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/apex/jobscheduler/framework/java/android/app/IAlarmManager.aidl;l=32-35;drc=ca41ed611ac9c6584c6d5c38ae8428b8e4f3b135)
* [`AlarmClockInfo` は非推奨の `readParcelable()` を型引数なしで呼び出します](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/apex/jobscheduler/framework/java/android/app/AlarmManager.java;l=1598;drc=04bf84e220ade9d7ad8ef0b2f7e6ce6ec72841c8)(apex モジュール内にあるため、新しいメソッドに切り替えられていません)
* [`android.content.pm.PackageParser$Activity`](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/content/pm/PackageParser.java;l=8240;drc=7d3ffbae618e9e728644a96647ed709bf39ae759) を `Parcelable` クラスとして指定します
* それを読み取ると、[単一の `Parcel` 引数を受け入れる任意の public コンストラクタの呼び出し](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/content/pm/PackageParser.java;l=7789-7795;drc=7d3ffbae618e9e728644a96647ed709bf39ae759) につながります
* [`android.os.PooledStringWriter`](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/os/PooledStringWriter.java;l=55;drc=782d49826862cbdc9d020fc9d85f8a6f64675dcb) を指定します。これは、指定された `Parcel` に対して `writeInt(0)` を呼び出します
* その `writeInt()` 呼び出しは、[`onTransact()`](https://developer.android.com/reference/android/os/Binder#onTransact(int,%20android.os.Parcel,%20android.os.Parcel,%20int)) の `data` 引数として受け取った `Parcel` に対して行われました。この `Parcel` は `/dev/binder` から `mmap` された読み取り専用メモリにバックアップされています。そこへの書き込みは `SIGSEGV` を引き起こします
また注目すべき点として、[私は以前の報告(例:CVE-2023-21098)の一部として `PackageParser`+`PooledStringWriter` の組み合わせを使用しました](https://github.com/michalbednarski/TheLastBundleMismatch)
# 全体の流れ
アプリ内で「すべて実行」ボタンを押すと、次のようになります
1. `RebootBackgroundRunner` が別プロセスとして起動されます。このプロセスは [`setsid()`](https://man7.org/linux/man-pages/man2/setsid.2.html) を使用してユーザースペースの再起動後も存続し、その後バックグラウンドで待機します
2. 新しい `PackageInstaller.Session` が割り当てられ、新しい `Checksum` オブジェクトが追加されます。その `Checksum` オブジェクトには、シリアル化中に整数オーバーフローを引き起こすサイズのバイト配列が含まれています。データが逆シリアル化されると、システムはデータが以前 `Checksum` ペイロードであった `PackageInstaller.Session` を認識します。具体的には、2つのセッションが注入されます。
* `sessionStageDir="/data/system"` かつ `prepared="true"` のもの(ステージディレクトリが既に準備済みで作成不要)
* `sessionStageDir="/data/app/dropped_apk"` かつ `prepared="false"` のもの(最初の `Session.openWrite()` でディレクトリが作成される)
3. 新しい `PackageInstaller.Session` が割り当てられ、すぐに破棄されます。これにより、システムが更新された内容を `install_sessions.xml` に書き込むようになります
4. 少し遅れて、`system_server` のクラッシュがトリガーされます
5. 次の `system_server` の起動時に、`install_sessions.xml` ファイルが読み取られ、注入した `PackageInstaller.Session` が使用可能になります
6. `RebootBackgroundRunner` はユーザースペースの再起動中にバックグラウンドで待機しており、システムが復旧して準備完了になったことを検知すると、次のステップを実行します
7. 一方の `PackageInstaller.Session` を使用して、新しい APK がアセットから抽出され、`/data/app/dropped_apk/base.apk` に書き込まれます
8. もう一方のセッションを使用して `/data/system/packages.xml` が読み取られ、そのファイルにパッチが適用されます。これにより、新しくドロップされた APK が既にインストール済みであり、それに使用された証明書が以前 `android:sharedUserId="android.uid.system"` に使用され、その目的でまだ信頼されていることが宣言されます。変更されたファイルは `/data/system/packages-backup.xml` として書き込まれます
9. 別の `system_server` クラッシュがトリガーされます
10. `system_server` が起動中に `packages-backup.xml` を検出すると、元の `packages.xml` が破損していると見なし、代わりにバックアップを使用します
11. システムが変更された `packages.xml` を読み取ったため、ドロップされたばかりのアプリが存在し、[`ACTION_BOOT_COMPLETED`](https://developer.android.com/reference/android/content/Intent#ACTION_BOOT_COMPLETED) から自身を起動します。その新しいアプリは、`AndroidManifest.xml` に `<manifest android:sharedUserId="android.uid.system">` と `<application android:process="system">` があるため、`system_server` 内で実行されます
# `utils/` 内のスクリプト
PoC アプリとともに、いくつかのスクリプトが含まれる `utils` ディレクトリがあります
* `moveapk.sh` は、コンパイルされた APK をドロッパーの `assets` に移動します。`gradle :droppedapk:assembleRelease` の後に実行します
* `peeksessions.sh` は、`install_sessions.xml` の現在の内容を表示できます(Android の `eng`/`userdebug` ビルドが必要)
* `wipesessions.sh` は、存在する `PackageInstaller.Session` をすべてクリアしてシステムを再起動します(Android の `eng`/`userdebug` ビルドが必要)
# トリビア
関連しているかは不明ですが、ABX 関連の可能性があるバグの履歴(`cd frameworks/base ; git log -S ABX`)を調べたところ、["Stop processing on IOException" コミット](https://android.googlesource.com/platform/frameworks/base/+/5112cfef2a2023a2629a426154547444593e9f9b%5E!/) を見つけました。これは **切り詰められた ABX ファイルを使用した単体テストの追加を含んでいます**。そのコミットは ["Ignore malformed shortcuts"](https://android.googlesource.com/platform/frameworks/base/+/d5122bfaf18f1503e73c1a3a177a56d0f604a008%5E%21/) の後続であり、[これは速報で DoS として説明されていました](https://source.android.com/docs/security/bulletin/2022-12-01#framework)