ObCallbacks: Stripping Dangerous Handles
This post covers how Peregrine registers an ObRegisterCallbacks pre-operation callback to intercept process handle requests, which access flags the driver considers dangerous, and how a spinlock-protected PID set determines which processes receive protection.
Seven Access Flags That Enable Cheating
Not all handle access rights are dangerous. A handle with PROCESS_QUERY_LIMITED_INFORMATION is harmless; every task manager on the system requests it. A handle with PROCESS_VM_WRITE can inject code into a running process. Peregrine defines the dangerous set as a single bitmask in obCallback.c:
// From: PeregrineKernelComponent/obCallback.c
#define DANGEROUS_PROCESS_ACCESS ( \
0x0001 /* PROCESS_TERMINATE */ | \
0x0002 /* PROCESS_CREATE_THREAD */ | \
0x0008 /* PROCESS_VM_OPERATION */ | \
0x0020 /* PROCESS_VM_WRITE */ | \
0x0040 /* PROCESS_DUP_HANDLE */ | \
0x0200 /* PROCESS_SET_INFORMATION */ | \
0x0800 /* PROCESS_SUSPEND_RESUME */ )
PROCESS_VM_READ (0x0010) is deliberately excluded. The source code comment notes this is to avoid noise: most diagnostic tools need read access, and reading alone is not sufficient for code injection [1]. The seven included flags each represent a capability that a cheat or injector needs: writing memory, creating remote threads, suspending the game loop, duplicating handles to escalate access, or terminating the process outright [1].
Callback Registration at Altitude 362249.1234
The registration function in obCallback.c builds an OB_CALLBACK_REGISTRATION structure, specifies an altitude string, and calls ObRegisterCallbacks [2]:
// From: PeregrineKernelComponent/obCallback.c
NTSTATUS createRegistration() {
OB_CALLBACK_REGISTRATION registrationInfo;
OB_OPERATION_REGISTRATION operationInfo;
RtlZeroMemory(®istrationInfo, sizeof(registrationInfo));
RtlZeroMemory(&operationInfo, sizeof(operationInfo));
operationInfo.ObjectType = PsProcessType;
operationInfo.Operations = OB_OPERATION_HANDLE_CREATE | OB_OPERATION_HANDLE_DUPLICATE;
operationInfo.PreOperation = CreateCallback;
registrationInfo.Version = OB_FLT_REGISTRATION_VERSION;
registrationInfo.OperationRegistrationCount = 1;
registrationInfo.RegistrationContext = NULL;
registrationInfo.OperationRegistration = &operationInfo;
UNICODE_STRING altitude;
RtlInitUnicodeString(&altitude, L"362249.1234");
registrationInfo.Altitude = altitude;
NTSTATUS status = ObRegisterCallbacks(®istrationInfo, &callbackRegistrationHandle);
if (!NT_SUCCESS(status)) {
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL,
"ObRegisterCallbacks failed 0x%08X\n", status);
}
return status;
}
The altitude 362249.1234 places Peregrine in the anti-virus callback range (320000-389999) defined by Microsoft’s altitude allocation guide [3]. Patchi chose to intercept both OB_OPERATION_HANDLE_CREATE (triggered by OpenProcess / NtOpenProcess) and OB_OPERATION_HANDLE_DUPLICATE (triggered by DuplicateHandle). The duplicate path matters because an attacker can open a handle with harmless access rights, then escalate it through duplication from a privileged process [2].
Only a PreOperation callback is registered; there is no PostOperation. The pre-operation path is where access rights can be modified or observed before the handle is created [2].
The Pre-Operation Callback: Four-Stage Triage
The callback runs on the requesting thread’s context at IRQL PASSIVE_LEVEL for process handles [4]. Every OpenProcess call on the system passes through it, so the logic is structured as a cascade of early returns. In obCallback.c:
// From: PeregrineKernelComponent/obCallback.c
OB_PREOP_CALLBACK_STATUS CreateCallback(
PVOID RegistrationContext,
POB_PRE_OPERATION_INFORMATION OperationInformation)
{
UNREFERENCED_PARAMETER(RegistrationContext);
/* 1. Kernel handles, always skip */
if (OperationInformation->KernelHandle)
return OB_PREOP_SUCCESS;
if (OperationInformation->ObjectType != *PsProcessType)
return OB_PREOP_SUCCESS;
PEPROCESS targetProc = (PEPROCESS)OperationInformation->Object;
if (targetProc == NULL)
return OB_PREOP_SUCCESS;
HANDLE targetPid = PsGetProcessId(targetProc);
/* 2. Only care about protected targets */
if (!StateIsPidProtected(targetPid))
return OB_PREOP_SUCCESS;
HANDLE callerPid = PsGetCurrentProcessId();
/* 3. Self-access, always skip */
if (callerPid == targetPid)
return OB_PREOP_SUCCESS;
/* 4. Caller is also protected (our own AC components), skip */
if (StateIsPidProtected(callerPid))
return OB_PREOP_SUCCESS;
The four stages, in order:
-
Kernel handles are always skipped. When
KernelHandleis set, the request originated from kernel mode, typically the memory manager, the I/O subsystem, or csrss.exe. Stripping these would cause a system crash or break fundamental OS operations [2]. -
Unprotected targets are ignored. If the target process is not in Peregrine’s protected PID set, the callback has no reason to intervene. This keeps the performance cost near zero for the vast majority of handle operations on the system.
-
Self-access is allowed. A process opening a handle to itself is normal. The CRT, the loader, and the process itself all do this for introspection.
-
Protected-to-protected access is trusted. If both the caller and the target are in the protected set, they are both Peregrine components (the game, the userland service) and should communicate freely.
Any handle request that passes all four checks reaches the access flag inspection.
Extracting Access Flags and Reporting to Userland
Once a request reaches the inspection stage, the callback extracts the DesiredAccess from the appropriate union member. The create and duplicate paths store it in different sub-structures within OB_PRE_OPERATION_INFORMATION [4]:
// From: PeregrineKernelComponent/obCallback.c
/* 5. Extract desired access */
ACCESS_MASK desiredAccess = 0;
const char* opName = "unknown";
if (OperationInformation->Operation == OB_OPERATION_HANDLE_CREATE) {
desiredAccess = OperationInformation->Parameters->CreateHandleInformation.DesiredAccess;
opName = "create";
} else if (OperationInformation->Operation == OB_OPERATION_HANDLE_DUPLICATE) {
desiredAccess = OperationInformation->Parameters->DuplicateHandleInformation.DesiredAccess;
opName = "duplicate";
} else {
return OB_PREOP_SUCCESS;
}
/* 6. Only report dangerous access */
if (!(desiredAccess & DANGEROUS_PROCESS_ACCESS))
return OB_PREOP_SUCCESS;
/* 7. Report to userland */
CHAR json[256];
RtlStringCchPrintfA(
json, ARRAYSIZE(json),
"{ \"event\": \"ob_callback\", \"op\": \"%s\", \"target_pid\": %lu, "
"\"caller_pid\": %lu, \"desired_access\": \"0x%08X\" }",
opName,
(ULONG)(ULONG_PTR)targetPid,
(ULONG)(ULONG_PTR)callerPid,
desiredAccess);
ComsSendToUser(json, (ULONG)strlen(json));
return OB_PREOP_SUCCESS;
}
If the access mask has no dangerous bits set, the callback returns immediately. Otherwise, it formats a JSON event containing the operation type, target PID, caller PID, and the full desired access mask, then sends it to the userland component via ComsSendToUser.
The callback currently reports suspicious handles but does not strip the dangerous flags. A production anti-cheat would typically zero out the dangerous bits with DesiredAccess &= ~DANGEROUS_PROCESS_ACCESS, neutering the handle in place so the caller receives a handle that lacks the access rights needed to do anything harmful. Peregrine’s implementation is detection-only. The developer left flag stripping out deliberately during development, since an overly aggressive stripping callback breaks legitimate system tools and makes debugging painful. Adding the one-liner is trivial once the detection logic is validated.
The Protected PID Registry: Spinlock-Guarded Fixed Array
The callback’s StateIsPidProtected check is backed by a fixed-size array guarded by a KSPIN_LOCK, defined in AppState.c:
// From: PeregrineKernelComponent/AppState.c
#define MAX_PIDS 32
typedef struct _APP_STATE {
HANDLE Pids[MAX_PIDS];
ULONG Count;
KSPIN_LOCK Lock;
} APP_STATE;
static APP_STATE g_State = { 0 };
The spinlock is necessary because the ObCallback runs on arbitrary thread contexts at IRQL up to DISPATCH_LEVEL, while PID additions and removals come from IOCTL handlers on a different thread [5]. The lookup is a linear scan:
// From: PeregrineKernelComponent/AppState.c
BOOLEAN StateIsPidProtected(HANDLE pid)
{
BOOLEAN found = FALSE;
KIRQL irql;
KeAcquireSpinLock(&g_State.Lock, &irql);
for (ULONG i = 0; i < g_State.Count; i++) {
if (g_State.Pids[i] == pid) { found = TRUE; break; }
}
KeReleaseSpinLock(&g_State.Lock, irql);
return found;
}
With a maximum of 32 entries, the linear scan is effectively free compared to the cost of the ObCallback dispatch itself. Thirty-two pointer comparisons under a spinlock adds negligible latency.
O(1) Removal With Swap-and-Pop
The removal function in AppState.c uses a swap-with-last strategy to avoid shifting array elements:
// From: PeregrineKernelComponent/AppState.c
NTSTATUS StateRemovePid(HANDLE pid)
{
KIRQL irql;
KeAcquireSpinLock(&g_State.Lock, &irql);
for (ULONG i = 0; i < g_State.Count; i++) {
if (g_State.Pids[i] == pid) {
g_State.Pids[i] = g_State.Pids[g_State.Count - 1];
g_State.Count--;
KeReleaseSpinLock(&g_State.Lock, irql);
return STATUS_SUCCESS;
}
}
KeReleaseSpinLock(&g_State.Lock, irql);
return STATUS_NOT_FOUND;
}
The swap-with-last trick gives O(1) removal at the cost of not preserving insertion order. Since the PID list is a set (order does not matter), this is a clean tradeoff. The addition function (StateAddPid) first scans for duplicates and returns STATUS_ALREADY_COMMITTED if the PID is already present, preventing the array from accumulating redundant entries.
The state module also provides StateClearPids and StateClearAll, both of which zero the array and reset the count under the spinlock. These are used during driver unload and when the userland component requests a full reset.
Unregistration on Driver Unload
On driver unload, unregisterRegistration calls ObUnRegisterCallbacks with the stored registration handle:
// From: PeregrineKernelComponent/obCallback.c
VOID unregisterRegistration(void) {
if (callbackRegistrationHandle != NULL) {
ObUnRegisterCallbacks(callbackRegistrationHandle);
callbackRegistrationHandle = NULL;
}
}
The kernel guarantees that once ObUnRegisterCallbacks returns, the callback will never be invoked again. There is no race with in-flight invocations [2].
Limitations
The current implementation has several known limitations:
- Detection-only, no enforcement. As noted above, the callback reports dangerous handle requests but does not strip the access flags. This means the handle is still usable for code injection or memory writes until the userland component acts on the event.
- Fixed PID capacity. The 32-entry limit is sufficient for the expected use case (one game process plus a few anti-cheat components), but it would need to grow for scenarios involving multiple protected applications.
- No thread object monitoring. The callback registers only for
PsProcessType. Thread handles with dangerous access rights (such as THREAD_SET_CONTEXT, which enables thread hijacking) are not monitored. Extending coverage toPsThreadTypewould require a second operation registration entry.
The core value of ObCallbacks is interception at the earliest possible point. The decision happens inside the Windows object manager itself, before the handle is created [2]. No userland hook can match this because the handle does not exist yet when the callback runs. The combination of a spinlock-protected PID set and a bitmask-based access check keeps the per-operation cost negligible, and the JSON event stream gives the userland component full context (caller PID, target PID, operation type, requested access) for policy decisions or forensic logging.
This post was generated by an LLM based on code from Peregrine Anti-Cheat. All code snippets are from the actual repository. Claims about Windows internals are sourced from Microsoft documentation.
References
[1] Microsoft, “Process Security and Access Rights” - learn.microsoft.com
[2] Microsoft, “ObRegisterCallbacks function” - learn.microsoft.com
[3] Microsoft, “Load order groups and altitudes for minifilter drivers” - learn.microsoft.com
[4] Microsoft, “OB_PRE_OPERATION_INFORMATION structure” - learn.microsoft.com
[5] Microsoft, “KeAcquireSpinLock function” - learn.microsoft.com