Building a (slightly) better Melkor

Melkor is a C# POC written by FuzzySec to simulate a TTP employed by InvisiMole. The concept is that post-ex assemblies are loaded into a payload/implant and kept encrypted using DPAPI whilst at rest. They are decrypted on demand and executed in a separate AppDomain. The AppDomain is unloaded once execution completes and only the encrypted assembly remains in memory ready to be used again. As a fan of .NET tradecraft, I wanted to try it out. However, I found that the results were not as promising as I’d hoped and I was still able to find multiple instance of plaintext indictors in memory.

The test assembly does nothing more than pop a message box, but the string gives us something to search for as an indicator.

public static void DoTheThing()
{
    MessageBox.Show("Morgoth Bauglir is my name..");
}

To set the scene, when running Melkor with this assembly, 8 instances of the string “Morgoth Bauglir” remained in memory. The goal of the post is to reduce this count as much as possible. I will through instances that I was able to address and which ones still need a solution.

tl;dr – fuck the garbage collector.

Melkor starts off by reading the assembly from disk and immediately uses DPAPI to encrypt it.

// Encrypt module
//==============
Console.WriteLine("[>] Reading assembly as Byte[]");
Byte[] bMod = File.ReadAllBytes(@"C:\Tools\Melkor\DemoModule\bin\Release\DemoModule.dll");
Console.WriteLine("[>] DPAPI CryptProtectData -> assembly[]");
hMelkor.DPAPI_MODULE dpMod = hMelkor.dpapiEncryptModule(bMod, "Melkor", 0);
if (dpMod.pMod != IntPtr.Zero)
{
    Console.WriteLine("    |_ Success");
    Console.WriteLine("    |_ pCrypto : 0x" + String.Format("{0:X}", (dpMod.pMod).ToInt64()));
    Console.WriteLine("    |_ iSize   : " + dpMod.iModSize);
    bMod = null;
}
else
{
    Console.WriteLine("\n[!] Failed to DPAPI encrypt module..");
    return;
}

Console.WriteLine("\n[?] Press enter to continue..");
Console.ReadLine();
[>] Reading assembly as Byte[]
[>] DPAPI CryptProtectData -> assembly[]
    |_ Success
    |_ pCrypto : 0x22241BC8E30
    |_ iSize   : 4850

[?] Press enter to continue..

Straight off the bat, we can see two instances of “Morgoth Bauglir” in Process Hacker after the encryption has taken place.

Both of these are due to “oversights(?)” in Melkor. The first is that bMod = null; (line 12 above) removes the reference to the original byte[] but it does not de-allocate the underlying memory, at least not immediately. Dynamic memory allocations such as these are cleaned up by the CLR’s garbage collector (GC) when there are no more references pointing to it. The complication is that since GC runs are expensive, it doesn’t bother doing it right away and it can be an indeterminate length of time until it’s actually performed. An overarching lesson I learned with this project is that you cannot rely on the GC at all when you have these OPSEC concerns in mind.

I did play around with calling GC.Collect() and GC.WaitForPendingFinalizers() but that didn’t make a difference. I assume there are some other conditions that are preventing this being cleaned (e.g. code execution still in the same method). In this case, I believe a better approach is to clear the array manually, for which there are a couple of approaches.

One is to walk over the array and zero out each element.

for (var i = 0; i < bMod.Length; i++)
{
    bMod[i] = 0x00;
}

Another is to call the Array.Clear method.

Array.Clear(bMod, 0, bMod.Length);

Testing after this change confirms that we’re down to one result.

This second allocation is left over by the dpapiEncryptModule method.

