
CVE-2024-49746の解説とエクスプロイト:AndroidのParcel::continueWriteによる、後で使用されるファイルディスクリプタのクローズ
この問題の修正は CVE-2024-49746 として公開されました: bulletin, patch
上記のタイトルは、Parcel::continueWrite メソッドからのコメントです。このメソッドは実際には Parcel オブジェクトのサイズ変更を担当しており、ユーザーが明示的に要求した場合(例えば setDataSize() を通じて)や、現在のデータ容量が小さすぎるために write メソッドのいずれかを呼び出す場合に、サイズ変更を行います。```cpp
status_t Parcel::continueWrite(size_t desired)
{
// SNIP: Validate desired size
// SNIP: Assign kernelFields & rpcFields from variant member of this class
// SNIP: Count number of objects (Binder handles and File Descriptors)
// that will be present after resize and assign to objectsSize
if (mOwner) {
// If the size is going to zero, just release the owner's data.
if (desired == 0) {
freeData();
return NO_ERROR;
}
// If there is a different owner, we need to take
// posession.
uint8_t* data = (uint8_t*)malloc(desired);
// SNIP: Check if malloc succeeded
binder_size_t* objects = nullptr;
if (kernelFields && objectsSize) {
objects = (binder_size_t*)calloc(objectsSize, sizeof(binder_size_t));
// SNIP: Check if calloc succeeded
// Little hack to only acquire references on objects
// we will be keeping.
size_t oldObjectsSize = kernelFields->mObjectsSize;
kernelFields->mObjectsSize = objectsSize;
acquireObjects();
kernelFields->mObjectsSize = oldObjectsSize;
}
// SNIP: rpcFields handling for non-/dev/binder Parcels
if (mData) {
memcpy(data, mData, mDataSize < desired ? mDataSize : desired);
}
if (objects && kernelFields && kernelFields->mObjects) {
memcpy(objects, kernelFields->mObjects, objectsSize * sizeof(binder_size_t));
}
// ALOGI("Freeing data ref of %p (pid=%d)", this, getpid());
if (kernelFields) {
// TODO(b/239222407): This seems wrong. We should only free FDs when
// they are in a truncated section of the parcel.
closeFileDescriptors();
}
mOwner(mData, mDataSize, kernelFields ? kernelFields->mObjects : nullptr,
kernelFields ? kernelFields->mObjectsSize : 0);
mOwner = nullptr;
// SNIP: Allocation count tracking
// SNIP: Assign data and objects to this object
} else if (mData) {
// SNIP: Resize data owned by this instance of Parcel
} else {
// SNIP: Allocate initial data for currently empty Parcel
}
return NO_ERROR;
}
[そのコメントが導入された時点](https://android.googlesource.com/platform/frameworks/native/+/53b6ffe5af3951e8784c451ef8c4ff19f3d6b196%5E!/)で、`closeFileDescriptors()` の呼び出しは `IPCThreadState::freeBuffer()`(上記のコードで `mOwner()` 関数ポインタを通じて呼び出される)から `continueWrite()` メソッドに移動されましたが、ロジックは以前と同じでした。結局のところ、`Parcel` は Android IPC の核となる部分であり、もしコア IPC が本来閉じてはいけないファイルディスクリプタを閉じていたとしたら、それは明らかな問題になるでしょう。
ここで重要な点に移ります。上記のコードはいつ使用されるのでしょうか?これは、Parcel クラスが Binder ドライバから受信したデータ(その時点では [`/dev/binder` `mmap`](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/native/libs/binder/ProcessState.cpp;l=587-592;drc=187efe18e3de6258af0230198c881915cc695567) 内に存在し、書き込みができない(そのメモリに書き込もうとすると `SIGSEGV` が発生する))の所有権を移動するときに使用されます。つまり、`Parcel` が着信トランザクションデータ([`onTransact()`](https://developer.android.com/reference/android/os/Binder#onTransact(int,%20android.os.Parcel,%20android.os.Parcel,%20int)) に渡される `data` 引数)または着信リプライ(すなわち、[`transact()`](https://developer.android.com/reference/android/os/IBinder#transact(int,%20android.os.Parcel,%20android.os.Parcel,%20int)) 呼び出しに `reply` 引数として渡された `Parcel` オブジェクト、`transact()` はその `Parcel` オブジェクト内に参照を設定します)のいずれかである場合です。
実際には、`if (mOwner)` ブロックに入る唯一のケースは、システムが[トランザクションデータを解放するために `setDataSize(0)` を呼び出す](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/native/libs/binder/IPCThreadState.cpp;l=1483-1488;drc=187efe18e3de6258af0230198c881915cc695567)場合ですが、その場合でも `if (desired == 0)` に入り、早期リターンが発生します。正当なシステム使用中に「異なる所有者がいる場合、所有権を取得する必要がある」パスに入るケースはありません。
# 「所有権取得」パスのトリガー
以前のエクスプロイトの1つで、[`createFromParcel()` が、本来読み取るべき `Parcel` に対して `writeInt(0)` を実際に呼び出すことができるケース](https://github.com/michalbednarski/TheLastBundleMismatch#side-effects)を示しました。その修正により、`AccountManagerService` 内での `Intent` 以外の `createFromParcel()` メソッドの実行は防止されましたが、`createFromParcel()` から `writeInt(0)` へのパスはそのまま維持されました。
まとめると、[`PackageParser` 内には以下のコードがあります](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/content/pm/PackageParser.java;l=7789-7795;drc=7d3ffbae618e9e728644a96647ed709bf39ae759):```java
final Class<T> cls = (Class<T>) Class.forName(componentName);
final Constructor<T> cons = cls.getConstructor(Parcel.class);
intentsList = new ArrayList<>(N);
for (int i = 0; i < N; ++i) {
intentsList.add(cons.newInstance(in));
}
したがって、Parcel オブジェクトを createFromParcel に渡し、それをシステム内で利用可能な単一の Parcel 引数を受け取る public コンストラクタに渡すことができます。
また別の場所では、次のコードがあります:```java public PooledStringWriter(Parcel out) { mOut = out; mPool = new HashMap<>(); mStart = out.dataPosition(); out.writeInt(0); // reserve space for final pool size. }
したがって、「所有権取得」パスをトリガーするには、`onTransact()` に `data` として渡された `Parcel` 上で任意の `readParcelable` 呼び出しが必要です。このエクスプロイトでは、以前別のエクスプロイトで使用した[同じパス](https://github.com/michalbednarski/LeakValue#putting-parcelables-in-system_server-and-retrieving-them)を利用しています。`readParcelable` 呼び出しによって `PackageParser$Activity.CREATOR.createFromParcel()` が呼び出され、さらにその中で `PooledStringWriter` の名前が読み取られ、コンストラクタが呼び出された後で `Parcel` データが終了します。そのため、`writeInt()` は Parcel を再割り当てする必要があり、これによって「所有権取得」パスに入ります。
ここで注意すべき点ですが、もしその時点で `Parcel` データが終了していなかった場合、`writeInt()` はデータをその場で上書きしようとします。データが `/dev/binder` の `mmap` でバックアップされている場合、これは `SIGSEGV` を引き起こします。
# ファイルディスクリプタサニタイザ
当初のアイデアは、「所有権取得」パスでファイルディスクリプタを閉じ、その後トランザクションの終了時に同じディスクリプタが再度閉じられるようにすることでした。しかし、その間に別のトランザクションで `system_server` 内に別のファイルディスクリプタを配置し、後で自分のファイルディスクリプタを取り戻すというものです。その時点でその FD は別のファイルを指していることになります。
これは古い AOSP バージョンを使用したエミュレータでは機能しましたが、より新しいバージョンで試したところ、[ファイルディスクリプタサニタイザ (FDSan)](https://android.googlesource.com/platform/bionic/+/refs/heads/main/docs/fdsan.md) によって阻止されました。
特に、[`android-14.0.0_r29` では FDSan のカバレッジが拡張され、`Parcel` 内の FD も対象となりました](https://android.googlesource.com/platform/frameworks/native/+/7772039cc5084247450f6113d9a18eca17f672aa%5E!/)。