Привет, Хабр! Меня зовут Миша, я работаю в МТС RED в команде тестирования на проникновение на позиции эксперта. В ходе пентестов очень часто приходится бороться с антивирусами. Увы, это может отнимать много времени, что негативно сказывается на результатах проекта. Тем не менее есть парочка крутых трюков, которые позволят на время забыть про антивирус на хосте, и один из них - выполнение полезной нагрузки в памяти. Ни для кого не секрет, что во время пентестов атакующим приходится использовать готовые инструменты, будь то нагрузка для Cobalt Strike, серверная часть от поднимаемого прокси-сервера или даже дампилка процесса lsass.exe. Что объединяет все эти файлы? То, что все они давным-давно известны антивирусам, и любой из них  не оставит без внимания факт появления вредоноса на диске.  Заметили ключевой момент? Факт появления вредоноса на диске. Неужели если мы сможем научиться выполнять пейлоад в оперативной памяти, то пройдём ниже радаров антивирусов? Давайте разберёмся с техниками выполнения файлов полностью в памяти и увидим, насколько жизнь атакующих станет проще, если они научатся работать, не затрагивая диск.  Не настраивайтесь на хардкор, я постараюсь рассказать всё простым и понятным языком.  Выполнение в памяти — абсолютно нормальное поведение. Я бы даже сказал, что всё только так и выполняется. По сути диск — лишь плацдарм, склад, с которого тянутся нужные программы, а затем загрузчик проецирует их в памяти и вызывает точку входа программы. Ничто не мешает нам собственноручно разместить байты данных в памяти, а затем заставить систему их выполнить.  Итак, предлагаю убедиться в том, что диск нам как таковой не нужен — всё успешно работает и без него, полностью в оперативной памяти. Пусть у нас будет файл example.exe, который сначала есть на диске, а потом его не станет: он пропадёт и останется лишь в ОЗУ. Такая техника называется Self-Deletion. Казалось бы, можно запустить пейлоад, а в нём предусмотреть вызов функции DeleteFIle(), но не тут-то было. При попытке удаления самого себя мы получим ошибку 0x5 ERROR_ACCESS_DENIED.  Тем не менее мы можем воспользоваться особенностями файловой системы NTFS, используемой в Windows. В ней существуют так называемые потоки данных, основным можно считать поток $DATA. Если пропадёт этот поток, то файл исчезнет, его невозможно будет прочитать.  К сожалению, поток удалить нельзя, но его можно переименовать, что так же приведёт к невозможности чтения содержимого файла и, как следствие, невозможности его повторного считывания и выполнения. Не будем особо углубляться в технические детали. Отмечу лишь, что переименование потока данных будет осуществляться с помощью функции SetFileInformationByHandle() с передачей в качестве FileInformationClass значения FileRenameInfo, а затем FileDispositionInfo.   Как мы видим, процесс успешно создан и продолжает свою работу, даже когда с диска система уже не в состоянии что-либо прочитать. Это доказывает тот факт, что файл считывается загрузчиком, помещается в оперативную память, а затем идёт его выполнение.  У некоторых языков есть встроенный функционал для выполнения определённого кода в памяти. Например, у C# есть неймспейс System.Reflection, а в нём класс Assembly с методом Load, который можно использовать для помещения и последующего выполнения C# сборки в памяти. Прототип следующий:  Функция принимает один-единственный параметр — rawAssembly. Он представляет собой массив байтов сборки, которую требуется поместить в память. Предлагаю рассмотреть файл Rubeus.exe — инструмент отлично подходит для демонстрации, ведь он написан на C#.  Для считывания байтов будем использовать File.ReadAllBytes, после чего будем передавать байты в описанную выше функцию и вызывать её точку входа.  Таким образом, мы можем на машине атакующего считать все байты полезной нагрузки, а затем на машине атакуемого вызвать метод 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 строку и функционал по её декодированию:  Причём не обязательно каждый раз генерировать новую сборку, ведь у нас есть возможность вызова дотнетовских методов из Powershell. В частности, можно обратиться к нужному нам System.Reflection, а из него вызывать метод Assembly.Load(), что позволит с таким же успехом загрузить сборку и обратиться к ней.  Синтаксис прост:  После чего нужно лишь выбрать желаемый для вызова метод, используя следующий синтаксис:  В случае с запуском через Powershell все байты сборки, передаваемой в метод Assembly.Load(), перед загрузкой окажутся в AMSI, поэтому нужно предварительно запатчить AMSI, чтобы он не ругался на наш загружаемый пейлоад.  Причём далеко не каждая сборка сможет успешно загрузиться подобным образом. Следует убедиться, что в проекте используется Net Framework, а не Net Core, так как Core не получится грузить в память. Вот статья, которой можно руководствоваться при изменении проекта с Core на Net Framework. Выбрать нужный фреймворк тоже можно непосредственно при создании проекта в Visual Studio:  В ходе исследования этого метода подгрузки сборок оказалось, что иногда у Powershell не получается обнаружить наличие сборки в памяти, поэтому придётся собственноручно вычленять и вызывать нужный метод:  В C# присутствует ещё один интересный механизм, позволяющий компилировать сборки буквально на ходу из представленного исходного кода. Причём, как я узнал позже, этот функционал появился относительно недавно, лишь в 2021 году.  Итак, сначала исходный код требуется подготовить с использованием CSharpSyntaxTree.ParseText(). В дальнейшем он должен храниться в виде экземпляра класса SyntaxTree.  Далее нужно добавить опции компиляции (у нас указывается, что это будет консольное приложение):  Теперь подготовим сборку, которая будет выполняться в памяти. Сначала создаём переменную, которая будет олицетворять сборку, для этого используется функция CSharpCompilation.Create(). Первым параметром указываем имя сборки, а последним — необходимые опции компилятора. В нашем случае генерируется рандомное имя.  Теперь у нас есть объект сборки, добавляем в неё исходный код, вызывая метод AddSyntaxTrees():  Внутри нашей сборки есть зависимости от других сборок. Например, для того же вывода на консоль требуется наличие метода System.Console.Write(), а откуда его возьмёт компилятор? Поэтому теперь в сборку следует добавить зависимости от других сборок. Они чаще всего представлены в виде .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. Наконец вся магия исполнения в памяти заключается в использовании экземпляра класса MemoryStream, который позволяет работать с данными в памяти. Этот экземпляр мы передаём в метод compilation.Emit() (используется для компиляции сборки), что приводит к помещению скомпилированной сборки в память.  Затем не составит труда извлечь сборку из памяти и вызвать метод из неё.   Таким образом, мы можем запускать практически любой удобный для нас код в памяти. Единственная проблема — исходники будут в явном виде находиться в программе, что не есть хорошо, конечно. Но тут можно использовать какие-нибудь криптографические либо кодировочные функции для сокрытия исходного кода.  Обратите внимание, что для запуска кода требуется добавить пакет Microsoft.CodeAnalysis.CSharp:  Дотнетовские сборки мы выполнять научились, но что, если программа была написана на С++? В этом случае она исполняется вне платформы CLR и будет считаться неуправляемым кодом. Как следствие, выполнить её в памяти через описанные выше методы не получится.  Точку ставить рано, ведь существуют шеллкоды. Что, если мы сгенерируем шеллкод от существующей программы на С++, затем засунем этот шеллкод в С# проект, в котором реализуем логику по инжекту этого шеллкода в адресное пространство текущего процесса? В таком случае на выходе у нас будет полноценная сборка, которая загружается с использованием System.Reflection.Assembly.Load() и выполняет наш шеллкод. Получается такая матрёшка из четырёх кукол: вызов Assembly.Load() — первая кукла, загружаемая сборка — вторая, шеллкод в сборке — третья, и, наконец, шеллкод представляет собой нашу С++ программу — четвёртая.  Итак, сначала предлагаю подготовить программу, которая будет осуществлять запуск нашего шеллкода. Здесь будем использовать стандартный шеллкод-раннер с помощью GetDelegateForFunctionPointer():  Теперь конвертируем байты этой сборки по описанному выше алгоритму в base64 строку и запускаем через System.Reflection.Assembly:  Отлично! Запуск тестового шеллкода работает. Пора переходить к генерации непосредственно самого шеллкода. Сначала определимся с программой. Предлагаю написать что-то более-менее серьёзное, чтобы проверить теорию наверняка. Используем графику, различные API-вызовы, циклы, коллбэки и прочую жуть:  Затем компилируем, после чего нужно перегнать программу в шеллкод. Для этого есть множество готовых инструментов:  https://github.com/TheWover/donut — стандартная версия https://github.com/S4ntiagoP/donut/tree/syscalls — donut с сисколлами https://github.com/hasherezade/pe_to_shellcode Можно даже использовать Visual Studio для генерации шеллкода, об этом подробно написано в этой статье. Я человек простой, поэтому предлагаю использовать стандартный donut:  Затем перегоняем из .bin формата в шестнадцатеричный шеллкод, который можно будет вставить в программу:  В файле будет представлен шеллкод нашей программы:  Добавляем шеллкод в шеллкод-раннер и проверяем, что всё работает:  Остаётся лишь получить байты сборки и запустить эту сборку через System.Reflection.Assembly:  И получаем успешное выполнение сборки с шеллкодом:  Благодаря этому способу запуска шеллкода антивирус не в состоянии обнаружить такой способ инъекции:  Существует метод запуска дотнетовских сборок через конвертацию в JScript, для этого используется следующий инструмент: https://github.com/tyranid/DotNetToJScript. Первым делом качаем проект по ссылке выше, открываем в студии, идём в Solution Explorer → тыкаем на TestClass.cs в проекте ExampleAssembly. Выбираем компилировать как .dll.  Затем наш код должен быть вставлен в классе TestClass(), например, следующий код выводит месседж-бокс:  После успешной компиляции в формате .dll используем скачанную выше тулзу для конвертации в js:  Полученный .js файл можно смело запускать, что приведёт к выполнению кода из TestClass(), а именно — появлению MessageBox.  Фиберы — это одна из единиц выполнения кода, как процесс или поток. Фибер работает внутри конкретного потока. То есть выстраивается иерархия процесс → поток → фибер. Внутри потока может быть несколько фиберов. Причём фиберы управляются и контролируются самим приложением, а не операционной системой. Благодаря фиберам можно выстраивать более гибкие механизмы синхронизации, потому что они имеют собственный стек и регистры. Фиберы удобно использовать для задач сокрытия исполнения кода, так как выполнение кода внутри фиберов отследить намного сложнее, чем выполнение кода внутри потока. Самое интересное заключается в том, что стек фибера, как только фибер завершит свою работу, будет очищен. В результате чего антивирусному ПО будет сложнее обнаружить вредоносную активность в нашей программе.  Если же фибер внутри себя вызывает другой фибер, то стек очищен не будет. Будет произведено переключение стека и значений регистров на те, которые должны быть у фибера, на который переключились. Например, если в основном потоке значение регистра EAX 0x00, у фибера 1 оно равно 0x01, а у фибера 2 0x02, то, при переключении основного потока на фибер 1 значение регистра EAX станет равно 0x01, а при переключении из фибера 1 на фибер 2 оно станет равно 0x02. После завершения работы фибера 2 примет значение фибера 1 и т. д.  В идеале для сокрытия пейлоада от АВ следует разместить его где-то в файле — например, в PE, в соседней DLL библиотеке или где-то ещё. Затем запустить кучу потоков, в них кучу фиберов, а в каком-то из фиберов — полезную нагрузку.  Фиберы поддерживаются как в C#, так и в C++. Для разнообразия предлагаю этот PoC написать на C++. Итак, основная функция для работы с фиберами — CreateFiber():  dwStackSize — начальный размер стека LPFIBER_START_ROUTINE — коллбэк-функция, которая будет считаться главной функцией фибера. Она вызывается при старте фибера lpParameter — некоторые дополнительные данные, которые мы хотим передать в фибер После создания фибера его запустить можно с помощью SwitchToFiber(). Обратите внимание, что нельзя напрямую вызывать эту функцию из потока — не произойдёт перехода потока управления. Поэтому требуется предварительно конвертировать текущий поток в фибер с помощью ConvertThreadToFiber().  Фиберы отлично подходят для исполнения наших пейлоадов в памяти по причине их достаточно хорошей скрытности. Предлагаю начать писать простенький PoC, в котором будет десять потоков и десять фиберов, но лишь в одном из фиберов будет осуществлен запуск нашего шеллкода.  Для синхронизации предлагаю использовать мьютекс. Создадим ещё в начале нашей программы мьютекс, а затем дёрнем его перед запуском шеллкода, чтобы предотвратить его повторные запуски.   Вам нужно лишь заменить шеллкод на шеллкод Rubeus. Благодаря такому серьёзному скрытию кода мы вновь успешно исполняем его в памяти и остаёмся вне поля зрения антивируса:  Существует целый класс программ, так называемых Reflective Loader's, которые позволяют загружать код в память. Рефлективная загрузка кода в память основывается на том, что разработчик собственноручно создаёт алгоритм по занесению PE-файла в память — так же, как это делает и сам Windows. Либо хотя бы на уровне, чтобы пейлоад мог запуститься.  На Github достаточно много готовых PoC, выделю самые интересные:  Invoke-ReflectivePEInjection — повершелловский вариант RunPE — подходит для запуска как управляемого, так и неуправляемого кода FilelessPELoader — одна из самых толковых реализаций. Тянет пейлоад с удалённого сервера  Причём можно отдельно выделить класс программ, служащих для рефлективного внедрения DLL: post/windows/manage/reflective_dll_inject — модуль MSF ReflectiveDllInjection Тем не менее иногда все эти специальные лоадеры бесполезны. В большинстве случаев на пентесте достаточно перегнать программу в шеллкод, а затем заставить систему его как-нибудь выполнить. Причём если просто отойти от проторенной дороги и использовать ранее неизвестный метод запуска шеллкода, с большой вероятностью получится обойти антивирус.  Например, можно поискать любые функции, принимающие в качестве одного из параметров коллбэк. В Windows присутствует множество GUI-функций и GUI-приложений, которые принимают коллбек. Скажем, функция PdhBrowseCounters() может использоваться для отображения специального диалогового окна, в котором можно выбрать интересующие нас счетчики производительности для программы монитора ресурсов системы. Функция принимает структуру PDH_BROWSE_DLG_CONFIG, одним из элементов которой является pCallback.  Проблема лишь в том, что этот коллбэк вызывается только после того, как пользователь выберет нужные счётчики производительности. Опять же, мы можем выбрать эти счётчики за пользователя, а затем, используя SendMessage() сымитировать отправку сообщения о выборе счетчиков нужному окну.   Или пусть это будет функция PssCaptureSnapshot(), которая позволяет создавать различные снапшоты процесса. После чего для получения информации о снапшоте можно пробежать по нему с помощью PssWalkMarkerCreate(), которому требуется первым параметром передать структуру PSS_ALLOCATOR, внутри которой и указываются коллбэки. Сами эти коллбэки нужны для кастомной реализации функций по выделению и освобождению памяти при работе системы со снепшотом, но ничего нам не помешает указать там наш шеллкод:  Как видим, полёт фантазии может быть любым, он не ограничен никем и ничем. Самое главное — не бояться экспериментировать и творить.  Подытоживая, можно сделать вывод, что методы исполнения в памяти обычно сводятся либо к использованию особенностей языка программирования, функционал которого позволяет осуществлять операции без взаимодействия с диском, либо же к генерации шеллкода из исполняемой программы. С другой стороны, наличие шеллкода в явном виде — плохая практика, поэтому нужно маскировать его всеми доступными способами, но об этом поговорим в следующий раз.