Export Table
How DLLs advertise their functions to callers — the Export Address Table structure, named exports versus ordinals, forwarded exports, and how shellcode resolves exports without the Windows loader
You're reverse engineering shellcode that walks the PEB to find kernel32.dll and manually calls LoadLibraryA. The shellcode hashes function names and compares them against exports to find the right function without importing anything. To understand what the shellcode is actually doing — and to write detection for it — you need to understand exactly how the Export Address Table is structured and how export lookups work.
Export Table Overview
The Export Address Table (EAT), also called the export directory or export table, is a PE structure that lists the functions a DLL makes available to callers. It's the supplier side of the import mechanism: importers look up functions in the EAT to find their addresses.
Every DLL you've ever used has an export table: kernel32.dll exports over 1,000 functions. ntdll.dll exports the NT API. Even EXEs can have export tables — though less common, it's legal and some specialized executables export functions.
EAT Structure
The export table is pointed to by Data Directory entry 0 (IMAGE_DIRECTORY_ENTRY_EXPORT). It consists of one IMAGE_EXPORT_DIRECTORY header and three parallel arrays:
Export Table Layout
──────────────────────────────────────────────────────────────────
IMAGE_EXPORT_DIRECTORY:
┌─────────────────────────────────────────────────────────────┐
│ Characteristics (unused, 0) │
│ TimeDateStamp │
│ MajorVersion, MinorVersion │
│ Name (RVA → "kernel32.dll\0") │
│ Base (ordinal base, typically 1) │
│ NumberOfFunctions — size of EAT array │
│ NumberOfNames — size of name/ordinal arrays │
│ AddressOfFunctions (RVA → EAT array) │
│ AddressOfNames (RVA → ENT array) │
│ AddressOfNameOrdinals (RVA → EOT array) │
└─────────────────────────────────────────────────────────────┘
Three parallel arrays:
ENT (Export Name Table) — array of RVAs to name strings, SORTED:
[ RVA("AcquireSRWLockExclusive"), RVA("AddAtomA"), ... ]
EOT (Export Ordinal Table) — parallel to ENT, indexes into EAT:
[ 0x001, 0x002, ... ]
EAT (Export Address Table) — indexed by ordinal-Base:
[ RVA(func0), RVA(func1), RVA(func2), ... ]
^index 0 ^index 1
Lookup by name:
1. Binary search ENT for name string
2. Get corresponding index from EOT → ordinal
3. EAT[ordinal - Base] → function RVA
IMAGE_EXPORT_DIRECTORY Fields
| Field | Description |
|---|---|
Name |
RVA to a null-terminated string with the DLL's name. Note: this is the canonical name embedded in the DLL itself, separate from the filename. You can rename a DLL on disk; this field still shows the original name. |
Base |
The starting ordinal number. Usually 1. If a DLL exports functions with ordinals 1–100, Base=1, and EAT[ordinal-1] maps each ordinal to a function. |
NumberOfFunctions |
Size of the EAT array. The total number of exported slots (including any gaps for unused ordinals). |
NumberOfNames |
Number of named exports. Functions exported only by ordinal (no name) count toward NumberOfFunctions but not NumberOfNames. |
AddressOfFunctions |
RVA to the EAT: an array of NumberOfFunctions RVAs, each being a function's address (or a forwarder string RVA). |
AddressOfNames |
RVA to the ENT: an array of NumberOfNames RVAs, each pointing to a function name string. Sorted alphabetically. |
AddressOfNameOrdinals |
RVA to the EOT: an array of NumberOfNames WORDs. Each WORD is an index into the EAT (not the final ordinal — subtract Base to get the EAT index). |
Named vs Ordinal-Only Exports
Every export has an ordinal — a 16-bit number used to index into the EAT. Ordinals are the primitive identifier. Names are optional annotations that make imports human-readable. Some DLLs export functions with no name at all — only ordinal access is supported. The Windows socket library ws2_32.dll historically exported some functions this way.
| Export Type | How to Call | How to Import in PE |
|---|---|---|
| Named export | GetProcAddress(hDll, "CreateFileW") |
INT entry with IMAGE_IMPORT_BY_NAME |
| Ordinal-only export | GetProcAddress(hDll, MAKEINTRESOURCE(42)) |
INT entry with high bit set, ordinal in lower 16 bits |
| Named + ordinal | Either method works | Either import method works |
Forwarded Exports
A forwarded export is an EAT entry that doesn't point to code in the current DLL. Instead, the EAT entry's RVA falls within the export directory's address range — the loader interprets it as a null-terminated ASCII string of the form "DLLName.FunctionName" or "DLLName.#ordinal" and looks up the function in the specified DLL.
Forwarded Export Detection
──────────────────────────────────────────────────────────────────
Normal EAT entry:
EAT[i] = 0x00012345 (RVA that points to code, outside export dir)
Forwarded EAT entry:
EAT[i] = 0x000A1234 (RVA that falls INSIDE the export directory)
→ points to string: "ntdll.RtlAllocateHeap"
Check: if EAT[i] >= ExportDirRVA AND EAT[i] < ExportDirRVA + ExportDirSize
then it's a forwarder string, not a function address
Common examples (historical, may vary by Windows version):
kernel32!HeapAlloc → ntdll.RtlAllocateHeap
kernel32!HeapFree → ntdll.RtlFreeHeap
kernel32!HeapReAlloc → ntdll.RtlReAllocateHeap
Manual Export Resolution (Shellcode Technique)
Shellcode can't rely on having an import table set up. Instead it finds functions by walking the PEB and manually parsing EATs. This is the most common shellcode initialization pattern:
; x64 shellcode: manual GetProcAddress equivalent
; Step 1: Get PEB via gs:[0x60]
mov rax, gs:[0x60] ; PEB pointer
; Step 2: PEB->Ldr (offset 0x18) -> InLoadOrderModuleList (offset 0x10)
mov rax, [rax + 0x18] ; PEB->Ldr
mov rax, [rax + 0x10] ; Ldr->InLoadOrderModuleList.Flink
; rax is now the first LIST_ENTRY (the process executable itself)
mov rax, [rax] ; second entry is ntdll.dll
mov rax, [rax] ; third entry is kernel32.dll
; Step 3: From LDR_DATA_TABLE_ENTRY, get DllBase (offset 0x30)
mov rbx, [rax + 0x20] ; DllBase of kernel32.dll
; Step 4: Parse EAT from DllBase
mov eax, [rbx + 0x3C] ; e_lfanew (offset to NT headers)
add rax, rbx ; absolute address of NT headers
mov edx, [rax + 0x88] ; Export directory RVA (OptHdr + 0x70 on PE32+)
add rdx, rbx ; absolute address of IMAGE_EXPORT_DIRECTORY
; Step 5: Walk ENT/EOT to find function by name hash
; (compare hashed name string vs target hash)
# Python equivalent: manual EAT parsing
def manual_find_export(pe, func_name: str) -> int:
if not hasattr(pe, 'DIRECTORY_ENTRY_EXPORT'):
return 0
exp = pe.DIRECTORY_ENTRY_EXPORT
for export in exp.symbols:
if export.name and export.name.decode() == func_name:
# export.address = RVA of the function
return pe.OPTIONAL_HEADER.ImageBase + export.address
return 0
def ror13_hash(name: str) -> int:
"""ROR13 hash used by many shellcode implementations."""
h = 0
for c in name:
h = (h >> 13 | h << (32 - 13)) & 0xFFFFFFFF
h = (h + ord(c)) & 0xFFFFFFFF
return h
def find_export_by_hash(pe, target_hash: int) -> str:
if not hasattr(pe, 'DIRECTORY_ENTRY_EXPORT'):
return ""
for exp in pe.DIRECTORY_ENTRY_EXPORT.symbols:
if exp.name:
name = exp.name.decode("ascii", errors="ignore")
if ror13_hash(name) == target_hash:
return name
return ""
Listing Exports with Python
import pefile
def list_exports(filepath):
pe = pefile.PE(filepath)
if not hasattr(pe, 'DIRECTORY_ENTRY_EXPORT'):
print("No exports")
pe.close()
return
exp_dir = pe.DIRECTORY_ENTRY_EXPORT
print(f"DLL Name: {exp_dir.name.decode()}")
print(f"Ordinal Base: {exp_dir.struct.Base}")
print(f"Total Functions: {exp_dir.struct.NumberOfFunctions}")
print(f"Named Exports: {exp_dir.struct.NumberOfNames}")
for sym in exp_dir.symbols:
name = sym.name.decode() if sym.name else f"ord_{sym.ordinal}"
addr = sym.address
if sym.forwarder_offset:
# It's a forwarder — address points to a string
fwd = sym.forwarder.decode() if sym.forwarder else "?"
print(f" ord={sym.ordinal:4d} {name} → FORWARD: {fwd}")
else:
print(f" ord={sym.ordinal:4d} RVA={hex(addr)} {name}")
pe.close()
Malware Export Tricks
DLL Side-Loading via Export Match
A malicious DLL placed in a search-order-preferred location (e.g., the application's own directory) must export the same functions as the legitimate DLL it's replacing, or the application will crash. DLL side-loading malware typically: (1) copies exports from the legitimate DLL, (2) implements a few of them as stubs that forward to the real DLL, and (3) implements the target function maliciously. Analyzing the exports of a suspicious DLL and checking whether they match a known legitimate DLL is a useful triage step.
Export-Only Execution Point
A malicious DLL loaded via rundll32 (rundll32.exe target.dll,ExportName) is executed by calling a named export. Malware using this technique has a minimal or single named export that contains the payload. Detecting rundll32.exe with an unusual DLL path or an export that doesn't match any documented function is a reliable detection signal.
Hiding Real Capabilities by Stripping Names
A DLL that exports functions only by ordinal hides its capabilities from casual static analysis. Tools that list exports by name see nothing; ordinal exports require correlating ordinal numbers against known DLL versions. Malware that needs to be loaded as a "plugin" for another tool sometimes strips export names to slow analysis.
Q & A
Why is the Export Name Table sorted alphabetically? Can't you just search linearly?
The ENT is sorted alphabetically so the loader can perform a binary search instead of a linear scan. Performance matters here: system DLLs like ntdll.dll and kernel32.dll export hundreds to thousands of functions. Every process loaded on the system imports from these DLLs. If each import lookup required scanning all 1,000+ export names linearly, process startup would be noticeably slower — especially for processes with many imports (large applications). With a sorted ENT, a binary search finds any export name in O(log n) comparisons — about 10 comparisons for 1,000 exports instead of up to 1,000. The sorted order is enforced by the linker when building the DLL; it's a compile-time arrangement, not sorted at runtime.
What happens if two exports have the same ordinal? Is that possible?
No — ordinals must be unique within a DLL's export table, because the EAT is indexed by ordinal. Two functions with the same ordinal would map to the same EAT slot, and only one could exist. The PE format enforces this through the structure: each EAT slot holds exactly one RVA. However, two names can map to the same ordinal (and thus the same function address). This creates aliases — two different names for the same function. For example, a DLL might export "FunctionV2" as both its canonical name and keep "FunctionV1" as an alias pointing to the same implementation. This is sometimes done during API evolution for backward compatibility.
How does PEB traversal work in x86 (32-bit) vs x64 (64-bit) shellcode?
The structure and offsets differ between 32-bit and 64-bit: In 32-bit mode: fs:[0x30] points to the PEB. PEB + 0x0C is the PEB_LDR_DATA pointer. PEB_LDR_DATA + 0x0C is the InLoadOrderModuleList. Each LDR_DATA_TABLE_ENTRY has the DLL base at offset 0x18. In 64-bit mode: gs:[0x60] points to the PEB. PEB + 0x18 is the PEB_LDR_DATA pointer (8-byte pointer on 64-bit). PEB_LDR_DATA + 0x10 is the InLoadOrderModuleList. Each LDR_DATA_TABLE_ENTRY has the DLL base at offset 0x30. The difference in offsets is because 64-bit structures use 8-byte pointers while 32-bit uses 4-byte pointers. Shellcode for process injection must match the target process's bitness — a 32-bit shellcode injected into a 64-bit process won't work because the PEB layout is different and the 32-bit registers can't hold 64-bit addresses.