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

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