Virtual Address Descriptors: The VAD Tree and Memory Region Tracking

By Debraj Basak·Jul 17, 2026·19 min readWindows Internals

The PEB lies. The loader list can be unlinked, module names spoofed, Ldr entries stripped so that EnumProcessModules returns garbage. If you have ever chased an injected DLL and found the user-mode reports contradicting each other, you already know the frustration. The VAD tree, held in the kernel and maintained by the Memory Manager itself, is the ground truth: every reservation, every commit, every mapped file the process actually owns. It is the ledger you cross-check when the user-mode view is compromised.

This post walks the VAD end to end on Windows 10/11 x64. We compile a tiny lab target, allocate a page with VirtualAlloc, watch the tree grow in WinDbg, then stomp a mapped image and see how Volatility catches the seam. Detection follows.


1. Virtual Memory in One Screen

Every Windows x64 process gets a 128 TB user-mode virtual address space (0 to 0x7FFF_FFFF_FFFF), paired with a 128 TB kernel half. That space is sparse. Only fragments are ever reserved, fewer are committed, and only committed pages that have actually been touched have real page table entries backing them.

Two structures cooperate to track this:

LayerStructureOwnerGranularity
Region ledgerVAD tree (_MMVAD nodes)Kernel Memory ManagerRange of pages
Page mappingPage tables (_MMPTE)CPU + kernelSingle 4 KB page

The PTEs describe individual pages that are currently in physical memory or on the pagefile. The VAD tree describes the intent: “process X has reserved pages 0x00007FF60000 through 0x00007FF60FFF as private RW, and no, no PTE exists yet because the process has not touched it.” A page fault on a reserved-but-uncommitted address is resolved by the VAD, not the PTE.

That separation is why the VAD matters for forensics. The tree exists whether the physical page is resident, paged out, or never touched.


2. The VAD as the Kernel’s Authoritative Ledger

A Virtual Address Descriptor is a kernel structure. One node per contiguous region of virtual address space that shares the same attributes: allocation type, protection, and backing. Each VirtualAlloc produces a node. Each MapViewOfFile produces a node. Each VirtualFree (with MEM_RELEASE) removes one.

The tree is per-process. Its root pointer sits in _EPROCESS.VadRoot, and only the kernel modifies it (under process-wide locks). Nothing in user space can rewrite the tree directly. You can call NtProtectVirtualMemory to update the Protection bitfield of a node, but you cannot forge a node, hide one, or make the kernel forget an allocation you made.

Contrast with the PEB’s Ldr list: user mode, walkable and writable by any thread with PROCESS_VM_WRITE. Every “hide my DLL from the module list” trick since NTIllusion is unlinking entries from that user-mode list. The VAD entry for the mapped image? Still there, unchanged, waiting for a forensic tool to enumerate it.


Illustration of a locked kernel ledger floating above a user-mode process space, representing the VAD tree as an authority unreachable from user space
The VAD tree lives in kernel memory and is maintained exclusively by the Memory Manager – user-mode code can request changes but cannot forge or unlink entries directly.

3. Why AVL

The tree is an AVL self-balancing binary search tree keyed on the region’s starting Virtual Page Number. Left child is a lower VPN, right child is a higher VPN. Insert, delete, and lookup are all O(log n) even with pathological allocation orders, and the kernel does a lot of lookups: every page fault, every VirtualQuery, every access violation dispatch walks the tree.

On x64 the wrapper is _RTL_AVL_TREE. Each node embeds a _RTL_BALANCED_NODE as its first member for the linkage (Left, Right, ParentValue with balance bits packed into the low two bits of the parent pointer).


4. The Structures, Bottom Up

4.1. _EPROCESS: The Entry Point

On Windows 10/11 x64:

+0x7d8 VadRoot   : _RTL_AVL_TREE
+0x7e0 VadHint   : Ptr64 Void
+0x7e8 VadCount  : Uint8B

Offsets shift between builds, so always resolve with dt nt!_EPROCESS in the debugger rather than hardcoding. VadHint is a cached last-accessed node for locality (subsequent lookups near the same VA hit it first).

4.2. _MMVAD_SHORT: Private Allocations

For private memory (created by VirtualAlloc), the kernel uses _MMVAD_SHORT. It embeds the tree linkage as its first member and holds the range plus flags. Pool-tagged VadS.

