Skip to content
KitploitKITPLOIT
도구블로그
제출
도구블로그
제출

해킹, 침투 테스트 및 사이버 보안 도구를 당신의 보안 무기고에!

Kitploit은 해킹, 사이버 보안 및 침투 테스트 도구 디렉토리입니다. 최신 프로젝트 업데이트를 발견하여 취약점을 찾고, 시스템을 분석하고, 테스트를 자동화하고, 보안을 강화하세요.

··피드·문의·개인정보·© 2026 Kitploit

도구 디렉토리

카테고리

모든 카테고리 보기
Loading categories
ThisSeemsWrong — CVE-2024-49746에 대한 라이트업 및 익스플로잇: 이후에 사용되는 파일 디스크립터를 닫는 Android의 Parcel::continueWrite | Kitploit
도구/GitHubGitHub/michalbednarski/thisseemswrong
Android SecurityExploit FrameworksVulnerability AnalysisInformation GatheringPayload DevelopmentBinary Exploitation
GitHubmichalbednarski/thisseemswrong

ThisSeemsWrong

CVE-2024-49746에 대한 라이트업 및 익스플로잇: 이후에 사용되는 파일 디스크립터를 닫는 Android의 Parcel::continueWrite

저장소 보기
471510개월 전Kitploit 검토 완료

인기

모두 보기 →

커뮤니티에서 가장 많이 사용되는 도구를 찾아보세요.

모든 도구 탐색

도구 컬렉션을 둘러보세요

모든 도구 보기 →
공유

이 문제에 대한 수정은 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

root@kitploit:~
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;

}

root@kitploit:~
[해당 주석이 도입되었을 때](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. }

root@kitploit:~
따라서 "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); }

root@kitploit:~
그래서, 이중 `FdTag` (예상되는 이전 태그를 지정하면서 태그를 닫거나 변경하지 않음)는 FDSan 오류를 유발하고, 이는 프로세스를 중단시키며, 우리는 아직 `closeFileDescriptors()` 호출에 도달하지도 못했습니다. "소유권 이전( take possession )" 경로는 정상적인 사용 중에는 죽은 코드(dead code)이므로 이러한 문제는 눈에 띄지 않을 수 있습니다

하지만 `if (obj.cookie != 0)` 조건을 볼 수 있습니다. 이 조건이 거짓이면 `Parcel` 내에 존재하는 FD가 실제로 해당 `Parcel`에 의해 소유되지 않는다는 뜻이며, 해당 `Parcel`이 존재하는 동안 FD를 계속 열어 둘 책임은 `Parcel` 사용자에게 있습니다. 그러나 이 `Parcel`은 방금 커널에서 왔기 때문에 `cookie` 값은 실제로 원래 프로세스에서 온 것이며 `Parcel`에 실제로 `mOwner`가 있을 때는 관련이 없는 것으로 간주됩니다. 하지만 "소유권 이전" 경로는 이를 실제로 고려하지 않고 `cookie` 값을 그대로 복사할 뿐입니다

이 모든 것을 종합하면, 송신자 측에서 `cookie` 값을 0으로 설정함으로써, 닫힌 파일 디스크립터를 참조하지만 소유하고 있다고 간주되지 않는 `Parcel`을 얻을 수 있으며, 이는 다시 닫지 않는다는 것을 의미합니다. 이를 통해 FDSan을 건드리지 않을 수 있지만, 동시에 이중 닫기(double-close) 공격 경로도 제거됩니다

# Parcel의 Java 측 트릭

이러한 FD는 여전히 다른 `Parcel`(그리고 다른 프로세스)로 전달될 수 있지만, 우리가 이러한 매달린(dangling) FD 생성을 트리거하는 방식은 리플렉션을 통한 `PooledStringWriter` 생성 후 `ArrayList<IntentInfo>`에 `add()`하려고 할 때 `ClassCastException`이 발생하는 것을 포함합니다

다음이 필요합니다:

* `onTransact()`의 `data` 인자로 전달된 `Parcel`의 끝에 있어야 함
* `PooledStringWriter` 생성을 수행하여, 그 후 Parcel이 매달린 FD를 갖게 되지만 `ClassCastException`도 발생해야 함
* 누출하려는 FD가 `system_server` 내에서 할당될 때까지 기다려야 함
* 해당 `Parcel`의 FD가 우리 프로세스로 전송될 다른 `Parcel`에 복사되어야 함

