Hooking the Cheat: MinHook API Interception
This post covers what happens after the kernel APC injection from the previous post 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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].
CreateRemoteThread: Detecting Cross-Process Thread Injection
The CreateRemoteThread hook logs any attempt to create a thread in another process. From dllmain.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:
// 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:
// 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:
// 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:
// 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:
{"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:
// 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 and notify routines provide the harder-to-bypass detection layer.
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, “DllMain entry point”, learn.microsoft.com
[2] Microsoft, “Windows 7 API Sets and KernelBase.dll”, learn.microsoft.com
[3] Microsoft, “VirtualAllocEx function”, learn.microsoft.com
[4] Microsoft, “Process Security and Access Rights”, learn.microsoft.com
[5] Microsoft, “System call mechanism”, learn.microsoft.com
[6] Microsoft, “CreateRemoteThread function”, learn.microsoft.com
[7] Microsoft, “Named Pipes”, learn.microsoft.com
[8] MinHook, “The Minimalistic x86/x64 API Hooking Library”, github.com