The modern red team is defined by its ability to compromise endpoints and take actions to complete objectives. To achieve the former, many teams implement their own custom command-and-control (C2) or use an open-source option. For the latter, there is a constant stream of post-exploitation tooling being released that takes advantage of various features in Windows, Active Directory and third-party applications. The execution mechanism for this tooling has, for the last several years, relied heavily on executing .NET assemblies in memory.
Despite being such a large part of the modern red team arsenal, tradecraft for executing .NET assemblies on a compromised endpoint has remained largely stagnant. In this blog post, we will discuss how red teams can bring their .NET execution harnesses into this decade.
Key highlights
- Operators can take control over many aspects of the CLR using “CLR customizations” when executing .NET assemblies in memory
- Taking over memory management for the CLR enables operators to control and track all allocations made by the CLR, and also provides an easy way to keep track of assemblies being loaded into the process
- Implementing a custom assembly loading manager enables a novel AMSI bypass using only “intended” functionality, with no byte patches or process hacking required
A brief history of executing assemblies
Not so long ago, many red teams relied on PowerShell for post-exploitation tooling. Cobalt Strike took a step to change that in 2018 by implementing the execute-assembly module. Execute-assembly would spawn a sacrificial process and inject a reflective DLL into it which would load the Common Language Runtime (CLR) and execute a .NET assembly provided by the operator.
This resulted in a lot of post-exploitation tooling being shifted over to .NET assemblies. After a while, defenders began to build detections for the “fork-and-run” behavior of execute-assembly, namely the reflective DLL injection. To get around this, Shawn Jones, also of the IBM Adversary Simulation team, developed the InlineExecute-Assembly Beacon Object File (BOF). This allowed operators to shift away from the “fork-and-run” behavior of execute-assembly and stay within the implant process. Since then, many C2 frameworks have adopted this behavior natively.
If you are not already familiar with how to host the CLR and execute .NET assemblies, I would recommend you read Shawn’s blog post linked above.
How to host the common language runtime
The execute-assembly technique takes advantage of a Windows feature known as “Unmanaged CLR Hosting”. The Common Language Runtime, or CLR, is the runtime environment for .NET. Users can write .NET assemblies in a variety of languages (C#, F#, etc.) which are compiled into an Intermediate Language (IL). The CLR is responsible for taking an assembly containing IL and executing it.
Traditionally, the execute-assembly technique has relied on the use of the deprecated ICorRuntimeHost
interface. The reason that offensive practitioners use this interface is that it allows for loading assemblies from an in-memory byte array, by creating an App Domain and using the Load_3
method. By loading from a byte array we avoid the problem of having to drop any code to the file system, which could be scanned by defensive solutions.
The ICorRuntimeHost
has since been replaced by the ICLRRuntimeHost
interface.
Figure 1: MSDN page for the ICLRRuntimeHost interface
The MSDN docs for the ICLRRuntimeHost
interface state that it adds the SetHostControl
method, but omits some methods provided by ICorRuntimeHost
. When Microsoft says “omission of some methods”, they mean all of the fun ones that let us load assemblies reflectively. In exchange, we get access to CLR Customizations through the SetHostControl
method.
Note: While we can’t use ICLRRuntimeHost
directly to load reflective assemblies, we can start the CLR using ICLRRuntimeHost
and then call GetInterface
to get an ICorRuntimeHost
interface. Then we can use the ICorRuntimeHost
interface to load reflective assemblies while also having our CLR Customizations enabled.
What are CLR customizations?
The SetHostControl
method allows us to provide our own implementation of the IHostControl
COM interface, which is how we can tell the CLR to use various customization features. CLR Customizations are a little talked-about feature which allow developers to take control over aspects of the CLR. Customizations work by using various “Manager” interfaces that we as the developer can implement, and our IHostControl
implementation will tell the CLR which managers we would like it to use. Anything we don’t implement will simply be handled by the CLR as it normally would. A list of supported managers is shown below.
Figure 2: Some of the interfaces supported for CLR customizations
I’ve used red boxes to highlight the two managers that will be covered in this blog post: IHostMemoryManager
and IHostAssemblyManager
. But first, we will implement our IHostControl
interface.
Implementing IHostControl
I wrote my initial proof-of-concept for CLR Customizations in C++, but I ultimately chose to re-implement everything in pure C, which is what we will look at in this post. I prefer implants written in C to avoid the bloat of C++, so I wanted this assembly execution harness in C as well. Implementing COM interfaces in C is a massive chore, but I hope that the following information will make it easier in the future. Below is how you need to define the IHostControl
interface, which I have named “MyHostControl”.
Figure 3: Header file implementing the IHostControl interface
To implement our COM interface, we must have the following components (shown above in this order):
- A typedef for a struct which contains the functions for our interface. The
QueryInterface
, AddRef
and Release
functions are boilerplate and will be in every COM interface. The two functions below, GetHostManager
and SetAppDomainManager
, are specific to this interface.
- A typedef for a struct that defines our actual interface, which has a Virtual Table (VTBL) and a Count.
- Definitions for the specific functions that we will implement separately. I’ve prefixed them with “MyHostControl_” as you will have to define
QueryInterface
/AddRef
/Release
for every COM interface.
- A const of the VTBL we defined earlier but populated with the functions we defined above.
Implementing the actual methods is a bit more straightforward, as shown below:
Figure 4: Implementing the QueryInterface, AddRef and Release methods
As I mentioned before, the QueryInterface
/AddRef
/Release
methods are boilerplate. The only thing you need to change to implement a different interface is the “xIID_IHostControl” value in the QueryInterface
method.
Figure 5: Implementing the GetHostManager method
Here we don’t actually need to implement the SetAppDomainManager
method and can simply return E_NOTIMPL, so long as we don’t try to call it later. The GetHostManager
method, which is the core of this interface, is really just a series of “if” statements where we check to see if the CLR is asking us for a manager that we care about. In the code above, I am checking if the provided IID is for the IHostMemoryManager
interface, and then creating a new memory manager pointing the ppObject argument to it.
Starting the CLR
Now that we’ve implemented our IHostControl
interface, we can call SetHostControl
and start the CLR. Below is a snippet of code for doing the normal CLR hosting stuff (CLRCreateInstance
, GetRuntime
, GetInterface
), and then calling SetHostControl
using our custom host control interface. Then we start the CLR.
Figure 6: Calling SetHostControl and starting the CLR
Taking over the CLR’s memory
Now that we know how to implement COM interfaces in C, implementing specific managers is more straightforward. The IHostMemoryManager
interface allows us to take control of the CLR’s memory management. Below is a list of all the functions we need to implement for IHostMemoryManager
.
Figure 7: List of methods for the IHostMemoryManager interface
You probably notice a few methods that enable some interesting behavior, namely the Virtual* methods (VirtualAlloc, VirtualProtect, VirtualQuery, VirtualFree) which are used to do the bulk of memory management on Windows. Below is a very barebones implementation of these methods.
Figure 8: Implementing VirtualAlloc, VirtualFree, VirtualQuery and VirtualProtect
Taking control over memory allocations enables the operator to get as fancy with it as they like. For example, you could perform the allocation API calls via indirect syscalls. You could also track all allocations made by the CLR and encrypt them when your implant goes to sleep. Note that encrypting CLR allocations is not very stable. In addition to the Virtual* methods, there is also the CreateMAlloc
method, which returns an implementation of the IHostMalloc
interface. This interface allows us to take control over all Heap allocations made by the CLR.
Tracking and clearing assembly artifacts
I mentioned above that trying to encrypt CLR memory allocations goes from “kind of unstable” to “very unstable”, depending on how exactly you do it. This is because if you encrypt or free a piece of memory that the CLR tries to reference later, the CLR will throw an error and crash your process. However, there is one exception to this that I have found: Allocations made during initial assembly loads.
When you load an assembly, whether that is in memory or from disk, the CLR will allocate space and map the assembly into memory. As far as I am aware, there was no good publicly known way to identify this memory region and wipe it, aside from searching process memory for byte patterns or allocations of the expected size. CLR customizations provide an easy mechanism to keep track of these allocations in the form of the AcquiredVirtualAddressSpace
method. This method is a notification callback that gets triggered whenever the CLR loads an assembly into the process, and the callback includes the address and size of the allocation as arguments. From my testing, this callback is only triggered when an assembly is loaded into the process, which provides us with a good way to keep track of assembly load allocations. For robustness, you can check the size or parse memory to ensure that it’s the assembly you are expecting. Below is an example of implementing this method. Since it is just a notification callback, you can do whatever you would like and then simply return S_OK.
Figure 9: Implementing the AcquiredVirtualAddressSpace method
Unlike the other allocations made by the CLR, I have not had any problems with encrypting or wiping this memory region after the assembly has finished executing. You may run into problems if you try to execute the same assembly in the same App Domain again as the CLR might try to use the cached assembly which is now invalid. Most implementations of execute-assembly will create a new App Domain and then destroy it after execution, so just be sure to test with your implementation.
This notification functionality also has a minor defensive application. Typically, defensive products will use Event Tracing for Windows (ETW) to keep track of assembly loads in the CLR, but this provides another way to be notified if an assembly has been loaded into the process. Since the memory address and size are included, it would be trivial for a defensive product to perform a memory scan on that region.
Managing assembly loads
The other managers that we will look at are IHostAssemblyManager
and IHostAssemblyStore
. IHostAssemblyManager
is responsible for two things: giving the CLR a list of assemblies it should handle loading (instead of us, as the CLR host), and returning an IHostAssemblyStore
interface to the CLR. IHostAssemblyStore
has two methods: ProvideAssembly
and ProvideModule
.
ProvideAssembly
will be called whenever the CLR is asked to load an assembly that is not in the list of assemblies that the CLR is responsible for loading (returned by IHostAssemblyManager
). The CLR calls ProvideAssembly
and passes in the identity string for an assembly, and ProvideAssembly
is responsible for returning the bytes of the assembly. You’ve likely seen an identity string before, it looks something like: “Seatbelt, Version=0.0.0.0, PublicKeyToken=null, Culture=neutral
”.
Once ProvideAssembly
has resolved the assembly, the assembly content is returned by setting a pointer which is provided as an argument. I’ve highlighted the relevant argument, ppStmAssemblyImage
, in the screenshot below.
Figure 10: Arguments for the ProvideAssembly method of the IHostAssemblyStore interface
The assembly is returned by setting the pointer to the address of an in-memory IStream. Typically the CLR would try to locate the assembly by following its directory search order on disk, similar to loading a DLL in a normal Windows process. But since we can provide our own implementation, we can take a request for an assembly that would typically be loaded from disk and instead provide an assembly that we have in memory. The assembly bytes do need to be in an IStream, which can be accomplished using the SHCreateMemStream function that takes in a byte array and returns an IStream.
You may be wondering why this matters if it’s simply another way to load assemblies in memory. What about the Anti-Malware Scan Interface (AMSI)?
Bypassing AMSI
AMSI is responsible for scanning any assemblies that are loaded reflectively for malicious content. Windows Defender uses AMSI, and AMSI also allows other EDRs to hook in and scan the contents of assemblies loaded in memory. Some tend to sneer at AMSI since it can be bypassed, but I feel that for something that is installed in Windows by default, AMSI is a fairly effective security feature. At a minimum, it will catch a lot of stock malicious .NET tooling (such as Seatbelt) being executed in memory. There is a rich cat-and-mouse history of bypassing AMSI among red teamers, but a lot of AMSI bypasses rely on patching the bytes for key functions (such as AmsiScanBuffer) so that they either fail to execute or return a “good” value. Traditional AMSI bypasses are messy as they leave Copy-on-Write bytes in the .text section of AMSI.dll’s memory, which are a dead giveaway for any defenders looking at a suspicious process. More sophisticated AMSI bypasses such as hardware breakpoint hooks also have other IOCs associated with them, like inspecting the thread context to look for debug register usage.
By implementing our own version of the ProvideAssembly
method, we can circumvent AMSI entirely. Traditionally, the Load_3
method is used to load assemblies from a byte array and Load_3
is instrumented by AMSI. But did you know that there are other seldom-used Load_* functions?
Figure 11: The Load family of methods
Load_2
takes an assembly identity string as an argument instead of a byte array like Load_3
. Typically, this would mean that the assembly has to be on disk somewhere that the CLR can find, but we know that when the CLR is asked to load an assembly by identity it will ask our ProvideAssembly
implementation to provide that assembly. We also know that we can return an in-memory byte array (in an IStream) from ProvideAssembly
. This means that we can call Load_2
and provide the CLR with an in-memory assembly that it will load.
Now the important part: because we’re calling Load_2
, the CLR believes that we’re loading our assembly from disk and AMSI does not scan our assembly bytes. In fact, AMSI never even gets loaded into the process.
Below is an example of executing Seatbelt via a call to Load_2
without AMSI being loaded into the process.
Figure 12: Executing Seatbelt and bypassing AMSI
Figure 13: A process module listing with no AMSI.dll loaded
According to a CLR Inside Out article published in the August 2006 edition of the MSDN magazine, Microsoft themselves have used a similar technique to have SQL Server 2005 load .NET assemblies from the database rather than from disk. The ability to store and execute .NET assemblies from a SQL database is a personal favorite lateral movement technique and can be done easily using SQLRecon, another X-Force Red tool written by Sanjiv Kawa.
Proof-of-concept and further operationalization
I am releasing a proof-of-concept for this technique that you can find on GitHub here. This proof-of-concept shows how to implement the IHostControl
, IHostMemoryManager
, IHostMalloc
, IHostAssemblyStore
and IHostAssemblyManager
COM interfaces. It calls SetHostControl
and starts the CLR using the ICLRRuntimeHost
interface.
The memory manager implementation simply calls the correct Windows APIs (ex: VirtualAlloc) but does include an example of tracking all memory allocations made by the CLR. It also includes an example of wiping the assembly load artifacts provided by the AcquiredVirtualAddressSpace
callback discussed earlier.
There is also a full proof-of-concept for the AMSI bypass. There is a caveat with this bypass if you try to implement it into your own tooling: The assembly identity that you attempt to load using the Load_2
method must match the identity of the assembly you ultimately return to the CLR. For example, if you call Load_2
with an argument of “Seatbelt, Version=0.0.0.0, PublicKeyToken=null, Culture=neutral”, then the assembly that you are ultimately trying to run must also have this identity. You cannot attempt to load mscorlib but instead return Seatbelt, as the CLR will check this and throw an error. Be mindful of what assembly names you are attempting to load, but if you are still trying to reflectively load an assembly named Seatbelt in whatever year you are reading this then I would suggest you close this blog post and pursue a more fulfilling activity.
In the proof-of-concept, I use the GetBindingIdentityFromStream
method of the ICLRAssemblyIdentityManager
interface to get the identity string for the assembly that is going to be executed. You could also shift this away from the implant and get the assembly identity on the client or teamserver, and then pass the identity string as an argument to your implant.
Defensive considerations
While this is a novel AMSI bypass, it is ultimately just that: an AMSI bypass. Defensive products should be utilizing defense-in-depth strategies and not simply relying on AMSI to detect malicious assemblies. Any assemblies loaded using this technique will also generate the same Event Tracing for Windows (ETW) events as any other in-memory assembly. Malicious assemblies can be detected via memory scanning, as we have seen several more advanced EDR platforms doing. Many post-exploitation tools also have their own unique IOCs.
As mentioned above there, are also some defensive applications of this research. The AcquiredVirtualAddressSpace
callback provides another method of being notified when assemblies are loaded into the process. If a defender were to implement the IHostAssemblyStore
interface then they would be inserted into the assembly loading chain and have the ability to block assembly loads entirely or modify the bytes of an assembly before it is loaded into the process. I’ll call my shot here and say that I think it’s highly likely there will be future developments in this area.
Why release this now?
I’d like to touch on our timeline with this research, and why we are publishing it now. I performed all of this research in June of 2023 and we have kept it internal since then, although it has been submitted to several conference CFPs. At the time that this research was performed, I scoured search engines for anything similar, initially looking for reference material and later seeking to confirm if I was the first to identify this behavior. Since then, I’ve performed periodic searches to see if anyone had published any similar work. At the beginning of January 2025, I found this piece: Using CLR Hosting to Evade AMSI, by Marcos González Hermida in the “NTT Data” magazine.
This piece disclosed this same bypass, and according to the magazine it was initially published in June of 2024 (the supplementary linked above is from July 2024). It’s an excellently written piece and I would recommend that you read it. The only notes I would make is that the author concludes that assemblies must be signed to be executed, and in their proof-of-concept, they use the GetType_2
method to manually obtain the Main method of the assembly they load (Rubeus, specifically). I have had no problem loading unsigned assemblies using this technique, and you can use the same Entrypoint
method to obtain the entry point of the loaded assembly that is used in many implementations of execute-assembly, without having to know the namespace/class names.
With the publication of Marcos’ piece and proof-of-concept, this AMSI bypass could now be considered public, so we decided it was time to publish our research along with a proof-of-concept of what we discovered.
Wrapping up
In this post, we looked at how you can use CLR customizations to improve your OPSEC while running .NET tooling in memory. Additionally, we demonstrated a full AMSI bypass utilizing these lesser-known CLR features. The use of .NET tooling will remain effective for offensive practitioners and threat actors. For this reason, it is critical that defenders understand how the CLR works and build defense-in-depth strategies to detect post-exploitation tooling.
Acknowledgments
Thank you Brett and Valentina for peer-reviewing this research!
- Brett Hawkins (@h4wkst3r)
- Valentina Palmiotti (@chompie1337)
Related works
Dealing with Failure: Failure Escalation Policy in CLR Hosts – This is the only real example I could find of offensive tradecraft using CLR customizations when I was initially doing this research.
Hosted Pumpkin – A GitHub repository containing a proof-of-concept for implementing several CLR customizations.
Shellcode: Loading .NET Assemblies From Memory – Donut was a great deal of help in wrangling all of the relevant data structures and definitions in C.
Customizing the Microsoft .NET Framework Common Language Runtime by Steven Pratschner – This is the definitive text on CLR Customizations. Simply a must-read if you have any interest in this area.
Senior Managing Security Consultant, Adversary Services, IBM X-Force Red