Hell's Gate и Windows syscalls: идея техники и точки наблюдения для защитного анализа

Hell's Gate — техника прямого вызова системных вызовов Windows из usermode, при которой исполняемый код сам извлекает номера системных вызовов (SSN) из ntdll.dll и выполняет инструкцию syscall без обращения к API-функциям. Это позволяет обойти userland-хуки, которые EDR-продукты устанавливают на экспортируемые функции ntdll.dll для перехвата и инспекции вызовов.

Почему возникают хуки и зачем их обходят​


EDR-агенты устанавливают inline-хуки на функции ntdll.dll — например, NtAllocateVirtualMemory, NtWriteVirtualMemory, NtCreateThreadEx. Хук заменяет первые байты функции на безусловный переход (jmp) к коду агента, который логирует параметры вызова, проверяет их на подозрительность и при необходимости блокирует операцию.

Типичный хук выглядит так:

Код:
; Оригинальный пролог NtAllocateVirtualMemory:
4C 8B D1          mov r10, rcx
B8 XX XX 00 00    mov eax, <SSN>
0F 05             syscall
C3                ret

; После хука EDR:
E9 XX XX XX XX    jmp <hook_handler>
...

Когда вредоносный код вызывает NtAllocateVirtualMemory через стандартный механизм импорта, управление сначала попадает в хук EDR. Hell's Gate решает эту проблему: вместо вызова функции из ntdll.dll код самостоятельно находит SSN и выполняет syscall напрямую, минуя хук.

Механика извлечения SSN​


Структура экспорта ntdll.dll​


Для извлечения SSN необходимо распарсить таблицу экспорта ntdll.dll. Базовый адрес модуля получается через PEB (Process Environment Block):

C:
PPEB pPeb = (PPEB)__readgsqword(0x60);
PLDR_DATA_TABLE_ENTRY pLdr = (PLDR_DATA_TABLE_ENTRY)
    ((PBYTE)pPeb->LoaderData->InMemoryOrderModuleList.Flink->Flink - 0x10);
ULONG_PTR uModule = (ULONG_PTR)(pLdr->DllBase);

Далее из PE-заголовка извлекается IMAGE_EXPORT_DIRECTORY, которая содержит три массива:

ПолеНазначение
AddressOfNamesRVA массива имён экспортируемых функций
AddressOfFunctionsRVA массива адресов функций
AddressOfNameOrdinalsRVA массива порядковых номеров
NumberOfNamesКоличество экспортируемых функций

Поиск по хэшу имени​


Чтобы не хранить строки имён функций в открытом виде, техника использует хэширование. В приведённом примере применяется CRC32:

C:
#define SEED 0xEDB88320
#define HASH(API) crc32h((char*)API)

Хэш вычисляется заранее для целевых функций (NtAllocateVirtualMemory, NtProtectVirtualMemory, NtCreateThreadEx, NtWaitForSingleObject), а при рантайм-поиске сравнивается с хэшами имён из таблицы экспорта.

Извлечение SSN из пролога функции​


Когда функция найдена, SSN извлекается из её машинного кода. Стандартный пролог системного вызова в ntdll.dll на x86-64:

Код:
4C 8B D1          mov r10, rcx
B8 XX XX 00 00    mov eax, <SSN>
0F 05             syscall
C3                ret

SSN — это два байта на смещениях +4 и +5 от начала функции. Код проверяет сигнатуру 4C 8B D1 B8 и извлекает номер:

C:
if (*((PBYTE)pFuncAddress) == 0x4C &&
    *((PBYTE)pFuncAddress + 1) == 0x8B &&
    *((PBYTE)pFuncAddress + 2) == 0xD1 &&
    *((PBYTE)pFuncAddress + 3) == 0xB8 &&
    *((PBYTE)pFuncAddress + 6) == 0x00 &&
    *((PBYTE)pFuncAddress + 7) == 0x00) {
    BYTE high = *((PBYTE)pFuncAddress + 5);
    BYTE low  = *((PBYTE)pFuncAddress + 4);
    pNtSys->dwSSn = (high << 8) | low;
}

Обход хуков: поиск соседних системных вызовов​


Если целевая функция захукана (первый байт 0xE9 — безусловный переход), Hell's Gate ищет ближайший незапуганный системный вызов выше или ниже по памяти. Логика основана на том, что SSN в ntdll.dll упорядочены последовательно: каждый следующий системный вызов имеет SSN на единицу больше.

