In-Memory .NET: Where the CLR’s Telemetry Actually Lives

Read time8 min
PublishedJan 15, 2026

Table of Contents

Every few months I sit through a red team debrief where someone claims execute-assembly is invisible because it never touches disk, or that flipping one environment variable turns the CLR deaf, or that running on .NET Core sidesteps AMSI. I have spent enough time in the runtime source and in ETW traces to know most of that is stale folklore. This post is about where the telemetry for in-memory .NET actually lives, what a defender can collect today, and the part that gets skipped: which of those signals survive an attacker who knows they are being watched.

The technique is simple to state. Cobalt Strike’s execute-assembly, and the many descendants that followed it, load a managed assembly (Rubeus, SharpHound, Seatbelt) straight from a byte array with System.Reflection.Assembly.Load(byte[]) or AssemblyLoadContext.LoadFromStream. Nothing is written to disk. The interesting question is not how to do that. It is what the platform records when you do.

The folklore, stated plainly

Three claims come up again and again, from both sides of the fence: Sysmon will catch the DLL load anyway; set COMPlus_ETWEnabled=0 and the CLR stops emitting; in-memory execution on .NET Core dodges AMSI. The first is structurally impossible for a managed load. The second is true only on .NET Framework and has been dead code on modern .NET for years. The third has been false since .NET Core 3.0. Let me take them in order.

Why Sysmon is the wrong tool for a managed load

This is not a config gap you can tune away. Sysmon Event ID 7 (Image Load) fires when the PE image loader maps a file-backed module, via the kernel’s PsSetLoadImageNotifyRoutine callback. A byte-array assembly never goes through that path. There is no file, no section object, no image-load notification, so there is no Event ID 7 for the managed assembly. What you can see is the CLR host itself loading:

				
					mscoree.dll / mscoreei.dll  ->  clr.dll (.NET Framework) or coreclr.dll (.NET 5+)
                            ->  managed assembly   (produces NO Event ID 7)
				
			

Two problems with relying even on that. If the target process already has the CLR resident (any normal .NET host, or a second in-memory run), the host-DLL load has already happened and the signal is gone. And Event ID 7 is disabled by default, is very high volume, and needs explicit configuration. SwiftOnSecurity’s widely deployed sysmon-config ships it with an effectively empty include, so it is off in practice. Olaf Hartong’s sysmon-modular does match the Framework host DLLs by OriginalFileName (clr.dll, mscoree.dll), but it has zero coverage for coreclr.dll or System.Private.CoreLib.dll, so it is blind to every .NET 5+ host. Those are real, current gaps you can check in the repositories today.

The deeper reason Sysmon cannot help is architectural. Sysmon subscribes to exactly one ETW provider, the DNS client, which is where Event ID 22 comes from. Everything else it reports comes from its own kernel driver callbacks. It does not subscribe to Microsoft-Windows-DotNETRuntime at all, so it cannot see CLR ETW events by design. The closest partial substitute is Event ID 10 (ProcessAccess): when a call stack references a reflectively loaded module, the CallTrace contains UNKNOWN frames, which is what NVISO’s sysmon_in_memory_assembly_execution.yml Sigma rule keys on. Useful, but indirect.

Where the telemetry actually lives

The real signal is the CLR’s own ETW providers. The same GUIDs are registered on .NET Framework and on .NET Core / 5+, so a single ETW session catches both worlds:

				
					Microsoft-Windows-DotNETRuntime         {e13c0d23-ccbc-4e12-931b-d9cc2eee27e4}
Microsoft-Windows-DotNETRuntimeRundown  {A669021C-C450-4609-A035-5AF59AF4DF18}
Microsoft-Windows-DotNETRuntimePrivate  {763FD754-7086-4dfe-95EB-C01A46FAF4CA}
				
			

On Framework, the Loader keyword gives you AssemblyLoad_V1 (event 154), ModuleLoad_V2 (152) and AppDomainLoad_V1 (156). The modern signal I care about is newer. The AssemblyLoaderKeyword (mask 0x4) arrived in .NET 5 and adds AssemblyLoadStart (event 290) and AssemblyLoadStop (291). These carry AssemblyPath, AssemblyLoadContext and RequestingAssembly. For a byte-array load there is no backing file, so AssemblyPath comes through empty. An empty AssemblyPath on AssemblyLoadStart is a direct indicator of an in-memory load:

				
					Provider == Microsoft-Windows-DotNETRuntime
EventID  == 290                    // AssemblyLoadStart
AssemblyPath == ""                  // no backing file  ->  byte[] load
AssemblyLoadContext != "Default"    // strong second signal
				
			

A non-Default AssemblyLoadContext is the strong secondary tell, because a lot of loaders spin up their own context. .NET Framework has no equivalent (there is no keyword at 0x4), so on Framework you fall back to the older Loader events and the empty ModuleILPath / ModuleNativePath on ModuleLoad.

