---
title: "Hooking the Cheat: MinHook API Interception"
description: "Inside Peregrine's injected DLL: inline hooking of ReadProcessMemory, WriteProcessMemory, and friends via MinHook, with IPC event reporting."
date: 2026-01-11
category: "Anti-Cheat"
tags: ["windows", "hooking", "minhook", "dll", "anti-cheat"]
---

This post covers what happens after the kernel APC injection from the [previous post](/blog/peregrine-kernel-apc-injection) lands Peregrine's DLL inside the target process. The DLL installs inline hooks on eight Win32 and NT APIs that cheat tools use for cross-process manipulation, logs every call over a named pipe to the Peregrine service, and never blocks or modifies the hooked function's behavior. The result is a passive telemetry layer that watches without interfering.

## DLL Entry Point and the Init Thread

The `DllMain` entry point is minimal. It suppresses further **DLL_THREAD_ATTACH** / **DLL_THREAD_DETACH** notifications (the DLL does not need them, and they would slow down thread creation), then spawns a dedicated initialization thread. From `dllmain.cpp`:

```cpp
// From: PeregrineDLL/dllmain.cpp
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
    UNREFERENCED_PARAMETER(hModule);
    UNREFERENCED_PARAMETER(lpReserved);
    switch (ul_reason_for_call) {
    case DLL_PROCESS_ATTACH: {
        DisableThreadLibraryCalls(hModule);
        HANDLE th = CreateThread(NULL, 0, InitThread, NULL, 0, NULL);
        if (th) CloseHandle(th);
        break;
    }
    // ...
    }
    return TRUE;
}
```

The initialization work happens in `InitThread` rather than directly in **DllMain** because the loader lock is held during **DLL_PROCESS_ATTACH** [1]. Calling **MH_Initialize** and **GetModuleHandleW** under the loader lock is technically safe, but **MH_CreateHook** modifies executable pages and Patchi chose to avoid any deadlock risk with other DLLs loading concurrently. The init thread is guarded by an interlocked flag so it runs exactly once:

```cpp
// From: PeregrineDLL/dllmain.cpp
static DWORD WINAPI InitThread(LPVOID) {
    if (InterlockedCompareExchange(&g_inited, 1, 0) != 0) return 0;
    // ...
}
```

## Eight Hooks Across Three Modules

`InitThread` resolves three module handles and installs eight hooks. From `dllmain.cpp`:

```cpp
// From: PeregrineDLL/dllmain.cpp
if (MH_Initialize() != MH_OK) {
    DebugLog("[PeregrineDLL] MH_Initialize failed\n");
    return 0;
}

HMODULE kb    = GetModuleHandleW(L"KernelBase.dll");
HMODULE k32   = GetModuleHandleW(L"kernel32.dll");
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");

// Memory access hooks
InstallHook(kb, k32, "ReadProcessMemory",  (void**)&oReadProcessMemory,  (void*)HookReadProcessMemory);
InstallHook(kb, k32, "WriteProcessMemory", (void**)&oWriteProcessMemory, (void*)HookWriteProcessMemory);
InstallHook(ntdll, NULL, "NtReadVirtualMemory",  (void**)&oNtReadVirtualMemory,  (void*)HookNtReadVirtualMemory);
InstallHook(ntdll, NULL, "NtWriteVirtualMemory", (void**)&oNtWriteVirtualMemory, (void*)HookNtWriteVirtualMemory);

// Memory manipulation hooks
InstallHook(kb, k32, "VirtualAllocEx",   (void**)&oVirtualAllocEx,   (void*)HookVirtualAllocEx);
InstallHook(kb, k32, "VirtualProtectEx", (void**)&oVirtualProtectEx, (void*)HookVirtualProtectEx);

// Thread/process hooks
InstallHook(kb, k32, "CreateRemoteThread", (void**)&oCreateRemoteThread, (void*)HookCreateRemoteThread);
InstallHook(kb, k32, "OpenProcess",        (void**)&oOpenProcess,        (void*)HookOpenProcess);
```

The hooks cover four categories of cross-process manipulation: memory reading, memory writing, memory allocation/protection changes, and thread/process access.

## KernelBase-First Resolution Strategy

The `InstallHook` function tries **KernelBase.dll** first, falling back to **kernel32.dll**. From `dllmain.cpp`:

```cpp
// From: PeregrineDLL/dllmain.cpp
static bool InstallHook(HMODULE primary, HMODULE fallback, LPCSTR name,
                         void** pReal, void* hook) {
    HookExport(primary, name, pReal, hook);
    if (!*pReal && fallback)
        HookExport(fallback, name, pReal, hook);
    // ...
}
```

