When you call OpenProcess(PROCESS_ALL_ACCESS, FALSE, edrPid), you’re not touching the EDR’s process directly. You’re asking the kernel to create a handle. The kernel calls every registered OB_OPERATION_REGISTRATION callback before it returns that handle. If the EDR driver registered one, it runs right there, in kernel mode, before you see anything. It can zero out every access bit you asked for. You get a handle. It’s just useless.

This is handle stripping via ObRegisterCallbacks. It’s been the standard EDR self-protection mechanism since Windows Vista SP1 / Windows Server 2008. Still effective, and the details are worth understanding end to end.

What the kernel does on OpenProcess

OpenProcess issues NtOpenProcess. The object manager resolves the target EPROCESS, checks security descriptors, then walks the registered callback list for PsProcessType before granting the handle. The relevant callback type is OB_OPERATION_HANDLE_CREATE.

There’s also OB_OPERATION_HANDLE_DUPLICATE. You need both. If you only register for HANDLE_CREATE, an attacker can call DuplicateHandle from a process that already has an existing handle with elevated rights. Inheritance is another path. Register for both or the protection has a hole.

The structures from wdm.h:

c
typedef struct _OB_PRE_OPERATION_INFORMATION {
    OB_OPERATION                   Operation;    // HANDLE_CREATE or HANDLE_DUPLICATE
    union {
        ULONG Flags;
        struct { ULONG KernelHandle : 1; ULONG Reserved : 31; };
    };
    PVOID                          Object;       // PEPROCESS 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 -- modify this
    ACCESS_MASK OriginalDesiredAccess;  // what was asked for before any callbacks ran
} OB_PRE_CREATE_HANDLE_INFORMATION;

DesiredAccess is what you modify. OriginalDesiredAccess is read-only – it’s the value before the callback chain touched anything. An attacker who knows about this field can read it in a lower-altitude pre-operation callback running before the EDR’s callback fires (that’s the BattleEye bypass story in a separate post).

Altitude: what 320000 means

Every registered callback has an altitude string. The object manager calls pre-operation callbacks in altitude order, highest to lowest. The altitude string "320000" sits in the 320000329999 range that FSFilter minifilter documentation lists for anti-malware products. OB callbacks borrow this convention; Microsoft does not formally publish per-category altitude ranges for ObRegisterCallbacks the way it does for minifilters. It’s a convention, not enforcement. Two drivers at the same altitude get called in registration order.

What altitude affects in practice: if your driver registers at 320000 and a rootkit registers at 320001, the rootkit’s callback runs first and can undo your strip before your callback fires. This is why some EDRs watch the callback list for unexpected entries.

The registration structs

ObRegisterCallbacks takes a pointer to OB_CALLBACK_REGISTRATION, which holds an array of OB_OPERATION_REGISTRATION entries:

c
typedef struct _OB_OPERATION_REGISTRATION {
    POBJECT_TYPE                *ObjectType;       // &PsProcessType or &PsThreadType
    OB_OPERATION                Operations;        // HANDLE_CREATE | HANDLE_DUPLICATE
    POB_PRE_OPERATION_CALLBACK  PreOperation;      // called before handle is granted
    POB_POST_OPERATION_CALLBACK PostOperation;     // called after (can't modify access here)
} OB_OPERATION_REGISTRATION, *POB_OPERATION_REGISTRATION;

typedef struct _OB_CALLBACK_REGISTRATION {
    USHORT                       Version;                      // OB_FLT_REGISTRATION_VERSION
    USHORT                       OperationRegistrationCount;
    UNICODE_STRING               Altitude;
    PVOID                        RegistrationContext;
    OB_OPERATION_REGISTRATION   *OperationRegistration;
} OB_CALLBACK_REGISTRATION, *POB_CALLBACK_REGISTRATION;

ObRegisterCallbacks returns a registration handle. You pass that handle to ObUnRegisterCallbacks on unload. Forgetting this on DriverUnload leaves dangling pointers in the kernel callback list and eventually causes a bugcheck.

The signed-driver requirement

ObRegisterCallbacks enforces that the calling driver’s image is signed with a valid Kernel Mode Code Signing (KMCS) certificate. Internally the check goes through MmVerifyCallbackFunction, which validates the image’s signature against WHQL or cross-certificate chains. The specific EKU OID commonly cited for this (1.3.6.1.4.1.311.10.3.20) has not been independently verified against ntoskrnl disassembly – treat it as a reference point, not a confirmed requirement. The kernel validates this at registration time.