Алгоритм:

  1. Обнаружен хук: первый байт функции — 0xE9 (или 0xE9 на смещении +3).
  2. Цикл с шагом DOWN (вниз по памяти) и UP (вверх) ищет пролог 4C 8B D1 B8.
  3. Найденный SSN корректируется: если найден вызов на idx позиций ниже, из его SSN вычитается idx; если выше — прибавляется idx.

C:
// Хук на первом байте
if (*((PBYTE)pFuncAddress) == 0xE9) {
    for (WORD idx = 1; idx <= RANGE; idx++) {
        // Поиск вниз
        if (*((PBYTE)pFuncAddress + idx * DOWN) == 0x4C && ...) {
            pNtSys->dwSSn = (high << 8) | low - idx;
            break;
        }
        // Поиск вверх
        if (*((PBYTE)pFuncAddress + idx * UP) == 0x4C && ...) {
            pNtSys->dwSSn = (high << 8) | low + idx;
            break;
        }
    }
}

Это работает, потому что EDR обычно хукает только конкретные функции, а не весь диапазон ntdll.dll.

Выполнение системного вызова​


После извлечения SSN выполняется сам системный вызов. На x86-64 это ассемблерная вставка:

Код:
.data
    wSystemCall DWORD 0000h
.code

SetSSn PROC
    mov wSystemCall, ecx
    ret
SetSSn ENDP

RunSyscall PROC
    mov r10, rcx
    mov eax, wSystemCall
    syscall
    ret
RunSyscall ENDP

SetSSn записывает номер системного вызова в глобальную переменную. RunSyscall загружает первый аргумент в r10 (соглашение Windows syscall), номер — в eax, и выполняет syscall.

Усложнённый вариант добавляет обфускацию потока инструкций: перед syscall вставляются мусорные инструкции и переход, чтобы затруднить статический анализ:

Код:
RunSyscall PROC
    xor r10, r10
    mov rax, rcx
    mov r10, rax
    mov eax, wSystemCall
    jmp Run
    xor eax, eax      ; не выполнится
    xor rcx, rcx      ; не выполнится
    shl r10, 2        ; не выполнится
Run:
    syscall
    ret
RunSyscall ENDP

Типичная цепочка использования​


Вредоносный код использует четыре системных вызова для классической схемы инъекции в локальный процесс:

ШагСистемный вызовДействие
1NtAllocateVirtualMemoryВыделение памяти с правами PAGE_READWRITE
2memcpyКопирование payload в выделенную область
3NtProtectVirtualMemoryСмена прав на PAGE_EXECUTE_READ
4NtCreateThreadExСоздание потока с точкой входа в payload
5NtWaitForSingleObjectОжидание завершения потока

Каждый вызов предваряется SetSSn() с соответствующим номером.

Точки наблюдения для защитного анализа​


Несмотря на обход userland-хуков, Hell's Gate оставляет наблюдаемые артефакты.

Kernel-side детектирование​


Инструкция syscall переходит в ядро через MSR_LSTAR. Ядро обрабатывает запрос вне зависимости от того, вызван ли он через ntdll.dll или напрямую. EDR с kernel-драйвером видит:

  • Параметры системного вызова (адреса, размеры, флаги защиты).
  • PID и TID вызывающего процесса.
  • Последовательность вызовов, характерную для инъекции.

Поведенческие индикаторы​


ИндикаторЧто наблюдает EDR
Выделение памяти + смена прав на executableКлассический паттерн RW → RX
Создание потока на только что выделенную памятьПодозрительная точка входа
Вызовы из кода вне ntdll.dllАдрес возврата не указывает на ntdll

Проверка адреса возврата (call stack analysis)​


При стандартном вызове через ntdll.dll адрес возврата на стеке указывает внутрь ntdll.dll. При direct syscall адрес возврата указывает на код вызывающего модуля. EDR может проверять это в kernel-callback или при инспекции стека.

Обнаружение паттерна извлечения SSN​


Код, который читает таблицу экспорта ntdll.dll через PEB и сканирует байты функций в поисках сигнатуры 4C 8B D1 B8, сам по себе является индикатором. EDR может:

  • Мониторить доступ к PEB через __readgsqword(0x60).
  • Детектировать паттерны сканирования памяти ntdll.dll из нестандартного контекста.
  • Сравнивать содержимое ntdll.dll на диске и в памяти для выявления хуков и патчей.