This matters because since Windows 7, most Win32 API implementations live in **KernelBase.dll** rather than **kernel32.dll** [2]. The **kernel32.dll** exports are forwarders. Hooking only the forwarder would catch direct callers of `kernel32!ReadProcessMemory`, but any code that imports from **KernelBase.dll** (or resolves the **KernelBase** version via **GetProcAddress**) would bypass the hook. **Hooking KernelBase catches both paths.**

The `HookExport` helper resolves the function, creates the MinHook trampoline, and enables it in one step:

```cpp
// From: PeregrineDLL/dllmain.cpp
static void HookExport(HMODULE mod, LPCSTR name, void** pReal, void* hook) {
    if (!mod) return;
    if (void* p = (void*)GetProcAddress(mod, name)) {
        if (MH_CreateHook(p, hook, pReal) == MH_OK) MH_EnableHook(p);
    }
}
```

## Observational Hooks: ReadProcessMemory and WriteProcessMemory

Each hook follows the same pattern: call the original function, extract the target PID from the handle, report the event via IPC. The hook does not block or modify behavior. It is purely observational. From `dllmain.cpp`:

```cpp
// From: PeregrineDLL/dllmain.cpp
static BOOL WINAPI HookReadProcessMemory(HANDLE hProcess, LPCVOID lpBase,
    LPVOID lpBuf, SIZE_T nSize, SIZE_T* pRead) {
    BOOL result = oReadProcessMemory(hProcess, lpBase, lpBuf, nSize, pRead);
    DWORD targetPID = GetProcessId(hProcess);
    ipc_log_event("ReadProcessMemory",
        "\"callerPID\":%lu,\"targetPID\":%lu,\"address\":%llu,\"size\":%llu",
        PID, targetPID,
        (unsigned long long)(ULONG_PTR)lpBase, (unsigned long long)nSize);
    return result;
}
```

The original function is called first, unconditionally. The DLL reports after the fact rather than before. Its job is telemetry, not prevention. The Peregrine service can decide to kill the process or flag the user based on the aggregated event stream.

**WriteProcessMemory** follows the identical pattern, logging the target PID, address, and size of the write.

## Filtering Self-Allocations in VirtualAllocEx

The **VirtualAllocEx** hook filters out self-allocations. From `dllmain.cpp`:

```cpp
// From: PeregrineDLL/dllmain.cpp
static LPVOID WINAPI HookVirtualAllocEx(HANDLE hProcess, LPVOID lpAddr,
    SIZE_T dwSize, DWORD flType, DWORD flProtect) {
    LPVOID result = oVirtualAllocEx(hProcess, lpAddr, dwSize, flType, flProtect);
    DWORD targetPID = GetProcessId(hProcess);
    if (targetPID != PID) {
        ipc_log_event("VirtualAllocEx",
            "\"callerPID\":%lu,\"targetPID\":%lu,\"address\":%llu,"
            "\"size\":%llu,\"protect\":\"0x%08X\"",
            PID, targetPID, (unsigned long long)(ULONG_PTR)result,
            (unsigned long long)dwSize, flProtect);
    }
    return result;
}
```

The `targetPID != PID` check is important. A process legitimately calls `VirtualAllocEx(GetCurrentProcess(), ...)` constantly: the CRT does it, allocators do it, everyone does it [3]. Only cross-process allocations are interesting for cheat detection.

The **VirtualProtectEx** hook applies the same self-filtering logic, logging cross-process protection changes with the new protection flags.

## OpenProcess: Filtering by Dangerous Access Flags

The **OpenProcess** hook filters by requested access flags. From `dllmain.cpp`:

```cpp
// From: PeregrineDLL/dllmain.cpp
static HANDLE WINAPI HookOpenProcess(DWORD dwAccess, BOOL bInherit, DWORD dwPID) {
    HANDLE result = oOpenProcess(dwAccess, bInherit, dwPID);
    const DWORD DANGEROUS = 0x0001 | 0x0002 | 0x0008 | 0x0010 | 0x0020 | 0x0040 | 0x0800;
    if (dwPID != PID && (dwAccess & DANGEROUS)) {
        ipc_log_event("OpenProcess",
            "\"callerPID\":%lu,\"targetPID\":%lu,\"access\":\"0x%08X\"",
            PID, dwPID, dwAccess);
    }
    return result;
}
```

The `DANGEROUS` bitmask covers seven process access rights [4]:

