The Anatomy of an Anti-Cheat

Peregrine is an educational anti-cheat system built from three cooperating components: a kernel minifilter driver in C, an injected DLL in C++ for in-process API hooking, and a Tauri (Rust + Svelte) desktop GUI that orchestrates everything. This post covers the full architecture and, more importantly, how these three layers communicate across privilege boundaries. The remaining posts in the series explore each subsystem in detail.

Three Components, Two Privilege Boundaries

Peregrine’s architecture splits across three components, each running at a different privilege level:

┌──────────────────────────────────────────────────────────────┐
│  Tauri GUI (Rust + Svelte)                                   │
│  peregrine-tauri.exe                                         │
│  ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌───────────────┐  │
│  │ Driver   │ │ IPC Pipe │ │ Detection │ │ ETW-TI        │  │
│  │ Polling  │ │ Server   │ │ Scans     │ │ Consumer      │  │
│  └────┬─────┘ └────┬─────┘ └───────────┘ └───────────────┘  │
│       │IOCTL       │Named Pipe                PPL+ETW        │
├───────┼────────────┼─────────────────────────────────────────┤
│       ▼            ▼                                         │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  Kernel Driver (Minifilter)                          │    │
│  │  PeregrineKernelComponent.sys                        │    │
│  │  • ObCallback (handle monitoring)                    │    │
│  │  • Process/Thread/Image notify routines              │    │
│  │  • APC DLL injection (shellcode + LdrLoadDll)        │    │
│  │  • PPL elevation (EPROCESS patch)                    │    │
│  │  • Minifilter (file access monitoring)               │    │
│  └──────────┬───────────────────────────────────────────┘    │
│             │ APC Injection                                   │
│             ▼                                                 │
│  ┌──────────────────────┐       ┌─────────────────────┐      │
│  │  PeregrineDLL        │       │  Target Process     │      │
│  │  (injected into      │──────▶│  (game / cheat)     │      │
│  │   target processes)  │  IPC  │                     │      │
│  │  • MinHook API hooks │ pipe  │  APIs hooked:       │      │
│  └──────────────────────┘       │  RPM, WPM, NtR/W   │      │
│                                 │  VirtualAllocEx ... │      │
│                                 └─────────────────────┘      │
└──────────────────────────────────────────────────────────────┘

The kernel driver (PeregrineKernelComponent.sys) lives in ring 0. It registers ObCallbacks, process/thread/image notify routines, a minifilter for file system monitoring, and handles APC-based DLL injection. The Tauri GUI (peregrine-tauri.exe) runs as a normal user-mode application and acts as the command center: it sends instructions to the driver via IOCTL, receives kernel events by polling, runs its own detection scans (module integrity, IAT/EAT hooks, thread analysis, blacklists), and consumes ETW Threat Intelligence events after the driver elevates it to PPL. The injected DLL (PeregrineDLL) gets loaded into target processes by the driver’s APC injection mechanism and hooks sensitive WinAPI calls with MinHook, reporting every suspicious call back to the GUI over a named pipe.

Three communication channels stitch these layers together: IOCTLs, named pipes, and APC injection.

The IOCTL Channel: GUI to Kernel and Back

The driver creates a device object at \Device\Peregrine with a symbolic link at \DosDevices\Peregrine, giving userland processes a path to open a handle via CreateFileW [1]. The setup in Coms.c uses conventional buffered I/O:

// From: PeregrineKernelComponent/Coms.c
NTSTATUS ComsInitialize(_In_ PDRIVER_OBJECT DriverObject) {
    UNICODE_STRING deviceName = RTL_CONSTANT_STRING(PEREGRINE_DEVICE_NAME);
    UNICODE_STRING symLink   = RTL_CONSTANT_STRING(PEREGRINE_SYMLINK_NAME);

    KeInitializeSpinLock(&g_ComsLock);

    NTSTATUS status = IoCreateDevice(
        DriverObject, 0, &deviceName,
        FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN,
        FALSE, &g_ComsDevice);
    // ...
    status = IoCreateSymbolicLink(&symLink, &deviceName);
    // ...
    g_ComsDevice->Flags |= DO_BUFFERED_IO;

    DriverObject->MajorFunction[IRP_MJ_CREATE]         = ComsCreateClose;
    DriverObject->MajorFunction[IRP_MJ_CLOSE]          = ComsCreateClose;
    DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = ComsDeviceControl;
    // ...
}

Two IOCTL codes define the protocol. One sends commands from userland into the driver, the other receives events from the driver back to userland:

// From: PeregrineKernelComponent/Coms.h
#define IOCTL_PEREGRINE_SEND_FROM_USER \
    CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS)
#define IOCTL_PEREGRINE_RECV_TO_USER \
    CTL_CODE(FILE_DEVICE_UNKNOWN, 0x802, METHOD_BUFFERED, FILE_ANY_ACCESS)

