IAT Resolution
How the loader patches the Import Address Table, bound imports, delay-load imports, forwarded exports, and the ways malware exploits the resolution process
A sample EDR alert fires for a process that calls VirtualAlloc followed immediately by WriteProcessMemory — classic injection preparation. But the EDR's import hook on VirtualAlloc never fired. The sample used a manual IAT walk, resolved VirtualAlloc by walking the export table, and called the function directly by address — bypassing the patched IAT entry entirely. Understanding IAT resolution is understanding exactly what the EDR hooked, and how malware unhooks it.
IAT Resolution: What the Loader Does
Chapter 7 covered the IAT data structures (IMAGE_IMPORT_DESCRIPTOR, INT, IAT). Here we focus on the loader's resolution algorithm and its security implications.
The Resolution Algorithm
For each IMAGE_IMPORT_DESCRIPTOR in the Import Directory:
1. Load the named DLL (DLL name at OriginalFirstThunk's Name field)
2. For each entry in the INT (OriginalFirstThunk array):
a. If high bit set → import by ordinal (value & 0x7FFF = ordinal)
b. Else → import by name: pointer to IMAGE_IMPORT_BY_NAME
- Read hint (2-byte guess at export ordinal table index)
- If hint valid → try EAT[hint] directly
- If name matches → use that address (fast path)
- If name doesn't match → binary search ENT for name
c. Write the resolved VA into the corresponding IAT slot
3. IAT is now a table of live function pointers, ready to use
The caller's compiled code has a indirect CALL through the IAT slot — call [IAT+offset]. After resolution, that slot holds the actual function address. EDR hooks typically overwrite this slot (IAT hook) or the function prologue itself (inline hook) with a trampoline. Malware that resolves functions manually bypasses both.
Bound Imports
The loader resolves imports at every process launch, which adds startup latency for DLL-heavy executables. Bound imports are a pre-resolution optimization: the linker or post-processing tool computes the expected VA of each import (based on the DLL's preferred base) and writes these values directly into the IAT at build time.
At load time, the loader checks whether the bound addresses are still valid: if the DLL loaded at its preferred base and its timestamp matches the one recorded in the IMAGE_BOUND_IMPORT_DESCRIPTOR, the IAT is already correct and the loader skips resolution. When ASLR is active (which rebases DLLs away from their preferred base), bound imports are always invalid and the loader ignores them, resolving normally.
A PE with bound imports on its IAT was likely built on a system where those specific DLL versions and preferred base addresses were valid. Comparing the bound VA values against known DLL bases can reveal the build environment. More importantly: packed or reflectively loaded PE files often have a zeroed or absent import directory — they can't have valid bound imports. Presence of valid bound imports is a weak indicator of legitimate build process.
Delay-Load Imports
Some DLLs are only used in rare code paths. Loading them at startup wastes memory and time. Delay-load imports (stored in the Delay Import Directory, data directory index 13) defer loading until first use:
- The linker generates a stub for each delay-loaded function
- The stub's first call invokes
__delayLoadHelper2(from the CRT) __delayLoadHelper2callsLoadLibraryfor the DLL, then resolves the function address- The stub's IAT slot is patched with the real address — subsequent calls go direct
Malware uses delay-load to evade static import analysis: a suspicious import (VirtualAllocEx, WriteProcessMemory) placed in the delay import table won't appear in the standard IDT and won't show up in naive import scanners.
Forwarded Exports
Some DLL exports don't actually live in the exporting DLL — they forward to another DLL. For example, many kernel32.dll functions forward to kernelbase.dll. When the loader encounters a forwarded export, it loads the target DLL and resolves the function there.
# Python: detect forwarded exports (forwarder string = non-code RVA)
import pefile
def list_forwarded_exports(path: str):
pe = pefile.PE(path)
if not hasattr(pe, 'DIRECTORY_ENTRY_EXPORT'):
return
for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols:
if exp.forwarder_offset is not None:
fwd = pe.get_string_at_rva(exp.forwarder_offset)
print(f" Ordinal {exp.ordinal}: {exp.name} -> {fwd.decode()}")
Malware IAT Techniques
| Technique | How it works | IAT involvement |
|---|---|---|
| Manual import resolution | Walk PEB → Ldr → InLoadOrderModuleList to find ntdll.dll base; walk EAT to resolve GetProcAddress; use GetProcAddress for everything else | Bypasses IAT entirely — no import directory entries for suspicious functions |
| Hash-based resolution | Same as above but uses ROR13 hash (or similar) instead of string comparison in EAT walk | Strings obfuscated — static analysis won't find API names in .rdata |
| GetProcAddress at runtime | String-obfuscated API names decoded at runtime, resolved via GetProcAddress | Function appears only in IAT as GetProcAddress; suspicious APIs absent |
| Delay-load smuggling | Place suspicious imports in delay import directory | Not visible in the standard IDT; evades import-list scanners |
| IAT hook | Overwrite a process's IAT slot with a pointer to a hook function (malware or EDR) | IAT points to hook, not real function |
| Import table wiping | After resolving all imports, zero out the import directory to hinder analysis | Import directory RVA in OptionalHeader.DataDirectory[1] zeroed |
Detection: IAT Integrity Check
"""
Detect IAT hooks by comparing live IAT entries to expected
addresses from the DLL's on-disk export table.
"""
import ctypes, ctypes.wintypes, pefile
OpenProcess = ctypes.windll.kernel32.OpenProcess
ReadProcessMemory = ctypes.windll.kernel32.ReadProcessMemory
PROCESS_VM_READ = 0x0010
PROCESS_QUERY_INFORMATION = 0x0400
def get_expected_function_va(dll_path: str, func_name: str, loaded_base: int) -> int:
"""Read DLL from disk, find export RVA, add actual loaded base."""
pe = pefile.PE(dll_path, fast_load=True)
pe.parse_data_directories()
for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols:
if exp.name and exp.name.decode() == func_name:
return loaded_base + exp.address
raise ValueError(f"Export {func_name} not found")
def read_iat_entry(pid: int, iat_va: int) -> int:
"""Read an 8-byte IAT slot from a target process."""
hProc = OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, 0, pid)
buf = ctypes.c_uint64()
read = ctypes.c_size_t()
ReadProcessMemory(hProc, ctypes.c_void_p(iat_va), ctypes.byref(buf),
ctypes.sizeof(buf), ctypes.byref(read))
ctypes.windll.kernel32.CloseHandle(hProc)
return buf.value
def check_iat_hook(pid: int, pe_path: str, dll_path: str,
dll_loaded_base: int, func_name: str, iat_slot_va: int):
live_ptr = read_iat_entry(pid, iat_slot_va)
expected_va = get_expected_function_va(dll_path, func_name, dll_loaded_base)
if live_ptr != expected_va:
print(f"[HOOK DETECTED] {func_name}")
print(f" IAT slot VA: 0x{iat_slot_va:016X}")
print(f" Live pointer: 0x{live_ptr:016X}")
print(f" Expected: 0x{expected_va:016X}")
print(f" Delta: 0x{abs(live_ptr - expected_va):X}")
Q & A
Why does malware use ROR13 hashing for API name resolution instead of just using strings?
ROR13 (rotate right 13 bits, XOR each character) reduces a function name string like "VirtualAlloc" to a 32-bit integer like 0x0726774C. Shellcode and position-independent code (PIC) use hash-based resolution because: (1) Embedding plaintext strings like "VirtualAlloc" or "CreateRemoteThread" in shellcode makes it trivially detectable by string search (both static scanning and IDS signatures). Hashes are opaque numbers with no human-readable value. (2) Position-independent shellcode can't hardcode function addresses — they change with ASLR. The code must walk the PEB at runtime. Walking the export table by string comparison requires a strlen/strcmp loop. Hash comparison is a single integer operation per export, making it simpler and faster in compact shellcode where every byte counts. (3) YARA rules that match on API name strings don't fire. (4) Hashes are stable across minor Windows versions as long as the function name doesn't change. The detection counter: compute the ROR13 hash for every known Windows API name and maintain a lookup table. When you see suspicious hash values in code or on the stack, look them up to identify which API is being resolved.
If the IAT gets patched by an EDR, how does the EDR know which IAT slot to patch?
When an EDR initializes, it typically injects a DLL (its "sensor DLL") into each process. The sensor DLL: (1) Parses the target process's PE headers in memory to find the Import Directory. (2) Walks each IMAGE_IMPORT_DESCRIPTOR to find the DLLs and functions the process imports. (3) For each function it wants to hook (e.g., NtAllocateVirtualMemory, CreateRemoteThread), it locates the IAT slot. The IAT slot VA is: ImageBase + IAT RVA + (slot_index * sizeof(pointer)). Each slot index corresponds to the same-index entry in the INT (OriginalFirstThunk). (4) VirtualProtect to make the IAT page writable (it's normally read-only after the loader finishes). (5) Write the hook function's address into the slot. (6) VirtualProtect back to read-only. All subsequent calls to the hooked function by that PE use the replaced IAT slot and land in the EDR's hook. Direct system calls bypass this because they invoke the syscall instruction directly, never going through an IAT-resolved function address.