DLL Exports and Forwarded Names
Understand how a DLL publishes functions through data directories, names, ordinals, addresses, and forwarded exports.
Cheatsheet for Lesson 5.3
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: ReturningOptionorResultis more accurate than inventing an offset.u32_at:u32_atis the bounded four-byte reader used by the parser;magicidentifies the header format.- forwarder string: If the RVA points back inside the export-directory range, it is a forwarder string instead, such as
KERNELBASE.VirtualQueryExorNTDLL.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
An export table is a DLL’s public index
Section titled “An export table is a DLL’s public index”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.
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 exportResult: 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
Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.
ordinal = 2.
Step 2, line 2. 2 - 1 = 1.
ordinal = 2; index = 1.
Step 3, line 3. There are three entries, indexed zero through two.
ordinal = 2; index = 1; valid = true.
Step 4, line 4. Read the selected entry.
ordinal = 2; index = 1; valid = true; entry = OTHER.RealName.
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.
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.
RVA is not a file offset
Section titled “RVA is not a file offset”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 VirtualAddressfile offset = section PointerToRawData + RVA inside sectionYou 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.
PE32 and PE32+ move the data directories
Section titled “PE32 and PE32+ move the data directories”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.
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.
A forwarded export points to another name
Section titled “A forwarded export points to another name”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:
Name, then ordinal, then address
Name tables: the same row i in both
row 1 gives index 0
Name ordinalsExport address table
functions[0]
functions[0] to [4], RVA in hexGetGold 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.
Every byte from a file is untrusted input
Section titled “Every byte from a file is untrusted input”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:
bytes_atchecks every slice boundary.checked_addcatches integer wraparound.- The optional-header magic chooses the correct layout.
- Section and export counts have teaching-tool limits.
- 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.
Build the complete parser
Section titled “Build the complete parser”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.
parse_export_tableIMAGE_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.Complete lab source: export_inspector.rs
export_inspector.rs
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, §ions);
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, §ions); 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.
Inspect real game files
Section titled “Inspect real game files”Build once:
cd windows-labscargo build --release --bin export_inspectorThen point it at files from your own installation:
.\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.
Questions to answer from the output
Section titled “Questions to answer from the output”- Does the file publish named exports, ordinal-only entries, or neither?
- Which names look like a supported plug-in or library boundary?
- Does an entry point to code/data or to a forwarder string?
- Is the file PE32 or PE32+, and does that match the game build?
- 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.
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?
PE sections can have different raw-file and virtual positions. A bounded section mapping converts an RVA into the correct file location.
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.
Plain Markdown, saved in this browser.
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.