Systems Programming · Binary Formats
PE/COFF Relative Virtual Addresses and the Import Directory
How the Windows loader resolves addresses without a link-time base, what an RVA is relative to, and why the import table is a tree of small structures with no fixed total size.
Portable Executable files name addresses differently from ELF and Mach-O, and the difference is forced by a requirement neither of the others has: a PE image must be loadable at an address that is decided at link time but may be changed at load time, and every reference inside it must survive that change. The format’s answer is the relative virtual address, a value that is an offset from the image base rather than an address in the process. Every internal pointer, every table location, and every entry point in a PE file is expressed this way, and the loader adds the actual base only when it needs a real address.
That indirection has a visible consequence: a PE file contains no absolute virtual addresses at all in its structures, only RVAs, and the bytes that do need to be absolute are patched by the loader from the relocation table. A reader that treats an RVA as a pointer and adds it to the file buffer gets the wrong bytes, because file offsets and RVAs are related only through the section table.
Two headers and the section table
A PE file starts with the old MS-DOS header, whose only surviving purpose is to carry the offset of the real header in its e_lfanew field. The MZ signature at the start and the 64-byte size of the DOS stub exist for compatibility with a time when DOS refused to run files it did not recognise and printed a message embedded in the stub instead. Modern tools read e_lfanew, seek there, and expect the four bytes PE\0\0.
Following the signature is the COFF file header, holding Machine, NumberOfSections, TimeDateStamp, PointerToSymbolTable, NumberOfSymbols, SizeOfOptionalHeader, and Characteristics. Then comes the optional header, whose name is misleading because it is mandatory for images. It begins with a magic value distinguishing PE32 from PE32+, then holds ImageBase, SectionAlignment, FileAlignment, SizeOfImage, SizeOfHeaders, AddressOfEntryPoint, and the data directory array.
SectionAlignment is the granularity at which sections are mapped, typically 4096 bytes, and FileAlignment is the granularity at which sections are stored in the file, typically 512 bytes. The two can differ, which is why an RVA is not simply a file offset plus a constant: the loader rounds each section’s file offset up to FileAlignment and its virtual address up to SectionAlignment, and the mapping between the two is per-section.
Each section header holds a name (eight bytes, NUL-padded rather than NUL-terminated), VirtualSize, VirtualAddress, SizeOfRawData, PointerToRawData, and characteristics flags. The RVA-to-file-offset conversion is: find the section whose VirtualAddress range contains the RVA, subtract that VirtualAddress, and add PointerToRawData. If the result exceeds SizeOfRawData but the RVA is still within VirtualSize, the referenced data is beyond what the file contains: it is zero-filled at load time, which is the PE equivalent of .bss.
/* Convert an RVA to a file offset using the section table. Every read is
bounds-checked against len because the header fields are untrusted. */
#include <stdint.h>
#include <string.h>
static uint32_t rd32(const uint8_t *p) {
uint32_t v; memcpy(&v, p, 4); return v; /* avoids unaligned loads */
}
static uint16_t rd16(const uint8_t *p) {
uint16_t v; memcpy(&v, p, 2); return v;
}
static long rva_to_offset(const uint8_t *buf, size_t len, uint32_t rva) {
if (len < 0x40) return -1;
uint32_t pe = rd32(buf + 0x3c); /* e_lfanew */
if (pe > len - 24) return -1;
uint16_t nsec = rd16(buf + pe + 6);
uint16_t optsz = rd16(buf + pe + 20);
size_t sec = (size_t)pe + 24 + optsz;
if (sec > len || (size_t)nsec * 40 > len - sec) return -1;
for (uint16_t i = 0; i < nsec; i++, sec += 40) {
uint32_t vsize = rd32(buf + sec + 8);
uint32_t va = rd32(buf + sec + 12);
uint32_t rawsz = rd32(buf + sec + 16);
uint32_t raw = rd32(buf + sec + 20);
if (rva >= va && rva - va < (vsize ? vsize : rawsz)) {
uint32_t delta = rva - va;
if (delta >= rawsz) return -1; /* zero-filled tail */
return (long)raw + delta < (long)len ? (long)raw + delta : -1;
}
}
return -1;
}
The vsize ? vsize : rawsz fallback is not superstition. Object files and some linkers emit a zero VirtualSize for sections where SizeOfRawData is the meaningful size, and code that uses only VirtualSize misses those sections entirely.
The image base and what happens when it moves
ImageBase is the address at which the linker expects the image to be mapped: 0x400000 for a typical executable and 0x10000000 for a DLL in the classic 32-bit layout. A PE32+ image uses 64-bit fields and, in practice, a preferred base chosen by the linker from a default policy.
If the loader cannot map the image at its preferred base, because another image already occupies that range, it maps it elsewhere and applies relocations. The .reloc section, whose location is recorded in the base relocation data directory, holds a list of blocks, each covering a 4 KiB page. Every block starts with the page RVA and the block size, followed by 16-bit entries in which the top four bits give the relocation type and the low twelve bits give the offset within the page. The common type on x86 adds the difference between actual and preferred base to the 32-bit field at that location. On x86-64, whose default build uses RIP-relative addressing throughout, the table is often absent entirely, and an image without a .reloc section can only load at its preferred base. The ELF equivalent of this table expresses the same base-relative arithmetic with per-type formulas, as Relocation Entries and the Arithmetic a Linker Performs sets out.
The relocation types are worth knowing because they reveal the addressing model. A 32-bit absolute fixup is the x86 default. A 64-bit fixup exists but is uncommon. High and low adjustment types support the 16-bit halves of a 32-bit address, an artefact of the segmented 16-bit era that persisted into 32-bit PE. Type zero marks padding that needs no fixup but is still listed, which is why a block can contain entries that the loader reads and ignores.
A practical consequence: address-space layout randomisation for PE images is implemented by choosing a different base at load time and relying on the relocation table, or by using a base-independent build. An image that cannot be relocated cannot be randomised, and the loader refuses to apply ASLR to it. The DYNAMICBASE characteristic bit in the optional header’s DllCharacteristics advertises that the image supports relocation.
The import directory is a tree, not a table
The import table is located through data directory entry 1, which gives an RVA to an array of IMAGE_IMPORT_DESCRIPTOR records terminated by an all-zero record. Each descriptor names a DLL by RVA, gives the RVA of two parallel arrays, and holds a timestamp that is zero for a normal bound import.
The two arrays are the Import Lookup Table and the Import Address Table. In the file they usually hold identical content: an array of entries, each either a 31-bit hint and a 31-bit name RVA for a named import, or a single high bit with a 31-bit ordinal for an import by ordinal, terminated by a zero entry. On a 64-bit image the entries are 8 bytes with the high bit in bit 63; on a 32-bit image they are 4 bytes with the high bit in bit 31. The loader reads the lookup table to find the name, resolves it, and writes the resulting address into the corresponding slot of the address table. Code compiled to call an imported function performs an indirect call through that slot, so the address table is the PE analogue of the GOT, and the mechanism mirrors the indirection described in The PLT and GOT Protocol That Defers Symbol Resolution.
The nesting does not stop there. The name RVA points into the import name table, a run of NUL-terminated strings that are not length-prefixed. The DLL name RVA points into the same table. There is no count field anywhere: the array length is discovered by scanning for the zero terminator, and the string length by scanning for a NUL byte. Every one of those scans is a potential unbounded read on a malformed file, and every offset is an RVA that must be converted through the section table before the bytes can be touched. A reader must bound each scan by the enclosing section’s file extent, not by the file’s total size, because an RVA that falls in the zero-filled tail of a section is valid as an address and invalid as file content. The cursor discipline that makes such checks uniform is set out in Parsing Untrusted Binaries Without Trusting Their Length Fields.
A further wrinkle: the same name can appear in the lookup table and in the address table, and in a bound image the address table holds pre-resolved addresses whose high bit may be set to indicate a forwarded or bound entry. A tool that reads the address table to discover import names produces garbage on bound images, which is why the lookup table exists separately and why readers should prefer it.
Delay loading and the second import table
A delay-load import directory, located through data directory entry 13, describes imports that are resolved on first call rather than at load time. Its structure parallels the normal import directory but adds fields for a module handle and for the address of a delay-load helper. The linker generates a stub that checks whether the address table slot has been filled; if not, it calls the helper, which loads the DLL and resolves the imports, then transfers control.
The mechanism is a PE-level reimplementation of lazy binding, with the difference that the decision is per-DLL rather than per-symbol and is made by the linker rather than by an environment variable. Its practical value is startup cost: a plugin that uses a large library only for an uncommon feature can defer loading that library until the feature is used, and a program can start successfully even when the DLL is absent, as long as the delayed import is never called.
The failure mode is that the delay is invisible at the call site. A program that appears to start correctly may fault on a path that has never been exercised, with the diagnostic pointing at the delay-load helper rather than at the missing library. Tools that list imports from the normal directory alone report an incomplete picture, and a dependency checker that ignores directory 13 will miss a required DLL entirely.
Exports, forwarders, and the ordinal namespace
The export directory, data directory entry 0, holds the DLL name, an ordinal base, three parallel arrays, and a count. The arrays are the export address table indexed by ordinal, the export name pointer table, and the export ordinal table mapping each name to an index in the address table. Names are not sorted by ordinal but are sorted lexicographically, which allows the loader to binary-search by name while still resolving by ordinal in constant time.
An export address entry whose RVA falls inside the export directory’s own range is not code but a forwarder string of the form OTHERDLL.Function. The loader follows the forward and resolves the symbol in the named DLL, which is how a system DLL can present a stable public surface while the implementation lives elsewhere. A reader that treats every export address as a function RVA will misreport forwarders as entry points inside the exporting DLL.
Ordinals matter more on Windows than symbol indices do in ELF, because a DLL can export functions by ordinal only, with no name at all. The ordinal base allows a DLL to begin its numbering at an arbitrary value, historically to reserve low numbers for system use. An import by ordinal is resolved by index arithmetic rather than by name lookup, which makes it faster and makes it fail differently: a missing ordinal produces an error that names the number, with no name to search for.
Reading a PE file safely
Three properties of the format make safe parsing harder than the equivalent task for ELF. There is no single table whose size bounds all others, so each directory entry must be validated independently against the section it lands in. There are no counts for most arrays, so termination depends on sentinel values that may be absent in a truncated file. And the header’s own claims about section count and optional header size are read from the file being validated, so every derived offset must be re-checked against the actual buffer length.
The checksum field in the optional header is not a security mechanism: it is computed by a specific algorithm over the file with the checksum field zeroed, and the loader verifies it only for drivers and a few other categories. A corrupted user-mode executable loads without complaint if its sections and imports are internally consistent, which is why integrity checks on PE files are performed by code signing rather than by the format.
References
- PE Format — Microsoft Learn, Windows App Development
- Microsoft PE and COFF Specification — Microsoft Corporation
- Common Object File Format (COFF) — Microsoft Corporation
- Peering Inside the PE: A Tour of the Win32 Portable Executable File Format — Matt Pietrek, Microsoft Systems Journal, 1994