Источник: https://github.com/AnaktaCTF/CTF/blob/main — Misc/Exploit-Development-ModernWindowsSystems.md

1. Базовые понятия и механизмы защиты Windows

DEP (Data Execution Prevention)

DEP предотвращает выполнение кода из областей памяти, предназначенных для хранения данных, например, стека или кучи.

Это разрушает простейшие сценарии, где злоумышленник записывает код в память данных и пытается выполнить его напрямую.

Обход DEP

Обычно через ROP (Return-Oriented Programming), где управление потоком захватывается через серию существующих инструкций, а не через инъекцию кода.

ASLR (Address Space Layout Randomization)

ASLR рандомизирует базовые адреса всех исполняемых файлов, библиотек и даже стека/кучи. Это делает невозможным предсказание адресов заранее.

Обход ASLR

Либо через информационные утечки, либо за счёт модулей без рандомизации (например, драйверов).

SMEP (Supervisor Mode Execution Prevention)

SMEP запрещает ядру выполнять код, размещённый в пользовательской памяти. Это защищает от атак, где злоумышленник пытается выполнить свой шеллкод, записанный в userland.

Обход SMEP

Изменение регистра CR4, отключение защиты, использование ROP.

SMAP (Supervisor Mode Access Prevention)

SMAP ещё более усиливает разделение между ядром и пользовательским пространством, запрещая даже доступ к пользовательской памяти без явного разрешения.

CFG (Control Flow Guard)

CFG защищает от атак типа ROP, ограничивая переходы к неожиданным адресам выполнения.

2. Что такое Null Pointer Dereference и почему это опасно

Null Pointer Dereference — это попытка обращения к памяти по адресу 0x0 или по малому смещению от нуля. В режиме пользователя это приводит к стандартному исключению, которое перехватывается системой. В режиме ядра это приводит к критической ошибке (BSOD).

В некоторых случаях неправильно обработанная ошибка может позволить злоумышленнику вмешаться в процесс исполнения, особенно если код ожидает, что по нулевому адресу будут находиться корректные данные.

Исторические примеры:

  • CVE-2013-3660: Уязвимость в драйвере Win32k.sys позволяла злоумышленнику произвести запись произвольных данных через Null Dereference.
  • CVE-2018-8120: Ошибка в обработке окон в ядре Windows.

3. Эксплуатация Null Dereference

Основная идея эксплуатации

  1. Спровоцировать обработку нулевого указателя в ядре.
  2. Заставить ядро обращаться к поддельной структуре, размещённой в контролируемой нами области памяти.
  3. Добиться, чтобы драйвер, доверяя данным, выполненным из структуры, сделал произвольную запись или чтение в память ядра.

Обход современных систем

На старых системах Windows (XP, Vista, Windows 7) можно было напрямую замапить нулевую страницу через NtAllocateVirtualMemory, заблокировав использование страницы ядром.
На современных системах это запрещено политиками безопасности ядра (Kernel-Mode Null Pointer Dereference Protection). Однако остаются редкие случаи:

  • Некорректная защита в сторонних драйверах.
  • Уязвимости в механизмах распределения памяти.
  • Специальные race-condition баги в обработке дескрипторов устройств.

Дополнительные техники захвата низких адресов

  • Использование NtMapViewOfSection для маппинга low memory через некорректные дескрипторы.
  • Баги старых фильтрующих драйверов файловых систем (например, антивирусов).
  • Манипуляция освобождением объектов в kernel pool с последующим захватом памяти.

4. Захват 0x0 и подготовка структуры

После получения возможности контролировать память по адресу 0x0, следующим шагом становится размещение фальшивой структуры, которая будет интерпретироваться уязвимым драйвером как валидная.

Пример фальшивой структуры

typedef struct _FAKE_STRUCT {
    int value;
    int flags;
    PVOID target;
} FAKE_STRUCT, *PFAKE_STRUCT;

Инициализация структуры

PFAKE_STRUCT fake = (PFAKE_STRUCT)0x0;
fake->value = 1;
fake->flags = 1;
fake->target = (PVOID)target_kernel_address;
  • value — используется для прохождения базовой проверки валидности объекта. Часто драйвер ожидает, что это поле будет равно определённому значению (например, 1 или 0x1234).
  • flags — позволяет активировать нужные ветви кода, например, разрешить выполнение операции записи по указателю.
  • target — критически важное поле: сюда мы записываем адрес в ядре, в который будет произведена запись управляющим кодом драйвера.

Важно

Поля структуры должны располагаться в памяти строго в том порядке и выравнивании, которое ожидает драйвер. На архитектурах x86 и x64 нарушение выравнивания может привести к неправильной интерпретации данных или краху системы до достижения цели эксплуатации.

