Fingerprinting anti-cheat by its pool allocation pattern
BattleEye’s initialization is a sequence of kernel allocations. Each one is a potential choke point. If you control what ExAllocatePool returns, you can prevent specific allocations from succeeding and break the initialization path that depends on them.
BEDaisy resolves both ObRegisterCallbacks and ExAllocatePool dynamically, through MmGetSystemRoutineAddress. A hook on that resolution intercepts both – the ObRegisterCallbacks path is covered in Intercepting BattleEye’s ObRegisterCallbacks at registration time. The ExAllocatePool path is a footnote there. It deserves its own treatment.
How the hook is installed
BEDaisy doesn’t import ExAllocatePool statically. Like ObRegisterCallbacks, it resolves pool allocation at runtime through MmGetSystemRoutineAddress. This is the same pattern described in Intercepting BattleEye’s ObRegisterCallbacks at registration time: dynamic resolution gives BE runtime control over symbol lookup and lets it interpose on callers.
The hook intercepts that resolution:
PVOID Hook_MmGetSystemRoutineAddress(PUNICODE_STRING SystemRoutineName)
{
UNICODE_STRING targetOb = RTL_CONSTANT_STRING(L"ObRegisterCallbacks");
UNICODE_STRING targetEa = RTL_CONSTANT_STRING(L"ExAllocatePool");
if (RtlEqualUnicodeString(SystemRoutineName, &targetOb, FALSE))
return &Hook_ObRegisterCallbacks;
if (RtlEqualUnicodeString(SystemRoutineName, &targetEa, FALSE))
return &Hook_ExAllocatePool;
return MmGetSystemRoutineAddress(SystemRoutineName);
}When BE calls MmGetSystemRoutineAddress and asks for "ExAllocatePool", it gets Hook_ExAllocatePool instead of the real kernel export. Every subsequent pool allocation BE makes through that resolved pointer goes through the hook.
The DriverEntry in the driver shows an IAT hook path (patching BEDaisy’s own IAT entry for MmGetSystemRoutineAddress) that’s been commented out in favour of hooking the kernel export directly. Either path delivers BE to Hook_MmGetSystemRoutineAddress. The mechanism for the kernel-export hook uses the same MDL remapping and trampoline approach described in the ObRegisterCallbacks post.
The allocation filter
The hook itself is short:
PVOID Hook_ExAllocatePool(POOL_TYPE PoolType, SIZE_T NumberOfBytes)
{
if (PoolType == 0x200 && NumberOfBytes == 0x1000 ||
PoolType == 0x200 && NumberOfBytes == 0x90)
return NULL;
return ExAllocatePool(PoolType, NumberOfBytes);
}Two conditions trigger a NULL return. Both require PoolType == 0x200. Everything else passes through to the real ExAllocatePool.
0x200 is NonPagedPoolNx, defined in wdm.h as decimal 512. NX nonpaged pool – nonpageable, accessible at any IRQL, and non-executable. The combination of pool type and specific sizes forms the fingerprint.
The two blocked sizes:
0x1000(4096 bytes): a page-sized NonPagedPoolNx allocation0x90(144 bytes): a sub-page NonPagedPoolNx allocation
Other NonPagedPoolNx allocations at different sizes pass through untouched. The filter is narrow by design – wide filters create false positives.
What a NULL return does to BE
ExAllocatePool returns NULL on allocation failure. Every well-written kernel driver checks this return value before use. A driver that doesn’t check will dereference NULL immediately and produce a bug check.
BEDaisy’s initialization path makes these allocations at a specific point. When the allocation returns NULL and BE’s code attempts to use the returned pointer without a null check – or even with one, if the allocation is treated as non-optional – initialization stops. BE doesn’t finish starting.
The specific failure mode depends on where in BE’s init sequence these allocations happen. That’s observable by running BE with the hook active and watching which point the initialization stalls. The source doesn’t claim to know the exact internal purpose of each allocation – only that blocking them stops BE from completing init.
Verify with DebugView: Run
dbgview.exeas administrator with kernel capture enabled before loading the driver. The hook can emit aDbgPrintExcall when a match fires, confirming which allocation was blocked. BE’s initialization output should stop at a predictable point when the hook is active.
Identifying the pattern
The natural question is how you arrive at (NonPagedPoolNx, 0x1000) and (NonPagedPoolNx, 0x90) as the right values to block. The answer is reverse engineering.
The approach:
- Load BEDaisy.sys in a controlled environment (a VM without BE’s user-mode enforcer, or via manual mapping)
- Hook
ExAllocatePoolwith a logging variant that records every(PoolType, NumberOfBytes, ReturnAddress)tuple during BE’s init path - Identify which allocations occur during early initialization – before the first tick of BE’s main monitoring loop
- Look for allocations that don’t appear in normal kernel driver initialization patterns
The logging variant looks structurally identical to the blocking version, but returns the real allocation and logs the parameters:
PVOID Hook_ExAllocatePool_Log(POOL_TYPE PoolType, SIZE_T NumberOfBytes)
{
PVOID result = ExAllocatePool(PoolType, NumberOfBytes);
/* log PoolType, NumberOfBytes, result, return address */
DbgPrintEx(0, 0, "[pool] type=0x%x size=0x%zx ret=%p caller=%p\n",
(ULONG)PoolType, NumberOfBytes, result,
(PVOID)_ReturnAddress());
return result;
}_ReturnAddress() is the MSVC intrinsic that captures the caller’s return address at runtime. Cross-referencing that address against BEDaisy’s loaded base gives you the offset into the binary, which you can then open in a disassembler to understand what each allocation is for.
After collecting the log, filter to allocations with PoolType == NonPagedPoolNx in the early init window. The sizes 0x1000 and 0x90 are what was identified as BE-specific patterns worth blocking. Whether they correspond to a reporting buffer, a context structure, or something else is left to whoever wants to disassemble the corresponding offsets in BEDaisy.
Verify with PoolMon: Run
poolmon.exe /bsorted by bytes before and after loading BE. Any new pool tags that appear during BE’s init are candidates for investigation. If the hook is blocking allocations, the tags associated with those allocations won’t appear at all.
The interception flow
False positive risk
The filter matches any NonPagedPoolNx allocation of exactly 4096 or 144 bytes – not just ones from BEDaisy. If another kernel component makes a NonPagedPoolNx allocation of those sizes through the same hooked pointer, it also gets NULL.
In practice, the hook is installed against the pointer BE resolved – not the kernel export globally. BE called MmGetSystemRoutineAddress and got back Hook_ExAllocatePool. The pointer BE holds is your hook. Other drivers that import ExAllocatePool statically, or resolve it independently via their own MmGetSystemRoutineAddress call, hit the real kernel export. They’re not affected.
The false positive risk is scoped to code that calls through the specific pointer BE resolved. In this architecture, that’s BE itself. The filter is narrow enough that accidental matches from unrelated allocations are unlikely within that scope.
If you’re logging rather than blocking, this scoping becomes visible immediately: the log will show you exactly which callers are hitting the hook and at what sizes.
Detection surface
The hook on MmGetSystemRoutineAddress is itself detectable. Any kernel-mode scanner that walks the export table of ntoskrnl.exe and compares the addresses of exported functions against their expected values will see the discrepancy. The kernel export points at a trampoline; a scanner that hashes export addresses or checks whether they fall within the expected .text range of ntoskrnl will flag it.
The pool allocations themselves are visible to any driver that calls the real ExAllocatePool. PoolMon and System Informer’s pool view show live allocations tagged by the allocating driver. If the blocked allocations would normally appear under BEDaisy’s pool tag and they’re absent, that’s an observable gap – though only to something looking specifically for that absence.
The bigger detection surface is the code cave in iorate.sys that the Hook_MmGetSystemRoutineAddress trampoline relies on. If BE walks loaded module images looking for tampered bytes in their .text sections, the 16-byte sequence written by patch_codecave_detour will stand out against the surrounding 0xCC padding. HVCI would prevent writing it in the first place.
None of this is novel risk. It’s the same surface exposed by the ObRegisterCallbacks interception described in the previous post. The pool filter is a secondary payload of the same hook, not a separate attack surface.