PWN^:Exploitation

Process Hollowing

Unmapping a suspended process's image and running a different PE from its base address, including the relocation fixups that make the swap work.

advancedupdated 2026-10-03windbg · process-hacker · pe-bear · sysmon

What it does

You start a legitimate process suspended, unmap the image it was built around, write a different PE into its address space, repair that PE’s absolute addresses for wherever it actually landed, point the thread’s entry at the new code, and resume. The process keeps the image path, PID and command line it started with, so anything that identifies a process by its path sees the host and not the payload.

The word “hollowing” covers a family of related things. This sheet is the strict form — NtUnmapViewOfSection against the host’s own base address, then a full manual PE load into the hole. The variants that skip the unmap are different techniques with different artefacts: see thread execution hijacking, and module stomping, which overwrites a legitimately loaded module instead.

Prerequisites

  • SeDebugPrivilege is not required for a process you create yourself, which is the whole appeal — you own the handle from CreateProcess, so there is no OpenProcess against a process you do not own.
  • Architecture must match. A 32-bit payload into a 64-bit host fails at the relocation stage, usually confusingly.
  • The payload PE needs a relocation table if it cannot be placed at its preferred ImageBase. A binary linked /FIXED has no .reloc section and can only be hollowed in where its preferred base is free, which in practice it is not.

Walkthrough

The sequence, with what each call is for and what it leaves behind. This is the shape of the technique rather than a buildable loader — the artefact column is the part that matters for the Detection section below.

#CallPurposeArtefact
1CreateProcessA(..., CREATE_SUSPENDED, ...)start the host, thread never runsprocess created with no thread activity; Sysmon event 1 with a normal image path
2GetThreadContextrecover the PEB address from the thread context—
3ReadProcessMemory at PEB+8read ImageBaseAddresscross-process read of own child
4NtUnmapViewOfSection(hProc, base)drop the host imagethe strong signal — a process whose base address has no mapped image
5VirtualAllocEx(..., MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTABLE_READWRITE)reserve SizeOfImage for the payloadRWX private commit, image-backed nowhere
6WriteProcessMemory ×Nheaders, then each section at its VirtualAddressrepeated cross-process writes
7relocation fixupspatch absolute addresses by the delta—
8SetThreadContextpoint RCX/EAX at the new entry pointthread entry outside any module
9ResumeThreadrun itexecution begins from private memory

Finding ImageBaseAddress

The PEB holds the image base eight bytes in on x86 (0x10 on x64). In WinDBG the host’s PEB is visible directly, which is the cheapest way to confirm your offsets are right before trusting them in code.

WinDBG output locating the suspended host process's PEB at address 0100e000
The host's PEB located in WinDBG, here at 0100e000. ired.team · Mantvydas Baranauskas
WinDBG showing the ImageBaseAddress field eight bytes past the PEB base
ImageBaseAddress sits eight bytes past the PEB on x86. ired.team · Mantvydas Baranauskas

The unmap

Before the unmap, the host’s base address holds its own image. This is worth looking at in a debugger once, because it is exactly the state that a defender’s memory scan compares against.

Debugger view of the host image still mapped at its ImageBaseAddress before hollowing
The host image still mapped at its base, before the unmap. ired.team · Mantvydas Baranauskas
Debugger showing the base address empty after NtUnmapViewOfSection, the image no longer present
After NtUnmapViewOfSection: the base address is empty. ired.team · Mantvydas Baranauskas

Re-allocating at the host’s old base often fails with ERROR_INVALID_ADDRESS even though the region reads as unmapped, so implementations generally let the allocator choose. That choice is what forces the relocation work in the next step — if you could always land on the payload’s preferred ImageBase, there would be nothing to fix up.

Relocations

Allocating anywhere other than the payload’s preferred ImageBase means every absolute address baked into the image is wrong by a fixed delta:

delta = allocated_base - payload_optional_header.ImageBase

Walk IMAGE_DIRECTORY_ENTRY_BASERELOC. Each IMAGE_BASE_RELOCATION block covers a 4 KB page and is followed by 16-bit entries; the top 4 bits are the type, the low 12 are the offset into that page. For each entry of type IMAGE_REL_BASED_HIGHLOW (x86) or IMAGE_REL_BASED_DIR64 (x64), add the delta to the pointer at that address. Types ABSOLUTE (0) are padding and are skipped.

Reading the section headers correctly is the step that silently ruins the rest, so check them against a PE parser rather than trusting your struct offsets.

Section header values read by the loader shown side by side with the same values in a PE parser
Section header values cross-checked against a PE parser. ired.team · Mantvydas Baranauskas

Detection

Hollowing is noisy once you know what to compare. The technique’s own requirements create the signals.

Image/memory mismatch. The decisive artefact: the process’s base address either has no mapped image, or has one whose contents do not match the file on disk at the path the process claims. Nothing legitimate unmaps its own primary image. Compare the in-memory PE headers at ImageBaseAddress against the file at the process’s image path — a mismatch in SizeOfImage, section names, or entry point is conclusive.

Private RWX in a fresh process. Step 5 commits a PAGE_EXECUTE_READWRITE region that is not backed by any file. Enumerate committed regions with VirtualQueryEx and flag MEM_PRIVATE + executable. Volatility’s malfind finds this directly; so does a Get-ForeignThread-style scan.

Thread entry outside a module. After SetThreadContext the thread’s start address is in private memory. NtQueryInformationThread(ThreadQuerySetWin32StartAddress) compared against the loaded module list separates injected threads from legitimate ones.

API sequence. CreateProcess(CREATE_SUSPENDED) → NtUnmapViewOfSection → VirtualAllocEx → WriteProcessMemory → SetThreadContext → ResumeThread against the same handle is a near-unambiguous pattern. Most EDR hooks at least NtUnmapViewOfSection and NtSetContextThread for exactly this reason.

Sysmon. Event 1 shows a process whose parent makes no sense for it (calc.exe spawned by a build output). Event 8 (CreateRemoteThread) does not fire — hollowing resumes an existing thread, which is part of why it was popular — so do not rely on it. Event 10 (ProcessAccess) with GrantedAccess including 0x0008/0x0020 (VM_WRITE, VM_OPERATION) is the usable one.

Behavioural. A suspended process that lives for more than a few milliseconds before resuming is unusual. So is a process whose loaded-module list is missing the DLLs its stated image imports.

References

On this page

☧ CPTS · ordered posts

Locked

Unlock with your Main_Key through local GPG.

Start the local GPG helper

In your cheatsheet checkout, run:

npm run unlock:helper

Open the link it prints, or paste its pairing code below. Keep the terminal open until GPG finishes.

Import an index you already decrypted

Download the encrypted index, then decrypt with GPG:

gpg --output report-index.json --decrypt report-index.gpg

Your private key stays in GPG. The decrypted index is saved in this browser so you can reopen it after navigation, refreshes and restarts. Lock this browser or clear this site's browser data to remove the saved index. Nothing is uploaded.