Virtual Memory & VAD Tree
How Windows manages each process's virtual address space — the Virtual Address Descriptor tree, VirtualQuery for runtime inspection, memory types and states, and how memory scanners detect injected shellcode
You're writing a memory scanner to find injected shellcode. You call VirtualQueryEx in a loop to walk the target process's address space and dump every region. But you need to know: which regions are "normal" for a process and which are anomalous? A MEM_PRIVATE + PAGE_EXECUTE_READ region with no backing file and high entropy is your red flag. This chapter explains how to read the address space and interpret what you find.
Virtual Memory Fundamentals
Each process has its own private virtual address space — a range of addresses that the process can use. On 64-bit Windows, user mode has 128 TB of virtual address space (addresses 0x0000000000000000 to 0x00007FFFFFFFFFFF). This is virtual — most of it is unmapped; the CPU's Memory Management Unit translates virtual addresses to physical RAM through page tables.
Key concepts:
- Virtual page: 4 KB unit of virtual address space. All memory operations work at page granularity.
- Reserved vs committed: Virtual address space can be reserved (address range claimed but no physical backing) or committed (physical RAM or pagefile space assigned).
- Working set: The subset of a process's virtual memory currently in physical RAM. Pages not in the working set are in the pagefile or zero-initialized on first access.
Process Address Space Layout
64-bit User-Mode Address Space Layout (typical process)
─────────────────────────────────────────────────────────────────────
0x0000000000000000 (null page — unmapped, access violation)
0x0000000000001000 Process starts here (not literal — for clarity)
Low addresses:
[PE image mapped at ASLR base, e.g., 0x00007FF600000000]
[Heap regions — multiple private heaps]
[Thread stacks — one per thread, each ~1-2 MB]
Mid addresses:
[Loaded DLLs — ntdll, kernel32, user32, etc.]
[Memory-mapped files — section objects]
[VirtualAlloc'd regions — explicit allocations]
0x00007FFE0000 SharedUserData (read-only page shared with kernel)
0x00007FFFFF000000 Kernel address space starts (inaccessible from user mode)
SharedUserData at 0x7FFE0000 contains:
SystemTime, TickCount, NtSystemRoot, NtProductType, and importantly:
KiUserExceptionDispatcher address (used by SEH)
Virtual Address Descriptor (VAD) Tree
The Windows Memory Manager tracks every allocated region in a process using a balanced binary tree called the VAD tree. Each MMVAD node describes one contiguous allocation:
| MMVAD Field | Description |
|---|---|
StartingVpn / EndingVpn | Start and end virtual page numbers (multiply by 0x1000 to get address) |
u.VadFlags | Type (private/mapped/image), protection bits, commit charge |
ControlArea | For mapped/image regions: points to the section object (file mapping). NULL for private regions. |
FileObject | For file-mapped regions: the backing file object (has a file name) |
The VAD tree lives in kernel memory (accessible via the EPROCESS structure). Memory forensics tools like Volatility use the VAD tree to enumerate all memory regions in a process — even regions that have been hidden from user-mode VirtualQueryEx by a kernel rootkit (though hiding from both the page tables and the VAD simultaneously is difficult).
VAD Region Types — What to Look For
─────────────────────────────────────────────────────────────────────
Type | Protection | Backing | Normal? | Suspicious if
──────────────┼────────────┼────────────┼────────────┼───────────────
Image | RX | PE file | Normal | High entropy in
(mapped PE) | | on disk | | "image" region
──────────────┼────────────┼────────────┼────────────┼───────────────
Mapped | RW/RO | File or | Normal | Anonymous
(section) | | pagefile | | execute mapping
──────────────┼────────────┼────────────┼────────────┼───────────────
Private | RW | Pagefile | Normal for | If exec bits
(VirtualAlloc)| or RX | (no file) | heap/stack | set → shellcode
──────────────┼────────────┼────────────┼────────────┼───────────────
Private+exec | RX/RWX | Pagefile | ⚠ RARE | Strong injection
| | (no file) | | indicator
VirtualQuery API
VirtualQueryEx walks the address space of a process, returning information about each region via a MEMORY_BASIC_INFORMATION structure:
#include <windows.h>
void ScanMemory(HANDLE hProc) {
MEMORY_BASIC_INFORMATION mbi;
ULONG_PTR addr = 0;
while (VirtualQueryEx(hProc, (LPCVOID)addr, &mbi, sizeof(mbi)) == sizeof(mbi)) {
if (mbi.State == MEM_COMMIT) {
// Check for suspicious combination: private + executable
BOOL is_exec = (mbi.Protect & PAGE_EXECUTE) ||
(mbi.Protect & PAGE_EXECUTE_READ) ||
(mbi.Protect & PAGE_EXECUTE_READWRITE) ||
(mbi.Protect & PAGE_EXECUTE_WRITECOPY);
BOOL is_private = (mbi.Type == MEM_PRIVATE);
if (is_exec && is_private) {
printf("[SUSPICIOUS] Base=%p Size=0x%zX Protect=0x%X Type=%s\n",
mbi.BaseAddress, mbi.RegionSize,
mbi.Protect,
mbi.Type == MEM_PRIVATE ? "PRIVATE" :
mbi.Type == MEM_IMAGE ? "IMAGE" :
mbi.Type == MEM_MAPPED ? "MAPPED" : "UNKNOWN");
}
}
addr = (ULONG_PTR)mbi.BaseAddress + mbi.RegionSize;
if (addr < (ULONG_PTR)mbi.BaseAddress) break; // overflow guard
}
}
Memory States and Types
| State / Type | Value | Meaning |
|---|---|---|
MEM_FREE | 0x10000 | Address range is not reserved or committed — available for allocation |
MEM_RESERVE | 0x2000 | Range is reserved (address claimed) but no physical storage allocated |
MEM_COMMIT | 0x1000 | Physical storage (RAM or pagefile) is allocated — pages can be accessed |
MEM_PRIVATE | 0x20000 | Private memory — backed by pagefile, no file backing. Created by VirtualAlloc. |
MEM_IMAGE | 0x1000000 | Memory-mapped image — backed by a PE file (section object from a DLL/EXE) |
MEM_MAPPED | 0x40000 | Memory-mapped file — backed by a file or pagefile section, not a PE image |
Memory Scanning for Injected Code
import ctypes, struct, math
from collections import Counter
def scan_process_memory(pid: int):
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
PROCESS_ALL_ACCESS = 0x1FFFFF
hProc = kernel32.OpenProcess(PROCESS_ALL_ACCESS, False, pid)
if not hProc:
print(f"OpenProcess failed: {ctypes.get_last_error()}")
return
MBI_SIZE = 48 # sizeof(MEMORY_BASIC_INFORMATION) on x64
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
PAGE_EXECUTE_READ = 0x20
PAGE_EXECUTE_READWRITE = 0x40
addr = 0
while addr < 0x7FFFFFFFFFFF:
mbi = ctypes.create_string_buffer(MBI_SIZE)
ret = kernel32.VirtualQueryEx(hProc, addr, mbi, MBI_SIZE)
if ret != MBI_SIZE:
break
base, alloc_base, alloc_prot, region_size, state, protect, mem_type = \
struct.unpack_from("QQIIIII", mbi.raw, 0)
is_committed = (state == MEM_COMMIT)
is_private = (mem_type == MEM_PRIVATE)
is_exec = protect in (PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE,
0x10, 0x80)
if is_committed and is_private and is_exec:
# Read and check entropy
buf = ctypes.create_string_buffer(min(region_size, 0x10000))
read = ctypes.c_size_t()
kernel32.ReadProcessMemory(hProc, base, buf, len(buf), ctypes.byref(read))
data = buf.raw[:read.value]
ent = _entropy(data) if data else 0
print(f"[!] Private+Exec: {hex(base)} size={hex(region_size)} entropy={ent:.2f}")
addr = base + region_size
kernel32.CloseHandle(hProc)
def _entropy(data):
if not data: return 0
c = Counter(data); n = len(data)
return -sum((v/n)*math.log2(v/n) for v in c.values())
Q & A
Why is MEM_PRIVATE + executable a strong injection indicator?
In a normal process, executable code lives in MEM_IMAGE regions — memory backed by PE files on disk. The code section of kernel32.dll, ntdll.dll, and the application itself all show up as MEM_IMAGE with PAGE_EXECUTE_READ. MEM_PRIVATE regions are created by VirtualAlloc and are backed by the pagefile — not by any file on disk. Legitimate MEM_PRIVATE memory is almost always non-executable: heap allocations (PAGE_READWRITE), thread stacks (PAGE_READWRITE + guard), and BSS/data segments. MEM_PRIVATE + PAGE_EXECUTE_READ means: executable code that is not backed by any file on disk. JIT-compiled code (JavaScript engines, .NET runtime) is a legitimate case — JIT compilers allocate private executable memory for their output. If you're scanning a non-JIT process and find a private executable region with high entropy, it's almost certainly injected shellcode or a reflectively loaded DLL. The check isn't foolproof (JIT engines and CFG bitmap pages are exceptions), but it's high-signal in the context of known processes.
What's the difference between reserving and committing virtual memory?
Reserving (MEM_RESERVE) claims a range of virtual addresses — the addresses are marked as yours but no physical RAM or pagefile space is allocated. Other allocations won't use those addresses. The reserved region is invisible to the process's code — accessing it causes an access violation. Committing (MEM_COMMIT) allocates the physical backing (RAM or pagefile) for a range within a reserved region. The first access to a committed page triggers a page fault; the Memory Manager allocates a physical page, zero-fills it, maps it in the page tables, and returns. Why use both? VirtualAlloc(MEM_RESERVE | MEM_COMMIT) reserves and commits in one step — most code uses this form. The two-step form (MEM_RESERVE first, then MEM_COMMIT portions later) is used to pre-reserve a large address range (e.g., for a growing stack or a custom allocator) while only paying the physical memory cost as portions are actually used. Thread stacks use this pattern: 1 MB reserved, 4 KB committed initially, expanded by guard page faults as the stack grows.