Skip to content
Lesson 5.3Executable Maps, Instruction References, and Scans

DLL Exports and Forwarded Names

Understand how a DLL publishes functions through data directories, names, ordinals, addresses, and forwarded exports.

Lesson 5.3
Read or listenPrint chapter or book
About 42 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 5.3Key ideas, reference and questions

A one-page summary of “DLL Exports and Forwarded Names” you can keep beside your notes. Plain Markdown.

Lesson 5.3 — DLL Exports and Forwarded Names: cheatsheet

  • Remember: An export table pairs names with ordinals and addresses. The public ordinal is the ordinal base plus the entry's position, not the position alone.
  • Also useful: Think of a shop's old phone number that now redirects to a new branch: callers still dial it and are passed along. A forwarder does that for a function name.

Key terms

  • Result: Returning Option or Result is more accurate than inventing an offset.
  • u32_at: u32_at is the bounded four-byte reader used by the parser; magic identifies the header format.
  • forwarder string: If the RVA points back inside the export-directory range, it is a forwarder string instead, such as KERNELBASE.VirtualQueryEx or NTDLL.RtlSomething.

Formulas and patterns

  • RVA inside section = target RVA - section VirtualAddress; file offset = section PointerToRawData + RVA inside section

Review

  • Q: When parsing a DLL file on disk, why can an export RVA not always be used directly as a file offset? A: An RVA measures the loaded image, so the section's virtual and raw ranges must be used to convert it
  • Q: Which sentence correctly contrasts an export with an import? A: An export says other images may ask this DLL for a name or ordinal; an import says this program needs one from elsewhere
Download cheatsheet (.md)

An import says, “my program needs this name from another image.” An export says, “other images may ask me for this name or ordinal.”

Windows uses these tables while loading executables and DLLs. Reverse engineers inspect them to understand a game’s dependencies, plug-in interfaces, graphics libraries, and engine boundaries before reading assembly.

An export table connects three parallel ideas:

Piece What it contains
Name pointer table RVAs of public ASCII names such as SDL_Init
Ordinal table Small indexes corresponding to those names
Export address table RVAs of code, data, or forwarder strings

Not every export needs a name. A DLL can export by ordinal only. This beginner tool lists named exports and reports the total number of address entries so the difference stays visible.

An ordinal is not simply a row’s position in the address table. The export directory also stores an ordinal base, commonly 1, and an export’s real public ordinal is ordinal_base + its index in the address table. A caller asking for “ordinal 5” with a base of 1 wants functions[5 - 1], not functions[5]. The parser below reads that base once and adds it back in for every named export so the printed ordinals match what the DLL actually publishes.

functions[0]ordinal 1
functions[1]ordinal 2
functions[2]ordinal 3
functions[3]ordinal 4
functions[4]ordinal 5
With ordinal_base = 1, the export published as “ordinal 5” sits at index 4: functions[5 - 1], not functions[5].

Public ordinal versus address-table index. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

ordinal_base = 1; entries = 3
index = public_ordinal - ordinal_base
check index is within the address table
entry = functions[index]
classify the entry before using it
report an RVA, forwarder, or missing export

Result: ordinal 2 selects OTHER.RealName. Follow the forwarded DLL and name instead of treating its text bytes as code.

Final values: ordinal = 2; index = 1; valid = true; entry = OTHER.RealName; type = forwarder.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.

    ordinal = 2.

  2. Step 2, line 2. 2 - 1 = 1.

    ordinal = 2; index = 1.

  3. Step 3, line 3. There are three entries, indexed zero through two.

    ordinal = 2; index = 1; valid = true.

  4. Step 4, line 4. Read the selected entry.

    ordinal = 2; index = 1; valid = true; entry = OTHER.RealName.

  5. Step 5, line 5. This fixture marks entry one as a string within the export directory, so it is a forwarder.

    ordinal = 2; index = 1; valid = true; entry = OTHER.RealName; type = forwarder.

  6. Step 6, line 6. Result: ordinal 2 selects OTHER.RealName. Follow the forwarded DLL and name instead of treating its text bytes as code.

    ordinal = 2; index = 1; valid = true; entry = OTHER.RealName; type = forwarder.