typedef struct _MMVAD_SHORT {
    _RTL_BALANCED_NODE VadNode;         // Left / Right / ParentValue
    ULONG              StartingVpn;     // low 32 bits
    ULONG              EndingVpn;       // low 32 bits
    UCHAR              StartingVpnHigh;
    UCHAR              EndingVpnHigh;
    // ...
    MMVAD_FLAGS        u;               // 32-bit bitfield
    LONG               ReferenceCount;
    // ...
} MMVAD_SHORT, *PMMVAD_SHORT;

StartingVpn and EndingVpn are page numbers, not addresses. To get the actual virtual address, multiply by 0x1000:

VA_start = (StartingVpnHigh << 32 | StartingVpn) * 0x1000
VA_end   = ((EndingVpnHigh   << 32 | EndingVpn)   * 0x1000) + 0xFFF

The + 0xFFF at the end accounts for the fact that EndingVpn is inclusive of the last page.

4.3. _MMVAD: File-Backed and Image Maps

For mapped files or PE images, the kernel uses the full _MMVAD structure. Pool-tagged Vad (with a trailing space).

dt nt!_MMVAD
   +0x000 Core              : _MMVAD_SHORT
   +0x040 u2                : <anonymous-tag>
   +0x048 Subsection        : Ptr64 _SUBSECTION
   +0x050 FirstPrototypePte : Ptr64 _MMPTE
   +0x058 LastContiguousPte : Ptr64 _MMPTE
   +0x060 ViewLinks         : _LIST_ENTRY
   +0x070 VadsProcess       : Ptr64 _EPROCESS
   +0x080 FileObject        : Ptr64 _FILE_OBJECT

The chain _MMVAD -> Subsection -> ControlArea -> FilePointer is what lets !vad print the filename for image-backed regions. When you inject a DLL by LoadLibrary, that entire chain is populated with references to the real on-disk file object. FileObject at +0x80 is the fast path to the same info.

FirstPrototypePte / LastContiguousPte bookend the prototype PTE array the Memory Manager uses to lazily materialize per-process PTEs for shared pages. When two processes map the same DLL, both _MMVAD nodes point into the same prototype range; the per-process PTEs are filled in on first access.

Pool tags to memorize:

TagStructureMeaning
VadS_MMVAD_SHORTPrivate allocation (heap, stack, VirtualAlloc)
Vad_MMVADMapped file or image
VadF_MMVADLarge-page VAD (typically private)
Vadm_MMVADLarge mapped section

Hierarchy diagram showing how _EPROCESS.VadRoot leads to the AVL tree, which branches into _MMVAD and _MMVAD_SHORT nodes with their respective sub-structures
Every VAD node descends from _EPROCESS.VadRoot; private allocations use the compact _MMVAD_SHORT while file-backed or image regions use the full _MMVAD with a Subsection chain to the on-disk FileObject.

5. _MMVAD_FLAGS: Where the Interesting Bits Live

Four bytes, packed. This is what forensic tools decode to classify a region.

struct _MMVAD_FLAGS {
    unsigned long VadType         : 3;   // 0..7
    unsigned long Protection      : 5;   // encoded page prot
    unsigned long PreferredNode   : 6;
    unsigned long NoChange        : 1;
    unsigned long PrivateMemory   : 1;   // 1 = private, 0 = section-backed
    unsigned long Teb             : 1;
    unsigned long PrivateFixup    : 1;
    unsigned long ManySubsections : 1;
    unsigned long Spare           : 12;
    unsigned long DeleteInProgress: 1;
};

The two fields you check first are always VadType and PrivateMemory.

VadType values:

ValueNameWhat it means
0VadNonePrivate commit / plain VirtualAlloc
1VadDevicePhysicalMemoryMapping over \Device\PhysicalMemory
2VadImageMapPE mapped as image (SEC_IMAGE)
3VadAweAddress Windowing Extensions
4VadWriteWatchTracked writes
5VadLargePagesLarge page backing
6VadRotatePhysicalRotate physical (GPU/DirectComposition)
7VadLargePageSectionLarge-page mapped section

Protection is not the Win32 PAGE_* constants. It is a 5-bit index into a kernel table. Common values you will see:

EncodedApproximate meaning
1PAGE_READONLY
2PAGE_EXECUTE
3PAGE_EXECUTE_READ
4PAGE_READWRITE
5PAGE_WRITECOPY
6PAGE_EXECUTE_READWRITE
7PAGE_EXECUTE_WRITECOPY

