---
title: "The Anatomy of an Anti-Cheat"
description: "A walkthrough of Peregrine's three-layer architecture: kernel driver, injected DLL, and Tauri GUI, and how they communicate."
date: 2025-11-08
category: "Anti-Cheat"
tags: ["windows", "kernel", "architecture", "anti-cheat"]
---

Peregrine is an educational anti-cheat system built from three cooperating components: a kernel minifilter driver in C, injected monitoring DLLs 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 layers communicate across privilege boundaries. The remaining posts in the series explore each subsystem in detail.

> **Update:** Injection is no longer a single `PeregrineDLL`. The tree builds **Game** and **Sensor** roles with dual kernel profiles. See [Game vs Sensor: Dual Inject DLLs](/blog/peregrine-multi-dll-game-sensor).

## 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:

```c
// 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:

```c
// 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**:

```rust
// 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:

```rust
// 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:

```c
// 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:

```rust
// 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`:

```rust
// 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:

```c
// 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:

```cpp
// 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:

```rust
// 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:

```c
// 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:

- [ObCallbacks and Handle Stripping](/blog/peregrine-obcallbacks) covers how the driver intercepts dangerous handle operations and strips access flags in real-time.
- [Kernel APC DLL Injection](/blog/peregrine-kernel-apc-injection) covers the shellcode construction, **LdrLoadDll** resolution, and x64/WoW64 APC queuing.
- [In-Process API Hooking with MinHook](/blog/peregrine-minhook-api-interception) covers the injected DLL's hook installation and IPC reporting.
- [Relocation-Aware Hashing](/blog/peregrine-relocation-aware-hashing) covers detecting code patches by comparing on-disk and in-memory module contents.
- [IAT and EAT Scanning](/blog/peregrine-iat-eat-scanning) covers detecting function pointer redirections in import and export tables.
- [Shellcode Thread Analysis](/blog/peregrine-shellcode-thread-analysis) covers finding shellcode threads and VAD regions without backing modules.
- [PPL Elevation and ETW Threat Intelligence](/blog/peregrine-ppl-etw-threat-intelligence) covers consuming kernel-level memory and thread events from a PPL-protected trace session.
- [A Minifilter for Self-Defense](/blog/peregrine-minifilter-self-defense) covers protecting anti-cheat files from tampering with a file system minifilter.
- [Kernel Scanning and System Integrity](/blog/peregrine-kernel-scanning) covers driver blacklisting, ObCallback enumeration, and system integrity checks.

*Drafted with LLM assistance from the [Peregrine Anti-Cheat](https://github.com/PatchRequest/PeregrineAntiCheat) source code, reviewed and verified against the actual implementation.*

## References

[1] Microsoft, "IoCreateDevice function (wdm.h)", [learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/nf-wdm-iocreatedevice](https://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](https://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](https://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](https://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](https://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](https://learn.microsoft.com/en-us/windows/win32/apiindex/windows-apisets)