An RVA, or relative virtual address, measures from the start of the mapped image, regardless of the live base Windows chose. A file offset measures from byte zero in the file on disk.

Sections connect the two coordinate systems:

RVA inside section = target RVA - section VirtualAddress
file offset = section PointerToRawData + RVA inside section

You cannot safely treat every RVA as a file offset. Sections can have different virtual and raw positions, and some virtual bytes have no stored file bytes at all.

The parser accepts an RVA only when it falls inside a section and its distance is smaller than SizeOfRawData. That last check prevents the parser from pretending zero-filled virtual padding exists in the file.

Treat every address-like number as belonging to a named coordinate system:

  • file offsets index the byte slice on disk;
  • RVAs index the mapped image relative to its base;
  • virtual addresses identify locations after the loader chooses a base;
  • table indexes select entries, not bytes.

Do not pass all four around as plain usize values. Small wrapper types such as FileOffset(u32) and Rva(u32) make accidental mixing visible and force conversions through one checked function.

The conversion is a partial function: some valid mapped RVAs have no file bytes, and malformed files may describe overlapping sections. Returning Option or Result is more accurate than inventing an offset.

The optional header is shaped differently for 32-bit PE32 and 64-bit PE32+. The magic value tells us which layout we have:

  • 0x10b: PE32, data directories begin 96 bytes into the optional header;
  • 0x20b: PE32+, data directories begin 112 bytes into the optional header.

Hard-coding one screenshot’s “export table is 0x78 after the PE signature” is brittle because that shortcut mixes header landmarks and assumes one layout.

The original helper assumes the export directory starts 96 bytes into every optional header. That matches PE32, but PE32+ puts it at 112. u32_at is the bounded four-byte reader used by the parser; magic identifies the header format.

Before: assume one optional-header layout
fn export_rva(bytes: &[u8], optional_offset: usize, magic: u16)
-> anyhow::Result<u32>
{
let export_directory = optional_offset + 96; // Assumes PE32.
let export_rva = u32_at(bytes, export_directory)?;
Ok(export_rva)
}

Format-aware parsing chooses the documented landmark for the detected format, rejects unknown formats, and checks the offset calculation before reading. The complete parser also verifies that directory entry zero is declared present.

Improved code

fn export_rva(bytes: &[u8], optional_offset: usize, magic: u16)
-> anyhow::Result<u32>
{
let directory_offset = match magic {
0x10b => 96,
0x20b => 112,
_ => anyhow::bail!("unsupported PE format"),
};
let export_directory = optional_offset.checked_add(directory_offset)
.context("export-directory offset overflowed")?;
let export_rva = u32_at(bytes, export_directory)?;
Ok(export_rva)
}

Show the diff

fn export_rva(bytes: &[u8], optional_offset: usize, magic: u16)
-> anyhow::Result<u32>
{
let export_directory = optional_offset + 96; // Assumes PE32.
let directory_offset = match magic {
0x10b => 96,
0x20b => 112,
_ => anyhow::bail!("unsupported PE format"),
};
let export_directory = optional_offset.checked_add(directory_offset)
.context("export-directory offset overflowed")?;
let export_rva = u32_at(bytes, export_directory)?;
Ok(export_rva)
}

Takeaway: select a format’s layout before calculating a field offset.

The program also checks NumberOfRvaAndSizes before reading directory entry zero. If the field is absent, reading entry zero would cross the file’s declared header.

An export-address entry usually holds an RVA for code or data. If the RVA points back inside the export-directory range, it is a forwarder string instead, such as KERNELBASE.VirtualQueryEx or NTDLL.RtlSomething.

The loader follows that string to another DLL export. This is how Windows can keep a familiar public name while moving the implementation behind it.

Looking a function up by name uses all three tables. The name’s row in the sorted name table selects the same row of the ordinal table, which holds an index into the address table; the entry there is either code or a forwarder:

Find an export by nameAn invented DLL: four sorted names, five address entries, and an export directory at RVA 0x9000 to 0x91FF. Pick a name and an ordinal base.

Name, then ordinal, then address

Name tables: the same row i in both

Names (sorted)
AddGoldi = 0
GetGoldi = 1
Queryi = 2
SetGoldi = 3
Name ordinals
2
0
3
1

Export address table

