DFIR^:DFIR

Hunting Injected Threads

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.

advancedupdated 2026-10-04powershell · windbg · process-explorer · process-hacker

What this is

The defensive counterpart to the injection sheets. Jared Atkinson’s Get-InjectedThread turns a one-line property of the Windows memory manager into a system-wide scan: legitimate code executes out of memory that is backed by a file on disk, and injected code usually does not. Everything else here is confirming a hit and knowing where the check fails.

The check itself:

  • Enumerate every thread of every process on the host.
  • For each thread, take its start address and query the memory region containing it.
  • Require Type == MEM_IMAGE and State == MEM_COMMIT.
  • A thread whose start address is in committed memory that is not image-backed is running code with no file behind it. Report it.

MEM_IMAGE means the region came from a mapped section view of a PE — a module the loader mapped. MEM_PRIVATE means the region was allocated with VirtualAlloc-family calls, which is what an injector does before it writes shellcode. That one bit distinguishes the two.

Prerequisites

  • PowerShell 5+ and local administrator. The scan opens handles into other processes and queries their memory; without elevation you see only your own.
  • Get-InjectedThread.ps1.
  • WinDBG with symbols, and Process Explorer or Process Hacker, for confirming a hit.
  • A test injection to find. The simplest reproduction is a CreateRemoteThread injection into a long-lived host — see classic DLL injection for the sequence, except pointing the thread at shellcode in private memory rather than at LoadLibrary.

Walkthrough

Scan

$hits = Get-InjectedThread
$hits

Each hit names the host process, the ProcessId, the ThreadId, the thread’s StartAddress, the region’s MemoryState, MemoryType and AllocatedMemoryProtection, and a Bytes array holding the first bytes at the start address.

PowerShell output of Get-InjectedThread reporting an injected thread inside explorer.exe with its start address, memory state and protection
A hit: a thread in explorer.exe starting in non-image-backed memory. ired.team · Mantvydas Baranauskas

AllocatedMemoryProtection is the field to read first on a hit. PAGE_EXECUTE_READWRITE on private memory is the loud case. PAGE_EXECUTE_READ means the injector allocated RW, wrote, then flipped to RX — more careful, equally reportable.

Turn the captured bytes into something comparable

The Bytes property is the actual code at the thread’s entry. Render it as an escaped string and you can diff it against known shellcode, feed it to a disassembler, or hash it:

($hits.Bytes | ForEach-Object tostring x2) -join "\x"

Matching those bytes against the payload from a suspected dropper is what converts “a thread looks odd” into “this specific sample ran here”.

Confirm the thread exists where you think it does

Get the thread ID from the scan output, then find the same thread in Process Explorer’s Threads tab for that process. The start address column there shows the same non-module address, usually rendered as a bare hex value rather than module!symbol+offset — which is itself the tell when you are eyeballing rather than scanning.

Process Explorer Threads tab for explorer.exe with the newly created injected thread and its numeric ID selected
The same thread in Process Explorer. Its ID matches the scan's ThreadId. ired.team · Mantvydas Baranauskas

Read the code in the debugger

Attach to the host process and work with the thread directly:

~
~~[0x1494]s
~.
u @$exentry

~ lists threads with their IDs and start addresses. ~~[tid]s switches context to a thread by its real thread ID rather than the debugger’s ordinal. ~. prints the current thread, including its start address — which is the value you cross-check against the scan. Then dump and disassemble at that address:

db 03730000 L40
u 03730000 L20
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
Thread ID, start address and the bytes at it, against the original payload. ired.team · Mantvydas Baranauskas

Confirm the region is the reason it was flagged

The whole check reduces to two fields of MEMORY_BASIC_INFORMATION. Query the region in the debugger and read them back:

!address 03730000

Type: <private> with State: MEM_COMMIT is the flagged condition. Type: MEM_IMAGE with a module name beside it is what every legitimate thread looks like.

WinDBG address-region output for the injected thread's memory beside the matching MemoryType and MemoryState fields from Get-InjectedThread
The region's Type and State in WinDBG, matching the scan's MemoryType and MemoryState. ired.team · Mantvydas Baranauskas

What it tells you

A hit is strong evidence, and it is not the whole story. The check answers one question precisely: is this thread’s entry point backed by a file. A hit means code is executing that the loader never mapped from disk. Legitimate exceptions exist — JIT compilers are the big one, so .NET, Java and JavaScript hosts will produce noise, and in those processes you need the start address correlated against the runtime’s own code heaps rather than treated as a verdict.

What it is good for operationally. Run it across a fleet and the output is small, which is the useful property. A triage sequence that works: scan, filter out known JIT hosts, then for every remaining hit grab Bytes and the AllocatedMemoryProtection, and pull a full memory image of the host for anything that is executable and private.

Three ways it is evaded, all of which change what you should look for.

  • Make the thread image-backed. Overwrite the code of a module the process legitimately loaded, then start the thread inside it. The region is still MEM_IMAGE, so the check passes. Detection moves to comparing the module’s in-memory bytes against the file on disk — see walking the PEB for the comparison, since you need the module list and the base addresses before you can diff anything.
  • Do not create a thread. Hijack one that already exists, so there is no new thread whose start address points anywhere unusual — the hijacked thread’s recorded start address is still the legitimate one, because SetThreadContext changes RIP, not the start address the kernel recorded. This is why thread execution hijacking survives this check, and why you want the instruction pointer of running threads, not just their start addresses.
  • Point the start address at a legitimate function. Start the thread on a real export and pass the payload as an argument, or fix up RIP after the fact. The start address is clean.

The structural lesson. Every evasion above works by breaking the specific correlation the check relies on. That is the normal shape of a memory-forensics detection: it tests one invariant, and it is defeated by whatever restores that one invariant. Run it, and run something with a different invariant beside it — an RWX-region sweep such as Volatility’s malfind (see Volatility 3), module-to-disk comparison, and the API-sequence telemetry from ETW.

References

On this page

☧ CPTS · ordered posts

Locked

Unlock with your Main_Key through local GPG.

Start the local GPG helper

In your cheatsheet checkout, run:

npm run unlock:helper

Open the link it prints, or paste its pairing code below. Keep the terminal open until GPG finishes.

Import an index you already decrypted

Download the encrypted index, then decrypt with GPG:

gpg --output report-index.json --decrypt report-index.gpg

Your private key stays in GPG. The decrypted index is saved in this browser so you can reopen it after navigation, refreshes and restarts. Lock this browser or clear this site's browser data to remove the saved index. Nothing is uploaded.