Capture ETW events with C++ (Part 2)

Author

Pavel Yosifovich has 25+ years as Software developer, trainer, consultant, author, and speaker. Co-author of “Windows Internals”. Author of “Windows Kernel Programming”, “Windows 10 System Programming, as well as System and kernel programming courses and “Windows Internals” series.

In Part 1, we set up an ETW session, connected it to a provider, and started receiving events. The problem is that receiving events is not the same as reading them. What we had at the end of Part 1 was a timestamp, a process ID, and a thread ID. That’s not nothing, but it’s not much either.

In this part, we go further. We use the TDH (Trace Data Helper) API to decode what’s actually inside those events: the event name, keywords, level, opcode, task, and the actual property values. By the end, you’ll have a consumer that produces output you can read and act on.

This is intermediate-level material. You should already be comfortable with basic ETW session setup. If you haven’t watched Part 1, go back and start there. https://trainsec.net/library/windows-internals/capture-etw-events-with-c-part-1/

The Event Record Structure

The OnEvent callback receives an EVENT_RECORD pointer. That structure has an EventHeader, which we used in Part 1 for the timestamp, process ID, and thread ID. But there’s more in that record.

Specifically, there are two members worth knowing about now: UserData and UserDataLength. This is where the actual event payload lives: a raw binary blob containing all the property values for that particular event.

The format of that blob is not officially documented. You can’t just cast it and read it directly. You have to go through the TDH API to interpret it. That’s technically a good thing, it abstracts over a lot of complexity, but it does mean you can’t shortcut it.

TdhGetEventInformation: The Two-Call Pattern

The function we need is TdhGetEventInformation. It takes the event record and fills in a TRACE_EVENT_INFO structure that describes the event’s schema: its name, properties, keywords, and so on.

The complication is that TRACE_EVENT_INFO is variable-length. It contains string data embedded after the fixed-size header, so you can’t just declare it on the stack and pass a pointer. You have to use a two-call pattern:

ULONG bufferSize = 0;
TdhGetEventInformation(record, 0, nullptr, nullptr, &bufferSize);
auto buffer = std::make_unique<BYTE[]>(bufferSize);
auto info = reinterpret_cast<TRACE_EVENT_INFO*>(buffer.get());
TdhGetEventInformation(record, 0, nullptr, info, &bufferSize);

First call: pass null for the buffer, get the required size back. Second call: allocate that many bytes, then fill the structure. The context parameter (the second argument) is not needed for most use cases. You can pass zero and null there.

Don’t forget to link against tdh.lib:

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

The header is tdh.h. In fact, I’d say it’s worth reading in full. The comments in that header are better documentation than anything on MSDN for understanding how ETW properties work.

Reading Event Metadata from TRACE_EVENT_INFO

Once you have a filled-in TRACE_EVENT_INFO, you can start pulling out event metadata. The structure uses offsets: each field like EventNameOffset, KeywordsNameOffset, OpcodeNameOffset, TaskNameOffset, and LevelNameOffset gives you a byte offset from the beginning of the structure where the corresponding Unicode string is stored.

The pattern for reading any of these is always the same:

if (info->EventNameOffset) {
    auto name = reinterpret_cast<PCWSTR>(
        reinterpret_cast<PBYTE>(info) + info->EventNameOffset
    );
    // use name
}

Always check the offset first. If it’s zero, that field isn’t present for this event. That’s expected. Not every event has every metadata field populated.

Doing this for all five fields gives you noticeably more readable output. Instead of seeing “process 1234 generated an event,” you see something like: keywords Work On Behalf, opcode Info, task CPU Priority Change, level Information. Now you know what happened.

Enumerating Event Properties

Metadata strings are useful, but the real payload is in the properties. That’s where you get the actual values: the new thread priority, the image path of a loaded DLL, the stack base of a terminated thread.

Properties are described by the EventPropertyInfoArray inside TRACE_EVENT_INFO. The number of top-level properties is in TopLevelPropertyCount. Top-level means directly attached to the event, not nested inside a sub-structure. Sub-structures exist and add complexity, but in practice most events you’ll encounter use simple top-level properties.

for (ULONG i = 0; i < info->TopLevelPropertyCount; i++) {
    auto& pi = info->EventPropertyInfoArray[i];
    // ...
}

