
Эксплойты ядра iOS всегда вызывали у меня огромный интерес. За последние годы эксплуатация ядра стала значительно сложнее, и традиционные уязвимости (например, связанные с повреждением виртуальной памяти) стали встречаться реже.
Тем не менее, летом 2023 года felix-pb выпустил три эксплойта под названием kfd. Это были первые опубликованные эксплойты ядра, работавшие на iOS 15.6 и выше.
Разрабатывая джейлбрейк для iOS 14 (Apex), я реализовал собственный эксплойт для уязвимости Physpuppet. В этой статье объясню, как эксплуатировать уязвимость типа physical use-after-free на современных версиях iOS.
Я ни в коем случае не утверждаю, что эксплуатация ядра — это легко, но уязвимости типа physical use-after-free оказались крайне мощными, практически не затронутыми последними мерами защиты XNU. Методика эксплуатации таких багов проста для понимания и реализации.
Эта работа не была бы возможна без помощи @staturnz, который также писал эксплойт для PhysPuppet для iOS 12 и 13. Исходный код эксплойта доступен по ссылке.
Управление памятью в XNU
Ядро XNU, лежащее в основе macOS, iOS, watchOS и других ОС Apple, управляет памятью так же, как и большинство других ОС. XNU работает с двумя типами памяти: физической и виртуальной.
Каждый процесс (включая само ядро) имеет карту виртуальной памяти. MachO-файл (исполняемый файл Darwin) определяет базовый адрес для каждого сегмента бинаря. Например, если MachO указывает базовый адрес 0x1000050000, то выделенная процессу память будет интерпретироваться процессом как начинающаяся с 0x1000050000. На практике физическая память так не работает: если два процесса запросят один и тот же базовый адрес или области их виртуальной памяти пересекутся, это приведет к ошибкам.
Физическая память начинается в области адреса 0x800000000. Виртуальная память выглядит как непрерывная для процесса, то есть каждый её участок последовательно отображается в памяти. Обычно память делится на страницы одинакового размера; для iOS это обычно 16 КБ (или 4 КБ на старых устройствах, например, с процессором A8). Для простоты будем считать размер страницы равным 16 КБ (0x4000 байт).
Пример работы виртуальной памяти:
Страница 1 @ 0x1000050000;
Страница 2 @ 0x1000054000;
Страница 3 @ 0x1000058000.
Можно скопировать 0xC000 байт с помощью memcpy()
, охватив все три страницы, и не заметить разницы. На самом деле эти страницы могут находиться по совершенно разным физическим адресам, например:
Страница 1 @ 0x800004000;
Страница 2 @ 0x80018C000;
Страница 3 @ 0x8000C4000.
Благодаря виртуальной памяти процессы могут работать с непрерывными областями памяти и заранее определенными базовыми адресами. При разыменовании указателя на виртуальный адрес этот адрес преобразуется в физический, из которого затем производится чтение или запись.
Таблицы страниц
Таблицы страниц (page tables, translation tables) хранят информацию о страницах памяти, доступных процессу. У обычного процесса в пользовательском пространстве (userland) на iOS виртуальное адресное пространство занимает диапазон от 0x0 до 0x8000000000.
Преобразованием между физическим и виртуальным адресным пространством занимается блок управления памятью (MMU). Именно через него проходят все исключения при обращении к памяти (memory faults).
Когда вы, например, пытаетесь разыменовать указатель 0x1000000000, MMU должен определить, какая физическая страница соответствует этому адресу. И вот здесь вступают в игру таблицы страниц.
Таблицы страниц — это список 64-битных адресов. В современных устройствах iOS есть три уровня таких таблиц:
Уровень 1: покрывает 0x1000000000 байт виртуальной памяти.
Уровень 2: покрывает 0x2000000 байт виртуальной памяти.
Уровень 3: покрывает 0x4000 байт памяти (одна страница).
Каждая запись в таблице может быть либо блочным отображением (block mapping), либо указателем на дочернюю таблицу следующего уровня.

