Investigating .NET CLR Usage Log Tampering Techniques For EDR Evasion (Part 2)


Last year, I blogged about Investigating .NET CLR Usage Log Tampering Techniques For EDR Evasion. In that part 1 post, we covered:

  • The purpose of .NET Usage Logs and when they are created
  • How Usage Logs are used to detect suspicious activity
  • Several mechanisms for tampering with Usage Logs to avoid log creation and subsequent detection
  • Defensive considerations for potentially detecting nefarious activity around .NET and Usage Log tampering.

Recently, I revisited the research topic to close the loop on some outstanding research and figured I would share. In this post, we’ll recap .NET Usage Logs, highlight two other tampering techniques, and review defensive considerations.

A Recap of .NET Usage Logs

When .NET applications are executed or when assemblies are injected into another process memory space (by the Red Team), the .NET Runtime is loaded to facilitate execution of the assembly code and to handle various and sundry .NET management tasks. One task, as initiated by the CLR (crl.dll), is to create a Usage Log file named after the executing process once the assembly is finished executing for the first time in the (user) session context. This log file contains .NET assembly module data, and its purpose serves as an information file for .NET native image autogeneration (auto-NGEN).

There are several directories dedicated for Usage Log creation depending on the .NET user context, the .NET version, or other specialty caveats such as a specialty application (e.g. Office/Store/etc.). A few examples include:

  • 64-bit .NET 4.0, User-Level: \Users\<user>\AppData\Local\Microsoft\CLR_v4.0\UsageLogs
  • 32-bit .NET 4.0, User-Level: \Users\<user>\AppData\Local\Microsoft\CLR_v4.0_32\UsageLogs
  • 64-bit .NET 4.0, System-Level: \Windows\System32\config\systemprofile\AppData\Local\Microsoft\CLR_v4.0
  • 32-bit .NET, 4.0 System-Level: \Windows\SysWOW64\config\systemprofile\AppData\Local\Microsoft\CLR_v4.0_32

Prior to process exit, the CLR typically writes to one of the aforementioned file paths if a log file does not already exist in the target directory. For instance, we can see that the powershell.exe.log Usage Log is created for the first time just prior to ‘gracefully’ terminating the powershell.exe process:

Monitoring Usage Log file creation events provide detection opportunities for identifying suspicious and/or unlikely processes that have loaded the .NET CLR.

Tampering Technique: Discretionary ACL Block

A low-effort, yet effective way to prevent the Usage Log write operation is by setting an Access Control List (ACL) entry on the target \UsageLogs directory. As an example, let’s target a user context named ‘user’ who runs a 64-bit .NET application.

First, let’s check the existing ACL on the \UsageLogs directory. This can be obtained with the get-acl PowerShell cmdlet:

get-acl c:\Users\user\AppData\Local\Microsoft\CLR_v4.0\UsageLogs\ |fl

As expected, ‘user’ has Allow-Full Control permission over the \UsageLogs folder in their respective home directory structure. Let’s use the following slightly modified C# code from Microsoft Docs to set a deny ACL entry on the \UsageLogs directory for ‘user’ using the AddAccessRule() method from the System.Security.AccessControl namespace:

using System;
using System.IO;
using System.Security.AccessControl;

