Chapter 7

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

Scenario

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:

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

FieldTypeDescription
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:

Loader Resolution Process

When the Windows loader processes the import table, it performs these steps for each imported DLL:

  1. Find the DLL: Search for the DLL using the DLL search order (see Chapter 21). Load it into memory if not already present.
  2. 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
  3. Write the IAT: Overwrite the corresponding entry in the Import Address Table (FirstThunk) with the resolved function address.
  4. 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 PatternWhat 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).