
CVE-2024-49746에 대한 라이트업 및 익스플로잇: 이후에 사용되는 파일 디스크립터를 닫는 Android의 Parcel::continueWrite
이 문제에 대한 수정은 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()` 호출은 (위 코드에서 `mOwner()` 함수 포인터를 통해 호출되는) `IPCThreadState::freeBuffer()`에서 `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()`에 전달되는 `data` 인자)이거나 수신 reply(`transact()` 호출에 `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)`에 진입하게 되어 조기 반환이 일어납니다. 그러나 정상적인 시스템 사용 중에는 "다른 소유자가 있다면 소유권을 가져와야 한다" 경로로 진입하는 경우가 없습니다.
# "소유권 가져오기" 경로 트리거하기
이전 익스플로잇 중 하나에서 [`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. }
따라서 "take possession" 경로를 트리거하려면 `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을 재할당해야 하며 우리의 "take possession" 경로로 진입하게 됩니다.
여기서 주목할 점은, 그 시점에 `Parcel` 데이터의 끝이 아니었다면 `writeInt()`는 데이터를 제자리에서 덮어쓰려 시도할 것이고, `/dev/binder` `mmap`으로 뒷받침되는 데이터의 경우 `SIGSEGV`가 발생했을 것입니다.
# 파일 디스크립터 샌나타이저
제 초기 아이디어는 "take possession" 경로가 파일 디스크립터를 닫도록 하고, 그 후 트랜잭션이 끝날 때 동일한 디스크립터가 다시 닫히도록 하는 것이었습니다. 그러나 그 사이에 다른 트랜잭션으로 `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` (예상되는 이전 태그를 지정하면서 태그를 닫거나 변경하지 않음)는 FDSan 오류를 유발하고, 이는 프로세스를 중단시키며, 우리는 아직 `closeFileDescriptors()` 호출에 도달하지도 못했습니다. "소유권 이전( take possession )" 경로는 정상적인 사용 중에는 죽은 코드(dead code)이므로 이러한 문제는 눈에 띄지 않을 수 있습니다