Эмулируем iPhone в QEMU
Начало пути
Мы начали наше исследование по эмуляции iOS с изучения уже существующих опенсорсных решений. Ранее мы уже успешно запускали alephsecurity/xnu-qemu-arm64, но нас беспокоило то, что проект имеет статус read-only.
Затем мы попробовали TrungNguyen1909/qemu-t8030 и обнаружили в нём довольно много интересных фич:
возможность восстановления iOS (при помощи второго QEMU-«компаньона» для подключения по USB)
запуск iOS 14
самую свежую версию QEMU
удобную wiki о запуске эмулятора
Благодаря этому проекту мы быстро получили доступ к оболочке и ssh, изменив System/Library/xpc/launchd.plist
, что стало отличной отправной точкой.
Нашей долговременной целью стала эмуляция функциональной iOS с UI и возможностью запуска хотя бы некоторых приложений.
Первое, что напрягло нас в проекте t8030 — то, что он добавил код в сам QEMU, чтобы пропатчить ядро xnu. Мы знали, что нам наверняка понадобится самим выполнять патчинг, поэтому хотели, чтобы это можно было делать чище. Так как у нас был опыт работы с реальным iPhone с джейлбрейком, мы решили попробовать использовать Pongo для применения патчей checkra1n
, поскольку это позволит нам удалить весь патчинг, выполненный в QEMU.
В сценарии джейлбрейка после взлома при помощи checkmate PongoOS инъецируется в SRAM и через USB передаётся модуль checkra1n-kpf
. Мы решили не разбираться с сырой поддержкой USB и увеличили SRAM эмулируемого телефона, а затем использовали PongoOS с модулем checkra1n KPF.
Выполнение PongoOS поначалу не обошлось без проблем: отсутствовал весь загрузочный код, обычно выполняемый bootrom или iboot, например, настройка FPU перед выполнением команд с double/float. Нам помогло чтение документации ARM (section 5.4) и гугление.
Фичи, добавленные в A13 и более поздних устройствах, неподдерживаемых Pongo, ломали сопоставление паттернов некоторых патчей. Например, команды Pointer Authentication (PAC) добавляли autda / xpacd, а Apple использовала другой slide.
Примеры с печально известным task_for_pid (tfp0)
% ipsw macho info kernelcache.release.iphone10b.decompressed
000: LC_SEGMENT_64 sz=0x006d8000 off=0x00000000-0x006d8000 addr=0xfffffff007004000-0xfffffff0076dc000 r-x/r-x __TEXT
Previous version (iphone-X (14.0_18A373_GM))
- tfp0 address 0xfffffff0076e9e70
0xfffffff0076e9e70 - 0ffffffff007004000 = 0x6e5e70
- binary
% hexdump -s 0x6e5e70 -n 8 kernelcache.release.iphone10b.decompressed
06e5e70 7f70 07ec fff0 0017
Raw: 0x0017_fff0_07ec_7f70
Ghidra: 0xffff_fff0_70ec_7f70
Later version (iphone-11 (14.0_18A5351d))
- tfp0 address 0x0xfffffff0076c9e40
0xfffffff0076c9e40 - 0ffffffff007004000 = 0x6c5e40
- binary
% hexdump -s 0x6c5e40 -n 8 kernelcache.research.iphone12b.decompressed
06c5e40 1d70 00f0 307a 8010
Raw: 0x8010_307a_00f0_1d70
Ghidra: 0xffff_fff0_07f0_5d70
Pongo позволил нам получить доступ к имеющимся патчам checkra1n для различных версий iOS, и хотя динамическое приложение было интересным, его было непросто читать и изменять. Мы хотели более декларативный подход, как и в настоящих патчах кода.
Поэтому мы создали инструменты, позволившие выполнять diff
между двумя Mach-O и генерировать текстовый файл патча с различиями в ассемблерном коде. Другая программа просто брала этот патч и применяла к двоичному файлу.
Затем мы запускались с Pongo и использовали монитор QEMU для дампа пропатченных Pongo областей памяти, а затем переассемблировали пропатченное ядро и, наконец, генерировали файл патча со всеми модификациями. Далее мы разбивали большой патч и снабжали его комментариями, благодаря чему могли анализировать и контролировать ровно то, что было пропатчено в ядре.
Здесь темно
Мы знали, что на современных iPhone все операции графического рендеринга проходят через Metal API, которому требуется реальный GPU. Мы считали, что эмуляция Apple Silicon GPU окажется слишком сложной задачей, поэтому придумали два решения:
Использовать программный рендеринг: похоже, это было возможно в старых версиях iOS (при помощи bootarg gpu=0)
Перенаправлять вызовы Metal на устройство, способное выполнять рендеринг, например, на iPhone или, возможно, на Mac с OSX
Решение с программным рендерингом казалось намного более простым, поэтому в первую очередь мы изучили его. К сожалению, опция bootarg ядра XNU исчезла в iOS 14. Изучив фреймворк QuartzCore при помощи Ghidra, мы пришли к выводу, что откат к программному рендерингу возможен, только если отсутствуют рендереры Metal.
Чтобы убедиться, что программный рендеринг в самом деле можно использовать, мы взяли реальный iPhone с джейлбрейком и пропатчили Quartzcore, чтобы применялся программный рендеринг. Мы действительно подтвердили, что это возможно! При таких модификациях UI стал намного медленнее и содержал артефакты в тех частях, которые, вероятно, требовали для рендеринга непосредственно Metal.
Проведя эти эксперименты, мы поняли, что можем реализовать программный рендеринг в QEMU для всего, что не использует напрямую Metal или OpenGL (то есть, по сути, все приложения UIKit).
Работая на двух физических iPhone, мы также исследовали альтернативную возможность применения прокси для вызовов Metal. Процесс был таким:
Спарсить все заголовки iOS при помощи LLVM.
Все указатели на объект Objetive C на сервере — это заглушка вызова в клиенте.
Сгенерировать автоматический код для обмена struct и указателями.
Перехватывать все функции и методы.
Перенаправлять каждый вызов на сервер, исполнять их и возвращать результат.
Мы смогли обеспечить обмен простыми вызовами для инициализации Metal, но потом осознали, что для того, чтобы что-то заработало, потребуется пройти ещё долгий путь. Язык Objetive C и Metal API довольно сложны и имеют множество особенностей, сильно затрудняющих это мероприятие.
В итоге мы отложили это решение на будущее и решили начать с программным рендерингом, который, несмотря на его большие ограничения, позволит нам быстрее перейти к другим задачам.
Кроме того, мы обнаружили, что фреймворки iOS раскрывают приватные API, отсутствующие в публичных заголовках. Хотя есть некоторые способы, позволяющие спарсить их и сгенерировать заголовки, чаще всего их нельзя использовать напрямую, и это ещё больше усложняло работу.
Охота за IOSurface
Попытавшись заставить работать программный рендеринг, мы решили, что нам всё равно нужно хотя бы устройство буфера кадров, а в оригинальном t8030 QEMU он реализован не был. Однако мы обнаружили форк проекта, который, очевидно, работал над поддержкой IOMFB, и решили попробовать с его помощью выполнить отладку дисплея.
И действительно, при восстановлении iOS с этой версией мы увидели логотип Apple и полосу прогресса. Однако при обычном запуске дисплей оставался совершенно чёрным. Настало время отладки!
Изучив IOMFB kext в Ghidra и реализацию буфера кадров в QEMU, мы поняли, что возможны два режима:
Сырой буфер кадров по фиксированному аппаратному адресу (как мы предполагали ранее).
Более сложный API, использующий регистры для конфигурирования различных плоскостей и применяющий DMA для записи данных поверхностей.
Сначала мы начали экспериментировать с сырым буфером кадров (позже обнаружилось, что именно с помощью него выполняет отображение Pongo). При работе с ним мы смогли отображать произвольные ARGB-поверхности, но при запуске система ничего не записывала в буфер кадров.
Мы начали изучать второй режим отображения. Включив трассировки в реализации буфера кадров в QEMU, мы увидели, что ядро настраивает графические плоскости при помощи регистров, но потом ничего не происходит.
На этом этапе нам нужно было выполнить отладку и разобраться, почему после запуска ничего не отображается, хотя буфер кадров реализован и обнаружен ядром.
Рандомизация адресов
Несмотря на наличие доступа по SSH, мы быстро упираемся в потолок возможностей наблюдения за аспектами работающей системы. Нам необходима возможность отладки компонентов ядра и пользовательского пространства при помощи GDB.
Рандомизации ядра настраивалась в инициализации платы t8030, позволявшей полностью отключить её.
В случае пользовательского пространства у нас было два случая: рандомизация для исполняемых файлов и для динамических библиотек внутри кэша dyld. В случае исполняемых файлов для её отключения достаточно было пропатчить функцию ядра _load_machfile
.
Случай с динамическими библиотеками мы обрабатываем немного иначе. Во-первых, нужно понимать, что каждая библиотека содержится внутри большого двоичного блоба, называемого кэшем dyld (расположен по адресу /System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64e).
Все библиотеки из этого кэша (называемые фреймворками) загружаются одновременно при запуске, а затем отображаются в пространство памяти процессов, несмотря на то, что dlopen вызывается с путём в файловой системе (вида /System/Library/Frameworks/QuartzCore).
Мы заметили, что рандомизация адресов происходит при запуске только один раз, после чего библиотека всегда загружается каждым исполняемым файлом по тому же адресу.
Нам оставалось написать на C какой-нибудь инструмент, который бы выполнял dlopen для каждой библиотеки-фреймворка, а затем использовал функции _dyld* для получения списка загруженных образов и их смещений.
Благодаря этому решению (а также обратному процессу при отладке с адресами, получаемыми из GDB) мы могли с лёгкостью отлаживать любую библиотеку из кэша dyld. Особенно нас интересовало kext IOMFB, демоны backboardd
и SpringBoard, а также фреймворк QuartzCore.
debugserver localhost:1111 –attach backboardd
iproxy 1111:1111
gdb-multiarch -x "set architecture arch" -x "target remote localhost:1111"
Примечание 1: позже мы выяснили, как отключать кэш dyld при помощи патчинга ядра. Это позволяет напрямую смотреть виртуальные адреса в кэше dyld на хосте (что мы и сделали при помощи отличной библиотеки Rust object из проекта Gimli).
Примечание 2: для отладки пользовательского пространства нам необходимо иметь на гостевом устройстве сервер gdb (см., например, пакет debugserver из Procursus). Изначально мы использовали gdb на хосте, но он был ограничен в возможностях или забагован, поэтому нам больше подошёл lldb.
Поговори со мной
Вооружившись работающим GDB, мы добились правильного запуска backboardd
, но потом осознали, что нам сильно могут помочь разобраться в происходящем (или не происходящем) системные логи.
На реальном iPhone можно получить системные логи (после подключения к компьютеру через USB) при помощи инструмента idevicesyslog. В процессе подключения требуется генерация пары ключей с хранением приватного ключа в телефоне. lockdownd использует его для проверки идентификации компьютера (после того, как пользователь авторизовал его один раз в UI).
Хоть нам удалось обеспечить взаимодействие с телефоном через USB, lockdownd не работал должным образом. После нескольких сессий в Ghidra мы осознали, что lockdownd пытался использовать набор ключей (keybag) для хранения приватного ключа, для чего потребовался бы отсутствующий у нас SEP.
Чтобы двигаться дальше, мы создали шелл-код, инъецированный вместо какой-то бесполезной (предположительно) функции. Код считывает заранее сгенерированную пару приватного/публичного ключей из файловой системы и, по сути, загружает их каждый раз, когда lockdownd пытается получить их из keybag.
После долгой отладки (и патчинга, позволяющего симулировать то, что пользователь доверяет компьютеру и телефон разблокирован) нам наконец удалось заставить всё это работать, и мы смогли подключиться к эмулируемому iPhone из нашего QEMU-«компаньона».
Позже мы выяснили, что также можно непосредственно в iPhone использовать для отображения логов инструмент oslog (на тот момент эта возможность не сработала), однако реализованное нами в дальнейшем позволило нам получать гораздо больше, чем просто логи.
К сожалению, судя по логам, QuartzCore инициировался правильно, отображая размер дисплея. Также из них следовало, что в качестве fallback использовался программный рендеринг. То есть всё работает правильно, но дисплея всё равно нет!
Примечание: отображалась ошибка формата пикселей, которую мы обошли, принудительно включив RGBA (ниже мы расскажем о патчинге в пользовательском пространстве), однако позже она была удалена.
PAC или не PAC
Внесение изменений в backboardd
для устранения ошибки формата пикселей показало нам, что у нас возникнут проблемы со множеством аспектов безопасности iOS.
Проверка подписи при загрузке и во время выполнения была исправлена патчами ядра, однако у нас оставались проблемы: сбои аутентификации указателей прерывали выполнение нашего модифицированного backboardd
.
Pointer Authentication — это фича, добавленная в ARM8.3; она используется в эмулируемой нами t8030, но не в t8015, которую мы применяли ранее, так что для нас это было новинкой.
Так как мы предвидели, что нас ждёт много патчинга, то решили сразу взяться за эту проблему и попытаться обойти её, чтобы упростить себе жизнь.
Сначала мы подумали, что можно просто заменить все команды PAC на NOP или на эквивалентные команды без PAC.
Хоть это, вероятно, и сработало бы, но при этом пришлось бы слишком вмешиваться в работу системы; позже мы обнаружили, что двоичный файл PAC ARM64 можно создать двумя способами:
Использовать специальный набор команд PAC, который может быть только в ARM8.3+ CPU.
Или «неиспользуемый» набор команд, который будет интерпретироваться как PAC, на ARM8.3+, или эквивалент без PAC в ранних версиях ARM.
После проведения тестов с Buildroot и системой на ARM64 Linux мы подтвердили это, а также убедились, что скомпилированные двоичные файлы для нашей платформы t8030 используют обратно совместимый набор инструкций (архитектура под названием arm64e).
По сути, всё, что нам нужно было сделать — это отключить принудительную проверку PAC (Pointer Authentication) в QEMU, и код должен был работать как обычный код без PAC. К сожалению, это не сработало; на тот момент мы использовали QEMU 7 и выяснили, что в QEMU 8 поведение было другим.
Тогда мы поступили логично — начали портировать текущую кодовую базу на QEMU 8.2.1. Это оказалось болезненным процессом, так как большая часть кода модифицировала стандартные части QEMU, особенно обработку специфичных для Apple инструкций genter/gexit и уровней исключений GL.
После бесчисленных паник XNU, подключений GDB к ядру, подключений GDB к самому QEMU и отчаянного git bisect для поиска последнего бага мы наконец-то снова заставили iOS загружаться на QEMU 8! А вместе с этим получили возможность отключать PAC и модифицировать любой исполняемый код где угодно и как угодно.
Свет в конце туннеля
Поскольку, судя по системным логам, backboardd
работал корректно, у нас не оставалось выбора, кроме как глубже изучить его поведение, чтобы понять, почему изображение по-прежнему не выводится.
Запись необработанных ARGB-кадров по этим адресам позволяла нам фактически изменять дисплей и работать с различными графическими плоскостями, что подтверждало исправность дисплейной подсистемы.
Оставалось несколько возможных вариантов:
backboardd
по какой-то причине ничего не записывалили выполнял запись не по правильному адресу
или записанное им было невалидным
Чтобы изучить вопрос, мы сначала попытались сдампить физическую память DMA, в которую должен был выполнять запись backboardd
: возможно, запись выполнялась, но отображалась неправильно.
Для этого мы использовали монитор QEMU, чтобы получить несмежные адреса, а затем — написанный на коленке скрипт для дампа физической памяти и объединения всего этого в один файл.
Затем при помощи ffplay мы попытались интерпретировать данные как ARGB-кадр, но, к сожалению, не получили ничего интересного.
Затем мы решили поэкспериментировать с поверхностями, выделенными iOS, получив при помощи GDB отображённые адреса в памяти backboardd
GDB (взломом iosurface_lock
).
Поискали по всем этим адресам подсказки, хоть и понятия не имели, что отображается и должно ли вообще что-то отображаться. Иногда мы встречали странные изображения в виде логотипа Apple, но запись кадров определённо выполнялась неправильно.
То же самое мы проделали на реальном iPhone 10 и с лёгкостью сдампили идеальные сырые ARGB-кадры текущего дисплея. Оказалось, что начиная с iPhone 11 (а значит, и в t8030) поверхности сжимаются и передаются GPU, который уже знает, как их обрабатывать.
Но так как этого не происходило на iPhone X (t8015), мы попытались модифицировать DTB в QEMU, чтобы передать в качестве chip-id не 8030, а 8015. И наконец мы добились отображения логотипа Apple после запуска!
Прогресс
Мы уже были довольны тем, что смогли отобразить логотип, но больше ничего другого не отображалось, а в системных логах было множество различной информации о неизвестных нам системных демонах и библиотеках.
На этом этапе мы могли лишь гадать, какие из куч ошибок в логах были связаны с текущей проблемой, и устранять их одну за другой, пока не изменится поведение UI.
Мы заметили проблемы с аутентификацией пользователя и обнаружили ошибки, вызванные демоном mobileactivationd и фреймворком SpringBoardFoundation.
После их патчинга UI начал отображать белую полосу прогресса, похожую на ту, которая видна на этапе восстановления. Полоса двигалась, но зависала на 90%, даже если мы ждали несколько часов.
Патчим это всё
Благодаря отключению рандомизации адресов мы смогли пропатчить пользовательское пространство и фреймворк кэша dyld. Точно так же, как в случае патчинга ядра, мы создали текстовые файлы файлы патчей, разбитые по двоичным файлам/библиотекам, и применяли их при помощи наших инструментов.
Однако мы быстро поняли. что нам придётся достаточно часто патчить кэш dyld, а при внесении изменений очень неудобно работать с двоичным файлом весом 2 ГБ.
Патчить его напрямую было нереально, мы все работаем в Linux, поэтому не можем модифицировать nvme напрямую, а копирование 2 ГБ туда и обратно через SSH потребовало бы кучу времени.
Поэтому мы дополнили наш внутренний инструмент diff/патчинга так, чтобы он работал с dyld и искал смещение фреймворка в блобе кэша dyld.
Кроме того, мы добавили опцию, позволяющую генерировать простые команды «dd» (и обратные им), которые можно было применять непосредственно на iPhone (повторно смонтировав файловую систему в rw).
Этот способ позволил нам протестировать множество итераций изменений; при этом для того, чтобы внесённые в кэш dyld модификации учитывались, достаточно было перезапустить iOS.
Примечание: чтобы это заработало, потребовалось ещё несколько дополнительных модификаций проверок подписей в ядре.
Оно живое!
Прежде чем разобраться, как устранить проблему зависающей полосы прогресса, мы немного поэкспериментировали с системным процессом PreBoard. Похоже, он виден пользователю только тогда, когда что-то пошло не так (например, прервано обновление).
Так как это системное приложение, выполняющее отрисовку напрямую при помощи backboardd
(точно так же, как это бы делал SpringBoard), его можно запустить непосредственно из командной строки. И благодаря этому мы получаем белый экран, предлагающий выполнить свайп для обновления!
Вооружившись знаниями из прошлых проектов об использовании VNC-сервера на физическом iPhone, мы попробовали добавить его, и спустя несколько неудачных попыток смогли разблокировать этот белый экран (не свайпом, а клавишей клавиатуры).
Сразу после разблокировки QEMU прекращает выполнение, потому что iOS использует недопустимую команду. Порывшись в backboardd
, мы обнаружили, что он использует фреймворк vImage для выполнения графических операций с аппаратным ускорением (например, _vHorizontalScale_ARGB_8888_Accelerate
).
Эти операции используют AMX (Apple Matrix Coprocessor), имеющий набор проприетарных команд, не реализованных в эмулированном CPU ARM, работающем в QEMU. К счастью, у фреймворка vImage есть альтернативные программные версии этих вызовов, задействующих только общие команды ARM, так что мы снова занялись патчингом.
В результате мы получили новый экран с настоящим окном UIKit, которое предлагало нам ввести пароль в работающее поле ввода текста. В нём можно было писать при помощи инъецированных через VNC событий клавиатуры.
На этом этапе мы знали, что у нас всё готово для правильного отображения SpringBoard, и его запуск оставался лишь делом времени.