- `PROCESS_TERMINATE` (0x0001)
- `PROCESS_CREATE_THREAD` (0x0002)
- `PROCESS_VM_OPERATION` (0x0008)
- `PROCESS_VM_READ` (0x0010)
- `PROCESS_VM_WRITE` (0x0020)
- `PROCESS_DUP_HANDLE` (0x0040)
- `PROCESS_CREATE_PROCESS` (0x0800)

A handle opened with only `PROCESS_QUERY_INFORMATION` is harmless for cheating purposes and does not generate noise in the event stream.

## NT-Layer Coverage: NtReadVirtualMemory and NtWriteVirtualMemory

Hooking both the Win32 and NT layers is not redundant. A moderately sophisticated cheat will skip the Win32 wrappers and call **NtReadVirtualMemory** / **NtWriteVirtualMemory** directly via a syscall stub. The ntdll hooks catch this approach. From `dllmain.cpp`:

```cpp
// From: PeregrineDLL/dllmain.cpp
static NTSTATUS NTAPI HookNtReadVirtualMemory(HANDLE hProcess, PVOID base,
    PVOID buf, SIZE_T size, PSIZE_T pRead) {
    NTSTATUS status = oNtReadVirtualMemory(hProcess, base, buf, size, pRead);
    DWORD targetPID = GetProcessId(hProcess);
    ipc_log_event("ReadProcessMemory",
        "\"callerPID\":%lu,\"targetPID\":%lu,\"address\":%llu,\"size\":%llu",
        PID, targetPID,
        (unsigned long long)(ULONG_PTR)base, (unsigned long long)size);
    return status;
}
```

The event name is `"ReadProcessMemory"` even though this is the NT-level hook. From the Peregrine service's perspective, a memory read is a memory read regardless of which API surface was used. The service correlates by caller PID and target PID, not by API name.

**One gap worth noting**: a cheat that issues raw `syscall` instructions without going through ntdll at all will bypass these hooks entirely. That is the direct-syscall technique (sometimes called "Hell's Gate" or "Halo's Gate"), and detecting it requires kernel-mode syscall hooking or approaches like InfinityHook [5]. For a working implementation of indirect syscalls from the offensive side, see the [Hell's Hall post](/blog/kassandra-hells-hall-syscalls) in the Kassandra series.

## CreateRemoteThread: Detecting Cross-Process Thread Injection

The **CreateRemoteThread** hook logs any attempt to create a thread in another process. From `dllmain.cpp`:

```cpp
// From: PeregrineDLL/dllmain.cpp
static HANDLE WINAPI HookCreateRemoteThread(HANDLE hProcess, LPSECURITY_ATTRIBUTES lpAttr,
    SIZE_T dwStackSize, LPTHREAD_START_ROUTINE lpStart, LPVOID lpParam,
    DWORD dwFlags, LPDWORD lpTid) {
    HANDLE result = oCreateRemoteThread(hProcess, lpAttr, dwStackSize,
        lpStart, lpParam, dwFlags, lpTid);
    DWORD targetPID = GetProcessId(hProcess);
    if (targetPID != PID) {
        ipc_log_event("CreateRemoteThread",
            "\"callerPID\":%lu,\"targetPID\":%lu,\"startAddress\":%llu",
            PID, targetPID, (unsigned long long)(ULONG_PTR)lpStart);
    }
    return result;
}
```

Like the other hooks, self-targeting calls are filtered out. The start address is logged, which allows the Peregrine service to correlate the thread creation with known injection patterns [6].

## The IPC Named Pipe Layer

All hook events flow through a named pipe to the Peregrine service. The IPC layer is intentionally simple: a single write-only pipe connection with a critical section for thread safety. From `ipc.c`:

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

static HANDLE g_pipe = INVALID_HANDLE_VALUE;
static CRITICAL_SECTION g_lock;

static HANDLE GetPipe(void) {
    if (g_pipe != INVALID_HANDLE_VALUE)
        return g_pipe;

    if (!WaitNamedPipeA(IPC_PIPE_NAMEA, 1000))
        return INVALID_HANDLE_VALUE;

    g_pipe = CreateFileA(
        IPC_PIPE_NAMEA,
        GENERIC_WRITE,
        0, NULL,
        OPEN_EXISTING,
        FILE_ATTRIBUTE_NORMAL,
        NULL);

    return g_pipe;
}
```

The pipe is lazily connected on first use. **WaitNamedPipeA** with a 1-second timeout handles the race condition where the DLL loads before the service has created the pipe endpoint [7]. If the pipe is not available, events are silently dropped. The hooks never block the target process's execution.

## Reconnection on Pipe Failure

If the pipe breaks (service restarts, pipe buffer overflow), `ipc_write_json` closes the handle and the next call to `GetPipe` reconnects. From `ipc.c`:

```c
// From: PeregrineDLL/ipc.c
void ipc_write_json(const char* json) {
    if (!json) return;
    EnsureLock();

    EnterCriticalSection(&g_lock);

    HANDLE h = GetPipe();
    if (h == INVALID_HANDLE_VALUE) {
        LeaveCriticalSection(&g_lock);
        return;
    }

    DWORD len = (DWORD)strlen(json);
    DWORD written = 0;
    if (!WriteFile(h, json, len, &written, NULL)) {
        ClosePipe();
    }

    LeaveCriticalSection(&g_lock);
}
```

The critical section initialization is itself guarded by an interlocked compare-exchange, so it is safe to call from any thread at any time:

```c
// From: PeregrineDLL/ipc.c
static void EnsureLock(void) {
    if (InterlockedCompareExchange(&g_lock_init, 1, 0) == 0)
        InitializeCriticalSection(&g_lock);
}
```

## JSON Event Formatting

The `ipc_log_event` wrapper formats each event as a JSON object. From `ipc.c`:

```c
// From: PeregrineDLL/ipc.c
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);
}
```

A typical message on the wire looks like:

```json
{"event":"ReadProcessMemory","callerPID":1234,"targetPID":5678,"address":140698832764928,"size":4}
```

No framing, no length prefix. Each **WriteFile** call writes one complete JSON object. The service reads in message mode (**PIPE_TYPE_MESSAGE**) so each **ReadFile** returns exactly one event [7]. This is sufficient for the current event volume. If the hook density increases or the target process is highly threaded, a ring buffer with batch flushing would be the natural next step.

## The Hello Handshake

After all hooks are installed, the init thread sends a `hello` event with the process's image name. From `dllmain.cpp`:

```cpp
// From: PeregrineDLL/dllmain.cpp
char exeName[MAX_PATH] = {0};
GetModuleFileNameA(NULL, exeName, MAX_PATH);
const char* baseName = exeName;
for (const char* p = exeName; *p; p++)
    if (*p == '\\' || *p == '/') baseName = p + 1;