Рекомендация

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

5. Получение произвольного чтения/записи в памяти ядра

После успешной подготовки структуры и триггера уязвимости следующий важный этап — реализация произвольной записи в память ядра, так называемого arbitrary write primitive.
Что происходит: как только драйвер, опираясь на данные из нашей фальшивой структуры, обращается к полю target и записывает туда значение, мы получаем возможность направлять запись в любой контролируемый нами адрес в пространстве ядра.

Пример уязвимого кода драйвера

if (p->flags & 0x1) {
    *(p->target) = 0xdeadbeef;
}

Возможности атак через произвольную запись

  • Изменение токена процесса (Privilege Escalation): переписав поле Token в структуре EPROCESS, можно выдать себе привилегии SYSTEM.
  • Подмена указателей в системных таблицах: например, изменение записей в SSDT или IDT позволяет перехватывать системные вызовы или обработку прерываний.
  • Инъекция кода в доверенные структуры: возможна модификация таких объектов, как DRIVER_OBJECT, чтобы вставить переход на свой код.
  • Перезапись function pointer’ов: перенаправление указателей на обработчики событий в структурах драйверов (например, изменение MajorFunction в IRP таблице).

Возможности при наличии произвольного чтения

  • Обход ASLR: чтение базовых адресов ядра и драйверов позволяет точно строить дальнейшие атаки, особенно ROP-цепочки.
  • Изучение структур безопасности: получение содержимого токенов, списка процессов и других системных объектов.
  • Повышение надёжности эксплуатации: возможность проверки содержимого памяти до изменения позволяет избегать крашей и повышать стабильность эксплойта.

6. Эскалация привилегий через замену токена

Наша основная задача — заменить токен безопасности текущего процесса (EPROCESS) на токен процесса SYSTEM.

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

  • Все процессы в Windows представлены структурами типа EPROCESS.
  • У каждой структуры EPROCESS есть поле Token, которое указывает на объект токена безопасности (структура TOKEN).
  • Токен процесса SYSTEM обладает полным набором привилегий.
  • Если мы скопируем токен из процесса SYSTEM в наш процесс, то автоматически получим права SYSTEM на уровне ядра.

Стандартный алгоритм выполнения

  1. Найти EPROCESS процесса SYSTEM: это можно сделать через глобальную переменную ядра PsInitialSystemProcess, которая всегда указывает на структуру EPROCESS процесса SYSTEM (PID=4).
  2. Извлечь токен процесса SYSTEM: считываем значение поля Token из найденной структуры.
  3. Найти EPROCESS текущего процесса: с помощью функции PsGetCurrentProcess() или, при необходимости, обойдя активный список процессов (ActiveProcessLinks).
  4. Записать токен SYSTEM в текущий процесс: переписываем поле Token нашей структуры на значение, извлечённое из SYSTEM.

Важный нюанс: очистка битов токена

В современных версиях Windows (Windows 8.1 и новее) токен безопасности обрабатывается с использованием специальных защитных битов в низших разрядах указателя токена. Эти биты используются системой для оптимизаций и защиты. Перед записью необходимо очистить младшие 4 бита токена, чтобы избежать некорректной работы или краха системы.

systemToken = systemToken & 0xFFFFFFFFFFFFFFF0;

Пример псевдокода

// Считываем токен процесса SYSTEM
ULONG64 systemToken = *(ULONG64*)(systemEPROCESS + OffsetToToken) & 0xFFFFFFFFFFFFFFF0;

// Перезаписываем токен текущего процесса
*(ULONG64*)(currentEPROCESS + OffsetToToken) = systemToken;

7. Проблема SMEP и её преодоление

Что делает SMEP

SMEP был введён в процессорах Intel начиная с архитектуры Ivy Bridge и поддерживается в Windows начиная с Windows 8. Его задача — предотвратить выполнение кода, размещённого в пространстве пользователя (user-mode), при работе в привилегированном режиме ядра (kernel-mode). Если ядро Windows или драйвер пытается выполнить код из пользовательской памяти, процессор немедленно вызывает исключение, и система падает с ошибкой BSOD.

Как обойти SMEP

Стандартный и наиболее прямой способ обхода SMEP — это отключение защиты через изменение регистра управления CR4.

  • Бит 20 регистра CR4 отвечает за включение/отключение SMEP.
  • Сбросив этот бит, мы позволяем процессору выполнять код из пользовательской памяти даже в режиме ядра.

Пример шеллкода для отключения SMEP

mov eax, cr4
and eax, 0xffefffff   ; Сбрасываем 20-й бит (отключаем SMEP)
mov cr4, eax
jmp shellcode         ; Переходим к нашему пользовательскому коду