namespace FileSystemExample
    class DirectoryExample
        public static void Main()
                Console.WriteLine("Hello World!");

                //Set Deny ACL for "user"
                DirectoryInfo dInfo = new DirectoryInfo(@"C:\Users\user\AppData\Local\Microsoft\CLR_v4.0\UsageLogs\");
                DirectorySecurity dSecurity = dInfo.GetAccessControl();
                dSecurity.AddAccessRule(new FileSystemAccessRule(@"WIN-FLARE\user", FileSystemRights.FullControl, AccessControlType.Deny));
            catch (Exception e)

After the application runs, the deny entry is added to the ACL:

Although the Allow-FullControl entry is still present, the Deny-FullControl entry takes precedence and prevents the creation of the Usage Log as well as access to the \UsageLogs directory in this case:

Note: This technique likely requires out-of-band cleanup when finished.

Tampering Technique: Inline Hooking

An interesting technique for dismantling user mode security features is hooking a target function and disrupting program flow in memory. Great examples of this include evading ETW by disrupting the EtwEventWrite() function in Kernel32.dll (thanks @_xpn_) and evading AMSI by patching the AmsiScanBuffer() in amsi.dll (thanks @_xpn_ and @_RastaMouse). Similarly, might we be able to use a technique for tampering with the .NET Usage Log creation events? If we fire up Procmon and inspect our .NET program trace, we can drill down to see the series of events that occur when the Usage Log is created for first time program execution:

Procmon gives us insight into the operation performed, and not surprisingly, we can see that the CreateFile operation (using CreateFileW()) is used to open the handle to create the target Usage Log file as shown in this partial stack trace:

It would seem that simply patching CreateFileW() in KernelBase.dll may prevent Usage Log creation and that is what we should just try it, right? Before exploring that possibility, let’s consider a few caveats and tradeoffs before proceeding:

  • Patch Timeliness: Whereas patching enabling security functions (e.g. ETW/AMSI) may make sense to do so earlier in program execution flow, patching CreateFileW() early may have adverse impact on the running program since such calls are made frequently during the process lifetime. As you may recall from the previous post, the Usage Log call to initiate the log creation process occurs during process shutdown as initiated by the CLR (which is also noted in the process trace above). As such, patching should occur near process exit.
  • Process Exit: .NET tradecraft varies between Command & Control (C2) frameworks, custom tooling/usage, etc. In many cases, the end of process execution is also the end of executing .NET assembly modules, so patching CreateFileW() near process end would generally work in these cases. However, if assemblies are executed inline, patching CreateFileW() may prove risky for the lifetime of the running process.
  • Patch Function Selection: Patching CreateFileW() may seem like the most obvious choice. However, inline hooking other candidate functions just might achieve the same effect.

Now, let’s assume that we would like to move forward with patching because it meets the use case requirements, so we open our target 64-bit .NET program in the x64dbg debugger to find lead information that may help us leverage a suitable (set of) patch instructions. First, we set a breakpoint on CreateFileW() and step through until find the instruction of interest in the disassembler. In this case, it is a JMP to KernelBase:CreateFileW:

At this point, we see that RCX register holds a memory address for the first parameter in CreateFileW(), which is the file path pointer for the target Usage Log:

Next, we step into the function and work our way through the instructions. To highlight, we observe several operations within KernelBase:CreateFileW() but no instructions that seem to manipulate RCX. However, we do see a call to the internal KernelBase:CreateFileInternal() function, which was evident in our earlier Procmon stack trace (as seen above).

Interestingly, we finally observe RCX manipulated in the disassembler after stepping into KernelBase:CreateFileInternal():

A quick Google search does not reveal official documentation about KernelBase:CreateFileInternal(). It is not an export of Kernel32 or KernelBase, so it definitely is an internal function. The best information found is a reference in this blog post by James Forshaw. For our use case, however, we are at a point where we may be adventuring down the proverbial rabbit hole, so analyzing the heavy lifting subsequently performed with CreateFileInternal() to work through the magical calls of all the *CreateFile* chains in user and kernel mode are beyond the scope of this post (but a good exercise nonetheless).

So, let’s get back on track, step out of CreateFileInternal() and back into CreateFileW(). Here, we observe the we are near the end of our CreateFileW() call as we approach the return (RET) instruction. Conveniently, this appears to be all that we need as it takes us back to the CLR after CreateFileW() is finished:

And after all that, let’s simply patch CreateFileW() with the return op code (0xC3) in the following C# code example:

using System;
using System.Runtime.InteropServices;

namespace MyVeryEvilTestAssembly
    class Program
        static void Main(string[] args)
            Console.WriteLine("Hellow World");

            //Placed at the end of our program for good measure

        static void EvadeUsageLogDetections()
            byte[] patch = new byte[] { 0xC3 }; //Patch with ret code
            IntPtr kernel32 = LoadLibrary("KernelBase.dll"); //We should be able to use Kernel32.dll as well
            IntPtr createFileAddr = GetProcAddress(kernel32, "CreateFileW");
            VirtualProtect(createFileAddr, (UIntPtr)patch.Length, 0x40, out uint oldProtect);
            Marshal.Copy(patch, 0, createFileAddr, patch.Length);
            VirtualProtect(createFileAddr, (UIntPtr)patch.Length, oldProtect, out oldProtect);

        public static extern IntPtr GetProcAddress(IntPtr hModule, string plpProcName2);

        public static extern IntPtr LoadLibrary(string lpLibFileName);

        public static extern bool VirtualProtect(IntPtr lpAddress, UIntPtr dwSize, uint flNewProtect, out uint pflOldProtect);

After analyzing our program once more in the debugger after the program change, we can see the return opcode patch applied, and the CreateFile operation is completely thwarted for Usage Log creation:

And that is just one patch option. There probably better ways to handle last error(s) 😉

Usage Log Defensive Considerations

Last year, I reported this issue to MSRC, and they concluded that Usage Log evasion was not a security boundary issue. However, the security takeaways in the previous post and this post are relevant:

*Monitor for \UsageLog directory ACL changes (via Event Log): Ensure the “Audit Object Access” setting is enabled (for success and failure) in the System Audit Policy or the “Audit File System” setting is enabled in the Advanced Audit Policy Configuration.

Set auditing on the the \UsageLog directories that should be monitored including the security principal (e.g. everyone). In Advanced settings, select “Change permissions”, ensure success is checked (at least), and apply.

Monitor for Event ID 4670 to detect DACL changes (per this source) An example event looks like this:

*Continue monitoring Usage Log creation and deletion events: Creation of log files for unmanaged processes that load the CLR and really have no business doing so should be treated as suspicious, especially those pesky script hosts.  Offensive operators will not always account for Usage Log tampering while executing their .NET tools when using commands like execute-assembly in Beacon. Keep in mind that some unmanaged processes legitimately load the CLR depending on the use case, such as mmc.exe. Usage Log deletion events could be an indicator of compromise if an actor tries to clean up rather than deploy an evasion technique.

*Continue monitoring for suspicious .NET runtime loads: Where as monitoring log file creation event for detection could be hit or miss, monitoring for suspicious .NET CLR loads (e.g. clr.dll, mscoree.dll, etc.) could yield interesting results when tuned correctly.

*Continue hunting for CLR configuration knob additions or modifications: The addition of the NGenAssemblyUsageLog string in the HKCU\Software\Microsoft\.NETFramework and HKLM\Software\Microsoft\.NETFramework Registry keys could be an indicator of compromise. Hunt for the prepending of COMPlus_NGenAssemblyUsageLog in permanent user/system environment variables. Event ID 4657 is generated when the audit object access policy is enabled and the target key is audited for key write/set value events (thanks @Cyb3rWard0g):

*Continue hunting for suspicious process events: Identifying early process termination and DLL unload events may be interesting in the context of detecting Usage Log evasion techniques.


And that’s a wrap., Thank you for taking the time to read this post. Feel free to send me a DM if you discover any other evasion techniques!