functions[0] to [4], RVA in hex
1A40
1B10
1C80
9140
1D00
Public ordinal = base + k
1
2
3
4
5
What the entry is
0x1A40 is outside the export directory: code in this DLLpublic ordinal 1 + 0 = 1

GetGold is row 1 of the sorted name table. Row 1 of the ordinal table holds 0, so the address entry is functions[0] = 0x1A40, published as ordinal 1 + 0 = 1.

Even a game DLL you intended to inspect might be truncated, damaged, from the wrong build, or simply not a PE file. A parser should fail with a useful message instead of panicking or allocating several gigabytes because a count field contained nonsense.

This implementation uses five guardrails:

  1. bytes_at checks every slice boundary.
  2. checked_add catches integer wraparound.
  3. The optional-header magic chooses the correct layout.
  4. Section and export counts have teaching-tool limits.
  5. Strings must terminate within 4096 bytes.

Validate from the outside inward. Prove the DOS header range before reading its PE offset, prove the PE and optional-header ranges before reading directories, then prove section and export ranges. A later valid-looking signature cannot make an earlier out-of-bounds read safe.

Before the listing, here is the exact 40-byte structure parse_export_table reads once it has a validated pointer to it, and the order in which it validates its way there.

+0x00(unused)
+0x04(unused)
+0x08(unused)
+0x0CName (RVA)
+0x10Base (ordinal base)
+0x14NumberOfFunctions
+0x18NumberOfNames
+0x1CAddressOfFunctions
+0x20AddressOfNames
+0x24AddressOfNameOrdinals
skipped
read by parse_export_table
The IMAGE_EXPORT_DIRECTORY, all 40 bytes (EXPORT_DIRECTORY_SIZE). parse_export_table skips the first three fields and reads the seven highlighted ones directly by offset.
PE32
PE32+
Check MZ and PE signatures
Read the optional-header magic
0x10b or 0x20b?
directory_offset = 96
directory_offset = 112
Read export_rva, export_size
Parse the section table
into a Vec of Section
file_offset(export_rva)
via rva_to_offset
Read image_name, ordinal_base,
function_count, name_count
For each name: look up ordinals[i],
functions[ordinal], check for a forwarder
Complete lab source: export_inspector.rs

export_inspector.rs

windows-labs/src/bin/ · Rust, 235 lines

Show the codeHide the code
use std::{fs, path::PathBuf};

use anyhow::{Context, ensure};

const PE32_MAGIC: u16 = 0x10b;
const PE32_PLUS_MAGIC: u16 = 0x20b;
const SECTION_HEADER_SIZE: usize = 40;
const EXPORT_DIRECTORY_SIZE: usize = 40;
const MAX_EXPORTS: usize = 100_000;
const MAX_STRING_BYTES: usize = 4_096;

#[derive(Clone, Copy, Debug)]
struct Section {
    virtual_address: u32,
    virtual_size: u32,
    raw_offset: u32,
    raw_size: u32,
}

#[derive(Debug)]
struct NamedExport {
    name: String,
    ordinal: u32,
    rva: u32,
    forwarder: Option<String>,
}

#[derive(Debug)]
struct ExportTable {
    image_name: String,
    function_count: u32,
    named: Vec<NamedExport>,
}

fn bytes_at(bytes: &[u8], offset: usize, length: usize) -> anyhow::Result<&[u8]> {
    let end = offset
        .checked_add(length)
        .context("file offset overflowed")?;
    bytes
        .get(offset..end)
        .with_context(|| format!("file ended before {offset:#x}..{end:#x}"))
}

fn u16_at(bytes: &[u8], offset: usize) -> anyhow::Result<u16> {
    Ok(u16::from_le_bytes(bytes_at(bytes, offset, 2)?.try_into()?))
}

fn u32_at(bytes: &[u8], offset: usize) -> anyhow::Result<u32> {
    Ok(u32::from_le_bytes(bytes_at(bytes, offset, 4)?.try_into()?))
}

fn c_string_at(bytes: &[u8], offset: usize) -> anyhow::Result<String> {
    let available = bytes
        .get(offset..)
        .with_context(|| format!("string offset {offset:#x} was outside the file"))?;
    let search_length = available.len().min(MAX_STRING_BYTES);
    let end = available[..search_length]
        .iter()
        .position(|byte| *byte == 0)
        .context("export string was not terminated within 4096 bytes")?;
    Ok(String::from_utf8_lossy(&available[..end]).into_owned())
}

