Skip to main content

callgraph_verify/
pe.rs

1//! Minimal, dependency-free PE32 reader — just enough to map a virtual address
2//! (VA) to file bytes. We deliberately hand-parse the headers instead of pulling
3//! in `goblin`/`object`: the contract is "pure byte", and the only thing the
4//! disassembler needs is `va -> &[machine code bytes]`.
5
6use std::sync::atomic::{AtomicU64, Ordering};
7
8/// A loaded PE image: the raw file plus the parsed section table.
9pub struct Pe {
10    data: Vec<u8>,
11    pub image_base: u64,
12    sections: Vec<Section>,
13    /// Process-unique identity assigned at parse time. Whole-image derived
14    /// caches (instruction starts, branch targets) key on this so two
15    /// same-layout PEs with different bytes can never share an entry — the
16    /// layout-only key they used before was the input-ignoring-cache class
17    /// of BUG #9. In production the macro holds ONE `Arc<Pe>` per exe path,
18    /// so one id per binary; tests that build many synthetic PEs each get
19    /// their own entries.
20    id: u64,
21}
22
23fn next_pe_id() -> u64 {
24    static NEXT_ID: AtomicU64 = AtomicU64::new(1);
25    NEXT_ID.fetch_add(1, Ordering::Relaxed)
26}
27
28#[derive(Clone, Debug)]
29pub struct Section {
30    pub name: String,
31    pub virtual_address: u32, // RVA
32    pub virtual_size: u32,
33    pub raw_offset: u32,
34    pub raw_size: u32,
35    /// Section characteristics (PE32 section-table `Characteristics` field).
36    /// Use [`Section::is_executable`] / [`Section::is_code`] to test the
37    /// relevant flags without remembering the bit constants.
38    pub characteristics: u32,
39}
40
41/// Standard PE32 section characteristics. Kept here so callers don't have
42/// to memorize the bit values.
43pub mod section_flags {
44    /// 0x00000020 — section contains executable code (CNT_CODE).
45    pub const CODE: u32 = 0x0000_0020;
46    /// 0x02000000 — section can be discarded after load (.reloc, .debug).
47    pub const DISCARDABLE: u32 = 0x0200_0000;
48    /// 0x20000000 — section is executable (CNT_CODE in MEM_EXECUTE form).
49    pub const EXECUTE: u32 = 0x2000_0000;
50    /// 0x40000000 — section is readable.
51    pub const READ: u32 = 0x4000_0000;
52    /// 0x80000000 — section is writable (.data, .bss).
53    pub const WRITE: u32 = 0x8000_0000;
54}
55
56impl Section {
57    /// True if the section contains executable code (`.text`).
58    /// Code sections carry either `MEM_EXECUTE` or `CNT_CODE` (typically both).
59    pub fn is_executable(&self) -> bool {
60        self.characteristics & (section_flags::EXECUTE | section_flags::CODE) != 0
61    }
62}
63
64fn u16(d: &[u8], o: usize) -> u16 {
65    u16::from_le_bytes([d[o], d[o + 1]])
66}
67fn u32(d: &[u8], o: usize) -> u32 {
68    u32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]])
69}
70
71impl Pe {
72    pub fn load(path: &str) -> Result<Pe, String> {
73        let data = std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?;
74        Pe::parse(data)
75    }
76
77    pub fn parse(data: Vec<u8>) -> Result<Pe, String> {
78        let sz = data.len();
79        if sz < 0x40 || &data[0..2] != b"MZ" {
80            return Err(format!(
81                "not an MZ image: first 2 bytes are {:?} (expected b\"MZ\"), file size = {sz} bytes",
82                &data[..data.len().min(2)]
83            ));
84        }
85        let e_lfanew = u32(&data, 0x3c) as usize;
86        if data.len() >= e_lfanew + 2 && &data[e_lfanew..e_lfanew + 2] == b"LE" {
87            return parse_le(data, e_lfanew);
88        }
89        if data.len() < e_lfanew + 24 || &data[e_lfanew..e_lfanew + 4] != b"PE\0\0" {
90            // Show the e_lfanew value, the bytes at that offset, and what's there.
91            // That single line is enough to tell whether the user gave us an MZ
92            // wrapped around something exotic (a DOS driver, a NE/LE binary)
93            // or just a corrupted PE.
94            let there = data
95                .get(e_lfanew..e_lfanew + 4)
96                .map(|s| format!("{:02x?}", s))
97                .unwrap_or_else(|| "<past EOF>".into());
98            return Err(format!(
99                "bad PE signature: e_lfanew = {e_lfanew:#x}, bytes there = [{there}] (expected b\"PE\\0\\0\"); file size = {sz} bytes"
100            ));
101        }
102        let coff = e_lfanew + 4;
103        let machine = u16(&data, coff);
104        if machine != 0x014c {
105            return Err(format!(
106                "unsupported machine {machine:#06x} (expected i386 = 0x014c); this crate only decodes 32-bit x86"
107            ));
108        }
109        let num_sections = u16(&data, coff + 2) as usize;
110        let opt_size = u16(&data, coff + 16) as usize;
111        let opt = coff + 20;
112        let magic = u16(&data, opt);
113        if magic != 0x10b {
114            return Err(format!(
115                "unsupported optional-header magic {magic:#06x} (expected PE32 = 0x10b); PE32+ (64-bit) and ROM images are not handled"
116            ));
117        }
118        let image_base = u32(&data, opt + 28) as u64;
119
120        let sec_table = opt + opt_size;
121        let mut sections = Vec::with_capacity(num_sections);
122        for i in 0..num_sections {
123            let s = sec_table + i * 40;
124            if s + 40 > data.len() {
125                return Err(format!(
126                    "section table runs past EOF: section #{i} would start at file offset {s:#x} \
127                     (e_lfanew={e_lfanew:#x} + opt_size={opt_size:#x} + {i}*40), \
128                     but file size is only {sz} bytes — corrupt header or wrong format"
129                ));
130            }
131            let raw_name = &data[s..s + 8];
132            let end = raw_name.iter().position(|&b| b == 0).unwrap_or(8);
133            sections.push(Section {
134                name: String::from_utf8_lossy(&raw_name[..end]).into_owned(),
135                virtual_size: u32(&data, s + 8),
136                virtual_address: u32(&data, s + 12),
137                raw_size: u32(&data, s + 16),
138                raw_offset: u32(&data, s + 20),
139                characteristics: u32(&data, s + 36),
140            });
141        }
142        Ok(Pe {
143            data,
144            image_base,
145            sections,
146            id: next_pe_id(),
147        })
148    }
149
150    /// Process-unique id assigned at parse time (see the field docs).
151    pub fn id(&self) -> u64 {
152        self.id
153    }
154
155    /// The section containing `va`, if any.
156    pub fn section_of(&self, va: u64) -> Option<&Section> {
157        let rva = va.checked_sub(self.image_base)? as u32;
158        self.sections.iter().find(|s| {
159            // Use the larger of virtual/raw size so the boundary check never
160            // excludes a byte that is actually present on disk.
161            let span = s.virtual_size.max(s.raw_size);
162            rva >= s.virtual_address && rva < s.virtual_address + span
163        })
164    }
165
166    /// Read up to `len` bytes of file data starting at virtual address `va`.
167    /// Returns fewer bytes near the end of a section's raw data (never reads
168    /// across a section boundary or into uninitialized/BSS tail).
169    pub fn read(&self, va: u64, len: usize) -> Option<&[u8]> {
170        let sec = self.section_of(va)?;
171        let rva = (va - self.image_base) as u32;
172        let off_in_sec = rva - sec.virtual_address;
173        if off_in_sec >= sec.raw_size {
174            return None; // inside virtual_size but past raw data (BSS) → no bytes
175        }
176        let file_off = (sec.raw_offset + off_in_sec) as usize;
177        let avail = (sec.raw_size - off_in_sec) as usize;
178        let take = len.min(avail).min(self.data.len().saturating_sub(file_off));
179        Some(&self.data[file_off..file_off + take])
180    }
181
182    /// Iterate over all sections in the PE
183    pub fn sections(&self) -> &[Section] {
184        &self.sections
185    }
186
187    pub fn is_executable_va(&self, va: u64) -> bool {
188        // A VA is "decodeable" iff it lies inside some section that has
189        // readable bytes on disk. The PE32 `Characteristics` flags are
190        // available via [`Section::is_executable`] for callers that
191        // want the stricter check (e.g. to skip `.rdata` when enumerating
192        // functions), but the disassembler's own gate stays permissive:
193        // any section with bytes here is fair game, mirroring the
194        // pre-flag-based behavior so synthetic / oddball PEs without
195        // characteristic flags still decode. The OS loader rounds
196        // sections to page granularity regardless, so a `CALL [0xABCD]`
197        // into `.text` from inside `.text` works at runtime whether the
198        // section is flagged executable or not.
199        self.section_of(va).is_some()
200    }
201
202    /// True if `va` lies inside a section whose PE32 `Characteristics` mark it
203    /// as code (`CNT_CODE` or `MEM_EXECUTE`) — the STRICT counterpart of
204    /// [`Pe::is_executable_va`]. Use this when deciding whether an immediate /
205    /// stored dword plausibly denotes a FUNCTION (it lives in `.text`), as
206    /// opposed to data (a `.rdata` vtable VA, a string address) that merely
207    /// happens to sit inside the image.
208    pub fn is_executable_section_va(&self, va: u64) -> bool {
209        self.section_of(va)
210            .map(|s| s.is_executable())
211            .unwrap_or(false)
212    }
213
214    /// Pretty-print every section as `name [RVA..RVA+size)` with the image base
215    /// baked in, plus a hint pointing at the closest section to `va` (by VA
216    /// distance) so the user can immediately tell whether they mistyped an
217    /// address or used the wrong file.
218    pub fn describe_sections(&self, va: u64) -> String {
219        let mut s = String::new();
220        let mut best: Option<(&Section, u64)> = None;
221        for sec in &self.sections {
222            let start = self.image_base + sec.virtual_address as u64;
223            let span = sec.virtual_size.max(sec.raw_size) as u64;
224            let end = start + span;
225            s.push_str(&format!(
226                "    {name:<8} [0x{start:08x}..0x{end:08x}) ({span} bytes){flag}\n",
227                name = sec.name,
228                start = start,
229                end = end,
230                span = span,
231                flag = if sec.is_executable() { " code" } else { "" },
232            ));
233            let d = if va < start {
234                start - va
235            } else { va.saturating_sub(end) };
236            if best.map(|(_, bd)| d < bd).unwrap_or(true) {
237                best = Some((sec, d));
238            }
239        }
240        if let Some((sec, d)) = best {
241            s.push_str(&format!(
242                "  closest section to {va:#010x}: {name} (distance {d:#x} bytes) — \
243                 did you mean 0x{start:08x}?",
244                name = sec.name,
245                start = self.image_base + sec.virtual_address as u64,
246            ));
247        }
248        s
249    }
250
251    /// The image's VA range `[image_base, end)` (end = highest section end).
252    pub fn image_range(&self) -> (u64, u64) {
253        let end = self
254            .sections
255            .iter()
256            .map(|s| s.virtual_address as u64 + s.virtual_size.max(s.raw_size) as u64)
257            .max()
258            .unwrap_or(0);
259        (self.image_base, self.image_base + end)
260    }
261
262    /// Every VA the image itself "knows about": targets of every `rel32` CALL /
263    /// JMP / Jcc byte pattern in `.text`, plus every little-endian u32 (read at
264    /// EVERY byte offset of every section's raw data) that lands inside the image
265    /// — vtable slots, switch jump tables, baked function pointers, data refs.
266    ///
267    /// Purpose: catch a MISATTRIBUTED or invented `origin` address. A real
268    /// function entry is always reachable somehow (called, tail-jumped — long
269    /// OR short form — or its address stored in a table), so an address
270    /// referenced NOWHERE cannot be one. Over-approximate by construction
271    /// (a spurious `0xE8` inside another instruction adds a junk target; a
272    /// random data dword may collide) — that only ever widens the accept
273    /// set, so it can't false-positive a real entry.
274    pub fn referenced_addrs(&self) -> std::collections::HashSet<u32> {
275        let (lo, hi) = self.image_range();
276        let mut out: std::collections::HashSet<u32> = std::collections::HashSet::new();
277        for sec in &self.sections {
278            let start = sec.raw_offset as usize;
279            let end = (start + sec.raw_size as usize).min(self.data.len());
280            if start >= end {
281                continue;
282            }
283            let blob = &self.data[start..end];
284            let sec_va = self.image_base + sec.virtual_address as u64;
285            // rel8/rel32 branch targets — only meaningful in the code section.
286            if sec.name == ".text" {
287                for i in 0..blob.len().saturating_sub(4) {
288                    // `JMP short rel8` (0xEB): the scan used to index rel32
289                    // branches only, so a function reached solely via short
290                    // jump (e.g. FALL.EXE `0x9dbe2`, reached only by `EB F0`
291                    // from `0x9dbeb`) was invisible to ORIGIN-UNREFERENCED.
292                    // Over-approximate like the rest: a stray 0xEB only
293                    // widens the set.
294                    if blob[i] == 0xEB && i + 2 <= blob.len() {
295                        let rel = blob[i + 1] as i8 as i64;
296                        let t = (sec_va + i as u64 + 2).wrapping_add(rel as u64);
297                        if (lo..hi).contains(&t) {
298                            out.insert(t as u32);
299                        }
300                    }
301                    let (op, rel_at) = match blob[i] {
302                        0xE8 | 0xE9 => (blob[i], i + 1), // CALL rel32 / JMP rel32
303                        0x0F if i + 6 <= blob.len() && (0x80..=0x8F).contains(&blob[i + 1]) => {
304                            (blob[i + 1], i + 2) // Jcc rel32
305                        }
306                        _ => continue,
307                    };
308                    let _ = op;
309                    if rel_at + 4 > blob.len() {
310                        continue;
311                    }
312                    let rel = i32::from_le_bytes([
313                        blob[rel_at],
314                        blob[rel_at + 1],
315                        blob[rel_at + 2],
316                        blob[rel_at + 3],
317                    ]) as i64;
318                    let t = (sec_va + (rel_at as u64 + 4)).wrapping_add(rel as u64);
319                    if (lo..hi).contains(&t) {
320                        out.insert(t as u32);
321                    }
322                }
323            }
324            // Absolute in-image dwords at every offset (vtables, jump tables, fn ptrs).
325            for w in blob.windows(4) {
326                let v = u32::from_le_bytes([w[0], w[1], w[2], w[3]]) as u64;
327                if (lo..hi).contains(&v) {
328                    out.insert(v as u32);
329                }
330            }
331        }
332        out
333    }
334}
335
336/// DOS4GW Linear Executable (LE) support — Daggerfall's FALL.EXE etc. are
337/// MZ+LE, not PE. We synthesize a PE-like image so the existing
338/// disassembler works unchanged:
339///
340/// * `image_base = 0`, `virtual_address = object reloc base`
341///   (entry VA = relocbase(obj1) + EIP, e.g. 0x10000+0x8DEB4 = 0x9DEB4).
342/// * First executable object is named `.text` so the existing
343///   `.text`-gated caches keep working; other objects become
344///   `.textN` / `.dataN`.
345/// * Page map entries are 4 bytes in LE: 24-bit big-endian physical page
346///   number + 8-bit flags (0 = legal). All FALL.EXE pages are flag 0 and
347///   sequential; iterated/zero pages are rejected for now.
348/// * `datapage` header field is a file-absolute offset (proven by
349///   datapage + pages*pagesize - (pagesize-lastpagesize) == file size).
350/// * Fixups are NOT applied yet: direct CALL rel32 (the call graph) is
351///   unaffected, but absolute `globals`/`imm_addrs` are pre-fixup bytes.
352///   Call-graph + CFG gates are sound; treat globals/vtable facts as
353///   best-effort until fixup application lands.
354fn parse_le(data: Vec<u8>, le_off: usize) -> Result<Pe, String> {
355    let sz = data.len();
356    let need = le_off + 0xAC;
357    if sz < need + 4 {
358        return Err(format!(
359            "truncated LE header: need {need:#x} bytes for header, file size = {sz} bytes"
360        ));
361    }
362    let at32 = |o: usize| -> Result<u32, String> {
363        if o + 4 > sz {
364            return Err(format!("LE header read past EOF at {o:#x}"));
365        }
366        Ok(u32::from_le_bytes([data[o], data[o + 1], data[o + 2], data[o + 3]]))
367    };
368    let at16 = |o: usize| -> Result<u16, String> {
369        if o + 2 > sz {
370            return Err(format!("LE header read past EOF at {o:#x}"));
371        }
372        Ok(u16::from_le_bytes([data[o], data[o + 1]]))
373    };
374    let cpu = at16(le_off + 8)?;
375    if cpu != 2 {
376        return Err(format!(
377            "unsupported LE cpu {cpu:#x} (expected 386 = 0x2); only 32-bit x86 LE is handled"
378        ));
379    }
380    let numpages = at32(le_off + 0x14)? as usize;
381    let pagesize = at32(le_off + 0x28)? as usize;
382    let lastpagesize = at32(le_off + 0x2C)? as usize;
383    let objtab_rel = at32(le_off + 0x40)? as usize;
384    let objcnt = at32(le_off + 0x44)? as usize;
385    let pagemap_rel = at32(le_off + 0x48)? as usize;
386    let itermap = at32(le_off + 0x4C)?;
387    let datapage_abs = at32(le_off + 0x80)? as usize;
388    if !(512..=16384).contains(&pagesize) {
389        return Err(format!("implausible LE pagesize {pagesize}"));
390    }
391    if numpages == 0 || numpages > 100_000 {
392        return Err(format!("implausible LE page count {numpages}"));
393    }
394    if objcnt == 0 || objcnt > 100 {
395        return Err(format!("implausible LE object count {objcnt}"));
396    }
397    if itermap != 0 {
398        return Err(format!(
399            "unsupported LE iterated-data map at {itermap:#x}: iterated pages not yet supported"
400        ));
401    }
402    let objtab = le_off.checked_add(objtab_rel).ok_or("LE objtab overflow")?;
403    let pagemap = le_off.checked_add(pagemap_rel).ok_or("LE pagemap overflow")?;
404    if objtab + objcnt * 24 > sz {
405        return Err(format!(
406            "LE object table runs past EOF: objtab={objtab:#x} + {objcnt}*24, file size {sz:#x}"
407        ));
408    }
409    if pagemap + numpages * 4 > sz {
410        return Err(format!(
411            "LE page map runs past EOF: pagemap={pagemap:#x} + {numpages}*4, file size {sz:#x}"
412        ));
413    }
414    if datapage_abs >= sz {
415        return Err(format!(
416            "LE data pages offset {datapage_abs:#x} past EOF (file size {sz:#x})"
417        ));
418    }
419    // Physical page N (1-based) lives at datapage + (N-1)*pagesize,
420    // except the last physical page which is lastpagesize bytes.
421    // Decode the whole map first so we can validate flags.
422    let mut phys_of_logical: Vec<(u32, u8)> = Vec::with_capacity(numpages);
423    for i in 0..numpages {
424        let o = pagemap + i * 4;
425        let b0 = data[o] as u32;
426        let b1 = data[o + 1] as u32;
427        let b2 = data[o + 2] as u32;
428        let flags = data[o + 3];
429        let page = (b0 << 16) | (b1 << 8) | b2;
430        if flags != 0 {
431            return Err(format!(
432                "unsupported LE page {i} flags={flags:#x} (page={page}): only flag-0 legal pages are supported; iterated/zero pages TODO"
433            ));
434        }
435        if page == 0 || page as usize > numpages {
436            return Err(format!(
437                "LE page map entry {i} has out-of-range physical page {page} (numpages={numpages})"
438            ));
439        }
440        phys_of_logical.push((page, flags));
441    }
442    let mut new_data: Vec<u8> = Vec::new();
443    let mut sections: Vec<Section> = Vec::with_capacity(objcnt);
444    let mut exec_seen = 0usize;
445    let mut data_seen = 0usize;
446    for oi in 0..objcnt {
447        let o = objtab + oi * 24;
448        let vsize = u32::from_le_bytes([data[o], data[o + 1], data[o + 2], data[o + 3]]) as usize;
449        let base =
450            u32::from_le_bytes([data[o + 4], data[o + 5], data[o + 6], data[o + 7]]) as u32;
451        let flags =
452            u32::from_le_bytes([data[o + 8], data[o + 9], data[o + 10], data[o + 11]]);
453        let page_idx =
454            u32::from_le_bytes([data[o + 12], data[o + 13], data[o + 14], data[o + 15]])
455                as usize;
456        let page_cnt =
457            u32::from_le_bytes([data[o + 16], data[o + 17], data[o + 18], data[o + 19]])
458                as usize;
459        if page_idx == 0 || page_idx - 1 + page_cnt > numpages {
460            return Err(format!(
461                "LE object {oi} page range [{page_idx}..{}) out of 1..={numpages}",
462                page_idx + page_cnt
463            ));
464        }
465        let start = new_data.len();
466        for j in 0..page_cnt {
467            let (phys, _) = phys_of_logical[page_idx - 1 + j];
468            let is_last_phys = phys as usize == numpages;
469            let plen = if is_last_phys { lastpagesize } else { pagesize };
470            let foff = datapage_abs + (phys as usize - 1) * pagesize;
471            if foff + plen > sz {
472                return Err(format!(
473                    "LE object {oi} page {j} (phys {phys}) file range [{foff:#x}..{:#x}) past EOF {sz:#x}",
474                    foff + plen
475                ));
476            }
477            new_data.extend_from_slice(&data[foff..foff + plen]);
478        }
479        let file_len = new_data.len() - start;
480        let raw_size = vsize.min(file_len);
481        // Drop padding beyond vsize (trailing bytes of the last page that
482        // belong to no object); keep BSS tail virtual (read returns None).
483        new_data.truncate(start + raw_size);
484        let is_exec = flags & 0x4 != 0;
485        let (name, characteristics) = if is_exec {
486            let n = if exec_seen == 0 {
487                ".text".to_string()
488            } else {
489                format!(".text{exec_seen}")
490            };
491            exec_seen += 1;
492            (
493                n,
494                section_flags::CODE | section_flags::EXECUTE | section_flags::READ,
495            )
496        } else {
497            let n = if data_seen == 0 {
498                ".data".to_string()
499            } else {
500                format!(".data{data_seen}")
501            };
502            data_seen += 1;
503            (
504                n,
505                section_flags::READ | section_flags::WRITE,
506            )
507        };
508        sections.push(Section {
509            name,
510            virtual_address: base,
511            virtual_size: vsize as u32,
512            raw_offset: start as u32,
513            raw_size: raw_size as u32,
514            characteristics,
515        });
516    }
517    Ok(Pe {
518        data: new_data,
519        image_base: 0,
520        sections,
521        id: next_pe_id(),
522    })
523}