A busy Windows machine has a lot to say about itself. ETW providers report disk activity, scheduling, network traffic, and the work of hundreds of other components. An investigator has to decide which of those sources deserve a closer look. A provider that appears to be doing routine profiling is easy to pass over.
While looking at how kernel drivers register with Event Tracing for Windows, I found that Windows does not authenticate the identity a provider claims. A driver passes a GUID to EtwRegister, and Windows accepts the registration without asking whether the driver does the work associated with that identifier. It does not validate a manifest, verify a signature, or check a catalog of known providers.
I built this implementation around that gap. A background thread collects real process data, and an ETW provider emits events based on the samples. During basic and intermediate inspection, the output looks like a Microsoft profiler doing ordinary work. The sampling is real. The reason the driver is doing it is what the telemetry leaves out.
Registration is open by design
ETW sits beneath much of Windows’ observability tooling. Performance Monitor, Process Monitor, Windows Performance Analyzer, and endpoint detection and response agents consume its event streams. Providers emit events, controllers create trace sessions and enable providers, and consumers read what those sessions collect. A default Windows installation has more than a thousand providers, covering everything from disk I/O to graphics compositor scheduling.
That system needs to let third-party software participate. Any kernel driver or user-mode component can register a provider, which is useful when writing a legitimate tool. It also means registration says very little about whether the provider’s claimed identity is true. A driver that profiles thread contexts and a driver that only claims to do so go through the same call.
From kernel mode, EtwRegister takes a GUID, optional callbacks, and a place to return the registration handle. Once it succeeds, trace sessions can subscribe to that GUID and receive the driver’s events through the normal ETW pipeline:
static REGHANDLE g_etwHandle = 0;
// this name shows up in: logman query providers, perfmon, event viewer
// pick something that belongs in the list
static const GUID PROVIDER_GUID = {
0xE8109B99, 0x0923, 0x4C5A,
{ 0x8A, 0x21, 0x67, 0x72, 0xB0, 0xAF, 0x44, 0x3D }
};
NTSTATUS initEtwProvider() {
return EtwRegister(&PROVIDER_GUID, NULL, NULL, &g_etwHandle);
}
There is a catch in how this appears to an administrator. Registration allows the driver to emit events, but it does not by itself put a named provider in logman query providers or the Event Viewer channel list. That requires a manifest installed through wevtutil im or matching registry entries under HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\WINEVT\Publishers. Without that metadata, the provider is visible when an active trace session captures its events, but enumeration tools do not list it.
The GUID introduces another tradeoff. Reusing a real Microsoft profiler GUID gives the identity a recognizable identifier, but risks a collision when both providers are loaded. A custom GUID with a plausible name avoids the collision. During live triage, investigators generally do not check every provider against a known-good catalog. There are too many of them for that to be a practical manual habit. A deeper investigation can make that check, though, and the distinction matters later.
The profiler has to do some profiling
A stream of invented sample values would give an analyst an easy way to disprove the cover. If the driver claims to collect process data, its payloads need to agree with the processes on the machine. I used a real sampling loop for that reason.
The thread rotates through actual processes, attaches to them, and records thread contexts:
static const WCHAR* g_sampleTargets[] = {
L"svchost.exe",
L"csrss.exe",
L"dwm.exe",
L"explorer.exe",
L"wmiprvse.exe",
L"services.exe",
L"lsass.exe"
};
void profilerThread(PVOID context) {
UNREFERENCED_PARAMETER(context);
while (g_profilerActive) {
// pick a target from the rotation
ULONG idx = (g_sampleIndex++) % ARRAYSIZE(g_sampleTargets);
PEPROCESS process = NULL;
if (NT_SUCCESS(findProcessByName(g_sampleTargets[idx], &process))) {
KAPC_STATE apcState;
KeStackAttachProcess(process, &apcState);
// sample thread contexts - real data, not fake
enumerateThreads(process, collectThreadSample);
KeUnstackDetachProcess(&apcState);
// record what was sampled before dropping the reference
g_lastSampledPid = PsGetProcessId(process);
g_sampleCount++;
ObDereferenceObject(process);
}
// randomize interval to avoid fingerprinting
LARGE_INTEGER delay;
delay.QuadPart = -((LONGLONG)(50 + (RtlRandomEx(&g_seed) % 450)) * 10000);
KeDelayExecutionThread(KernelMode, FALSE, &delay);
}
PsTerminateSystemThread(STATUS_SUCCESS);
}
The targets include core system services, the Desktop Window Manager compositor, and the shell. They are processes a profiler has a reasonable explanation for sampling. The delay varies between 50 and 500 milliseconds so the loop does not present the fixed-interval signature that automated behavioral analysis flags. A dump of the driver’s data section contains authentic samples rather than a repeated set of placeholder values.
I run this on a dedicated system thread created with PsCreateSystemThread. A work item would be the wrong fit for a loop that keeps sleeping and coming back. Microsoft’s documentation prohibits long-running blocking loops in work item callbacks because they starve the system worker thread pool. The background work needs a thread whose lifetime belongs to the driver.
Events that agree with the samples
Collecting real data only solves part of the problem. The events also need to describe the kind of work the provider claims to do. ETW consumers use an event descriptor’s severity level, task identifier, and keyword mask to filter and categorize output. This provider emits informational events with a sampling keyword:
static const EVENT_DESCRIPTOR SAMPLE_EVENT = {
.Id = 1000,
.Version = 1,
.Channel = 0,
.Level = TRACE_LEVEL_INFORMATION,
.Opcode = 0,
.Task = 1,
.Keyword = 0x1 // PERF_SAMPLING
};
void emitSampleEvent() {
if (!g_etwHandle)
return;
ULONG pid = HandleToUlong(g_lastSampledPid);
ULONG count = g_sampleCount;
LARGE_INTEGER timestamp;
KeQuerySystemTimePrecise(×tamp);
EVENT_DATA_DESCRIPTOR eventData[3];
EventDataDescCreate(&eventData[0], &pid, sizeof(ULONG));
EventDataDescCreate(&eventData[1], &count, sizeof(ULONG));
EventDataDescCreate(&eventData[2], ×tamp, sizeof(LARGE_INTEGER));
EtwWrite(g_etwHandle, &SAMPLE_EVENT, NULL, 3, eventData);
}
The payload contains the PID from the most recent sample, a monotonically increasing sample count, and a precise timestamp. The PID comes from the sampling thread, so it identifies a process the driver actually touched. When an analyst captures the events, the values correspond to activity on the system.
That correspondence is why I kept the sampling loop. A plausible event name is easy to supply. Making the data behind it survive a comparison with system state requires the driver to perform the advertised work.
The timing has to agree too
Sampling and emission run on separate schedules. If an event describes sampling svchost.exe at time T, the thread needs to have attached to that process near T. I keep the shared context small enough that the relationship is straightforward:
struct profiler_context {
ULONG_PTR lastSampledPid; // use InterlockedExchangePointer for cross-CPU visibility
ULONG sampleCount; // use InterlockedIncrement in production
BOOLEAN active; // use InterlockedExchange8 or KeEvent for shutdown
};
The emitter runs 20 to 80 milliseconds behind the sampling thread. That delay is deliberate. Production profilers separate collection from emission because writing an event during a measurement adds latency to the measurement itself. A small propagation delay fits that behavior better than firing every event at the exact instant of its sample.
The thread context and the event payload have to tell the same story, including when the work happened. An authentic PID attached to the wrong timestamp is still a discrepancy.
Looking at it from the administrator’s side
With a manifest installed, the provider’s name appears in logman query providers alongside hundreds of built-in entries. Enabling a trace session produces ordinary-looking output:
Provider Name: WmiPerfHost
Events/sec: ~3-8 (consistent)
Level: Informational
Keywords: PERF_SAMPLING
The captured events contain PIDs, counts, and timestamps. In Windows Performance Analyzer, the sampling work appears as periodic CPU micro-spikes, consistent with a thread cycling through process attachments and thread context reads. At that level of inspection, it looks like dozens of other providers doing background work.
Channel 0 events without an installed manifest do not route to Event Viewer by default. They still appear in active trace sessions, but passive discovery through Event Viewer will not find them. Installing the manifest makes the driver more visible and gives its claimed identity a more complete presence in the tools an administrator would use.
This is the extent of the cover’s advantage in a standard investigation. The driver has an ordinary explanation for its visible behavior, so the investigator needs a reason to examine it further. Disassembling the binary is a much larger job than reviewing a provider list, and routine triage usually needs some independent suspicion before going that far.
More integrations mean more claims to support
A single sampling event is only a small part of what a real profiler emits. Context switch notifications, stack walk captures, and module load records can make the provider look more complete. They also leave the investigator several event streams to evaluate instead of one repeated event class.
Performance counters provide another place to check the identity. Registering them through PcwRegister puts entries such as “Samples/sec” and “Context Switches Observed” in Performance Monitor’s counter catalog. An analyst sees data consistent with the profiling claim in a separate tool.
Windows Management Instrumentation offers a similar path. A registered WMI class can expose profiling statistics through PowerShell and management consoles. A query with Get-WmiObject then finds a profiling interface backed by real data.
Each integration gives the cover another place to hold up during inspection. It also gives me another place to make a mistake. The added code brings imports and behavior that must stay consistent, and it leaves more of the binary for static analysis to examine. I do not consider a longer feature list an automatic improvement.
Where the explanation stops working
The cover can survive ordinary inspection, but it has several ways to come apart under a closer look.
GUID provenance is one. Microsoft documents its profiler GUIDs, and community catalogs track third-party providers. An identifier absent from every known catalog is an anomaly that an automated tool can find at scale. Reusing a real GUID avoids that specific discrepancy and reintroduces the collision risk.
A manifest comparison is another. Legitimate profiler events have defined field layouts. Comparing the provider’s output field by field with the published schema exposes structural differences that a quick look at event names and timestamps misses.
Correlating ETW with other driver activity is more revealing still. If the driver handles unrelated IOCTL requests, their timing can show that the profiling telemetry is separate from its actual purpose. One source describes sampling while another describes something else. Looking at both removes the benefit of reviewing ETW in isolation.
The binary contains the clearest evidence. Disassembly exposes imports, code paths, and data structures unrelated to profiling. Runtime telemetry cannot change the program stored on disk. This is the hardest gap to close because the driver still contains both its cover activity and its real functionality.
Even the provider’s lifetime can contradict its identity. Registering when one particular application starts and disappearing when it exits does not resemble a persistent monitoring tool. The claimed profiler needs to follow normal driver load patterns for that timing to hold up.
What this technique assumes
The driver must already be running. ETW cover traffic does not load an unsigned driver, bypass Authenticode verification, or get it through Hypervisor-protected Code Integrity enforcement. All of this happens after loading.
A partial implementation can also be worse than no cover. Static payloads, missing sampling infrastructure, and inconsistent timing give an investigator discrepancies that the driver introduced for itself. The simulation has to survive the amount of scrutiny applied in the target environment.
Real profilers provide plenty to compare against. Many are open source, and their manifests, field layouts, sampling cadences, and lifecycles are documented. Those details decide whether the activity makes sense as profiling. A provider name cannot do that work on its own.
What interested me was how much the interpretation changed once the driver had believable activity to show. EtwRegister accepts the identity without authenticating the GUID, metadata, or behavior behind it. The investigator can see the driver working, but now has to establish why its explanation is wrong. That buys room during triage. The unrelated code in the binary is still there when someone decides to look.