Skip to content
KitploitKITPLOIT
ИнструментыБлог
Отправить
ИнструментыБлог
Отправить

Инструменты для хакинга, пентеста и кибербезопасности — ваш арсенал защиты!

Kitploit — это каталог инструментов для хакинга, кибербезопасности и пентестинга. Находите последние обновления проектов для поиска уязвимостей, анализа систем, автоматизации тестирования и усиления вашей безопасности.

··Ленты·Контакты·Конфиденциальность·© 2026 Kitploit

Каталог инструментов

Категории

Все категории
Loading categories
ThisSeemsWrong — Разбор и эксплойт для CVE-2024-49746: Parcel::continueWrite в Android закрывает файловые дескрипторы, используемые позже. | Kitploit
Инструменты/GitHubGitHub/michalbednarski/thisseemswrong
Безопасность AndroidФреймворки для эксплойтовАнализ уязвимостейСбор информацииРазработка Полезной НагрузкиЭксплуатация Бинарных Файлов
GitHubmichalbednarski/thisseemswrong

ThisSeemsWrong

Разбор и эксплойт для CVE-2024-49746: Parcel::continueWrite в Android закрывает файловые дескрипторы, используемые позже.

Репозиторий
4715211 месяцев назадПроверено Kitploit

Популярное

Смотреть все →

Откройте для себя самые используемые инструменты нашего сообщества.

Изучить все инструменты

Просмотрите нашу коллекцию инструментов

Смотреть все инструменты →
Поделиться