On post-2015 Windows (after Microsoft turned on KMCS enforcement for all kernel modules), you need either a real EV code signing certificate with WHQL attestation, or test-signing mode enabled (bcdedit /set testsigning on). An unsigned driver calling ObRegisterCallbacks gets STATUS_ACCESS_DENIED back.

This is distinct from DSE. DSE controls whether the driver loads at all. The ObRegisterCallbacks signing check is an additional restriction on who can register callbacks even after loading. The DSE bypass post covers loading unsigned drivers; that gets you past the load gate but not past this check. If you want to call ObRegisterCallbacks from an unsigned driver in a test environment, test-signing mode is the only clean path.

Call flow

user modeOpenProcess(ALL_ACCESS, pid)syscall NtOpenProcessobject managerresolves EPROCESS, checks DACLwalk OB callback list (altitude order)PreOperation callbacks fireEDR callback: strip DesiredAccess bitsaccess = 0handle opens butuselessaccess = QUERY_LIMITEDhandle opens withreduced rightshandle created with modified accessPostOperation callbacks fire (read-only at this point)handle returned to user modeGetLastError() == 0, but access is stripped

The driver implementation

A minimal driver that registers handle stripping callbacks and exposes an IOCTL to set the protected PID.

ioctl_defs.h – shared between driver and user-mode client:

c
#pragma once

#define DEVICE_NAME     L"\\Device\\HandleGuard"
#define SYMLINK_NAME    L"\\DosDevices\\HandleGuard"

// CTL_CODE(FILE_DEVICE_UNKNOWN=0x22, 0x800, METHOD_BUFFERED=0, FILE_ANY_ACCESS=0)
#define IOCTL_SET_PROTECTED_PID  CTL_CODE(0x22, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)
#define IOCTL_CLEAR_PROTECTED_PID CTL_CODE(0x22, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS)

typedef struct _SET_PID_REQUEST {
    ULONG Pid;
} SET_PID_REQUEST, *PSET_PID_REQUEST;

handle_guard.c – the full driver:

c
#include <ntddk.h>
#include <wdm.h>
#include "ioctl_defs.h"

// Globals
static PDEVICE_OBJECT  g_DeviceObject     = NULL;
static PVOID           g_CbHandle         = NULL;
static volatile HANDLE g_ProtectedPid     = NULL;

// Forward declarations
static OB_PREOP_CALLBACK_STATUS PreOpCallback(PVOID Context,
    POB_PRE_OPERATION_INFORMATION Info);
static VOID PostOpCallback(PVOID Context,
    POB_POST_OPERATION_INFORMATION Info);
static NTSTATUS RegisterCallbacks(VOID);
static VOID     UnregisterCallbacks(VOID);
static NTSTATUS DispatchIoctl(PDEVICE_OBJECT DevObj, PIRP Irp);
static NTSTATUS DispatchCreateClose(PDEVICE_OBJECT DevObj, PIRP Irp);
DRIVER_UNLOAD DriverUnload;

// Pre-operation callback: strip dangerous access bits
static OB_PREOP_CALLBACK_STATUS
PreOpCallback(PVOID Context, POB_PRE_OPERATION_INFORMATION Info)
{
    UNREFERENCED_PARAMETER(Context);

    // Only care about process objects
    if (Info->ObjectType != *PsProcessType)
        return OB_PREOP_SUCCESS;

    // Skip handles opened with OBJ_KERNEL_HANDLE -- kernel-internal operations
    if (Info->KernelHandle)
        return OB_PREOP_SUCCESS;

    PEPROCESS target = (PEPROCESS)Info->Object;
    HANDLE    targetPid = PsGetProcessId(target);

    if (targetPid != g_ProtectedPid)
        return OB_PREOP_SUCCESS;

    // Access mask surgery: strip everything except QUERY_LIMITED_INFORMATION.
    // PROCESS_QUERY_LIMITED_INFORMATION (0x1000) lets the caller read basic
    // process info (exit code, image name, timing) but not inject or read memory.
    // We keep it because blocking it entirely causes some legitimate Windows
    // components to malfunction (e.g. task manager reads it to show image names).
    if (Info->Operation == OB_OPERATION_HANDLE_CREATE) {
        Info->Parameters->CreateHandleInformation.DesiredAccess &=
            PROCESS_QUERY_LIMITED_INFORMATION;
    } else {
        // OB_OPERATION_HANDLE_DUPLICATE: same strip on the duplicate target
        Info->Parameters->DuplicateHandleInformation.DesiredAccess &=
            PROCESS_QUERY_LIMITED_INFORMATION;
    }

    return OB_PREOP_SUCCESS;
}