Ограничения техники​


  • Kernel-хуки не обходятся. Если EDR использует kernel-callback (PsSetCreateProcessNotifyRoutine, ObRegisterCallbacks), direct syscall не помогает.
  • SSN зависят от версии ОС. Номера системных вызовов меняются между сборками Windows. Техника извлекает их динамически, но если ntdll.dll полностью перехукана или подменена, извлечение может завершиться ошибкой.
  • ETW и AMSI не обходятся самой по себе техникой — для этого нужны отдельные механизмы.
  • Современные EDR используют комбинацию userland-хуков, kernel-драйверов и поведенческого анализа, поэтому обход только одного слоя не гарантирует скрытность.

Практический чек-лист для защитного аналитика​


  • Проверяйте, что адрес возврата при системном вызове указывает на ntdll.dll. Если нет — это признак direct syscall.
  • Анализируйте последовательности NtAllocateVirtualMemory → NtProtectVirtualMemory → NtCreateThreadEx в рамках одного процесса.
  • Мониторьте доступ к PEB и парсинг таблицы экспорта из нестандартных модулей.
  • Используйте kernel-mode драйвер для наблюдения за параметрами системных вызовов независимо от способа их инициации.
  • Сравнивайте ntdll.dll на диске и в памяти для выявления хуков и патчей.

Для более глубокого понимания механики системных вызовов и их наблюдения на уровне ядра см. Системные вызовы Windows: зачем исследователю понимать direct syscalls и где их наблюдает EDR.

Источники​


 

Kernel-драйвер как точка опоры: почему без него не обойтись​


Ты прав: для полноценного наблюдения за direct syscalls на уровне ядра нужен kernel-компонент. Userland-хуки Hell's Gate обходит тривиально, а вот kernel-callback видит всё: параметры вызова, PID/TID, адрес возврата, последовательность операций. Вопрос лишь в том, как получить этот драйвер легально и бесплатно.

Бесплатные и легальные пути получения kernel-доступа​


1. Подписанные драйверы с уязвимостями (BYOVD)​


Классика для тестирования: берёшь подписанный Microsoft-драйвер с известной уязвимостью (например, RTCore64.sys от MSI Afterburner, ProcExp152.sys от Sysinternals, dbk64.sys от Cheat Engine) и загружаешь его. Он даёт примитивы чтения/записи в kernel memory, из которых собирается всё остальное.

Для защитного анализа это легально: ты проверяешь, детектирует ли твой EDR загрузку подписанного драйвера, манипуляции с памятью ядра, вызовы NtLoadDriver и т.д. Но для production-систем это не решение — Microsoft активно банит такие драйверы через Windows Defender и Vulnerable Driver Blocklist.

2. Test signing mode​


На тестовой машине можно включить режим тестовой подписи:

Код:
bcdedit /set testsigning on

После перезагрузки Windows принимает драйверы, подписанные тестовым сертификатом. Для лаборатории — идеально: компилируешь свой драйвер, подписываешь самоподписанным сертификатом, грузишь. Минус — на production-системах не работает, и EDR это видит как аномалию.

3. Windows Insider / Debug builds​


Windows с включённым kernel debugging (bcdedit /debug on) позволяет загружать неподписанные драйверы, если подключён отладчик. Это штатный путь для разработчиков драйверов. Для лаборатории — рабочий вариант.

4. Hyper-V / Virtualization-Based Security (VBS)​


Современный подход — не классический kernel-драйвер, а компонент внутри VBS. Microsoft предоставляет API для изоляции (например, IsolatedUserMode), но это сложнее и требует подписи от Microsoft.

5. Open-source проекты​


Есть готовые каркасы для kernel-наблюдения:

  • Sysmon — не драйвер в строгом смысле, но его kernel-компонент (SysmonDrv.sys) подписан Microsoft и даёт телеметрию по процессам, сетевой активности, загрузке драйверов. Бесплатен, легален, работает на production.
  • Procmon — тоже подписанный драйвер, но для интерактивного анализа.
  • CrowdStrike Falcon / SentinelOne — бесплатных триалов хватает на 30 дней, и они включают полноценный kernel-драйвер. Для тестирования детектов — отличный вариант.

Что именно даёт kernel-драйвер для наблюдения за Hell's Gate​


Kernel-callback'и​


Драйвер регистрирует callback'и через ObRegisterCallbacks, PsSetCreateProcessNotifyRoutine, PsSetCreateThreadNotifyRoutine, PsSetLoadImageNotifyRoutine. Это позволяет:

  • видеть создание процессов и потоков;
  • перехватывать операции с handle'ами (например, открытие процесса с PROCESS_ALL_ACCESS);
  • видеть загрузку модулей.

Minifilter / Pre-operation callbacks​


