Skip to content

Thread hijacking

Redirects an already-running thread into the payload, so no thread is ever created and the heavily instrumented NtCreateThreadEx path is avoided.

ATT&CKT1055.003 (Process Injection: Thread Execution Hijacking)
StabilityExperimental
CategoryExecution
YAML keythread_hijacking
Introduced inunreleased (thread hijacking)

Takes over a thread that already exists in a running process: suspends it, points its instruction pointer at the payload and resumes it. No thread is created, so the CreateRemoteThread/NtCreateThreadEx path — the part of the kernel interface every EDR instruments — is never used.

The payload must be position-independent code, and it should not return: under the default dedicated_stack: true a ret reads a null return address and takes the target down. The exception is deliberate — a cooperative payload that returns exactly once is supported with dedicated_stack: false (see The stack question below).

ParameterTypeRequiredDefaultDescription
payloadstringnothe only declared payloadName of the payload in build.payloads to run
targetstringnosvchost.exeImage name of a running process to hijack; matched case-insensitively against the base name (a full path works too)
pidintegernounsetExplicit process id to hijack; wins over target
dedicated_stackboolnotrueGive the hijacked thread a private stack for the payload
wait_msintegerno0Bounded wait, in milliseconds, for the target to exit; 0 does not wait

The payload must be format: shellcode; plan::validate rejects a pe, dll or dotnet payload for this technique at build time. The target must match the loader’s architecture, and the packer checks that when target is an absolute path it can read (it resolves System32 the way the stub’s bitness would, so C:\Windows\System32\ping.exe is checked against build.output.arch). A bare image name is not a path, and a pid or a $arg.N$ target has no path at all: none of those is checked, so pass a path when the plan should be refused before it is built. A bitness mismatch that reaches run time is untested; expect it to surface as a failing context round trip, not as a redirect into the payload.

Two footnotes the defaults hide:

  • svchost.exe is a name every Windows install has running, but a plan that hijacks it is hijacking a service host. Set target or pid explicitly for anything but a lab run.
  • wait_ms values of 4294967295 or more clamp to that value, and the syscall layer reads it as an infinite wait (WaitForSingleObject(INFINITE), a null NtWaitForSingleObject timeout). It is the “run until the target is gone” setting; everything smaller is a real bound.
runtime:
- technique: thread_hijacking
params:
target: notepad.exe
dedicated_stack: true
wait_ms: 0
sequenceDiagram
    participant L as Loader
    participant T as Target process
    L->>T: find pid (ToolHelp snapshot)
    L->>T: nt_open_process
    L->>T: nt_alloc_vm / nt_write_vm / nt_protect_vm
    L->>T: nt_suspend_thread (first thread)
    L->>T: nt_get_context_thread
    L->>T: nt_set_context_thread (Rip -> payload)
    L->>T: nt_resume_thread
    T->>T: payload runs in place
  1. Find the target. With pid set it is used directly; otherwise a ToolHelp process snapshot is walked for the first running image whose name matches target (compared by base name, so a full path works too).
  2. Open it with nt_open_process (PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_QUERY_INFORMATION | SYNCHRONIZE).
  1. Place the payload, exactly as the other injection techniques do: nt_alloc_vm a PAGE_READWRITE region (never RWX), nt_write_vm the payload into it, nt_protect_vm it to read+execute. All of this happens before any thread is touched, so the window in which the target is frozen stays as short as possible.
  1. Pick a thread. A ToolHelp thread snapshot is walked for the first thread owned by the target pid.
  2. Redirect it. nt_suspend_thread, nt_get_context_thread, Rip/Eip = payload (and Rsp/Esp = the private stack, when dedicated_stack is on), nt_set_context_thread, nt_resume_thread.

dedicated_stack: true (the default) reserves and commits a fresh 1 MB stack in the target and points the thread at it. The pointer handed to the payload is the slot a ret reads, it holds zero, and it sits where a call would have left it (rsp % 16 == 8, which the x64 ABI expects at a function’s first instruction) — smoke-align-probe.yaml in code_examples/thread_hijacking/ checks that from inside the target. The payload therefore cannot corrupt the frames the target thread was suspended in, and it has no caller to return to: a payload that returns jumps to address zero and takes the target down with 0xC0000005, which is what smoke-ret-stack.yaml measures. Real payloads (beacons, stagers) do not return, so the trade is the right way round by default.

