Difficulty: Beginner

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:

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 GenerationCLR VersionsKey InterfaceHeader
Legacy (v2).NET 2.0, 3.0, 3.5ICorRuntimeHostmscoree.h
Modern (v4).NET 4.0+ICLRMetaHost + ICLRRuntimeInfometahost.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

CLRCreateInstance
Get ICLRMetaHost
GetRuntime
Get ICLRRuntimeInfo
GetInterface
Get ICorRuntimeHost
Start()
Initialize CLR
CreateDomain
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)

Decrypt Module
Chaskey CTR
AMSI Bypass
Patch AmsiScanBuffer
Start CLR
ICLRMetaHost chain
Create Domain
Random name
Load_3
SAFEARRAY bytes
Invoke
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?

Donut uses _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?

Creating a new AppDomain provides isolation from the host process and allows Donut to unload the domain after execution, reducing forensic artifacts. The random domain name also avoids creating a recognizable IOC.

3. Why must AMSI be bypassed before calling Assembly::Load?

Starting with Windows 10, AMSI is integrated into the CLR. When 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.