fn rva_to_offset(
    bytes: &[u8],
    rva: u32,
    size_of_headers: u32,
    sections: &[Section],
) -> anyhow::Result<usize> {
    if rva < size_of_headers {
        let offset = rva as usize;
        bytes_at(bytes, offset, 1)?;
        return Ok(offset);
    }

    for section in sections {
        let span = section.virtual_size.max(section.raw_size);
        let Some(delta) = rva.checked_sub(section.virtual_address) else {
            continue;
        };
        if delta >= span || delta >= section.raw_size {
            continue;
        }
        let offset = section
            .raw_offset
            .checked_add(delta)
            .context("section file offset overflowed")? as usize;
        bytes_at(bytes, offset, 1)?;
        return Ok(offset);
    }

    anyhow::bail!("RVA {rva:#x} does not point to bytes stored in this file")
}

fn parse_export_table(bytes: &[u8]) -> anyhow::Result<ExportTable> {
    ensure!(bytes_at(bytes, 0, 2)? == b"MZ", "missing DOS MZ signature");
    let pe_offset = u32_at(bytes, 0x3c)? as usize;
    ensure!(
        bytes_at(bytes, pe_offset, 4)? == b"PE\0\0",
        "missing PE signature"
    );

    let section_count = u16_at(bytes, pe_offset + 6)? as usize;
    let optional_size = u16_at(bytes, pe_offset + 20)? as usize;
    let optional_offset = pe_offset
        .checked_add(24)
        .context("optional-header offset overflowed")?;
    bytes_at(bytes, optional_offset, optional_size)?;

    let magic = u16_at(bytes, optional_offset)?;
    let (directory_offset, directory_count_offset) = match magic {
        PE32_MAGIC => (96_usize, 92_usize),
        PE32_PLUS_MAGIC => (112_usize, 108_usize),
        other => anyhow::bail!("unsupported optional-header magic {other:#x}"),
    };
    ensure!(
        optional_size >= directory_offset + 8,
        "optional header has no complete export-directory entry"
    );
    ensure!(
        u32_at(bytes, optional_offset + directory_count_offset)? >= 1,
        "optional header reports no data directories"
    );

    let size_of_headers = u32_at(bytes, optional_offset + 60)?;
    let export_rva = u32_at(bytes, optional_offset + directory_offset)?;
    let export_size = u32_at(bytes, optional_offset + directory_offset + 4)?;
    ensure!(
        export_rva != 0 && export_size >= EXPORT_DIRECTORY_SIZE as u32,
        "this image has no export table"
    );

    let section_table_offset = optional_offset
        .checked_add(optional_size)
        .context("section-table offset overflowed")?;
    ensure!(
        section_count <= 96,
        "unreasonable section count: {section_count}"
    );
    let mut sections = Vec::with_capacity(section_count);
    for index in 0..section_count {
        let offset = section_table_offset
            .checked_add(index * SECTION_HEADER_SIZE)
            .context("section-header offset overflowed")?;
        bytes_at(bytes, offset, SECTION_HEADER_SIZE)?;
        sections.push(Section {
            virtual_size: u32_at(bytes, offset + 8)?,
            virtual_address: u32_at(bytes, offset + 12)?,
            raw_size: u32_at(bytes, offset + 16)?,
            raw_offset: u32_at(bytes, offset + 20)?,
        });
    }

    let file_offset = |rva| rva_to_offset(bytes, rva, size_of_headers, &sections);
    let directory = file_offset(export_rva)?;
    bytes_at(bytes, directory, EXPORT_DIRECTORY_SIZE)?;

    let image_name = c_string_at(bytes, file_offset(u32_at(bytes, directory + 12)?)?)?;
    let ordinal_base = u32_at(bytes, directory + 16)?;
    let function_count = u32_at(bytes, directory + 20)?;
    let name_count = u32_at(bytes, directory + 24)?;
    ensure!(
        function_count as usize <= MAX_EXPORTS && name_count as usize <= MAX_EXPORTS,
        "export table is too large for this teaching tool"
    );

    let functions = file_offset(u32_at(bytes, directory + 28)?)?;
    let names = file_offset(u32_at(bytes, directory + 32)?)?;
    let ordinals = file_offset(u32_at(bytes, directory + 36)?)?;
    bytes_at(bytes, functions, function_count as usize * 4)?;
    bytes_at(bytes, names, name_count as usize * 4)?;
    bytes_at(bytes, ordinals, name_count as usize * 2)?;

    let export_end = export_rva
        .checked_add(export_size)
        .context("export-directory RVA range overflowed")?;
    let mut named = Vec::with_capacity(name_count as usize);
    for index in 0..name_count as usize {
        let name_rva = u32_at(bytes, names + index * 4)?;
        let ordinal_index = u16_at(bytes, ordinals + index * 2)? as u32;
        ensure!(
            ordinal_index < function_count,
            "export ordinal index {ordinal_index} is outside the address table"
        );
        let function_rva = u32_at(bytes, functions + ordinal_index as usize * 4)?;
        let forwarder = if (export_rva..export_end).contains(&function_rva) {
            Some(c_string_at(bytes, file_offset(function_rva)?)?)
        } else {
            None
        };
        named.push(NamedExport {
            name: c_string_at(bytes, file_offset(name_rva)?)?,
            ordinal: ordinal_base
                .checked_add(ordinal_index)
                .context("export ordinal overflowed")?,
            rva: function_rva,
            forwarder,
        });
    }

    Ok(ExportTable {
        image_name,
        function_count,
        named,
    })
}

