Most of the public research on BattleEye’s kernel-mode handle protection focuses on one thing: ObRegisterCallbacks. Register a pre-operation callback, get called before any process handle is opened, strip the dangerous access flags. The mechanism is well-understood. What’s less discussed is how BEDaisy.sys intercepts your attempt to do the same thing, and why the naive bypass approaches don’t survive contact with the actual driver.

This is a writeup of what I found while reversing BEDaisy — specifically the IAT hook it uses to gate ObRegisterCallbacks callers, and the particular approach I landed on that differs from the Pink-Eye and BlindEye public implementations.

Nothing here is a disclosure. The mechanism is documented, the API is public, and I’m framing this as what it is: understanding how the protection works.

What BattleEye is protecting

When a process opens a handle to the game, the kernel invokes any registered OB_OPERATION_REGISTRATION callbacks before the handle is created. BEDaisy registers one of these callbacks on PsProcessType for OB_OPERATION_HANDLE_CREATE. Its pre-operation handler strips PROCESS_VM_READ, PROCESS_VM_WRITE, PROCESS_VM_OPERATION, and PROCESS_ALL_ACCESS from the requested access mask — so even if you ask for PROCESS_ALL_ACCESS, you get back a neutered handle.

The relevant structures from wdm.h:

c
// Passed to your pre-operation callback for every handle-create event
typedef struct _OB_PRE_OPERATION_INFORMATION {
    OB_OPERATION          Operation;        // HandleCreate or DuplicateHandle
    union { ULONG Flags; struct { ULONG KernelHandle : 1; }; };
    PVOID                 Object;           // the process object being opened
    POBJECT_TYPE          ObjectType;
    PVOID                 CallContext;
    POB_PRE_OPERATION_PARAMETERS Parameters;
} OB_PRE_OPERATION_INFORMATION, *POB_PRE_OPERATION_INFORMATION;

typedef struct _OB_PRE_CREATE_HANDLE_INFORMATION {
    ACCESS_MASK DesiredAccess;         // what the caller asked for (modifiable)
    ACCESS_MASK OriginalDesiredAccess; // what was asked for before any callbacks
} OB_PRE_CREATE_HANDLE_INFORMATION;

BEDaisy’s callback modifies Parameters->CreateHandleInformation.DesiredAccess, stripping flags before the handle is created. The fix everyone reaches for first is “just call ObRegisterCallbacks with your own callback that undoes the strip.” The problem is that BEDaisy anticipated this.

How BEDaisy intercepts ObRegisterCallbacks

BEDaisy doesn’t call ObRegisterCallbacks directly. It resolves kernel exports at runtime using MmGetSystemRoutineAddress — the kernel equivalent of GetProcAddress. And it patches its own Import Address Table to intercept that function.

MmGetSystemRoutineAddress is imported by BEDaisy.sys. The IAT contains a pointer to the kernel export at load time. BEDaisy walks its own IAT in its initialisation path, finds that entry, and replaces it with a pointer to its own hook. Every call to MmGetSystemRoutineAddress from anywhere within BEDaisy now hits that hook first.

The hook checks the requested export name. For "ObRegisterCallbacks", it returns a pointer to BEDaisy’s own intercept function rather than the real kernel export. Any driver that resolves ObRegisterCallbacks through the normal path — a call to MmGetSystemRoutineAddress that ends up in BEDaisy’s hook — hands its registration directly to BE’s interception code.

The full interception chain

Our KM driverHook_MmGetSysRoutineAddrhooks exportntoskrnl.exeMmGetSystemRoutineAddressBEDaisy.sys IATMmGetSysRoutineAddr ptrpatched to point at →BEDaisy hook fnintercepts ObRegCallbackscalls kernel export (our hook)Hook_ObRegisterCallbackssaves BE callback, redirects to code caveregisters cave as callbackcode cave in iorate.sys16-byte trampoline → HookCallback()HookCallback: call BE's callback → restore DesiredAccess from OriginalDesiredAccess

The naive bypass and why it fails

The obvious move: resolve ObRegisterCallbacks directly (it’s an exported ntoskrnl function — get its address from the PEB kernel module list without going through MmGetSystemRoutineAddress). This bypasses BEDaisy’s IAT hook entirely. The problem is that BEDaisy tracks registered callbacks. It can walk the internal callback list and flag any entry it didn’t register through its own proxy. The moment it detects an unrecognised registration on PsProcessType, it reacts.

The less obvious move: hook into BEDaisy’s hook. Which is what I did.

The IAT hook on MmGetSystemRoutineAddress

Our driver hooks MmGetSystemRoutineAddress at the kernel export level — a trampoline at the real function. When BEDaisy calls MmGetSystemRoutineAddress through its patched IAT, the call chain is:

BEDaisy IAT → BEDaisy’s own hook → (calls kernel) → our trampoline

Our trampoline runs first, intercepts the lookup, and routes it:

c
// Our hook on the kernel export itself
PVOID Hook_MmGetSystemRoutineAddress(PUNICODE_STRING SystemRoutineName)
{
    if (wcsstr(SystemRoutineName->Buffer, L"ObRegisterCallbacks"))
        return (PVOID)&Hook_ObRegisterCallbacks;   // ← intercept registration
    if (wcsstr(SystemRoutineName->Buffer, L"ExAllocatePool"))
        return (PVOID)&Hook_ExAllocatePool;        // ← intercept allocations
    return MmGetSystemRoutineAddress(SystemRoutineName);  // pass everything else through
}