One correction worth making, because I see it repeated: a byte-array assembly is not a “dynamic” assembly. The Dynamic flags (AssemblyFlags 0x2, ModuleFlags 0x4) are set only for Reflection.Emit. The discriminator for an in-memory load is the empty path field, not a dynamic flag. If you filter on the dynamic flag you will miss the technique entirely.

Two corrections from the runtime source

The dead COMPlus_ETWEnabled knob

On .NET Framework, COMPlus_ETWEnabled=0 really does suppress the CLR providers. That knob was removed from CoreCLR between 2.2 and 3.0. On .NET 5 and later it does nothing at all. In the runtime, ETW::CEtwTracer::Register() registers the providers unconditionally, with no config gate in front of it. You can read that in src/coreclr/vm/eventtrace.cpp, with the absent config values in src/coreclr/inc/clrconfigvalues.h. The modern variable people reach for, DOTNET_EnableDiagnostics=0, disables the EventPipe / diagnostic IPC channel, but it does not unregister the ETW providers, so an ETW sensor still receives events. The environment-variable trick that “everyone knows” only works on the framework nobody is deploying new tooling on.

AMSI on Core is alive

The claim that in-memory .NET on Core avoids AMSI has been false since .NET Core 3.0. Both Assembly.Load(byte[]) and AssemblyLoadContext.LoadFromStream are scanned. The scan lives in src/coreclr/vm/amsi.cpp (Amsi::IsBlockedByAmsiScan), called from the FlatImageLayout path. There are honest caveats to state: it is Windows-only, it fails open (if amsi.dll will not load the scan is simply skipped), the AMSI app name is coreclr, the content name is passed as nullptr, and only the byte-array path is scanned (disk loads are already Defender’s job). When a load is blocked it surfaces as a BadImageFormatException carrying the ERROR_VIRUS_INFECTED message text, which is itself a host-side artefact you can catch in exception telemetry or crash logs.

The resilience problem, and it is the important one

Here is the part most write-ups get wrong, and it changes how you weight everything above. The CLR ETW events, including AssemblyLoadStart and the rundown (DCStart) enumeration, are emitted in-process, from user mode, through ntdll!EtwEventWrite. A payload running inside that process can patch the prologue of EtwEventWrite (or, on Framework, set COMPlus_ETWEnabled=0) before it does anything noisy. Once that write is stubbed out, the event never reaches the kernel, so it never reaches any consumer, including a remote or out-of-process collector and including the rundown provider. Do not describe the CLR provider events as surviving in-process tampering. They do not.

So the honest position is this: the CLR provider tells you the most, and the empty-AssemblyPath signal is genuinely valuable because a lot of tooling never bothers to patch ETW. But it is degradable, and it is the first thing a competent operator silences. You pair it with signals sourced below the process, which an EtwEventWrite patch cannot reach:

  • Kernel image-load correlation. Sysmon Event ID 7 on clr.dll or coreclr.dll mapping into a process with no legitimate reason to host the CLR. This comes from the kernel loader callback, not the runtime, so patching ETW inside the process does nothing to it.
  • The diagnostic named pipe. On .NET Core 3.0+, the runtime opens \\.\pipe\dotnet-diagnostic-<PID> (no timestamp, no -socket suffix, which is the Unix form). Sysmon Event ID 17 / 18 records it. Classic Cobalt Strike execute-assembly hosts the .NET Framework CLR, which does not create this pipe (it uses anonymous pipes). Treat dotnet-diagnostic as a Core-host tell, not a universal artefact.
  • Environment and registry tamper telemetry. The presence of COMPlus_* / DOTNET_* diagnostic variables in a process environment block is worth collecting. The prefix reality: DOTNET_ is .NET 6+, .NET 5 reads only COMPlus_ / CORECLR_, and precedence runs DOTNET_ first. An ETW-disable variable on a non-developer box is a finding.
  • The patch artefact itself. An integrity scan of the EtwEventWrite prologue in loaded modules catches the tamper directly, regardless of what the runtime would have emitted.

Elastic Security Labs’ work fits here and is worth being accurate about. Their in-memory .NET detection uses userland hooking plus on-demand memory scanning, and, separately, kernel ETW call stacks: clr.dll loaded from unbacked or RWX memory with a call stack ending in ...mscoreei.dll!CreateInterface tagged Unbacked, mapped to T1055. That is a kernel-sourced call-stack approach, not the DotNETRuntime rundown provider. The rundown enumeration of already-loaded assemblies is a Microsoft-documented capability, exercised by tooling like Mandiant’s SilkETW, and like all the CLR provider events it is subject to the same in-process silencing.