static VOID
PostOpCallback(PVOID Context, POB_POST_OPERATION_INFORMATION Info)
{
    UNREFERENCED_PARAMETER(Context);
    UNREFERENCED_PARAMETER(Info);
    // Post-callback can inspect but cannot modify GrantedAccess on process objects.
    // Nothing to do here.
}

static NTSTATUS RegisterCallbacks(VOID)
{
    UNICODE_STRING altitude;
    RtlInitUnicodeString(&altitude, L"320000");

    OB_OPERATION_REGISTRATION opReg = { 0 };
    opReg.ObjectType  = PsProcessType;
    opReg.Operations  = OB_OPERATION_HANDLE_CREATE | OB_OPERATION_HANDLE_DUPLICATE;
    opReg.PreOperation  = PreOpCallback;
    opReg.PostOperation = PostOpCallback;

    OB_CALLBACK_REGISTRATION reg = { 0 };
    reg.Version                    = OB_FLT_REGISTRATION_VERSION;
    reg.OperationRegistrationCount = 1;
    reg.Altitude                   = altitude;
    reg.RegistrationContext        = NULL;
    reg.OperationRegistration      = &opReg;

    return ObRegisterCallbacks(&reg, &g_CbHandle);
}

static VOID UnregisterCallbacks(VOID)
{
    if (g_CbHandle) {
        ObUnRegisterCallbacks(g_CbHandle);
        g_CbHandle = NULL;
    }
}

static NTSTATUS
DispatchCreateClose(PDEVICE_OBJECT DevObj, PIRP Irp)
{
    UNREFERENCED_PARAMETER(DevObj);
    Irp->IoStatus.Status      = STATUS_SUCCESS;
    Irp->IoStatus.Information = 0;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
    return STATUS_SUCCESS;
}

static NTSTATUS
DispatchIoctl(PDEVICE_OBJECT DevObj, PIRP Irp)
{
    UNREFERENCED_PARAMETER(DevObj);
    PIO_STACK_LOCATION sp     = IoGetCurrentIrpStackLocation(Irp);
    ULONG              code   = sp->Parameters.DeviceIoControl.IoControlCode;
    NTSTATUS           status = STATUS_SUCCESS;
    ULONG_PTR          info   = 0;

    switch (code) {
    case IOCTL_SET_PROTECTED_PID: {
        ULONG inLen = sp->Parameters.DeviceIoControl.InputBufferLength;
        if (inLen < sizeof(SET_PID_REQUEST)) {
            status = STATUS_BUFFER_TOO_SMALL;
            break;
        }
        PSET_PID_REQUEST req = (PSET_PID_REQUEST)Irp->AssociatedIrp.SystemBuffer;
        // InterlockedExchangePointer for atomic update -- PID is a HANDLE (pointer-sized)
        InterlockedExchangePointer(&g_ProtectedPid, (PVOID)(ULONG_PTR)req->Pid);
        DbgPrint("[handle_guard] protecting PID %lu\n", req->Pid);
        break;
    }
    case IOCTL_CLEAR_PROTECTED_PID:
        InterlockedExchangePointer(&g_ProtectedPid, NULL);
        DbgPrint("[handle_guard] protection cleared\n");
        break;
    default:
        status = STATUS_INVALID_DEVICE_REQUEST;
        break;
    }

    Irp->IoStatus.Status      = status;
    Irp->IoStatus.Information = info;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
    return status;
}

