//! Turning `/proc//maps` lines into a usable module table, and turning an //! address back into `module+offset`. //! //! # The Wine mapping gotcha this module exists to work around //! //! Under Wine, only a PE's 4 KiB header stays file-backed. Wine copies every //! section into ANONYMOUS memory. So `grep CardsDLL /proc//maps` returns //! exactly one line, 4 KiB long, and a module table built naively from path //! grouping will report CardsDLL as a 4 KiB module. It is really 0x31d000 bytes. //! An agent who trusts the maps extent concludes the module is "barely mapped" //! and gives up, or computes a wrong module size and mis-attributes every hit. //! //! The fix: read `SizeOfImage` out of the live PE header at the module base. //! That field is authoritative for the module's real extent, and the header is //! the one part of the image that is reliably readable. //! //! # Deriving the slide automatically //! //! Wine rewrites the `ImageBase` field of the *live* header to the actual load //! address, so the live header cannot tell us where the module wanted to load. //! The on-disk file still can, and the maps line gives us its path. Reading the //! on-disk `ImageBase` and subtracting gives the relocation slide: //! //! ```text //! slide = live_base - disk_image_base //! live_va = static_va + slide //! ``` //! //! For CardsDLL that is `0x6ffffc140000 - 0x180000000 = 0x6ffe7c140000`, the //! number every Ghidra-derived address in this project has to be adjusted by. //! Printing it removes the most error-prone manual step in the workflow. use crate::maps::Region; use crate::mem::ProcMem; use std::fs; #[derive(Debug, Clone)] pub struct Module { /// Bare file name, e.g. `CardsDLL_Win64_retail.dll`. pub name: String, pub path: String, /// Lowest mapped address carrying this path. For a PE this is the header. pub base: u64, /// Highest address still carrying this path in the maps. Badly understates /// the truth under Wine; see the module docs. pub maps_end: u64, /// Number of separate maps lines mentioning this path. pub region_count: usize, /// `SizeOfImage` from the live PE header, the real extent. pub size_of_image: Option, /// `ImageBase` from the on-disk file: where the module was linked to load. pub disk_image_base: Option, } impl Module { /// Best available end address: PE-derived when we have it, maps otherwise. pub fn end(&self) -> u64 { match self.size_of_image { Some(size) => self.base + size, None => self.maps_end, } } /// The relocation slide: add this to a static (Ghidra) VA to get a live VA. pub fn slide(&self) -> Option { self.disk_image_base .map(|disk| self.base as i128 - disk as i128) } /// Is this actually a PE image, as opposed to a device node, font or `.nls` /// data file that merely happens to be mapped? pub fn is_pe(&self) -> bool { self.size_of_image.is_some() } /// Only PE images claim an address range. /// /// Without the `is_pe` guard this mis-attributes badly. `/dev/nvidia0` is /// mapped at many scattered addresses, so its min..max span covers gigabytes /// of unrelated anonymous memory, and every hit in there would be reported /// as `nvidia0+0x...`. A non-PE mapping only ever owns the exact regions /// listed for it in the maps, which `describe` handles as a fallback. pub fn contains(&self, va: u64) -> bool { self.is_pe() && va >= self.base && va < self.end() } } /// Little-endian scalar helpers. Returning `Option` keeps a truncated or /// malformed header from panicking the whole run. fn u16_at(buf: &[u8], off: usize) -> Option { buf.get(off..off + 2) .map(|s| u16::from_le_bytes([s[0], s[1]])) } fn u32_at(buf: &[u8], off: usize) -> Option { buf.get(off..off + 4) .map(|s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) } fn u64_at(buf: &[u8], off: usize) -> Option { buf.get(off..off + 8) .map(|s| u64::from_le_bytes([s[0], s[1], s[2], s[3], s[4], s[5], s[6], s[7]])) } /// `SizeOfImage` and `ImageBase` from a PE header blob. /// /// Layout: `e_lfanew` at 0x3c points at the `PE\0\0` signature; the 20-byte /// COFF header follows; the optional header starts at signature+24. Within the /// optional header `SizeOfImage` sits at 0x38 for both PE32 and PE32+ (the /// layouts diverge only between 0x18 and 0x20). `ImageBase` is 8 bytes at 0x18 /// for PE32+ and 4 bytes at 0x1c for PE32. fn parse_pe(buf: &[u8]) -> Option<(u64, u64)> { if buf.get(0..2)? != b"MZ" { return None; } let nt = u32_at(buf, 0x3c)? as usize; if buf.get(nt..nt + 4)? != b"PE\0\0" { return None; } let opt = nt + 24; let magic = u16_at(buf, opt)?; let size_of_image = u32_at(buf, opt + 0x38)? as u64; let image_base = match magic { 0x20b => u64_at(buf, opt + 0x18)?, // PE32+ 0x10b => u32_at(buf, opt + 0x1c)? as u64, // PE32 _ => return None, }; Some((size_of_image, image_base)) } fn pe_from_disk(path: &str) -> Option<(u64, u64)> { // 4 KiB is more than enough for MZ + PE + optional header on any real image. let data = fs::read(path).ok()?; parse_pe(&data[..data.len().min(4096)]) } /// Build the module table. Modules are returned sorted by base address. pub fn modules(regions: &[Region], mem: &ProcMem) -> Vec { use std::collections::HashMap; let mut by_path: HashMap<&str, (u64, u64, usize)> = HashMap::new(); for r in regions { let Some(path) = r.path.as_deref() else { continue; }; if r.pseudo() { continue; } let entry = by_path.entry(path).or_insert((u64::MAX, 0, 0)); entry.0 = entry.0.min(r.start); entry.1 = entry.1.max(r.end); entry.2 += 1; } let mut out: Vec = by_path .into_iter() .map(|(path, (base, maps_end, region_count))| { // The live header gives the true extent; the on-disk header gives // the link-time base, which is what the slide is measured against. let live = mem.read_partial(base, 4096); let live_pe = parse_pe(&live); let disk_pe = pe_from_disk(path); Module { name: path.rsplit('/').next().unwrap_or(path).to_string(), path: path.to_string(), base, maps_end, region_count, size_of_image: live_pe.map(|(s, _)| s).or(disk_pe.map(|(s, _)| s)), disk_image_base: disk_pe.map(|(_, b)| b), } }) .collect(); out.sort_by_key(|m| m.base); out } /// Case-insensitive lookup by name substring, e.g. `cardsdll`. pub fn find_module<'a>(mods: &'a [Module], needle: &str) -> Option<&'a Module> { let needle = needle.to_ascii_lowercase(); mods.iter() .find(|m| m.name.to_ascii_lowercase().contains(&needle)) } /// Describe an address as `module+0xoff`, falling back to the region kind. /// /// Checking module image spans BEFORE the region list is essential here: a hit /// inside CardsDLL's `.rdata` lands in an anonymous region as far as the maps /// are concerned, and would otherwise be reported as `anon`, throwing away the /// single most useful piece of context. pub fn describe(va: u64, mods: &[Module], regions: &[Region]) -> String { if let Some(m) = mods.iter().find(|m| m.contains(va)) { return format!("{}+{:#x}", m.name, va - m.base); } match regions.iter().find(|r| va >= r.start && va < r.end) { Some(r) => match r.path.as_deref() { Some(p) => format!("{}+{:#x}", p.rsplit('/').next().unwrap_or(p), va - r.start), None => format!("anon:{:#x}({})", r.start, r.perms), }, None => "unmapped".to_string(), } }