How EDRs Hook: IAT Patching, Inline JMP, and ntdll Trampolines
Before you can bypass an EDR's userland hooks, you need to understand exactly what it did to your process. When an EDR's DLL loads into your process during startup, it patches specific functions in memory — replacing the first few bytes of APIs like NtAllocateVirtualMemory, NtCreateThreadEx, and NtWriteVirtualMemory with a jump to its own analysis code. Every time your implant calls one of these functions, it executes the EDR's code first. This chapter dissects all three common hooking mechanisms — IAT patching, inline 5-byte JMP hooks, and 14-byte absolute JMP hooks — shows you how to read them from a running process to see exactly what an EDR has modified, and explains the trampoline that lets the EDR call the original function after analysis.
The Three Hook Mechanisms
─────────────────────────────────────────────────────────────────────────
MECHANISM 1: IAT PATCHING (Import Address Table hook)
─────────────────────────────────────────────────────────────────────────
Normal IAT for ntdll.dll in your process:
your_implant.exe .idata section (IAT):
offset 0x0000: [0x7FFF12340000] ← address of NtAllocateVirtualMemory
offset 0x0008: [0x7FFF12341234] ← address of NtCreateThreadEx
...
After IAT patching by EDR:
your_implant.exe .idata section (IAT):
offset 0x0000: [0x7FFF88880000] ← address of EDR's hook function
offset 0x0008: [0x7FFF88881234] ← address of EDR's hook function
Effect: When your code calls NtAllocateVirtualMemory via IAT,
it jumps to the EDR's hook instead.
Bypass: Walk the IAT and re-patch addresses back to real functions
(GetProcAddress resolves the real ntdll addresses from ntdll's EAT).
─────────────────────────────────────────────────────────────────────────
MECHANISM 2: INLINE 5-BYTE JMP (relative near jump hook)
─────────────────────────────────────────────────────────────────────────
Normal NtAllocateVirtualMemory in ntdll:
0x7FFF12340000: 4C 8B D1 mov r10, rcx ← real prologue
0x7FFF12340003: B8 18 00 00 00 mov eax, 18h ← syscall number
0x7FFF12340008: 0F 05 syscall
0x7FFF1234000A: C3 ret
After 5-byte JMP hook by EDR:
0x7FFF12340000: E9 XX XX XX XX jmp [relative_offset_to_edr_hook]
0x7FFF12340005: 00 00 00 (overwritten / padding)
...
The original bytes (4C 8B D1 B8 18) are saved in a "trampoline"
Notes:
E9 = opcode for near JMP
32-bit signed relative offset: target - (hookaddr + 5)
Works if EDR hook is within ±2GB of hooked function (usually true)
─────────────────────────────────────────────────────────────────────────
MECHANISM 3: 14-BYTE ABSOLUTE JMP (for 64-bit cross-segment jumps)
─────────────────────────────────────────────────────────────────────────
When the EDR hook is more than 2GB away (uncommon but possible):
0x7FFF12340000: FF 25 00 00 00 00 jmp [rip+0] ← indirect jump
0x7FFF12340006: XX XX XX XX ← low 32 bits of target address
0x7FFF1234000A: XX XX XX XX ← high 32 bits of target address
Total: 14 bytes patched, target can be any 64-bit addressReading Hooks from a Live Process
/* read_hooks.c — Detect EDR hooks on ntdll functions
Reads the first 8 bytes of each ntdll export and determines
whether it's been patched with an IAT hook or inline JMP.
Build:
x86_64-w64-mingw32-gcc -O2 -o read_hooks.exe read_hooks.c
*/
#include <windows.h>
#include <stdio.h>
typedef enum {
HOOK_NONE = 0,
HOOK_IAT = 1,
HOOK_JMP5 = 2, /* E9 relative jump (5 bytes) */
HOOK_JMP14 = 3, /* FF 25 absolute jump (14 bytes) */
HOOK_UNKNOWN = 4
} HookType;
typedef struct {
const char *func_name;
PVOID func_addr;
BYTE original_bytes[8];
HookType hook_type;
PVOID hook_target; /* where the hook redirects to */
} HookInfo;
static HookType detect_hook(PVOID func_addr, PVOID *out_target) {
BYTE *bytes = (BYTE *)func_addr;
*out_target = NULL;
/*
* Normal x64 syscall stub prologue (unhooked ntdll Nt* function):
* 4C 8B D1 mov r10, rcx
* B8 ?? 00 00 00 mov eax, [syscall_number]
* If the first 3 bytes are 4C 8B D1, function is probably clean.
*/
if (bytes[0] == 0x4C && bytes[1] == 0x8B && bytes[2] == 0xD1) {
return HOOK_NONE;
}
/* E9 = relative near JMP (5-byte hook) */
if (bytes[0] == 0xE9) {
/* Decode the relative offset */
INT32 rel_offset = *(INT32 *)(bytes + 1);
/* Target = instruction_after_jmp + rel_offset = (addr + 5) + rel_offset */
*out_target = (PVOID)((PBYTE)func_addr + 5 + rel_offset);
return HOOK_JMP5;
}
/* FF 25 = indirect JMP via RIP-relative memory (14-byte hook) */
if (bytes[0] == 0xFF && bytes[1] == 0x25) {
INT32 rip_offset = *(INT32 *)(bytes + 2);
PVOID *target_ptr = (PVOID *)((PBYTE)func_addr + 6 + rip_offset);
*out_target = *target_ptr;
return HOOK_JMP14;
}
return HOOK_UNKNOWN;
}
static void check_ntdll_hooks(void) {
/* Functions most commonly hooked by EDRs */
static const char *suspects[] = {
"NtAllocateVirtualMemory",
"NtWriteVirtualMemory",
"NtCreateThreadEx",
"NtQueueApcThread",
"NtQueueApcThreadEx",
"NtMapViewOfSection",
"NtUnmapViewOfSection",
"NtCreateSection",
"NtOpenProcess",
"NtProtectVirtualMemory",
"NtSuspendThread",
"NtResumeThread",
"NtSetContextThread",
"NtGetContextThread",
"LdrLoadDll",
"NtCreateFile",
NULL
};
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
if (!hNtdll) { printf("[-] ntdll not found\n"); return; }
PBYTE ntdll_base = (PBYTE)hNtdll;
printf("ntdll.dll base: %p\n", hNtdll);
printf("%-40s %-8s %s\n", "Function", "Status", "Hook target");
printf("%-40s %-8s %s\n", "--------", "------", "-----------");
int hooked_count = 0;
for (int i = 0; suspects[i]; i++) {
PVOID func = GetProcAddress(hNtdll, suspects[i]);
if (!func) continue;
PVOID target = NULL;
HookType ht = detect_hook(func, &target);
const char *status = "CLEAN";
char detail[64] = "-";
if (ht == HOOK_JMP5 || ht == HOOK_JMP14 || ht == HOOK_UNKNOWN) {
status = (ht == HOOK_JMP5) ? "JMP5" :
(ht == HOOK_JMP14) ? "JMP14" : "UNKNOWN";
if (target) {
/* Determine which module the hook target is in */
HMODULE hMod = NULL;
GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS,
(LPCSTR)target, &hMod);
char mod_name[MAX_PATH] = "???";
GetModuleBaseNameA(GetCurrentProcess(), hMod, mod_name, MAX_PATH);
snprintf(detail, sizeof(detail), "%p (%s)", target, mod_name);
}
hooked_count++;
}
printf("%-40s %-8s %s\n", suspects[i], status, detail);
}
printf("\nTotal hooked: %d / %d checked\n", hooked_count,
(int)(sizeof(suspects)/sizeof(suspects[0]) - 1));
}
int main(void) {
printf("=== ntdll Hook Scanner ===\n\n");
check_ntdll_hooks();
return 0;
}
The Trampoline — How EDRs Call the Original Function
When an EDR patches NtAllocateVirtualMemory with an inline JMP,
it needs a way to call the ORIGINAL NtAllocateVirtualMemory after analysis.
The EDR's hook can't just re-call the patched function (infinite loop).
The trampoline mechanism:
─────────────────────────────────────────────────────────────────────────
EDR patches NtAllocateVirtualMemory at ntdll (7FFF12340000):
E9 XX XX XX XX jmp → EDR_Hook_NtAllocateVirtualMemory
EDR allocates a trampoline buffer in its own DLL (or in an RX region):
[Trampoline_NtAllocateVirtualMemory]:
4C 8B D1 mov r10, rcx ← saved original bytes (1-3)
B8 18 00 00 00 mov eax, 18h ← saved original bytes (4-8)
FF 25 00 00 00 00 jmp [rip+0] ← absolute jump to rest of function
0x7FFF12340005 [target: instruction 6 of original, after the 5 hooked bytes]
So when YOUR code calls NtAllocateVirtualMemory:
1. Your code → ntdll JMP stub → EDR_Hook_NtAllocateVirtualMemory
2. EDR hook analyzes arguments (checks for injection patterns)
3. If EDR allows the call: EDR calls Trampoline
4. Trampoline executes saved bytes → jumps to rest of original function
5. Original NtAllocateVirtualMemory executes the syscall
6. Returns to EDR hook
7. EDR hook returns to your code
If EDR BLOCKS the call:
EDR hook returns STATUS_ACCESS_DENIED (or any error code) directly
without ever calling the trampoline.
Your code receives an error, not a crash.
Visual:
Your code
↓ call
[ntdll JMP hook → EDR]
↓
EDR_Hook() {
analyze_args();
if (suspicious) return STATUS_ACCESS_DENIED;
return Trampoline_NtAllocateVirtualMemory(args); // call original
}
↓
[Trampoline: saved bytes + jmp to rest of ntdll]
↓
[syscall instruction → kernel]
↑
[returns through the entire chain]
What Major EDRs Actually Hook
Function │ Why EDRs hook it ────────────────────────────┼──────────────────────────────────────────────────────── NtAllocateVirtualMemory │ Detect RWX allocations, large allocations in remote proc NtWriteVirtualMemory │ Cross-process memory writes (injection) NtProtectVirtualMemory │ RW→RX transitions (shellcode prep) NtCreateThreadEx │ New thread creation (especially in remote process) NtQueueApcThread(Ex) │ APC-based injection NtMapViewOfSection │ Mapping injection (Ch26) NtUnmapViewOfSection │ Process hollowing (Ch27) NtOpenProcess │ Handle acquisition to another process NtCreateSection │ Section object creation with SEC_IMAGE LdrLoadDll │ DLL loading — check against allowlist NtCreateFile │ File creation in suspicious paths NtSetContextThread │ Thread hijacking (Ch32) NtSuspendThread │ Thread suspension (for later modification) NtResumeThread │ Thread resumption after modification CreateRemoteThread │ (kernel32 wrapper — hooked separately) WriteProcessMemory │ (kernel32 wrapper — hooked separately) AmsiScanBuffer │ AMSI — detect PowerShell/VBScript payload EtwEventWrite │ ETW — detect ETW bypass attempts MiniDumpWriteDump │ Detect credential dumping attempts Note: Different EDRs hook different functions. CrowdStrike, SentinelOne, Microsoft Defender for Endpoint, Carbon Black, and Cylance all have different hook sets. Some hook at kernel32, some at ntdll, some at both. Run read_hooks.exe in a protected environment to see exactly what's hooked.
Questions & Answers
How does an EDR install hooks at process startup without being the first code to run?
EDRs register for process creation notifications at the kernel level using PsSetCreateProcessNotifyRoutine or similar callbacks. When a new process is created, the kernel fires the EDR's kernel-mode callback. The EDR's kernel module then arranges for the EDR's user-mode DLL to be injected into the new process — commonly via AppInit_DLLs (legacy), a kernel-mode APC that queues a LoadLibrary call on the new process's main thread before it starts, or via the Windows loader's DLL notification mechanism. The EDR DLL loads as part of the process initialization sequence, before any user code runs (its DllMain fires during ntdll's LdrpInitializeProcess). At DllMain, the EDR patches the target ntdll functions with inline JMPs. All of this happens in milliseconds, before your implant's WinMain function is called. This is why Early Bird APC (Ch31) is effective — the APC fires during this same initialization window, before the EDR's DLL has been injected.
Can you detect which specific EDR is present in a process by examining its hooks?
Yes — EDRs have distinctive hooking patterns. The hook target address falls in the EDR's DLL, which has a recognizable module name. Your read_hooks.exe code already does this: when it calls GetModuleBaseNameA on the hook target's address, it returns the EDR's DLL name (e.g., CrowdStrike.dll, SentinelOneProvider.dll, mpengine.dll for Defender). Additional fingerprinting: some EDRs add distinctive byte patterns in their hook stubs, or use specific hook lengths that differ from the default 5 bytes. The set of functions hooked is also somewhat distinctive — different EDRs have different hook inventories based on their detection philosophy. This information is useful in a red team context to adjust your bypass technique to match what you know about the specific EDR you're facing.
What does "deep hooking via ntdll trampoline" mean compared to shallow hooks?
"Shallow" hooks patch the Win32 API layer (kernel32.dll) — functions like CreateRemoteThread, WriteProcessMemory, VirtualAllocEx. These are the high-level documented APIs. "Deep" hooks patch the ntdll native API layer (NtCreateThreadEx, NtWriteVirtualMemory, NtAllocateVirtualMemory) — the thin wrappers around syscalls. When someone "unhooks" at the Win32 level (restores kernel32 functions) but doesn't address ntdll hooks, the ntdll hooks still catch them. A "trampoline" is the saved-bytes-plus-jump construct that lets the EDR call the real function after analysis. "Deep hooking via ntdll trampoline" means: hooks are at the ntdll level (catching all calls regardless of whether they go through kernel32), and the EDR uses a trampoline to call the real syscall stub after its analysis is done — ensuring the hooked function still works normally when the EDR allows it.