1. Введение
Всем привет и добро пожаловать во вторую часть серии наших статей в блоге, в которой обобщаются наши исследования в области фаззинга и эксплуатации UEFI. В 1 части серии, метко названной "Переход от общего понимания о UEFI к фактическому дампу прошивки UEFI", мы дали некоторую сжатую, но необходимую справочную информацию о флэш-памяти SPI и обсудили программный подход к дампу данных на диск. Мы завершили эту часть, распаковав образ прошивки с помощью множества инструментов.
Эта часть продолжается с того места, где мы остановились. Мы начнем с предоставления дополнительной справочной информации о UEFI в целом, как с точки зрения самого процесса загрузки (Каковы различные этапы загрузки? Как они связаны? И так далее), так и с точки зрения разработчиков (то есть какие API доступны для приложений UEFI).
Затем мы перейдем к ручному реверс-инжинирингу некоторых модулей UEFI. В этом посте мы будем медленно, но верно продвигаться к более динамичным подходам. Если вы будете следовать этой статье, к тому времени, когда вы закончите её читать, у вас будет рабочая среда, способная эмулировать, трассировать и отлаживать модули UEFI.
2. Этапы загрузки UEFI
Как болезненный урок, извлеченный из устаревшей BIOS, UEFI пытается сделать процесс загрузки максимально методичным и организованным. По этой причине спецификация UEFI делит процесс загрузки на отдельные фазы, каждая из которых отвечает за настройку определенных компонентов, критически важных для надежной работы машины. После завершения определенного этапа он должен передать управление следующему этапу в цепочке, возможно, с некоторыми вспомогательными данными, которые помогут ему выполнять свои действия. Графически процесс загрузки UEFI часто изображается с помощью трудных для понимания диаграмм, заполненных малоизвестными аббревиатурами, такими как SEC, PEI, DXE, BDS и так далее.
Точный и всесторонний обзор процесса загрузки, который также включает анализ поверхности атаки, был недавно написан @depletionmode. Здесь мы просто дадим краткий обзор каждого из этих этапов.
- Фаза SEC: вопреки популярному мифу, при загрузке в средах UEFI ЦП не начинает волшебным образом работать в 32-битном защищенном режиме или 64-битном длинном режиме. Скорее, первые несколько инструкций, выполняемых ЦП, по-прежнему являются устаревшими, 16-битными инструкциями реального режима. Поскольку в реальном режиме сделать что-то очень трудно, одна из первых задач фазы SEC - переключить процессор в защищенный режим. Кроме того, к этому времени контроллер памяти, отвечающий за DRAM, еще не инициализирован, поэтому этап SEC также отвечает за настройку кешей ЦП для использования в качестве временной RAM (метод, известный как CAR - Cache-as-RAM ).
- Фаза PEI: Фаза инициализации перед EFI, часто сокращаемая до PEI, обычно находится в собственном томе прошивки (FV) на флэш-памяти SPI. Она состоит из исполняемых модулей, которые придерживаются формата файла TE (Terse Executable), тесно связанного с хорошо известным форматом PE из Windows.
Фаза PEI отвечает за обнаружение и инициализацию основной памяти. После того, как основная оперативная память становится доступной, на этапе PEI может завершиться работа памяти CAR и перейти к инициализации группы других устройств на материнской плате. Чтобы передать информацию на этап DXE, модули PEI могут создавать и заполнять массив структур данных, называемых HOB (Hand-Off Blocks).
- Фаза DXE: среда выполнения драйвера, или для краткости фаза DXE, - это то место, где происходит большая часть тяжелой работы. Как и фаза PEI, фаза DXE также находится в собственном FV. Основное отличие состоит в том, что на этот раз исполняемые модули - это не файлы TE, а подлинные файлы PE32. На 64-битных машинах мы также должны ожидать чтобы найти файлы PE32+, то есть фаза DXE будет выполняться в 64-битном длинном режиме.
DXE фаза имеет выделенный диспетчер, чья работа состоит в том, чтобы перечислить все различные модули DXE и выполнить их один за другим. Эти модули отвечают за настройку Режима Управления Системой (SMM), доступ к сетям, хранилищам и стекам файловой системы и в основном за предоставление любых услуг, которые могут потребоваться загрузчику на основе UEFI для запуска ядра. С точки зрения безопасности этап DXE представляет особый интерес, поскольку обычно именно на нем реализуется и применяется безопасная загрузка.
- Фаза BDS: после завершения фазы DXE управление переходит к фазе BDS (Выбор Загрузочного Устройства). На этом этапе анализируется GPT диска и выполняется поиск системного раздела EFI. Как только он будет найден, можно загрузить и запустить диспетчер загрузки, например bootmgfw.efi.
- Фаза TSL: на этом этапе (Переходная Загрузка Системы) диспетчер загрузки либо запускает приложение без ОС, такое как оболочка UEFI, либо чаще запускает загрузчик. Задача загрузчика, такого как winload.efi, - подготовить среду выполнения для ядра, а затем загрузить само ядро. Когда это будет сделано, загрузчик должен вызвать службу UEFI под названием ExitBootServices(). Таким образом, загрузчик сигнализирует об окончании процесса загрузки.
- Фаза RT: во время фазы выполнения ядро ОС должно быть запущено и должно работать. Затем оно может перейти к загрузке драйверов устройств, созданию служб, фоновых процессов и так далее.
В остальной части этого сообщения в статье, если не указано иное, мы сосредоточимся исключительно на фазе DXE.
3. Основные службы UEFI
В этом разделе мы кратко рассмотрим некоторые из наиболее распространенных сервисов, доступных для приложений UEFI. Читая его, вы должны помнить, что не все основные сервисы будут рассмотрены. Для получения полной информации обратитесь к последней версии спецификации UEFI на uefi.org.
Сервисы UEFI можно условно разделить на две отдельные категории: сервисы загрузки и сервисы времени выполнения. Их можно рассматривать как базовые строительные блоки, на которых может быть построено микропрограммное обеспечение, подобно тому, как традиционная ОС предоставляет набор четко определенных API-интерфейсов для создания приложений на их основе.
Загрузочные службы
Как следует из названия, службы загрузки используются для облегчения процесса загрузки. Они доступны с этапа DXE до момента, когда загрузчик ОС вызывает ExitBootServices(). После этого все службы загрузки завершаются, память службы загрузки освобождается, и все, что остается, - это службы среды выполнения. Службы загрузки можно разделить на следующие подкатегории:
- Сервисы виртуальной памяти, которые поддерживают управление памятью на уровне страниц. Двумя наиболее известными сервисами в этой категории, несомненно, являются AllocatePages() и FreePages(). Если вы работаете с Windows, вы можете рассматривать их как версии UEFI более знакомых API, таких как VirtualAlloc() и VirtualFree().
- Сервисы памяти пула, которые поддерживают управление памятью небольшими фрагментами, не занимающими всю страницу. К ним относятся AllocatePool() и FreePool(), которые можно рассматривать как версии UEFI пар API, таких как HeapAlloc()/HeapFree() или более знакомый malloc/free.
- Службы событий, используемые для синхронизации потока выполнения до тех пор, пока не будет получено определенное событие. В эту категорию входят такие службы, как CreateEvent(), CreateEventEx(), NotifyEvent(), SignalEvent(), WaitForEvent() и CloseEvent().
- Сервисы протокола, которые служат основой для импорта и экспорта функций между различными модулями UEFI. По сути, протокол UEFI объединяет две вещи:
1. Уникальный идентификатор в виде GUID (128-битное целое число, процесс генерации которого гарантирует уникальность с незначительной вероятностью коллизий). Что касается обозначений, идентификаторы GUID обычно записываются как {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}, где "x" обозначает шестнадцатеричную цифру.
2. Интерфейс, который может иметь форму любой двоичной структуры. Несмотря на то, что спецификация UEFI не налагает никаких ограничений на формат интерфейса, большинство протоколов разработано так, чтобы интерфейс имел форму vtable, то есть массив указателей на функции. Таким образом, каждый клиент, который получает указатель на интерфейс (мы вскоре увидим, как это делается на практике), может использовать его для вызова связанных с ним функций. Чтобы завершить предыдущий пример, определен интерфейс для протокола Print2:
где каждый из членов структуры фактически является указателем на функцию. Например, последний член (AsciiValueToStringS) прототипируется следующим образом:
Чтобы сделать протокол UEFI доступным для других модулей, мы можем использовать одну из следующих служб: InstallProtocolInterface(), ReinstallProtocolInterface() или InstallMultipleProtocolInterfaces(). Помимо идентификатора GUID, идентифицирующего протокол, и указателя на интерфейс, все эти службы ожидают дополнительный аргумент типа EFI_HANDLE. Этот аргумент - непрозрачное значение, представляющее вызывающий модуль (в большинстве случаев - его базовый адрес). Принимая во внимание значение этого аргумента EFI_HANDLE, мы можем различать несколько реализаций одного и того же интерфейса, каждая из которых предлагается другим модулем.
Чтобы использовать интерфейс UEFI, можно использовать службы LocateProtocol() или OpenProtocol(). Основное различие между ними состоит в том, что LocateProtocol() просто возвращает первый экземпляр протокола, который соответствует заданному GUID, в то время как OpenProtocol() ожидает, что вызывающий объект передаст дополнительный аргумент EFI_HANDLE, чтобы полностью определить, какая запрашивается реализация протокола.
Кроме того, вызывающая функция может также перечислить все EFI_HANDLES, реализующие данный протокол, с помощью LocateHandleBuffer(). Таким образом, распространенной практикой среди разработчиков UEFI является сначала вызов LocateHandleBuffer() для получения массива всех дескрипторов модулей, реализующих определенный протокол, а затем итерация по нему при вызове OpenProtocol() для каждой записи.
Службы времени выполнения
В отличие от служб загрузки, которые вызываются только на время процесса загрузки, службы времени выполнения сохраняются в памяти даже после того, как UEFI-совместимый загрузчик передает управление ядру операционной системы. Несмотря на их доступность, ОС не обязана использовать их каким-либо значимым образом. Философия Windows, например, состоит в том, чтобы ограничить доступ к службам UEFI во время выполнения и вместо этого отдать предпочтение родным драйверам ОС с последующей поддержкой среды выполнения ACPI.
По сравнению с множеством доступных служб загрузки список поддерживаемых служб времени выполнения относительно невелик. Единственное подмножество сервисов, которые представляют для нас особый интерес, - это те, которые имеют дело с переменными UEFI NVRAM. Эти службы, а именно GetVariable(), SetVariable() и QueryVariableInfo(), будут обсуждаться в следующих статьях, когда мы будем говорить о фаззинге UEFI.
4. Реверсинг образа UEFI вручную.
Как упоминалось выше, модули UEFI имеют один из двух возможных форматов исполняемых файлов:
1. Переносимый исполняемый файл (PE): формат файла PE в основном хорошо известен своим повсеместным использованием в операционной системе Windows, где стандартные пользовательские приложения (.EXE), разделяемые библиотеки (.DLL), апплеты панели управления (.CPL), устройства драйверы (.SYS) и даже само ядро (NTOSKRNL) используют один и тот же базовый формат файла. Таким образом, спецификация PE должна быть знакома всем, кто проводил некоторые низкоуровневые исследования и разработки на платформе Windows, поэтому мы не будем вдаваться в подробности здесь. Если вы хотите углубить или освежить свои знания о различных концепциях и структурах PE, ознакомьтесь с этой (https://wiki.osdev.org/PE) отличной статьей на странице OSDev.org Wiki. В контексте UEFI файлы PE включают большинство исполняемых файлов, имеющихся в типичном образе прошивки.Они могут инкапсулировать как 32-, так и 64-битный код и выполняться "позже" в последовательности загрузки, обычно начиная с фазы DXE. Хотя PE-файлы, используемые UEFI, идентичны по формату файлам, используемым в Windows, некоторые функции формата обычно не используются (например, модули UEFI не импортируют другие модули с помощью IMAGE_IMPORT_DESCRIPTOR). Кроме того, подпись точки входа сильно отличается от подписи Windows. Обычно его прототипируют как:
Где EFI_SYSTEM_TABLE - это структура, содержащая указатели как на таблицу служб загрузки, так и на таблицу служб времени выполнения.
2. Краткий исполняемый файл (TE): формат файла TE - это урезанная версия формата PE. Он был создан с целью уменьшения накладных расходов, связанных с различными заголовками PE/COFF в образах PE32/PE32+, тем самым экономя бесценное место на флеш-чипе SPI. Например, в то время как общий формат PE определяет 16 различных каталогов данных, указывая на важные структуры, такие как таблица импорта, таблица экспорта или раздел ресурсов, формат TE намного более минималистичен и определяет только два действительных каталога данных: один для базовых перемещений и один для отладочной информации. В отличие от файлов PE, которые могут содержать 32- или 64-разрядный код, файлы TE ограничиваются только 32-разрядным кодом. Таким образом, их использование в UEFI ограничено почти исключительно фазой PEI.
Зная, что консорциум UEFI решил принять исполняемый формат, который уже широко используется, неудивительно, что все основные платформы RE (среди прочих IDA Pro, Ghidra и Binary Ninja) поддерживают синтаксический анализ, загрузку и дизассемблирование модулей UEFI из коробки. Тем не менее, простая загрузка модуля UEFI в IDA Pro с последующим просмотром ассемблерного кода не очень продуктивна по двум основным причинам:
1.Службы UEFI никогда не вызываются напрямую. Вместо этого они вызываются косвенно через указатель на таблицу служб загрузки или таблицу служб времени выполнения. Из-за этого просмотр ассемблерного листинга для сайта вызова не дает ничего, кроме непонятной мнемоники в виде call qword ptr [rax + 0x140]. С учетом этой инструкции практически невозможно выяснить, какая именно служба UEFI была вызвана (если вы не запомнили наизусть шестнадцатеричные смещения для различных служб. В таком случае, будьте нашим гостем).
2. UEFI интенсивно использует идентификаторы GUID, чтобы однозначно идентифицировать различные объекты, участвующие в процессе загрузки. Среди них мы можем найти, например, протоколы, установленные через InstallMultipleProtocolInterfaces(), разделы в GPT или даже HOB, используемые для обмена информацией между различными фазами загрузки. Некоторые проекты, такие как UEFTool, который ранее был представлен в части 1, уже скомпилировали изрядное количество этих определений GUID в базу данных GUID, которую можно легко импортировать и обрабатывать. В идеале мы захотим воспользоваться преимуществами таких баз данных во время наших сессий реверсинга.
Подводя итог, мы хотели бы иметь своего рода автоматизированный инструмент, который помог бы нам преобразовать расплывчатые и неоднозначные списки, такие как следующие:
В гораздо более читаемом и ясном представлении, таком как это:
К счастью для нас, на протяжении многих лет сообществу исследователей безопасности удавалось создать некоторые высококачественные инструменты и плагины для популярных платформ RE, которые помогают сделать реверсинг UEFI гораздо менее болезненным.
Здесь мы взглянем на некоторые из наиболее достойных похвалы:
- ida_efiutils: вероятно, самый старый плагин в списке, первоначальные коммиты относятся к 2012 году (!).Несмотря на то, что он не обслуживается, он по-прежнему способен разрешать изрядное количество GUID, встречающихся "в дикой природе", а также правильно определять вызовы служб загрузки и выполнения.
- efi_swiss_knife: один из многих инструментов, связанных с UEFI, созданных @osxreverser.Он написан на C, а не на Python, и в основном тестировался в среде Mac OSX. Дополнительные сведения смотри в соответствующем сообщении в блоге.
- UEFI_REtools: призван облегчить задачу реверс инжиниринга UEFI из IDA Pro.
Его наиболее уникальная и интересная особенность - это возможность сочетать вызовы сервисов "провайдера", таких как InstallProtocolInterfaces(), с вызовами "потребительских" сервисов, таких как LocateProtocol(). Таким образом, он может построить граф отношений между разными модулями UEFI, принадлежащими одному и тому же образу прошивки:
- efiXplorer: новейшее дополнение к семейству, созданное некоторыми известными деятелями в области безопасности микропрограмм. Этот плагин очень сильно делает упор на производительность, плюс его преимущество в том, что он постоянно поддерживается.
Александр Матросов недавно сделал отличную презентацию этого плагина, которая также включает ссылки на некоторые другие упомянутые выше плагины.
Совет: при реверсинге некоторых модулей UEFI в IDA Pro мы заметили, что в некоторых случаях вывод декомпилятора Hex-Rays был частичным в ассемблерном листинге. В этих случаях велика вероятность того, что декомпилятор был чрезмерно агрессивен и полностью удалил некоторые ссылки на память.
Хотя теоретически это может происходить с каждым типом модуля, похоже, что это более распространено среди образов UEFI, вероятно, из-за попыток доступа к фиксированным адресам в MMIO. Чтобы обойти эту проблему, мы можем:
- Пометить переменную, которая была "оптимизирована", с помощью ключевого слова "volatile", затем снова декомпилировать.
или
- Пометить весь раздел кода как "доступный для записи", затем снова декомпилировать (спасибо @mztropics).
Отладка и эмуляция кода UEFI
Пока нам удалось выполнить базовый статический анализ двоичных файлов UEFI. Хотя это определенно большой шаг вперед, во многих случаях одного статического анализа просто недостаточно, и его необходимо дополнить методами динамического анализа. В таких случаях UEFI, похоже, создает больше технических проблем, чем большинство других технологий. Основная причина этого в том, что модули UEFI обычно выполняются на ранней стадии процесса загрузки, задолго до того, как ядро ОС будет загружено в память. В результате будет наивно открывать модуль UEFI в стандартном отладчике и ожидать, что все пойдет гладко.
Одной из первых серьезных попыток системного подхода к решению этой проблемы является проект под названием efi_dxe_emulator, основанный на известном механизме эмуляции Unicorn, который является форком QEMU. Однако, в отличие от QEMU, который стремится стать полноценным эмулятором системы, движок Unicorn фокусируется исключительно на эмуляции ЦП, не беспокоясь о мельчайших деталях виртуализации различных аппаратных периферийных устройств.
Более того, в то время как QEMU - это всего лишь инструмент, движок Unicorn - это настоящая фреймворк, и как таковой предоставляет богатый набор API, которые могут использоваться большим количеством языков программирования через выделенные привязки. Среди прочего, эти API-интерфейсы позволяют пользователю проверять, настраивать и изменять поток выполнения, обеспечивая практически беспрецедентный контроль над эмулируемым кодом. Заинтересованный читатель может найти больше информации о сходствах и различиях между Unicorn и QEMU здесь.
К сожалению, одного движка Unicorn недостаточно для динамического анализа модулей UEFI. Поскольку единственная цель Unicorn - эмуляция ЦП, у него нет априорных знаний о концепциях, связанных с UEFI, таких как службы загрузки, службы времени выполнения, протоколы или даже формат PE. Чтобы устранить эти недостатки, efi_dxe_emulator самостоятельно реализует некоторые из наиболее часто используемых сервисов UEFI. Затем он создает некоторые необходимые структуры данных, такие как таблица служб загрузки и таблица служб времени выполнения, и размещает перехватчики для перехвата всех вызовов этих служб. Как только вызов службы UEFI был перехвачен таким образом, поток выполнения направляется к трамплину, который переходит к подпрограмме обработчика, имитирующей эффекты вызова. Более подробное объяснение того, как это делается на практике, можно найти здесь (https://reverse.put.as/2019/10/29/crafting-an-efi-emulator/).
В дополнение к основным сервисам UEFI, эмулятор также реализует PE-загрузчик (чтобы загружать изображения UEFI в память), простую кучу, базу данных дескрипторов и базовый интерактивный отладчик, который позволяет выполнять эмулируемый код. Схематично архитектуру efi_dxe_emulator можно изобразить как:
Некоторое время мы использовали efi_dxe_emulator в качестве платформы для проведения любых исследований, связанных с UEFI. На самом деле, мы были так взволнованы этим инструментом и безграничными возможностями, которые он открывал для нас, что в конечном итоге мы портировали его на Windows и даже добавили к нему несколько дополнительных функций, таких как "code coverage collection" и интеграцию с IDA Pro. Однако тот факт, что efi_dxe_emulator написан на простом языке C, отсутствие поддержки сообщества и отсутствие каких-либо высокоуровневых интерфейсов в конечном итоге убедили нас искать альтернативы.
И все же время, потраченное на работу с efi_dxe_emulator, не прошло даром. Учитывая огромный опыт, который мы приобрели с помощью этого инструмента, у нас было гораздо больше шансов правильно охарактеризовать наши требования с помощью новой платформы эмуляции, которую мы искали:
К счастью для нас, нам не потребовалось много времени, прежде чем мы наткнулись на новую платформу эмуляции под названием Qiling. Судя по списку критериев, который мы только что обрисовали, эта новый фреймворк, казалось, почти идеально соответствовал нашим потребностям:
Схематично архитектура Qiling и ее взаимосвязь с другими упомянутыми технологиями может быть изображена как:
6. Добавление поддержки UEFI в Qiling
Как мы обсуждали в разделе 3 (Основные службы UEFI), модули UEFI DXE работают в среде, аналогичной элементарной ОС. У них есть API, которые они могут вызывать, и методы, которые они могут использовать для связи с другими модулями. По этой причине мы решили реализовать поддержку UEFI в Qiling как модуль ОС.
Чтобы добиться хорошей поддержки выполнения модуля DXE в Qiling, мы должны были достичь следующих целей:
Большая часть реализации UEFI находится в каталоге os/uefi, однако, поскольку загрузчик играет большую роль в загрузке, перемещении и выполнении модулей, он также является важной частью. Функция запуска в os/uefi/uefi.py является основной функцией выполнения UEFI, однако она выполняется после того, как функция запуска loader/pe_uefi.py выполнила тяжелую работу по загрузке модулей и установке хуков и протоколов.
Хотя по крайней мере один модуль загружается функцией запуска pe_uefi.py, мы включили поддержку динамической загрузки модулей, которая позволяет загружать зависимости, когда они обнаруживаются с помощью вызова ql.loader.map_and_load.
Модули bootup.py и runtime.py отвечают как за установку хуков для служб загрузки и выполнения, так и за реализацию хуков.
shutdown.py включает в себя хуки для завершения выполнения модуля и используются для загрузки следующего модуля или возврата к предыдущему модулю в случае превентивного выполнения, когда модуль уже запущен (в случае загрузки модуля из хука с execute_now = True аргумент).
Загрузчик начинает с отображения пространства для стека и кучи. Он выделяет буфер в куче для EFI_SYSTEM_TABLE (основная структура, которую каждый модуль DXE получает в качестве аргумента при выполнении). EFI_SYSTEM_TABLE включает указатели для структур RuntimeServices и BootServices, мы записываем все структуры в тот же буфер кучи после EFI_SYSTEM_TABLE. Мы заполняем таблицы, используя классы ctypes, которые мы создали из UefiSpec.h и других файлов заголовков, включенных в проект EDK II. Затем мы сериализуем объекты в эмулируемую кучу.
Для хуков функций мы резервируем место в куче для каждой функции (размер указателя - 8 байт, для выравнивания) и устанавливаем перехватчик для этого адреса. Qiling уже был отличный механизм хукинга. Нам нужно было только создать декоратор функции перехвата UEFI, чтобы использовать правильное соглашение о вызовах. Помимо таблиц служб, о которых мы уже говорили, загрузчик также устанавливает таблицу конфигурации, содержащую таблицы HOB_LIST и EFI_DXE_SERVICES.
EFI_DXE_SERVICES - это интерфейс, который позволяет модулям управлять памятью и пространствами ввода/вывода, включая определение новых, удаление, установку их возможностей и получение их карты. Чтобы сэкономить время разработки, мы решили не реализовывать функциональность этого интерфейса, поэтому мы реализовали только функции-заглушки, которые возвращают значения кодов ошибок. В случае, когда функциональность любой из функций должна быть реализована, это может быть выполнено путем перезаписи ловушки с помощью функции set_api или путем расширения Qiling.
По причинам, которые будут объяснены в будущем, многие модули вылетают или не запускаются полностью, когда мы пытаемся их выполнить, поскольку они предполагают, что несколько протоколов уже установлены. Пытаясь поддерживать как можно больше модулей, мы внедрили несколько протоколов как часть Qiling. Другие протоколы должны быть реализованы для каждого проекта или могут быть переданы в Qiling, если они достаточно общие и позволяют запускать множество модулей.
Мы реализовали следующие протоколы: EFI_SMM_BASE2_PROTOCOL, EFI_MM_ACCESS_PROTOCOL и EFI_SMM_SW_DISPATCH2_PROTOCOL. Как можно понять из их названия, все они связаны с SMM (режимом управления системой), и их реализация позволяет выполнять многие модули SMM.
Как и EFI_DXE_SERVICES для реализации протоколов SMM, мы также решили ограничить время разработки и реализовать только интерфейсы, а не все функции, которые они поддерживают. Мы всегда возвращаем false из функции InSmm, указывая, что система в настоящее время не работает в SMM, и большинство других функций возвращают коды ошибок. Мы используем одни и те же хуки для функций BootServices как в режиме SMM, так и в режиме без SMM. Поскольку мы не находимся в режиме SMM, функция SMM_SW_DISPATCH2_Register немедленно выполняет обратный вызов.
Помимо этих протоколов, мы также устанавливаем для каждого модуля EFI_LOADED_IMAGE_PROTOCOL, который устанавливается каждый раз, когда модуль загружается и регистрируется с помощью дескриптора модуля.
Использование Qiling для эмуляции UEFI:
Самый простой способ использовать Qiling для эмуляции UEFI - использовать Qiling qltool.
В этом примере мы видим, что функция GetVariable возвращает ошибку, поскольку мы не инициализировали хранилище переменных. Мы можем предоставить переменные как словарь в pickel файле. Позже мы запустим скрипт для генерации pickel файла из образа UEFI ROM.
Более сложный пример представлен в examples/simple_efi_x8664.py (часть репозитория Qiling):
В этом примере мы видим, что для загрузки модуля и его выполнения требуется всего несколько строк кода, и даже перезапись хуков с помощью функции set_api тривиальна. Мы можем заменить хук целиком или установить хук до или после исходного кода ловушки (onenter/onexit). В этом примере мы знаем, что модуль вызовет RegisterProtocolNotify для регистрации обратного вызова, когда протокол станет доступным, здесь мы устанавливаем событие в триггерное состояние, даже если протокол недоступен (мы не загружали модуль, который его реализует) и добавьте указатель функции обратного вызова в список notify_list, чтобы он вызывал, когда модули завершают выполнение своей основной функции. Мы выполняем один обратный вызов из notify_list каждый раз, когда модуль возвращается из выполнения, и только когда мы закончили с обратными вызовами, мы выполняем следующий модуль в списке модулей.
Отладка с использованием Qiling также очень проста и может быть достигнута с помощью qltool или специального скрипта. Поскольку в Qiling включен сервер GDB, можно использовать любой клиент, включая GDB и IDA Pro. При использовании qltool необходимо добавить –gdb 127.0.0.1:9999 при локальной отладке или –gdb 0.0.0.0:9999 при отладке с удаленного компьютера.
7. Что будет дальше?
Мы прошли долгий путь в этой статье. Мы перешли от простого ручного анализа модулей UEFI к созданию полнофункциональной среды эмуляции UEFI, способной отслеживать и отлаживать код UEFI. Очевидный вопрос, который обычно возникает в этих сценариях, - "Что будет дальше?". Мы воспользуемся оставшейся частью этого раздела, чтобы указать на несколько возможных векторов достижения прогресса.
Прежде всего, важно понимать, что пока мы способны правильно эмулировать только отдельные модули UEFI. В действительности модули UEFI сильно зависят друг от друга и обычно работают в тандеме для достижения определенных целей. В некоторой степени эмуляция одного модуля UEFI эквивалентна загрузке двоичного файла без сопоставления всех разделяемых библиотек, от которых он зависит для правильной работы. Таким образом, эмуляция отдельных модулей имеет серьезные ограничения.
Чтобы исправить это, мы должны добавить в загрузчик UEFI какой-то уровень оркестровки. В идеале этот уровень должен автоматически разрешать зависимости между модулями и гарантировать, что модуль будет загружен только после того, как все его зависимости будут разрешены. Использование такого уровня оркестрации позволит нам постепенно перейти от эмуляции отдельных модулей к эмуляции целых FV (например, тома, на котором размещается фаза DXE).
Во-вторых, при обзоре функций, предлагаемых Qiling, мы вкратце упомянули, что он имеет надежный интерфейс с AFL++, форком ванильного AFL, которая может (среди прочего) фазить любой фрагмент кода, эмулируемый движком Unicorn. Неудивительно, что у нас возникло сильное искушение объединить эти два инструмента и разработать фаззер поверх Qiling и AFL++, который действительно можно использовать для фаззинга кода UEFI. Обе темы будут подробно обсуждаться в следующих частях, так что следите за обновлениями.
Автор https://labs.sentinelone.com/moving...odules-to-dynamic-emulation-of-uefi-firmware/
Автор перевода: yashechka
Переведено специально для https://xss.pro
Всем привет и добро пожаловать во вторую часть серии наших статей в блоге, в которой обобщаются наши исследования в области фаззинга и эксплуатации UEFI. В 1 части серии, метко названной "Переход от общего понимания о UEFI к фактическому дампу прошивки UEFI", мы дали некоторую сжатую, но необходимую справочную информацию о флэш-памяти SPI и обсудили программный подход к дампу данных на диск. Мы завершили эту часть, распаковав образ прошивки с помощью множества инструментов.
Эта часть продолжается с того места, где мы остановились. Мы начнем с предоставления дополнительной справочной информации о UEFI в целом, как с точки зрения самого процесса загрузки (Каковы различные этапы загрузки? Как они связаны? И так далее), так и с точки зрения разработчиков (то есть какие API доступны для приложений UEFI).
Затем мы перейдем к ручному реверс-инжинирингу некоторых модулей UEFI. В этом посте мы будем медленно, но верно продвигаться к более динамичным подходам. Если вы будете следовать этой статье, к тому времени, когда вы закончите её читать, у вас будет рабочая среда, способная эмулировать, трассировать и отлаживать модули UEFI.
2. Этапы загрузки UEFI
Как болезненный урок, извлеченный из устаревшей BIOS, UEFI пытается сделать процесс загрузки максимально методичным и организованным. По этой причине спецификация UEFI делит процесс загрузки на отдельные фазы, каждая из которых отвечает за настройку определенных компонентов, критически важных для надежной работы машины. После завершения определенного этапа он должен передать управление следующему этапу в цепочке, возможно, с некоторыми вспомогательными данными, которые помогут ему выполнять свои действия. Графически процесс загрузки UEFI часто изображается с помощью трудных для понимания диаграмм, заполненных малоизвестными аббревиатурами, такими как SEC, PEI, DXE, BDS и так далее.
Точный и всесторонний обзор процесса загрузки, который также включает анализ поверхности атаки, был недавно написан @depletionmode. Здесь мы просто дадим краткий обзор каждого из этих этапов.
- Фаза SEC: вопреки популярному мифу, при загрузке в средах UEFI ЦП не начинает волшебным образом работать в 32-битном защищенном режиме или 64-битном длинном режиме. Скорее, первые несколько инструкций, выполняемых ЦП, по-прежнему являются устаревшими, 16-битными инструкциями реального режима. Поскольку в реальном режиме сделать что-то очень трудно, одна из первых задач фазы SEC - переключить процессор в защищенный режим. Кроме того, к этому времени контроллер памяти, отвечающий за DRAM, еще не инициализирован, поэтому этап SEC также отвечает за настройку кешей ЦП для использования в качестве временной RAM (метод, известный как CAR - Cache-as-RAM ).
- Фаза PEI: Фаза инициализации перед EFI, часто сокращаемая до PEI, обычно находится в собственном томе прошивки (FV) на флэш-памяти SPI. Она состоит из исполняемых модулей, которые придерживаются формата файла TE (Terse Executable), тесно связанного с хорошо известным форматом PE из Windows.
Фаза PEI отвечает за обнаружение и инициализацию основной памяти. После того, как основная оперативная память становится доступной, на этапе PEI может завершиться работа памяти CAR и перейти к инициализации группы других устройств на материнской плате. Чтобы передать информацию на этап DXE, модули PEI могут создавать и заполнять массив структур данных, называемых HOB (Hand-Off Blocks).
- Фаза DXE: среда выполнения драйвера, или для краткости фаза DXE, - это то место, где происходит большая часть тяжелой работы. Как и фаза PEI, фаза DXE также находится в собственном FV. Основное отличие состоит в том, что на этот раз исполняемые модули - это не файлы TE, а подлинные файлы PE32. На 64-битных машинах мы также должны ожидать чтобы найти файлы PE32+, то есть фаза DXE будет выполняться в 64-битном длинном режиме.
DXE фаза имеет выделенный диспетчер, чья работа состоит в том, чтобы перечислить все различные модули DXE и выполнить их один за другим. Эти модули отвечают за настройку Режима Управления Системой (SMM), доступ к сетям, хранилищам и стекам файловой системы и в основном за предоставление любых услуг, которые могут потребоваться загрузчику на основе UEFI для запуска ядра. С точки зрения безопасности этап DXE представляет особый интерес, поскольку обычно именно на нем реализуется и применяется безопасная загрузка.
- Фаза BDS: после завершения фазы DXE управление переходит к фазе BDS (Выбор Загрузочного Устройства). На этом этапе анализируется GPT диска и выполняется поиск системного раздела EFI. Как только он будет найден, можно загрузить и запустить диспетчер загрузки, например bootmgfw.efi.
- Фаза TSL: на этом этапе (Переходная Загрузка Системы) диспетчер загрузки либо запускает приложение без ОС, такое как оболочка UEFI, либо чаще запускает загрузчик. Задача загрузчика, такого как winload.efi, - подготовить среду выполнения для ядра, а затем загрузить само ядро. Когда это будет сделано, загрузчик должен вызвать службу UEFI под названием ExitBootServices(). Таким образом, загрузчик сигнализирует об окончании процесса загрузки.
- Фаза RT: во время фазы выполнения ядро ОС должно быть запущено и должно работать. Затем оно может перейти к загрузке драйверов устройств, созданию служб, фоновых процессов и так далее.
В остальной части этого сообщения в статье, если не указано иное, мы сосредоточимся исключительно на фазе DXE.
3. Основные службы UEFI
В этом разделе мы кратко рассмотрим некоторые из наиболее распространенных сервисов, доступных для приложений UEFI. Читая его, вы должны помнить, что не все основные сервисы будут рассмотрены. Для получения полной информации обратитесь к последней версии спецификации UEFI на uefi.org.
Сервисы UEFI можно условно разделить на две отдельные категории: сервисы загрузки и сервисы времени выполнения. Их можно рассматривать как базовые строительные блоки, на которых может быть построено микропрограммное обеспечение, подобно тому, как традиционная ОС предоставляет набор четко определенных API-интерфейсов для создания приложений на их основе.
Загрузочные службы
Как следует из названия, службы загрузки используются для облегчения процесса загрузки. Они доступны с этапа DXE до момента, когда загрузчик ОС вызывает ExitBootServices(). После этого все службы загрузки завершаются, память службы загрузки освобождается, и все, что остается, - это службы среды выполнения. Службы загрузки можно разделить на следующие подкатегории:
- Сервисы виртуальной памяти, которые поддерживают управление памятью на уровне страниц. Двумя наиболее известными сервисами в этой категории, несомненно, являются AllocatePages() и FreePages(). Если вы работаете с Windows, вы можете рассматривать их как версии UEFI более знакомых API, таких как VirtualAlloc() и VirtualFree().
- Сервисы памяти пула, которые поддерживают управление памятью небольшими фрагментами, не занимающими всю страницу. К ним относятся AllocatePool() и FreePool(), которые можно рассматривать как версии UEFI пар API, таких как HeapAlloc()/HeapFree() или более знакомый malloc/free.
- Службы событий, используемые для синхронизации потока выполнения до тех пор, пока не будет получено определенное событие. В эту категорию входят такие службы, как CreateEvent(), CreateEventEx(), NotifyEvent(), SignalEvent(), WaitForEvent() и CloseEvent().
- Сервисы протокола, которые служат основой для импорта и экспорта функций между различными модулями UEFI. По сути, протокол UEFI объединяет две вещи:
1. Уникальный идентификатор в виде GUID (128-битное целое число, процесс генерации которого гарантирует уникальность с незначительной вероятностью коллизий). Что касается обозначений, идентификаторы GUID обычно записываются как {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}, где "x" обозначает шестнадцатеричную цифру.
2. Интерфейс, который может иметь форму любой двоичной структуры. Несмотря на то, что спецификация UEFI не налагает никаких ограничений на формат интерфейса, большинство протоколов разработано так, чтобы интерфейс имел форму vtable, то есть массив указателей на функции. Таким образом, каждый клиент, который получает указатель на интерфейс (мы вскоре увидим, как это делается на практике), может использовать его для вызова связанных с ним функций. Чтобы завершить предыдущий пример, определен интерфейс для протокола Print2:
где каждый из членов структуры фактически является указателем на функцию. Например, последний член (AsciiValueToStringS) прототипируется следующим образом:
Чтобы сделать протокол UEFI доступным для других модулей, мы можем использовать одну из следующих служб: InstallProtocolInterface(), ReinstallProtocolInterface() или InstallMultipleProtocolInterfaces(). Помимо идентификатора GUID, идентифицирующего протокол, и указателя на интерфейс, все эти службы ожидают дополнительный аргумент типа EFI_HANDLE. Этот аргумент - непрозрачное значение, представляющее вызывающий модуль (в большинстве случаев - его базовый адрес). Принимая во внимание значение этого аргумента EFI_HANDLE, мы можем различать несколько реализаций одного и того же интерфейса, каждая из которых предлагается другим модулем.
Чтобы использовать интерфейс UEFI, можно использовать службы LocateProtocol() или OpenProtocol(). Основное различие между ними состоит в том, что LocateProtocol() просто возвращает первый экземпляр протокола, который соответствует заданному GUID, в то время как OpenProtocol() ожидает, что вызывающий объект передаст дополнительный аргумент EFI_HANDLE, чтобы полностью определить, какая запрашивается реализация протокола.
Кроме того, вызывающая функция может также перечислить все EFI_HANDLES, реализующие данный протокол, с помощью LocateHandleBuffer(). Таким образом, распространенной практикой среди разработчиков UEFI является сначала вызов LocateHandleBuffer() для получения массива всех дескрипторов модулей, реализующих определенный протокол, а затем итерация по нему при вызове OpenProtocol() для каждой записи.
Службы времени выполнения
В отличие от служб загрузки, которые вызываются только на время процесса загрузки, службы времени выполнения сохраняются в памяти даже после того, как UEFI-совместимый загрузчик передает управление ядру операционной системы. Несмотря на их доступность, ОС не обязана использовать их каким-либо значимым образом. Философия Windows, например, состоит в том, чтобы ограничить доступ к службам UEFI во время выполнения и вместо этого отдать предпочтение родным драйверам ОС с последующей поддержкой среды выполнения ACPI.
По сравнению с множеством доступных служб загрузки список поддерживаемых служб времени выполнения относительно невелик. Единственное подмножество сервисов, которые представляют для нас особый интерес, - это те, которые имеют дело с переменными UEFI NVRAM. Эти службы, а именно GetVariable(), SetVariable() и QueryVariableInfo(), будут обсуждаться в следующих статьях, когда мы будем говорить о фаззинге UEFI.
4. Реверсинг образа UEFI вручную.
Как упоминалось выше, модули UEFI имеют один из двух возможных форматов исполняемых файлов:
1. Переносимый исполняемый файл (PE): формат файла PE в основном хорошо известен своим повсеместным использованием в операционной системе Windows, где стандартные пользовательские приложения (.EXE), разделяемые библиотеки (.DLL), апплеты панели управления (.CPL), устройства драйверы (.SYS) и даже само ядро (NTOSKRNL) используют один и тот же базовый формат файла. Таким образом, спецификация PE должна быть знакома всем, кто проводил некоторые низкоуровневые исследования и разработки на платформе Windows, поэтому мы не будем вдаваться в подробности здесь. Если вы хотите углубить или освежить свои знания о различных концепциях и структурах PE, ознакомьтесь с этой (https://wiki.osdev.org/PE) отличной статьей на странице OSDev.org Wiki. В контексте UEFI файлы PE включают большинство исполняемых файлов, имеющихся в типичном образе прошивки.Они могут инкапсулировать как 32-, так и 64-битный код и выполняться "позже" в последовательности загрузки, обычно начиная с фазы DXE. Хотя PE-файлы, используемые UEFI, идентичны по формату файлам, используемым в Windows, некоторые функции формата обычно не используются (например, модули UEFI не импортируют другие модули с помощью IMAGE_IMPORT_DESCRIPTOR). Кроме того, подпись точки входа сильно отличается от подписи Windows. Обычно его прототипируют как:
Где EFI_SYSTEM_TABLE - это структура, содержащая указатели как на таблицу служб загрузки, так и на таблицу служб времени выполнения.
2. Краткий исполняемый файл (TE): формат файла TE - это урезанная версия формата PE. Он был создан с целью уменьшения накладных расходов, связанных с различными заголовками PE/COFF в образах PE32/PE32+, тем самым экономя бесценное место на флеш-чипе SPI. Например, в то время как общий формат PE определяет 16 различных каталогов данных, указывая на важные структуры, такие как таблица импорта, таблица экспорта или раздел ресурсов, формат TE намного более минималистичен и определяет только два действительных каталога данных: один для базовых перемещений и один для отладочной информации. В отличие от файлов PE, которые могут содержать 32- или 64-разрядный код, файлы TE ограничиваются только 32-разрядным кодом. Таким образом, их использование в UEFI ограничено почти исключительно фазой PEI.
Зная, что консорциум UEFI решил принять исполняемый формат, который уже широко используется, неудивительно, что все основные платформы RE (среди прочих IDA Pro, Ghidra и Binary Ninja) поддерживают синтаксический анализ, загрузку и дизассемблирование модулей UEFI из коробки. Тем не менее, простая загрузка модуля UEFI в IDA Pro с последующим просмотром ассемблерного кода не очень продуктивна по двум основным причинам:
1.Службы UEFI никогда не вызываются напрямую. Вместо этого они вызываются косвенно через указатель на таблицу служб загрузки или таблицу служб времени выполнения. Из-за этого просмотр ассемблерного листинга для сайта вызова не дает ничего, кроме непонятной мнемоники в виде call qword ptr [rax + 0x140]. С учетом этой инструкции практически невозможно выяснить, какая именно служба UEFI была вызвана (если вы не запомнили наизусть шестнадцатеричные смещения для различных служб. В таком случае, будьте нашим гостем).
2. UEFI интенсивно использует идентификаторы GUID, чтобы однозначно идентифицировать различные объекты, участвующие в процессе загрузки. Среди них мы можем найти, например, протоколы, установленные через InstallMultipleProtocolInterfaces(), разделы в GPT или даже HOB, используемые для обмена информацией между различными фазами загрузки. Некоторые проекты, такие как UEFTool, который ранее был представлен в части 1, уже скомпилировали изрядное количество этих определений GUID в базу данных GUID, которую можно легко импортировать и обрабатывать. В идеале мы захотим воспользоваться преимуществами таких баз данных во время наших сессий реверсинга.
Подводя итог, мы хотели бы иметь своего рода автоматизированный инструмент, который помог бы нам преобразовать расплывчатые и неоднозначные списки, такие как следующие:
В гораздо более читаемом и ясном представлении, таком как это:
К счастью для нас, на протяжении многих лет сообществу исследователей безопасности удавалось создать некоторые высококачественные инструменты и плагины для популярных платформ RE, которые помогают сделать реверсинг UEFI гораздо менее болезненным.
Здесь мы взглянем на некоторые из наиболее достойных похвалы:
- ida_efiutils: вероятно, самый старый плагин в списке, первоначальные коммиты относятся к 2012 году (!).Несмотря на то, что он не обслуживается, он по-прежнему способен разрешать изрядное количество GUID, встречающихся "в дикой природе", а также правильно определять вызовы служб загрузки и выполнения.
- efi_swiss_knife: один из многих инструментов, связанных с UEFI, созданных @osxreverser.Он написан на C, а не на Python, и в основном тестировался в среде Mac OSX. Дополнительные сведения смотри в соответствующем сообщении в блоге.
- UEFI_REtools: призван облегчить задачу реверс инжиниринга UEFI из IDA Pro.
Его наиболее уникальная и интересная особенность - это возможность сочетать вызовы сервисов "провайдера", таких как InstallProtocolInterfaces(), с вызовами "потребительских" сервисов, таких как LocateProtocol(). Таким образом, он может построить граф отношений между разными модулями UEFI, принадлежащими одному и тому же образу прошивки:
- efiXplorer: новейшее дополнение к семейству, созданное некоторыми известными деятелями в области безопасности микропрограмм. Этот плагин очень сильно делает упор на производительность, плюс его преимущество в том, что он постоянно поддерживается.
Александр Матросов недавно сделал отличную презентацию этого плагина, которая также включает ссылки на некоторые другие упомянутые выше плагины.
Совет: при реверсинге некоторых модулей UEFI в IDA Pro мы заметили, что в некоторых случаях вывод декомпилятора Hex-Rays был частичным в ассемблерном листинге. В этих случаях велика вероятность того, что декомпилятор был чрезмерно агрессивен и полностью удалил некоторые ссылки на память.
Хотя теоретически это может происходить с каждым типом модуля, похоже, что это более распространено среди образов UEFI, вероятно, из-за попыток доступа к фиксированным адресам в MMIO. Чтобы обойти эту проблему, мы можем:
- Пометить переменную, которая была "оптимизирована", с помощью ключевого слова "volatile", затем снова декомпилировать.
или
- Пометить весь раздел кода как "доступный для записи", затем снова декомпилировать (спасибо @mztropics).
Отладка и эмуляция кода UEFI
Пока нам удалось выполнить базовый статический анализ двоичных файлов UEFI. Хотя это определенно большой шаг вперед, во многих случаях одного статического анализа просто недостаточно, и его необходимо дополнить методами динамического анализа. В таких случаях UEFI, похоже, создает больше технических проблем, чем большинство других технологий. Основная причина этого в том, что модули UEFI обычно выполняются на ранней стадии процесса загрузки, задолго до того, как ядро ОС будет загружено в память. В результате будет наивно открывать модуль UEFI в стандартном отладчике и ожидать, что все пойдет гладко.
Одной из первых серьезных попыток системного подхода к решению этой проблемы является проект под названием efi_dxe_emulator, основанный на известном механизме эмуляции Unicorn, который является форком QEMU. Однако, в отличие от QEMU, который стремится стать полноценным эмулятором системы, движок Unicorn фокусируется исключительно на эмуляции ЦП, не беспокоясь о мельчайших деталях виртуализации различных аппаратных периферийных устройств.
Более того, в то время как QEMU - это всего лишь инструмент, движок Unicorn - это настоящая фреймворк, и как таковой предоставляет богатый набор API, которые могут использоваться большим количеством языков программирования через выделенные привязки. Среди прочего, эти API-интерфейсы позволяют пользователю проверять, настраивать и изменять поток выполнения, обеспечивая практически беспрецедентный контроль над эмулируемым кодом. Заинтересованный читатель может найти больше информации о сходствах и различиях между Unicorn и QEMU здесь.
К сожалению, одного движка Unicorn недостаточно для динамического анализа модулей UEFI. Поскольку единственная цель Unicorn - эмуляция ЦП, у него нет априорных знаний о концепциях, связанных с UEFI, таких как службы загрузки, службы времени выполнения, протоколы или даже формат PE. Чтобы устранить эти недостатки, efi_dxe_emulator самостоятельно реализует некоторые из наиболее часто используемых сервисов UEFI. Затем он создает некоторые необходимые структуры данных, такие как таблица служб загрузки и таблица служб времени выполнения, и размещает перехватчики для перехвата всех вызовов этих служб. Как только вызов службы UEFI был перехвачен таким образом, поток выполнения направляется к трамплину, который переходит к подпрограмме обработчика, имитирующей эффекты вызова. Более подробное объяснение того, как это делается на практике, можно найти здесь (https://reverse.put.as/2019/10/29/crafting-an-efi-emulator/).
В дополнение к основным сервисам UEFI, эмулятор также реализует PE-загрузчик (чтобы загружать изображения UEFI в память), простую кучу, базу данных дескрипторов и базовый интерактивный отладчик, который позволяет выполнять эмулируемый код. Схематично архитектуру efi_dxe_emulator можно изобразить как:
Некоторое время мы использовали efi_dxe_emulator в качестве платформы для проведения любых исследований, связанных с UEFI. На самом деле, мы были так взволнованы этим инструментом и безграничными возможностями, которые он открывал для нас, что в конечном итоге мы портировали его на Windows и даже добавили к нему несколько дополнительных функций, таких как "code coverage collection" и интеграцию с IDA Pro. Однако тот факт, что efi_dxe_emulator написан на простом языке C, отсутствие поддержки сообщества и отсутствие каких-либо высокоуровневых интерфейсов в конечном итоге убедили нас искать альтернативы.
И все же время, потраченное на работу с efi_dxe_emulator, не прошло даром. Учитывая огромный опыт, который мы приобрели с помощью этого инструмента, у нас было гораздо больше шансов правильно охарактеризовать наши требования с помощью новой платформы эмуляции, которую мы искали:
- Активно разрабатывается и поддерживается сообществом исследователей безопасности.
- Написан на дружественном языке высокого уровня, таком как Python. По крайней мере, фреймворк должен обеспечивать соответствующие привязки к такому языку.
- Предоставлять примитивы более высокого уровня, чем сам движок Unicorn. В идеале нам не следует заботиться о загрузке исполняемых образов, управлении кучей или реализации команд отладчика.
- Интеграция с механизмом фаззинга, таким как AFL, является значительным преимуществом.
К счастью для нас, нам не потребовалось много времени, прежде чем мы наткнулись на новую платформу эмуляции под названием Qiling. Судя по списку критериев, который мы только что обрисовали, эта новый фреймворк, казалось, почти идеально соответствовал нашим потребностям:
- Он активно развивается сообществом, и почти ежедневно предлагаются новые запросы на включение. Кроме того, платформа недавно была представлена на ряде крупных конференций, таких как ZeroNights, NULLCON и HITB, и вызвала много положительных отзывов.
- Написан полностью на Python, что обеспечивает очень быстрые циклы разработки и тестирования.
- Несмотря на то, что Qiling основан на движке Unicorn для эмуляции основного процессора, Qiling идет намного дальше и предлагает несколько очень полезных слоев поверх него. К ним относятся загрузчики для различных форматов исполняемых файлов, реализация кучи и даже заглушка GDB для обеспечения отладки эмулируемого кода. Подробное сравнение Qiling и Unicorn можно найти здесь (https://www.qiling.io/comparison/).
- Qiling может использовать AFL++, форк AFL, управляемый сообществом, для выполнения фаззинга на основе покрытия эмулируемого кода.
Схематично архитектура Qiling и ее взаимосвязь с другими упомянутыми технологиями может быть изображена как:
6. Добавление поддержки UEFI в Qiling
Как мы обсуждали в разделе 3 (Основные службы UEFI), модули UEFI DXE работают в среде, аналогичной элементарной ОС. У них есть API, которые они могут вызывать, и методы, которые они могут использовать для связи с другими модулями. По этой причине мы решили реализовать поддержку UEFI в Qiling как модуль ОС.
Чтобы добиться хорошей поддержки выполнения модуля DXE в Qiling, мы должны были достичь следующих целей:
- Создать модуль "ОС" UEFI.
- Создать загрузчик для файлов UEFI PE.
- Внедрить сервисы загрузки и выполнения в Python, чтобы они могли быть легко перезаписаны пользователями Qiling.
- Реализоватье несколько протоколов, которые используются многими модулями DXE и обычно реализуются модулями PEI или DXECore (диспетчер в фазе DXE).
Большая часть реализации UEFI находится в каталоге os/uefi, однако, поскольку загрузчик играет большую роль в загрузке, перемещении и выполнении модулей, он также является важной частью. Функция запуска в os/uefi/uefi.py является основной функцией выполнения UEFI, однако она выполняется после того, как функция запуска loader/pe_uefi.py выполнила тяжелую работу по загрузке модулей и установке хуков и протоколов.
Хотя по крайней мере один модуль загружается функцией запуска pe_uefi.py, мы включили поддержку динамической загрузки модулей, которая позволяет загружать зависимости, когда они обнаруживаются с помощью вызова ql.loader.map_and_load.
Модули bootup.py и runtime.py отвечают как за установку хуков для служб загрузки и выполнения, так и за реализацию хуков.
shutdown.py включает в себя хуки для завершения выполнения модуля и используются для загрузки следующего модуля или возврата к предыдущему модулю в случае превентивного выполнения, когда модуль уже запущен (в случае загрузки модуля из хука с execute_now = True аргумент).
Загрузчик начинает с отображения пространства для стека и кучи. Он выделяет буфер в куче для EFI_SYSTEM_TABLE (основная структура, которую каждый модуль DXE получает в качестве аргумента при выполнении). EFI_SYSTEM_TABLE включает указатели для структур RuntimeServices и BootServices, мы записываем все структуры в тот же буфер кучи после EFI_SYSTEM_TABLE. Мы заполняем таблицы, используя классы ctypes, которые мы создали из UefiSpec.h и других файлов заголовков, включенных в проект EDK II. Затем мы сериализуем объекты в эмулируемую кучу.
Для хуков функций мы резервируем место в куче для каждой функции (размер указателя - 8 байт, для выравнивания) и устанавливаем перехватчик для этого адреса. Qiling уже был отличный механизм хукинга. Нам нужно было только создать декоратор функции перехвата UEFI, чтобы использовать правильное соглашение о вызовах. Помимо таблиц служб, о которых мы уже говорили, загрузчик также устанавливает таблицу конфигурации, содержащую таблицы HOB_LIST и EFI_DXE_SERVICES.
EFI_DXE_SERVICES - это интерфейс, который позволяет модулям управлять памятью и пространствами ввода/вывода, включая определение новых, удаление, установку их возможностей и получение их карты. Чтобы сэкономить время разработки, мы решили не реализовывать функциональность этого интерфейса, поэтому мы реализовали только функции-заглушки, которые возвращают значения кодов ошибок. В случае, когда функциональность любой из функций должна быть реализована, это может быть выполнено путем перезаписи ловушки с помощью функции set_api или путем расширения Qiling.
По причинам, которые будут объяснены в будущем, многие модули вылетают или не запускаются полностью, когда мы пытаемся их выполнить, поскольку они предполагают, что несколько протоколов уже установлены. Пытаясь поддерживать как можно больше модулей, мы внедрили несколько протоколов как часть Qiling. Другие протоколы должны быть реализованы для каждого проекта или могут быть переданы в Qiling, если они достаточно общие и позволяют запускать множество модулей.
Мы реализовали следующие протоколы: EFI_SMM_BASE2_PROTOCOL, EFI_MM_ACCESS_PROTOCOL и EFI_SMM_SW_DISPATCH2_PROTOCOL. Как можно понять из их названия, все они связаны с SMM (режимом управления системой), и их реализация позволяет выполнять многие модули SMM.
Как и EFI_DXE_SERVICES для реализации протоколов SMM, мы также решили ограничить время разработки и реализовать только интерфейсы, а не все функции, которые они поддерживают. Мы всегда возвращаем false из функции InSmm, указывая, что система в настоящее время не работает в SMM, и большинство других функций возвращают коды ошибок. Мы используем одни и те же хуки для функций BootServices как в режиме SMM, так и в режиме без SMM. Поскольку мы не находимся в режиме SMM, функция SMM_SW_DISPATCH2_Register немедленно выполняет обратный вызов.
Помимо этих протоколов, мы также устанавливаем для каждого модуля EFI_LOADED_IMAGE_PROTOCOL, который устанавливается каждый раз, когда модуль загружается и регистрируется с помощью дескриптора модуля.
Использование Qiling для эмуляции UEFI:
Самый простой способ использовать Qiling для эмуляции UEFI - использовать Qiling qltool.
В этом примере мы видим, что функция GetVariable возвращает ошибку, поскольку мы не инициализировали хранилище переменных. Мы можем предоставить переменные как словарь в pickel файле. Позже мы запустим скрипт для генерации pickel файла из образа UEFI ROM.
Более сложный пример представлен в examples/simple_efi_x8664.py (часть репозитория Qiling):
Python:
import sys
import pickle
sys.path.append("..")
from qiling import *
from qiling.const import *
from qiling.os.uefi.const import *
def force_notify_RegisterProtocolNotify(ql, address, params):
event_id = params['Event']
if event_id in ql.loader.events:
ql.loader.events[event_id]['Guid'] = params["Protocol"]
# let's force notify
event = ql.loader.events[event_id]
event["Set"] = True
ql.loader.notify_list.append((event_id, event['NotifyFunction'], event['NotifyContext']))
######
return EFI_SUCCESS
return EFI_INVALID_PARAMETER
def my_onenter(ql, param_num, params, f, arg, kwargs):
print("\n")
print("=" * 40)
print(" Enter into my_onenter mode")
print("=" * 40)
print("\n")
return param_num, params, f, arg, kwargs
if __name__ == "__main__":
with open("rootfs/x8664_efi/rom2_nvar.pickel", 'rb') as f:
env = pickle.load(f)
ql = Qiling(["rootfs/x8664_efi/bin/TcgPlatformSetupPolicy"], "rootfs/x8664_efi", env=env)
ql.set_api("hook_RegisterProtocolNotify", force_notify_RegisterProtocolNotify)
ql.set_api("hook_CopyMem", my_onenter, QL_INTERCEPT.ENTER)
ql.run()
В этом примере мы видим, что для загрузки модуля и его выполнения требуется всего несколько строк кода, и даже перезапись хуков с помощью функции set_api тривиальна. Мы можем заменить хук целиком или установить хук до или после исходного кода ловушки (onenter/onexit). В этом примере мы знаем, что модуль вызовет RegisterProtocolNotify для регистрации обратного вызова, когда протокол станет доступным, здесь мы устанавливаем событие в триггерное состояние, даже если протокол недоступен (мы не загружали модуль, который его реализует) и добавьте указатель функции обратного вызова в список notify_list, чтобы он вызывал, когда модули завершают выполнение своей основной функции. Мы выполняем один обратный вызов из notify_list каждый раз, когда модуль возвращается из выполнения, и только когда мы закончили с обратными вызовами, мы выполняем следующий модуль в списке модулей.
Отладка с использованием Qiling также очень проста и может быть достигнута с помощью qltool или специального скрипта. Поскольку в Qiling включен сервер GDB, можно использовать любой клиент, включая GDB и IDA Pro. При использовании qltool необходимо добавить –gdb 127.0.0.1:9999 при локальной отладке или –gdb 0.0.0.0:9999 при отладке с удаленного компьютера.
7. Что будет дальше?
Мы прошли долгий путь в этой статье. Мы перешли от простого ручного анализа модулей UEFI к созданию полнофункциональной среды эмуляции UEFI, способной отслеживать и отлаживать код UEFI. Очевидный вопрос, который обычно возникает в этих сценариях, - "Что будет дальше?". Мы воспользуемся оставшейся частью этого раздела, чтобы указать на несколько возможных векторов достижения прогресса.
Прежде всего, важно понимать, что пока мы способны правильно эмулировать только отдельные модули UEFI. В действительности модули UEFI сильно зависят друг от друга и обычно работают в тандеме для достижения определенных целей. В некоторой степени эмуляция одного модуля UEFI эквивалентна загрузке двоичного файла без сопоставления всех разделяемых библиотек, от которых он зависит для правильной работы. Таким образом, эмуляция отдельных модулей имеет серьезные ограничения.
Чтобы исправить это, мы должны добавить в загрузчик UEFI какой-то уровень оркестровки. В идеале этот уровень должен автоматически разрешать зависимости между модулями и гарантировать, что модуль будет загружен только после того, как все его зависимости будут разрешены. Использование такого уровня оркестрации позволит нам постепенно перейти от эмуляции отдельных модулей к эмуляции целых FV (например, тома, на котором размещается фаза DXE).
Во-вторых, при обзоре функций, предлагаемых Qiling, мы вкратце упомянули, что он имеет надежный интерфейс с AFL++, форком ванильного AFL, которая может (среди прочего) фазить любой фрагмент кода, эмулируемый движком Unicorn. Неудивительно, что у нас возникло сильное искушение объединить эти два инструмента и разработать фаззер поверх Qiling и AFL++, который действительно можно использовать для фаззинга кода UEFI. Обе темы будут подробно обсуждаться в следующих частях, так что следите за обновлениями.
Автор https://labs.sentinelone.com/moving...odules-to-dynamic-emulation-of-uefi-firmware/
Автор перевода: yashechka
Переведено специально для https://xss.pro