injected-thread-hunting.md (11275B)
1 --- 2 title: "Hunting Injected Threads" 3 description: "The MEM_IMAGE test behind Get-InjectedThread, how to confirm a hit in WinDBG by thread start address, and the three ways the check is evaded." 4 category: dfir 5 subcategory: "Windows Internals" 6 tags: [detection-engineering, process-injection, windows, memory-forensics, threat-hunting] 7 tools: [powershell, windbg, process-explorer, process-hacker, volatility] 8 difficulty: advanced 9 updated: 2026-10-04 10 upstreamName: "ired.team" 11 upstreamUrl: "https://ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/get-injectedthread" 12 upstreamAuthor: "Mantvydas Baranauskas" 13 upstreamLicense: none 14 upstreamRelation: derived 15 references: 16 - name: "Get-InjectedThread.ps1" 17 url: "https://gist.github.com/jaredcatkinson/23905d34537ce4b5b1818c3e6405c1d2" 18 author: "Jared Atkinson" 19 relation: inspired 20 note: "Origin of the technique. The MEM_IMAGE / MEM_COMMIT test this sheet is built around is Atkinson's." 21 - name: "Defenders think in graphs too" 22 url: "https://posts.specterops.io/defenders-think-in-graphs-too-part-1-572524c71e91" 23 author: "Jared Atkinson" 24 relation: inspired 25 note: "The reasoning that produced the check — working backwards from what injection must do." 26 - name: "Understanding and evading Get-InjectedThread" 27 url: "https://blog.xpnsec.com/undersanding-and-evading-get-injectedthread/" 28 author: "Adam Chester" 29 relation: inspired 30 note: "The three evasions in the limitations section." 31 - name: "MEMORY_BASIC_INFORMATION" 32 url: "https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-memory_basic_information" 33 relation: inspired 34 note: "Field definitions for Type and State, which is what the check reads." 35 --- 36 37 ## What this is 38 39 The defensive counterpart to the injection sheets. Jared Atkinson's `Get-InjectedThread` 40 turns a one-line property of the Windows memory manager into a system-wide scan: legitimate 41 code executes out of memory that is backed by a file on disk, and injected code usually does 42 not. Everything else here is confirming a hit and knowing where the check fails. 43 44 The check itself: 45 46 - Enumerate every thread of every process on the host. 47 - For each thread, take its start address and query the memory region containing it. 48 - Require `Type == MEM_IMAGE` and `State == MEM_COMMIT`. 49 - A thread whose start address is in committed memory that is *not* image-backed is running 50 code with no file behind it. Report it. 51 52 `MEM_IMAGE` means the region came from a mapped section view of a PE — a module the loader 53 mapped. `MEM_PRIVATE` means the region was allocated with `VirtualAlloc`-family calls, which 54 is what an injector does before it writes shellcode. That one bit distinguishes the two. 55 56 ## Prerequisites 57 58 - PowerShell 5+ and local administrator. The scan opens handles into other processes and 59 queries their memory; without elevation you see only your own. 60 - [Get-InjectedThread.ps1](https://gist.github.com/jaredcatkinson/23905d34537ce4b5b1818c3e6405c1d2). 61 - WinDBG with symbols, and Process Explorer or Process Hacker, for confirming a hit. 62 - A test injection to find. The simplest reproduction is a `CreateRemoteThread` injection 63 into a long-lived host — see [classic DLL injection](/sheets/exploitation/dll-injection) 64 for the sequence, except pointing the thread at shellcode in private memory rather than at 65 `LoadLibrary`. 66 67 ## Walkthrough 68 69 ### Scan 70 71 ```powershell 72 $hits = Get-InjectedThread 73 $hits 74 ``` 75 76 Each hit names the host process, the `ProcessId`, the `ThreadId`, the thread's 77 `StartAddress`, the region's `MemoryState`, `MemoryType` and `AllocatedMemoryProtection`, and 78 a `Bytes` array holding the first bytes at the start address. 79 80 <figure class="shot"> 81 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/injected-threads-get-injected-thread.png" 82 alt="PowerShell output of Get-InjectedThread reporting an injected thread inside explorer.exe with its start address, memory state and protection" 83 loading="lazy" referrerpolicy="no-referrer"> 84 <figcaption>A hit: a thread in <code>explorer.exe</code> starting in non-image-backed memory. 85 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 86 </figcaption> 87 </figure> 88 89 `AllocatedMemoryProtection` is the field to read first on a hit. `PAGE_EXECUTE_READWRITE` 90 on private memory is the loud case. `PAGE_EXECUTE_READ` means the injector allocated RW, 91 wrote, then flipped to RX — more careful, equally reportable. 92 93 ### Turn the captured bytes into something comparable 94 95 The `Bytes` property is the actual code at the thread's entry. Render it as an escaped string 96 and you can diff it against known shellcode, feed it to a disassembler, or hash it: 97 98 ```powershell 99 ($hits.Bytes | ForEach-Object tostring x2) -join "\x" 100 ``` 101 102 Matching those bytes against the payload from a suspected dropper is what converts "a thread 103 looks odd" into "this specific sample ran here". 104 105 ### Confirm the thread exists where you think it does 106 107 Get the thread ID from the scan output, then find the same thread in Process Explorer's 108 Threads tab for that process. The start address column there shows the same non-module 109 address, usually rendered as a bare hex value rather than `module!symbol+offset` — which is 110 itself the tell when you are eyeballing rather than scanning. 111 112 <figure class="shot"> 113 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/injected-threads-threadid.png" 114 alt="Process Explorer Threads tab for explorer.exe with the newly created injected thread and its numeric ID selected" 115 loading="lazy" referrerpolicy="no-referrer"> 116 <figcaption>The same thread in Process Explorer. Its ID matches the scan's <code>ThreadId</code>. 117 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 118 </figcaption> 119 </figure> 120 121 ### Read the code in the debugger 122 123 Attach to the host process and work with the thread directly: 124 125 ```erlang 126 ~ 127 ~~[0x1494]s 128 ~. 129 u @$exentry 130 ``` 131 132 `~` lists threads with their IDs and start addresses. `~~[tid]s` switches context to a thread 133 by its real thread ID rather than the debugger's ordinal. `~.` prints the current thread, 134 including its start address — which is the value you cross-check against the scan. Then dump 135 and disassemble at that address: 136 137 ```erlang 138 db 03730000 L40 139 u 03730000 L20 140 ``` 141 142 <figure class="shot"> 143 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/injected-threads-inspection.png" 144 alt="WinDBG panes showing an injected thread's ID, its StartAddress and a hex dump of the bytes at that address, with the original shellcode alongside for comparison" 145 loading="lazy" referrerpolicy="no-referrer"> 146 <figcaption>Thread ID, start address and the bytes at it, against the original payload. 147 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 148 </figcaption> 149 </figure> 150 151 ### Confirm the region is the reason it was flagged 152 153 The whole check reduces to two fields of `MEMORY_BASIC_INFORMATION`. Query the region in the 154 debugger and read them back: 155 156 ```erlang 157 !address 03730000 158 ``` 159 160 `Type: <private>` with `State: MEM_COMMIT` is the flagged condition. `Type: MEM_IMAGE` with a 161 module name beside it is what every legitimate thread looks like. 162 163 <figure class="shot"> 164 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/injected-threads-address.png" 165 alt="WinDBG address-region output for the injected thread's memory beside the matching MemoryType and MemoryState fields from Get-InjectedThread" 166 loading="lazy" referrerpolicy="no-referrer"> 167 <figcaption>The region's Type and State in WinDBG, matching the scan's <code>MemoryType</code> and <code>MemoryState</code>. 168 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 169 </figcaption> 170 </figure> 171 172 ## What it tells you 173 174 **A hit is strong evidence, and it is not the whole story.** The check answers one question 175 precisely: is this thread's entry point backed by a file. A hit means code is executing that 176 the loader never mapped from disk. Legitimate exceptions exist — JIT compilers are the big 177 one, so .NET, Java and JavaScript hosts will produce noise, and in those processes you need 178 the start address correlated against the runtime's own code heaps rather than treated as a 179 verdict. 180 181 **What it is good for operationally.** Run it across a fleet and the output is small, which 182 is the useful property. A triage sequence that works: scan, filter out known JIT hosts, 183 then for every remaining hit grab `Bytes` and the `AllocatedMemoryProtection`, and pull a 184 full memory image of the host for anything that is executable and private. 185 186 **Three ways it is evaded, all of which change what you should look for.** 187 188 - *Make the thread image-backed.* Overwrite the code of a module the process legitimately 189 loaded, then start the thread inside it. The region is still `MEM_IMAGE`, so the check 190 passes. Detection moves to comparing the module's in-memory bytes against the file on disk 191 — see [walking the PEB](/sheets/dfir/peb-walking) for the comparison, since you need the 192 module list and the base addresses before you can diff anything. 193 - *Do not create a thread.* Hijack one that already exists, so there is no new thread whose 194 start address points anywhere unusual — the hijacked thread's recorded start address is 195 still the legitimate one, because `SetThreadContext` changes `RIP`, not the start address 196 the kernel recorded. This is why 197 [thread execution hijacking](/sheets/exploitation/thread-execution-hijacking) survives this 198 check, and why you want the *instruction pointer* of running threads, not just their start 199 addresses. 200 - *Point the start address at a legitimate function.* Start the thread on a real export and 201 pass the payload as an argument, or fix up `RIP` after the fact. The start address is clean. 202 203 **The structural lesson.** Every evasion above works by breaking the specific correlation the 204 check relies on. That is the normal shape of a memory-forensics detection: it tests one 205 invariant, and it is defeated by whatever restores that one invariant. Run it, and run 206 something with a different invariant beside it — an RWX-region sweep such as Volatility's 207 `malfind` (see [Volatility 3](/sheets/dfir/volatility)), module-to-disk comparison, and the 208 API-sequence telemetry from [ETW](/sheets/dfir/etw-telemetry). 209 210 ## References 211 212 - [Get-InjectedThread.ps1](https://gist.github.com/jaredcatkinson/23905d34537ce4b5b1818c3e6405c1d2) — Jared Atkinson 213 - [Defenders think in graphs too](https://posts.specterops.io/defenders-think-in-graphs-too-part-1-572524c71e91) — Jared Atkinson 214 - [Understanding and evading Get-InjectedThread](https://blog.xpnsec.com/undersanding-and-evading-get-injectedthread/) — Adam Chester 215 - [MEMORY_BASIC_INFORMATION](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-memory_basic_information) 216 - [Volatility 3](/sheets/dfir/volatility) — `malfind` and the memory-image route