Skip to content

Lesson 2.3 · Binary Formats· 45 min

Imports, Exports and the IAT

How PE files declare the API functions they use and provide, how the loader fills the IAT, and how malware hides its imports from you.

Objectives

  • Walk the import directory from IMAGE_IMPORT_DESCRIPTOR to the ILT, IAT and hint/name entries
  • Explain how the loader resolves imports, including by ordinal and via delay-loading
  • Parse an export directory, resolve a name to an address and recognise forwarders
  • Compute and interpret an imphash, knowing its limits
  • Recognise dynamic resolution and API hashing as ways of hiding imports

A Windows program on its own can do almost nothing: it cannot open a file, send a packet or create a process without calling into system DLLs such as kernel32.dll, ntdll.dll, advapi32.dll or ws2_32.dll. The PE format records those dependencies in the import table, and DLLs publish what they offer in the export table.

For an analyst, the import table is often the richest single artefact in a file. A sample importing VirtualAllocEx, WriteProcessMemory and CreateRemoteThread is announcing its intentions before you disassemble a byte. That is precisely why malware authors work so hard to hide it — and why you need to understand the structures well enough to notice when they are missing.

The import directory

Data directory 1 (see PE Headers) points at an array of 20-byte IMAGE_IMPORT_DESCRIPTOR structures, one per imported DLL, terminated by an all-zero entry:

OffsetFieldMeaning
0x00OriginalFirstThunkRVA of the Import Lookup Table (ILT), also called the Import Name Table
0x04TimeDateStamp0 unless the imports are bound
0x08ForwarderChainLegacy binding field; usually 0 or -1
0x0CNameRVA of the DLL name, e.g. "KERNEL32.dll"
0x10FirstThunkRVA of the Import Address Table (IAT)

The ILT and the IAT are parallel arrays of thunks: 8 bytes each in PE32+, 4 bytes in PE32, terminated by a zero entry. Each thunk is interpreted as follows:

  • Top bit clear — the value is an RVA to an IMAGE_IMPORT_BY_NAME structure: a 2-byte hint followed by the NUL-terminated function name.
  • Top bit set (0x8000000000000000 in PE32+, 0x80000000 in PE32) — import by ordinal; the low 16 bits are the ordinal number and there is no name.
text
IMAGE_IMPORT_DESCRIPTOR (KERNEL32.dll)
 ├─ Name ──────────────► "KERNEL32.dll"
 ├─ OriginalFirstThunk ─► ILT                      hint/name table
 │                        [0] 0x8450 ────────────► 0x0127 "DeleteCriticalSection"
 │                        [1] 0x8468 ────────────► 0x014D "EnterCriticalSection"
 │                        [2] 0x8480 ────────────► 0x0239 "GetCurrentProcessId"
 │                        [3] 0
 └─ FirstThunk ─────────► IAT   (on disk: same values as the ILT)
                          [0] 0x8450   ── after loading ──► 0x7FFA1B2C3D40
                          [1] 0x8468                        0x7FFA1B2C1180
                          [2] 0x8480                        0x7FFA1B2C0A20
                          [3] 0

The hint is the index where the linker expected to find the name in the DLL's export name table. The loader tries that slot first and falls back to a binary search if the hint is wrong, so hints are an optimisation, not a requirement.

Tip: Import by ordinal hides the function name. For common system DLLs such as ws2_32.dll and oleaut32.dll, ordinals are stable and tools like pefile translate them back to names. For arbitrary DLLs, an ordinal import is opaque until you inspect the target DLL's export table.

How the loader fills the IAT

When a process starts (see How a Binary Is Loaded and Run), the loader walks the import descriptors one by one:

  1. Load the DLL named by Name, recursively resolving its own imports.
  2. For each ILT entry, look the function up in that DLL's export table, by name (using the hint first) or by ordinal.
  3. Write the resolved absolute address into the matching IAT slot.

The ILT is left untouched; it remains a record of what was requested. The IAT, once filled, is simply an array of function pointers. The compiled code never calls an API directly; it calls through the IAT:

asm
call  qword ptr [rip + 0x6E3A]   ; → IAT slot for GetCurrentProcessId
; or, via a small stub the linker generates:
call  GetCurrentProcessId_stub
...
GetCurrentProcessId_stub:
jmp   qword ptr [rip + 0x6F12]   ; → the same IAT slot

That single indirection is why disassemblers can label API calls even in stripped binaries: they know which IAT slot each call reads. It is also why the IAT is a prime target for hooking — overwrite one pointer and every call to that API goes somewhere else (see API hooking).

