Memory forensicsHighPublished

Process hollowing detection via VAD and section object cross-reference

Process hollowing and its modern variants (module stomping, image overwrite) share a common artifact: a Virtual Address Descriptor (VAD) node that maps an image file whose on-disk content no longer matches the in-memory pages. We detail a detection methodology that cross-references the VAD tree's ImageFilePointer, the PEB Ldr module list, and a section object hash to surface injected code hiding behind legitimate module names — with a 1.8% false-positive rate on clean game populations.

CR
Clubhouse AC Research
Jan 30, 2026 13 min read Defensive use only

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:

VariantMechanismVAD present?Corpus %
Classic hollowingUnmap image, remap injected PEImageFilePointer = null or wrong34%
Module stompingOverwrite loaded DLL pages in-placeImageFilePointer = legitimate path, content differs41%
Section remapNtMapViewOfSection SEC_IMAGE overwriteImageFilePointer = SEC_IMAGE section, pages replaced18%
PE-to-shellcodeMap anonymous SEC_COMMIT, copy PE manuallyNo ImageFilePointer; MEM_IMAGE flag absent7%

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

rules/process_hollowing_vad.rulePseudocode
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.