Допустим, вы записали в первую запись таблицы второго уровня физический адрес 0x800004000 с флагом block
. Тогда виртуальные адреса 0x1000000000–0x1002000000 будут отображаться на физические адреса 0x800004000–0x802004000.
Если же вместо block
в записи будет стоять флаг table
, то каждая страница внутри диапазона 0x1000000000–0x1002000000 будет назначаться индивидуально через таблицу третьего уровня, адрес которой хранится в той самой записи второго уровня.
Physical use-after-free
Если вы всё еще читаете и не заскучали на объяснении таблиц страниц — значит, дальше будет проще. Принцип работы этих таблиц — ключ к пониманию сути уязвимости physical use-after-free.
Сама уязвимость работает следующим образом:
Процесс в пользовательском пространстве (userland) выделяет виртуальную память с правами на чтение и запись.
Таблицы страниц обновляются: в них добавляется отображение соответствующего физического адреса, с разрешениями на чтение и запись для процесса.
Процесс освобождает ранее выделенную память из userland.
Из-за бага типа physical use-after-free ядро не удаляет отображение из таблиц страниц, а лишь «удаляет» его на уровне виртуальной памяти (VM), которая отвечает за учет выделенной процессом памяти.
В результате VM-слой ядра считает, что соответствующие физические страницы свободны, и добавляет их в глобальный список свободных страниц.
Таким образом, процесс сохраняет возможность читать и записывать данные в страницы, которые могут быть повторно выделены ядром в качестве памяти ядра. Причем ядро об этом не знает!
Что это дает атакующему? Если ядро решит повторно выделить N из этих освобожденных страниц под свои нужды, мы получаем возможность читать и писать в N страниц случайной памяти ядра прямо из пользовательского пространства. Это мощный примитив: если важный объект ядра окажется на одной из этих страниц, мы сможем изменить его содержимое по своему усмотрению.
Стратегия эксплуатации
Не вдаваясь в детали каждой конкретной уязвимости (прочитать о них можно по этой ссылке), предположим, что каждое срабатывание (trigger) вызывает physical use-after-free на неизвестном количестве страниц памяти ядра.
Встает несколько проблем:
Мы не знаем, сколько страниц будет повторно выделено ядром.
Мы не знаем, какие именно страницы будут выделены.
Мы не знаем, на какие виртуальные адреса ядра они будут отображены.
У нас есть случайное количество страниц памяти по случайным адресам, часть которых может быть использована ядром под свои нужды. Лучшее, что можно сделать в этой ситуации — воспользоваться техникой heap spray.
Heap spray
Единственный надёжный способ превратить исходный примитив в более мощный — «заспамить» (spray) память ядра большим количеством одинаковых объектов в надежде, что хотя бы один из них попадет на контролируемую нами страницу.
Для kfd эта техника впервые была адаптирована исследователем opa334, а изначально она использовалась в эксплойте weightBufs через IOSurface. Весь процесс heap spray выглядит так:
Выделяем большое количество объектов IOSurface (они размещаются в памяти ядра).
При создании каждого объекта записываем в одно из его полей «магическое» значение, чтобы потом можно было его распознать.
Сканируем освободившиеся страницы в поисках этого магического значения.
Если объект IOSurface найден на контролируемой странице, мы достигли цели!
Пример кода (упрощенно):
void spray_iosurface(io_connect_t client, int nSurfaces, io_connect_t **clients, int *nClients) {
if (*nClients >= 0x4000) return;
for (int i = 0; i < nSurfaces; i++) {
fast_create_args_t args;
lock_result_t result;
size_t size = IOSurfaceLockResultSize;
args.address = 0;
args.alloc_size = *nClients + 1;
args.pixel_format = IOSURFACE_MAGIC;
IOConnectCallMethod(client, 6, 0, 0, &args, 0x20, 0, 0, &result, &size);
io_connect_t id = result.surface_id;
(*clients)[*nClients] = id;
*nClients = (*nClients) += 1;
}
}
Мы создаем nSurfaces
объектов IOSurface с магическим значением, затем повторяем процедуру, пока не найдем объект на контролируемой странице. После этого сохраняем адрес и ID объекта для дальнейшего использования и читаем поле receiver
объекта IOSurface для получения адреса структуры task нашего процесса.
Чтение и запись памяти ядра
Теперь у нас есть объект IOSurface в памяти ядра, к которому мы можем обращаться из пользовательского пространства — поскольку физическая страница, где он расположен, также отображена в наш процесс. Как это использовать, чтобы получить примитив чтения/записи памяти ядра?
У объекта IOSurface есть два полезных поля:
указатель на 32-битный счетчик использования объекта (use count);
указатель на 64-битное значение индексированной метки времени (indexed timestamp).
Если вызвать методы для чтения use count и записи indexed timestamp, но предварительно перезаписать указатели на эти поля, можно получить произвольное чтение 32-битного значения и произвольную запись 64-битного значения в памяти ядра.
Чтение: перезаписываем указатель на use count (учитывая смещение 0x14 байт), затем вызываем метод чтения.
uint32_t get_use_count(io_connect_t client, uint32_t surfaceID) {
uint64_t args[1] = {surfaceID};
uint32_t size = 1;
uint64_t out = 0;
IOConnectCallMethod(client, 16, args, 1, 0, 0, &out, &size, 0, 0);
return (uint32_t)out;
}
uint32_t iosurface_kread32(uint64_t addr) {
uint64_t orig = iosurface_get_use_count_pointer(info.object);
iosurface_set_use_count_pointer(info.object, addr - 0x14); // Read is offset by 0x14
uint32_t value = get_use_count(info.client, info.surface);
iosurface_set_use_count_pointer(info.object, orig);
return value;
}
Запись: перезаписываем указатель на indexed timestamp, затем вызываем метод записи.
void set_indexed_timestamp(io_connect_t client, uint32_t surfaceID, uint64_t value) {
uint64_t args[3] = {surfaceID, 0, value};
IOConnectCallMethod(client, 33, args, 3, 0, 0, 0, 0, 0, 0);
}
void iosurface_kwrite64(uint64_t addr, uint64_t value) {
uint64_t orig = iosurface_get_indexed_timestamp_pointer(info.object);
iosurface_set_indexed_timestamp_pointer(info.object, addr);
set_indexed_timestamp(info.client, info.surface, value);
iosurface_set_indexed_timestamp_pointer(info.object, orig);
}
Теперь у нас есть относительно надежные примитивы чтения и записи памяти ядра. 32-битное чтение можно расширить до любого размера, читая несколько раз или приводя к меньшему типу, аналогично и для 64-битной записи.
Следующий шаг для джейлбрейка — разработать еще более стабильные примитивы чтения и записи, модифицируя таблицы страниц процесса. Это относительно просто для arm64-устройств, но для arm64e (начиная с A12) таблицы страниц защищены механизмом PPL (Page Protection Layer), так что для их записи потребуется обход PPL.
Подытожим, как выглядит цепочка эксплуатации:
Вызвать physical use-after-free для получения произвольного количества освобожденных страниц.
Выделить большое количество объектов IOSurface с магическим значением в памяти ядра.
Дождаться, когда один из объектов попадет на контролируемую страницу.
Использовать баг для изменения указателей в объекте IOSurface, чтобы методы IOSurface позволяли выполнять произвольное чтение и запись через эти указатели.
Заключение
Надеюсь, мне удалось продемонстрировать, что уязвимости типа physical use-after-free могут быть относительно простыми для эксплуатации даже на новых версиях iOS. Техника через IOSurface работает вплоть до iOS 16, где некоторые поля, используемые для чтения/записи, были защищены PAC на arm64e-устройствах, а также появились другие изменения, нарушающие примитив чтения на arm64.
Бонус: arm64e, PPL и SPTM
В iOS 17 на устройствах с A15 и новее появились Secure Page Table Monitor (SPTM) и Trusted Execution Monitor (TXM). До этого управление таблицами страниц осуществлялось через Page Protection Layer (PPL), работающем на более высоком уровне привилегий, чем ядро. PPL гарантировал, что ни пользовательское пространство, ни само ядро не могут получить доступ к странице до передачи её PPL. В iOS 17 PPL был разделен на два монитора, работающих в так называемых «guarded exception levels». SPTM — первый код, запускающийся после iBoot, и именно он отображает ядро в память.
В эксплойте kfd есть функция puaf_helper_give_ppl_pages
, которая заставляет ядро заполнить список свободных страниц PPL. Если не вызвать её перед основным эксплойтом, PPL может попытаться занять одну из освобожденных страниц, что приведет к панике ядра с ошибкой «page still has mappings».
После выхода kfd быстро выяснилось, что он поддерживает ранние бета-версии iOS 17, но не работает на устройствах с поддержкой SPTM (вот твит об этом). Многие решили, что SPTM полностью закрывает возможность эксплуатации physical use-after-free. На самом деле это не так: паника означает лишь, что трюк с puaf_helper_give_ppl_pages
не работает с аллокатором страниц SPTM, а не что сама уязвимость невозможна.
Однако в одной из более поздних версий iOS 17 SPTM действительно заблокировал эксплуатацию physical use-after-free: теперь страница, выделенная как память пользовательского процесса, никогда не может быть повторно выделена как память ядра до следующей перезагрузки. Таким образом, максимум, что можно сделать с physical use-after-free под SPTM — читать и писать память другого пользовательского процесса.
Это неудивительно, учитывая мощь подобных багов: ASLR ядра, PAN, zone_require и kalloc_type практически не влияют на них, PPL можно обойти, а PAC влияет только на те объекты, с помощью которых можно получить примитивы чтения/записи ядра.
Исходный код эксплойта доступен по ссылке. В будущем планируется публикация подробного разбора разработки джейлбрейка Apex для iOS 14, использующего этот эксплойт.
Если у вас есть вопросы или замечания, автор просит связаться по email, указанному в оригинале.