VOID DriverUnload(PDRIVER_OBJECT DriverObject)
{
    // Unregister before destroying the device.
    // Callbacks can still fire until ObUnRegisterCallbacks returns.
    UnregisterCallbacks();

    UNICODE_STRING symlink;
    RtlInitUnicodeString(&symlink, SYMLINK_NAME);
    IoDeleteSymbolicLink(&symlink);
    IoDeleteDevice(DriverObject->DeviceObject);
    DbgPrint("[handle_guard] unloaded\n");
}

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
    UNREFERENCED_PARAMETER(RegistryPath);

    UNICODE_STRING devName, symlink;
    RtlInitUnicodeString(&devName,  DEVICE_NAME);
    RtlInitUnicodeString(&symlink,  SYMLINK_NAME);

    NTSTATUS status = IoCreateDevice(DriverObject, 0, &devName,
                                     FILE_DEVICE_UNKNOWN, 0, FALSE,
                                     &g_DeviceObject);
    if (!NT_SUCCESS(status)) {
        DbgPrint("[handle_guard] IoCreateDevice failed: 0x%08X\n", status);
        return status;
    }

    status = IoCreateSymbolicLink(&symlink, &devName);
    if (!NT_SUCCESS(status)) {
        IoDeleteDevice(g_DeviceObject);
        return status;
    }

    // Register callbacks before the device is fully accessible.
    status = RegisterCallbacks();
    if (!NT_SUCCESS(status)) {
        DbgPrint("[handle_guard] ObRegisterCallbacks failed: 0x%08X\n", status);
        IoDeleteSymbolicLink(&symlink);
        IoDeleteDevice(g_DeviceObject);
        return status;
    }

    DriverObject->DriverUnload                          = DriverUnload;
    DriverObject->MajorFunction[IRP_MJ_CREATE]         = DispatchCreateClose;
    DriverObject->MajorFunction[IRP_MJ_CLOSE]          = DispatchCreateClose;
    DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DispatchIoctl;

    // Use direct I/O for simplicity; IOCTL_SET_PROTECTED_PID uses METHOD_BUFFERED
    // so the I/O manager copies the input buffer automatically.
    g_DeviceObject->Flags |= DO_BUFFERED_IO;
    g_DeviceObject->Flags &= ~DO_DEVICE_INITIALIZING;

    DbgPrint("[handle_guard] loaded, awaiting PID via IOCTL\n");
    return STATUS_SUCCESS;
}

A few notes on the implementation:

g_ProtectedPid is volatile HANDLE. HANDLE is pointer-sized (ULONG_PTR internally). Using InterlockedExchangePointer gives you an atomic swap without a spinlock. Two IOCTLs racing to set the PID could tear without it.

RegisterCallbacks is called before DriverObject->MajorFunction is populated. That ordering doesn’t matter here – callbacks fire on any OpenProcess against the target PID, not on IRPs to the device. If ObRegisterCallbacks fails (unsigned driver, wrong signing cert, duplicate altitude registration), bail from DriverEntry and don’t leave a partial device setup.

KernelHandle is TRUE when the handle was opened with OBJ_KERNEL_HANDLE – these are kernel-internal operations. Kernel-mode callers without OBJ_KERNEL_HANDLE still appear with KernelHandle=FALSE and will be filtered by this callback. The check skips only the intentionally kernel-private handles, not all kernel callers. Without it, kernel components opening handles with OBJ_KERNEL_HANDLE against the protected process would get those handles stripped, which breaks legitimate kernel operations.

What the attacker sees

User-mode code calling OpenProcess against a protected PID:

c
// attacker.c
HANDLE h = OpenProcess(PROCESS_ALL_ACCESS, FALSE, edrPid);
if (h == NULL) {
    printf("OpenProcess failed: %lu\n", GetLastError());
    return;
}
// h is non-NULL -- the call succeeded
DWORD exitCode = 0;
BOOL ok = GetExitCodeProcess(h, &exitCode);
printf("GetExitCode: %s\n", ok ? "ok" : "failed");

// Try to read memory
BYTE buf[8];
SIZE_T read = 0;
BOOL rpmOk = ReadProcessMemory(h, (PVOID)0x7FFE0000, buf, sizeof(buf), &read);
printf("ReadProcessMemory: %s, error=%lu\n", rpmOk ? "ok" : "FAILED", GetLastError());

CloseHandle(h);

Output with the driver protecting the EDR’s PID:

text
OpenProcess succeeded (handle is non-NULL)
GetExitCode: ok        <-- PROCESS_QUERY_LIMITED_INFORMATION allows this
ReadProcessMemory: FAILED, error=5   <-- ERROR_ACCESS_DENIED

OpenProcess returns a valid non-NULL handle. GetLastError() is zero. The call didn’t fail. But the handle has only PROCESS_QUERY_LIMITED_INFORMATION (0x1000). ReadProcessMemory requires PROCESS_VM_READ (0x0010). That bit was stripped. The read fails with ERROR_ACCESS_DENIED.

GetExitCodeProcess works because it needs only PROCESS_QUERY_INFORMATION (0x0400) or PROCESS_QUERY_LIMITED_INFORMATION (0x1000), which the callback preserved. This is intentional. Applications like Task Manager call GetExitCodeProcess on arbitrary PIDs to detect terminated processes. Strip that too and you break observable system behavior.

The access bits that survive the strip: PROCESS_QUERY_LIMITED_INFORMATION only. Everything else, including PROCESS_TERMINATE, PROCESS_VM_READ, PROCESS_VM_WRITE, PROCESS_VM_OPERATION, PROCESS_CREATE_THREAD, PROCESS_SUSPEND_RESUME, is gone.

