daemon-sec-cheatsheet

The cheatsheet vault for operators: AD, enumeration, exploitation, priv-esc, web, DFIR
git clone https://git.daemon-sec.xyz/daemon-sec-cheatsheet.git
Log | Files | Refs | README | LICENSE

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