Each EVENT_PROPERTY_INFO has a NameOffset for the property name, and a flags field that tells you what kind of property it is. For simple properties, flags is zero. That’s what we handle here. For arrays, sub-structures, and provider-side filters, you’ll need to handle additional cases. The TDH documentation and my ETW Studio sample on GitHub cover those. But for a first pass, simple properties cover the majority of what you’ll encounter in practice.

Windows master developer badge 1

$2,111

$1,478 or $150 X 10 payments

Windows Master Developer

Takes you from a “generic” C programmer to a master Windows programmer in user mode and kernel mode.

Formatting Property Values with TdhFormatProperty

For simple properties, the function you want is TdhFormatProperty. It takes the event info, a pointer into the user data, the property’s in-type and out-type, and a buffer to write the formatted string into:

WCHAR text[256];
USHORT textLen = ARRAYSIZE(text);
USHORT consumed = 0;
ULONG status = TdhFormatProperty(
    info,
    nullptr,     // map info — not needed for simple types
    pointerSize,
    pi.nonStructType.InType,
    pi.nonStructType.OutType, (USHORT)pi.length,
    (USHORT)userDataLength,
    (PBYTE)userData,
    &textLen, text, &consumed);

if (status == ERROR_SUCCESS) {
    // display text
    printf(“Value: %ws\n”, text);
    userDataLength -= consumed;
    userData += consumed;
}

A few things worth noting here. The consumed output tells you how many bytes of user data this property occupied. You advance your pointer by that amount before moving to the next property. If you don’t track this correctly, everything after the first property will be garbage.

The map info parameter handles enumeration types where a raw integer value maps to a named string. That’s useful for symbolic constants, but it requires an additional TDH call. The pattern is in the Microsoft samples if you need it.

32-bit vs 64-bit Events

The pointerSize parameter matters when a property contains a pointer. ETW events can come from 32-bit processes, even on a 64-bit system. The size of a pointer in those events is four bytes, not eight.

You can detect this from the event header flags:

ULONG pointerSize =
    (record->EventHeader.Flags & EVENT_HEADER_FLAG_32_BIT_HEADER) ? 4 : 8;

If you always assume 64-bit, you’ll get the right answer most of the time. But if a 32-bit process fires an event with a pointer-type property, the sizing will be off and the output will be wrong. Worth handling correctly if you’re building something generic.

What You Actually See

Running this against the Windows Kernel Process provider gives you output like:

  • Thread Stop: stack base, stack limit, TEB pointer, start address
  • CPU Priority Change: old priority 16, new priority 8, timestamp
  • Image Load: image path, process ID, load address

That’s a lot more useful than a timestamp and a process ID. You can start correlating events, filtering by task name, or acting on specific property values in real time.


Security and Offensive Angle

ETW is one of the primary telemetry channels that endpoint detection and response tools use to observe process behavior. Understanding how to consume these events is directly relevant whether you’re building a detection pipeline or reasoning about what an EDR actually sees.

The image load event is a good example. Every DLL mapped into a process fires one of these. If you’re analyzing malware behavior or writing detection rules, that event stream tells you exactly what was loaded and when. Process create and thread start events are similarly valuable.

On the offensive side, ETW can be tampered with. Knowing what data flows through these kernel providers and what properties are exposed helps you reason about what tampering would and wouldn’t conceal. That’s a topic for a separate discussion, but it’s worth keeping in mind.


Key Takeaways

  • TdhGetEventInformation requires a two-call pattern to handle its variable-length output
  • TRACE_EVENT_INFO stores metadata as string offsets from the structure base; always check if an offset is non-zero before reading
  • Simple properties (flags == 0) cover most real-world events; complex types require additional handling
  • TdhFormatProperty consumes user data sequentially; track the consumed byte count and advance your pointer
  • The tdh.h header file is better documentation than MSDN for this API

Liked the content?

Subscribe to the free TrainSec knowledge library, and get insider access to new content, discounts and additional materials.

Keep Learning with TrainSec

This content is part of the free TrainSec Knowledge Library, where students can deepen their understanding of Windows internals, malware analysis, and reverse engineering. Subscribe for free and continue learning with us:

https://trainsec.net/library

blue depth

About the author

Pavel Yosifovich has 25+ years as Software developer, trainer, consultant, author, and speaker. Co-author of “Windows Internals”. Author of “Windows Kernel Programming”, “Windows 10 System Programming, as well as System and kernel programming courses and “Windows Internals” series.
Even more articles from the free knowledge library
Writing a Simple Key Logger

In this video, Pavel walks through how to implement a basic keylogger in Windows using GetKeyState, handling character normalization (Shift,

Read More