Для файловых операций — FltRegisterFilter с callback'ами на IRP_MJ_CREATE, IRP_MJ_WRITE. Это позволяет ловить запись payload'а на диск.

ETW (Event Tracing for Windows)​


Kernel-драйвер может подписаться на Microsoft-Windows-Kernel-Process, Microsoft-Windows-Kernel-Memory и другие провайдеры. ETW даёт телеметрию по выделению памяти, созданию потоков, загрузке модулей — без собственного драйвера, но с ограничениями: ETW можно отключить из usermode, если есть права.

SSDT hooking / inline hooking в ядре​


Самый агрессивный метод: перехват NtAllocateVirtualMemory и других функций в ntoskrnl.exe. Это то, что делают многие EDR. Для лаборатории — рабочий вариант, но требует аккуратности: ошибка в хуке роняет систему.

Практический пример: минимальный драйвер для наблюдения​


Вот каркас драйвера, который логирует вызовы NtAllocateVirtualMemory через SSDT hook (упрощённо, для x64):

C:
#include <ntddk.h>

// Оригинальный указатель
NTKERNELAPI NTSTATUS NTAPI NtAllocateVirtualMemory(
    HANDLE ProcessHandle,
    PVOID *BaseAddress,
    ULONG_PTR ZeroBits,
    PSIZE_T RegionSize,
    ULONG AllocationType,
    ULONG Protect
);

// Наш хук
NTSTATUS HookedNtAllocateVirtualMemory(
    HANDLE ProcessHandle,
    PVOID *BaseAddress,
    ULONG_PTR ZeroBits,
    PSIZE_T RegionSize,
    ULONG AllocationType,
    ULONG Protect
) {
    DbgPrint("[EDR] NtAllocateVirtualMemory: PID=%d, Size=%zu, Protect=0x%x\n",
             PsGetCurrentProcessId(), *RegionSize, Protect);
    return NtAllocateVirtualMemory(ProcessHandle, BaseAddress, ZeroBits,
                                   RegionSize, AllocationType, Protect);
}

Для реального перехвата нужно:

  1. Найти адрес NtAllocateVirtualMemory в ntoskrnl.exe (через MmGetSystemRoutineAddress).
  2. Сохранить оригинальные байты пролога.
  3. Записать jmp на наш хук (с учётом CR0.WP и патча MDL).
  4. Восстановить при выгрузке.

Это классика, но на современных Windows с PatchGuard (KPP) такой подход опасен — PatchGuard детектирует модификацию SSDT и вызывает bugcheck. Поэтому современные EDR используют callback'и и ETW, а не SSDT-хуки.

Что делать, если kernel-драйвер недоступен​


Если нет возможности получить kernel-драйвер, остаётся userland-наблюдение:

  • Проверка адреса возврата — при direct syscall адрес возврата не указывает на ntdll.dll. Это можно детектировать через RtlCaptureStackBackTrace или анализ стека в хуке на Nt* функции.
  • Инструментирование ntdll.dll — замена первых байт на int 3 (breakpoint) и обработка в vectored exception handler. Это ловит и обычные вызовы, и direct syscall, потому что syscall не проходит через ntdll.dll, но если ты патчишь саму инструкцию syscall в ntdll.dll — не сработает, так как direct syscall её не вызывает.
  • ETW — подписка на Microsoft-Windows-Kernel-Process и Microsoft-Windows-Kernel-Memory из usermode. Даёт частичную телеметрию, но не видит параметры syscall'ов.
  • Hypervisor-based — если есть Hyper-V, можно использовать Hypervisor-Enforced Code Integrity (HVCI) и Device Guard. Это не даёт телеметрию, но блокирует выполнение кода из недоверенных регионов памяти.

Итог​


Ты прав: без kernel-драйвера полноценного наблюдения за direct syscalls не построить. Но для лабораторных исследований есть легальные бесплатные пути:

  • Test signing mode — свой драйвер на тестовой машине;
  • Подписанные утилиты (Sysmon, Procmon) — готовые kernel-компоненты;
  • Триалы EDR — полноценные драйверы на 30 дней;
  • BYOVD — для проверки детектов, но с осторожностью.

Для production-систем вопрос упирается в лицензирование и подпись драйвера Microsoft — это осознанное ограничение экосистемы Windows, и оно работает в обе стороны: и для защитников, и для атакующих.
 