Data directory 12 (IAT) records where the IATs live. Some very old linkers set OriginalFirstThunk to 0, leaving only the IAT; the loader then reads names from the IAT itself before overwriting it. Tools must handle both cases.

Bound and delay-loaded imports

  • Bound imports (directory 11) pre-compute IAT values at link time for a specific DLL version. With ASLR they are nearly always stale and are rare in modern binaries.
  • Delay-load imports (directory 13) use a separate descriptor array, IMAGE_DELAYLOAD_DESCRIPTOR. The DLL is not loaded at startup; instead, each IAT slot initially points at a helper (__delayLoadHelper2 in MSVC) that loads the DLL and resolves the function on first call. MSVC enables this with /DELAYLOAD:name.dll.

Delay-loaded APIs are easy to miss because some tools list them separately or not at all. In pefile, check pe.DIRECTORY_ENTRY_DELAY_IMPORT in addition to pe.DIRECTORY_ENTRY_IMPORT.

Exports

A DLL (and occasionally an EXE) publishes functions through data directory 0, which points at a 40-byte IMAGE_EXPORT_DIRECTORY:

OffsetFieldMeaning
0x0CNameRVA of the DLL's internal name
0x10BaseOrdinal of the first entry in AddressOfFunctions
0x14NumberOfFunctionsEntries in the function address array
0x18NumberOfNamesEntries in the name arrays (may be fewer)
0x1CAddressOfFunctionsRVA of an array of DWORD RVAs (the EAT)
0x20AddressOfNamesRVA of an array of name RVAs, sorted
0x24AddressOfNameOrdinalsRVA of an array of WORD indexes into the EAT

(The first 12 bytes hold Characteristics, TimeDateStamp and a version.)

Three arrays work together:

text
 AddressOfNames[i]      AddressOfNameOrdinals[i]      AddressOfFunctions[j]
 (sorted, ASCII order)  (index j into EAT)            (RVA)
 ┌───────────────┐      ┌─────┐                        ┌────────┐ j=0  ordinal Base+0
 │ "LabBeep"     │ ───► │  2  │ ──────────┐     ┌────► │ 0x1360 │ add_numbers
 │ "add_numbers" │ ───► │  0  │ ──────────┼─────┘      ├────────┤ j=1  ordinal Base+1
 │ "lab_version" │ ───► │  1  │ ──────────┼──────────► │ 0x1374 │ lab_version
 └───────────────┘      └─────┘           │            ├────────┤ j=2  ordinal Base+2
                                          └──────────► │ 0x806C │ inside export dir:
                                                       ├────────┤ forwarder string
                                                       │  0 ... │ j=3..8 unused
                                                       ├────────┤ j=9  ordinal Base+9
                                                       │ 0x1381 │ (no name)
                                                       └────────┘

Resolving a name: binary-search AddressOfNames (sorted by raw byte value, so uppercase names come before lowercase ones) to get index i, read j = AddressOfNameOrdinals[i], and the function RVA is AddressOfFunctions[j]. Resolving an ordinal n: use AddressOfFunctions[n - Base] directly. Entries with no name are ordinal-only exports.

Forwarders