That table (MmProtectToValue) is defined in the kernel and has been stable across modern Windows 10/11 builds – index 0 is PAGE_NOACCESS, and the order shown above is the canonical one. The PAGE_GUARD and PAGE_NOCACHE modifiers are folded into other bit positions in _MMPTE, not here.

PrivateMemory = 1 on a region with Protection = 6 (RWX) in a process that has no reason to JIT is the single loudest signal in malfind. It is the “unbacked RWX private commit” hunt query.


6. What Each WinAPI Actually Does to the Tree

API (user)Nt* (kernel)VAD effect
VirtualAlloc (reserve)NtAllocateVirtualMemoryCreates a _MMVAD_SHORT node, PrivateMemory=1, Protection=0 (no access)
VirtualAlloc (commit)sameSame node, Protection updated, no PTE yet
VirtualProtectNtProtectVirtualMemoryUpdates Protection on existing node; may split a node if the range is partial
MapViewOfFileNtMapViewOfSectionCreates a _MMVAD node, PrivateMemory=0, Subsection populated, VadType=VadImageMap for SEC_IMAGE
VirtualFree (MEM_RELEASE)NtFreeVirtualMemoryRemoves node, sets DeleteInProgress during teardown
VirtualQuery[Ex]NtQueryVirtualMemoryRead-only walk of the tree, returns MEMORY_BASIC_INFORMATION

The kernel path lands in MiInsertVad (insert), MiRemoveVad (remove), and MiLocateAddress (lookup). Splitting a range with a partial VirtualProtect is genuinely nontrivial: the kernel allocates a new node, adjusts the tree, and rebalances. Attackers using RWX in small slivers can end up creating a suspicious number of adjacent VadS nodes with weird protection quilting; that itself is a hunt signal.


7. Lab Target

Create vad_lab_target.c. Compile with MSVC (cl /Zi vad_lab_target.c) or MinGW (x86_64-w64-mingw32-gcc -g vad_lab_target.c -o vad_lab_target.exe). The point is to produce a textbook VadS region that we can watch through each state transition.

// vad_lab_target.c - intentionally simple, for the VAD walkthrough
#include <windows.h>
#include <stdio.h>

int main(void) {
    printf("[+] PID = %lu\n", GetCurrentProcessId());
    printf("[+] Press ENTER to VirtualAlloc RW...\n");
    getchar();

    // (1) Reserve + commit a page as PAGE_READWRITE -> creates VadS node
    LPVOID mem = VirtualAlloc(NULL, 0x1000,
                              MEM_COMMIT | MEM_RESERVE,
                              PAGE_READWRITE);
    printf("[+] Alloc @ %p  (Protection = PAGE_READWRITE)\n", mem);
    getchar();

    // (2) Copy in a harmless "payload": ret (0xC3) plus filler
    unsigned char payload[16] = {
        0x48, 0x31, 0xC0,       // xor rax, rax
        0xC3,                   // ret
        0x90, 0x90, 0x90, 0x90,
        0x90, 0x90, 0x90, 0x90,
        0x90, 0x90, 0x90, 0x90
    };
    memcpy(mem, payload, sizeof(payload));
    printf("[+] Written 16 bytes. Press ENTER to VirtualProtect -> RX...\n");
    getchar();

    // (3) Flip protection to PAGE_EXECUTE_READ -> Protection bitfield updates
    DWORD old = 0;
    VirtualProtect(mem, 0x1000, PAGE_EXECUTE_READ, &old);
    printf("[+] Now PAGE_EXECUTE_READ. Press ENTER to call it...\n");
    getchar();

    // (4) Call the region as a function pointer (returns 0)
    int (*fn)(void) = (int(*)(void))mem;
    int rv = fn();
    printf("[+] Call returned %d. Press ENTER to VirtualFree...\n", rv);
    getchar();

    // (5) MEM_RELEASE -> node removed from VAD tree
    VirtualFree(mem, 0, MEM_RELEASE);
    printf("[+] Freed. Exiting.\n");
    return 0;
}

Each getchar() is a debugger checkpoint. Between them the process is idle, so you can attach the kernel debugger, inspect the tree, and continue.


8. Walking the Tree Live in WinDbg

