Chapter 8

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

Scenario

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

FieldDescription
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 TypeHow to CallHow 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.