If a function RVA points inside the export directory itself (between the directory's RVA and RVA + Size), it is not code: it points at a string like "NTDLL.RtlAllocateHeap", and the loader resolves that target instead. Windows uses forwarding heavily — many kernel32.dll exports forward to kernelbase.dll or ntdll.dll. Malware uses it for DLL proxying: a malicious DLL placed where a legitimate one would be found forwards every export to the real DLL so that the host program keeps working, while its own DllMain runs the payload.

What exports tell an analyst

  • The internal name often survives renaming: a file called update.dat whose export directory says loader_x64.dll has told you something.
  • Export names reveal intended entry points: a DLL meant for rundll32.exe dropped.dll,Start must export Start, and a COM-registered DLL exports DllRegisterServer.
  • A DLL that exports the same names as a system DLL, mostly as forwarders, is a proxy — look closely at the few that are not forwarded.
  • Reflectively loaded DLLs often export a single loader function such as ReflectiveLoader (see reflective DLL injection).

Imphash

Imphash, introduced by Mandiant in 2014, is an MD5 over the import table in a normalised form: each import becomes dllname.function in lower case, with the extension stripped from the DLL name and known ordinals translated to names; the entries are joined with commas in their original order and hashed.

Because the import list and its order depend on the source code and the build, samples compiled from the same codebase often share an imphash even when their file hashes differ. That makes it useful for pivoting in VirusTotal and similar platforms, and in YARA via the pe.imphash() function.

Know its limits:

  • Packed samples share the packer's tiny import table, so all UPX-packed files built with the same UPX version look alike.
  • Tiny import tables (a handful of CRT imports) collide across unrelated programs.
  • Go, Delphi and statically linked binaries produce imphashes shared by thousands of unrelated files.
  • Reordering imports or adding one unused API changes the hash completely.

Treat imphash as a clustering hint to confirm with other evidence, not as attribution.

What imports reveal, and how malware hides them

A normal program's imports read like a capability summary. Some combinations analysts learn to recognise:

ImportsSuggests
OpenProcess, VirtualAllocEx, WriteProcessMemory, CreateRemoteThreadProcess injection
RegCreateKeyExW, RegSetValueExW with a Run key stringRegistry persistence
CryptAcquireContext, CryptEncrypt, FindFirstFileWFile encryption (ransomware-like)
InternetOpenA, InternetOpenUrlA, URLDownloadToFileWDownloading
SetWindowsHookExA, GetAsyncKeyStateKeylogging
IsDebuggerPresent, CheckRemoteDebuggerPresentAnti-debugging

Because this is so informative, malware tries to keep the import table boring:

  • Dynamic resolution. Call LoadLibraryA and GetProcAddress at runtime to obtain function pointers. The sensitive API never appears in the import table, though its name usually appears as a string. See dynamic import resolution.
  • Hidden strings. The same, with the names built on the stack or decrypted at runtime, so that neither the import table nor strings reveals them. See stack strings.
  • API hashing. Walk the loaded modules through the PEB, parse each DLL's export table in memory, hash every export name and compare against precomputed constants. No GetProcAddress, no strings — only numbers like 0x726774C. See API hashing.
  • Packing. The real imports are rebuilt by the unpacker; the packed file shows only what the stub needs. A table consisting of little more than LoadLibraryA, GetProcAddress, VirtualProtect and ExitProcess is the classic sign, and exactly what UPX leaves behind.
  • IAT destruction. After resolving imports, wipe or corrupt the import structures in memory so that a dumped image cannot be rebuilt easily. See IAT destruction.

A suspiciously short import table in a large binary is itself a finding. Absence of evidence here is evidence — of hiding.

Warning: An import is a capability, not a behaviour. Many legitimate programs import WriteProcessMemory (debuggers, game launchers, security tools). Use imports to decide where to look, then confirm in the code.

Lab: visible, hidden and exported functions

You will build a benign program that calls some APIs normally and one dynamically, compare what different tools see, then build a DLL and dissect its exports. The examples use mingw-w64; MSVC equivalents are noted.

  1. Write the program:

    c
    // imports.c
    #include <windows.h>
    #include <stdio.h>
    
    typedef BOOL (WINAPI *pGetComputerNameA)(LPSTR, LPDWORD);
    
    int main(void) {
        char name[MAX_COMPUTERNAME_LENGTH + 1];
        DWORD len = sizeof(name);
    
        /* Static imports: resolved by the loader, listed in the import table */
        DWORD pid = GetCurrentProcessId();
        Sleep(10);
        MessageBoxA(NULL, "benign lab program", "imports demo", MB_OK);
    
        /* Dynamic import: absent from the import table */
        HMODULE k32 = LoadLibraryA("kernel32.dll");
        pGetComputerNameA fn =
            (pGetComputerNameA)GetProcAddress(k32, "GetComputerNameA");
        if (fn && fn(name, &len))
            printf("pid %lu on %s\n", pid, name);
        return 0;
    }
    bash
    x86_64-w64-mingw32-gcc -O0 -s -o imports.exe imports.c -luser32
    # MSVC: cl imports.c user32.lib
  2. List the imports with pefile, including the ILT and IAT addresses:

    python
    # imports.py
    import sys, pefile
    pe = pefile.PE(sys.argv[1])
    for desc in pe.DIRECTORY_ENTRY_IMPORT:
        s = desc.struct
        print(f"{desc.dll.decode()}  ILT={s.OriginalFirstThunk:#x} IAT={s.FirstThunk:#x}")
        for imp in desc.imports:
            label = imp.name.decode() if imp.name else f"ordinal {imp.ordinal}"
            print(f"    {imp.address:#x}  hint={imp.hint}  {label}")
    print("imphash:", pe.get_imphash())
    bash
    python3 imports.py imports.exe

    Find GetCurrentProcessId, Sleep, LoadLibraryA and GetProcAddress under KERNEL32.dll, and MessageBoxA under USER32.dll. The address column is the IAT slot's virtual address. GetComputerNameA is not there.

  3. Now search the strings:

    bash
    strings imports.exe | grep -iE "computername|messagebox|kernel32|sleep"

    GetComputerNameA shows up — as a plain string passed to GetProcAddress. Write down the rule this illustrates: a function name in strings but not in imports, next to GetProcAddress, means dynamic resolution.

  4. Check the on-disk IAT. Confirm that before loading, the IAT holds the same hint/name RVAs as the ILT:

    python
    d = pe.DIRECTORY_ENTRY_IMPORT[0].struct
    for k in range(3):
        ilt = pe.get_qword_at_rva(d.OriginalFirstThunk + 8 * k)
        iat = pe.get_qword_at_rva(d.FirstThunk + 8 * k)
        print(hex(ilt), hex(iat), pe.get_string_at_rva(ilt + 2))
  5. Compare imphashes. Record the imphash, then add a call to GetTickCount() to imports.c, rebuild, and compute it again. Compare with the imphash of hello.exe from the earlier lessons and, if you still have it, hello_upx.exe.

  6. Build a DLL with exports. Use a module-definition file to control ordinals, add a forwarder and one ordinal-only export:

    c
    // mylib.c
    #include <windows.h>
    
    int add_numbers(int a, int b) { return a + b; }
    const char *lab_version(void) { return "1.0"; }
    int hidden_helper(void) { return 42; }
    
    BOOL WINAPI DllMain(HINSTANCE inst, DWORD reason, LPVOID reserved) {
        return TRUE;
    }
    text
    ; mylib.def
    LIBRARY mylib
    EXPORTS
        add_numbers    @1
        lab_version    @2
        LabBeep = kernel32.Beep @3
        hidden_helper  @10 NONAME
    bash
    x86_64-w64-mingw32-gcc -shared -s -o mylib.dll mylib.c mylib.def
    # MSVC: cl /LD mylib.c /link /DEF:mylib.def
  7. Inspect the exports:

    python
    import pefile
    pe = pefile.PE("mylib.dll")
    ed = pe.DIRECTORY_ENTRY_EXPORT
    d = pe.OPTIONAL_HEADER.DATA_DIRECTORY[0]
    print("internal name:", pe.get_string_at_rva(ed.struct.Name))
    print("Base", ed.struct.Base, "functions", ed.struct.NumberOfFunctions,
          "names", ed.struct.NumberOfNames)
    print(f"export dir {d.VirtualAddress:#x}-{d.VirtualAddress + d.Size:#x}")
    for s in ed.symbols:
        print(s.ordinal, hex(s.address), s.name, s.forwarder)
    print(hex(pe.FILE_HEADER.Characteristics))

    Expect Base = 1, NumberOfFunctions = 10 (ordinals 4–9 are empty slots), NumberOfNames = 3, a LabBeep entry whose RVA falls inside the export directory range with forwarder kernel32.Beep, and ordinal 10 with no name. The Characteristics value includes 0x2000, the DLL flag.

  8. Open imports.exe and mylib.dll in PE-bear and look at the Imports and Exports tabs. Click an import and compare the Thunk and Original Thunk columns. If you have a Windows VM, open mylib.dll in Dependencies to see the forwarder resolved to kernel32.dll.

Questions to answer: Why does NumberOfFunctions differ from NumberOfNames in your DLL? How would you call hidden_helper from another program without a name? What would change in step 3 if the string "GetComputerNameA" were built character by character on the stack? Which KERNEL32 imports in imports.exe did you not write yourself, and where do they come from?

Key takeaways

  • Each IMAGE_IMPORT_DESCRIPTOR names a DLL and points at two parallel thunk arrays: the ILT (what was requested) and the IAT (overwritten by the loader with real addresses).
  • Thunks with the top bit set are ordinal imports; otherwise they point at a hint and a name. Delay-loaded imports live in a separate directory.
  • Exports use three arrays — names, name ordinals and function RVAs; an RVA that lands inside the export directory is a forwarder string.
  • Imphash clusters samples built from the same code, but collides for packed, tiny or runtime-heavy binaries.
  • Missing imports are a signal: dynamic resolution, API hashing, packing and IAT destruction all exist to keep the import table quiet.