Scanning the Kernel: Drivers, Callbacks, and System Integrity

This post covers the three kernel-side scanning modules in Peregrine that assess the integrity of the execution environment: a blacklist scan of loaded drivers, an enumeration of registered ObCallbacks, and a set of system integrity checks covering test-signing, HVCI, and hypervisor presence. These scans answer three questions: what drivers are loaded, who else registered ObCallbacks, and is the system’s code integrity configuration sane.

Twenty Known Cheat and Exploit Drivers

The first scan is a filename-based blacklist check against all loaded kernel modules. The blacklist in DriverScan.c contains 20 entries:

// From: PeregrineKernelComponent/DriverScan.c
static const char* g_DriverBlacklist[] = {
    "dbk64.sys",        // Cheat Engine
    "dbk32.sys",        // Cheat Engine 32-bit
    "kdmapper.sys",     // manual mapper
    "capcom.sys",       // exploit driver
    "cpuz141.sys",      // CPU-Z exploit
    "iqvw64e.sys",      // Intel NAL exploit
    "gdrv.sys",         // Gigabyte exploit
    "winring0.sys",     // WinRing0 exploit
    "winring0x64.sys",  // WinRing0 x64
    "asrdrv106.sys",    // ASRock exploit
    "ene.sys",          // ENE exploit
    "bsmi.sys",         // Biostar exploit
    "msio64.sys",       // MSI exploit
    "phymemx64.sys",    // physical memory exploit
    "rtkio64.sys",      // Realtek exploit
    "rzpnk.sys",        // Razer exploit
    "elbycdio.sys",     // ElbyCDIO exploit
    "nvoclock.sys",     // NvOclock exploit
    "winio64.sys",      // WinIo exploit
    "zemana.sys",       // Zemana anti-malware (sometimes abused)
};

These names are familiar in the BYOVD (Bring Your Own Vulnerable Driver) space. dbk64.sys is Cheat Engine’s kernel component. iqvw64e.sys is the Intel Network Adapter Diagnostic Driver, widely abused by kdmapper and other cheat loaders to map unsigned code into kernel space [1]. capcom.sys is a signed Capcom driver that disables SMEP and calls a user-supplied function pointer, making it a textbook BYOVD payload [2]. The remaining entries are hardware vendor drivers with exposed physical memory read/write IOCTLs (gdrv.sys, asrdrv106.sys, msio64.sys) and tools that are benign in isolation but are staples of cheat development toolchains.

Enumerating Loaded Modules With ZwQuerySystemInformation

The enumeration in DriverScan.c uses ZwQuerySystemInformation with class SystemModuleInformation (11), which returns the full list of loaded kernel modules with their paths, base addresses, and sizes [3]:

