Summary
- Detected 100% of classic hollowing and 91% of module-stomp variants in our 408-sample corpus.
- Three-way cross-reference: VAD ImageFilePointer, PEB Ldr base address and section-object page hash.
- 1.8% false-positive rate on 2,100-process clean game corpus, suppressible to 0.3% with signing-chain allowlist.
Background
Process hollowing, creating a process in suspended state, unmapping its image sections and mapping injected code in their place, was documented by Nic Bhansali and others around 2012 and has remained a persistent injection technique because it reuses the identity (name, PID, parent) of a legitimate process. It is equally applicable in the cheat ecosystem: an injector that creates a hollowed svchost.exe or RuntimeBroker.exe process presents a name that passes anti-cheat process-enumeration name-lists.
Modern variants are subtler. Module stomping (overwriting a loaded DLL's image pages without unmapping) and image-section overwrite (replacing pages via NtMapViewOfSection with SEC_IMAGE + PAGE_EXECUTE_WRITECOPY) leave the VAD and PEB Ldr entries in place but diverge from the on-disk image content. Both are detectable by comparing in-memory page content against the expected section object hash, a comparison that does not require re-reading the file, since the section object persists in the kernel.
Hollowing taxonomy
We observed four injection variants across the 408 samples in our corpus:
| Variant | Mechanism | VAD present? | Corpus % |
|---|---|---|---|
| Classic hollowing | Unmap image, remap injected PE | ImageFilePointer = null or wrong | 34% |
| Module stomping | Overwrite loaded DLL pages in-place | ImageFilePointer = legitimate path, content differs | 41% |
| Section remap | NtMapViewOfSection SEC_IMAGE overwrite | ImageFilePointer = SEC_IMAGE section, pages replaced | 18% |
| PE-to-shellcode | Map anonymous SEC_COMMIT, copy PE manually | No ImageFilePointer; MEM_IMAGE flag absent | 7% |
VAD fundamentals
The Virtual Address Descriptor tree is the kernel's data structure for tracking virtual memory allocations in a process. Each node (a _MMVAD or _MMVAD_SHORT struct, depending on type) covers a contiguous virtual address range and carries:
- StartingVpn / EndingVpn, virtual page numbers for the range start and end.
- u.VadFlags.PrivateMemory , 0 for mapped files / sections, 1 for heap/stack/anonymous allocations.
- Subsection → ControlArea → FilePointer , for image-backed mappings, a pointer to the _FILE_OBJECT that backs the section. This is the ImageFilePointer we compare against PEB Ldr entries.
- u.VadFlags.ImageMap, 1 when the region was mapped as a SEC_IMAGE section. Anonymous regions containing a manually-mapped PE will have this flag clear.
The kernel does not automatically update the VAD's ImageFilePointer when the pages are overwritten via WriteProcessMemory or NtMapViewOfSection, it tracks the backing file, not the current page content. This is the gap we exploit.
Detection signals
Three independent signals, each detectable from kernel-mode without relying on user-mode API results (which may themselves be hooked):
Signal A, VAD ImageFilePointer vs. PEB Ldr base
Walk the process VAD tree. For each ImageMap node, resolve the ImageFilePointer to a file path. Compare against the full PEB Ldr InMemoryOrderModuleList: every loaded module should have a corresponding VAD node whose ImageFilePointer resolves to the module's FullDllName. A VAD node whose ImageFilePointer is null, resolves to a different path, or has no matching Ldr entry is a hollowing indicator.
Signal B, Section object hash mismatch
For an ImageMap VAD node, the kernel keeps the original mapped section object (ControlArea → Segment) in the Section subsystem. The Segment structure contains per-page hash-check infrastructure used by Code Integrity (CI). We compute a hash over the .text section pages as seen in the section object (pre-modification) and compare against the actual in-process page content. A divergence indicates pages have been written since mapping, the canonical module-stomp indicator.
Signal C, MEM_IMAGE flag on unsigned region
An anonymous SEC_COMMIT allocation (heap-allocated PE) will have VadFlags.ImageMap = 0 but its pages may be executable. When executable anonymous memory contains a valid PE header and its import table resolves to system libraries, that is a manual-map / PE-to-shellcode indicator (variant 4 in our taxonomy). This is the classic 'Execute + no image backing' heuristic, but applied at the VAD level rather than the page table level, lower false-positive rate.
Detection rule
rule ProcessHollowing_VAD_Section
{
meta:
severity = "high"
category = "injection"
confidence = 0.94
inputs:
for each process in running_processes():
vad := walk_vad_tree(process)
ldr := read_peb_ldr(process)
pages := map<vad_node, page_hashes>()
match:
// Signal A: ImageMap VAD with no matching PEB Ldr entry
any node in vad where
node.image_map == true and
node.image_file_ptr != null and
not any m in ldr where
m.dll_base == node.start_va and
m.full_dll_name == resolve_path(node.image_file_ptr)
// Signal B: .text section hash diverges from section object
or any node in vad where
node.image_map == true and
hash_pages(node.text_section, process) !=
hash_section_object(node.control_area.segment, ".text")
// Signal C: executable anonymous memory with PE header
or any node in vad where
node.image_map == false and
node.protect has EXECUTE and
node.private_memory == true and
read_va(process, node.start_va, 2) == [0x4D, 0x5A] // 'MZ'
// Manual-mapped PE — not backed by a section object
emit:
artifact {
process = process.name,
pid = process.pid,
signal = matched_signal,
vad_range = (node.start_va, node.end_va),
claimed_path = resolve_path(node.image_file_ptr),
ldr_path = matched_ldr_entry.full_dll_name,
hash_delta = (signal == "B" ? hash_mismatch_detail : null)
}
}Signal B is the most technically expensive, it requires hashing in-process pages and comparing against the section object, which involves a kernel-mode read of the target address space. In practice, we only trigger Signal B on VAD nodes that pass a pre-filter (executable ImageMap region where the on-disk file is a known legitimate system binary, the false-positive exposure is concentrated in this subset).
Validation
408
Injected-process samples in corpus
97.3%
Detection rate (all variants combined)
1.8%
False-positive rate on 2,100 clean processes
0.3%
FP rate with signing-chain allowlist
Detection rate by variant: classic hollowing 100%, module stomping 97%, section remap 95%, PE-to-shellcode 91%. The module-stomp miss rate (3%) and section-remap miss rate (5%) come from variants that stomp only a single non-critical page, not the .text section entry point. Production rules include a “stomped page count” threshold to catch these while maintaining the false-positive budget.
The 1.8% false-positive rate on clean processes was dominated by two categories: games that use custom loaders that copy PE sections to new allocations before calling the entry point (legitimate pattern in DRM-heavy titles) and .NET JIT-compiled assemblies where the in-process image pages diverge from the on-disk MSIL by design. Both are suppressible by checking for a valid IMAGE_COR20_HEADER (CLR header) or a known DRM signing certificate.
Edge cases
- .NET / CLR processes. The CLR compiles MSIL to native code and writes the result into executable pages within the loaded assembly's mapped region. Signal B will fire on every JIT-compiled method. Suppressed by: detecting IMAGE_COR20_HEADER presence in the mapped section and skipping Signal B for those VAD nodes.
- Self-modifying code with guard pages. A small number of anti-tamper systems (Denuvo, VMProtect) unpack code at runtime into the image's existing section pages, triggering Signal B. The allowlist check against the file's signing certificate suppresses these; all known DRM vendors use Authenticode-signed binaries.
- Large-page mappings. On systems with 2 MB large-page support enabled, the VAD/PFN relationship differs slightly, large-page nodes do not have per-page PFN entries, and the Signal B hash path must be adjusted. This affects <2% of the machines in our corpus (primarily server-class hardware running game servers rather than client machines).
Defensive material
The section-object hash path and the per-variant pre-filter heuristics are withheld from this note. The detection rule ships in the Clubhouse AC scanner in compiled form. DFIR teams and anti-cheat vendors seeking the full implementation can reach the team at security@clubhouseac.shop.