VAD Scanning: Detecting Manually Mapped DLLs from Kernel Mode
This post covers how Peregrine’s kernel driver detects manually mapped DLLs by scanning a process’s virtual address space for executable private memory that is not backed by any image file. Manual mapping is one of the most common cheat injection techniques because it completely bypasses the Windows module list, making the injected code invisible to usermode enumeration. Peregrine’s VAD scan finds these regions by querying memory information from kernel mode and filtering for committed, executable, non-image pages.
Why Manual Mapping Evades Usermode Detection
When Windows loads a DLL through LoadLibrary, the loader performs several visible operations: it maps the file as an image section, adds the module to the PEB_Ldr doubly-linked lists, and fires an LDR_DLL_NOTIFICATION callback [1]. Tools like EnumProcessModules [2] walk these lists to enumerate loaded modules, and Peregrine’s own thread analysis builds a module map from them.
Manual mapping sidesteps all of this. Instead of calling LoadLibrary, the injector:
- Opens the target process with VirtualAllocEx [3] to allocate a region of private memory.
- Copies the PE sections into that region with WriteProcessMemory [4].
- Resolves imports and applies base relocations manually.
- Changes the memory protection to executable with VirtualProtectEx [5].
- Creates a remote thread or hijacks an existing one to execute the entry point.
The result is executable code running in the process’s address space with no corresponding entry in any module list. EnumProcessModules cannot see it. NtQueryInformationProcess with ProcessImageFileName does not know about it. The code is functionally loaded but structurally invisible to the loader.
However, the Windows memory manager still knows about the allocation. Every region of virtual memory, whether file-backed or privately allocated, is tracked by the kernel’s Virtual Address Descriptor (VAD) tree [6]. Querying this tree reveals the allocation even when the loader has no record of it.
How VAD Scanning Works: ZwQueryVirtualMemory from Kernel Mode
The kernel maintains a VAD tree per process inside the EPROCESS structure [6]. Each node describes a range of virtual addresses and carries metadata: protection flags, allocation type, and whether the region is backed by a file (image) or is private memory. The documented API to query this information is ZwQueryVirtualMemory with the MemoryBasicInformation class, which fills a MEMORY_BASIC_INFORMATION structure for a given address [7].
Peregrine’s driver walks the entire usermode address space of a target process by starting at address zero and advancing by each region’s size. For each region, the driver checks four properties:
- State: Is the region committed (
MEM_COMMIT)? Free or reserved pages are irrelevant. - Protection: Does the region have execute permissions (
PAGE_EXECUTE,PAGE_EXECUTE_READ,PAGE_EXECUTE_READWRITE, orPAGE_EXECUTE_WRITECOPY)? Non-executable regions cannot contain runnable code. - Type: Is the region private (
MEM_PRIVATE) or mapped (MEM_MAPPED) rather than image-backed (MEM_IMAGE)? Legitimate DLLs loaded by the Windows loader appear asMEM_IMAGEbecause they are mapped from PE files on disk [7]. - Guard flag: Is
PAGE_GUARDset? Guard pages are used for stack growth and should be excluded [8].
A region that is committed, executable, not image-backed, not a guard page, and at least one page in size is suspicious. Legitimate executables and DLLs loaded through the normal loader always appear as MEM_IMAGE. Private executable memory large enough to hold code is a strong indicator of manual mapping, reflective loading, or raw shellcode injection.
The Kernel Implementation: VadScanProcess
The scan is implemented in VadScan.c. The function takes a target PID, obtains a kernel handle to the process, and walks the address space:
// From: PeregrineKernelComponent/VadScan.c
NTSTATUS VadScanProcess(_In_ HANDLE ProcessId) {
NTSTATUS status;
PEPROCESS process = NULL;
PEPROCESS selfProcess = PsGetCurrentProcess();
HANDLE hProc = NULL;
KdPrint(("Peregrine: VAD scan for PID %lu\n", (ULONG)(ULONG_PTR)ProcessId));
status = PsLookupProcessByProcessId(ProcessId, &process);
if (!NT_SUCCESS(status)) {
KdPrint(("Peregrine: PsLookupProcessByProcessId failed 0x%X\n", status));
return status;
}
status = ObOpenObjectByPointer(process, OBJ_KERNEL_HANDLE, NULL,
PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, *PsProcessType, KernelMode, &hProc);
if (!NT_SUCCESS(status)) {
ObDereferenceObject(process);
KdPrint(("Peregrine: ObOpenObjectByPointer failed 0x%X\n", status));
return status;
}
// ... address space walk follows ...
}
The function uses PsLookupProcessByProcessId [9] to convert the PID into an EPROCESS pointer, then ObOpenObjectByPointer [10] to get a kernel handle with PROCESS_QUERY_INFORMATION | PROCESS_VM_READ access. This kernel handle is required by ZwQueryVirtualMemory, which expects a process handle rather than an EPROCESS pointer.
Walking the Address Space Region by Region
The core loop iterates from address zero up to the usermode limit (0x00007FFFFFFFFFFF on x64). Each call to ZwQueryVirtualMemory returns information about the region containing the current address, including its size, which determines the next address to query:
// From: PeregrineKernelComponent/VadScan.c
ULONG_PTR addr = 0;
ULONG hits = 0;
ULONG totalRegions = 0;
while (addr < USER_ADDR_MAX) {
MEMORY_BASIC_INFORMATION_KM mbi = { 0 };
SIZE_T retLen = 0;
status = ZwQueryVirtualMemory(hProc, (PVOID)addr, 0 /* MemoryBasicInformation */,
&mbi, sizeof(mbi), &retLen);
if (!NT_SUCCESS(status)) break;
ULONG_PTR base = (ULONG_PTR)mbi.BaseAddress;
SIZE_T size = mbi.RegionSize;
totalRegions++;
BOOLEAN isCommitted = (mbi.State == MBI_MEM_COMMIT);
BOOLEAN isExec = (mbi.Protect & MBI_EXECUTE_FLAGS) != 0;
BOOLEAN isGuard = (mbi.Protect & MBI_PAGE_GUARD) != 0;
BOOLEAN isImage = (mbi.Type == MBI_MEM_IMAGE);
if (isCommitted && isExec && !isGuard && !isImage && size >= 0x1000) {
// ... suspicious region handling ...
}
ULONG_PTR next = base + size;
if (next <= addr) break;
addr = next;
}
The USER_ADDR_MAX constant is defined as 0x00007FFFFFFFFFFFULL, the canonical upper boundary for usermode addresses on 64-bit Windows [11]. The loop advances by base + size after each query, which skips to the start of the next region. The next <= addr guard prevents infinite loops in case of unexpected return values.
The execute flags are combined into a single mask:
// From: PeregrineKernelComponent/VadScan.c
#define MBI_PAGE_EXECUTE 0x10
#define MBI_PAGE_EXECUTE_READ 0x20
#define MBI_PAGE_EXECUTE_READWRITE 0x40
#define MBI_PAGE_EXECUTE_WRITECOPY 0x80
#define MBI_EXECUTE_FLAGS (MBI_PAGE_EXECUTE | MBI_PAGE_EXECUTE_READ | \
MBI_PAGE_EXECUTE_READWRITE | MBI_PAGE_EXECUTE_WRITECOPY)
These match the standard Windows memory protection constants [8]. Any region with one of these flags set contains potentially executable code.
Probing for PE Headers with MmCopyVirtualMemory
When a suspicious region is found, the driver does not stop at reporting the address. It reads the first two bytes to check for the MZ signature that begins every PE file:
// From: PeregrineKernelComponent/VadScan.c
BOOLEAN hasPe = FALSE;
UCHAR header[2] = { 0 };
SIZE_T bytesRead = 0;
__try {
status = MmCopyVirtualMemory(process, (PVOID)base,
selfProcess, header, sizeof(header), KernelMode, &bytesRead);
if (NT_SUCCESS(status) && bytesRead >= 2 && header[0] == 'M' && header[1] == 'Z') {
hasPe = TRUE;
}
} __except (EXCEPTION_EXECUTE_HANDLER) { }
MmCopyVirtualMemory is an undocumented but widely known kernel function that copies memory between process address spaces. The driver reads from the target process into its own address space. The __try/__except block catches access violations in case the page has been decommitted or the process has exited between the query and the read.
The PE header check adds a confidence tier to the detection:
- Executable private memory with an MZ header: very high confidence manual mapping. The injector wrote a full PE image and did not erase the header.
- Executable private memory without an MZ header: still suspicious, but could be raw shellcode, JIT-compiled code, or a manual mapper that erases the PE header after loading (a common evasion technique).
The driver also flags whether the region has PAGE_EXECUTE_READWRITE protection, which is noteworthy because it means the memory is simultaneously writable and executable. Legitimate code is almost always mapped PAGE_EXECUTE_READ; RWX regions indicate either self-modifying code or an injector that did not bother tightening permissions after writing.
Reporting Suspicious Regions as JSON Events
Each suspicious region is serialized as a JSON event and sent to the usermode client through the driver’s communications channel:
// From: PeregrineKernelComponent/VadScan.c
BOOLEAN isRwx = (mbi.Protect & 0xFF) == MBI_PAGE_EXECUTE_READWRITE;
CHAR json[COMS_MAX_MESSAGE_SIZE];
RtlStringCchPrintfA(json, ARRAYSIZE(json),
"{ \"event\": \"vad_suspicious\", \"pid\": %lu, "
"\"base\": \"0x%llX\", \"size\": %llu, "
"\"protection\": \"%s\", \"type\": \"%s\", "
"\"has_pe_header\": %s, \"rwx\": %s }",
(ULONG)(ULONG_PTR)ProcessId,
(unsigned long long)base,
(unsigned long long)size,
ProtString(mbi.Protect),
TypeString(mbi.Type),
hasPe ? "true" : "false",
isRwx ? "true" : "false");
ComsSendToUser(json, (ULONG)strlen(json));
hits++;
The event includes the base address, size, protection string, memory type, whether the MZ signature was found, and whether the region is RWX. After the full walk completes, the driver sends a summary event:
// From: PeregrineKernelComponent/VadScan.c
CHAR json[COMS_MAX_MESSAGE_SIZE];
RtlStringCchPrintfA(json, ARRAYSIZE(json),
"{ \"event\": \"vad_scan_complete\", \"pid\": %lu, "
"\"suspicious_count\": %lu, \"regions_scanned\": %lu }",
(ULONG)(ULONG_PTR)ProcessId, hits, totalRegions);
ComsSendToUser(json, (ULONG)strlen(json));
This gives the client both per-region details and an overall scan summary.
The IOCTL Bridge: Command 13
The scan is triggered from usermode via the driver’s IOCTL interface. The communications layer in Coms.c dispatches command byte 0x0D (13) to VadScanProcess:
// From: PeregrineKernelComponent/Coms.c
case 13: { // VAD scan for executable private memory
if (DataSize < 1 + sizeof(HANDLE)) {
KdPrint(("Peregrine: cmd=13 too small (%lu)\n", DataSize));
return;
}
RtlCopyMemory(&pid, Data + 1, sizeof(pid));
KdPrint(("Peregrine: user requested VAD scan for PID %lu\n", (ULONG)(ULONG_PTR)pid));
VadScanProcess(pid);
break;
}
The command format is one byte (command ID 13) followed by a HANDLE-sized PID value (8 bytes on x64). The Rust-side Tauri client in driver_comm.rs wraps this into a typed method:
// From: peregrine-tauri/src-tauri/src/driver_comm.rs
pub fn scan_vad(&self, pid: u32) -> Result<(), String> {
let handle_size = std::mem::size_of::<usize>();
let mut buf = vec![13u8];
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 handle_size branching handles the difference between 32-bit and 64-bit pointer sizes, matching the kernel’s expectation of a HANDLE-sized value. The command goes through the IOCTL_PEREGRINE_SEND_FROM_USER control code, which is defined identically on both sides:
// 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)
For more detail on how Peregrine’s kernel-to-usermode communication layer works, see the architecture overview.
The Test Binary: Simulating Manual Mapping
The repository includes cheat_manualmap.c, a test binary that simulates manual mapping against a target process. It follows the exact sequence a real cheat injector would use:
// From: test/cheat_manualmap.c
/* Allocate 64 KB to simulate a realistic DLL size */
SIZE_T allocSize = 64 * 1024;
LPVOID remoteMem = VirtualAllocEx(hProc, NULL, allocSize,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
The allocation starts as PAGE_READWRITE, not executable. The test then builds a minimal PE image in a local buffer with a valid MZ signature, a PE header, and shellcode at offset 0x1000 (simulating a .text section):
// From: test/cheat_manualmap.c
/* Minimal MZ + PE header so the VAD scanner can detect PE signature */
localBuf[0] = 'M';
localBuf[1] = 'Z';
*(DWORD*)(localBuf + 0x3C) = 0x80; /* e_lfanew */
*(DWORD*)(localBuf + 0x80) = 0x00004550; /* PE\0\0 signature */
*(WORD*)(localBuf + 0x84) = 0x8664; /* Machine: AMD64 */
/* Write shellcode at offset 0x1000 (simulated .text section) */
SIZE_T codeOff = 0x1000;
memcpy(localBuf + codeOff, shellcode, sizeof(shellcode));
After writing the buffer into the target process, it changes the protection to executable:
// From: test/cheat_manualmap.c
/* Change to executable */
DWORD oldProt = 0;
VirtualProtectEx(hProc, remoteMem, allocSize, PAGE_EXECUTE_READ, &oldProt);
The test also supports a --no-header flag that zeroes the first 0x200 bytes after writing, simulating an advanced manual mapper that erases the PE header to hinder forensic analysis:
// From: test/cheat_manualmap.c
/* Optionally erase PE header to simulate advanced manual mapper */
if (erase_header) {
unsigned char zeros[0x200] = { 0 };
WriteProcessMemory(hProc, remoteMem, zeros, sizeof(zeros), &written);
printf("[MANUALMAP] Erased PE header (%zu bytes zeroed)\n", written);
}
This tests both detection tiers: with the header intact, the VAD scan reports has_pe_header: true; with the header erased, it reports has_pe_header: false but still flags the region as suspicious because the memory is executable, private, and not image-backed. The region is detectable regardless of whether the PE header survives.
How VAD Scanning Complements Existing Detections
VAD scanning fills a specific gap in Peregrine’s detection stack. Each existing detection covers a different phase of the injection lifecycle:
- Thread analysis detects threads executing outside known modules. This catches the execution phase, but requires a thread to actually be running from the injected memory at scan time.
- Module integrity checking detects tampering with legitimately loaded DLLs by comparing their in-memory contents against their on-disk images. This catches hooking and patching, but only applies to modules that went through the loader.
- ETW Threat Intelligence provides real-time telemetry for cross-process operations like
ALLOCVM_REMOTEandWRITEVM_REMOTE. This catches the injection as it happens, but requires Peregrine to be running when the injection occurs.
VAD scanning is different because it examines the result, not the method. It does not need to catch the injection in progress or find a running thread. As long as the injected code remains in memory, the executable private region is visible. This makes it effective against dormant payloads that have been mapped but not yet executed, or code that runs briefly and then sleeps.
The detections are strongest when combined. An ALLOCVM_REMOTE ETW event followed by a new executable private region in the VAD scan, followed by a suspicious thread with its start address in that region, provides a complete injection narrative from allocation through execution.
Limitations and False Positives
VAD scanning has inherent false positive sources that affect any tool using this technique.
JIT compilers are the most significant source. The .NET CLR, JavaScript V8 engine, Java HotSpot, and LuaJIT all allocate private executable memory at runtime to hold compiled code [12]. These regions are committed, executable, and private, matching the exact criteria the scan looks for. A game built on Unity (which uses the Mono or IL2CPP runtime) will have JIT regions that trigger the scan.
Executable packers and DRM that unpack code into allocated memory at startup produce similar regions. Some anti-tamper solutions intentionally use private executable memory as part of their protection scheme.
The scan is a point-in-time snapshot. It finds what exists when the scan runs. If the cheat maps memory, executes an initialization routine, and then frees the allocation, the scan must run during that window to catch it. Persistent cheats that remain mapped for the duration of the game session are reliably detectable; transient injections that clean up after themselves are not.
Header erasure reduces confidence but does not prevent detection. As demonstrated by the test binary’s --no-header mode, zeroing the MZ signature removes one indicator but the region itself is still flagged. The has_pe_header field in the event allows the client to distinguish high-confidence detections (PE header present) from lower-confidence ones (executable private memory without a recognizable header).
PAGE_EXECUTE_READWRITE is not always malicious. Some legitimate software, particularly older applications and certain middleware, uses RWX memory for trampolines or self-patching code. The rwx flag in the scan results enables the client to weight RWX regions more heavily without treating them as definitive proof of injection.
Drafted with LLM assistance from the Peregrine Anti-Cheat source code, reviewed and verified against the actual implementation.
References
- [1] Microsoft, LdrRegisterDllNotification function
- [2] Microsoft, EnumProcessModules function
- [3] Microsoft, VirtualAllocEx function
- [4] Microsoft, WriteProcessMemory function
- [5] Microsoft, VirtualProtectEx function
- [6] Microsoft, Virtual Address Descriptors
- [7] Microsoft, MEMORY_BASIC_INFORMATION structure
- [8] Microsoft, Memory Protection Constants
- [9] Microsoft, PsLookupProcessByProcessId function
- [10] Microsoft, ObOpenObjectByPointer function
- [11] Microsoft, Virtual Address Space (x64)
- [12] Microsoft, .NET RyuJIT Overview