fn main() -> anyhow::Result<()> {
    let path = std::env::args_os()
        .nth(1)
        .map(PathBuf::from)
        .context("usage: export_inspector <game.exe-or-dll>")?;
    let bytes = fs::read(&path).with_context(|| format!("could not read {}", path.display()))?;
    let table = parse_export_table(&bytes)?;

    println!("File:             {}", path.display());
    println!("Image name:       {}", table.image_name);
    println!("Address entries:  {}", table.function_count);
    println!("Named exports:    {}", table.named.len());
    println!();
    println!("Ordinal  RVA         Name / forwarder");
    for export in table.named {
        match export.forwarder {
            Some(target) => println!(
                "{:<8} {:#010x}  {} -> {target}",
                export.ordinal, export.rva, export.name
            ),
            None => println!(
                "{:<8} {:#010x}  {}",
                export.ordinal, export.rva, export.name
            ),
        }
    }
    Ok(())
}
use std::{fs, path::PathBuf};
use anyhow::{Context, ensure};
const PE32_MAGIC: u16 = 0x10b;
const PE32_PLUS_MAGIC: u16 = 0x20b;
const SECTION_HEADER_SIZE: usize = 40;
const EXPORT_DIRECTORY_SIZE: usize = 40;
const MAX_EXPORTS: usize = 100_000;
const MAX_STRING_BYTES: usize = 4_096;
#[derive(Clone, Copy, Debug)]
struct Section {
virtual_address: u32,
virtual_size: u32,
raw_offset: u32,
raw_size: u32,
}
#[derive(Debug)]
struct NamedExport {
name: String,
ordinal: u32,
rva: u32,
forwarder: Option<String>,
}
#[derive(Debug)]
struct ExportTable {
image_name: String,
function_count: u32,
named: Vec<NamedExport>,
}
fn bytes_at(bytes: &[u8], offset: usize, length: usize) -> anyhow::Result<&[u8]> {
// 📏 Every later PE reader funnels through this checked boundary, so a bad
// table entry becomes context-rich data error instead of an index panic.
let end = offset.checked_add(length).context("file offset overflowed")?;
bytes.get(offset..end)
.with_context(|| format!("file ended before {offset:#x}..{end:#x}"))
}
fn u16_at(bytes: &[u8], offset: usize) -> anyhow::Result<u16> {
Ok(u16::from_le_bytes(bytes_at(bytes, offset, 2)?.try_into()?))
}
fn u32_at(bytes: &[u8], offset: usize) -> anyhow::Result<u32> {
Ok(u32::from_le_bytes(bytes_at(bytes, offset, 4)?.try_into()?))
}
fn c_string_at(bytes: &[u8], offset: usize) -> anyhow::Result<String> {
let available = bytes.get(offset..)
.with_context(|| format!("string offset {offset:#x} was outside the file"))?;
// 🛡️ A corrupt file may omit NUL forever; cap the search before scanning.
let search_length = available.len().min(MAX_STRING_BYTES);
let end = available[..search_length].iter()
.position(|byte| *byte == 0)
.context("export string was not terminated within 4096 bytes")?;
Ok(String::from_utf8_lossy(&available[..end]).into_owned())
}
fn rva_to_offset(
bytes: &[u8],
rva: u32,
size_of_headers: u32,
sections: &[Section],
) -> anyhow::Result<usize> {
if rva < size_of_headers {
// 🧭 Header RVAs map directly to the same file offset, but the final byte
// check still proves that the claimed header is present on disk.
let offset = rva as usize;
bytes_at(bytes, offset, 1)?;
return Ok(offset);
}
for section in sections {
// 📏 VirtualSize describes memory, RawSize describes bytes stored on
// disk. The larger span finds the section; RawSize gates actual reads.
let span = section.virtual_size.max(section.raw_size);
let Some(delta) = rva.checked_sub(section.virtual_address) else {
continue;
};
if delta >= span || delta >= section.raw_size {
continue;
}
let offset = section.raw_offset.checked_add(delta)
.context("section file offset overflowed")? as usize;
bytes_at(bytes, offset, 1)?;
return Ok(offset);
}
anyhow::bail!("RVA {rva:#x} does not point to bytes stored in this file")
}
fn parse_export_table(bytes: &[u8]) -> anyhow::Result<ExportTable> {
ensure!(bytes_at(bytes, 0, 2)? == b"MZ", "missing DOS MZ signature");
let pe_offset = u32_at(bytes, 0x3c)? as usize;
ensure!(bytes_at(bytes, pe_offset, 4)? == b"PE\0\0", "missing PE signature");
let section_count = u16_at(bytes, pe_offset + 6)? as usize;
let optional_size = u16_at(bytes, pe_offset + 20)? as usize;
let optional_offset = pe_offset.checked_add(24)
.context("optional-header offset overflowed")?;
bytes_at(bytes, optional_offset, optional_size)?;
let magic = u16_at(bytes, optional_offset)?;
// 🧭 PE32 and PE32+ place data-directory fields at different offsets. The
// magic chooses the layout; the host tool's pointer width is irrelevant.
let (directory_offset, directory_count_offset) = match magic {
PE32_MAGIC => (96_usize, 92_usize),
PE32_PLUS_MAGIC => (112_usize, 108_usize),
other => anyhow::bail!("unsupported optional-header magic {other:#x}"),
};
ensure!(optional_size >= directory_offset + 8,
"optional header has no complete export-directory entry");
ensure!(u32_at(bytes, optional_offset + directory_count_offset)? >= 1,
"optional header reports no data directories");
let size_of_headers = u32_at(bytes, optional_offset + 60)?;
let export_rva = u32_at(bytes, optional_offset + directory_offset)?;
let export_size = u32_at(bytes, optional_offset + directory_offset + 4)?;
ensure!(export_rva != 0 && export_size >= EXPORT_DIRECTORY_SIZE as u32,
"this image has no export table");
let section_table_offset = optional_offset.checked_add(optional_size)
.context("section-table offset overflowed")?;
ensure!(section_count <= 96, "unreasonable section count: {section_count}");
let mut sections = Vec::with_capacity(section_count);
for index in 0..section_count {
let offset = section_table_offset.checked_add(index * SECTION_HEADER_SIZE)
.context("section-header offset overflowed")?;
bytes_at(bytes, offset, SECTION_HEADER_SIZE)?;
sections.push(Section {
virtual_size: u32_at(bytes, offset + 8)?,
virtual_address: u32_at(bytes, offset + 12)?,
raw_size: u32_at(bytes, offset + 16)?,
raw_offset: u32_at(bytes, offset + 20)?,
});
}
let file_offset = |rva| rva_to_offset(bytes, rva, size_of_headers, &sections);
let directory = file_offset(export_rva)?;
bytes_at(bytes, directory, EXPORT_DIRECTORY_SIZE)?;
let image_name = c_string_at(bytes, file_offset(u32_at(bytes, directory + 12)?)?)?;
let ordinal_base = u32_at(bytes, directory + 16)?;
let function_count = u32_at(bytes, directory + 20)?;
let name_count = u32_at(bytes, directory + 24)?;
// 🛡️ Counts come from an untrusted file and drive both allocation and loops.
ensure!(function_count as usize <= MAX_EXPORTS && name_count as usize <= MAX_EXPORTS,
"export table is too large for this teaching tool");
let functions = file_offset(u32_at(bytes, directory + 28)?)?;
let names = file_offset(u32_at(bytes, directory + 32)?)?;
let ordinals = file_offset(u32_at(bytes, directory + 36)?)?;
bytes_at(bytes, functions, function_count as usize * 4)?;
bytes_at(bytes, names, name_count as usize * 4)?;
bytes_at(bytes, ordinals, name_count as usize * 2)?;
let export_end = export_rva.checked_add(export_size)
.context("export-directory RVA range overflowed")?;
let mut named = Vec::with_capacity(name_count as usize);
for index in 0..name_count as usize {
let name_rva = u32_at(bytes, names + index * 4)?;
let ordinal_index = u16_at(bytes, ordinals + index * 2)? as u32;
ensure!(ordinal_index < function_count,
"export ordinal index {ordinal_index} is outside the address table");
let function_rva = u32_at(bytes, functions + ordinal_index as usize * 4)?;
let forwarder = if (export_rva..export_end).contains(&function_rva) {
Some(c_string_at(bytes, file_offset(function_rva)?)?)
} else {
None
};
named.push(NamedExport {
name: c_string_at(bytes, file_offset(name_rva)?)?,
ordinal: ordinal_base.checked_add(ordinal_index)
.context("export ordinal overflowed")?,
rva: function_rva,
forwarder,
});
}
Ok(ExportTable { image_name, function_count, named })
}
fn main() -> anyhow::Result<()> {
let path = std::env::args_os().nth(1).map(PathBuf::from)
.context("usage: export_inspector <game.exe-or-dll>")?;
let bytes = fs::read(&path)
.with_context(|| format!("could not read {}", path.display()))?;
let table = parse_export_table(&bytes)?;
println!("File: {}", path.display());
println!("Image name: {}", table.image_name);
println!("Address entries: {}", table.function_count);
println!("Named exports: {}", table.named.len());
println!();
println!("Ordinal RVA Name / forwarder");
for export in table.named {
match export.forwarder {
Some(target) => println!("{:<8} {:#010x} {} -> {target}",
export.ordinal, export.rva, export.name),
None => println!("{:<8} {:#010x} {}",
export.ordinal, export.rva, export.name),
}
}
Ok(())
}