Исправление этой проблемы вышло как 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

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()` был перенесён из `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)), либо входящим ответом (то есть объектом `Parcel`, который был передан в вызов [`transact()`](https://developer.android.com/reference/android/os/IBinder#transact(int,%20android.os.Parcel,%20android.os.Parcel,%20int)) как аргумент `reply`; `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)`, который выполняет ранний возврат. Однако при легитимном использовании системы нет сценария, при котором мы бы попали в ветку "If there is a different owner, we need to take possession".

# Запуск пути «take possession»

В одном из моих предыдущих эксплойтов я показал [случай, когда `createFromParcel()` может вызвать `writeInt(0)` для `Parcel`, из которого должен читать](https://github.com/michalbednarski/TheLastBundleMismatch#side-effects). Хотя исправление там предотвратило выполнение любых методов `createFromParcel()`, не относящихся к `Intent`, внутри `AccountManagerService`, путь от `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, любому доступному в системе public конструктору, принимающему единственный аргумент Parcel

И в другом месте у нас есть следующий код:```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», нам нужен произвольный вызов `readParcelable` для `Parcel`, который был передан в `onTransact()` в качестве `data`; в этом эксплойте я использую для этого [тот же путь, что использовал ранее в другом](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()` попытался бы перезаписать данные на месте, что в случае данных, поддерживаемых `mmap` из `/dev/binder`, привело бы к `SIGSEGV`.

# File Descriptor Sanitizer

Моя первоначальная идея заключалась в том, чтобы путь «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 было расширено на FD внутри `Parcel`](https://android.googlesource.com/platform/frameworks/native/+/7772039cc5084247450f6113d9a18eca17f672aa%5E!/).

На самом деле, после того как FDSan начал покрывать Parcel, я даже не мог добраться до вызова `closeFileDescriptors()`, когда `Parcel` содержал FD. Перед этим вызовом выполняется вызов `acquireObjects();`, который получает ссылки на хэндлы `Binder` (которые освобождаются вызовом `mOwner()` позже внутри этой функции); однако `acquireObjects()` также [устанавливает теги FDSan для FD](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()`. Поскольку путь «вступления во владение» при обычном использовании является мёртвым кодом, такие проблемы могут остаться незамеченными.

Однако мы видим условие `if (obj.cookie != 0)`. Если оно ложно, это означает, что FD, присутствующий внутри `Parcel`, на самом деле не принадлежит этому `Parcel`, и именно пользователь `Parcel` обязан держать их открытыми, пока существует этот `Parcel`. Но поскольку этот `Parcel` только что пришёл из ядра, значения `cookie` на самом деле приходят из исходного процесса и считаются нерелевантными, когда `Parcel` действительно имеет `mOwner`. Однако путь «вступления во владение» на самом деле этого не учитывает и просто копирует значения `cookie`.

Сложив всё это вместе, установив значения `cookie` в ноль на стороне отправителя, мы можем получить `Parcel`, который ссылается на уже закрытые файловые дескрипторы, но при этом не считает себя их владельцем, а значит, не будет закрывать их снова. Это позволяет нам избежать срабатывания FDSan, однако также устраняет любые пути эксплуатации через двойное закрытие.

# Приёмы на стороне Java в Parcel

Такие FD всё ещё могут быть переданы в другой `Parcel` (а затем и в другой процесс), однако наш способ инициировать создание таких висячих FD включает создание `PooledStringWriter` через рефлексию, после чего выбрасывается `ClassCastException`, когда мы пытаемся `add()` его в `ArrayList<IntentInfo>`.

Нам нужно:

* Оказаться в конце `Parcel`, который был передан как аргумент `data` в `onTransact()`
* Выполнить создание `PooledStringWriter`, после чего Parcel будет содержать висячие FD, но также будет выброшено `ClassCastException`
* Дождаться, пока интересующие нас для утечки FD будут выделены внутри `system_server`
* Добиться, чтобы FD из этого `Parcel` были скопированы в какой-то другой `Parcel`, который будет отправлен в наш процесс

Чтобы выполнить все эти требования, мне понадобится использовать как старые, так и новые трюки.

## Старые трюки

Начнём с повторного рассмотрения старых трюков, большинство из них уже описано в моём [`LazyValue`-using-`Parcel`-after-`recycle()` эксплойте](https://github.com/michalbednarski/LeakValue)

1. [Класс `RemoteViews` выполняет десериализацию содержащегося в нём `Bundle` с установленным `Parcel.ReadWriteHelper`](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/widget/RemoteViews.java;l=2287-2300;drc=9b2e54f25456f2726ab1a15e6b6dc19395a3b5b4). [Когда установлен `ReadWriteHelper`, `Bundle`-ы десериализуются немедленно, а не лениво](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/os/BaseBundle.java;l=1886-1896;drc=e1841f84f41213879e1f1b45ad4300b96970e545). Также стоит отметить, что все `Bundle`-ы, содержащиеся внутри этого `Bundle` в `RemoteViews`, тоже будут десериализованы немедленно, а не только тот, что находится непосредственно внутри `RemoteViews`
2. [Если при десериализации `Bundle` внутри `system_server` выбрасывается `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` во время десериализации выполняет блокирующий исходящий 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`.

Эти структуры имеют префикс длины, поэтому разные версии одной и той же структуры совместимы в пределах системы, если только в середину не добавляются новые поля (то есть версии совместимы, если одна версия является префиксом другой).

Давайте посмотрим, какой код 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. Однако здесь у нас есть блок finally, который перед вызовом setDataPosition() проверяет отсутствие переполнения. Если переполнение есть, будет выброшен BadParcelableException. Что произойдёт, если блок finally выбросит Exception, когда другое исключение уже ожидает обработки? Исключение, выброшенное внутри finally, имеет приоритет, предыдущее Exception молча исчезает. Теперь вместо ClassCastException у нас BadParcelableException, который Bundle услужливо игнорирует, чтобы избежать Exception-ов внутри .

Примечание о патче ParcelableListBinder

Мы используем ParcelableListBinder из MediaSession, чтобы поместить произвольный Parcelable внутрь system_server и позже получить этот объект обратно.

Недавно появился патч для ParcelableListBinder, который запрещает именно это.

С точки зрения этого эксплойта, этот патч на самом деле меня не останавливает, но мне нужно по-разному обрабатывать его наличие и отсутствие.

Если этот патч присутствует, элементы, не являющиеся QueueItem, молча удаляются из списка, получаемого system_server (не вызывая Exception-ов). Поскольку для побочных эффектов нам нужен не-QueueItem, мы можем поместить не-QueueItem первым элементом, а затем настоящий QueueItem. Это не позволяет выполнить произвольную десериализацию Parcelable, однако содержит Bundle, в котором могут быть File Descriptors (которые мы захотим передать в наш процесс).

Если этот патч отсутствует, у нас нет этого препятствия, но мы также не можем использовать тот же поток. Поскольку в вышеуказанном случае у нас был бы список и с RemoteViews, и с QueueItem, то ParceledListSlice отказался бы передавать список смешанного типа. В этом случае мне нужно поместить наши утёкшие FD внутри экземпляра RemoteViews.

Но ещё интереснее причина, по которой этот патч был внедрён. В сообщении коммита упоминается "разрешение приложениям запускаться из фона", и хотя Android Security Bulletin мало что сообщает, мы можем найти полезную информацию в записи 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)

Аннотации "сегмент A" и "B" в приведённом выше списке относятся к блокам между комментариями "START A"/"END A"/"START B"/"END B" в моём классе FdLeaker.java, номера относятся к пунктам в списке ниже.

Приведённое выше дерево описывает иерархию с точки зрения отправителя, но с точки зрения получателя оно выглядит немного иначе:

  1. Мы получаем данные из ParcelableListBinder, самый внешний объект — RemoteViews.
  2. Внутри этого RemoteViews есть вложенный Bundle. RemoteViews установит Parcel.ReadWriteHelper, поэтому этот Bundle и все Bundle-ы внутри него будут читаться жадно. Это необходимо, потому что иначе мы не смогли бы выполнить произвольный readParcelable из ReceiverInfo.readFromParcel().
  3. Parcelable[] здесь — просто удобная обёртка для группировки всех Parcelable-ов, которые я помещаю внутрь RemoteViews.

Итак, какие File Descriptors мы можем получить?

Давайте рассмотрим описанную выше атаку с высокого уровня:

  1. File Descriptor открывается внутри system_server, ссылка на него запоминается, и FD закрывается.
  2. Я заставляю system_server открыть другой File Descriptor.
  3. Висячий File Descriptor, созданный мной на шаге 1, отправляется мне обратно.

У этой атаки есть существенное ограничение: мы не можем захватить File Descriptors, которые были открыты до начала нашей атаки.

Тем не менее, есть ещё несколько полезных вещей, которые мы могли бы сделать.

Захват InputChannel

Честно говоря, это единственный вариант эксплойта, который мне удалось заставить работать без дополнительных допущений.

Входные события, то есть события от сенсорного экрана и клавиатуры, принимаются приложением через UNIX-сокет от system_server. Когда Activity запускается или добавляет новое окно в систему, создаётся новый InputChannel, что, в свою очередь, означает, что создаётся пара UNIX-сокетов, и один из концов отправляется приложению, а другой используется system_server для отправки событий.

Расположение структур, отправляемых через эти сокеты, хорошо определено (поскольку они должны быть совместимы между 32-битными и 64-битными процессами), и похоже, что неожиданные порядковые номера никого не смущают. Кроме того, InputChannel — похоже, единственный сокет, выделяемый внутри system_server после вызова startActivity(), так что легко определить, какой FD является серверной стороной сокета InputChannel.

Скриншот экрана настроек "О телефоне" с диалогом "Имя устройства", в который введено "key injection demo"

Хотя это игрушечный пример, мы также могли бы одобрять запросы разрешений или установку приложений, включать Media Projection или Accessibility Service.

Захват соединения с zygote во время загрузки

Этот вариант в основном теоретический: мне удалось выполнить эту атаку на медленно работающем эмуляторе, однако на реальном устройстве окно гонки было слишком маленьким.

system_server открывает соединение с /dev/socket/zygote только один раз при запуске, после чего все запросы отправляются через это соединение.

Во время загрузки system_server MediaSessionService (который используется для отправки и получения Parcelable-ов в/из system_server) публикуется в servicemanager до того, как будет установлено соединение с zygote.

Поэтому теоретически приложение может запустить вторичный процесс, вызвать крах system_server и затем из этого фонового процесса выполнить атаку во время запуска system_server.

Захват соединения с zygote через SensorService

Итак, есть ещё один баг, который я нашёл: вот метод 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:~
We've got `dup(clone->data[0])` call. `clone` is a `native_handle_t` received from remote process. The native handle contains certain number of FDs and certain number of plain integers within data, amounts of which user of native handle should check by [looking into `numFds` and `numInts`](https://cs.android.com/android/platform/superproject/main/+/main:system/core/libcutils/include/cutils/native_handle.h;l=37-38;drc=efb735f4d5a2f04550e33e8aa9485f906018fe4e) before accessing `data`

Here we even have "check specific to memory type" section which checks that, for `SENSOR_DIRECT_MEM_TYPE_ASHMEM` this is checked here, for `SENSOR_DIRECT_MEM_TYPE_GRALLOC` format of native handle is device specific and cannot be validated here. The thing is that for "runtime sensors" type is always treated as `SENSOR_DIRECT_MEM_TYPE_ASHMEM`, but we can specify `SENSOR_DIRECT_MEM_TYPE_GRALLOC` to bypass validation in that case

This code is however only reachable if "runtime sensors" are present. I'm not sure in what case that really happens, I think it is when user uses "Nearby app streaming" (?)

For testing though, I'm adding small class that allows registration of [`VirtualDevice`](https://developer.android.com/reference/android/companion/virtual/package-summary), you can use it through```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"

Скачать инструмент
system_server
  • PackageParser$Activity (9)
    • PooledStringWriter
  • Внешний ReceiverInfo имеет определённую длину, которая приходится на середину его данных, однако во время чтения это ещё неизвестно, и Intent читается из него обычным образом.
  • То, что нам понадобится прочитать позже, читается через вызов readString, выполняемый статическим ComponentName.readFromParcel() (используется ComponentName, потому что он использует UTF-16 readString независимо от версии Android; этот readString всё равно вернёт null, потому что эта строка перекрывается с объектами Binder, так что хотя конструкция ComponentName перескочила через данные, скрытые от этого прохода, объект ComponentName не создаётся).
  • Мы входим во второй вложенный Bundle; в конце десериализации этого Bundle будет перехвачен BadParcelableException.
  • Затем мы входим во второй ReceiverInfo. У этого ReceiverInfo длина задана как Integer.MAX_VALUE, и поэтому в блоке finally будет выброшен BadParcelableException, который молча отбрасывает ClassCastException.
  • Третий Bundle. Он нужен лишь для того, чтобы иметь возможность добраться до произвольного readParcelable из ReceiverInfo. Теперь мы можем это сделать, поскольку находимся внутри RemoteViews, поэтому Bundle читается жадно.
  • Комбинация PackageParser$Activity + PooledStringWriter вызывает writeInt(0) на Parcel, из которого идёт чтение. Поскольку мы находились в конце Parcel, writeInt() должен расширить ёмкость Parcel, что запускает путь "take possession". Эта комбинация также приводит к ClassCastException, который поглощается, как описано в пунктах 7 и 6 выше.
  • Мы достигаем конца внешнего ReceiverInfo; ReceiverInfo перемещается на позицию согласно длине в своём заголовке и оказывается внутри данных, которые ранее находились внутри ComponentName и читаются как следующие элементы в Parcelable[].
  • Здесь находится ParceledListSlice, который выполняет блокирующую Binder-транзакцию в мой процесс. На этот момент File Descriptors, определённые в этом Parcel, были закрыты, но на их месте ещё ничего интересного не появилось. Пока эта десериализация ждёт возврата из этого вызова, я могу заставить систему открыть несколько интересных File Descriptors, которые затем будут отправлены мне.
  • Это часть, где File Descriptors берутся из этого Parcel для отправки мне. Разница между случаями зависит от того, фильтрует ли ParcelableListBinder элементы или нет. a. Если ParcelableListBinder не фильтрует элементы, FD сохраняются внутри ParcelableParcel, который, аналогично Bundle, копирует данные Parcel дословно через Parcel.appendFrom(), но не имеет специальной логики hasReadWriteHelper(), поэтому делает это, несмотря на то, что находится внутри Bundle из RemoteViews. b. Если ParcelableListBinder фильтрует элементы, это конец RemoteViews; объект RemoteViews отбрасывается ParcelableListBinder, но для меня это нормально, так как побочные эффекты уже произошли. Следующий элемент, полученный ParcelableListBinder, — это QueueItem, который содержит MediaDescription, который, в свою очередь, содержит Bundle, где и хранятся мои утёкшие FD.