{
    DPAPI_MODULE dpMod = new DPAPI_MODULE();

    DATA_BLOB oPlainText = makeBlob(bMod);
    DATA_BLOB oCipherText = new DATA_BLOB();
    DATA_BLOB oEntropy = makeBlob(bEntropy);

    Boolean bStatus = CryptProtectData(ref oPlainText, sModName, ref oEntropy, IntPtr.Zero,
        IntPtr.Zero, CRYPTPROTECT_LOCAL_MACHINE, ref oCipherText);

    if (bStatus)
    {
        dpMod.sModName = sModName;
        dpMod.iModVersion = iModVersion;
        dpMod.iModSize = oCipherText.cbData;
        dpMod.pMod = oCipherText.pbData;
    }

    return dpMod;
}

Amongst other arguments, CryptProtectData takes one DATA_BLOB structure containing the original plaintext content and another to hold the encrypted content. The structure looks like this:

internal struct DATA_BLOB
{
    public int cbData;
    public IntPtr pbData;
}

The oPlainText blob (line 4 above) is created using another method called makeBlob.

{
    DATA_BLOB oBlob = new DATA_BLOB();

    oBlob.pbData = Marshal.AllocHGlobal(bData.Length);
    oBlob.cbData = bData.Length;
    RtlZeroMemory(oBlob.pbData, bData.Length);
    Marshal.Copy(bData, 0, oBlob.pbData, bData.Length);

    return oBlob;
}

We can see that some new memory is being allocated using Marshal.AllocHGlobal (line 4 above) and the original byte[] is copied into it (line 7 above). This is unmanaged memory and therefore will never be handled by the GC. Instead, it must be freed manually using Marshal.FreeHGlobal which we can see is never called anywhere. A somewhat dirty workaround is to call Marshal.FreeHGlobal(oPlainText.pbData) in dpapiEncryptModule right before return dpMod.

We’re now down to zero results with the encrypted assembly in memory.

Melkor then moves onto the next step which is to decrypt the assembly and execute it in a new AppDomain.

// Create AppDomain & load module
//==============
Console.WriteLine("[>] DPAPI CryptUnprotectData -> assembly[] copy");
hMelkor.DPAPI_MODULE oMod = hMelkor.dpapiDecryptModule(dpMod);
if (oMod.iModSize != 0)
{
    Console.WriteLine("    |_ Success");
}
else
{
    Console.WriteLine("\n[!] Failed to DPAPI decrypt module..");
    return;
}

Console.WriteLine("[>] Create new AppDomain and invoke module through proxy..");
hMelkor.loadAppDomainModule("dothething", "Angband", oMod.bMod);

Console.WriteLine("\n[?] Press enter to continue..");
Console.ReadLine();

Before clearing the MessageBox, we can verify that it is indeed running inside a new AppDomain with the name “Angband”.

And instances of “Morgoth Bauglir” in memory has jumped to 6.

It’s expected that we’ll find these in memory whilst the assembly is executing, so let’s see what happens when we clear the box and allow Melkor to unload the AppDomain.

[>] Unloading AppDomain
[>] Freeing CryptUnprotectData

[?] Press enter to exit..

The AppDomain is gone.

But now we have 8(!) instances of “Morgoth Bauglir” in memory.

Some of these stem from similar issues as above, specifically with how data between the DPAPI_MODULE and DATA_BLOB structures is handled.

internal struct DPAPI_MODULE
{
    public String sModName;
    public int iModVersion;
    public int iModSize;
    public IntPtr pMod;
    public Byte[] bMod;
}

The dpapiDecryptModule initialises a new DPAPI_MODULE called oMod (line 2 below) and a new DATA_BLOB called oPlainText to hold the decrypted assembly (line 7 below).