When BEDaisy resolves ObRegisterCallbacks, it gets our Hook_ObRegisterCallbacks. We now see exactly what callback structure it’s about to register.

The call-through-and-repair pattern

Public implementations (Pink-Eye, BlindEye) tend to suppress or replace BE’s callback outright. That’s detectable — BE’s own runtime state no longer matches what it registered, and BE can check.

My approach: call through to BE’s callback, let it do exactly what it wants, then repair the side-effect.

c
// Saved during Hook_ObRegisterCallbacks
static POB_PRE_OPERATION_CALLBACK _be_original_ob_callback = NULL;
static PVOID _be_pre_ob_callback_cave = NULL;

OB_PREOP_CALLBACK_STATUS HookCallback(
    PVOID RegistrationContext,
    POB_PRE_OPERATION_INFORMATION OperationInformation)
{
    // Let BE's callback run — it strips DesiredAccess, logs what it wants,
    // updates its internal state. From BE's perspective, everything is normal.
    OB_PREOP_CALLBACK_STATUS result = _be_original_ob_callback
        ? _be_original_ob_callback(RegistrationContext, OperationInformation)
        : OB_PREOP_SUCCESS;

    // Repair: restore the access mask to what was originally requested,
    // before any callback had a chance to modify it.
    OperationInformation->Parameters->CreateHandleInformation.DesiredAccess =
        OperationInformation->Parameters->CreateHandleInformation.OriginalDesiredAccess;

    return result;
}

OriginalDesiredAccess is the access mask as requested by the caller before the callback chain ran. BE has executed its callback, its internal telemetry is satisfied, and we quietly restore the original access afterward. The handle is created with full access. BE thinks it stripped it.

The code cave problem

HookCallback can’t live in an unsigned driver’s memory and be accepted by ObRegisterCallbacks. The kernel validates that all registered callbacks reside in signed-module address ranges. Your unsigned kernel driver doesn’t qualify.

The solution: find a code cave in a legitimate signed driver and write a 16-byte detour there. I use iorate.sys — a Microsoft-signed disk I/O rate-limiting driver present on most systems with predictable zero-padded regions at the end of its .text section.

c
NTSTATUS Hook_ObRegisterCallbacks(
    POB_CALLBACK_REGISTRATION CallbackRegistration,
    PVOID *RegistrationHandle)
{
    // 1. Save BE's pre-operation callback before we redirect it
    _be_original_ob_callback =
        CallbackRegistration->OperationRegistration->PreOperation;

    // 2. Find a 16-byte zero region in iorate.sys .text section
    PLDR_DATA_TABLE_ENTRY iorate = find_loaded_module(L"iorate.sys");
    _be_pre_ob_callback_cave = find_code_cave(iorate->DllBase, 16);

    // 3. Patch a JMP [rip+0] + absolute address into the cave
    //    (14 bytes — fits easily in the 16-byte cavity)
    write_absolute_jmp(_be_pre_ob_callback_cave, (ULONG64)&HookCallback);

    // 4. Point the registration at the cave — signed module address, passes validation
    CallbackRegistration->OperationRegistration->PreOperation =
        (POB_PRE_OPERATION_CALLBACK)_be_pre_ob_callback_cave;

    // 5. Let ObRegisterCallbacks proceed normally
    return ObRegisterCallbacks(CallbackRegistration, RegistrationHandle);
}

The cave address is inside iorate.sys’s text section — a signed Microsoft driver. The kernel’s callback-address validation sees a valid signed-module range and accepts it. From there the trampoline immediately jumps to HookCallback in our unsigned driver. The signing check doesn’t recurse.

The ExAllocatePool hook

Hook_MmGetSystemRoutineAddress also intercepts ExAllocatePool and drops specific allocation patterns:

c
PVOID Hook_ExAllocatePool(POOL_TYPE PoolType, SIZE_T NumberOfBytes)
{
    // Block specific NonPagedPoolNx (0x200) allocations at known sizes.
    // These correspond to allocation patterns in BE's reporting path.
    // Public implementations block 24-byte PagedPool — these are different targets.
    if (PoolType == 0x200 &&
       (NumberOfBytes == 0x1000 || NumberOfBytes == 0x90))
        return NULL;
    return ExAllocatePool(PoolType, NumberOfBytes);
}

PoolType 0x200 is NonPagedPoolNx. The specific sizes correlate with allocation patterns in BEDaisy’s detection reporting infrastructure. I won’t speculate further on the exact semantics — the effect is observable and the exact mechanism is left to anyone else who wants to reverse it.

The bigger picture

The call-through-and-repair pattern works because it doesn’t change what BE observes. Its callback fires, its internal state updates, its logging runs — and then we quietly undo the only thing we care about. BE has no mechanism to detect that DesiredAccess was modified after its callback returned, because the kernel doesn’t re-call callbacks after the fact.

This entire chain only works in environments where you can write to signed kernel modules — which means no HVCI. On a hardened system, the code cave approach fails at the first write to iorate.sys. That’s not a bug in this technique; it’s the correct trade-off point between techniques. For the HVCI case, the right answer is the hypervisor series — operating below the layer where HVCI has any leverage.