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:
| Offset | Field | Meaning |
|---|---|---|
0x00 | OriginalFirstThunk | RVA of the Import Lookup Table (ILT), also called the Import Name Table |
0x04 | TimeDateStamp | 0 unless the imports are bound |
0x08 | ForwarderChain | Legacy binding field; usually 0 or -1 |
0x0C | Name | RVA of the DLL name, e.g. "KERNEL32.dll" |
0x10 | FirstThunk | RVA 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_NAMEstructure: a 2-byte hint followed by the NUL-terminated function name. - Top bit set (
0x8000000000000000in PE32+,0x80000000in PE32) — import by ordinal; the low 16 bits are the ordinal number and there is no name.
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] 0The 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.dllandoleaut32.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:
- Load the DLL named by
Name, recursively resolving its own imports. - For each ILT entry, look the function up in that DLL's export table, by name (using the hint first) or by ordinal.
- 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:
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 slotThat 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 (__delayLoadHelper2in 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:
| Offset | Field | Meaning |
|---|---|---|
0x0C | Name | RVA of the DLL's internal name |
0x10 | Base | Ordinal of the first entry in AddressOfFunctions |
0x14 | NumberOfFunctions | Entries in the function address array |
0x18 | NumberOfNames | Entries in the name arrays (may be fewer) |
0x1C | AddressOfFunctions | RVA of an array of DWORD RVAs (the EAT) |
0x20 | AddressOfNames | RVA of an array of name RVAs, sorted |
0x24 | AddressOfNameOrdinals | RVA of an array of WORD indexes into the EAT |
(The first 12 bytes hold Characteristics, TimeDateStamp and a version.)
Three arrays work together:
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.datwhose export directory saysloader_x64.dllhas told you something. - Export names reveal intended entry points: a DLL meant for
rundll32.exe dropped.dll,Startmust exportStart, and a COM-registered DLL exportsDllRegisterServer. - 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:
| Imports | Suggests |
|---|---|
OpenProcess, VirtualAllocEx, WriteProcessMemory, CreateRemoteThread | Process injection |
RegCreateKeyExW, RegSetValueExW with a Run key string | Registry persistence |
CryptAcquireContext, CryptEncrypt, FindFirstFileW | File encryption (ransomware-like) |
InternetOpenA, InternetOpenUrlA, URLDownloadToFileW | Downloading |
SetWindowsHookExA, GetAsyncKeyState | Keylogging |
IsDebuggerPresent, CheckRemoteDebuggerPresent | Anti-debugging |
Because this is so informative, malware tries to keep the import table boring:
- Dynamic resolution. Call
LoadLibraryAandGetProcAddressat 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
stringsreveals 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 like0x726774C. 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,VirtualProtectandExitProcessis 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.
-
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 -
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.exeFind
GetCurrentProcessId,Sleep,LoadLibraryAandGetProcAddressunderKERNEL32.dll, andMessageBoxAunderUSER32.dll. Theaddresscolumn is the IAT slot's virtual address.GetComputerNameAis not there. -
Now search the strings:
bash strings imports.exe | grep -iE "computername|messagebox|kernel32|sleep"GetComputerNameAshows up — as a plain string passed toGetProcAddress. Write down the rule this illustrates: a function name in strings but not in imports, next toGetProcAddress, means dynamic resolution. -
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)) -
Compare imphashes. Record the imphash, then add a call to
GetTickCount()toimports.c, rebuild, and compute it again. Compare with the imphash ofhello.exefrom the earlier lessons and, if you still have it,hello_upx.exe. -
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 NONAMEbash x86_64-w64-mingw32-gcc -shared -s -o mylib.dll mylib.c mylib.def # MSVC: cl /LD mylib.c /link /DEF:mylib.def -
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, aLabBeepentry whose RVA falls inside the export directory range with forwarderkernel32.Beep, and ordinal 10 with no name. TheCharacteristicsvalue includes0x2000, the DLL flag. -
Open
imports.exeandmylib.dllin 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, openmylib.dllin Dependencies to see the forwarder resolved tokernel32.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_DESCRIPTORnames 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.