이 모든 요구 사항을 충족하려면 이전 트릭과 새로운 트릭을 모두 사용해야 합니다

## 이전 트릭

이전 트릭을 다시 살펴보는 것부터 시작하겠습니다. 대부분은 이미 제 [`LazyValue`-`Parcel`-`recycle()`-후-사용-공격](https://github.com/michalbednarski/LeakValue)에서 설명했습니다

1. [`RemoteViews` 클래스는 `Parcel.ReadWriteHelper`가 설정된 상태로 포함된 `Bundle`의 역직렬화를 수행합니다](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/widget/RemoteViews.java;l=2287-2300;drc=9b2e54f25456f2726ab1a15e6b6dc19395a3b5b4). [`ReadWriteHelper`가 설정되면 `Bundle`은 지연(lazily) 역직렬화되지 않고 즉시(eagerly) 역직렬화됩니다](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/os/BaseBundle.java;l=1886-1896;drc=e1841f84f41213879e1f1b45ad4300b96970e545). 또한 이 시점에서 `RemoteViews` 아래 해당 `Bundle` 내에 포함된 모든 `Bundle`도 직접 `RemoteViews` 안에 있는 하나만이 아니라 즉시 역직렬화된다는 점에 주목할 만합니다
2. [`system_server` 내에서 `Bundle` 역직렬화 중 `BadParcelableException`이 발생하면 해당 `BadParcelableException`은 조용히 잡히고 `Bundle` 내용이 비워집니다](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/os/BaseBundle.java;l=479-485;drc=efb735f4d5a2f04550e33e8aa9485f906018fe4e). 단, 우리의 `createFromParcel`-내-쓰기 트리거는 여기서 잡히지 않을 `ClassCastException`을 발생시킨다는 점에 유의하세요
3. [`ParceledListSlice` 클래스는 역직렬화 중에 직렬화된 데이터에 지정된 객체로 차단(blocking) 발신 Binder 호출을 수행합니다](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/content/pm/BaseParceledListSlice.java;l=95-102;drc=e220b578ebc0885a28b83c95cb9ec78581bd8364). 이를 사용하여 역직렬화 실행을 지연시킬 수 있습니다

이 모든 것이 이제 필요하지만, 그것이 전부는 아닙니다

## 새로운 트릭

[AIDL은 RPC 인터페이스 구현을 생성하는 도구입니다](https://developer.android.com/guide/components/aidl). 하지만 그 외에도 `Parcelable` 구조체 구현을 생성할 수도 있습니다

이러한 구조체는 길이-접두사(length-prefixed) 방식이므로, 필드가 중간에 추가되지 않는 한 동일 구조체의 서로 다른 버전은 시스템 내에서 호환됩니다. 즉, 한 버전이 다른 버전의 접두사(prefix)이면 버전이 호환됩니다

AIDL이 [`ReceiverInfo` 구조체](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/app/ReceiverInfo.aidl)에 대해 생성한 코드를 살펴보겠습니다:```java
public final void readFromParcel(android.os.Parcel _aidl_parcel)
{
  int _aidl_start_pos = _aidl_parcel.dataPosition();
  int _aidl_parcelable_size = _aidl_parcel.readInt();
  try {
    if (_aidl_parcelable_size < 4) throw new android.os.BadParcelableException("Parcelable too small");;
    if (_aidl_parcel.dataPosition() - _aidl_start_pos >= _aidl_parcelable_size) return;
    intent = _aidl_parcel.readTypedObject(android.content.Intent.CREATOR);
    if (_aidl_parcel.dataPosition() - _aidl_start_pos >= _aidl_parcelable_size) return;
    data = _aidl_parcel.readString();
    if (_aidl_parcel.dataPosition() - _aidl_start_pos >= _aidl_parcelable_size) return;
    extras = _aidl_parcel.readTypedObject(android.os.Bundle.CREATOR);
    // SNIP: Other fields
  } finally {
    if (_aidl_start_pos > (Integer.MAX_VALUE - _aidl_parcelable_size)) {
      throw new android.os.BadParcelableException("Overflow in the size of parcelable");
    }
    _aidl_parcel.setDataPosition(_aidl_start_pos + _aidl_parcelable_size);
  }
}

이 코드를 사용하면 남은 두 가지 트릭을 수행할 수 있습니다. 두 트릭 모두 "take possession" 경로를 트리거한 후 추가 작업을 수행하는 데 필요합니다. 해당 경로는 Parcel의 끝에 있어야 하며 ClassCastException을 발생시킵니다.

먼저 Parcel에서 길이를 읽은 다음, 그 길이를 사용하여 작성된 버전에 존재하지 않는 필드를 건너뜁니다. 마지막에는 해당 길이를 기준으로 Parcel 내부의 위치를 조정합니다. 해당 AIDL Parcelable의 위치보다 앞으로 이동할 수는 없지만(그리고 Parcel의 끝을 넘어 이동하는 것은 가능하지만 실제로 위험한 작업은 아닙니다), 이미 읽은 객체의 중간으로는 이동할 수 있습니다. 따라서 첫 번째 읽기 중에 Parcel의 끝에 도달하여 PooledStringWriter를 생성한 다음, 이 ReceiverInfo 객체 내부에 있는 무언가의 중간으로 되감을 수 있습니다.

이로써 Parcel의 끝에 있어야 하는 문제는 해결되지만, 여전히 ClassCastException 문제가 남습니다. 그런데 여기에는 setDataPosition()을 호출하기 전에 오버플로가 없는지 검증하는 finally 블록이 있습니다. 오버플로가 있으면 BadParcelableException이 발생합니다. 그렇다면 finally 블록에서 다른 예외가 대기 중일 때 Exception이 발생하면 어떻게 될까요? finally 내부에서 발생한 예외가 우선하며, 이전 Exception은 조용히 사라집니다. 이제 ClassCastException 대신 Bundle이 system_server 내부에서 Exception이 발생하는 것을 피하기 위해 무시하는 BadParcelableException이 발생합니다.

ParcelableListBinder 패치에 관한 참고 사항

우리는 MediaSession의 ParcelableListBinder를 사용하여 임의의 Parcelable을 system_server 안에 넣고 나중에 해당 객체를 다시 가져옵니다.

최근에는 정확히 그런 동작을 허용하지 않는 ParcelableListBinder 패치가 있었습니다.

이 익스플로잇의 관점에서 이 패치는 실제로 나를 막지 못하지만, 패치가 있는 경우와 없는 경우를 다르게 처리해야 합니다.

해당 패치가 있는 경우, QueueItem이 아닌 항목은 system_server가 수신하는 목록에서 조용히 제거됩니다(Exception을 발생시키지 않습니다). 우리는 부수 효과를 위해 QueueItem이 아닌 항목이 필요하므로, 첫 번째 항목으로 QueueItem이 아닌 항목을 넣고 그 뒤에 실제 QueueItem을 이어서 넣을 수 있습니다. 실제 QueueItem은 임의의 Parcelable 역직렬화를 허용하지 않지만, 우리 프로세스로 전달되기를 원하는 File Descriptors를 포함할 수 있는 Bundle을 포함합니다.

패치가 없는 경우에는 그런 장애물이 없지만, 동일한 흐름을 사용할 수도 없습니다. 위의 경우 RemoteViews와 QueueItem이 모두 포함된 목록이 생성되므로 ParceledListSlice는 혼합 유형 목록의 전송을 거부합니다. 그 경우에는 유출된 FD를 RemoteViews 인스턴스 안에 넣어야 합니다.

하지만 이 패치에 대해 더 흥미로운 점은 패치가 도입된 이유입니다. 커밋 메시지는 "앱이 백그라운드에서 시작하도록 허용"이라고 언급하며, Android 보안 게시판은 많은 것을 말해 주지 않지만, CVE 항목에서 유용한 정보를 찾을 수 있습니다. 해당 CVE는 Notification.mAllowlistToken 필드를 가리키며, 이 필드는 읽는 동안 정적 필드에서 가져올 수 있고, system_server 내부에서는 백그라운드 Activity 시작을 허용하는 토큰이며, 나중에 그 토큰은 Notification.writeToParcel()에 기록됩니다. 이제 system_server가 임의의 Parcelable을 역직렬화하여 앱으로 다시 보내는 모든 경우가 취약점이 되는 것일까요? 어쨌든 지금은 그냥 생각일 뿐이며, 이 익스플로잇에서는 어차피 더 많은 작업을 하고 싶습니다.

이것들을 조합하기

이 익스플로잇은 제가 지금까지 만든 Parcelable 가젯 체인 중 가장 복잡한 구성을 갖추고 있다고 생각합니다:

  • RemoteViews (1)
    • ReflectionAction (2)
      • Bundle
        • Parcelable[] (3)
          • ReceiverInfo (seek용, 4 및 10)
            • Intent
              • ComponentName (세그먼트 A, 5)
                • 선택적 패딩 File Descriptors
                • ParceledListSlice (11)
                • ParcelableParcel 또는 QueueItem (12)
              • Bundle (catch용, 세그먼트 B, 6)
                • ReceiverInfo (rethrow용, 7)
                  • Bundle (8)

위 목록의 "segment A" 및 "B" 주석은 제 FdLeaker.java 클래스에 있는 "START A"/"END A"/"START B"/"END B" 주석 사이의 블록을 나타냅니다. 숫자는 아래 목록의 지점을 나타냅니다.

위 트리는 송신자 관점에서의 계층 구조를 설명합니다. 하지만 수신자 관점에서는 조금 다르게 보입니다:

  1. 우리는 ParcelableListBinder로부터 데이터를 수신하며, 가장 바깥쪽 객체는 RemoteViews입니다.
  2. 해당 RemoteViews에는 중첩된 Bundle이 있습니다. RemoteViews는 Parcel.ReadWriteHelper를 설정하므로 이 Bundle과 그 안의 모든 Bundle들은 즉시 읽힙니다. 그렇지 않으면 ReceiverInfo.readFromParcel()에서 임의의 readParcelable을 실행할 수 없기 때문에 이는 필수적입니다.
  3. Parcelable[]는 여기서 제가 RemoteViews 안에 넣는 모든 Parcelable을 그룹화하기 위한 편의 래퍼일 뿐입니다.

그렇다면 어떤 File Descriptors를 가져올 수 있을까요?

위 공격을 높은 수준에서 살펴보겠습니다:

  1. system_server 내에서 File Descriptor가 열리고, 해당 File Descriptor에 대한 참조가 기억된 후 FD가 닫힙니다.
  2. 나는 system_server가 다른 File Descriptor를 열도록 트리거합니다.
  3. 1단계에서 생성한 Dangling File Descriptor가 다시 나에게 전송됩니다.

이 공격에는 중요한 제한이 있습니다: 공격이 시작되기 전에 열린 File Descriptors는 가져올 수 없습니다.

그러나 여전히 우리가 할 수 있는 몇 가지 유용한 작업이 있습니다.

InputChannel 가져오기

솔직히 말하면, 이것은 추가 가정 없이 작동시킨 유일한 익스플로잇 변형입니다.

입력 이벤트, 즉 터치스크린과 키보드의 이벤트는 앱이 system_server의 UNIX 소켓을 통해 수신합니다. Activity가 시작되거나 시스템에 새 창을 추가할 때 새로운 InputChannel이 생성되며, 이는 곧 UNIX 소켓 쌍이 생성되고 한쪽 끝은 앱으로 전송되며 다른 쪽 끝은 system_server가 이벤트를 보내는 데 사용됨을 의미합니다.

이러한 소켓을 통해 전송되는 구조체의 레이아웃은 잘 정의되어 있으며(32비트와 64비트 프로세스 간에 호환되어야 하므로) 예상치 못한 시퀀스 번호 때문에 문제가 발생하지 않는 것으로 보입니다. 또한 InputChannel은 startActivity() 호출 이후 system_server 내부에 할당되는 유일한 소켓인 것처럼 보이므로, 어떤 FD가 InputChannel의 서버 측 소켓인지 쉽게 확인할 수 있습니다.

"휴대전화 정보" 설정 화면에 "기기 이름" 대화상자가 표시되어 있고 "key injection demo"가 입력된 스크린샷

이것은 장난감 수준의 예제이지만, 권한 프롬프트나 애플리케이션 설치를 승인하거나 Media Projection 또는 Accessibility Service를 활성화할 수도 있습니다.

부팅 중 zygote 연결 가져오기

이것은 대부분 이론적입니다. 저는 느리게 실행되는 에뮬레이터에서 이 공격을 수행할 수 있었지만, 실제 기기에서는 경합 창(race window)이 너무 작았습니다.

system_server는 시작 시 /dev/socket/zygote에 대한 연결을 한 번만 연 다음, 이후 모든 요청은 해당 연결을 사용하여 전송됩니다.

system_server 부팅 중에 MediaSessionService(system_server와 Parcelable을 주고받는 데 사용됨)는 zygote에 대한 연결이 설정되기 전에 servicemanager에 게시됩니다.

따라서 이론적으로 앱이 보조 프로세스를 시작하고, system_server가 충돌하도록 만든 다음, 그 백그라운드 프로세스에서 system_server 시작 중에 공격을 수행하는 것이 가능합니다.

SensorService를 통한 zygote 연결 가져오기

그래서 제가 찾은 또 다른 버그가 있습니다. 여기 SensorService::createSensorDirectConnection() 메서드```cpp sp SensorService::createSensorDirectConnection( const String16& opPackageName, int deviceId, uint32_t size, int32_t type, int32_t format, const native_handle *resource) { // SNIP: Reject direct connections when sensor privacy is enabled // SNIP: Irrelevant parameter checks

root@kitploit:~
// check specific to memory type
switch(type) {
    case SENSOR_DIRECT_MEM_TYPE_ASHMEM: { // channel backed by ashmem
        if (resource->numFds < 1) {
            ALOGE("Ashmem direct channel requires a memory region to be supplied");
            android_errorWriteLog(0x534e4554, "70986337");  // SafetyNet
            return nullptr;
        }
        // SNIP: Further validation for SENSOR_DIRECT_MEM_TYPE_ASHMEM
    }
    case SENSOR_DIRECT_MEM_TYPE_GRALLOC:
        // no specific checks for gralloc
        break;
    default:
        ALOGE("Unknown direct connection memory type %d", type);
        return nullptr;
}

native_handle_t *clone = native_handle_clone(resource);
if (!clone) {
    return nullptr;
}
native_handle_set_fdsan_tag(clone);

sp<SensorDirectConnection> conn;
int channelHandle = 0;
if (deviceId == RuntimeSensor::DEFAULT_DEVICE_ID) {
    // SNIP: usual case where sensor belong to this device (not app streaming)
} else {
    auto runtimeSensorCallback = mRuntimeSensorCallbacks.find(deviceId);
    if (runtimeSensorCallback == mRuntimeSensorCallbacks.end()) {
        ALOGE("Runtime sensor callback for deviceId %d not found", deviceId);
    } else {
        int fd = dup(clone->data[0]);
        channelHandle = runtimeSensorCallback->second->onDirectChannelCreated(fd);
    }
}
// SNIP: Return connection

}

root@kitploit:~
`dup(clone->data[0])` 호출이 있습니다. `clone`은 원격 프로세스에서 받은 `native_handle_t`입니다. 네이티브 핸들은 데이터 안에 일정 개수의 FD와 일정 개수의 일반 정수를 포함하며, 네이티브 핸들 사용자는 `data`에 접근하기 전에 [ `numFds`와 `numInts` 확인](https://cs.android.com/android/platform/superproject/main/+/main:system/core/libcutils/include/cutils/native_handle.h;l=37-38;drc=efb735f4d5a2f04550e33e8aa9485f906018fe4e)을 통해 그 개수를 확인해야 합니다.

여기에는 "메모리 유형별 검사" 섹션도 있는데, `SENSOR_DIRECT_MEM_TYPE_ASHMEM`의 경우 여기서 검사되고, `SENSOR_DIRECT_MEM_TYPE_GRALLOC`의 경우 네이티브 핸들 형식이 기기별로 다르므로 여기서 검증할 수 없습니다. 문제는 "런타임 센서"의 경우 유형이 항상 `SENSOR_DIRECT_MEM_TYPE_ASHMEM`으로 처리되지만, 이 경우 `SENSOR_DIRECT_MEM_TYPE_GRALLOC`을 지정하면 검증을 우회할 수 있다는 점입니다.

그러나 이 코드는 "런타임 센서"가 존재할 때만 도달할 수 있습니다. 실제로 어떤 경우에 그런 일이 발생하는지 확실하지 않으며, 사용자가 "Nearby app streaming(주변 앱 스트리밍)"을 사용할 때인 것 같습니다(?).

테스트를 위해 [`VirtualDevice`](https://developer.android.com/reference/android/companion/virtual/package-summary) 등록을 허용하는 작은 클래스를 추가하고 있습니다. 다음과 같이 사용할 수 있습니다.```sh
adb shell 'CLASSPATH=$(pm path com.example.thisseemswrong | cut -d: -f2) app_process / com.example.thisseemswrong.VirtualDeviceReg'

그 후, 프로덕션 디바이스에서 system uid로 코드를 실행할 수 있습니다.

파일 디스크립터 목록이 포함된 긴 텍스트를 표시하는 앱으로, 끝 부분에는 "device=2 fd=181", "Found zygote, sending request", "uid=1000(system) gid=1000(system), groups=1000(system),1065(reserved_disk),3009(readproc) context=u:r:system_app:s0"가 있습니다.

도구 다운로드
  • PackageParser$Activity (9)
    • PooledStringWriter
  • 가장 바깥쪽 ReceiverInfo는 데이터 중간에 해당하는 길이가 정의되어 있지만, 읽는 동안에는 아직 알 수 없으므로 Intent가 정상적으로 읽힙니다.
  • 나중에 읽어야 할 것들은 정적 ComponentName.readFromParcel()이 수행하는 readString 호출을 통해 읽힙니다(ComponentName을 사용하는 이유는 Android 버전과 관계없이 UTF-16 readString을 사용하기 때문입니다. 해당 문자열이 Binder 객체와 겹치기 때문에 readString은 여전히 null을 반환합니다. 따라서 ComponentName의 생성은 이 패스에서 숨겨진 데이터를 건너뛰지만, ComponentName 객체는 생성되지 않습니다).
  • 두 번째 중첩 Bundle에 들어가며, 이 Bundle의 역직렬화가 끝나면 BadParcelableException이 포착됩니다.
  • 그런 다음 두 번째 ReceiverInfo에 들어갑니다. 이 ReceiverInfo는 길이가 Integer.MAX_VALUE로 지정되어 있으므로 finally 블록에서 BadParcelableException을 발생시키고, ClassCastException을 조용히 버립니다.
  • 세 번째 Bundle입니다. 이것은 ReceiverInfo에서 임의의 readParcelable에 도달하기 위한 것일 뿐입니다. 우리는 RemoteViews 내부에 있으므로 Bundle이 즉시 읽히기 때문에 이제 그렇게 할 수 있습니다.
  • PackageParser$Activity + PooledStringWriter 콤보는 읽고 있는 Parcel에 대한 writeInt(0) 호출을 트리거합니다. 우리가 Parcel의 끝에 있었으므로 writeInt()는 Parcel 용량을 확장해야 하며, 이로 인해 "take possession" 경로가 트리거됩니다. 이 콤보는 또한 ClassCastException을 유발하며, 이 예외는 위의 7단계와 6단계에서 설명한 대로 삼켜집니다.
  • 바깥쪽 ReceiverInfo의 끝에 도달합니다. ReceiverInfo는 헤더의 길이에 따라 위치를 탐색하며, 이전에 ComponentName 안에 있던 데이터 내부로 이동합니다. 해당 데이터는 Parcelable[]의 다음 항목으로 읽힙니다.
  • 여기에는 내 프로세스로 차단(blocking) Binder 트랜잭션을 수행하는 ParceledListSlice가 있습니다. 이 시점에서 이 Parcel에 정의된 File Descriptors는 닫혔지만, 아직 그 자리를 차지한 흥미로운 것은 없습니다. 이 역직렬화가 해당 호출의 반환을 기다리는 동안, 나는 시스템이 흥미로운 File Descriptors를 열도록 만들 수 있으며, 그런 다음 그것들이 나에게 전송됩니다.
  • 이 부분은 File Descriptors가 이 Parcel에서 추출되어 나에게 전송되는 곳입니다. ParcelableListBinder가 항목을 필터링하는지 여부에 따라 다릅니다. a. ParcelableListBinder가 항목을 필터링하지 않는 경우, FD는 ParcelableParcel 안에 저장됩니다. ParcelableParcel은 Bundle과 유사하게 Parcel.appendFrom()을 사용하여 Parcel 데이터를 그대로 복사하지만, 특별한 hasReadWriteHelper() 논리가 없으므로 RemoteViews의 Bundle 아래에 있음에도 불구하고 그렇게 수행합니다. b. ParcelableListBinder가 항목을 필터링하는 경우, 이것은 RemoteViews의 끝입니다. RemoteViews 객체는 ParcelableListBinder에 의해 폐기되지만, 부수 효과는 이미 발생했으므로 나에게는 괜찮습니다. ParcelableListBinder가 수신하는 다음 항목은 QueueItem이며, MediaDescription을 포함하고, 이는 다시 Bundle을 포함합니다. 바로 이 Bundle에 유출된 FD가 보관됩니다.