Inline Hooking
Patching function prologues with JMP redirects — the 5-byte relative JMP, the trampoline pattern, x64 absolute JMP, the hotpatch variant, and byte-comparison detection
An EDR hooks NtCreateFile in ntdll. Every call to NtCreateFile now passes through the EDR's analysis code. A malware sample uses the ntdll integrity check from Chapter 27, detects the modified bytes at the ntdll stub prologue, and uses Halo's Gate to find the correct SSN, then issues a direct syscall. The EDR's hook is effectively bypassed. Understanding inline hooking is prerequisite to understanding both the EDR and the bypass — they're two sides of the same patch.
5-Byte Relative JMP Hook
The simplest inline hook patches the first 5 bytes of a function with a near JMP: opcode 0xE9 followed by a 4-byte signed offset from the next instruction to the hook function. On x86 (32-bit), this reaches anywhere in a 2 GB range. On x64, the same 5-byte JMP is limited to ±2 GB relative to the target — so the hook function must be within that range of the hooked function.
// 5-byte relative JMP hook installation
typedef struct {
PVOID pOriginal; // address of hooked function
PVOID pHookFn; // address of our hook
BYTE stolen[16]; // saved original bytes
PVOID pTrampoline; // trampoline for calling original
} HookEntry;
void InstallHook5(HookEntry *h)
{
// Save original bytes (copy enough for the JMP + any displaced instructions)
memcpy(h->stolen, h->pOriginal, 5);
// Calculate relative offset: JMP target = hook - (original + 5)
INT32 rel = (INT32)((BYTE*)h->pHookFn - ((BYTE*)h->pOriginal + 5));
// Build the 5-byte JMP patch
BYTE patch[5] = { 0xE9, 0, 0, 0, 0 };
*(INT32*)(patch + 1) = rel;
// VirtualProtect, write, restore protection
DWORD oldProt;
VirtualProtect(h->pOriginal, 5, PAGE_EXECUTE_READWRITE, &oldProt);
memcpy(h->pOriginal, patch, 5);
VirtualProtect(h->pOriginal, 5, oldProt, &oldProt);
}
The Trampoline Pattern
When the hook fires and the hook function needs to call the original function (to avoid breaking the caller), it can't simply call pOriginal — that would loop back through the hook. Instead, a trampoline is built:
Original function (before hook):
+0: 4C 8B D1 mov r10, rcx ─┐ stolen bytes
+3: B8 18 00 mov eax, 0x18 ─┘ (first 5 bytes)
+5: 00 00 (rest of stub)
+7: 0F 05 syscall
+9: C3 ret
Trampoline (allocated in RWX memory):
+0: 4C 8B D1 mov r10, rcx ─┐ copied stolen bytes
+3: B8 18 00 mov eax, 0x18 ─┘
+5: E9 XX XX JMP (original+5) ── jump back to the rest of original
Patched original function:
+0: E9 XX XX XX XX JMP hook_fn ── jumps to our hook function
Hook function flow:
hook_fn(args) {
// analyze / log / block
result = trampoline(args); // calls real function via trampoline
return result; // return original result to caller
}
x64 Absolute JMP (14-byte)
When the hook function cannot be placed within ±2 GB of the target (common in 64-bit DLLs loaded at arbitrary ASLR addresses), a 14-byte absolute JMP is used:
; x64 14-byte absolute JMP to anywhere in 64-bit address space:
; FF 25 00 00 00 00 = JMP QWORD PTR [RIP+0]
; 8 bytes of target address
void BuildAbsJmp64(BYTE *buf, UINT64 target)
{
buf[0] = 0xFF; buf[1] = 0x25; // JMP [RIP+0]
buf[2] = 0x00; buf[3] = 0x00; buf[4] = 0x00; buf[5] = 0x00; // RIP offset = 0
*(UINT64*)(buf + 6) = target; // absolute target address
}
Hotpatch Variant
Microsoft builds many system DLLs with a 2-byte hotpatch area: mov edi, edi (0x8B 0xFF, a 2-byte no-op) at the function prologue, preceded by 5 bytes of padding (0x90 NOPs or INT 3s). A hotpatch hook writes a 2-byte short JMP at the prologue and a 5-byte JMP at the padding, without touching bytes after the prologue. The upside: no need to disassemble and identify instruction boundaries; the 2-byte slot is pre-allocated. The downside: only works on hotpatch-enabled functions, and modern x64 DLLs rarely include hotpatch padding.
Hook Detection
"""
Detect inline hooks in ntdll by comparing live function bytes
against the on-disk image.
"""
import ctypes, ctypes.wintypes, pefile, os
def detect_inline_hooks(target_dll: str = "ntdll.dll"):
sysdir = ctypes.create_unicode_buffer(260)
ctypes.windll.kernel32.GetSystemDirectoryW(sysdir, 260)
disk_path = os.path.join(sysdir.value, target_dll)
# Parse on-disk PE to get .text RVA and data
pe = pefile.PE(disk_path, fast_load=True)
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_EXPORT']])
text_section = next((s for s in pe.sections
if s.Name.strip(b'\x00') == b'.text'), None)
if not text_section: return
disk_text = text_section.get_data()
text_rva = text_section.VirtualAddress
dll_base = ctypes.windll.kernel32.GetModuleHandleW(target_dll)
text_va = dll_base + text_rva
# Read in-memory .text
size = len(disk_text)
mem_buf = (ctypes.c_ubyte * size)()
read = ctypes.c_size_t()
ctypes.windll.kernel32.ReadProcessMemory(
ctypes.wintypes.HANDLE(-1),
ctypes.c_void_p(text_va), mem_buf, size, ctypes.byref(read))
mem_text = bytes(mem_buf[:read.value])
# Check export function prologues (first 5 bytes)
for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols:
if not exp.address: continue
offset = exp.address - text_rva
if offset < 0 or offset + 5 > len(disk_text): continue
disk_bytes = disk_text[offset:offset+5]
live_bytes = mem_text[offset:offset+5]
if disk_bytes != live_bytes:
fname = exp.name.decode() if exp.name else f"ord{exp.ordinal}"
print(f" [HOOK] {fname}")
print(f" disk: {disk_bytes.hex()}")
print(f" live: {live_bytes.hex()}")
# Decode JMP target if it's a 5-byte rel JMP
if live_bytes[0] == 0xE9:
import struct
rel = struct.unpack_from('<i', live_bytes, 1)[0]
target = dll_base + exp.address + 5 + rel
print(f" JMP target: 0x{target:016X}")
Q & A
What is a prologue hook versus an epilogue hook, and are there other inline hook positions?
A prologue hook patches the first few bytes of a function — the most common placement because: (1) The function hasn't done anything yet, so all arguments are available in registers/stack. (2) The hook can capture all calls uniformly. (3) It's the easiest position to patch without needing deep disassembly. An epilogue hook patches the last few bytes of a function (typically around the ret instruction). It captures return values and can modify them. The challenge: a function may have multiple ret paths, requiring patching each one separately. Mid-function hooks (trampoline mid-execution) are less common but used when a specific code path needs monitoring, not the entire function. Some hooking frameworks (Detours, MinHook) support patching any instruction boundary within a function, not just the prologue. Detours-style hooking: (1) Find the target function. (2) Disassemble enough instructions at the prologue to safely displace them (at least 5 bytes, but complete instructions — can't split an instruction). (3) Create a trampoline with those instructions + a JMP back to the instruction after. (4) Overwrite the prologue with a JMP to the hook. The disassembly requirement is the hard part on x86/x64 because instructions are variable-length. MinHook uses a disassembler to find safe instruction boundaries; ad-hoc hookers sometimes steal a fixed 5-byte block and hope for no partial instruction, which breaks if the prologue happens to start with a 6+ byte instruction.
How does the Detours library handle multithreaded hooking to prevent races between hook installation and in-progress function calls?
This is one of the core technical challenges in hook libraries. Microsoft Detours solves it via a transaction model: (1) DetourTransactionBegin — start the hook transaction. (2) DetourUpdateThread for each thread in the process that should be paused during the patch. Detours suspends each specified thread via SuspendThread. (3) DetourAttach / DetourDetach — specify what to hook. At this point nothing is patched yet. (4) DetourTransactionCommit — atomically: write all patches, resume all suspended threads. The suspension ensures no thread is executing in the prologue region at the moment the patch is written. If a thread is suspended inside the function after the prologue, the trampoline must be correctly set up before threads resume — which is why the transaction model builds the trampoline first, then patches the prologue in the commit step. There is a TOCTOU window: if a thread is between the prologue and the trampoline JMP during the patch write, it can execute partially patched code. Detours minimizes this window by completing the patch atomically (the 5-byte write is architecturally atomic on x86 if aligned — a concern worth noting). In practice, Detours is widely used in production software (Microsoft Application Virtualization, Games for Windows Live, and many security products) and is considered production-quality for this use. The alternative approach some EDRs use: they install hooks at process creation time (before any user threads run), eliminating the race entirely.