PWN^:Exploitation

Thread Execution Hijacking

Suspending an existing thread and repointing its instruction pointer at your code, so nothing new is ever created to notice.

advancedupdated 2026-10-03windbg · process-hacker · sysmon · volatility

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_WRITE on the process, and THREAD_SUSPEND_RESUME, THREAD_GET_CONTEXT, THREAD_SET_CONTEXT on 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 RIP kills the host thread, which is often the difference between a quiet injection and an application that visibly hangs.

Walkthrough — running-thread hijack

#CallPurposeArtefact
1OpenProcesshandle to the targetSysmon 10
2VirtualAllocEx(..., PAGE_EXECUTE_READWRITE)space for shellcodeprivate RWX region
3WriteProcessMemorythe shellcodecross-process write
4CreateToolhelp32Snapshot / Thread32Firstfind a thread of the targetthread enumeration
5OpenThread(THREAD_ALL_ACCESS, ...)handle to that thread—
6SuspendThreadfreeze ita suspended thread in a running process
7GetThreadContextcapture registers—
8set ctx.Rip = shellcodeAddressredirectthread with RIP outside any module
9SetThreadContextapplyEDR commonly hooks NtSetContextThread
10ResumeThreadrunexecution from private memory

Finding the target’s thread is step one in practice — you need a thread ID, usually the process’s main thread.

Thread listing for the target notepad process with its main thread ID identified
Locating the target's main thread ID. ired.team · Mantvydas Baranauskas

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.

Retrieved thread context structure showing the hijacked thread's CPU register values
The hijacked thread's captured register context. ired.team · Mantvydas Baranauskas

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.

Thread context showing RIP now set to the shellcode address inside the target process memory
RIP repointed to the shellcode address in the target's memory. ired.team · Mantvydas Baranauskas
Shellcode executing after the hijacked thread is resumed, with a shell returned
Execution after 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:

  1. CreateProcessA(..., CREATE_SUSPENDED, ...).
  2. Read the PEB to find ImageBaseAddress, parse the headers, compute ImageBase + OptionalHeader.AddressOfEntryPoint.
  3. WriteProcessMemory your shellcode directly over the entry point.
  4. 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

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.