// From: PeregrineKernelComponent/DriverScan.c
NTSTATUS DriverScanEnumerate(void) {
    ULONG needed = 0;
    NTSTATUS status;

    // First call to get required size
    status = ZwQuerySystemInformation(SystemModuleInformationEx, NULL, 0, &needed);
    if (status != STATUS_INFO_LENGTH_MISMATCH && !NT_SUCCESS(status)) {
        // ...
        return status;
    }

    // Allocate with some extra room
    needed += 4096;
    PRTL_PROCESS_MODULES modules = (PRTL_PROCESS_MODULES)
        ExAllocatePool2(POOL_FLAG_NON_PAGED, needed, 'nScD');
    if (!modules) {
        return STATUS_INSUFFICIENT_RESOURCES;
    }

    status = ZwQuerySystemInformation(SystemModuleInformationEx, modules, needed, &needed);
    if (!NT_SUCCESS(status)) {
        ExFreePoolWithTag(modules, 'nScD');
        return status;
    }

This is the standard two-call pattern: first call with a NULL buffer to get the required size, allocate with some slack (the module list can grow between calls if a driver loads in the gap), then call again with the real buffer. The returned RTL_PROCESS_MODULES structure contains a count and a flexible array of RTL_PROCESS_MODULE_INFORMATION entries, each carrying the full path, a filename offset, the image base, and the image size [3].

Blacklist Matching: Case-Insensitive Filename Comparison

For each module, the filename is extracted via the OffsetToFileName field and compared against the blacklist. The comparison function in DriverScan.c is a manual case-insensitive ANSI string compare:

// From: PeregrineKernelComponent/DriverScan.c
static BOOLEAN StrEqualNoCase(const char* a, const char* b) {
    while (*a && *b) {
        char ca = *a >= 'A' && *a <= 'Z' ? *a + 32 : *a;
        char cb = *b >= 'A' && *b <= 'Z' ? *b + 32 : *b;
        if (ca != cb) return FALSE;
        a++;
        b++;
    }
    return *a == *b;
}

Only blacklisted hits are reported to userland. Sending the full driver list would flood the communication channel for no actionable gain:

// From: PeregrineKernelComponent/DriverScan.c
    for (ULONG i = 0; i < totalCount; i++) {
        PRTL_PROCESS_MODULE_INFORMATION mod = &modules->Modules[i];

        const char* fullPath = (const char*)mod->FullPathName;
        const char* fileName = fullPath + mod->OffsetToFileName;

        BOOLEAN blacklisted = IsBlacklisted(fileName);
        if (blacklisted) {
            blacklistedCount++;
            CHAR json[COMS_MAX_MESSAGE_SIZE];
            RtlStringCchPrintfA(
                json, ARRAYSIZE(json),
                "{ \"event\": \"driver_scan\", \"driver\": \"%s\", "
                "\"path\": \"%s\", \"base\": \"0x%p\", "
                "\"size\": %lu, \"blacklisted\": true }",
                fileName, fullPath,
                mod->ImageBase, mod->ImageSize);
            ComsSendToUser(json, (ULONG)strlen(json));
        }
    }

After the per-driver loop, a summary event reports totals:

// From: PeregrineKernelComponent/DriverScan.c
    CHAR json[COMS_MAX_MESSAGE_SIZE];
    RtlStringCchPrintfA(
        json, ARRAYSIZE(json),
        "{ \"event\": \"driver_scan_complete\", "
        "\"total_drivers\": %lu, \"blacklisted_count\": %lu }",
        totalCount, blacklistedCount);
    ComsSendToUser(json, (ULONG)strlen(json));

A filename blacklist is trivially bypassable by renaming the driver. This is a known limitation, and it is intentional as a first-pass check. A determined attacker will rename dbk64.sys to something innocuous. The blacklist catches the lazy cases and the pre-built cheat loaders that do not bother renaming. Deeper checks (hash-based identification, certificate validation, memory signature scanning) belong in a separate module.

Enumerating ObCallback Registrations via Undocumented Structures

The second scan is more interesting. Peregrine itself registers an ObCallback to monitor handle creation, but a cheat driver could also register one to strip Peregrine’s protections or hide its activity. To find out who else is watching, ObCallbackScan.c walks the undocumented callback list inside the OBJECT_TYPE structure.

The OBJECT_TYPE structure (pointed to by the exported globals PsProcessType and PsThreadType) contains a CallbackList at offset 0xC8 on Windows 10 2004+ and Windows 11 x64 [4]. This is a doubly-linked list of callback entries. The source defines the internal layout:

// From: PeregrineKernelComponent/ObCallbackScan.c
#define OBJECT_TYPE_CALLBACK_LIST_OFFSET  0xC8

typedef struct _OB_CALLBACK_ENTRY_INTERNAL {
    LIST_ENTRY  CallbackList;       // +0x00
    ULONG       Operations;         // +0x10
    ULONG       Enabled;            // +0x14
    PVOID       Registration;       // +0x18
    POBJECT_TYPE ObjectType;        // +0x20
    PVOID       PreOperation;       // +0x28
    PVOID       PostOperation;      // +0x30
} OB_CALLBACK_ENTRY_INTERNAL, *POB_CALLBACK_ENTRY_INTERNAL;

These offsets come from reverse engineering. Microsoft does not document this structure. The scan walks the linked list and for each entry, resolves the PreOperation and PostOperation function pointers back to the owning driver:

// From: PeregrineKernelComponent/ObCallbackScan.c
static NTSTATUS ScanObjectTypeCallbacks(POBJECT_TYPE ObjectType, const char* typeName) {
    if (!ObjectType) return STATUS_INVALID_PARAMETER;

    PLIST_ENTRY callbackList = (PLIST_ENTRY)(
        (PUCHAR)ObjectType + OBJECT_TYPE_CALLBACK_LIST_OFFSET);

    ULONG count = 0;

    __try {
        PLIST_ENTRY entry = callbackList->Flink;

        while (entry != callbackList) {
            POB_CALLBACK_ENTRY_INTERNAL cb = CONTAINING_RECORD(
                entry, OB_CALLBACK_ENTRY_INTERNAL, CallbackList);

            char preDriver[64] = { 0 };
            char postDriver[64] = { 0 };

            if (cb->PreOperation) {
                ObCallbackScanResolveDriver(cb->PreOperation, preDriver, sizeof(preDriver));
            }
            if (cb->PostOperation) {
                ObCallbackScanResolveDriver(cb->PostOperation, postDriver, sizeof(postDriver));
            }

            CHAR json[COMS_MAX_MESSAGE_SIZE];
            RtlStringCchPrintfA(
                json, ARRAYSIZE(json),
                "{ \"event\": \"ob_callback_found\", \"type\": \"%s\", "
                "\"pre_op\": \"0x%p\", \"pre_driver\": \"%s\", "
                "\"post_op\": \"0x%p\", \"post_driver\": \"%s\", "
                "\"operations\": \"0x%08X\", \"enabled\": %s }",
                typeName,
                cb->PreOperation,
                cb->PreOperation ? preDriver : "none",
                cb->PostOperation,
                cb->PostOperation ? postDriver : "none",
                cb->Operations,
                cb->Enabled ? "true" : "false");

            ComsSendToUser(json, (ULONG)strlen(json));
            count++;
            entry = entry->Flink;

            // Safety: don't walk more than 256 entries
            if (count > 256) break;
        }
    }
    __except (EXCEPTION_EXECUTE_HANDLER) {
        KdPrint(("Peregrine: Exception while scanning %s callbacks\n", typeName));
    }

    return STATUS_SUCCESS;
}

The 256-entry safety limit prevents an infinite loop if the list is corrupted. This is a real concern when walking undocumented structures that a hostile driver might have tampered with. The __try/__except block catches access violations from the same scenario.

Resolving Kernel Addresses to Driver Names

The address resolution function is the piece that turns raw kernel pointers into actionable intelligence. Given a function pointer, it walks the module list and finds which driver’s address range contains it:

// From: PeregrineKernelComponent/ObCallbackScan.c
BOOLEAN ObCallbackScanResolveDriver(PVOID Address, char* NameBuf, ULONG NameBufSize) {
    if (!g_ModuleCache || !Address || !NameBuf || NameBufSize == 0) return FALSE;

    ULONG_PTR addr = (ULONG_PTR)Address;

    for (ULONG i = 0; i < g_ModuleCache->NumberOfModules; i++) {
        PRTL_PROCESS_MODULE_INFORMATION_OB mod = &g_ModuleCache->Modules[i];
        ULONG_PTR base = (ULONG_PTR)mod->ImageBase;
        if (addr >= base && addr < base + mod->ImageSize) {
            const char* fileName = (const char*)mod->FullPathName + mod->OffsetToFileName;
            RtlStringCchCopyA(NameBuf, NameBufSize, fileName);
            return TRUE;
        }
    }

    RtlStringCchCopyA(NameBuf, NameBufSize, "unknown");
    return FALSE;
}

If the function pointer does not fall within any known module’s image range, it resolves to "unknown". This is itself a detection signal. A callback whose function pointer lives outside any loaded module likely belongs to manually mapped code, which is exactly how cheat drivers avoid showing up in the module list.

The module cache is loaded once before the scan using the same ZwQuerySystemInformation two-call pattern from the driver scan module, then freed after both object types have been scanned:

// From: PeregrineKernelComponent/ObCallbackScan.c
NTSTATUS ObCallbackScanEnumerate(void) {
    NTSTATUS status;

    status = LoadModuleCache();
    if (!NT_SUCCESS(status)) {
        // Continue anyway, just won't resolve driver names
    }

    ScanObjectTypeCallbacks(*PsProcessType, "process");
    ScanObjectTypeCallbacks(*PsThreadType, "thread");

    CHAR json[COMS_MAX_MESSAGE_SIZE];
    RtlStringCchPrintfA(json, ARRAYSIZE(json),
        "{ \"event\": \"ob_callback_scan_complete\" }");
    ComsSendToUser(json, (ULONG)strlen(json));

    FreeModuleCache();
    return STATUS_SUCCESS;
}

The scan covers both PsProcessType and PsThreadType, the two object types that cheats and security products typically register callbacks for [5]. On a clean system with Peregrine loaded, the results would show Peregrine’s own callback plus whatever the installed AV/EDR has registered. On a compromised system, there might be callbacks from unexpected drivers, or callbacks whose function pointers resolve to "unknown".

Test-Signing Detection via Code Integrity Flags

The third scanning module in SystemCheck.c checks three aspects of the system’s security posture.

ZwQuerySystemInformation with class SystemCodeIntegrityInformation (103) returns a bitmask of code integrity flags [3]:

// From: PeregrineKernelComponent/SystemCheck.c
static void CheckTestSigning(void) {
    SYSTEM_CODEINTEGRITY_INFORMATION ci = { 0 };
    ci.Length = sizeof(ci);

    NTSTATUS status = ZwQuerySystemInformation(
        SystemCodeIntegrityInformationClass,
        &ci, sizeof(ci), NULL);

    if (!NT_SUCCESS(status)) return;

    BOOLEAN testSigning = (ci.CodeIntegrityOptions & CODEINTEGRITY_OPTION_TESTSIGN) != 0;
    BOOLEAN ciEnabled   = (ci.CodeIntegrityOptions & CODEINTEGRITY_OPTION_ENABLED) != 0;

    // ... formats and sends JSON with test_signing, code_integrity_enabled, raw_flags
}

If CODEINTEGRITY_OPTION_TESTSIGN (0x0002) is set, the system was booted with bcdedit /set testsigning on, which means any self-signed driver can load [6]. No legitimate gaming setup needs test-signing enabled. This is the single easiest indicator that something is off.

HVCI Status: Is the Hypervisor Enforcing Code Integrity

Hypervisor-protected Code Integrity (HVCI) prevents unsigned kernel code from executing even if a vulnerable driver provides a write primitive [7]. The check queries the same code integrity structure but looks at different flags:

// From: PeregrineKernelComponent/SystemCheck.c
static void CheckHVCI(void) {
    SYSTEM_CODEINTEGRITY_INFORMATION ci = { 0 };
    ci.Length = sizeof(ci);

    NTSTATUS status = ZwQuerySystemInformation(
        SystemCodeIntegrityInformationClass,
        &ci, sizeof(ci), NULL);

    if (!NT_SUCCESS(status)) return;

    BOOLEAN hvciEnabled = (ci.CodeIntegrityOptions & CODEINTEGRITY_OPTION_HVCI_KMCI_ENABLED) != 0;
    BOOLEAN hvciAudit   = (ci.CodeIntegrityOptions & CODEINTEGRITY_OPTION_HVCI_KMCI_AUDITMODE) != 0;
    BOOLEAN hvciIum     = (ci.CodeIntegrityOptions & CODEINTEGRITY_OPTION_HVCI_IUM_ENABLED) != 0;

    // ... formats and sends JSON with hvci_enabled, hvci_audit_mode, hvci_ium
}

The source defines three relevant flags: CODEINTEGRITY_OPTION_HVCI_KMCI_ENABLED (0x0400), CODEINTEGRITY_OPTION_HVCI_KMCI_AUDITMODE (0x0800), and CODEINTEGRITY_OPTION_HVCI_IUM_ENABLED (0x1000). HVCI in audit mode logs violations without blocking them, which is still useful for detection but not for prevention. The IUM (Isolated User Mode) flag indicates whether Windows Defender Credential Guard or similar VSM-based features are active [7].

CPU Vendor and Hypervisor Identification via CPUID

The final check uses the CPUID instruction to identify the CPU vendor and detect whether a hypervisor is present:

// From: PeregrineKernelComponent/SystemCheck.c
static void CheckCPUVendor(void) {
    int regs[4] = { 0 };
    char vendor[13] = { 0 };

    // CPUID leaf 0: vendor string
    __cpuid(regs, 0);
    *(int*)(vendor + 0) = regs[1]; // EBX
    *(int*)(vendor + 4) = regs[3]; // EDX
    *(int*)(vendor + 8) = regs[2]; // ECX

    // CPUID leaf 1: ECX bit 31 = hypervisor present
    __cpuid(regs, 1);
    BOOLEAN hypervisorPresent = (regs[2] & (1 << 31)) != 0;

    char hvVendor[13] = { 0 };
    if (hypervisorPresent) {
        // CPUID leaf 0x40000000: hypervisor vendor
        __cpuid(regs, 0x40000000);
        *(int*)(hvVendor + 0) = regs[1];
        *(int*)(hvVendor + 4) = regs[2];
        *(int*)(hvVendor + 8) = regs[3];
    }

    // ... formats and sends JSON with cpu_vendor, hypervisor_present, hypervisor_vendor
}

CPUID leaf 1, ECX bit 31 is defined by the hypervisor specification as the “hypervisor present” bit [8]. When set, leaf 0x40000000 returns the hypervisor vendor string: "Microsoft Hv" for Hyper-V, "VMwareVMware" for VMware, "KVMKVMKVM" for KVM. This matters because some cheats rely on custom hypervisors or hypervisor-based DMA to read game memory without triggering any kernel-level detection.

Orchestrating All Three Checks

The three checks are called by a single entry point in SystemCheck.c:

// From: PeregrineKernelComponent/SystemCheck.c
NTSTATUS SystemCheckRunAll(void) {
    CheckTestSigning();
    CheckHVCI();
    CheckCPUVendor();

    CHAR json[COMS_MAX_MESSAGE_SIZE];
    RtlStringCchPrintfA(json, ARRAYSIZE(json),
        "{ \"event\": \"system_check_complete\" }");
    ComsSendToUser(json, (ULONG)strlen(json));

    return STATUS_SUCCESS;
}

The userland component receives a stream of JSON events, one per check result, then a completion marker. It can aggregate them into a system integrity profile. A system with test-signing enabled, HVCI disabled, and a hypervisor vendor string that is not "Microsoft Hv" is, statistically speaking, not a normal gaming PC.

Limitations and Fragility

These scans are useful first-pass checks, but they are far from bulletproof:

  • The driver blacklist is filename-based. Renaming dbk64.sys to anything else defeats it. Hash-based identification or certificate validation would be more robust but is not implemented.
  • The ObCallback enumeration relies on a hardcoded structure offset (0xC8). This offset is specific to Windows 10 2004+ and Windows 11 x64. Earlier versions of Windows, or future builds, may place the CallbackList at a different offset. A rootkit could also corrupt the list to cause the scan to crash or produce incorrect results, though the __try/__except guard and 256-entry limit mitigate the crash risk.
  • The system integrity checks report what the OS tells the driver. A sufficiently deep attacker (one who controls the hypervisor or has patched the kernel’s code integrity reporting) could spoof these values.

The value is in the composition. No single check is decisive, but a system that loads dbk64.sys, has test-signing enabled, runs under a non-Microsoft hypervisor, and has ObCallbacks registered from an unknown module paints a clear picture. The userland component correlates these signals with runtime monitoring data from ObCallbacks and memory scanning to build a holistic view of the environment’s trustworthiness.

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] hfiref0x, “KDU - Kernel Driver Utility” - github.com/hfiref0x/KDU

[2] Bill Demirkapi, “Capcom Rootkit Proof-of-Concept” - an analysis of the Capcom.sys vulnerability and SMEP bypass

[3] Microsoft, “ZwQuerySystemInformation function” - learn.microsoft.com

[4] The OBJECT_TYPE callback list offset (0xC8) is derived from reverse engineering of Windows 10 2004+ / Windows 11 x64 kernel binaries. This structure is not documented by Microsoft.

[5] Microsoft, “ObRegisterCallbacks function” - learn.microsoft.com

[6] Microsoft, “BCDEdit /set testsigning” - learn.microsoft.com

[7] Microsoft, “Hypervisor-protected Code Integrity (HVCI)” - learn.microsoft.com

[8] Microsoft, “Hypervisor Top-Level Functional Specification: CPUID” - learn.microsoft.com