The page wraps a few lines more tightly than cargo fmt so the whole parser remains readable on a narrow screen. The buildable source is the source of truth.

Build once:

Terminal window
cd windows-labs
cargo build --release --bin export_inspector

Then point it at files from your own installation:

Terminal window
.\target\release\export_inspector.exe `
"C:\Games\Wesnoth\wesnoth.exe"
.\target\release\export_inspector.exe `
"C:\Games\AssaultCube\bin_win32\openal32.dll"
.\target\release\export_inspector.exe `
"C:\Games\UrbanTerror\Quake3-UrT.exe"

An EXE may have no export table, and that is a valid result. DLLs are usually more interesting. Try a Windows DLL such as C:\Windows\System32\kernel32.dll to find forwarded exports, then compare that result with lesson 11.1.

  1. Does the file publish named exports, ordinal-only entries, or neither?
  2. Which names look like a supported plug-in or library boundary?
  3. Does an entry point to code/data or to a forwarder string?
  4. Is the file PE32 or PE32+, and does that match the game build?
  5. Can the same DLL name from two game installations have different exports?

Do not call an export merely because its name looks interesting. A name does not reveal the complete function signature, responsibility for allocation and cleanup, calling convention, or whether the API is intended for outside use.

The complete parser is export_inspector.rs.

Reference: Microsoft’s PE and COFF format specification, especially optional-header data directories, section headers, the export directory, address table, name table, ordinal table, and forwarder RVAs.

Knowledge check

Map an exported function

When parsing a DLL file on disk, why can an export RVA not always be used directly as a file offset?

Finished Lesson 5.3?

Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

Chapter 5: 11 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.