Import Table
How a PE declares the functions it needs from external DLLs — the Import Descriptor Table, the Import Name Table, the Import Address Table, and what the loader does to resolve them before your code runs
You're analyzing a suspicious binary and you open its imports. It shows only three functions: LoadLibraryA, GetProcAddress, and VirtualAlloc. That's the classic signature of a reflective loader or packed malware that resolves its real imports dynamically. Understanding the import table structure tells you exactly what information a legitimate versus malicious binary exposes — and what techniques hide that information.
What the Import Table Is For
Almost every Windows application uses code from DLLs — kernel32.dll for process/file/memory operations, user32.dll for window management, ntdll.dll for NT API access. The PE import table is the manifest that lists which DLLs and which functions from each DLL the executable needs. The Windows loader reads this table at process startup and resolves all the addresses before transferring control to the entry point.
The import mechanism has three interrelated data structures:
- Import Descriptor Table (IDT): one entry per imported DLL
- Import Name Table (INT): per-DLL list of function names or ordinals (read-only, doesn't change)
- Import Address Table (IAT): per-DLL list of resolved function addresses (written by the loader)
The Three Import Structures
Import Table Layout
──────────────────────────────────────────────────────────────────
IDT (Import Descriptor Table) — one IMAGE_IMPORT_DESCRIPTOR per DLL:
┌──────────────────────────────────────────────────────────────┐
│ OriginalFirstThunk (RVA → INT) │ FirstThunk (RVA → IAT) │
│ TimeDateStamp (0 before bound) │ ForwarderChain │
│ Name (RVA → "kernel32.dll\0") │ │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ ... next DLL ... │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ All-zeros entry (terminator) │
└──────────────────────────────────────────────────────────────┘
INT (Import Name Table) — original thunks, one per function:
┌────────────────────────────────────────────────────────────┐
│ RVA → IMAGE_IMPORT_BY_NAME {Hint, "CreateFileW\0"} │
│ RVA → IMAGE_IMPORT_BY_NAME {Hint, "WriteFile\0"} │
│ ... │
│ 0x0000000000000000 (null terminator) │
└────────────────────────────────────────────────────────────┘
IAT (Import Address Table) — same layout before loading:
┌────────────────────────────────────────────────────────────┐
│ [RVA to name] ← matches INT before loading │
│ [RVA to name] │
┌────────────────────────────────────────────────────────────┐
AFTER LOADER RUNS:
│ 0x00007FFC1A23B490 ← actual address of CreateFileW │
│ 0x00007FFC1A23C110 ← actual address of WriteFile │
└────────────────────────────────────────────────────────────┘
IMAGE_IMPORT_DESCRIPTOR Fields
| Field | Type | Description |
|---|---|---|
OriginalFirstThunk |
DWORD (RVA) | Points to the INT. Never modified by the loader — this preserves the original list of imported names even after addresses are resolved. |
TimeDateStamp |
DWORD | 0 = unbound. When binding is used, this is the DLL's timestamp at bind time. |
ForwarderChain |
DWORD | Index of first forwarder reference (-1 if none). Used when one DLL forwards an export to another. |
Name |
DWORD (RVA) | RVA of null-terminated DLL name string, e.g., "kernel32.dll" |
FirstThunk |
DWORD (RVA) | Points to the IAT. Before loading: contains same values as INT. After loading: overwritten with resolved addresses. |
Import By Name vs Import By Ordinal
Each entry in the INT/IAT is a pointer-sized value. The high bit controls how the function is identified:
- Import by name: High bit = 0. The lower bits are an RVA to an
IMAGE_IMPORT_BY_NAMEstructure:{ WORD Hint; CHAR Name[]; }. The Hint is a suggested index into the DLL's export name table (a performance hint, not required to be accurate). - Import by ordinal: High bit = 1. The lower 16 bits are the ordinal number. No name string involved — the function is looked up directly by its export ordinal.
Loader Resolution Process
When the Windows loader processes the import table, it performs these steps for each imported DLL:
- Find the DLL: Search for the DLL using the DLL search order (see Chapter 21). Load it into memory if not already present.
- Walk the INT: For each entry in the Import Name Table (OriginalFirstThunk), look up the function in the loaded DLL's export table:
- By name: search the EAT's name table, find the ordinal, resolve the function address
- By ordinal: look up directly in the EAT by ordinal index
- Write the IAT: Overwrite the corresponding entry in the Import Address Table (FirstThunk) with the resolved function address.
- Repeat for all entries, then move to the next DLL descriptor.
After this process completes, every slot in every IAT holds the absolute address of the corresponding function. When your code calls CreateFileW(), the compiler generated an indirect call through the IAT: call [IAT_slot_for_CreateFileW]. The IAT slot now holds the real address of CreateFileW in kernel32.dll.
Parsing Imports with Python
import pefile
def list_imports(filepath):
pe = pefile.PE(filepath)
if not hasattr(pe, 'DIRECTORY_ENTRY_IMPORT'):
print("No import table found (packed?)")
return
for entry in pe.DIRECTORY_ENTRY_IMPORT:
dll_name = entry.dll.decode("utf-8", errors="replace")
print(f"\n[{dll_name}]")
for imp in entry.imports:
if imp.name:
name = imp.name.decode("utf-8", errors="replace")
print(f" {hex(imp.address):20s} {name}")
else:
print(f" {hex(imp.address):20s} Ordinal #{imp.ordinal}")
pe.close()
def flag_suspicious_imports(filepath):
"""Flag imports that suggest specific malware capabilities."""
suspicious = {
"process injection": ["VirtualAllocEx", "WriteProcessMemory", "CreateRemoteThread", "NtCreateThreadEx"],
"privilege escalation": ["AdjustTokenPrivileges", "OpenProcessToken", "LookupPrivilegeValue"],
"defense evasion": ["IsDebuggerPresent", "CheckRemoteDebuggerPresent", "NtQueryInformationProcess"],
"dynamic loading": ["LoadLibraryA", "LoadLibraryW", "GetProcAddress"],
"network": ["WSAStartup", "connect", "InternetOpenUrl", "HttpSendRequest"],
"shellcode execution": ["VirtualAlloc", "VirtualProtect"],
}
pe = pefile.PE(filepath)
found_imports = set()
if hasattr(pe, 'DIRECTORY_ENTRY_IMPORT'):
for entry in pe.DIRECTORY_ENTRY_IMPORT:
for imp in entry.imports:
if imp.name:
found_imports.add(imp.name.decode("utf-8", errors="replace"))
for category, funcs in suspicious.items():
matches = [f for f in funcs if f in found_imports]
if matches:
print(f"[{category}] {matches}")
pe.close()
Delay-Load Imports
Delay-load imports are an optimization where a DLL isn't loaded until the first time one of its functions is actually called. The mechanism uses a separate data directory (IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT). At compile time, the linker generates a stub function for each delay-loaded import; the stub loads the DLL and resolves the address on first call, then patches the IAT so subsequent calls go directly.
Malware uses delay-load to avoid having suspicious DLL names (like wininet.dll or ws2_32.dll) in the static import table, where analysts would immediately notice network capability. The DLL is only loaded at runtime when the network function is actually needed, and static analysis of the import table shows no network imports.
Malware Import Patterns
| Import Pattern | What It Means |
|---|---|
| Only LoadLibrary + GetProcAddress | Packed or reflectively-loaded malware. All real imports are resolved dynamically via these two functions, hiding the full capability from static analysis. |
| VirtualAlloc + WriteProcessMemory + CreateRemoteThread | Classic process injection triplet. The process allocates memory in a target, writes shellcode, then creates a thread to execute it. |
| VirtualAlloc + VirtualProtect + no write functions | Shellcode loader pattern: allocate RW memory, write shellcode there, flip to RX with VirtualProtect, execute. |
| No imports at all (empty import table) | Shellcode-style PE or hand-crafted binary that uses a custom loader to find exports manually via PEB traversal. |
| AdjustTokenPrivileges + OpenProcessToken | Privilege escalation setup — typically followed by SeDebugPrivilege or SeTcbPrivilege acquisition. |
| CryptEncrypt / CryptDecrypt | Encryption — could be ransomware, secure C2 communication, or payload decryption. |
IAT Hooking
The IAT is a writable array of function addresses in the process's memory. IAT hooking replaces one of those addresses with a hook function's address. When the target program calls the imported function, it goes to the hook instead.
IAT Hooking — Before and After
─────────────────────────────────────────────────────────────────
Normal IAT entry for VirtualAlloc:
IAT[VirtualAlloc] ──────────────────► kernel32!VirtualAlloc
After IAT hook:
IAT[VirtualAlloc] ──► hook_function ──► kernel32!VirtualAlloc
│
└── also logs/blocks the call
Code in target process:
call [IAT_VirtualAlloc] ; indirect call through IAT
The IAT pointer was overwritten — the hook runs transparently
EDR products use IAT hooking as a monitoring technique. Malware can detect IAT hooks by comparing IAT entries to the addresses in the actual DLL's export table — a mismatch means a hook is present. Malware can bypass IAT hooks by resolving functions manually (walking the PEB module list, parsing the EAT) and calling them directly rather than through the IAT. That bypasses IAT hooks but not inline hooks (which modify the function prologue itself).
Q & A
Why does the import table have both an INT and an IAT? Why not just one table?
The design separates the "what to import" list (INT — permanent, read-only reference) from the "resolved addresses" list (IAT — written by the loader). Without the INT, once the loader overwrites the IAT with addresses, you'd lose the original function names — there'd be no way to know which function each address slot corresponds to (important for debuggers and analysis tools). The INT is never modified; it preserves the original import list forever. The IAT is what the code actually uses at runtime through indirect calls. This two-table design enables: (1) debuggers to show function names even after addresses are resolved, (2) "binding" optimization — pre-resolving addresses at install time and storing them in the IAT, then verifying them at load time by comparing DLL timestamps against the IID's TimeDateStamp, (3) IAT hooking without losing track of what was originally there (you can always recover original addresses from the INT).
How does malware resolve imports without using the import table?
The technique is called PEB module traversal or manual import resolution. The Process Environment Block (PEB) at fs:[0x30] (32-bit) or gs:[0x60] (64-bit) contains a pointer to a loader data structure (PEB_LDR_DATA) that has three doubly-linked lists of all loaded modules. By walking InLoadOrderModuleList, malware can find any loaded DLL by name. Once it has the DLL's base address, it manually parses the PE headers in memory to find the export table, then searches for functions by name or hash. This process requires no Win32 API calls — it's all done by walking kernel data structures that are always accessible. The technique is common in shellcode because shellcode can't rely on an import table being set up. Many malware samples hash DLL/function names (using simple algorithms like ROR13) to avoid having cleartext API name strings, making static analysis harder.
Can a PE import from itself (circular import)?
A PE cannot import from itself — imports require the loader to look up a function in a different module's export table. But a related scenario does exist: DLL forwarding, where DLL A's export for function X is actually forwarded to function Y in DLL B. For example, historically kernel32!HeapAlloc forwarded to ntdll!RtlAllocateHeap. The forwarded export entry contains a string like "ntdll.RtlAllocateHeap" instead of a function RVA. When the loader resolves this import, it sees the forwarded string and looks up the real function in the target DLL. This can create chains: A→B→C. Circular forwarding chains would cause infinite loops in the loader, so Windows doesn't permit them. In practice, forwarding only goes toward lower-level DLLs (kernel32 → ntdll, not the other way around).