IAT Hooking
Replacing pointers in the Import Address Table to intercept API calls without modifying DLL code — how EDRs use it, how attackers bypass it, and cross-view detection
An EDR component injected into a process finds CreateRemoteThread in the process's IAT and overwrites that pointer with the address of its own hook function. Now every call to CreateRemoteThread by that process flows through the EDR's code — the callers (compiled code using call [IAT+offset]) never notice the redirect. The attacker's code calls CreateRemoteThread by resolving it manually via GetProcAddress and calling that address directly — bypassing the IAT pointer entirely.
How IAT Hooking Works
The IAT (Import Address Table) is a table of function pointers, filled in by the loader at process startup. Every call to an imported function in compiled code goes through a slot in this table: call QWORD PTR [rip + iat_offset] on x64. IAT hooking overwrites one of these slots with the address of a hook function.
Normal IAT state (after loader):
IAT slot for CreateRemoteThread: 0x00007FF81234ABCD ← kernel32!CreateRemoteThread
Caller code:
call QWORD PTR [IAT + CreateRemoteThread_offset]
↓
Executes: kernel32!CreateRemoteThread
After IAT hook:
IAT slot for CreateRemoteThread: 0x000001234567BEEF ← EDR hook function
Caller code:
call QWORD PTR [IAT + CreateRemoteThread_offset]
↓
Executes: EDR_hook_CreateRemoteThread
↓ (after analysis)
Calls real kernel32!CreateRemoteThread via resolved pointer
Installing an IAT Hook
// Walk a module's IAT and replace a specific import with a hook function
PVOID InstallIATHook(HMODULE hModule, const char *dllName,
const char *funcName, PVOID pHookFn)
{
PBYTE pBase = (PBYTE)hModule;
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)pBase;
PIMAGE_NT_HEADERS pNT = (PIMAGE_NT_HEADERS)(pBase + pDos->e_lfanew);
// Get import directory
DWORD importRVA = pNT->OptionalHeader.DataDirectory[
IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;
if (!importRVA) return NULL;
PIMAGE_IMPORT_DESCRIPTOR pIDesc = (PIMAGE_IMPORT_DESCRIPTOR)(pBase + importRVA);
for (; pIDesc->Name; pIDesc++) {
char *pDllName = (char*)(pBase + pIDesc->Name);
if (_stricmp(pDllName, dllName) != 0) continue;
// Walk INT (OriginalFirstThunk) and IAT (FirstThunk) in parallel
PIMAGE_THUNK_DATA pINT = (PIMAGE_THUNK_DATA)(pBase + pIDesc->OriginalFirstThunk);
PIMAGE_THUNK_DATA pIAT = (PIMAGE_THUNK_DATA)(pBase + pIDesc->FirstThunk);
for (; pINT->u1.AddressOfData; pINT++, pIAT++) {
if (IMAGE_SNAP_BY_ORDINAL(pINT->u1.Ordinal)) continue;
PIMAGE_IMPORT_BY_NAME pName = (PIMAGE_IMPORT_BY_NAME)
(pBase + pINT->u1.AddressOfData);
if (strcmp((char*)pName->Name, funcName) != 0) continue;
// Found the slot — save original and replace
PVOID pOriginal = (PVOID)pIAT->u1.Function;
DWORD oldProt;
VirtualProtect(&pIAT->u1.Function, sizeof(ULONG_PTR),
PAGE_READWRITE, &oldProt);
pIAT->u1.Function = (ULONG_PTR)pHookFn;
VirtualProtect(&pIAT->u1.Function, sizeof(ULONG_PTR),
oldProt, &oldProt);
return pOriginal;
}
}
return NULL;
}
EDR Use of IAT Hooking
| Function hooked via IAT | What EDR monitors |
|---|---|
| CreateRemoteThread (kernel32) | Remote thread injection by the monitored process |
| WriteProcessMemory (kernel32) | Memory write to another process |
| VirtualAllocEx (kernel32) | Memory allocation in another process (injection preparation) |
| OpenProcess (kernel32) | What processes this process tries to open, and with what access |
| LoadLibraryA / W (kernel32) | DLL loads initiated by this process (for module allowlisting) |
IAT Hook Bypass Techniques
| Bypass | Mechanism |
|---|---|
| GetProcAddress + direct call | Resolve the function address directly from the DLL's export table; store in a local variable; call via local pointer. The IAT slot is not consulted. |
| Manual export table walk | Walk the DLL's export table (EAT) by hand (PEB traversal); get the real function VA. Same result as GetProcAddress but without calling GetProcAddress itself. |
| Self-IAT repair | Read the expected function VA from the DLL's export table; overwrite the hooked IAT slot with the correct address. Effectively unhooks specific IAT entries. |
| Direct syscall (for NT functions) | Bypass the entire Win32/ntdll layer; relevant only for ntdll functions that are NT stubs. |
Cross-View IAT Detection
"""
Detect IAT hooks by comparing live IAT entries to expected
addresses from the DLL's EAT.
For each IAT entry, the expected address = DLL_load_base + export_RVA.
A different address in the IAT = hook.
"""
import ctypes, ctypes.wintypes, pefile, os
def check_iat_integrity(pe_path: str):
"""
Check IAT entries of the PE at pe_path against expected EAT addresses.
The PE must be loaded in the current process.
"""
pe = pefile.PE(pe_path)
hMod = ctypes.windll.kernel32.GetModuleHandleW(os.path.basename(pe_path))
base = hMod
if not hasattr(pe, 'DIRECTORY_ENTRY_IMPORT'):
return
sysdir = ctypes.create_unicode_buffer(260)
ctypes.windll.kernel32.GetSystemDirectoryW(sysdir, 260)
for imp in pe.DIRECTORY_ENTRY_IMPORT:
dll_name = imp.dll.decode()
dll_disk = os.path.join(sysdir.value, dll_name)
hDll = ctypes.windll.kernel32.GetModuleHandleW(dll_name)
if not hDll or not os.path.exists(dll_disk):
continue
try:
dll_pe = pefile.PE(dll_disk, fast_load=True)
dll_pe.parse_data_directories(
directories=[pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_EXPORT']])
exp_map = {e.name.decode(): e.address
for e in dll_pe.DIRECTORY_ENTRY_EXPORT.symbols
if e.name}
except: continue
for entry in imp.imports:
if not entry.name: continue
func_name = entry.name.decode()
live_ptr = entry.address # current IAT slot value
if func_name not in exp_map: continue
expected = hDll + exp_map[func_name]
if live_ptr != expected:
print(f" [IAT HOOK] {dll_name}!{func_name}")
print(f" Expected: 0x{expected:016X}")
print(f" Live: 0x{live_ptr:016X}")
Q & A
Can an IAT hook affect all callers in the process, or only callers that go through that specific IAT table?
Each PE module (EXE or DLL) has its own IAT. An IAT hook on one module's table only intercepts calls originating from that module's compiled code. If process.exe calls CreateRemoteThread through its own IAT, and EDR hooks process.exe's IAT, the EDR sees those calls. But if process.exe loads another.dll which also calls CreateRemoteThread via its own IAT — that's a different IAT table in another.dll. The EDR must hook another.dll's IAT separately to intercept calls from that module. EDRs that use IAT hooks typically enumerate all loaded modules and hook each module's IAT individually when the module loads. PsSetLoadImageNotifyRoutine (kernel callback) notifies the EDR when any new DLL loads, triggering IAT hooking of the new module. The implication for attackers: IAT hooks are per-module and per-slot. An attacker's injected shellcode that resolves functions via GetProcAddress or PEB traversal has no IAT — there's nothing to hook. The injected code calls the function by resolved VA directly. This is why IAT hooks are useful for monitoring compiled code (legitimate host processes) but don't protect against code that bypasses the IAT.
Is there a way to restore a hooked IAT without calling VirtualProtect (which would itself be observable)?
Yes, though it's more complex. The direct approach requires VirtualProtect because the IAT pages are PAGE_READONLY after loader initialization. Alternatives: (1) WriteProcessMemory from the same process (HANDLE(-1)) bypasses the VirtualProtect requirement for some operations — but WriteProcessMemory itself triggers ETW-TI events, so it's equally observable. (2) Mapping a writable view: if the attacker creates a duplicate writable view of the same physical pages that the IAT occupies (using NtMapViewOfSection with write access to the section backing the PE), writes to the mapped view would modify the shared pages without calling VirtualProtect on the original mapping. This technique (a variant of mapping bypass) is complex and fragile. (3) Direct page manipulation: with kernel access, modify the page table entries to temporarily make the page writable. This is obviously not user-mode available. (4) In practice, almost all IAT restoration code uses VirtualProtect. The VirtualProtect signal — specifically VirtualProtect on address ranges belonging to known module images — is a reliable detection point regardless of whether it's ntdll unhooking or IAT restoration. The key detection: VirtualProtect calls touching image-mapped regions in process memory, especially if the caller is not a known EDR or debugging DLL.