process-hollowing.md (10140B)
1 --- 2 title: "Process Hollowing" 3 description: "Unmapping a suspended process's image and running a different PE from its base address, including the relocation fixups that make the swap work." 4 category: exploitation 5 subcategory: "Process Injection" 6 tags: [process-injection, evasion, windows, maldev, t1055] 7 tools: [windbg, process-hacker, pe-bear, sysmon] 8 difficulty: advanced 9 updated: 2026-10-03 10 upstreamName: "ired.team" 11 upstreamUrl: "https://ired.team/offensive-security/code-injection-process-injection/process-hollowing-and-pe-image-relocations" 12 upstreamAuthor: "Mantvydas Baranauskas" 13 upstreamLicense: none 14 upstreamRelation: derived 15 references: 16 - name: "MITRE ATT&CK T1055.012 — Process Hollowing" 17 url: "https://attack.mitre.org/techniques/T1055/012/" 18 license: none 19 relation: inspired 20 note: "Technique ID, and the detection data sources the Detection section is organised around." 21 --- 22 23 ## What it does 24 25 You start a legitimate process suspended, unmap the image it was built around, write a 26 different PE into its address space, repair that PE's absolute addresses for wherever it 27 actually landed, point the thread's entry at the new code, and resume. The process keeps the 28 image path, PID and command line it started with, so anything that identifies a process by 29 its path sees the host and not the payload. 30 31 The word "hollowing" covers a family of related things. This sheet is the strict form — 32 `NtUnmapViewOfSection` against the host's own base address, then a full manual PE load into 33 the hole. The variants that skip the unmap are different techniques with different 34 artefacts: see [thread execution hijacking](/sheets/exploitation/thread-execution-hijacking), 35 and module stomping, which overwrites a legitimately loaded module instead. 36 37 ## Prerequisites 38 39 - `SeDebugPrivilege` is not required for a process you create yourself, which is the whole 40 appeal — you own the handle from `CreateProcess`, so there is no `OpenProcess` against a 41 process you do not own. 42 - Architecture must match. A 32-bit payload into a 64-bit host fails at the relocation 43 stage, usually confusingly. 44 - The payload PE needs a relocation table if it cannot be placed at its preferred 45 `ImageBase`. A binary linked `/FIXED` has no `.reloc` section and can only be hollowed in 46 where its preferred base is free, which in practice it is not. 47 48 ## Walkthrough 49 50 The sequence, with what each call is for and what it leaves behind. This is the shape of the 51 technique rather than a buildable loader — the artefact column is the part that matters for 52 the Detection section below. 53 54 | # | Call | Purpose | Artefact | 55 |---|---|---|---| 56 | 1 | `CreateProcessA(..., CREATE_SUSPENDED, ...)` | start the host, thread never runs | process created with no thread activity; Sysmon event 1 with a normal image path | 57 | 2 | `GetThreadContext` | recover the PEB address from the thread context | — | 58 | 3 | `ReadProcessMemory` at PEB+8 | read `ImageBaseAddress` | cross-process read of own child | 59 | 4 | `NtUnmapViewOfSection(hProc, base)` | drop the host image | **the strong signal** — a process whose base address has no mapped image | 60 | 5 | `VirtualAllocEx(..., MEM_COMMIT\|MEM_RESERVE, PAGE_EXECUTABLE_READWRITE)` | reserve `SizeOfImage` for the payload | RWX private commit, image-backed nowhere | 61 | 6 | `WriteProcessMemory` ×N | headers, then each section at its `VirtualAddress` | repeated cross-process writes | 62 | 7 | relocation fixups | patch absolute addresses by the delta | — | 63 | 8 | `SetThreadContext` | point `RCX`/`EAX` at the new entry point | thread entry outside any module | 64 | 9 | `ResumeThread` | run it | execution begins from private memory | 65 66 ### Finding `ImageBaseAddress` 67 68 The PEB holds the image base eight bytes in on x86 (`0x10` on x64). In WinDBG the host's PEB 69 is visible directly, which is the cheapest way to confirm your offsets are right before 70 trusting them in code. 71 72 <figure class="shot"> 73 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/Screenshot%20from%202019-04-28%2016-36-33.png" 74 alt="WinDBG output locating the suspended host process's PEB at address 0100e000" 75 loading="lazy" referrerpolicy="no-referrer"> 76 <figcaption>The host's PEB located in WinDBG, here at <code>0100e000</code>. 77 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 78 </figcaption> 79 </figure> 80 81 <figure class="shot"> 82 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/Screenshot%20from%202019-04-28%2016-38-29.png" 83 alt="WinDBG showing the ImageBaseAddress field eight bytes past the PEB base" 84 loading="lazy" referrerpolicy="no-referrer"> 85 <figcaption><code>ImageBaseAddress</code> sits eight bytes past the PEB on x86. 86 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 87 </figcaption> 88 </figure> 89 90 ### The unmap 91 92 Before the unmap, the host's base address holds its own image. This is worth looking at in a 93 debugger once, because it is exactly the state that a defender's memory scan compares 94 against. 95 96 <figure class="shot"> 97 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/Screenshot%20from%202019-04-28%2016-50-46.png" 98 alt="Debugger view of the host image still mapped at its ImageBaseAddress before hollowing" 99 loading="lazy" referrerpolicy="no-referrer"> 100 <figcaption>The host image still mapped at its base, before the unmap. 101 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 102 </figcaption> 103 </figure> 104 105 <figure class="shot"> 106 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/Peek%202019-04-28%2016-53.gif" 107 alt="Debugger showing the base address empty after NtUnmapViewOfSection, the image no longer present" 108 loading="lazy" referrerpolicy="no-referrer"> 109 <figcaption>After <code>NtUnmapViewOfSection</code>: the base address is empty. 110 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 111 </figcaption> 112 </figure> 113 114 Re-allocating at the host's old base often fails with `ERROR_INVALID_ADDRESS` even though 115 the region reads as unmapped, so implementations generally let the allocator choose. That 116 choice is what forces the relocation work in the next step — if you could always land on the 117 payload's preferred `ImageBase`, there would be nothing to fix up. 118 119 ### Relocations 120 121 Allocating anywhere other than the payload's preferred `ImageBase` means every absolute 122 address baked into the image is wrong by a fixed delta: 123 124 ```text 125 delta = allocated_base - payload_optional_header.ImageBase 126 ``` 127 128 Walk `IMAGE_DIRECTORY_ENTRY_BASERELOC`. Each `IMAGE_BASE_RELOCATION` block covers a 4 KB 129 page and is followed by 16-bit entries; the top 4 bits are the type, the low 12 are the 130 offset into that page. For each entry of type `IMAGE_REL_BASED_HIGHLOW` (x86) or 131 `IMAGE_REL_BASED_DIR64` (x64), add the delta to the pointer at that address. Types 132 `ABSOLUTE` (0) are padding and are skipped. 133 134 Reading the section headers correctly is the step that silently ruins the rest, so check them 135 against a PE parser rather than trusting your struct offsets. 136 137 <figure class="shot"> 138 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/Screenshot%20from%202019-04-28%2017-08-28.png" 139 alt="Section header values read by the loader shown side by side with the same values in a PE parser" 140 loading="lazy" referrerpolicy="no-referrer"> 141 <figcaption>Section header values cross-checked against a PE parser. 142 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 143 </figcaption> 144 </figure> 145 146 ## Detection 147 148 Hollowing is noisy once you know what to compare. The technique's own requirements create 149 the signals. 150 151 **Image/memory mismatch.** The decisive artefact: the process's base address either has no 152 mapped image, or has one whose contents do not match the file on disk at the path the 153 process claims. Nothing legitimate unmaps its own primary image. Compare the in-memory PE 154 headers at `ImageBaseAddress` against the file at the process's image path — a mismatch in 155 `SizeOfImage`, section names, or entry point is conclusive. 156 157 **Private RWX in a fresh process.** Step 5 commits a `PAGE_EXECUTE_READWRITE` region that is 158 not backed by any file. Enumerate committed regions with `VirtualQueryEx` and flag 159 `MEM_PRIVATE` + executable. Volatility's `malfind` finds this directly; so does a 160 `Get-ForeignThread`-style scan. 161 162 **Thread entry outside a module.** After `SetThreadContext` the thread's start address is in 163 private memory. `NtQueryInformationThread(ThreadQuerySetWin32StartAddress)` compared against 164 the loaded module list separates injected threads from legitimate ones. 165 166 **API sequence.** `CreateProcess(CREATE_SUSPENDED)` → `NtUnmapViewOfSection` → 167 `VirtualAllocEx` → `WriteProcessMemory` → `SetThreadContext` → `ResumeThread` against the 168 same handle is a near-unambiguous pattern. Most EDR hooks at least `NtUnmapViewOfSection` 169 and `NtSetContextThread` for exactly this reason. 170 171 **Sysmon.** Event 1 shows a process whose parent makes no sense for it (calc.exe spawned by 172 a build output). Event 8 (`CreateRemoteThread`) does **not** fire — hollowing resumes an 173 existing thread, which is part of why it was popular — so do not rely on it. Event 10 174 (`ProcessAccess`) with `GrantedAccess` including `0x0008`/`0x0020` (`VM_WRITE`, 175 `VM_OPERATION`) is the usable one. 176 177 **Behavioural.** A suspended process that lives for more than a few milliseconds before 178 resuming is unusual. So is a process whose loaded-module list is missing the DLLs its stated 179 image imports. 180 181 ## References 182 183 - [MITRE ATT&CK T1055.012 — Process Hollowing](https://attack.mitre.org/techniques/T1055/012/) 184 - [Thread execution hijacking](/sheets/exploitation/thread-execution-hijacking) — the 185 no-unmap variant