What to actually run

SilkETW / SilkService is the classic consumer of these providers. It is archived read-only as of August 2024, so treat it as a reference rather than a maintained product. The canonical invocation captures the Jit, Interop, Loader and NGen keywords:

				
					SilkETW.exe -t user -pn Microsoft-Windows-DotNETRuntime -uk 0x2038 -ot file -p C:\dotnet.json
				
			

SilkETW famously shipped a YARA rule that flagged Seatbelt run via execute-assembly by matching ManagedInteropMethodName=GetTokenInformation in the interop events. The maintained alternative I reach for now is Sealighter, built on krabsetw; its SealighterTI mode can even consume the Threat-Intelligence provider without a signed driver. On the EDR side, be careful with claims: Microsoft Defender for Endpoint’s documented .NET visibility is through AMSI. I have not seen it verified that MDE subscribes to the DotNETRuntime provider, so I would not assert that it does.

What this does not catch

NativeAOT is the honest blind spot. An assembly compiled ahead-of-time to a native binary has no CLR to host: no coreclr.dll, no JIT events, no assembly-loader events, and no AMSI scan, because none of that machinery is present. Every signal in this post is a CLR signal, so NativeAOT erases all of it. That is a telemetry-erasure deployment choice, and you detect it the way you detect any unsigned native binary running from an odd location: ordinary process-creation and image-load telemetry, reputation, and signing. The CLR-specific hunts simply do not apply.

Conclusion

The short version I give SOC leads is this. Sysmon cannot see a managed load, and that is structural, not a tuning problem. The telemetry that describes what actually ran lives in the CLR ETW providers, and the empty-AssemblyPath event on .NET 5+ is a clean in-memory indicator. But that provider is emitted in-process and is the first thing a capable operator turns off, so you never build your detection on it alone. You anchor on kernel-sourced signals (image-load correlation, the diagnostic pipe on Core hosts, environment tampering, and the EtwEventWrite patch artefact itself) and use the CLR events as the rich context on top. Stop repeating the environment-variable and AMSI-bypass folklore. On modern .NET it is wrong, and it is leading defenders to look in the wrong place.

A Sigma rule to hunt this

If you want to hunt this in your own environment, here is a Sigma rule for the image-load footprint. It watches the CLR host DLLs loading into a script host or LOLBin, the out-of-process signal that survives an operator patching ETW inside the process.

				
					title: CLR Hosting DLL Loaded by Scripting or LOLBin Host
id: 86242c31-2f84-40c9-91bf-ad4b23b8b34c
status: experimental
description: Detects the .NET CLR hosting DLLs loading into a script host or LOLBin, a common footprint of in-memory .NET execution (execute-assembly and descendants).
references:
    - https://valhguard.com/2026/01/15/in-memory-dotnet-clr-telemetry/
author: Adrian Diaz, Valhguard
date: 2026-01-15
tags:
    - attack.stealth
    - attack.t1620
logsource:
    category: image_load
    product: windows
detection:
    selection_host:
        Image|endswith:
            - '\rundll32.exe'
            - '\regsvr32.exe'
            - '\wscript.exe'
            - '\cscript.exe'
            - '\mshta.exe'
            - '\installutil.exe'
    selection_clr:
        ImageLoaded|endswith:
            - '\clr.dll'
            - '\coreclr.dll'
    condition: all of selection_*
falsepositives:
    - InstallUtil.exe is a managed binary and always loads the CLR, so sanctioned InstallUtil use will match
    - Rare legitimate administrative scripts that host the CLR in one of these hosts
level: high

				
			

It validates clean and compiles to Splunk, Sentinel (KQL), Elastic and CrowdStrike. It is deliberately narrow, so it will miss a CLR injected into a normal .NET process. For that case pair it with the empty-AssemblyPath signal above.

References

More from the blog

Article banner for Virtualization as a Weapon: detecting portable QEMU
Virtualization as a Weapon: Detecting Portable QEMU and Red Team VMs

Virtualization is not just infrastructure; it is a weapon. Red Teams leverage portable QEMU instances to bypass host-based EDRs and evade behavioral analysis. We analyze how attackers deploy these “invisible machines” without administrative privileges and provide the KQL hunting queries defenders need to detect unauthorized virtualization on their networks.

Read More »
Article banner for Detecting Driver Loading: sc.exe versus devcon.exe
Detecting Driver Loading: sc.exe vs. devcon.exe

Loading kernel drivers is a “Holy Grail” operation for attackers, granting Ring 0 privileges for persistence or EDR blinding. We analyze the forensic difference between the loud method (sc.exe) and the stealthy method (devcon.exe) to help Blue Teams build resilient detections.

Read More »

Discover more from Valhguard

Subscribe now to keep reading and get access to the full archive.

Continue reading