Я не буду углубляться в то, как работает стек, потому что я уже рассказал об этом в предыдущей статье, которая доступна по вышеуказанной ссылке. Ранее мы видели, что мы можем манипулировать инструкциями call и jmp для запроса callback windows в вызове API LoadLibrary. Однако обнаружение трассировки стека выходит далеко за рамки простого отслеживания загрузки DLL. Когда вы внедряете отражающую DLL в локальный или удаленный процесс, вам приходится вызывать вызовы API, такие как VirtualAllocEx/VirtualProtectEx, которые косвенно вызывают NtAllocateVirtualMemory/NtProtectVirtualMemory. Однако, когда вы проверите стек вызовов легитимных вызовов API, вы заметите, что WINAPI, такие как VirtualAlloc/VirtualProtect, в основном вызываются не-Windows DLL-функциями. Большинство windows DLL вызывают NtAllocateVirtualMemory/NtProtectVirtualMemory напрямую. Ниже приведен пример стека вызовов для NtProtectVirtualMemory при вызове RtlAllocateHeap.
Это означает, что поскольку ntdll.dll не зависит ни от какой другой DLL, все функции в ntdll, которые требуют игры с разрешениями для областей памяти, будут вызывать NTAPI напрямую. Таким образом, это означает, что если мы сможем перенаправить наш вызов NtAllocateVirtualMemory через чистый стек из самой ntdll.dll, нам вообще не придется беспокоиться об обнаружении. Большинство красных команд полагаются на косвенные системные вызовы, чтобы избежать обнаружения. В случае косвенных вызовов системы вы просто переходите к адресу инструкции вызова системы после тщательного создания стека, но проблема здесь в том, что косвенные вызовы системы изменят только адрес возврата инструкции вызова системы в ntdll.dll. Адрес возврата в данном случае - это место, куда должна вернуться инструкция syscall после завершения выполнения syscall. Но остальная часть стека ниже адреса возврата все еще будет подозрительной, поскольку она выходит из области RX. Если EDR проверит полный стек NTAPI, он может легко определить, что адрес возврата в конечном итоге возвращается в выделенную пользователем область RX. Это означает, что адрес возврата в регион ntdll.dll, но стек, исходящий из региона RX, является 100% аномалией с нулевой вероятностью ложного срабатывания. Это легкая победа для EDR, использующих ETW для трассировки системных вызовов в ядре.
Таким образом, чтобы обойти это, я потратил некоторое время на реверс нескольких функций ntdll.dll и обнаружил, что с небольшим знанием ассемблера и того, как работают callbacks windows, мы сможем манипулировать callback'ом для вызова любой NTAPI-функции. В этой статье мы рассмотрим пример NtAllocateVirtualMemory, возьмем код из первой части блога и изменим его. Мы возьмем пример того же API TpAllocWork, который может выполнить функцию обратного вызова. Но вместо того, чтобы передавать указатель на строку, как мы делали в случае с Dll Proxying, на этот раз мы передадим указатель на структуру. В этот раз мы также избежим глобальных переменных, убедившись, что вся необходимая информация находится в структуре, поскольку мы не можем иметь глобальные переменные при написании шеллкодов. Определение NtAllocateVirtualMemory согласно msdn следующее:
C:
__kernel_entry NTSYSCALLAPI NTSTATUS NtAllocateVirtualMemory(
[in] HANDLE ProcessHandle,
[in, out] PVOID *BaseAddress,
[in] ULONG_PTR ZeroBits,
[in, out] PSIZE_T RegionSize,
[in] ULONG AllocationType,
[in] ULONG Protect
);
Это означает, что нам нужно передать указатель на NtAllocateVirtualMemory и его аргументы внутри структуры обратному вызову, чтобы наш обратный вызов мог извлечь эту информацию из структуры и выполнить ее. Мы проигнорируем аргументы, которые остаются статичными, такие как ULONG_PTR ZeroBits, который всегда равен нулю, и ULONG AllocationType, который всегда равен MEM_RESERVE|MEM_COMMIT, что в шестнадцатеричном формате равно 0x3000. Таким образом, добавив оставшиеся аргументы, структура будет выглядеть следующим образом:
C:
typedef struct _NTALLOCATEVIRTUALMEMORY_ARGS {
UINT_PTR pNtAllocateVirtualMemory; // pointer to NtAllocateVirtualMemory - rax
HANDLE hProcess; // HANDLE ProcessHandle - rcx
PVOID* address; // PVOID *BaseAddress - rdx; ULONG_PTR ZeroBits - 0 - r8
PSIZE_T size; // PSIZE_T RegionSize - r9; ULONG AllocationType - MEM_RESERVE|MEM_COMMIT = 3000 - stack pointer
ULONG permissions; // ULONG Protect - PAGE_EXECUTE_READ - 0x20 - stack pointer
} NTALLOCATEVIRTUALMEMORY_ARGS, *PNTALLOCATEVIRTUALMEMORY_ARGS;
Затем мы инициализируем структуру необходимыми аргументами, передадим ее в качестве указателя в TpAllocWork и вызовем нашу функцию WorkCallback, которая написана на ассемблере.
C:
#include <windows.h>
#include <stdio.h>
typedef NTSTATUS (NTAPI* TPALLOCWORK)(PTP_WORK* ptpWrk, PTP_WORK_CALLBACK pfnwkCallback, PVOID OptionalArg, PTP_CALLBACK_ENVIRON CallbackEnvironment);
typedef VOID (NTAPI* TPPOSTWORK)(PTP_WORK);
typedef VOID (NTAPI* TPRELEASEWORK)(PTP_WORK);
typedef struct _NTALLOCATEVIRTUALMEMORY_ARGS {
UINT_PTR pNtAllocateVirtualMemory; // pointer to NtAllocateVirtualMemory - rax
HANDLE hProcess; // HANDLE ProcessHandle - rcx
PVOID* address; // PVOID *BaseAddress - rdx; ULONG_PTR ZeroBits - 0 - r8
PSIZE_T size; // PSIZE_T RegionSize - r9; ULONG AllocationType - MEM_RESERVE|MEM_COMMIT = 3000 - stack pointer
ULONG permissions; // ULONG Protect - PAGE_EXECUTE_READ - 0x20 - stack pointer
} NTALLOCATEVIRTUALMEMORY_ARGS, *PNTALLOCATEVIRTUALMEMORY_ARGS;
extern VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE Instance, PVOID Context, PTP_WORK Work);
int main() {
LPVOID allocatedAddress = NULL;
SIZE_T allocatedsize = 0x1000;
NTALLOCATEVIRTUALMEMORY_ARGS ntAllocateVirtualMemoryArgs = { 0 };
ntAllocateVirtualMemoryArgs.pNtAllocateVirtualMemory = (UINT_PTR) GetProcAddress(GetModuleHandleA("ntdll"), "NtAllocateVirtualMemory");
ntAllocateVirtualMemoryArgs.hProcess = (HANDLE)-1;
ntAllocateVirtualMemoryArgs.address = &allocatedAddress;
ntAllocateVirtualMemoryArgs.size = &allocatedsize;
ntAllocateVirtualMemoryArgs.permissions = PAGE_EXECUTE_READ;
FARPROC pTpAllocWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpAllocWork");
FARPROC pTpPostWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpPostWork");
FARPROC pTpReleaseWork = GetProcAddress(GetModuleHandleA("ntdll"), "TpReleaseWork");
PTP_WORK WorkReturn = NULL;
((TPALLOCWORK)pTpAllocWork)(&WorkReturn, (PTP_WORK_CALLBACK)WorkCallback, &ntAllocateVirtualMemoryArgs, NULL);
((TPPOSTWORK)pTpPostWork)(WorkReturn);
((TPRELEASEWORK)pTpReleaseWork)(WorkReturn);
WaitForSingleObject((HANDLE)-1, 0x1000);
printf("allocatedAddress: %p\n", allocatedAddress);
getchar();
return 0;
}
Вот здесь все становится интересным. В случае DLL-прокси мы выполняли LoadLibrary только с одним аргументом - именем загружаемой DLL, которое передается в регистр RCX. Но в случае с NtAllocateVirtualMemory у нас в общей сложности 6 аргументов. Это означает, что первые четыре аргумента идут в регистры быстрого вызова, т.е. RCX, RDX, R8 и R9. Однако оставшиеся два аргумента должны быть помещены в стек после выделения некоторого пространства для наших 4 регистров. Обратите внимание, что в верхней части нашего стека в настоящее время находится возвращаемое значение внутренней NTAPI-функции TppWorkpExecuteCallback по адресу 0ffset 0x130. Вот как выглядит стек вызовов при вызове функции обратного вызова WorkCallback.
Теперь вот в чем загвоздка: если вы измените вершину стека, где находится адрес возврата, добавите место для 4 регистров и добавите к ним аргументы, то вся стековая структура будет перепутана и испортит развертку стека. Таким образом, мы должны модифицировать стек, не изменяя сам фрейм стека, а изменяя только значения внутри фрейма стека. Каждый фрейм стека начинается и заканчивается у синей линии, показанной на рисунке выше. Наш стековый кадр для TppWorkpExecuteCallback имеет достаточно места внутри себя, чтобы вместить 6 аргументов. Поэтому следующим шагом будет извлечение данных из нашей структуры NTALLOCATEVIRTUALMEMORY_ARGS и перемещение их в соответствующие регистры и стек. Когда мы вызываем TpAllocWork, мы передаем указатель на структуру NTALLOCATEVIRTUALMEMORY_ARGS функции WorkCallback, это означает, что наш указатель на структуру должен сейчас находиться в регистре RDX. Каждое значение в нашей структуре состоит из 8 байт (для x64, для x86 это будет 4 байта). Итак, мы извлечем эти значения QWORD из структуры и переместим их в RCX, RDX, R8, R9 и оставшиеся значения в стеке после корректировки пространства самонаведения. Соглашение о вызове функций x64 в windows согласно документации msdn будет выглядеть следующим образом:
C:
__kernel_entry NTSYSCALLAPI NTSTATUS NtAllocateVirtualMemory(
[in] HANDLE ProcessHandle, // goes into rcx
[in, out] PVOID *BaseAddress, // goes into rdx
[in] ULONG_PTR ZeroBits, // goes into r8
[in, out] PSIZE_T RegionSize, // goes into r9
[in] ULONG AllocationType, // goes to stack after adjusting homing space for 4 arguments
[in] ULONG Protect // goes to stack below the 5th argument after adjusting homing space for 4 arguments
);
Преобразование этой логики в ассемблер будет выглядеть следующим образом:
Код:
section .text
global WorkCallback
WorkCallback:
mov rbx, rdx ; backing up the struct as we are going to stomp rdx
mov rax, [rbx] ; NtAllocateVirtualMemory
mov rcx, [rbx + 0x8] ; HANDLE ProcessHandle
mov rdx, [rbx + 0x10] ; PVOID *BaseAddress
xor r8, r8 ; ULONG_PTR ZeroBits
mov r9, [rbx + 0x18] ; PSIZE_T RegionSize
mov r10, [rbx + 0x20] ; ULONG Protect
mov [rsp+0x30], r10 ; stack pointer for 6th arg
mov r10, 0x3000 ; ULONG AllocationType
mov [rsp+0x28], r10 ; stack pointer for 5th arg
jmp rax
Чтобы объяснить приведенный выше код:
- Сначала мы резервируем наш указатель на структуру, находящуюся в регистре RDX, в регистр RBX. Мы делаем это потому, что при вызове NtAllocateVirtualMemory мы будем использовать регистр RDX в качестве второго аргумента.
- Мы перемещаем первые 8 байт из адреса в регистер RBX (struct NTALLOCATEVIRTUALMEMORY_ARGS, т.е. UINT_PTR pNtAllocateVirtualMemory) в регистр rax, куда мы перейдем позже после корректировки аргументов.
- Перемещаем второй набор из 8 байт (HANDLE hProcess) из структуры в RCX
- Третий набор из 8 байт, т.е. указатель на NULL-указатель (PVOID* адрес), хранящийся в структуре, перемещаем в RDX. Именно сюда будет записан наш выделенный адрес с помощью NtAllocateVirtualMemory
- Мы обнуляем регистр R8 для аргумента ULONG_PTR ZeroBits
- Мы перемещаем 6-й аргумент, т.е. последний аргумент, который должен идти внизу всех аргументов (ULONG Protect, т.е. разрешения PAGE) в R10, а затем перемещаем его на смещение 0x30от верхнего указателя стека.
- Указатель вершины стека = RSP \= адрес возврата TppWorkpExecuteCallback, который составляет 8 байт.
- Размер пространства наведения для 4 аргументов = 4x8 = 32 байта
- Пространство для 5-го аргумента = 8 байт
- Таким образом, 32+8 = 40 = 0x28 (это место, куда будет помещен второй последний 5-й аргумент)
- Таким образом, 32+8+8 = 48 = 0x30 (сюда попадет последний 6-й аргумент).
- Наконец, мы перемещаем значение 5-го аргумента (ULONG AllocationType), т.е. 0x3000 - MEM_COMMIT|MEM_RESERVE в регистр R10, а затем сдвигаем его на смещение 0x28 от RSP
Если собрать все вместе, вот как это выглядит перед переходом к NtAllocateVirtualMemory:
- Разобранный код показывает инструкции asm, которые мы написали. Текущий указатель инструкции находится сразу после корректировки стека и перед переходом к NtAllocateVirtualMemory Регистры показывают аргументы для NtAllocateVirtualMemory Дамп показывает структуру NTALLOCATEVIRTUALMEMORY_ARGS в памяти. Каждый 8-байтовый блок памяти является объектом, относящимся к содержимому структуры Стек показывает скорректированный стек для NtAllocateVirtualMemory
- Регистры показывают аргументы для NtAllocateVirtualMemory
- Дамп показывает структуру NTALLOCATEVIRTUALMEMORY_ARGS в памяти. Каждый 8-байтовый блок памяти является объектом, относящимся к содержимому структуры
- Стек показывает скорректированный стек для NtAllocateVirtualMemory
Быстрый взгляд на стек после выполнения NtAllocateVirtualMemory показывает правильный стек вызовов, который можно прекрасно размотать. Вы также можете увидеть, что вызов syscall для NtAllocateVirtualMemory вернул ноль, что означает, что вызов был успешным.
Стек снова чист, как хрусталь, без признаков чего-либо вредоносного. Обратите внимание, что это не stacking spooing, потому что в нашем случае стек разворачивается полностью без сбоев. Существует еще много подобных вызовов API, которые можно использовать для проксирования различных функций; я оставлю это на усмотрение читателей, чтобы они использовали свои собственные творческие способности. Полный код для этого можно найти в моем репозитории github.
Спасибо всем, Героям, которые прочитали этот материал. Моё дальнейшее развитие и другую интересную информацию из сферы ИБ вы можете найти на моём канале Дневник Безопасника
Последнее редактирование модератором: