此问题的修复以 CVE-2024-49746 的形式出现:公告,补丁
上面的标题是来自 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` 要么是传入的事务数据(作为 `data` 参数传给 [`onTransact()`](https://developer.android.com/reference/android/os/Binder#onTransact(int,%20android.os.Parcel,%20android.os.Parcel,%20int))),要么是传入的回复(即作为 `reply` 参数传给 [`transact()`](https://developer.android.com/reference/android/os/IBinder#transact(int,%20android.os.Parcel,%20android.os.Parcel,%20int)) 调用的 `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)`,它会提前返回。在合法的系统使用过程中,没有任何情况下我们会进入“如果存在不同的所有者,我们需要接管”这条路径。
# 触发“接管”路径
在我之前的一个漏洞利用中,我展示了 [`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));
}
因此,我们可以将传递给 createFromParcel 的 Parcel 对象传递给系统中任何可用的、接受单个 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()` 的 `Parcel` 上(作为 `data`)进行任意的 `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`
# File Descriptor Sanitizer
我最初的想法是让“接管”路径关闭文件描述符,之后在事务结束时同样的描述符会被再次关闭,但在这两件事之间,我会在另一个事务中把其他文件描述符放入 `system_server`,稍后再取回我的文件描述符,因为此时该 FD 指向的是不同的文件
在使用旧版 AOSP 的模拟器上这个方法有效,但当我尝试更新版本时,这个计划被 [File Descriptor Sanitizer (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!/)
事实上,在 FDSan 覆盖 Parcel 之后,当 `Parcel` 包含 FD 时,我甚至无法到达 `closeFileDescriptors()` 调用。在该调用之前,会有对 `acquireObjects();` 的调用,它会获取对 `Binder` 句柄的引用(这些引用稍后会在该函数内由 `mOwner()` 调用释放),然而 `acquireObjects()` 还会 [为 FD 设置 FDSan 标签](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/native/libs/binder/Parcel.cpp;l=175-178;drc=f4c9b48c19f1b040efb35932b322f47e7779cafe):```cpp
case BINDER_TYPE_FD:
if (obj.cookie != 0) { // owned
FdTag(obj.handle, nullptr, who);
}
问题在于我们已经对从内核接收到的 FD 打了标签```cpp // In Parcel::ipcSetDataReference, which assigns this Parcel object to data from kernel (mOwner != null) if (type == BINDER_TYPE_FD) { // FDs from the kernel are always owned FdTag(flat->handle, nullptr, this); }
因此,双重 `FdTag`(在不关闭或更改 tag 的情况下指定预期的旧 tag)会导致 FDSan 错误,从而中止进程,而且我们甚至还没有执行到 `closeFileDescriptors()` 调用。由于“接管”路径在正常使用期间是死代码,此类问题可能不会被注意到
然而,我们可以看到 `if (obj.cookie != 0)` 条件。如果该条件为假,则意味着 `Parcel` 中的 FD 并非真正由该 `Parcel` 拥有,只要该 `Parcel` 存在,保持这些 FD 打开就是 `Parcel` 使用者的责任。但由于这个 `Parcel` 刚从内核传来,`cookie` 值实际上来自原始进程,并且当 `Parcel` 确实具有 `mOwner` 时,这些值被认为无关紧要。但“接管”路径实际上并未考虑这一点,而只是复制 `cookie` 值
综合以上所有内容,通过在发送方将 `cookie` 值设为零,我们可以得到一个引用已关闭文件描述符的 `Parcel`,但它并不认为自己拥有这些描述符,这意味着它不会再次关闭它们。这让我们能够避免触发 FDSan,但也消除了所有双重关闭的利用路径
# Parcel 的 Java 端技巧