← PC Reverse Engineering

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).

ETW hooking — a kernel topic that never really goes away

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:

Call stack when an exception fires — takeover via 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:

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:

Entering the function — the magic number on the stack

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:

  1. Steal the pointer and replace it.
  2. Configure the NT Kernel Logger and enable ETW.
  3. The hard part — how to enable the PMC counter so execution actually reaches EtwpReserveWithPmcCounters.
  4. Walk the stack to locate the syscall routine.
  5. 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:

The guard that keeps EtwpReserveWithPmcCounters unreachable

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:

EtwSetPerformanceTraceInformation — the part to reverse

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):

Hooked on 19041/1903 and the latest Win11

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