Важные моменты:

  • Доступ к регистру CR4 возможен только из режима ядра. Нельзя изменить CR4 из пользовательского режима обычной программой.
  • Требуется аккуратная работа с регистрами процессора. Неверное изменение CR4 может нарушить работу всей операционной системы, например, отключить другие важные проверки процессора.
  • Возможны ограничения на некоторых системах: в некоторых корпоративных или защищённых системах изменения CR4 могут быть дополнительно защищены через Hyper-V или VBS (Virtualization-Based Security).

Альтернативные подходы при невозможности изменить CR4

  • ROP-цепочка: построение последовательности инструкций, которая изменяет CR4 через существующие гаджеты в ядре или драйверах.
  • Перенос кода: загрузка кода в trusted memory region (например, в память драйвера) и выполнение оттуда, минуя user-mode.
  • DMA-атаки: в очень специфичных условиях можно использовать устройства прямого доступа к памяти для перепрошивки памяти ядра.

8. Использование ROP-цепочки для обхода SMEP

Если выполнение собственного кода в режиме ядра невозможно напрямую, одним из эффективных методов обойти это является использование техники Return-Oriented Programming (ROP). Вместо инъекции и исполнения пользовательского кода напрямую, ROP позволяет злоумышленнику перехватывать управление и вызывать последовательность уже существующих инструкций (гаджетов) в доверенных модулях, таких как ntoskrnl.exe или драйверы.

Каждый гаджет обычно заканчивается инструкцией ret и выполняет небольшую операцию, например, перенос значения между регистрами или изменение флага. Для обхода SMEP через ROP строится минимальная цепочка, которая выполняет следующую последовательность действий:

  1. С помощью гаджета pop rcx; ret загружается новое значение в регистр RCX. Это значение должно представлять модифицированный CR4-регистр с отключённым битом SMEP.
  2. Далее через гаджет mov cr4, rcx; ret новое значение записывается обратно в регистр CR4.
  3. После этого осуществляется переход на адрес пользовательского шеллкода, например, через jmp shellcode или через другой подходящий гаджет.

Поиск необходимых ROP-гаджетов осуществляется с помощью специализированных инструментов вроде ROPgadget, mona.py для Immunity Debugger, или путём ручного анализа бинарных файлов через дизассемблеры (IDA Pro, Ghidra). Важно помнить, что из-за включённого ASLR адреса модулей могут меняться при каждой загрузке системы, поэтому для стабильной работы эксплойта требуется динамическое определение адресов в рантайме.

9. Инжекция шеллкода и выполнение полезной нагрузки

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

Простейший пример шеллкода: захват токена SYSTEM

void TokenStealingShellcode() {
    __asm {
        pushad;                   // Сохраняем все регистры на стеке

        mov eax, fs:[0x124];       // Получаем текущий KPCR (Kernel Processor Control Region)
        mov eax, [eax + 0x50];     // Переходим к структуре EPROCESS текущего процесса

    find_system:
        mov ecx, [eax + 0xb8];     // Переходим к следующему элементу в списке процессов
        sub ecx, 0xb8;             // Корректируем смещение до начала EPROCESS
        mov eax, ecx;
        cmp [eax + 0x174], 4;      // Сравниваем PID процесса с 4 (PID процесса SYSTEM)
        jne find_system;           // Если не найден — продолжаем искать

        mov edx, [eax + 0x208];    // Сохраняем токен процесса SYSTEM
        mov eax, fs:[0x124];       
        mov eax, [eax + 0x50];     // Снова получаем EPROCESS текущего процесса
        mov [eax + 0x208], edx;    // Перезаписываем токен нашего процесса токеном SYSTEM

        popad;                    // Восстанавливаем сохранённые регистры
        ret;                      // Возвращаемся к нормальному исполнению
    }
}

Что делает этот шеллкод

  • Ищет процесс SYSTEM: проходит по списку всех процессов в системе (ActiveProcessLinks) до тех пор, пока не найдёт процесс с идентификатором PID = 4, что соответствует процессу SYSTEM.
  • Копирует токен безопасности: считывает токен процесса SYSTEM и записывает его в поле Token структуры EPROCESS текущего процесса. После этого текущий процесс наследует все привилегии SYSTEM-процесса.

Важные детали при написании шеллкода

  • Минимизация размера: шеллкод должен быть как можно короче, чтобы уменьшить вероятность обнаружения.
  • Избежание абсолютных адресов: следует использовать относительные обращения или структурные переходы (через fs:[0x124]), чтобы код работал на разных версиях Windows.
  • Защита от краха: важно аккуратно сохранять и восстанавливать регистры (pushad/popad), чтобы минимально воздействовать на окружение процесса.