InfinityHook on Modern Windows: An ETW-Based Hook That Survives PatchGuard
About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.
This research is published for educational and defensive purposes. The target sample is anonymized throughout.
InfinityHook on Modern Windows: An ETW-Based Hook That Survives PatchGuard
ETW hooking is one of those kernel topics that refuses to die. At its core it exploits a Microsoft design weakness: when Windows records ETW system calls, the ETW components intercept the call and run their recording code. Because that code path is enormous, it keeps containing function pointers — and those pointers can be swapped for our own callbacks without PatchGuard ever firing on the syscall itself (the “can’t hook direct NtXXX” rule stays intact).

Project: https://github.com/Oxygen1a1/InfinityHook_latest
The early ETW hook approaches
The first-generation ETW hooks swapped several HalPxx pointers — but those are now monitored by PatchGuard.
https://github.com/everdox/InfinityHook
The second generation works like EAC’s global exception takeover, and it also modifies function pointers on the ETW hook’s unavoidable path. The trick is to patch HalpStallCounter[0xE] (originally HalpTscQueryCounterOrdered) plus the NtGlobalFlags flag. After the patch, certain exceptions — including kernel-mode ones — route through that slot and get taken over.
The call stack below, from WinDbg, shows EAC taking over exceptions by modifying HalpStallCounter:

An ETW hook that works on current Windows
That second generation still works, but it’s painful: per-version compatibility and stack fixing alone eat an enormous amount of effort.
So I looked at a newer technique (from an article by a researcher I’ll cite at the end). The core idea goes like this: the .data section holds mutable globals, and “stealing a pointer” means rewriting a function pointer that lives in .data. The most interesting target is HalPrivateDispatchTable — a table where ntoskrnl.exe keeps HAL’s exported functions in one convenient place. HAL is used all over the place, and ETW is no exception.
Here’s the ETW syscall path:

Note the call rax — that’s the ordinary syscall. Before entering the ETW syscall, the original syscall is stored on the stack, which means once we intercept ETW we can rewrite that stack slot and hook the syscall.
Step into the function:

If locating the syscall used to be impossible, now the stack’s magic number pins down its address so we can replace it. Following on into EtwTraceSiloKernelEvent, every path — regardless of arguments — funnels into EtwpLogKernelEvent:
void __fastcall EtwTraceSiloKernelEvent(
__int64 a1,
__int64 a2,
unsigned int a3,
unsigned int a4,
unsigned __int16 a5,
int a6)
{
unsigned __int64 v9; // rsi
unsigned int v10; // ebx
bool v11; // zf
unsigned int v12; // ecx
__int64 v13; // r8
__int64 v14; // rcx
__int64 v15; // rbx
unsigned int v16; // edi
__int64 v17; // rdx
__int64 v18; // rcx
unsigned int v19; // ecx
v9 = a4;
v10 = *(_DWORD *)(EtwpHostSiloState + 4224);
while ( 1 )
{
v11 = !_BitScanForward(&v12, v10);
if ( v11 )
break;
v10 &= v10 - 1;
v13 = v12;
v14 = 32i64 * v12 + EtwpHostSiloState + 4260;
if ( v14 )
{
if ( ((unsigned int)v9 & *(_DWORD *)(v14 + 4 * (v9 >> 29)) & 0x1FFFFFFF) != 0 )
EtwpLogKernelEvent(a2, EtwpHostSiloState, *(unsigned __int8 *)(EtwpHostSiloState + 2 * v13 + 4208), a3, a5, a6);
}
}
if ( a1 )
{
v15 = *(_QWORD *)(*(_QWORD *)(a1 + 1272) + 864i64);
if ( v15 )
{
v16 = *(_DWORD *)(v15 + 4224);
while ( 1 )
{
v11 = !_BitScanForward(&v19, v16);
if ( v11 )
break;
v17 = v19;
v16 &= v16 - 1;
v18 = 32i64 * v19 + v15 + 4260;
if ( v18 && ((unsigned int)v9 & *(_DWORD *)(v18 + 4 * (v9 >> 29)) & 0x1FFFFFFF) != 0 )
EtwpLogKernelEvent(a2, v15, *(unsigned __int8 *)(v15 + 2 * v17 + 4208), a3, a5, a6);
}
}
}
}
EtwpLogKernelEvent is the star of this show. The relevant part:
else
{
if ( (v32 & 0x800) == 0 )
goto LABEL_17;
v50 = 0;
if ( !*(_DWORD *)(*(_QWORD *)(v14 + 1000) + 8i64) )
goto LABEL_17;
while ( 1 )
{
v51 = *(_QWORD *)(v14 + 1000);
if ( *(_WORD *)(v51 + 2i64 * v50 + 12) == a5 )
break;
if ( ++v50 >= *(_DWORD *)(v51 + 8) )
goto LABEL_17;
}
LODWORD(Flags) = a6;
TimeStamp = &v58;
v34 = (char *)EtwpReserveWithPmcCounters(v14, a5);// normal Etw trace kernel setup won't reach here! ETW must be configured
In fact, this branch is only reached if the ETW Logger syscall has been configured properly. The important part of that function:
mcData = LoggerContext->PmcData;
v8 = *(_DWORD *)(PmcData + 20);
v9 = 8 * (unsigned __int8)v8 + 16;
v10 = v9 + AuxSize;
CurrentIrql = KeGetCurrentIrql();
if ( CurrentIrql < 2u )
{
v12 = KeGetCurrentIrql();
__writecr8(2ui64);
if ( KiIrqlFlags )
{
if ( (KiIrqlFlags & 1) != 0 && v12 <= 0xFu )
{
SchedulerAssist = KeGetCurrentPrcb()->SchedulerAssist;
*(_DWORD *)(SchedulerAssist + 20) |= (-1 << (v12 + 1)) & 4;
}
}
}
v14 = EtwpReserveTraceBuffer((unsigned int *)LoggerContext, v10, (__int64)BufferHandle, TimeStamp, Flags);
v15 = v14;
if ( v14 )
{
*(LARGE_INTEGER *)(v14 + 8) = *TimeStamp;
*(_WORD *)(v14 + 4) = v10;
*(_WORD *)(v14 + 6) = HookId;
*(_DWORD *)v14 = (unsigned __int8)Flags | ((unsigned __int8)v8 << 8) | 0xC0110000;
v21 = *(struct _HAL_PMC_COUNTERS **)(PmcData + 8i64 * (unsigned int)KeGetPcr()->Prcb.Number + 24);
if ( v21 )
HalPrivateDispatchTable.HalCollectPmcCounters(v21, (unsigned __int64 *)(v14 + 16));
else
memset((void *)(v14 + 16), 0, 8i64 * (unsigned __int8)v8);
That HalPrivateDispatchTable.HalCollectPmcCounters(v21, (unsigned __int64 *)(v14 + 16)); is the replaceable spot. Swap HalCollectPmcCounters in HalPrivateDispatchTable and we take over the syscall without tripping PatchGuard.
Implementation
The mechanics are straightforward: call ZwTraceControl to set up the NT Kernel Logger, then the steps are:
- Steal the pointer and replace it.
- Configure the NT Kernel Logger and enable ETW.
- The hard part — how to enable the PMC counter so execution actually reaches
EtwpReserveWithPmcCounters. - Walk the stack to locate the syscall routine.
- Replace the syscall you want to hook.
Everything except step 3 already exists in the first-generation ETW hook code. The reference article[1] says, for this part, “the code has been omitted from this article.” — so I had to reverse it from the scraps of information it gave. I eventually worked it out.
If you enable ETW the normal way, EtwpReserveWithPmcCounters stays unreachable. Look at the guard:

So a specific bit must be set, and setting it requires NtSetSystemInformation (which I had to reverse myself). The part to reverse lives in EtwSetPerformanceTraceInformation:

EtwpUpdatePmcCounters allocates PmcData and sets the flag; EtwpUpdatePmcEvents configures which hook ids are enabled — for syscall that’s 0xf33. The resulting code to set the PMC counter:
/* this actually needs ZwSetSystemInformation, but there's no proper doc or writeup, so I reversed Windows by hand */
NTSTATUS EtwInitilizer::open_pmc_counter()
{
auto status = STATUS_SUCCESS;
auto pmc_count_info = (PEVENT_TRACE_PROFILE_COUNTER_INFORMATION)(nullptr);
auto pmc_event_info=(PEVENT_TRACE_SYSTEM_EVENT_INFORMATION)(nullptr);
constexpr auto syscall_hookid = 0xf33ul;
if (!__is_open) return STATUS_FLT_NOT_INITIALIZED;
do {
/* get the logger id from ckcl_context */
auto EtwpDebuggerData=reinterpret_cast<ULONG***>( \
kstd::SysInfoManager::getInstance()->getSysInfo()->EtwpDebuggerData);
if (!EtwpDebuggerData) {
status = STATUS_NOT_SUPPORTED;
LOG_ERROR("failed to get EtwpDebuggerData!\r\n");
}
/* see the first-gen ETW hook; abbreviated here */
auto logger_id = EtwpDebuggerData[2][2][0];
pmc_count_info = kalloc<EVENT_TRACE_PROFILE_COUNTER_INFORMATION>(NonPagedPool);
if (!pmc_count_info) {
LOG_ERROR("failed to alloc memory for pmc_count!\r\n");
status = STATUS_MEMORY_NOT_ALLOCATED;
break;
}
// first configure PMC count; we only care about one hook id — the syscall hook id 0xf33; profile source is arbitrary
pmc_count_info->EventTraceInformationClass = EventTraceProfileCounterListInformation;
pmc_count_info->TraceHandle = ULongToHandle(logger_id)/* this is actually the logger id */;
pmc_count_info->ProfileSource[0] = 1;/* arbitrary */
status=ZwSetSystemInformation(SystemPerformanceTraceInformation, pmc_count_info, sizeof EVENT_TRACE_PROFILE_COUNTER_INFORMATION);
if (!NT_SUCCESS(status)) {
LOG_ERROR("failed to configure pmc counter,errcode=%x\r\n", status);
break;
}
// then configure the PMC event; only one hook id is needed
pmc_event_info = kalloc<EVENT_TRACE_SYSTEM_EVENT_INFORMATION>(NonPagedPool);
if (!pmc_event_info) {
LOG_ERROR("failed to alloc memory for pmc_event_info!\r\n");
status = STATUS_MEMORY_NOT_ALLOCATED;
break;
}
pmc_event_info->EventTraceInformationClass = EventTraceProfileEventListInformation;
pmc_event_info->TraceHandle = ULongToHandle(logger_id);
pmc_event_info->HookId[0] = syscall_hookid;/* must be 0xf33 */
status = ZwSetSystemInformation(SystemPerformanceTraceInformation, pmc_event_info, sizeof EVENT_TRACE_SYSTEM_EVENT_INFORMATION);
if (!NT_SUCCESS(status)) {
LOG_ERROR("failed to configure pmc event,errcode=%x\r\n", status);
break;
}
} while (false);
//clean up
if (pmc_count_info) ExFreePool(pmc_count_info);
if (pmc_event_info) ExFreePool(pmc_event_info);
return status;
}
There’s another gotcha in EtwpUpdatePmcCounters:
__int64 __fastcall EtwpUpdatePmcCounters(
_WMI_LOGGER_CONTEXT *wmi_context,
_KPROFILE_SOURCE *profile_array,
unsigned int count)
{
unsigned int v6; // r12d
__int64 result; // rax
_ETW_PMC_SUPPORT *PmcData; // r14
__int64 i; // r8
unsigned int v10; // r15d
unsigned int cpu_idx; // edi
__int64 j; // rsi
signed __int32 unk_struct[22]; // [rsp+0h] [rbp-58h] BYREF
v6 = KeNumberProcessors_0;
if ( !count || count > EtwpMaxPmcCounter || wmi_context->PoolType == 1 )// must not be PagedPool
return 0xC000000Di64;
if ( wmi_context->PmcData || (result = EtwpAllocatePmcData((__int64)wmi_context), !(_DWORD)result) )// allocated successfully
{
count > EtwpMaxPmcCounter has to hold — and on my VM that value is 1, so I had to force it. On my physical machine it was already > 1, but that global is hard to locate; I found it by signature scanning.
Results
Tested on 19041, 1903, and the latest Windows 11 (on my physical machine):

References
[1] Fun With Another PG-Compliant Hook — https://revers.engineering/fun-with-pg-compliant-hook/
Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).
Get in touch