Both use METHOD_BUFFERED, meaning the I/O manager copies the input buffer into kernel space via Irp->AssociatedIrp.SystemBuffer [2]. No MDLs, no direct user pointers.

On the Rust side, the Tauri GUI constructs these same IOCTL codes at compile time and opens the device with CreateFileW:

// From: peregrine-tauri/src-tauri/src/driver_comm.rs
const DEVICE_PATH: &str = r"\\.\Peregrine";

const fn ctl_code(dev: u32, func: u32, method: u32, access: u32) -> u32 {
    (dev << 16) | (access << 14) | (func << 2) | method
}

const IOCTL_SEND: u32 = ctl_code(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS);
const IOCTL_RECV: u32 = ctl_code(FILE_DEVICE_UNKNOWN, 0x802, METHOD_BUFFERED, FILE_ANY_ACCESS);

Commands are byte-packed: the first byte is the command ID, followed by the payload. The command set covers PID management (add, remove, clear), PPL elevation, driver/ObCallback scanning, system integrity checks, DLL injection paths, target process names, and injection enable/disable. Eleven commands in total. The Rust DriverHandle wraps each into a typed method, so the GUI code never has to think about raw bytes:

// From: peregrine-tauri/src-tauri/src/driver_comm.rs
pub fn add_pid(&self, pid: u32) -> Result<(), String> {
    let handle_size = std::mem::size_of::<usize>();
    let mut buf = vec![1u8]; // command ID
    if handle_size == 8 {
        buf.extend_from_slice(&(pid as u64).to_le_bytes());
    } else {
        buf.extend_from_slice(&pid.to_le_bytes());
    }
    self.send_command(&buf)
}

The PID is extended to 8 bytes on x64 because the kernel-side command handler reads it as a HANDLE (which is pointer-width). This is one of those places where the user/kernel boundary forces explicit size handling.

A Circular Buffer Between Worlds

The driver needs to send events to userland (ObCallback notifications, APC injection results, driver scan results, file access reports) but cannot block waiting for the GUI to read. Every callback context in the kernel is either at PASSIVE_LEVEL or DISPATCH_LEVEL, and blocking at DISPATCH_LEVEL is a guaranteed blue screen [3].

The solution is a lock-protected circular buffer. The driver queues messages into a ring of 1024 slots, each up to 1024 bytes:

// From: PeregrineKernelComponent/Coms.c
static COMS_MESSAGE g_MessageQueue[COMS_MAX_QUEUE_DEPTH];
static ULONG g_Head = 0;
static ULONG g_Tail = 0;
static ULONG g_Count = 0;

NTSTATUS ComsSendToUser(const void* Data, ULONG DataSize) {
    // ...
    KIRQL oldIrql;
    KeAcquireSpinLock(&g_ComsLock, &oldIrql);

    // If full, overwrite the oldest (advance tail) to avoid blocking producers.
    if (g_Count == COMS_MAX_QUEUE_DEPTH) {
        g_Tail = (g_Tail + 1) % COMS_MAX_QUEUE_DEPTH;
        g_Count--;
    }

    RtlCopyMemory(g_MessageQueue[g_Head].Data, Data, DataSize);
    g_MessageQueue[g_Head].Length = DataSize;
    g_Head = (g_Head + 1) % COMS_MAX_QUEUE_DEPTH;
    g_Count++;

    KeReleaseSpinLock(&g_ComsLock, oldIrql);
    return STATUS_SUCCESS;
}

When the buffer is full, the oldest message gets silently overwritten. This is a deliberate design choice: a producer running in an ObCallback or notify routine must never block, and returning STATUS_INSUFFICIENT_RESOURCES from there would either drop the message anyway or cause callers to retry in a tight loop. The circular overwrite guarantees bounded memory and O(1) insertion at the cost of potentially losing old events if the GUI falls behind.

The IOCTL_PEREGRINE_RECV_TO_USER handler pops one message per call. The GUI polls it from a dedicated thread at 5 ms intervals:

// From: peregrine-tauri/src-tauri/src/lib.rs
fn start_driver_polling(app: AppHandle) {
    std::thread::spawn(move || {
        let mut poll_handle: Option<DriverHandle> = None;
        loop {
            std::thread::sleep(std::time::Duration::from_millis(5));

            if poll_handle.is_none() {
                poll_handle = DriverHandle::open().ok();
                if poll_handle.is_none() {
                    std::thread::sleep(std::time::Duration::from_secs(1));
                    continue;
                }
            }

            match poll_handle.as_ref().unwrap().recv_event() {
                Ok(Some(raw)) => {
                    let s = String::from_utf8_lossy(&raw);
                    let fixed = s.replace('\\', "\\\\");
                    if let Ok(obj) = serde_json::from_str::<serde_json::Value>(&fixed) {
                        // ...
                        let _ = app.emit("driver-event", &obj);
                    }
                }
                Ok(None) => {}
                Err(_) => { poll_handle = None; }
            }
        }
    });
}

