ETW Threat Intelligence — How Windows Watches Process Injection
ETW-TI notes (WinDbg)
Microsoft-Windows-Threat-Intelligence is a kernel ETW provider. Defender (DefenderApiLogger) and EDRs subscribe. Ordinary processes cannot — consumers are PPL-Antimalware.
GUID {F4E1897C-BB5D-5668-F1D8-040F4D8DD344}. Always hashes to bucket 29.
ETW roles: provider fires → session buffers → consumer reads. If no session, IsEnabled=0 and the kernel skips the event (no cost).
.reload /f first if names do not resolve. LiveKD is read-only — eb needs a writable kernel debug session.
| Event | Trigger |
|---|---|
ALLOCVM |
NtAllocateVirtualMemory with exec |
MAPVIEW |
NtMapViewOfSection into another process |
READVM / WRITEVM |
cross-process memory |
PROTECTVM |
change to exec |
QUEUEUSERAPC |
APC inject |
SETTHREADCONTEXT |
context hijack |
Find _ETW_GUID_ENTRY
nt!EtwpDebuggerData[3] → silo → GuidHashTable[29] → walk Flink until GUID
_ETW_GUID_ENTRY
+0x000 GuidList LIST_ENTRY (bucket chain)
+0x028 Guid 16 bytes ← first DWORD Data1 = 0xF4E1897C
+0x060 ProviderEnableInfo _TRACE_ENABLE_INFO (OR of all slots)
+0x080 EnableInfo[8] one slot per session, each 0x20
+0x188 SiloState back-pointer to silo
_TRACE_ENABLE_INFO
+0x000 IsEnabled 1=on, 0=off ← eb this
+0x004 Level
+0x006 LoggerId USHORT, index into EtwpLoggerContext
+0x008 EnableProperty
+0x010 MatchAnyKeyword
+0x018 MatchAllKeyword
+0x028 / +0x060 / +0x080 are hex. Slot s = entry + 0x80 + s*0x20.
Hash table
Silo root
dp nt!EtwpDebuggerData L4
4th pointer (+0x18, index 3) = _ETW_SILODRIVERSTATE. Save it.
? nt!EtwpDebuggerData - nt
Dump silo
dt nt!_ETW_SILODRIVERSTATE <silo>
| Field | Off | Use |
|---|---|---|
EtwpLoggerContext |
+0x1C8 |
session array (names, optional) |
EtwpGuidHashTable |
+0x1D0 |
64 buckets, embedded (silo+0x1D0) |
Bucket size
?? sizeof(nt!_ETW_HASH_BUCKET)
Expect 0x38 (56). Bucket 29 offset = 29 * 0x38 = 0x658.
TI GUID hash (fixed):
(Data1 ^ Data2Data3 ^ Data4[0] ^ Data4[4]) & 0x3F
= (0xF4E1897C ^ 0xBB5D5668 ^ 0xF1 ^ 0x04) & 0x3F
= 0x1D = 29
Bucket 29
dt nt!_ETW_HASH_BUCKET <silo>+0x1D0+0x658
On current builds ListHead is [3]. TI is a trace GUID → use ListHead[0].Flink.
dt nt!_ETW_GUID_ENTRY <ListHead[0].Flink>
If Guid is not TI, dq that Flink and dt the next node. Stop when Flink equals the bucket address (silo+0x1D0+0x658). Empty list: ListHead[0].Flink == bucket address.
(etwti.c reads kread64(bucketHead) = this same Flink, then walks until Data1 == 0xF4E1897C.)
Confirm GUID (optional search)
Prefer the Flink walk. Search only if the list is messy:
s -b <silo>+0x1D0 L?0x2000000 7c 89 e1 f4 5d bb 68 56 f1 d8 04 0f 4d 8d d3 44
The hit is the Guid field, not the struct. Subtract 0x28:
dt nt!_ETW_GUID_ENTRY <hit>-0x28
Do not dt the hit address itself — GuidList will look like the GUID bytes. Multiple hits = extra silos; keep the one whose SiloState (+0x188) matches <silo>.
dt nt!_ETW_GUID_ENTRY <entry>
List: is anyone listening?
Aggregate (fast path):
dt nt!_TRACE_ENABLE_INFO <entry>+0x60
IsEnabled=1 → at least one session. Defender often has MatchAnyKeyword = 0xFFFFFFFFFFFFFFFF.
Eight slots (+0x80, step +0x20):
dt nt!_TRACE_ENABLE_INFO <entry>+0x80
dt nt!_TRACE_ENABLE_INFO <entry>+0xa0
… eight times
Note LoggerId where IsEnabled=1. Typical: 2–3 slots.
Session name
EtwpLoggerContext is on the silo (+0x1C8). Also the third QWORD of dp nt!EtwpDebuggerData L4.
dt nt!_ETW_SILODRIVERSTATE <silo> EtwpLoggerContext
LoggerId comes from an enabled slot (IsEnabled=1):
dt nt!_TRACE_ENABLE_INFO <entry>+0x80
Each array slot is 8 bytes. Example: LoggerId 6 → 6*8 = 0x30.
dq <EtwpLoggerContext>+30 L1
Left column = address of slot [6]. Right column = _WMI_LOGGER_CONTEXT *. dt the right value:
dt nt!_WMI_LOGGER_CONTEXT <result>
LoggerName is the consumer (session), e.g. DefenderApiLogger. Confirm field offsets with dt on the target.
Do not dt EtwpLoggerContext itself. Slot [0] is often 1 (sentinel) — skip it. Only follow array + LoggerId*8 when IsEnabled=1.
Disable
Zero aggregate:
eb <entry>+0x60 0
Zero one consumer (slot 0 = +0x80):
eb <entry>+0x80 0
etwti.c disable zeros aggregate and all 8 slots.
Kernel then skips TI events. LiveKD cannot write.
Commands
| Cmd | Does |
|---|---|
dp / dq / dd |
dump pointers / QWORDs / DWORDs |
eb |
edit byte (IsEnabled) |
dt |
dump typed struct |
?? sizeof(...) |
type size, no memory read |
s -b |
byte search (GUID) |
poi(addr) |
read pointer at addr |
Offsets (+0x1D0, +0x60, +0x80, …) are build-specific — confirm with dt on the target. Bucket 29 is GUID-fixed.