Thread execution hijacking
← injection index · docs/index
New to maldev injection? Read the injection/README.md vocabulary callout first.
TL;DR
Spawn a CREATE_SUSPENDED child, allocate + write + protect shellcode
in its address space, then mutate its main thread's saved register
state so RIP points at the shellcode before resuming. No new thread,
no APC — the existing thread is redirected at the CPU-context
level. Stealth tier: medium; the trade-off is a NtSetContextThread
on a non-debugger flow, which EDR specifically watches.
| Trait | Value |
|---|---|
| Target class | Child (suspended) |
| Creates a new thread? | No — redirects the existing main thread via NtSetContextThread |
Uses WriteProcessMemory? | Yes (NtWriteVirtualMemory) |
| Stealth tier | Medium — no Create*Thread, no APC; NtSetContextThread outside debug context is the EDR signal |
| Bypasses CreateThread callbacks? | Yes — same reasoning as Early Bird APC |
When to pick a different method:
- Want APC delivery instead of register mutation? → Early Bird APC — sister technique, same setup, different trigger.
- Want to inject into an existing PID? → Thread Hijack works on any thread you can
OpenProcess(PROCESS_VM_*)— but the existing thread interrupt is louder than APC. - Want the spawn itself to look like another process? → Pair with Process Arg Spoofing.
Primer
CreateRemoteThread creates a new thread; EarlyBird queues an APC.
Thread Execution Hijacking does neither — it abuses the fact that
Windows lets a debugger (or anything with THREAD_GET_CONTEXT | THREAD_SET_CONTEXT) pause a thread, read its full register file, edit
the instruction pointer, write the registers back, and resume. The
implant takes the same path: pause → read CONTEXT → write Rip to the
shellcode address → write back → ResumeThread.
The result is that the sacrificial child's main thread starts running
at the shellcode address instead of the original entry point. No
Create*Thread* event ever fires. The trade-off is the
NtSetContextThread system call, which is unusual outside debugger
workflows and is itself instrumented by every modern EDR.
The legacy alias MethodProcessHollowing points at this technique;
genuine PE hollowing (overwriting the child's image with a different
PE) is not implemented in this package.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Kern as "Kernel"
participant Child as "Child (suspended)"
Impl->>Kern: CreateProcess(CREATE_SUSPENDED)
Kern->>Child: process + main thread, frozen
Kern-->>Impl: hProcess, hThread
Impl->>Kern: NtAllocateVirtualMemory(RW)
Impl->>Kern: NtWriteVirtualMemory(shellcode)
Impl->>Kern: NtProtectVirtualMemory(RX)
Impl->>Kern: NtGetContextThread(hThread)
Kern-->>Impl: CONTEXT (Rip = original entry)
Impl->>Impl: ctx.Rip = remoteAddr
Impl->>Kern: NtSetContextThread(hThread, ctx)
Kern->>Child: thread Rip rewritten
Impl->>Kern: ResumeThread(hThread)
Child->>Child: thread runs at shellcode address
Steps:
- Spawn the sacrificial child suspended.
- Allocate / write / protect the shellcode in the child.
- Get the main thread's CONTEXT (
NtGetContextThread) — note that the kernel returns the saved register file because the thread is suspended. - Mutate
ctx.Rip(orEipon x86) to the shellcode address. - Set the modified CONTEXT back (
NtSetContextThread). - Resume the thread.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/inject is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
cfg := &inject.WindowsConfig{
Config: inject.Config{
Method: inject.MethodThreadHijack,
ProcessPath: `C:\Windows\System32\notepad.exe`,
},
}
inj, err := inject.NewWindowsInjector(cfg)
if err != nil { return err }
return inj.Inject(shellcode)
Composed (indirect syscalls, hardened sacrificial parent)
inj, err := inject.Build().
Method(inject.MethodThreadHijack).
ProcessPath(`C:\Windows\System32\RuntimeBroker.exe`).
IndirectSyscalls().
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Advanced (preset evasion + thread hijack)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
_ = evasion.ApplyAll(preset.Stealth(), nil)
inj, err := inject.Build().
Method(inject.MethodThreadHijack).
ProcessPath(`C:\Windows\System32\WerFault.exe`).
IndirectSyscalls().
Use(inject.WithXORKey(0xA5)).
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Complex (Pipeline equivalent)
Pipeline does not have a packaged ThreadHijackExecutor (it would
need a saved CONTEXT and a thread handle); the named-method path is
the supported one. For experimental setups, replicate the logic in
inject/injector_remote_windows.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
CREATE_SUSPENDED child of an unusual parent | Sysmon Event 1 (CreationFlags) |
NtSetContextThread on a thread of a freshly-spawned process | EDR-Ti providers, userland hooks. Outside debugger workflows this is a high-fidelity signal |
Cross-process NtWriteVirtualMemory | EDR userland + ETW |
Modified Rip in CONTEXT pointing into a non-image-backed region | EDR memory scanners on the child |
| Process tree mismatch | notepad.exe child of a non-explorer.exe parent |
D3FEND counters:
- D3-PSA
—
CREATE_SUSPENDED+ register mutation is the textbook hollowing-family chain. - D3-PCSV
— verifies thread
Ripagainst image segments.
Hardening for the operator: route NT calls through indirect syscalls; pair with PPID spoofing; choose a sacrificial process whose own initialisation does not race the shellcode (avoid heavyweight binaries that spawn workers immediately).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.003 | Process Injection: Thread Execution Hijacking | suspended-child variant | D3-PSA |
Limitations
- x64 only in the current implementation (
CONTEXT.Rip). x86 would needEipand a differentCONTEXTflags mask. - Original entry point never runs. The sacrificial process never
reaches its real
main. If the shellcode does not hand control back, the child appears to have started and immediately died — a small but non-zero behavioural anomaly. NtSetContextThreadis high-signal. EDRs that miss theCREATE_SUSPENDEDflag still catch the context modification. Direct/indirect syscalls help against userland hooks but not against ETW-Ti.- Race-prone for fast spawns. Some sacrificial binaries
(
csrss.exeadjacents, lightly-instrumented processes) finish initial setup beforeNtGetContextThreadreturns. Stick to well-behaved utilities.
See also
- Early Bird APC — same suspended-child shape, uses an APC instead of register mutation.
- CreateRemoteThread — the loud baseline.
- Process Argument Spoofing — pair to mask the child's command line as a benign tool.
process/spoofparent— pair to set a realistic parent for the sacrificial child.- SafeBreach Labs, Process Hollowing & Doppelgänging, 2017 — taxonomy of register-mutation injection.