Back to course

Converting RVAs to File Offsets

15 minintermediateModule 3: Addressing & Data DirectoriesContinues from pe-addresses
15 min · Module 3: Addressing & Data Directories · Foundations

Why This Matters

Every tool that inspects a PE off disk hits the same wall: the headers hand you RVAs, but the bytes live at file offsets. The import directory says 0x3A04; xxd wants a byte position. Dumpbin, debuggers, signature checkers, unpackers, malware analyzers — all of them run this conversion thousands of times per file, and all of them break in the same way when it is wrong: they parse whatever bytes happen to sit at the bad offset and produce confidently bogus tables.

The conversion is also the moment the section table earns its keep. Without it, the section table is just names and numbers; with it, the table becomes the translation function between memory and disk.

A Simple Model

You might hope for one subtraction: file = RVA - constant. It cannot work, because each section is padded differently between memory and disk. Sample A proves it with three sections:

SectionVirtualAddressPointerToRawDataVA minus raw (the would-be constant)
.text0x10000x4000xC00
.rdata0x30000x1C000x1400
.data0x50000x30000x2000

Three different deltas in one file. So the model is: find which section owns the RVA, then apply that section's own delta.

  1. Locate the section whose memory span contains the RVA.
  2. Compute delta = RVA - that section's VirtualAddress.
  3. Add delta to that section's PointerToRawData.

The model's missing piece: "memory span" must be measured with max(VirtualSize, SizeOfRawData) — and a delta can land in a region that exists in memory but has no bytes on disk.

The Actual Detail

The algorithm, exactly as a parser should implement it:

input: RVA
if RVA < first section VirtualAddress (0x1000 in Sample A):
    return RVA            (headers map at file offset equal to RVA)
for each section s:
    span = max(s.VirtualSize, s.SizeOfRawData)
    if s.VirtualAddress <= RVA < s.VirtualAddress + span:
        delta = RVA - s.VirtualAddress
        if delta >= s.SizeOfRawData:
            return NO DISK BYTES   # zero-fill tail, memory only
        return s.PointerToRawData + delta
return ERROR              # RVA not owned by any section

Why max(VirtualSize, SizeOfRawData) for the span: VirtualSize is the true in-memory size, SizeOfRawData is the FileAlignment-padded size on disk, and either can be larger. Sample A's .text has VirtualSize 0x17BC but 0x1800 bytes on disk — the extra 0x44 bytes are alignment padding, real file bytes inside the section's disk extent. Sample A's .data runs the other way: VirtualSize 0x648, only 0x200 bytes on disk — the remaining 0x448 bytes are the zero-filled tail (uninitialized data) that exists in memory and nowhere in the file. Using VirtualSize alone wrongly rejects padded-but-present bytes; using SizeOfRawData alone wrongly accepts nothing for shrinking sections. The spec puts it directly: SizeOfRawData "is rounded but the VirtualSize field is not, it is possible for SizeOfRawData to be greater than VirtualSize as well."

Worked example, verified byte for byte: the import directory has RVA 0x3A04. It falls in .rdata (VirtualAddress 0x3000, VirtualSize 0x132C, SizeOfRawData 0x1400, PointerToRawData 0x1C00), since 0x3000 <= 0x3A04 < 0x3000 + 0x1400. Delta = 0x3A04 - 0x3000 = 0xA04, which is less than 0x1400, so file offset = 0x1C00 + 0xA04 = 0x2604.

Common mistakes. Subtracting one global constant — fails for every section after the first, as the delta table above shows. Testing membership with VirtualSize only — misses the alignment padding the spec says is normal. And converting the Security directory at all — its field is already a file offset, so it skips this algorithm entirely (directory index 4).

A Real Example

At file offset 0x2604 — the conversion result for RVA 0x3A04 — sit the first import descriptors, little-endian RVAs throughout:

00002604: e03a 0000 0000 0000 0000 0000 e23d 0000  .:...........=..
00002614: 0030 0000 a03b 0000 0000 0000 0000 0000  .0...;..........

Decode: OriginalFirstThunk = 0x3AE0, TimeDateStamp = 0, ForwarderChain = 0, Name = 0x3DE2, FirstThunk = 0x3000 — the descriptor objdump reports as vma: 00003a04 ... 00003ae0 ... 00003de2 00003000. The conversion landed on exactly the right bytes.

Second conversion, the entry point: RVA 0x1D40 lies in .text (0x1000, raw 0x400, raw size 0x1800). Delta 0xD40 < 0x1800, so file = 0x400 + 0xD40 = 0x1140, where the machine code 48 83 ec 28 waits. Two sections, two deltas, one algorithm.

A case with no disk bytes: RVA 0x5700 sits in .data (VirtualAddress 0x5000, VirtualSize 0x648, SizeOfRawData 0x200). Membership holds (delta 0x700 < 0x648), but delta 0x700 is greater than SizeOfRawData 0x200 — there is no file byte at 0x5700; at runtime the loader zero-fills it. A converter that blindly adds PointerToRawData would read 0x3700, which is .reloc bytes — the classic silent misparse.

Try It

Run the algorithm on Sample A for any RVA:

$ python3 -c "
import struct
d = open('cli-64.exe','rb').read()
secs = []
st = 0x208
for i in range(6):
    o = st + 40*i
    vs, va, sr, pr = struct.unpack_from('<IIII', d, o+8)
    secs.append((d[o:o+8].split(b'\x00')[0], vs, va, sr, pr))
def rva2off(rva):
    for name, vs, va, sr, pr in secs:
        if va <= rva < va + max(vs, sr):
            delta = rva - va
            return None if delta >= sr else pr + delta
    return rva
print(hex(rva2off(0x3A04)))   # 0x2604
print(hex(rva2off(0x1D40)))   # 0x1140
"

Then confirm the bytes by hand — the two checks that never lie:

$ xxd -s 0x2604 -l 32 cli-64.exe
00002604: e03a 0000 0000 0000 0000 0000 e23d 0000  .:...........=..
$ xxd -s 0x1140 -l 16 cli-64.exe
00001140: 4883 ec28 e8d7 0300 0048 83c4 28e9 72fe  H..(.....H..(.r.

Or skip the code entirely: objdump -p cli-64.exe prints every directory RVA, and objdump -h prints each section's file off — do one subtraction by hand to feel the delta change between sections.

Check Your Understanding

Without looking back: why is there no single constant you can subtract from an RVA to get a file offset?

Apply It

Convert entry point RVA 0x1D40 by hand. From the section table: .text has VirtualAddress 0x1000, VirtualSize 0x17BC, SizeOfRawData 0x1800, PointerToRawData 0x400.

The pattern: span test with max(), subtract VirtualAddress, reject the zero-fill tail, add PointerToRawData. Four steps, in that order, every time.

Connect

The algorithm leaned on four fields per section — VirtualAddress, VirtualSize, SizeOfRawData, PointerToRawData — without saying where they come from. They are four of the ten fields in the 40-byte section header, the structure that starts right after the optional header.

Next: The Section Table — the 40-byte record decoded field by field against real bytes, including the eight-character name rule.