When conpty.dll exports CreatePseudoConsole, some other image's import descriptor will say "DLL name: conpty.dll, symbol: CreatePseudoConsole". The loader already knows the name — now it has to find the actual code inside this file and hand the address back. A string search over the whole DLL would work but would be slow and fragile, so PE packages exports as a small header plus three arrays designed for lookup.
This is also what GetProcAddress walks at runtime, what dumpbin /exports prints, and what a reverse engineer reads first when asking "what does this DLL actually offer?". Understanding it explains why ordinals break between versions, why some functions appear twice in an export list, and how one DLL can pretend to export code it does not contain.
Think of the export directory as one header row and three parallel columns:
Slot i of the name table and slot i of the ordinal table are the same row: name i maps to EAT index ordinal[i], and the caller-visible ordinal is that index plus the Ordinal Base. A single header (40 bytes) stores the DLL's own name and the RVAs of all three arrays. Data directory index 0 points at it.
The model's missing piece: there are two lookup routes into the same EAT — by name (search, then translate) and by ordinal (direct index). Names are convenience; ordinals are the real addressing scheme.
The spec's export directory table, one row, 40 bytes total:
| Offset | Size | Field | What it says |
|---|---|---|---|
| 0 | 4 | Export Flags | Reserved, must be 0 |
| 4 | 4 | Time/Date Stamp | When the export data was created (0 or 0xFFFFFFFF means "not meaningful") |
| 8 | 2 | Major Version | User-settable |
| 10 | 2 | Minor Version | User-settable |
| 12 | 4 | Name RVA | ASCII name of this DLL itself |
| 16 | 4 | Ordinal Base | Starting ordinal of the EAT, usually 1 |
| 20 | 4 | Address Table Entries | Length of the EAT (NumberOfFunctions) |
| 24 | 4 | Number of Name Pointers | Length of the name table and of the ordinal table |
| 28 | 4 | Export Address Table RVA | Where the RVAs are |
| 32 | 4 | Name Pointer RVA | Where the sorted name RVAs are |
| 36 | 4 | Ordinal Table RVA | Where the 16-bit indexes are |
The spec's lookup algorithm for a name: binary-search the name pointer table for a match at index i, read ordinal = OrdinalTable[i], take rva = ExportAddressTable[ordinal], and report ordinal + OrdinalBase to the caller. The reverse path subtracts Base first. The EAT entry itself has two meanings: normally an export RVA into code or data — but if it falls inside the export directory's own address range, it is a forwarder RVA, a pointer to a string like "MYDLL.expfunc" or "MYDLL.#27" naming a symbol in a different DLL. The spec's own example: Windows XP's Kernel32.HeapAlloc forwards to "NTDLL.RtlAllocateHeap", so applications keep importing only Kernel32.
The export directory of GitHub Copilot's conpty.dll, at file offset 0x150D0 (data directory index 0: RVA 0x164D0, size 0x214):
000150d0: 0000 0000 ffff ffff 0000 0000 8465 0100 .............e.. 000150e0: 0100 0000 0e00 0000 0e00 0000 f864 0100 .............d.. 000150f0: 3065 0100 6865 0100 205f 0000 0e..he.. _..
0000 0000 — Export Flags, zero as required. Next dword ffffffff — a non-meaningful timestamp per the spec.0000 0000 — Major/Minor version 0/0.8465 0100 = 0x16584 — Name RVA; the string there is conpty.dll.0100 0000 — Ordinal Base = 1.0e00 0000 twice — 14 address-table entries and 14 names (every export is named here).f864 0100 / 3065 0100 / 6865 0100 — RVAs of the EAT (0x164F8), name pointer table (0x16530), ordinal table (0x16568), all packed into the 0x214-byte export directory.The 14 names, exactly as objdump prints them (ordinal = EAT index + Base):
[ 0] +base[ 1] 0000 ClearPseudoConsole [ 1] +base[ 2] 0001 ClosePseudoConsole [ 4] +base[ 5] 0004 ConptyCreatePseudoConsole [ 11] +base[ 12] 000b CreatePseudoConsole [ 13] +base[ 14] 000d ResizePseudoConsole
Note the aliasing: EAT slot for ordinal 5 (ConptyCreatePseudoConsole) and slot for ordinal 12 (CreatePseudoConsole) both hold RVA 0x05BD0 — two names, one function. And no entry in this EAT falls inside the directory range 0x164D0-0x166E4, so none of the fourteen exports is a forwarder.
objdump prints the header and both name/ordinal columns:
$ objdump -x conpty.dll | sed -n '/Export Tables/,+14p'
The Export Tables (interpreted .rdata section contents)
Export Flags 0
Time/Date stamp ffffffff
Major/Minor 0/0
Name 0000000000016584 conpty.dll
Ordinal Base 1
Number in:
Export Address Table 0000000e
[Name Pointer/Ordinal] Table 0000000e$ objdump -x conpty.dll | sed -n '/\[Ordinal\/Name Pointer\]/,+6p'
[Ordinal/Name Pointer] Table -- Ordinal Base 1
Ordinal Hint Name
[ 0] +base[ 1] 0000 ClearPseudoConsole
[ 1] +base[ 2] 0001 ClosePseudoConsole
[ 2] +base[ 3] 0002 ConptyClearPseudoConsole
[ 3] +base[ 4] 0003 ConptyClosePseudoConsoleDecode the header yourself — the directory starts at file 0x150D0 because .rdata has RVA 0x11000 at file 0xFC00 and the export RVA is 0x164D0:
$ python3 -c "import struct;d=open('conpty.dll','rb').read();v=struct.unpack_from('<IIHHIIIIIII',d,0x150d0);print('base',v[5],'funcs',v[6],'names',v[7],hex(v[8]),hex(v[9]),hex(v[10]))"
base 1 funcs 14 names 14 0x164f8 0x16530 0x16568What to look for: Address Table Entries versus Number of Name Pointers — if they differ, some exports are ordinal-only. Then spot any EAT value that lies inside the directory's own RVA range: that is a forwarder.
Without looking back: a loader asks conpty.dll for ordinal 12. Which export does it get, and how does it find it?
You are auditing an EAT entry whose value is 0x16590, and the export directory covers RVAs 0x164D0 to 0x166E4. What have you found?
The pattern: read the directory size as a fence. EAT values inside the fence are strings; values outside it are code.
Both tables so far are eager: the loader resolves everything before main runs. But a DLL pulled in for one rare code path still taxes every process start — and if it is missing, the process will not start at all. The next lesson shows the structure that moves that cost and that failure to the first call instead.
Next: Delay-Load Imports — the second, parallel import machinery and the helper that runs it.