Intercepting BattleEye's ObRegisterCallbacks at registration time
The previous post covered what BEDaisy’s ObRegisterCallbacks registration actually does: strip PROCESS_VM_READ, PROCESS_VM_WRITE, PROCESS_VM_OPERATION, and PROCESS_ALL_ACCESS from any handle opened against the protected process. If you haven’t read it, the short version is that DesiredAccess gets neutered before the handle is created.
The obvious counter is to remove BE’s callback from the registered list. Walk the internal _OBJECT_TYPE callback list, find BEDaisy’s entry, unlink it. The handle stripping stops. The problem is that BEDaisy periodically audits its own registration. If the entry is gone or modified, it knows. That’s the approach that gets caught.
The approach covered here doesn’t touch the callback list at all. BE’s callback runs. It strips DesiredAccess. Then our callback runs and restores it. From BE’s perspective, the registration is intact, the callback fired, the telemetry is satisfied. The handle gets created with the original access mask anyway.
Why MmGetSystemRoutineAddress matters
BEDaisy doesn’t import ObRegisterCallbacks statically. It resolves kernel exports at runtime using MmGetSystemRoutineAddress – the kernel equivalent of GetProcAddress. The reason is operational: a static import appears in the IAT and is trivially visible to static analysis. Dynamic resolution through MmGetSystemRoutineAddress gives BE control over when and how the symbol is resolved, and lets it interpose on callers who try the same trick.
That interposition is exactly what the commented-out IATHook call in a BEDaisy emulator driver’s DriverEntry targets: patch BEDaisy’s own IAT entry for MmGetSystemRoutineAddress so every dynamic resolution BEDaisy performs runs through your intercept first. When BE resolves "ObRegisterCallbacks", it gets your Hook_ObRegisterCallbacks instead of the real export.
A companion intercept driver’s DriverEntry shows this path commented out in favour of hooking the kernel export directly:
/*
PVOID be_module = NULL;
while (true)
{
be_module = Utils::GetSystemModuleBase(L"BEDaisy.sys");
if (be_module != NULL) break;
}
if (be_module)
{
Utils::IATHook(be_module, "MmGetSystemRoutineAddress", &Hook_MmGetSystemRoutineAddress);
}
*/Either path achieves the same result: when BE calls MmGetSystemRoutineAddress and asks for "ObRegisterCallbacks", it gets your function pointer.
PVOID Hook_MmGetSystemRoutineAddress(PUNICODE_STRING SystemRoutineName)
{
if (wcsstr(SystemRoutineName->Buffer, L"ObRegisterCallbacks"))
return &Hook_ObRegisterCallbacks;
if (wcsstr(SystemRoutineName->Buffer, L"ExAllocatePool"))
return &Hook_ExAllocatePool;
return MmGetSystemRoutineAddress(SystemRoutineName);
}Everything except "ObRegisterCallbacks" and "ExAllocatePool" passes through to the real MmGetSystemRoutineAddress. BE gets valid pointers for everything it needs except the two you’re intercepting.
Intercepting the registration
When BE calls the pointer it got back from MmGetSystemRoutineAddress("ObRegisterCallbacks"), it lands in Hook_ObRegisterCallbacks. At this point you have the full OB_CALLBACK_REGISTRATION structure before it’s been passed to the real kernel function. That structure contains the PreOperation pointer – the function BE will call on every handle-create event.
Save it, then replace it with something you control:
static POB_PRE_OPERATION_CALLBACK _be_original_ob_callback = NULL;
static uint64_t _be_pre_ob_callback_cave = 0;
NTSTATUS Hook_ObRegisterCallbacks(
POB_CALLBACK_REGISTRATION callback_registration,
PVOID *registration_handle)
{
DbgPrintEx(0, 0, "[+] BE Called ObRegisterCallbacks\n");
_be_original_ob_callback =
callback_registration->OperationRegistration->PreOperation;
PRTL_PROCESS_MODULES modules = Utils::GetModuleList();
for (ULONG i = 0; i < modules->NumberOfModules; i++)
{
PRTL_PROCESS_MODULE_INFORMATION module = &modules->Modules[i];
if (!strstr((const char*)module->FullPathName, "iorate"))
continue;
_be_pre_ob_callback_cave =
Utils::find_codecave(module->ImageBase, 16);
if (_be_pre_ob_callback_cave != 0)
{
if (!Utils::patch_codecave_detour(
_be_pre_ob_callback_cave, (uint64_t)&HookCallback))
return STATUS_UNSUCCESSFUL;
callback_registration->OperationRegistration->PreOperation =
(POB_PRE_OPERATION_CALLBACK)_be_pre_ob_callback_cave;
ExFreePoolWithTag(modules, 0);
break;
}
}
return ObRegisterCallbacks(callback_registration, registration_handle);
}The key move is on the second-to-last line before ObRegisterCallbacks: PreOperation is replaced with the code cave address. The kernel sees a PreOperation pointer and validates it. That validation checks whether the address falls within a loaded signed module’s address range. Your unsigned driver’s memory doesn’t qualify. iorate.sys does.
Code caves in iorate.sys
The callback address validation passes because the cave lives inside iorate.sys – a Microsoft-signed disk I/O rate-limiting driver present on most Windows systems. The cave itself is just padding bytes the linker inserted after a return instruction.
find_codecave scans the .text section for a contiguous run of 0xCC (INT3) bytes of at least length N, immediately preceded by a RET opcode (0xC2 or 0xC3):
static QWORD find_codecave(VOID *module, INT length)
{
IMAGE_DOS_HEADER *dos = (IMAGE_DOS_HEADER *)module;
IMAGE_NT_HEADERS *nt = (IMAGE_NT_HEADERS *)((BYTE *)dos + dos->e_lfanew);
QWORD start = 0, size = 0;
QWORD header_offset = (QWORD)IMAGE_FIRST_SECTION(nt);
for (INT x = 0; x < nt->FileHeader.NumberOfSections; ++x)
{
IMAGE_SECTION_HEADER *hdr = (IMAGE_SECTION_HEADER *)header_offset;
if (strcmp((CHAR *)hdr->Name, ".text") == 0)
{
start = (QWORD)module + hdr->VirtualAddress;
size = hdr->Misc.VirtualSize;
break;
}
header_offset += sizeof(IMAGE_SECTION_HEADER);
}
QWORD match = 0;
INT curlength = 0;
BOOLEAN ret = FALSE;
for (QWORD cur = start; cur < start + size; ++cur)
{
if (!ret && is_retop(*(BYTE *)cur))
ret = TRUE;
else if (ret && *(BYTE *)cur == 0xCC)
{
if (!match) match = cur;
if (++curlength == length) return match;
}
else
{
match = curlength = 0;
ret = FALSE;
}
}
return 0;
}Sixteen bytes is enough. The trampoline written into the cave is exactly 16 bytes:
static BOOLEAN patch_codecave_detour(QWORD address, QWORD target)
{
BYTE assembly[16] = {
0x50, // push rax
0x48, 0xB8, 0x00,0x00,0x00,0x00,
0x00,0x00,0x00,0x00, // mov rax, TARGET
0x48, 0x87, 0x04, 0x24, // xchg qword ptr [rsp], rax
0xC3 // retn
};
*(QWORD *)(assembly + 3) = target;
return remap_page((VOID *)address, assembly, 16, FALSE);
}The sequence is a stack-swap trampoline. push rax saves rax onto the stack. mov rax, TARGET loads the real destination. xchg [rsp], rax atomically swaps: the return address slot on the stack gets TARGET’s address, and rax gets the original return address back. retn pops TARGET into rip, which is then executed with rax restored to whatever the caller had. The net effect: execution transfers to HookCallback with no visible register corruption.
remap_page uses MDL-based remapping (IoAllocateMdl / MmProbeAndLockPages / MmMapLockedPagesSpecifyCache / MmProtectMdlSystemAddress(PAGE_EXECUTE_READWRITE)) to write into the read-only page without tripping write-protection. This is the standard approach when CR0.WP toggling isn’t available or isn’t safe.
Verify with WinObj: Open WinObj as administrator → navigate to
\KernelObjects→ look for any custom object namespace entries. Separately, attach a kernel debugger and runu iorate+<offset>at the cave address found by the driver to confirm the trampoline bytes are present.
OB_PRE_OPERATION_INFORMATION: OriginalDesiredAccess vs DesiredAccess
The OB_PRE_OPERATION_INFORMATION structure passed to every registered pre-operation callback contains two access masks:
typedef struct _OB_PRE_CREATE_HANDLE_INFORMATION {
ACCESS_MASK DesiredAccess; // modifiable: what the caller will get
ACCESS_MASK OriginalDesiredAccess; // read-only: what the caller originally asked for
} OB_PRE_CREATE_HANDLE_INFORMATION;OriginalDesiredAccess is set once when the handle-open operation begins. It reflects what the caller passed to OpenProcess (or equivalent). It doesn’t change regardless of what callbacks do to DesiredAccess.
DesiredAccess is what callbacks can modify. BE’s pre-operation callback clears the dangerous bits from DesiredAccess. If the caller asked for PROCESS_ALL_ACCESS, BE’s callback sets DesiredAccess to something neutered. The kernel then creates the handle with the neutered mask.
Pre-operation OB callbacks fire highest altitude to lowest. To run before BE’s callback, the intercepting driver must register at a higher altitude than BEDaisy. In this design that ordering problem is avoided entirely: our driver does not register a separate OB callback at all. Instead it replaces BE’s own PreOperation pointer with the cave address. The kernel fires what it thinks is BE’s callback, which is actually HookCallback. HookCallback then manually calls the real BE callback. The ordering is ours to control because we own the dispatch.
Restoring DesiredAccess after BE’s callback
HookCallback is what ends up at the cave address. Its job is straightforward:
OB_PREOP_CALLBACK_STATUS HookCallback(
PVOID RegistrationContext,
POB_PRE_OPERATION_INFORMATION OperationInformation)
{
// BE's callback runs here -- it strips DesiredAccess, logs, updates
// its internal state. From its perspective, everything is normal.
OB_PREOP_CALLBACK_STATUS result =
_be_original_ob_callback(RegistrationContext, OperationInformation);
// Restore the original access mask after BE's modifications.
OperationInformation->Parameters->CreateHandleInformation.DesiredAccess =
OperationInformation->Parameters->CreateHandleInformation.OriginalDesiredAccess;
return result;
}BE’s callback fires. Its internal telemetry fires. Its logging fires. It modifies DesiredAccess exactly as it always has. Then HookCallback copies OriginalDesiredAccess back over DesiredAccess. The kernel proceeds with the original access mask and creates an unrestricted handle.
BE has no way to observe this within the callback. The post-callback DesiredAccess value isn’t something BE re-reads. The handle creation happens after the full callback chain completes, using whatever DesiredAccess is at that point.
Note: restoring DesiredAccess to a value higher than what the callback left violates documented OB callback semantics – callbacks are not supposed to add rights beyond DesiredAccess. This works because ObpCallPreOperationCallbacks does not enforce that constraint at runtime. That is undocumented behavior and could change.
The interception flow
Why this resists callback list auditing
The naive bypass – removing or unlinking BE’s callback entry – produces an observable gap: the _OBJECT_TYPE callback list no longer contains BE’s entry. BE can walk that list and detect the absence. Depending on the BEDaisy version, absence triggers an integrity alert, a ban event, or both.
This approach leaves the callback list intact. BE’s entry is registered. It points to _be_pre_ob_callback_cave – an address inside iorate.sys. The cave address is a valid signed-module address. If BE audits the registered callbacks and checks whether the stored PreOperation pointer falls within a known signed module, it passes: iorate.sys is signed.
BE’s callback fires on every relevant handle-open. From BE’s internal state, the registration is working as intended. The only thing BE can’t observe is that DesiredAccess gets written back after its callback returns.
The ExAllocatePool hook
Hook_MmGetSystemRoutineAddress also intercepts "ExAllocatePool" and blocks specific allocation sizes:
PVOID Hook_ExAllocatePool(POOL_TYPE PoolType, SIZE_T NumberOfBytes)
{
if (PoolType == 0x200 && NumberOfBytes == 0x1000 ||
PoolType == 0x200 && NumberOfBytes == 0x90)
return NULL;
return ExAllocatePool(PoolType, NumberOfBytes);
}0x200 is NonPagedPoolNx. The sizes 0x1000 and 0x90 correlate with allocation patterns in BEDaisy’s detection reporting path. Blocking them suppresses certain reporting allocations before they occur. The exact semantics are observable in BEDaisy itself – this post doesn’t claim more than the source shows.
Constraints
This technique requires writing to iorate.sys’s .text section. The write goes through MDL remapping, not a direct pointer write, but it still requires a writable mapping of a page that’s nominally read-only. On systems with HVCI enabled, the hypervisor enforces W^X on kernel pages. The MDL-remap path doesn’t bypass HVCI’s second-level address translation enforcement. The cave write fails and the hook doesn’t install.
On a non-HVCI system, write protection is only CR0.WP, and the MDL approach works around it cleanly without touching CR0 directly.
The hypervisor series covers operating below HVCI’s enforcement layer.