{
    DPAPI_MODULE oMod = new DPAPI_MODULE();

    Byte[] bEncrypted = new Byte[oEncMod.iModSize];
    Marshal.Copy(oEncMod.pMod, bEncrypted, 0, oEncMod.iModSize);

    DATA_BLOB oPlainText = new DATA_BLOB();
    DATA_BLOB oCipherText = makeBlob(bEncrypted);
    DATA_BLOB oEntropy = makeBlob(bEntropy);

    String sDescription = String.Empty;
    Boolean bStatus = CryptUnprotectData(ref oCipherText, ref sDescription, ref oEntropy,
        IntPtr.Zero, IntPtr.Zero, 0, ref oPlainText);
    
    if (bStatus)
    {
        oMod.pMod = oPlainText.pbData;
        oMod.bMod = new Byte[oPlainText.cbData];
        Marshal.Copy(oPlainText.pbData, oMod.bMod, 0, oPlainText.cbData);
        oMod.iModSize = oPlainText.cbData;
        oMod.iModVersion = oEncMod.iModVersion;
    }

    return oMod;
}

If the decryption is successful it copies the unmanaged data pointer of oPlainText to oMod.pMod (line 17 above) but also makes an entire copy of the data in a new byte[] on oMod.bMod (lines 18/19 above). So that effectively means we now have one managed and one unmanaged copy of the same decrypted assembly. After the AppDomain has been unloaded, a method called hMelkor.freeMod(oMod) is executed which attempts to free the data in the DPAPI_MODULE structure. All this method does is call LocalFree(oMod.pMod) which I think has two problems. The first is that LocalFree free’s up the memory handle but doesn’t erase its content; the second is that that oMod.bMod is never cleared either.

Melkor is already using RtlZeroMemory in places, so we can use that to rework the method to look something like this:

public static void freeMod(DPAPI_MODULE oMod)
{
    RtlZeroMemory(oMod.pMod, oMod.iModSize);
    LocalFree(oMod.pMod);
    Array.Clear(oMod.bMod, 0, oMod.bMod.Length);
}

As a side note – there are multiple instances in the project of memory allocations that contain the encrypted data that are not cleared either. I’ve omitted them from the post since they didn’t have an impact on our plaintext string in memory. If this code got a full refactor, then they should be freed as well.

If we run the tool top to bottom now, we’re down to 4 results by the end.

These are coming from the loadAppDomainModule method and unfortunately, I haven’t found a way to remove them. I believe the issue is that when you have a reference to a new AppDomain in the current AppDomain, then new heap allocations are made to hold the information, including all the assemblies that are loaded within it.

For example:

// create a new AppDomain
AppDomain oDomain = AppDomain.CreateDomain(sAppDomain, null, null, null, false);

ShadowRunnerProxy pluginProxy = (ShadowRunnerProxy)oDomain.CreateInstanceAndUnwrap(
    typeof(ShadowRunnerProxy).Assembly.FullName, typeof(ShadowRunnerProxy).FullName);

// load & execute the assembly
pluginProxy.LoadAssembly(bMod, sMethod);

// unload the AppDomain
AppDomain.Unload(oDomain);

We require a reference to the AppDomain, oDomain because we need to pass it to AppDomain.Unload. The AppDomain class/type does not have any properties that tell you the base address or anything that would help in manually freeing that memory. If anybody has more experience with this and can suggest some workarounds, do please reach out.

Related posts

ANYSIZE_ARRAY in C#

There are multiple structures in Windows that contain fixed sized arrays. The instance...

SafeHandle vs IntPtr

C# is a popular language in both the commercial space (think ASP.NET Core, MVC,...

C# Source Generators

Introduction

C# Source Generators made their first appearance around the release of .NET 5 and...

.NET Startup Hooks

tl;dr

Since .NET Core 3, the dotnet runtime has provided a low-level hook that allows...

Latest posts

ANYSIZE_ARRAY in C#

There are multiple structures in Windows that contain fixed sized arrays. The instance...

SafeHandle vs IntPtr

C# is a popular language in both the commercial space (think ASP.NET Core, MVC,...

C# Source Generators

Introduction

C# Source Generators made their first appearance around the release of .NET 5 and...

.NET Startup Hooks

tl;dr

Since .NET Core 3, the dotnet runtime has provided a low-level hook that allows...