dedicated_stack: false leaves Rsp/Esp alone. The payload then runs on the target thread’s live stack: a cooperative payload that returns exactly once pops the return address the suspended frame would have used, lands in its caller, and the target carries on — smoke-ret-no-stack.yaml shows the target alive afterwards with the same threads it had before. A payload that uses more stack than the suspended frame had left corrupts the target.

The payload runs in another process, so the loader cannot read the payload’s own exit code. wait_ms bounds how long the loader waits for the target to exit:

SituationReturned code
wait_ms: 0259 (STILL_ACTIVE) — the target was not observed
The target exited inside the windowThe target’s exit status
The window expired259 (STILL_ACTIVE)
A step failedErr, which the loader reports and applies on_fail to (the builtin default prints the message and exits 1)

A crashing target does not signal immediately: Windows Error Reporting holds the process while it collects the report (WerFault.exe started ~1.2 s after the fault; the process object signalled at ~3.3 s in the runs below). A wait_ms shorter than that reports 259 even though the payload certainly ran — treat STILL_ACTIVE as “not observed”, never as “did not run”.

  • Thread-creation telemetry. NtCreateThreadEx/CreateRemoteThread are instrumented by every EDR and by kernel callbacks (PsSetCreateThreadNotifyRoutine); this technique never calls them. The only kernel-side trace is a suspend/resume pair on an existing thread.
  • Static thread-creation imports. A loader that only creates threads still imports CreateRemoteThread/NtCreateThreadEx; neither appears in the artifact’s IAT (picaro audit, see Measurements).
  • RWX scanning. The payload region is PAGE_READWRITE until it is complete, then PAGE_EXECUTE_READ.
  • Win32 wrapper telemetry, with syscalls.mode: indirect. The technique’s syscall list (NtOpenProcess, NtSuspendThread, NtGetContextThread, NtSetContextThread, …) is what makes the indirect mode meaningful here: the suspend/context/resume calls then leave the IAT altogether. The mode is a loader setting, not a technique parameter — see docs/features/ref/syscalls.md.
  • SetThreadContext telemetry. NtSetContextThread on a thread the caller does not own is a strong, well-known signal; EDRs that hook it (or the user-mode SetThreadContext) see the exact redirection.
  • Thread suspension outliers. A suspend on a thread of a different process, followed by an immediate resume, is unusual outside debuggers.
  • Unbacked executable memory. The payload region is private, committed, executable and backed by no module on disk — the same signature every in-memory loader has. It is what unhook_ntdll/sleep_obfuscation exist to blur, not to remove.
  • Private memory at the target. With dedicated_stack: true the target also gains a 1 MB private, read-write, fully committed region: double the anomalous footprint in a process that had none.
  • Cross-process handles. nt_open_process with PROCESS_VM_WRITE on another process is visible to handle-based telemetry.
  • Stack inspection at the target. With dedicated_stack: true the first frames of the hijacked thread point into private memory instead of into the thread’s original call chain.
  • The crash artefact. A payload that faults leaves an Application Error (id 1000) record naming the target with ModuleName = unknown and a fault offset inside private, unbacked memory — an unusual combination for an editor or a ping. The smoke tests manufacture it on purpose.

Measured on the packing host (x86_64-pc-windows-msvc, release stub, Windows 11 26100) against ping.exe -t 127.0.0.1, with the plans in code_examples/thread_hijacking/ and its run_case.ps1 harness. “Observed gone” is the moment the target’s process object signalled, i.e. when the NtWaitForSingleObject inside the loader returned; the harness baseline (cmd /c exit 0) is ~120–170 ms on this host.

PlanPayloaddedicated_stackwait_msLoader’s exit statusTarget
smoke.yamlud2true40000xC000001Ddied, 0xC000001D
smoke-no-stack.yamlud2false40000xC000001Ddied, 0xC000001D
smoke-ret-stack.yamlmov eax,42; rettrue40000xC0000005died, fault at address 0x0
smoke-ret-no-stack.yamlmov eax,42; retfalse3000259 (STILL_ACTIVE)alive, 5 threads before and 5 after
smoke-fire-and-forget.yamlmov eax,42; retfalse0259 (STILL_ACTIVE)alive, 6 threads before and 6 after
smoke-align-probe.yamlrsp alignment probetrue50000xC000001D (fault at payload +0x0b)died
smoke-indirect.yamlud2true50000xC000001Ddied
smoke-target-path.yamlud2false50000xC000001Ddied

