Writing an Evasive .NET Shellcode Loader
The basics
Let's assume that you have the fanciest and stealthiest C2 shellcode that has ever existed. If it's running, every EDR is blind to it, and you can do whatever your heart desires. That's nice, but how do you get it running?
This is where a loader comes into play. Even if you have the best shellcode, your loader needs to carry it to execution just as stealthily as the commands executed by the shellcode. Otherwise, all the time you invested in the shellcode is wasted if your loader is detected by the EDR.
So, the most basic thing to do if you want to write a loader is to allocate some memory, copy the shellcode into said memory, mark it as executable, and call the memory address of the shellcode. After you compile your code, you'll get an executable file. Besides the pretty awful loading method (VirtualAlloc → memcpy → VirtualProtect → (void*)hMemory()), any competent EDR will start to scream and alert everyone because:
- Your executable is probably not signed → big red flag
- The file was never seen before anywhere (not even across EDR tenants; yes, EDR tenants most likely share this info internally, at least via file hashes) → small red flag
- And other compiler-related red flags, which will be the topic of another post
All these red flags increase the likelihood that your executable will be flagged at least as suspicious, if not malicious.
So, how do we solve this conundrum? Well, most obviously, we don't use a self-compiled EXE, not unless we find ourselves in the unlikely position of having a valid Extended Validation code signing certificate.
Okay, so we can't use an EXE, but we still need to run our code, so we need some kind of executable, and that's how we get to dynamically linked libraries (DLLs for short). Although we can run a DLL directly via rundll32.exe, it's not recommended. The more elegant way is to let the DLL be loaded by a (hopefully) trusted, signed, and clean EXE. And this is the idea behind DLL Sideloading.
We look for a trusted executable that tries to load a (non-existent) DLL, and we place our DLL at the expected path with our loader in it. Although EDRs do see that a specific function was executed from within the DLL, they tend to care less about it, especially if the execution originates from a high-reputation EXE.
.NET
Okay, so can we do the same trick (placing a DLL at a specific path) and execute code in the context of a .NET (aka managed) binary? Well, no, or at least not as easily as in the case of unmanaged binaries. The main reasons are strong-name and Authenticode signing. dotSec has a two-part blog post (Part 1 and Part 2) about how these work and how this can still be bypassed in specific circumstances.
But there is an easier solution, and it's called AppDomain Manager Hijacking. In the .NET world, developers can define so-called AppDomains. At a high level, these are isolated environments within a managed process, and the AppDomain Manager, as the name suggests, manages what is loaded and executed where.
The nice thing about the AppDomain Manager is that, if configured, it pretty much halts the execution of the original assembly until it's done initializing the environment. This means that we don't have to analyze which functions are called from which DLL, and we don't have to wait for them to be called to run our malicious code.
The other main benefit of AppDomain Managers is that we can configure them for any compiled and signed EXE without modifying it. The only real requirement is that our malicious DLL is strong-name signed, but that only verifies that the DLL was not tampered with, not whether the DLL originates from a known publisher.
To define a custom AppDomain manager, we create a new class that extends the AppDomainManager class and override its InitializeNewDomain method.
public sealed class AppDomainManagerLoader: AppDomainManager {
public override void InitializeNewDomain(AppDomainSetup appDomainInfo) {
// TODO your loader logic comes here
}
}
Why even bother with .NET if I can just write a native loader?
The main reason is that some initial access methods require a .NET application, such as ClickOnce. The other reason is that if we'd like to use .NET-based tools through the C2 implant, the .NET framework has to be loaded. Loading the Common Language Runtime (CLR) into a native process might trigger detection by the EDR, as this is not expected behavior for a normal native process.
How to configure AppDomain Managers
There are two main ways to configure an AppDomain Manager: either via a configuration file or via environment variables. Upon starting a .NET EXE, the system looks for a config file with the name <exe name without .exe>.config in the same directory. If we create this file with the following content, we can instruct the application to load our DLL and start initializing the AppDomain Manager.
<configuration>
<runtime>
<appDomainManagerType value="MyNamespace.TestAppDomainManager" />
<appDomainManagerAssembly value="MyAssembly, Version=1.0.0.0, Culture=neutral, PublicKeyToken=f1368f7b12a08d72" />
</runtime>
</configuration>
At this point we are basically done: if we never return from the initialization, the EXE will wait indefinitely, and we can do whatever we want within the context of the original EXE.
I should note that config-file-based hijacking might be flagged by some EDRs as suspicious activity.
One major drawback of AppDomain Manager hijacking is that both the config file and the malicious DLL need to reside in the same directory as the EXE, which restricts the possibility of using LOLBINs if the user executing the initial access payload does not have the necessary permissions to place files in installation directories. I'm currently researching whether it's possible to redirect the DLL search path and thus hijack already installed applications without permission to write to their installation folders.
Evasive .NET loader
Okay, so we now know how to theoretically execute malicious code in the context of a signed and trusted .NET EXE, but how do we actually do that?
One of the main benefits of .NET is that a lot of things are abstracted and taken care of by the framework. Unlike in unmanaged C, devs are not required to take care of, e.g., memory management. Well, this is a good thing if we want to develop a "normal" application, as we don't need to take care of low-level stuff, but it's something of an obstacle when it comes to malware development.
It should be noted that .NET does actually allow the use of native interfaces through Platform Invoke (short for P/Invoke). With P/Invoke, we can import a native DLL and use one of its exported functions.
using System;
using System.Runtime.InteropServices;
public partial class Program
{
// Import user32.dll (containing the function we need) and define
// the method corresponding to the native function.
[LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
private static partial int MessageBoxW(IntPtr hWnd, string lpText, string lpCaption, uint uType);
public static void Main(string[] args)
{
// Invoke the function as a regular managed method.
MessageBoxW(IntPtr.Zero, "Command-line message box", "Attention!", 0);
}
}
As bohops describes in their blog, the main issue with this approach is that this import is actually listed in the CLR metadata of the compiled assembly. This, in turn, means that we provide more information to the EDR or any analyst. Depending on what we import, the EDR might flag the assembly as suspicious or malicious during the static scan phase before we even have a chance to start our loader.
We can, however, circumvent this by dynamically resolving the native interface. The basic idea is that we define a .NET-compatible function that will act as a wrapper. If we stay with our previous example, the wrapper for MessageBoxW would look like this:
internal static Int32 MessageBoxW(IntPtr hWnd, String lpText, String lpCaption, UInt32 uType) {
Type[] paramTypes = { typeof(IntPtr), typeof(String), typeof(String), typeof(UInt32) };
Object[] args = { hWnd, lpText, lpCaption, uType };
object res = BuildFunction(typeof(Int32), "user32.dll", "MessageBoxW", args, paramTypes);
return (Int32)res;
}
Any other function we would want to use would have the same structure. The heavy lifting will be done in the BuildFunction function, which will look like this:
// source: https://bohops.com/2022/04/02/unmanaged-code-execution-with-net-dynamic-pinvoke/
public static object BuildFunction(Type type, string library, string method, Object[] args, Type[] paramTypes)
{
AssemblyName assemblyName = new AssemblyName("Temp01");
AssemblyBuilder assemblyBuilder = AppDomain.CurrentDomain.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Run);
ModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule("Temp02");
MethodBuilder methodBuilder = moduleBuilder.DefinePInvokeMethod(method, library, MethodAttributes.Public | MethodAttributes.Static | MethodAttributes.PinvokeImpl, CallingConventions.Standard, type, paramTypes, CallingConvention.Winapi, CharSet.Ansi);
methodBuilder.SetImplementationFlags(methodBuilder.GetMethodImplementationFlags() | MethodImplAttributes.PreserveSig);
moduleBuilder.CreateGlobalFunctions();
MethodInfo dynamicMethod = moduleBuilder.GetMethod(method);
object res = dynamicMethod.Invoke(null, args);
return res;
}
This function creates a new AppDomain, loads the specific DLL into this AppDomain, resolves the function with the provided parameter types, and calls the native function with the provided arguments.
One caveat I have encountered with this implementation is that it does not distinguish between functions that were already resolved and ones that need to be resolved. This means that if a native function is used multiple times during execution, this resolver will load the same DLL multiple times and create new AppDomains for each new instance, which is not efficient and generates more telemetry. So it is recommended to implement some form of caching, which, for brevity, will be left to the reader.
Even though detection of DefinePInvokeMethod should be relatively straightforward (just as bohops mentioned), we used this technique with great success in recent engagements. A future endeavor would be to integrate another obfuscation method called HInvoke, presented by dr4k0nia.
Conclusion
This post should provide a few basic building blocks to begin building evasive loaders in .NET using native interfaces. These building blocks alone won't be enough, though, because using "OPSEC unsafe" techniques, such as RWX memory regions, will probably result in detection.
Evading EDRs is a multi-layered process, where the goal is to minimize markers and thereby avoid reaching the threshold of being suspicious.
Want to learn more? Schedule a free consultation with our team.
Want to learn more about our Red Teaming & Pentesting services?
View Our Services