ipc_log_event("hello", "\"callerPID\":%lu,\"image\":\"%s\"", PID, baseName);
```

This lets the service confirm the injection succeeded and associate subsequent events from this PID with the correct process image. If the service never receives a `hello` from a process that should have been injected, it knows the APC injection failed or was blocked.

## Limitations of Inline Hooking

The hook set is deliberately conservative: eight hooks covering four major cross-process manipulation categories. Each hook is observational, not blocking, which means the DLL's presence has near-zero impact on the process's behavior. A cheat tool will not crash or behave differently because of the hooks; it just gets watched.

The fundamental limitation is inline hooking itself. MinHook works by overwriting the first bytes of each target function with a jump to the hook [8]. Anything that integrity-checks those bytes (or restores them from a clean copy of the DLL on disk) can unhook the DLL. That is a cat-and-mouse game that every user-mode anti-cheat has to play. Mitigation strategies include periodic re-hooking, watching for **VirtualProtect** calls on hook pages, or moving the critical detection logic into the kernel component entirely.

**The DLL is a telemetry sensor, not a security boundary.** Its value is in providing real-time event data to the Peregrine service, which makes policy decisions based on the aggregated stream. The kernel driver's [ObCallbacks](/blog/peregrine-obcallbacks) and notify routines provide the harder-to-bypass detection layer.

*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, "DllMain entry point", [learn.microsoft.com](https://learn.microsoft.com/en-us/windows/win32/dlls/dllmain)

[2] Microsoft, "Windows 7 API Sets and KernelBase.dll", [learn.microsoft.com](https://learn.microsoft.com/en-us/windows/win32/apiindex/windows-apisets)

[3] Microsoft, "VirtualAllocEx function", [learn.microsoft.com](https://learn.microsoft.com/en-us/windows/win32/api/memoryapi/nf-memoryapi-virtualallocex)

[4] Microsoft, "Process Security and Access Rights", [learn.microsoft.com](https://learn.microsoft.com/en-us/windows/win32/procthread/process-security-and-access-rights)

[5] Microsoft, "System call mechanism", [learn.microsoft.com](https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/x64-architecture)

[6] Microsoft, "CreateRemoteThread function", [learn.microsoft.com](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createremotethread)

[7] Microsoft, "Named Pipes", [learn.microsoft.com](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipes)

[8] MinHook, "The Minimalistic x86/x64 API Hooking Library", [github.com](https://github.com/TsudaKageworkeryu/minhook)