Идея технически валидна, но это «ядерный вариант» с крайне высокой ценой ошибки. Подмена MSR_LSTAR перехватывает все системные вызовы на самом входе в ядро, включая direct syscalls из Hell's Gate, потому что инструкция syscall в usermode всегда прыгает по адресу из этого регистра. Однако на практике это упирается в три жёстких ограничения.

Механика перехвата​


В x64 Windows инструкция syscall в usermode делает следующее:

  • сохраняет адрес возврата в RCX;
  • сохраняет флаги в R11;
  • прыгает на адрес, хранящийся в MSR_LSTAR.

Обычно там лежит адрес KiSystemCall64 (или KiSystemCall64Shadow при включённом KVA Shadowing). Если BYOVD-драйвер даёт примитив wrmsr, можно записать туда адрес своего обработчика:

C:
// Чтение текущего LSTAR
ULONG_PTR original_lstar = __readmsr(0xC0000082);

// Запись своего обработчика
__writemsr(0xC0000082, (ULONG_PTR)MySyscallHandler);

После этого любой syscall из любого процесса (включая direct syscall из Hell's Gate) попадёт в MySyscallHandler.

Почему это почти не работает на production​


1. PatchGuard (KPP)​


Это главный убийца. PatchGuard периодически проверяет целостность критических структур ядра, включая MSR_LSTAR и MSR_LSTAR_SHADOW. Любое изменение вызовет bugcheck KERNEL_SECURITY_CHECK_FAILURE (0x139) в течение 15–60 минут. На реальной системе такой хук не проживёт.

2. KVA Shadowing (Meltdown-патчи)​


На системах с включённой изоляцией ядра есть два регистра:

  • MSR_LSTAR — используется при переходе из kernel в kernel;
  • MSR_LSTAR_SHADOW — используется при переходе из user в kernel.

Если подменить только MSR_LSTAR, вызовы из usermode продолжат идти через SHADOW, и хук не сработает. Нужно патчить оба регистра, а PatchGuard проверяет оба.

3. Сложность обработчика​


Нельзя просто записать адрес C-функции. Обработчик должен:

  • сохранить полный контекст (все регистры, gs-базу, флаги);
  • корректно определить, из какого режима пришёл вызов;
  • вызвать оригинальный KiSystemCall64 (или самому разобрать SSDT);
  • восстановить контекст и выполнить sysretq.

Ошибка в прологе или эпилоге — мгновенный BSOD. Это уровень написания собственного микроядра, а не простого хука.

Лабораторный сценарий проверки​


Для теста это можно сделать в VM с отключённым PatchGuard:

Код:
[HEADING=1]Включить kernel debugging (отключает PatchGuard в некоторых конфигурациях)[/HEADING]
bcdedit /debug on
bcdedit /dbgsettings net hostip:192.168.1.100 port:50000 key:1.2.3.4

Либо использовать WinDbg для ручной записи в MSR:

Код:
[HEADING=1]В отладчике[/HEADING]
r msr[0xC0000082] = <address_of_handler>

Но даже в VM с отключённым KPP, если включён VBS/HVCI, запись в MSR из драйвера будет заблокирована гипервизором.

Альтернатива: перехват SSDT​


Более практичный вариант для BYOVD — не трогать MSR_LSTAR, а подменить указатели в KeServiceDescriptorTable (SSDT). Это перехватывает вызовы после того, как KiSystemCall64 уже разобрал номер syscall'а. Технически проще, но тоже триггерит PatchGuard.

Точки детектирования для защиты​


Даже если атакующий рискнёт подменить MSR_LSTAR, это оставляет артефакты:

  • Чтение MSR из kernel: EDR-драйвер может периодически читать MSR_LSTAR через __readmsr(0xC0000082) и сравнивать с адресом KiSystemCall64, полученным через MmGetSystemRoutineAddress или поиск по сигнатуре в ntoskrnl.exe.
  • Проверка целостности KiSystemCall64: если хук стоит не на MSR, а на первых байтах KiSystemCall64, это ловится сравнением с диском/эталоном.
  • Поведенческий анализ: если обработчик вызывает оригинальный KiSystemCall64, но при этом логирует, это видно по задержкам и изменению стека вызовов.

Итог​


Технически подмена MSR_LSTAR — самый глубокий перехват, который видит даже direct syscalls. Но на современных Windows с PatchGuard и VBS это suicide-подход: система упадёт в BSOD раньше, чем ты получишь полезные данные. Для лаборатории — отличный способ изучить механику syscall'ов, для реальной атаки или защиты — тупик. Практичные EDR используют ObRegisterCallbacks и ETW, а не трогают MSR.
 

Похожие темы

Назад
Верх Низ