If the driver handle breaks (driver unloaded, access denied), the polling thread drops it and retries with a 1-second backoff. Every successfully dequeued JSON event gets emitted as a driver-event into the Svelte frontend via Tauri’s emit API. This is the mechanism that makes kernel-level detections show up in the UI in near real-time.

The Named Pipe: DLL to GUI

The injected DLL runs inside the target process and needs to report hooked API calls back to the GUI. IOCTLs are not an option here; the DLL should not be opening handles to arbitrary kernel devices from inside a game process. Instead, Peregrine uses a Windows named pipe at \\.\pipe\peregrine_ipc [4].

The GUI side creates the pipe server in ipc.rs:

// From: peregrine-tauri/src-tauri/src/ipc.rs
const PIPE_NAME: &str = r"\\.\pipe\peregrine_ipc";

pub fn start_ipc_server(stop: Arc<AtomicBool>) -> IpcReceiver {
    let (tx, rx) = std::sync::mpsc::channel();

    std::thread::spawn(move || {
        // ...
        while !stop.load(Ordering::Relaxed) {
            let pipe = unsafe {
                CreateNamedPipeW(
                    PCWSTR(wide.as_ptr()),
                    FILE_FLAGS_AND_ATTRIBUTES(PIPE_ACCESS_DUPLEX),
                    PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
                    PIPE_UNLIMITED_INSTANCES,
                    BUF_SIZE, BUF_SIZE, 0,
                    Some(&mut sa), // DACL: D:(A;;GA;;;WD)
                )
            };
            // ...
            // For each connected client, spawn a reader thread
            // that splits incoming bytes on JSON boundaries
        }
    });
    rx
}

The pipe uses a permissive DACL (D:(A;;GA;;;WD), which grants all access to Everyone) so that even low-integrity processes can connect. Each connected client gets its own reader thread that accumulates bytes and splits them on JSON object boundaries with a brace-depth parser. This allows the DLL to fire off JSON blobs without framing (no length prefix, no delimiter) and the server reconstructs them.

On the DLL side, the IPC client is minimal. ipc.c opens the pipe with CreateFileA and writes raw JSON strings:

// From: PeregrineDLL/ipc.c
#define IPC_PIPE_NAMEA "\\\\.\\pipe\\peregrine_ipc"

void ipc_log_event(const char* event, const char* fmt, ...) {
    char params[768];
    va_list args;
    va_start(args, fmt);
    vsnprintf(params, sizeof(params), fmt, args);
    va_end(args);

    char buf[1024];
    _snprintf_s(buf, sizeof(buf), _TRUNCATE,
        "{\"event\":\"%s\",%s}", event, params);

    ipc_write_json(buf);
}

The pipe handle is lazily initialized on first write, protected by a CRITICAL_SECTION, and re-established if the connection drops. The DLL can start logging immediately after injection; it calls ipc_log_event("hello", ...) as soon as hooks are installed to announce itself to the GUI.

The Third Channel: APC Injection

The driver does not wait for userland to tell it when to inject. Instead, it monitors process creation through PsSetCreateProcessNotifyRoutineEx and image loads through PsSetLoadImageNotifyRoutine [5]. When a target process loads kernel32.dll, the driver resolves LdrLoadDll from ntdll’s export table, writes a small shellcode stub and a UNICODE_STRING pointing to the DLL path into the target process, and queues a user-mode APC that calls the shellcode. The APC fires on the next alertable wait in that thread, which during process startup happens almost immediately.

This is a fully autonomous kernel-to-userland injection path with no userland roundtrip. The GUI’s only role is to configure which process names to target and which DLL paths to use (via IOCTL commands 8 through 11), and the driver handles the rest. A later post in the series covers the APC injection mechanism in detail.

Eight Hooked APIs Across Three Modules

Once injected, the DLL initializes MinHook and installs inline hooks on eight API functions:

// From: PeregrineDLL/dllmain.cpp
HMODULE kb    = GetModuleHandleW(L"KernelBase.dll");
HMODULE k32   = GetModuleHandleW(L"kernel32.dll");
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");

InstallHook(kb, k32, "ReadProcessMemory",  ...);
InstallHook(kb, k32, "WriteProcessMemory", ...);
InstallHook(ntdll, NULL, "NtReadVirtualMemory",  ...);
InstallHook(ntdll, NULL, "NtWriteVirtualMemory", ...);
InstallHook(kb, k32, "VirtualAllocEx",   ...);
InstallHook(kb, k32, "VirtualProtectEx", ...);
InstallHook(kb, k32, "CreateRemoteThread", ...);
InstallHook(kb, k32, "OpenProcess",        ...);