The relayed status is the target’s, bit for bit, in every crashing row.

  • Threads created in the target: 0. Measured with the two plans whose payload leaves the target alive: smoke-fire-and-forget.yaml (wait_ms: 0) ran the same six thread ids before and after the hijack, and smoke-ret-no-stack.yaml counted 5 before and 5 after. A control run with no hijack over the same window shows the same stability, so a dropped thread would have been visible (ping’s own helper threads do drop later — see code_examples/thread_hijacking/NOTES.md, which is why the window matters).
  • Wall clock. The non-crashing rows resolve at wait_ms + ~130 ms; the crashing ones at 2.6–3.6 s regardless of wait_ms (Error Reporting, above); smoke-fire-and-forget.yaml at 171–232 ms total.
  • Faulting address. The Application Error record names the payload: ud2 faults at payload base + 0x0, mov eax,42; ret on a private stack at absolute 0x0 (the null return slot), and the alignment probe at base + 0x0b, which is its ud2. ModuleName is unknown in all three: the thread died in private memory, not in a module.
  • Footprint (picaro audit). syscalls.mode: none: 105 imported functions, with VirtualAllocEx and WriteProcessMemory flagged as suspicious. mode: indirect: 100 functions, “IAT looks minimal” — VirtualAllocEx, WriteProcessMemory, VirtualProtectEx, SuspendThread, ResumeThread and WaitForSingleObject are all gone. OpenThread, GetExitCodeProcess and the ToolHelp calls remain in both modes: the documented exceptions. Both artifacts report sensitive 0, technique names 0, paths 0.
  • Every message and the target name go through StringsConfig; the fragment carries no runtime literal (the tech_dbg! format strings are the usual debug-only exception, compiled out unless build.stub.debug: true). audit reports no sensitive, technique-name or path strings for the artifacts above.
  • The private stack’s entry pointer is the null return slot itself, and it is 8 bytes below the 16-byte-aligned top — what a call leaves. Both halves were wrong until this was measured: the null was written 8 bytes below the pointer the thread was given (so the guard only held because freshly committed pages are zero-filled), and the payload was entered 8 bytes off the ABI’s alignment. smoke-align-probe.yaml is the regression test: it answers 0xC000001D (the ud2 arm) with the fix and answered 0x80000003 (the int3 arm) before it; the fault offsets name the exact instruction in both cases.
  • The CONTEXT is 16-byte aligned through a #[repr(align(16))] wrapper: x64 Get/SetThreadContext fault on a misaligned context (the windows binding only guarantees 8).
  • A failure after the suspend resumes the thread before returning, so a failed hijack does not leave the target frozen for good.
  • Thread selection is “the first thread the snapshot returns”, deliberately: the gentler choice — a thread parked in a wait, whose stack already holds a return address into a wait loop — means parsing per-thread wait reasons out of SystemProcessInformation, a large structure whose layout drifts between Windows versions (the waiting-thread-hijacking reference in code_examples/thread_hijacking/refs/ does exactly that, and pays for it with a return-address validation step). See The stack question above for what the choice costs a returning payload with dedicated_stack: false.
  • Position-independent payloads only: the payload has no imports resolved for it and no image mapped, exactly like the other format: shellcode techniques.
  • MITRE ATT&CK T1055.003 — Process Injection: Thread Execution Hijacking.
  • Microsoft, SetThreadContext / GetThreadContext / SuspendThread / ResumeThread (Win32 API reference).
  • MITRE ATT&CK T1106 — Native API (the nt_open_process/nt_suspend_thread path the technique takes instead of the Win32 wrappers).
  • joaoviictorti/RustRedOps, Thread-Hijacking/{Remote,Local} — the same Rip rewrite in Rust with windows-rs, and the source of the 16-byte CONTEXT wrapper. It leaves the stack alone, makes the payload region RWX and waits on the thread without a bound.
  • hasherezade/waiting_thread_hijacking (Check Point Research, Waiting Thread Hijacking, 2025) — a hijack that never calls SetThreadContext: it overwrites the return address of a waiting thread and stages a jump back inside the payload region, so the target keeps running. The closest thing to a “restore the context” variant of this technique.
  • Local copies and a per-reference comparison: code_examples/thread_hijacking/refs/NOTES.md.