Kernel-mode code reading target process memory bypasses handle access masks entirely — MmCopyVirtualMemory is the undocumented kernel export behind most cross-process reads, and it has no interest in what your callback stripped.

An attacker who knows the callback is active can inspect OriginalDesiredAccess in a lower-altitude pre-operation callback (it’s readable before the EDR’s callback strips it), but they can’t modify GrantedAccess in a post-callback for process objects. The kernel makes GrantedAccess read-only at that point. The only place to restore access is a pre-operation callback – and that puts you back in the same arms race the BattleEye post describes.

What PROCESS_QUERY_LIMITED_INFORMATION actually allows

PROCESS_QUERY_LIMITED_INFORMATION was added in Vista as a lower-privilege subset of PROCESS_QUERY_INFORMATION. Access rights it enables:

  • GetExitCodeProcess
  • GetProcessTimes
  • QueryProcessCycleTime
  • GetProcessId (can also work on pseudohandles)
  • QueryFullProcessImageName

Rights it does not enable: memory operations, thread enumeration, handle table access, token manipulation, debug port attachment. An attacker with only this level can confirm the process exists and is running, and get its image name. That’s it.

EDRs preserve it rather than zeroing access entirely for the same reason: some Windows system components (including CSRSS and Task Manager) open handles to every process on the system at limited info level. Zeroing the access mask outright causes those operations to return handles that fail on the first use, which produces detectable anomalies in system behavior.

IOCTL from user mode

Setting the protected PID from a user-mode tool:

c
// set_pid.c
#include <windows.h>
#include <stdio.h>
#include "ioctl_defs.h"

int main(int argc, char **argv) {
    if (argc < 2) { printf("usage: set_pid <pid>\n"); return 1; }

    HANDLE h = CreateFileW(L"\\\\.\\HandleGuard",
                           GENERIC_READ | GENERIC_WRITE,
                           0, NULL, OPEN_EXISTING, 0, NULL);
    if (h == INVALID_HANDLE_VALUE) {
        printf("CreateFile failed: %lu\n", GetLastError());
        return 1;
    }

    SET_PID_REQUEST req = { (ULONG)atoi(argv[1]) };
    DWORD returned = 0;
    BOOL ok = DeviceIoControl(h, IOCTL_SET_PROTECTED_PID,
                              &req, sizeof(req),
                              NULL, 0, &returned, NULL);
    printf("%s\n", ok ? "protection set" : "failed");
    CloseHandle(h);
    return ok ? 0 : 1;
}

The IOCTL uses METHOD_BUFFERED, so the I/O manager copies the input into Irp->AssociatedIrp.SystemBuffer before calling the dispatch routine. No ProbeForRead needed on the buffer. See the IOCTL dispatch post for how METHOD_BUFFERED vs METHOD_NEITHER affects this.

HVCI

On a system with Hypervisor-Protected Code Integrity enabled, the signed-driver requirement still applies, but there’s an additional constraint: HVCI uses VTL1 to enforce that no kernel-mode page is simultaneously writable and executable. This doesn’t affect ObRegisterCallbacks directly – it’s a normal kernel API call with no page table manipulation involved. A properly signed driver running in VTL0 can still register callbacks on an HVCI system.

What HVCI does affect is the code cave approach used to bypass the signing check from an unsigned driver. On HVCI, you can’t write a trampoline into a signed driver’s text section. The hypervisor rejects the write. This driver, being intended for legitimate use with a real signature, is unaffected.

Callback lifetime and race conditions

The gap between UnregisterCallbacks and device teardown in DriverUnload matters. After ObUnRegisterCallbacks returns, no new callback invocations will start. But a callback already executing on another CPU can still be in PreOpCallback when ObUnRegisterCallbacks returns. The kernel handles this: ObUnRegisterCallbacks waits for all in-flight callbacks to complete before returning.

The other race: a handle open arriving between RegisterCallbacks and DriverEntry setting up dispatch routines. This isn’t a problem here because RegisterCallbacks fires before DO_DEVICE_INITIALIZING is cleared. The device isn’t accessible to user mode yet. No IOCTL can arrive. The protected PID is NULL. The callback fires but hits the targetPid != g_ProtectedPid check and returns immediately.

Once DriverEntry returns and the device is accessible, IOCTLs and handle opens can race. InterlockedExchangePointer on g_ProtectedPid makes the PID update atomic. A handle open arriving mid-IOCTL either sees the old PID or the new one. It won’t see a torn pointer.