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
SeDebugPrivilegeis not required for a process you create yourself, which is the whole appeal — you own the handle fromCreateProcess, so there is noOpenProcessagainst 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/FIXEDhas no.relocsection 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.
| # | Call | Purpose | Artefact |
|---|---|---|---|
| 1 | CreateProcessA(..., CREATE_SUSPENDED, ...) | start the host, thread never runs | process created with no thread activity; Sysmon event 1 with a normal image path |
| 2 | GetThreadContext | recover the PEB address from the thread context | — |
| 3 | ReadProcessMemory at PEB+8 | read ImageBaseAddress | cross-process read of own child |
| 4 | NtUnmapViewOfSection(hProc, base) | drop the host image | the strong signal — a process whose base address has no mapped image |
| 5 | VirtualAllocEx(..., MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTABLE_READWRITE) | reserve SizeOfImage for the payload | RWX private commit, image-backed nowhere |
| 6 | WriteProcessMemory ×N | headers, then each section at its VirtualAddress | repeated cross-process writes |
| 7 | relocation fixups | patch absolute addresses by the delta | — |
| 8 | SetThreadContext | point RCX/EAX at the new entry point | thread entry outside any module |
| 9 | ResumeThread | run it | execution 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.
0100e000.
ired.team · Mantvydas Baranauskas
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.
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.
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
- MITRE ATT&CK T1055.012 — Process Hollowing
- Thread execution hijacking — the no-unmap variant