You need a kernel debugger attached to the lab VM. KDNET over the network is the least painful setup on modern Windows: run bcdedit /debug on and bcdedit /dbgsettings NET HOSTIP:<host> PORT:50000 KEY:... in the guest, then attach WinDbg Preview on the host.

Launch the target, note its PID, and break in.

8.1. Find the _EPROCESS

kd> !process 0 0 vad_lab_target.exe
PROCESS ffffab8a2d5b3080
    SessionId: 1  Cid: 0e94    Peb: 3f4e3ff000
    Image: vad_lab_target.exe

8.2. Locate VadRoot

kd> dt nt!_EPROCESS ffffab8a2d5b3080 VadRoot VadCount
   +0x7d8 VadRoot   : _RTL_AVL_TREE
   +0x7e8 VadCount  : 0x2a

The tree is under VadRoot.Root. Dump the whole thing:

First read the VadRoot.Root pointer (that is what !vad expects, not the address of the VadRoot field itself):

kd> dt nt!_RTL_AVL_TREE ffffab8a2d5b3080+7d8
   +0x000 Root : 0xffffab8a`1c987654 Void

kd> !vad 0xffffab8a1c987654
VAD          Level    Start       End        Commit
ffffab8a1c123abc  4    7ff60000   7ff60000       1  Private      READWRITE

ffffab8a1c987654  0    7ff700000  7ff7000ff    256  Mapped Exe   EXECUTE_WRITECOPY  vad_lab_target.exe
ffffab8a1c112233  3    7ffab0000  7ffab01ff    512  Mapped Exe   EXECUTE_WRITECOPY  ntdll.dll
...

Actual output varies. The Private READWRITE row on line 1 (single page committed, start VPN 7ff60000) is our VirtualAlloc. VPN to VA: 0x7ff60000 * 0x1000 = 0x7ff60000000 (matches the pointer the target printed, minus the offset the linker chose). Note that !vad prints the raw VPN in the Start/End columns, so the same node’s _MMVAD_SHORT.StartingVpn field will read as 0x7ff60000 when you dt it directly.

8.3. Inspect the Node

kd> dt nt!_MMVAD_SHORT ffffab8a1c123abc
   +0x000 VadNode           : _RTL_BALANCED_NODE
      +0x018 StartingVpn       : 0x7ff60000
   +0x01c EndingVpn         : 0x7ff60000
   +0x020 StartingVpnHigh   : 0
   +0x021 EndingVpnHigh     : 0
   +0x030 u                 : <unnamed-tag>
      +0x000 VadFlags       : _MMVAD_FLAGS

Peel the flags:

kd> dt nt!_MMVAD_FLAGS ffffab8a1c123abc+30
   +0x000 VadType         : 0y000     (0 = VadNone)
   +0x000 Protection      : 0y00100   (4 = PAGE_READWRITE)
   +0x000 PrivateMemory   : 0y1
   +0x000 DeleteInProgress: 0y0

Continue the target past the second checkpoint (VirtualProtect fires) and re-inspect the same node address:

kd> dt nt!_MMVAD_FLAGS ffffab8a1c123abc+30
   +0x000 Protection      : 0y00011   (3 = PAGE_EXECUTE_READ)

Same node, same VPN range, same tree position. Only the Protection bitfield changed. That is NtProtectVirtualMemory in action.

8.4. Verify the Pool Tag

kd> !pool ffffab8a1c123abc
 Pool page ffffab8a1c123000 region is Nonpaged pool
  ...
 *ffffab8a1c123ab0 size:  0x40 previous size: 0x30  (Allocated) *VadS

VadS. Private allocation. Matches PrivateMemory = 1.

Traverse manually if you want to see the tree shape:

kd> dt nt!_MMVAD_SHORT <node> VadNode.Left VadNode.Right

Follow Left for lower VAs, Right for higher. The bottom two bits of ParentValue encode the balance factor; mask them off (& ~0x3) before dereferencing.


9. Volatility 3: The Same View Post-Mortem

Take a memory dump of the lab VM (WinPmem, DumpIt, or .dump /f from WinDbg). Then:

vol.py -f lab.mem windows.pslist | grep vad_lab_target
vol.py -f lab.mem windows.vadinfo --pid 3732

Expected output for our region (abbreviated):

PID    Process              Start           End             Tag   Protection            File
3732   vad_lab_target.exe   0x7ff60000000   0x7ff60000fff   VadS  PAGE_EXECUTE_READ     
3732   vad_lab_target.exe   0x7ff700000000  0x7ff70000ffff  Vad   PAGE_EXECUTE_WRITECOPY \Device\...\vad_lab_target.exe

Now the malfind plugin:

vol.py -f lab.mem windows.malfind --pid 3732

Malfind’s heuristic is straightforward: it flags VadS regions where the protection allows execution and the first bytes look like code (an MZ header, or valid x86/x64 instructions and no null header). Our lab region has a xor rax, rax; ret prologue with NOPs, so malfind will happily flag it and print a disassembly.

That is the shape of every reasonable EDR memory scanner: enumerate VADs, classify each region, focus on PrivateMemory=1 with executable protection, and disassemble the head.


10. Attacker Abuse: VAD Stomping and Hollowing

The reason the VAD matters offensively is exactly the reason it matters defensively: it is what the memory scanner reads.

Classic injection (T1055.001) is loud. VirtualAllocEx creates a fresh VadS node in the target. WriteProcessMemory fills it. CreateRemoteThread runs it. Every memory scanner that classifies private+executable as anomalous catches it.

VAD stomping / module stomping is the attacker’s answer. The technique:

  1. Attacker chooses a benign, signed DLL that the target already loads (or maps a fresh one with LoadLibrary).
  2. In its address range, VirtualProtect a section (usually .text) to PAGE_EXECUTE_READWRITE.
  3. Overwrite the code with shellcode.
  4. Flip protection back to PAGE_EXECUTE_READ.
  5. Redirect execution into the overwritten region (thread hijack, APC, etc.).

The VAD node still says VadImageMap, still references the on-disk FileObject with the real signed DLL path. To a scanner walking the tree, the region is “image-backed”, which most heuristics trust more than private commits. The only tell is a content hash of the in-memory pages versus the on-disk file. Modern EDRs do exactly that, but plenty of older tooling does not.

Sample lab code (self-targeting, run against the operator’s own process):

// vad_stomp_lab.c - self-target only; never against a process you don't own
#include <windows.h>
#include <stdio.h>
#include <string.h>

int main(void) {
    // Load a benign helper DLL we already ship (any signed DLL in System32
    // works; use one you're not otherwise depending on).
    HMODULE h = LoadLibraryA("version.dll");
    if (!h) return 1;

    // Locate its .text region. In real ops you'd parse the PE headers;
    // for the lab, grab an exported function as a target address.
    FARPROC target = GetProcAddress(h, "GetFileVersionInfoA");
    printf("[+] Overwriting @ %p (inside VadImageMap for version.dll)\n", target);

    unsigned char payload[] = {
        0x48, 0x31, 0xC0,   // xor rax, rax
        0xC3                // ret
    };

    DWORD old = 0;
    VirtualProtect(target, sizeof(payload), PAGE_EXECUTE_READWRITE, &old);
    memcpy(target, payload, sizeof(payload));
    VirtualProtect(target, sizeof(payload), PAGE_EXECUTE_READ, &old);

    printf("[+] Stomp complete. VAD still shows version.dll on disk.\n");
    // Redirect: call our stomped export
    ((void(*)(void))target)();
    return 0;
}

Dump the VM, run windows.vadinfo, and the region will still print with the version.dll file path attached. Run windows.malfind and it will not flag it (private+executable heuristic misses image-backed stomping). You need a hash-check plugin (windows.dlllist + windows.moddump, then compare hash to disk) to see the mismatch. That gap is exactly what stompers rely on.

Process hollowing (T1055.012) goes further: create a suspended child process, NtUnmapViewOfSection the primary image, allocate a fresh region at the same base, write a different PE, fix up the entry point in the thread context, resume. The VadImageMap node in the child either references the original PE (if you skipped the unmap and just overwrote), or has been recreated as VadS at the image base with no file backing (if you unmapped). Modern Sysmon (13+) catches this via Event ID 25 – ProcessTampering by comparing the mapped image to disk.


Flow diagram tracing the VAD stomping technique from LoadLibrary through VirtualProtect and shellcode overwrite, showing how naive VAD scanners are bypassed but content hash checks catch the discrepancy
VAD stomping leaves the VadImageMap node intact and file-path references unchanged; only a scanner that hashes in-memory page content against the on-disk PE will detect the mismatch.

11. Common Attacker Techniques

TechniqueDescription
Classic remote injectionVirtualAllocEx + WriteProcessMemory + CreateRemoteThread. Creates a fresh VadS in target. Loudest.
VAD / module stompingOverwrite a mapped image’s committed pages while keeping the VadImageMap node intact. Region looks image-backed to naive scanners.
Process hollowingReplace image contents (or unmap-and-remap) in a suspended child. VAD may show original file path with divergent content.
Process doppelgangingTxF-based; the section is created from a transacted image so the on-disk file “looks” legit while the section content differs.
DLL hollowingSimilar to stomping but on a legitimately loaded DLL’s data or code sections, avoiding creation of any new VAD entry.
Reflective DLL injectionCopy raw DLL bytes into a target VadS region and self-relocate. VAD shows private commit with executable protection: classic malfind hit.
PEB unlinkingRemoves Ldr entries in user mode. Does not touch the VAD, so windows.vadinfo still shows the mapped image.

12. Defensive Strategies and Detection

12.1. Sysmon Event IDs

Event IDEventVAD relevance
1Process CreateBaseline; correlate with unusual command lines that follow injection
7ImageLoadCorrelate module load path with what windows.vadinfo reports later
8CreateRemoteThreadFires when a remote thread is created in another process; classic injection last step
10ProcessAccessCross-process handle grab; GrantedAccess containing 0x0020 (VM_WRITE), 0x0008 (VM_OPERATION), 0x0002 (CREATE_THREAD) is the setup for VAD manipulation
25ProcessTamperingSysmon 13+; fires when the in-memory image diverges from the mapped file (hollowing)

12.2. ETW Providers

ProviderGUID / nameWhat it yields
Microsoft-Windows-Kernel-Process{22FB2CD6-0E7B-422B-A0C7-2FAD1FD0E716}Process, thread, image events
Microsoft-Windows-Kernel-Memory{D1D93EF7-E1F2-4F45-9943-03D245FE6C00}Memory allocation/free events
Microsoft-Windows-Threat-IntelligenceETW-TI (PPL-gated)VirtualAlloc, VirtualProtect, MapViewOfSection, WriteProcessMemory page-level detail

ETW-TI is what Microsoft Defender for Endpoint consumes for injection telemetry. If you want your own detections to reach parity with commercial EDR, this is the provider to subscribe to. Access requires PPL protection on the consumer, which is why hobbyist tooling rarely uses it.

12.3. Sigma Rules

Cross-process VAD write setup (setup for stomping or classic injection):

title: Cross-Process Handle With VM_WRITE Access
id: 7a3f8d90-4b0a-4b0e-8f24-1c9e4d6f3a11
logsource:
    product: windows
    service: sysmon
detection:
    selection:
        EventID: 10
        GrantedAccess|contains:
            - '0x1fffff'   # PROCESS_ALL_ACCESS
            - '0x1f3fff'
            - '0x143a'     # VM_WRITE | VM_OPERATION | CREATE_THREAD
    filter_legit:
        SourceImage|endswith:
            - '\MsMpEng.exe'
            - '\csrss.exe'
            - '\wininit.exe'
    condition: selection and not filter_legit
fields:
    - SourceImage
    - TargetImage
    - GrantedAccess
    - CallTrace
level: high

Remote thread creation (last step of most injections):

title: Remote Thread Created In Non-System Target
id: b81f0d21-eb9c-4a70-b0d3-3d2c1b7f5c02
logsource:
    product: windows
    service: sysmon
detection:
    selection:
        EventID: 8
    filter_system:
        SourceImage|startswith: 'C:\Windows\System32\'
        TargetImage|startswith: 'C:\Windows\System32\'
    condition: selection and not filter_system
level: high

Process tampering / hollowing:

title: Sysmon ProcessTampering Image Replaced
id: 3c1b2a5d-7f10-4b19-9a2c-08e8b1f4d221
logsource:
    product: windows
    service: sysmon
detection:
    selection:
        EventID: 25
        Type: 'Image is replaced'
    condition: selection
level: high

12.4. Hunt Queries Against VAD Snapshots

Run periodically on endpoint memory snapshots or via a Volatility-backed pipeline:

  • Regions with PrivateMemory = 1 and Protection in {PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY} inside processes that have no JIT (anything that isn’t a browser, dotnet, java, node, PowerShell, etc.)
  • VadImageMap regions whose mapped file hash does not match the on-disk hash (stomping signature).
  • Regions with FileObject pointing to \Device\PhysicalMemory or unsigned paths outside System32, SysWOW64, or the app install directory.
  • Sudden increase in adjacent VadS nodes with alternating protections (indicative of small-sliver RWX flipping).

12.5. Hardening

  • Enable Arbitrary Code Guard (ACG) on sensitive processes. ACG blocks NtProtectVirtualMemory from setting executable on a region that was writable, and blocks NtAllocateVirtualMemory for RWX. It kills the naive stomping and reflective loading paths outright.
  • Code Integrity Guard (CIG) blocks loading unsigned DLLs, which shuts down the “map arbitrary DLL as image, then stomp it” opener.
  • Enable ProcessCreationMitigationPolicy extension-point disable to prevent AppInit and legacy DLL injection.
  • Deploy Sysmon 13+ with a config that includes EIDs 7, 8, 10, 25 with sensible filters.
  • Feed ETW-TI into your EDR/SIEM if the platform supports it.

Illustration of a vault door with three defence layers symbolising Arbitrary Code Guard, Code Integrity Guard, and ETW telemetry as layered mitigations against VAD-based injection
Effective VAD-level defence is layered: process mitigations like ACG block runtime protection flips, CIG prevents unsigned image stomping candidates, and ETW-TI provides page-granular telemetry for injection detection.

13. Tools for VAD Analysis

ToolDescriptionLink
WinDbg PreviewKernel debugger with !vad, !process, dt, !pool; primary live inspection toolhttps://learn.microsoft.com/windows-hardware/drivers/debugger/
Volatility 3Memory forensics; windows.vadinfo, windows.vadwalk, windows.malfindhttps://www.volatilityfoundation.org/
Process HackerLive per-process memory map with region protections and mapped fileshttps://processhacker.sourceforge.io/
Sysinternals VMMapUser-mode view of a process’s VAD (via NtQueryVirtualMemory); great for visualising fragmentationhttps://learn.microsoft.com/sysinternals/downloads/vmmap
WinPmem / DumpItLive memory acquisition for offline Volatility analysishttps://github.com/Velocidex/WinPmem
Sysmon (13+)EID 8, 10, 25 for injection and tampering detectionhttps://learn.microsoft.com/sysinternals/downloads/sysmon
PE-sieve / Hollows HunterScans running processes for hollowing, stomping, and reflective loads by comparing in-memory to diskhttps://github.com/hasherezade/pe-sieve

14. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Process Injection (parent)T1055Sysmon EID 8, 10; ETW-TI; VAD anomalies
DLL InjectionT1055.001Correlate EID 10 (VM_WRITE) with EID 8; new VadS in target
Process HollowingT1055.012Sysmon EID 25; VAD file-hash mismatch
Process DoppelgangingT1055.013Transacted image loads; ETW-TI section create
Module Stomping (variant)T1055.001Content hash of VadImageMap regions vs on-disk PE
Process DiscoveryT1057NtQueryVirtualMemory traffic; EID 10 without follow-on writes

15. Summary

  • The VAD tree is the kernel’s authoritative per-process memory ledger: an AVL tree of _MMVAD_SHORT / _MMVAD nodes rooted at _EPROCESS.VadRoot, immune to user-mode forgery.
  • Each node encodes a region’s VPN range, protection, private-vs-mapped status, and file backing; the VadS / Vad pool tags and _MMVAD_FLAGS bits are how you classify what a region really is.
  • Every VirtualAlloc, MapViewOfFile, and VirtualProtect translates into a specific mutation of the tree, so the tree is a complete record of what the process has done to its address space.
  • Attackers cannot make VAD entries disappear, but they can camouflage: VAD stomping keeps the VadImageMap node intact while replacing page contents, and process hollowing relies on the same trick at PE-load time.
  • Detection is a two-front effort. Live telemetry (Sysmon EIDs 8, 10, 25; ETW-TI for page-level allocation events) plus periodic VAD hunting (Volatility malfind, image-vs-disk hash comparison, RWX-in-private-commit queries).

Related Tutorials

References

Get new drops in your inbox

Windows internals, exploit dev, and red-team write-ups - no spam, unsubscribe anytime.