File Offsets, RVAs, and Live Addresses
Connect positions in a PE file to relative virtual addresses and the addresses Windows gives its loaded modules.
Cheatsheet for Lesson 5.2
A one-page summary of “File Offsets, RVAs, and Live Addresses” you can keep beside your notes. Plain Markdown.
Lesson 5.2 — File Offsets, RVAs, and Live Addresses: cheatsheet
- Remember: File offset, RVA and live address are coordinates in different spaces. Convert between them with the section table and the module base.
- Also useful: Think of a cookbook that says add a cup of water: the water is not printed, the cook supplies it. The loader supplies the zero bytes the same way.
Key terms
SizeOfHeaders: Header RVAs belowSizeOfHeadersare a special case that commonly map directly to the same file offset.
Formulas and patterns
offset_in_section = RVA - V;file_offset = R + offset_in_section
Review
- Q: A symbol has RVA
0x2500and the module loaded at0x7FF600000000. How is its live address found? A: Add the RVA to the live module base - Q: What is the difference between the preferred image base and the live module base? A: Preferred: where the file asks to load; live: where Windows put it this run
Keep four address terms separate
Section titled “Keep four address terms separate”The PE map in Lesson 5.1 told us that an instruction can have one position in the EXE file and another in a running process. Suppose your debugger recorded an instruction at 0x007CCD91 yesterday. To find it after restarting the game, you need to know which part of that number belonged to the module and which part Windows chose for that run.
We will use four names for the four different positions involved:
- a preferred image base is where the file would like to be loaded;
- a live module base is where Windows placed it in this process run;
- an RVA is a distance from the beginning of the module;
- a virtual address is the final live address used by the CPU.
An RVA is not a pointer stored in the game. It is arithmetic describing a location inside one loaded image. The PE file’s section table maps RVAs to sections such as code or data.
Do not confuse an RVA with a file offset. A file offset counts bytes in the EXE on disk. An RVA counts from the mapped image base after Windows has arranged sections according to virtual addresses and alignment. They can differ.
The complete vocabulary is worth making explicit:
| Name | Coordinate system | Typical formula |
|---|---|---|
| File offset | Bytes from the start of the file on disk | section.raw_ptr + offset_in_section |
| RVA | Bytes from the start of the mapped image | section.virtual_address + offset_in_section |
| Preferred VA | Address requested by the PE headers | preferred_image_base + RVA |
| Live VA | Address in this process now | live_module_base + RVA |
These are coordinates in different spaces. Adding a live module base to a file offset is the binary-analysis version of adding meters to seconds: the numbers fit in an integer, but the operation has no useful meaning.
Map an RVA back to bytes on disk
Section titled “Map an RVA back to bytes on disk”To translate an RVA to a file offset, first find the PE section whose virtual
range contains it. For a section with virtual start V, raw file start R,
virtual size VS, and raw size RS:
offset_in_section = RVA - Vfile_offset = R + offset_in_sectionThe mapping is valid only when the RVA lies in the section’s mapped range and
offset_in_section < RS. A section can have more virtual bytes than raw bytes;
the loader fills that tail with zeros, so no corresponding file byte exists.
Conversely, alignment padding on disk is not necessarily meaningful mapped
memory. Header RVAs below SizeOfHeaders are a special case that commonly map
directly to the same file offset.
Example section:
VirtualAddress = 0x2000VirtualSize = 0x0900PointerToRawData = 0x0600SizeOfRawData = 0x0800RVA 0x2340 is 0x340 bytes into the section, so its file offset is
0x0600 + 0x0340 = 0x0940. RVA 0x2880 is mapped inside the virtual tail but
is beyond the 0x800 raw bytes; it represents zero-filled memory rather than a
byte stored in the file.
1 The two bars, drawn to scale
File section: 0x800 bytes at file offset 0x0600
Fileoffset 0x06000x0E00file bytesMapped range: 0x900 bytes at RVA 0x2000
ImageRVA 0x20000x2900backed by the file0x100 zerosThe mapped range is longer than the stored bytes by 0x100.
2 Offset 0x340 into the section
File section: 0x800 bytes at file offset 0x0600
Fileoffset 0x06000x0E00offset 0x0940Mapped range: 0x900 bytes at RVA 0x2000
ImageRVA 0x20000x2900RVA 0x2340The same distance from the start gives file offset 0x0600 + 0x340 = 0x0940 and RVA 0x2000 + 0x340 = 0x2340.
3 RVA 0x2880 falls in the zero tail
File section: 0x800 bytes at file offset 0x0600
Fileoffset 0x06000x0E00file bytes end hereno file byteMapped range: 0x900 bytes at RVA 0x2000
ImageRVA 0x20000x2900backed by the fileRVA 0x2880 is in the zero tailThat RVA is 0x880 into the range, past the 0x800 stored bytes, so it has no file byte at all.
The file stores 0x800 bytes starting at offset 0x0600. The image maps 0x900 bytes starting at RVA 0x2000. The last 0x100 are zeros.
Follow the 0x2340 branch through the section mapping. Subtraction finds a
distance inside the section; addition then expresses it in the file’s
coordinate system:
Two bars drawn to the same scale: the loaded section covering RVAs 0x2000 to 0x2900 with a zero-filled tail after 0x2800, and the file’s raw data from 0x0600 to 0x0E00. RVA 0x2340 is 0x0340 into the section; adding that to 0x0600 gives file offset 0x0940. RVA 0x2880 is 0x0880 in, beyond the raw data, so it has no file byte.
Read the steps
- A section is stored in the file and also mapped into memory, and the two places are numbered differently. Its row says: RVA range [0x2000, 0x2900), 0x0800 bytes of raw data, stored from file offset 0x0600.
- Start with an RVA: 0x2340. It is a distance from the mapped image’s base, not a position in the file. It lies inside the section’s virtual range.
- Find how far into the section it is: 0x2340 − 0x2000 = 0x0340. That distance is the same in memory and in the file’s raw data.
- Check that the file has a byte there: SizeOfRawData is 0x0800, and 0x0340 is below it.
- Carry the same distance to the file: PointerToRawData 0x0600 + 0x0340 = 0x0940. That is where the byte lives on disk.
- A different RVA, 0x2880, fails the check. Its distance into the section is 0x0880, which is not below 0x0800: it falls in the zero-filled tail that exists only in memory.
The other illustrated RVA, 0x2880, fails the raw-byte check: its 0x880 section offset lies in the zero-filled virtual tail.
This simulation needs JavaScript. The lesson text around it works through the same numbers.
A rigorous parser checks every subtraction and addition, validates section ranges against the actual file length, and rejects overlapping or impossible metadata. PE headers describe a file; they do not deserve blind trust merely because Windows-shaped field names were decoded.
Resolving an RVA is useful only when the build identity and the live module range are part of the calculation:
ASLR, Address Space Layout Randomization, changes the live base, while a game update can change the RVA. Checking both facts keeps an old note from turning into a believable but wrong address.
Why an address moves
Section titled “Why an address moves”Old notes often show an address such as 0x007CCD91. That may be the address from one run, not a permanent location.
Windows can load an executable at a different base address each run; that is what ASLR randomizes. The useful part that usually stays the same for an exact build is the distance from the module’s beginning: the Relative Virtual Address.
ASLR makes fixed absolute addresses less predictable. Windows can relocate an image when its PE metadata supports relocation. The module’s internal layout for that exact file usually stays the same, so base-plus-RVA reaches the same instruction even when the base changes.
live virtual address = live module base + RVARVA = observed address - observed module baseThe useful invariant is algebraic: for one exact module build, an internal location’s displacement from the module base can remain stable even when the base changes. ASLR changes the first term, not the meaning of the second:
run A: base A + same RVA = address Arun B: base B + same RVA = address BThe RVA is stable only relative to that image layout. Recompiling or updating the game can move the instruction inside the module, so the build identity must travel with the RVA.
Small wrapper types can stop coordinate-space mistakes before runtime:
Optional: practise keeping the RVA inside its image before forming a live address.
#[derive(Clone, Copy, Debug)]struct Rva(u32);
#[derive(Clone, Copy, Debug)]struct LiveAddress(usize);
fn resolve_rva(base: LiveAddress, image_size: usize, rva: Rva) -> Option<LiveAddress> { let offset = rva.0 as usize; if offset >= image_size { return None; } base.0.checked_add(offset).map(LiveAddress)}Both values are integers internally, but the type names preserve their units. The function also states two proof obligations: the RVA belongs to this image, and addition cannot overflow the address type.
Derive the Wesnoth example
Section titled “Derive the Wesnoth example”For the 32-bit Wesnoth 1.14.9 course build, the original disassembly records the instruction at 0x007CCD91. If the module was loaded at 0x00400000 during that capture, subtracting the base gives the RVA; adding a different run’s base back to that same RVA gives that run’s own live address:
Never assume the example base. Measure the module in the run you are actually debugging.
Ask Windows where the module lives
Section titled “Ask Windows where the module lives”The shared Process wrapper uses CreateToolhelp32Snapshot, Module32FirstW, and Module32NextW from the windows crate. The chapter tool becomes small because those unsafe Windows boundaries already live in one reviewed module:
#[cfg(windows)]fn main() -> anyhow::Result<()> { use anyhow::Context; use gha_windows_labs::Process;
let mut arguments = std::env::args().skip(1); let process_name = arguments.next().unwrap_or_else(|| "wesnoth.exe".to_owned()); let module_name = arguments.next().unwrap_or_else(|| process_name.clone()); let requested_rva = arguments .next() .map(|text| usize::from_str_radix(text.trim_start_matches("0x"), 16)) .transpose() .context("RVA must be hexadecimal, such as 3CCD91")?;
let process = Process::open_by_name(&process_name, false)?; let (base, size) = process.module(&module_name)?; let end = base.checked_add(size).context("module range overflowed")?;
println!("{} (PID {})", process.name(), process.id()); println!(" live base: {base:#010x}"); println!(" size: {size:#010x}"); println!(" end: {end:#010x}");
if let Some(rva) = requested_rva { anyhow::ensure!(rva < size, "RVA {rva:#x} is outside the module"); let address = base.checked_add(rva).context("base + RVA overflowed")?; println!(" {base:#010x} + {rva:#010x} = {address:#010x}"); } Ok(())}The two guards matter. An RVA outside the module is probably for the wrong version, and an overflowing addition is never a valid address.
ToolHelp returns information about modules in the chosen process. The snapshot is still a moment-in-time view: a DLL can unload after enumeration. For the course EXE this is stable during the short calculation, but reusable tooling should handle a later read failure normally.
Run it against the course build
Section titled “Run it against the course build”Start a local Wesnoth match, then run:
.\target\i686-pc-windows-msvc\release\module_inspector.exe ` wesnoth.exe wesnoth.exe 3CCD91Open the printed live address in x64dbg. The bytes and disassembly must match the lesson before you use the address.
Repeat the process after restarting Wesnoth. If the base changes but base + RVA still reaches the expected instruction, you have separated stable location information from one-run information.
The buildable tool is module_inspector.rs, and its ToolHelp implementation is in process.rs.
module_inspector.rs
Show the codeHide the code
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
use anyhow::Context;
use gha_windows_labs::Process;
let mut arguments = std::env::args().skip(1);
let process_name = arguments.next().unwrap_or_else(|| "wesnoth.exe".to_owned());
let module_name = arguments.next().unwrap_or_else(|| process_name.clone());
let requested_rva = arguments
.next()
.map(|text| usize::from_str_radix(text.trim_start_matches("0x"), 16))
.transpose()
.context("RVA must be hexadecimal, such as 7CCD91")?;
let process = Process::open_by_name(&process_name, false)?;
let (base, size) = process.module(&module_name)?;
let end = base.checked_add(size).context("module range overflowed")?;
println!("{} (PID {})", process.name(), process.id());
println!(" module: {module_name}");
println!(" live base: {base:#010x}");
println!(" size: {size:#010x}");
println!(" end: {end:#010x}");
if let Some(rva) = requested_rva {
anyhow::ensure!(rva < size, "RVA {rva:#x} is outside the module");
let address = base.checked_add(rva).context("base + RVA overflowed")?;
println!(" {base:#010x} + {rva:#010x} = {address:#010x}");
}
Ok(())
}
#[cfg(not(windows))]
fn main() {
eprintln!("This live module inspector uses ToolHelp and must run on Windows.");
}process.rs
Show the codeHide the code
use std::{ffi::c_void, mem::size_of};
use anyhow::{Context, Result};
use windows::Win32::{
Foundation::ERROR_NO_MORE_FILES,
System::{
Diagnostics::{
Debug::{FlushInstructionCache, ReadProcessMemory, WriteProcessMemory},
ToolHelp::{
CreateToolhelp32Snapshot, MODULEENTRY32W, Module32FirstW, Module32NextW,
PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPMODULE,
TH32CS_SNAPMODULE32, TH32CS_SNAPPROCESS,
},
},
Memory::{
MEM_COMMIT, MEMORY_BASIC_INFORMATION, PAGE_EXECUTE, PAGE_EXECUTE_READ,
PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS,
PAGE_PROTECTION_FLAGS, PAGE_READONLY, PAGE_READWRITE, PAGE_WRITECOPY, VirtualProtectEx,
VirtualQueryEx,
},
SystemInformation::{
IMAGE_FILE_MACHINE, IMAGE_FILE_MACHINE_I386, IMAGE_FILE_MACHINE_UNKNOWN,
},
Threading::{
IsWow64Process2, OpenProcess, PROCESS_ACCESS_RIGHTS, PROCESS_QUERY_INFORMATION,
PROCESS_VM_OPERATION, PROCESS_VM_READ, PROCESS_VM_WRITE,
},
},
};
use super::OwnedHandle;
#[derive(Clone, Debug)]
pub struct ProcessEntry {
pub id: u32,
pub name: String,
}
#[derive(Clone, Copy, Debug)]
pub struct MemoryRegion {
pub base: usize,
pub size: usize,
pub readable: bool,
pub writable: bool,
pub executable: bool,
}
#[derive(Debug)]
pub struct Process {
handle: OwnedHandle,
id: u32,
name: String,
}
fn wide_text(buffer: &[u16]) -> String {
let end = buffer
.iter()
.position(|unit| *unit == 0)
.unwrap_or(buffer.len());
String::from_utf16_lossy(&buffer[..end])
}
fn no_more_files(error: &windows::core::Error) -> bool {
error.code() == ERROR_NO_MORE_FILES.to_hresult()
}
impl Process {
/// Lists every running process with ToolHelp.
pub fn list() -> Result<Vec<ProcessEntry>> {
// SAFETY: no borrowed pointers are passed; the returned snapshot is owned below.
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }?;
let snapshot = OwnedHandle::from_raw(snapshot)?;
let mut raw = PROCESSENTRY32W {
dwSize: size_of::<PROCESSENTRY32W>() as u32,
..Default::default()
};
// SAFETY: `raw` is correctly sized and writable for the duration of the call.
unsafe { Process32FirstW(snapshot.raw(), &mut raw) }?;
let mut processes = Vec::new();
loop {
processes.push(ProcessEntry {
id: raw.th32ProcessID,
name: wide_text(&raw.szExeFile),
});
// SAFETY: `raw` remains a valid PROCESSENTRY32W output buffer.
match unsafe { Process32NextW(snapshot.raw(), &mut raw) } {
Ok(()) => {}
Err(error) if no_more_files(&error) => break,
Err(error) => return Err(error.into()),
}
}
Ok(processes)
}
pub fn find(name: &str) -> Result<ProcessEntry> {
Self::list()?
.into_iter()
.find(|entry| entry.name.eq_ignore_ascii_case(name))
.with_context(|| format!("process {name:?} is not running"))
}
pub fn open_by_name(name: &str, allow_write: bool) -> Result<Self> {
let entry = Self::find(name)?;
Self::open(entry, allow_write)
}
pub fn open(entry: ProcessEntry, allow_write: bool) -> Result<Self> {
let mut access: PROCESS_ACCESS_RIGHTS = PROCESS_QUERY_INFORMATION | PROCESS_VM_READ;
if allow_write {
access |= PROCESS_VM_OPERATION | PROCESS_VM_WRITE;
}
Self::open_with_access(entry, access)
}
pub fn open_with_access(entry: ProcessEntry, access: PROCESS_ACCESS_RIGHTS) -> Result<Self> {
// SAFETY: the PID is a value; inheritance is disabled; `OwnedHandle` owns the result.
let handle = unsafe { OpenProcess(access, false, entry.id) }?;
Ok(Self {
handle: OwnedHandle::from_raw(handle)?,
id: entry.id,
name: entry.name,
})
}
pub fn is_32_bit(&self) -> Result<bool> {
let mut process_machine = IMAGE_FILE_MACHINE::default();
let mut native_machine = IMAGE_FILE_MACHINE::default();
// SAFETY: both output pointers remain valid for the call.
unsafe {
IsWow64Process2(
self.handle.raw(),
&mut process_machine,
Some(&mut native_machine),
)?;
}
Ok(process_machine == IMAGE_FILE_MACHINE_I386
|| (process_machine == IMAGE_FILE_MACHINE_UNKNOWN
&& native_machine == IMAGE_FILE_MACHINE_I386))
}
pub fn id(&self) -> u32 {
self.id
}
pub fn name(&self) -> &str {
&self.name
}
pub fn raw_handle(&self) -> windows::Win32::Foundation::HANDLE {
self.handle.raw()
}
pub fn read_exact(&self, address: usize, output: &mut [u8]) -> Result<()> {
let mut bytes_read = 0_usize;
// SAFETY: `output` is writable for its length. Windows validates the remote range.
unsafe {
ReadProcessMemory(
self.handle.raw(),
address as *const c_void,
output.as_mut_ptr().cast(),
output.len(),
Some(&mut bytes_read),
)?;
}
anyhow::ensure!(bytes_read == output.len(), "short read at {address:#x}");
Ok(())
}
pub fn read_bytes(&self, address: usize, count: usize) -> Result<Vec<u8>> {
let mut bytes = vec![0_u8; count];
self.read_exact(address, &mut bytes)?;
Ok(bytes)
}
pub fn read_u32(&self, address: usize) -> Result<u32> {
let bytes: [u8; 4] = self
.read_bytes(address, 4)?
.try_into()
.expect("the read length is exactly four");
Ok(u32::from_le_bytes(bytes))
}
pub fn read_f32(&self, address: usize) -> Result<f32> {
Ok(f32::from_bits(self.read_u32(address)?))
}
pub fn write_exact(&self, address: usize, bytes: &[u8]) -> Result<()> {
let mut bytes_written = 0_usize;
// SAFETY: `bytes` is readable for its length. Windows validates the remote range.
unsafe {
WriteProcessMemory(
self.handle.raw(),
address as *const c_void,
bytes.as_ptr().cast(),
bytes.len(),
Some(&mut bytes_written),
)?;
}
anyhow::ensure!(bytes_written == bytes.len(), "short write at {address:#x}");
Ok(())
}
pub fn write_u32(&self, address: usize, value: u32) -> Result<()> {
self.write_exact(address, &value.to_le_bytes())
}
/// Writes machine code, flushes the CPU instruction cache, and restores protection.
pub fn write_code(&self, address: usize, bytes: &[u8]) -> Result<()> {
let mut old = PAGE_PROTECTION_FLAGS::default();
// SAFETY: Windows validates the target range and writes `old` before returning.
unsafe {
VirtualProtectEx(
self.handle.raw(),
address as *const c_void,
bytes.len(),
PAGE_EXECUTE_READWRITE,
&mut old,
)?;
}
let write_result = self.write_exact(address, bytes);
if write_result.is_ok() {
// SAFETY: the process handle is live and the written range is the one above.
unsafe {
FlushInstructionCache(
self.handle.raw(),
Some(address as *const c_void),
bytes.len(),
)?;
}
}
let mut ignored = PAGE_PROTECTION_FLAGS::default();
// SAFETY: this restores the protection returned by the first call.
let restore_result = unsafe {
VirtualProtectEx(
self.handle.raw(),
address as *const c_void,
bytes.len(),
old,
&mut ignored,
)
};
write_result?;
restore_result?;
Ok(())
}
pub fn module(&self, wanted_name: &str) -> Result<(usize, usize)> {
// Include SNAPMODULE32 so a 64-bit tool can list a 32-bit game module.
// SAFETY: the PID is valid and the returned snapshot is owned below.
let snapshot =
unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, self.id) }?;
let snapshot = OwnedHandle::from_raw(snapshot)?;
let mut raw = MODULEENTRY32W {
dwSize: size_of::<MODULEENTRY32W>() as u32,
..Default::default()
};
// SAFETY: `raw` is correctly sized and writable.
unsafe { Module32FirstW(snapshot.raw(), &mut raw) }?;
loop {
if wide_text(&raw.szModule).eq_ignore_ascii_case(wanted_name) {
return Ok((raw.modBaseAddr as usize, raw.modBaseSize as usize));
}
// SAFETY: `raw` remains a valid output buffer.
match unsafe { Module32NextW(snapshot.raw(), &mut raw) } {
Ok(()) => {}
Err(error) if no_more_files(&error) => break,
Err(error) => return Err(error.into()),
}
}
anyhow::bail!("module {wanted_name:?} was not found in {}", self.name)
}
pub fn regions(&self, start: usize, end: usize) -> Result<Vec<MemoryRegion>> {
let mut current = start;
let mut regions = Vec::new();
while current < end {
let mut raw = MEMORY_BASIC_INFORMATION::default();
// SAFETY: `raw` is a valid output structure; Windows validates the address.
let returned = unsafe {
VirtualQueryEx(
self.handle.raw(),
Some(current as *const c_void),
&mut raw,
size_of::<MEMORY_BASIC_INFORMATION>(),
)
};
if returned == 0 {
break;
}
let protection = raw.Protect;
let blocked = protection.contains(PAGE_GUARD) || protection.contains(PAGE_NOACCESS);
let readable = !blocked
&& raw.State == MEM_COMMIT
&& [
PAGE_READONLY,
PAGE_READWRITE,
PAGE_WRITECOPY,
PAGE_EXECUTE_READ,
PAGE_EXECUTE_READWRITE,
PAGE_EXECUTE_WRITECOPY,
]
.iter()
.any(|flag| protection.contains(*flag));
let writable = readable
&& [
PAGE_READWRITE,
PAGE_WRITECOPY,
PAGE_EXECUTE_READWRITE,
PAGE_EXECUTE_WRITECOPY,
]
.iter()
.any(|flag| protection.contains(*flag));
let executable = !blocked
&& [
PAGE_EXECUTE,
PAGE_EXECUTE_READ,
PAGE_EXECUTE_READWRITE,
PAGE_EXECUTE_WRITECOPY,
]
.iter()
.any(|flag| protection.contains(*flag));
let base = raw.BaseAddress as usize;
let size = raw.RegionSize;
regions.push(MemoryRegion {
base,
size,
readable,
writable,
executable,
});
let next = base.checked_add(size).context("memory map overflowed")?;
anyhow::ensure!(next > current, "memory map did not advance");
current = next;
}
Ok(regions)
}
}RVA is not a version check
Section titled “RVA is not a version check”An RVA can move when the game is rebuilt. Keep all three checks:
- exact supported game version;
- live module base plus the version’s RVA;
- exact original bytes or a unique pattern at the final address.
That is why the code-cave installers reject unknown bytes instead of hoping the address is close enough.
References: Microsoft PE format and GetModuleHandleW.
Relocate the symbol
A symbol has RVA 0x2500 and the module loaded at 0x7FF600000000. How is its live address found?
An RVA is a distance from the image base. ASLR changes the live base, while the RVA remains tied to that exact module build.
Finished Lesson 5.2?
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.