What this covers
Instead of creating a thread to run your code, you take one that already exists. Suspend it, read its register context, point its instruction pointer at shellcode you have written into the process, and resume. The thread belongs to the target, was started by the target, and appears in no remote-thread telemetry.
Two variants are folded in here because they are the same idea at different moments:
- Running-thread hijack — against a live process, grab its main thread and repoint
RIP. - AddressOfEntryPoint — against a process you created suspended, overwrite the image’s entry point so the thread runs your code when first resumed. No context manipulation needed, because the thread has not started yet.
Prerequisites
PROCESS_VM_OPERATION,PROCESS_VM_WRITEon the process, andTHREAD_SUSPEND_RESUME,THREAD_GET_CONTEXT,THREAD_SET_CONTEXTon the thread.- Shellcode must be position-independent. You are jumping into it with whatever register state the hijacked thread happened to have.
- Restore or accept the loss. Save the original context before you change it. Code that
does not return to the saved
RIPkills the host thread, which is often the difference between a quiet injection and an application that visibly hangs.
Walkthrough — running-thread hijack
| # | Call | Purpose | Artefact |
|---|---|---|---|
| 1 | OpenProcess | handle to the target | Sysmon 10 |
| 2 | VirtualAllocEx(..., PAGE_EXECUTE_READWRITE) | space for shellcode | private RWX region |
| 3 | WriteProcessMemory | the shellcode | cross-process write |
| 4 | CreateToolhelp32Snapshot / Thread32First | find a thread of the target | thread enumeration |
| 5 | OpenThread(THREAD_ALL_ACCESS, ...) | handle to that thread | — |
| 6 | SuspendThread | freeze it | a suspended thread in a running process |
| 7 | GetThreadContext | capture registers | — |
| 8 | set ctx.Rip = shellcodeAddress | redirect | thread with RIP outside any module |
| 9 | SetThreadContext | apply | EDR commonly hooks NtSetContextThread |
| 10 | ResumeThread | run | execution from private memory |
Finding the target’s thread is step one in practice — you need a thread ID, usually the process’s main thread.
The captured context is what you must preserve — it holds the CPU registers as the thread was executing, and it is the only route back to normal operation.
After the context is set, RIP points into the allocated region rather than into any loaded
module — which is simultaneously the point of the technique and the way it is caught.
RIP repointed to the shellcode address in the target's memory.
ired.team · Mantvydas Baranauskas
ResumeThread.
ired.team · Mantvydas Baranauskas
Walkthrough — AddressOfEntryPoint
Against a process started CREATE_SUSPENDED, the thread exists but has not executed, so
there is no context to juggle:
CreateProcessA(..., CREATE_SUSPENDED, ...).- Read the PEB to find
ImageBaseAddress, parse the headers, computeImageBase + OptionalHeader.AddressOfEntryPoint. WriteProcessMemoryyour shellcode directly over the entry point.ResumeThread.
On the “without RWX” framing. This variant is often presented as avoiding RWX memory,
and it does avoid allocating a fresh RWX region — you are writing into the image’s own
executable section. But WriteProcessMemory has to make that page writable to write it, so
an executable page does transiently become writable regardless. The honest claim is that
there is no new private RWX allocation to find afterwards, not that no write-to-executable
ever happens. A hook on NtProtectVirtualMemory still sees it.
The upside for the operator is real though: the shellcode lives inside an image-backed,
on-disk-matching region, so malfind-style private-RWX hunting misses it entirely. The
catch moves to comparing the in-memory entry point against the file on disk.
Detection
Thread start address vs module list. NtQueryInformationThread with
ThreadQuerySetWin32StartAddress, compared against the loaded modules, finds any thread
beginning in unbacked memory. Works on the running-thread variant.
NtSetContextThread on a foreign thread. Setting the context of a thread in another
process is rare outside debuggers. Most EDR products hook this specifically, because it is
the one call the running-thread variant cannot avoid.
No Sysmon event 8. Worth stating plainly: hijacking creates no remote thread, so
CreateRemoteThread telemetry never fires. A detection strategy resting on event 8 does not
see this technique at all. Event 10 (ProcessAccess) is the one that fires, with
GrantedAccess including PROCESS_VM_WRITE.
Suspend/resume pattern. SuspendThread on a thread you do not own, followed within
milliseconds by SetThreadContext and ResumeThread, is the signature. Thread suspension
counts are visible in Process Hacker.
Entry-point integrity. For the AddressOfEntryPoint variant, read the first bytes at
ImageBase + AddressOfEntryPoint in memory and compare with the same offset in the file on
disk. A mismatch in a process that has just started is conclusive, and it is the only check
that catches this variant reliably.
Private RWX. Applies to the running-thread variant only — step 2 leaves a
MEM_PRIVATE executable region. malfind, or VirtualQueryEx enumeration looking for
committed executable private pages.
References
- MITRE ATT&CK T1055.003
- Process hollowing — the variant that unmaps the host image first
- MITRE ATT&CK T1055.004 — APC queue injection, the other route to running code on a thread you did not create