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.