Each hook calls the original function first (so the target process behavior is unmodified), then reports the call to the GUI via the named pipe. The InstallHook helper tries the primary module first (KernelBase, where most Win32 APIs actually live on modern Windows), then falls back to the secondary. This matters because ReadProcessMemory in kernel32.dll is just a forwarder to KernelBase on Windows 10+ [6].

Startup Sequence in the Tauri GUI

At startup, the Tauri run() function registers all Tauri commands and spins up both polling threads:

// From: peregrine-tauri/src-tauri/src/lib.rs
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![
            connect_driver, add_pid, remove_pid, clear_pids,
            set_ppl, scan_drivers, scan_ob_callbacks, system_check,
            add_injection_target, clear_injection_targets, start_etw_ti,
            check_modules, check_iat, check_eat, check_threads, scan_blacklist,
        ])
        .setup(|app| {
            let handle = app.handle().clone();
            start_driver_polling(handle.clone());
            start_ipc_polling(handle);
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

The connect_driver command is the first thing the UI calls. It opens the driver handle, registers the GUI’s own PID as protected (so the driver’s ObCallback does not flag the GUI’s own handle operations), discovers DLL paths on disk, and sends them to the driver via IOCTLs. From that point on, the system is live: the driver watches process creation, the DLL injection pipeline is armed, the GUI polls for kernel events and listens for pipe messages, and every detection result flows into the Svelte frontend.

Kernel-Side Initialization Order

The driver entry point in main.c initializes subsystems in a strict order with rollback on failure:

// From: PeregrineKernelComponent/main.c
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
    NTSTATUS status = ComsInitialize(DriverObject);
    if (!NT_SUCCESS(status)) return status;

    status = createRegistration();
    if (!NT_SUCCESS(status)) { ComsCleanup(); return status; }

    status = registerNotifyRoutine();
    if (!NT_SUCCESS(status)) { unregisterRegistration(); ComsCleanup(); return status; }

    DriverObject->DriverUnload = DriverUnload;
    StateInit();
    InjInit();

    status = MfInit(DriverObject, RegistryPath);
    if (!NT_SUCCESS(status)) {
        KdPrint(("Peregrine: MfInit failed 0x%X (minifilter disabled, rest OK)\n", status));
    }

    return STATUS_SUCCESS;
}

Communications come first (IOCTL device creation), then ObCallbacks, then notify routines, then state and injection initialization, and finally the minifilter. The minifilter is the only optional component. If MfInit fails (perhaps the INF was not installed), the driver logs the error and continues. Everything else is required; failure triggers cleanup of all previously initialized subsystems and returns an error status.

The Road Ahead

This post covered the skeleton. The remaining posts in this series dig into each subsystem:

  • Post 2: ObCallbacks and Handle Stripping covers how the driver intercepts dangerous handle operations and strips access flags in real-time.
  • Post 3: Kernel Notify Routines covers process creation, thread creation, and image load callbacks that feed the event stream.
  • Post 4: APC-Based DLL Injection covers the shellcode construction, LdrLoadDll resolution, and x64/WoW64 APC queuing.
  • Post 5: PPL Elevation via EPROCESS Patching covers turning the GUI into a Protected Process Light to consume ETW Threat Intelligence.
  • Post 6: In-Process API Hooking with MinHook covers the injected DLL’s hook installation and IPC reporting.
  • Post 7: Module Integrity and IAT/EAT Scanning covers detecting patches and inline hooks in running processes.
  • Post 8: Thread Scanning and Manual-Map Detection covers finding shellcode threads and VAD regions without backing modules.
  • Post 9: A Minifilter for Self-Defense covers protecting anti-cheat files from tampering with a file system minifilter.
  • Post 10: ETW Threat Intelligence covers consuming kernel-level memory and thread events from a PPL-protected trace session.

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, “IoCreateDevice function (wdm.h)”, learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-iocreatedevice

[2] Microsoft, “Using Buffered I/O”, learn.microsoft.com/en-us/windows-hardware/drivers/kernel/using-buffered-i-o

[3] Microsoft, “Dispatch Routines and IRQLs”, learn.microsoft.com/en-us/windows-hardware/drivers/kernel/dispatch-routines-and-irqls

[4] Microsoft, “Named Pipe Operations”, learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-operations

[5] Microsoft, “PsSetCreateProcessNotifyRoutineEx”, learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntddk/nf-ntddk-pssetcreateprocessnotifyroutineex

[6] Microsoft, “API Sets”, learn.microsoft.com/en-us/windows/win32/apiindex/windows-apisets