Skip to main content

macho_cpp/
vtable.rs

1use serde::{Deserialize, Serialize};
2
3use crate::model::addr::Va;
4use crate::model::macho_file::MachoFile;
5use crate::model::symbol::SymbolTable;
6use crate::{Error, Result};
7use macho_demangle::demangle_symbol;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10/// The VtableIndex type.
11pub struct VtableIndex {
12    /// The vtables field.
13    pub vtables: Vec<VtableEntry>,
14    truncated: bool,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18/// The VtableEntry type.
19pub struct VtableEntry {
20    /// The name field.
21    pub name: Option<String>,
22    /// The mangled_name field.
23    pub mangled_name: Option<String>,
24    #[serde(with = "va_serde")]
25    /// The va field.
26    pub va: Va,
27    /// The size field.
28    pub size: u64,
29    /// The slots field.
30    pub slots: Vec<VtableSlot>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34/// The VtableSlot type.
35pub struct VtableSlot {
36    /// The offset field.
37    pub offset: u64,
38    #[serde(with = "va_serde")]
39    /// The va field.
40    pub va: Va,
41    /// The target field.
42    pub target: SlotTarget,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(tag = "kind", rename_all = "snake_case")]
47/// The SlotTarget type.
48#[non_exhaustive]
49pub enum SlotTarget {
50    /// The Function variant.
51    Function {
52        /// The String field.
53        name: String,
54        #[serde(with = "va_serde")]
55        /// The Va field.
56        va: Va,
57    },
58    /// The PureVirtual variant.
59    PureVirtual,
60    /// The TypeInfo variant.
61    TypeInfo {
62        #[serde(with = "va_serde")]
63        /// The Va field.
64        va: Va,
65    },
66    /// The OffsetToTop variant.
67    OffsetToTop {
68        /// The i64 field.
69        value: i64,
70    },
71    /// The Unknown variant.
72    Unknown {
73        /// The u64 field.
74        value: u64,
75    },
76}
77
78mod va_serde {
79    use serde::{Deserialize, Deserializer, Serializer};
80
81    use crate::model::addr::Va;
82
83    pub fn serialize<S>(value: &Va, serializer: S) -> Result<S::Ok, S::Error>
84    where
85        S: Serializer,
86    {
87        serializer.serialize_u64(value.0)
88    }
89
90    pub fn deserialize<'de, D>(deserializer: D) -> Result<Va, D::Error>
91    where
92        D: Deserializer<'de>,
93    {
94        u64::deserialize(deserializer).map(Va)
95    }
96}
97
98/// Resolved value at a vtable slot after decoding chained fixups.
99#[derive(Debug)]
100enum ResolvedSlotValue {
101    /// A rebase target: this is the resolved VA the pointer refers to.
102    Address(u64),
103    /// A bind (import): the pointer refers to an imported symbol.
104    Import { name: String },
105    /// No fixup data available; use the raw value directly.
106    Raw(u64),
107}
108
109impl VtableIndex {
110    /// Performs build.
111    pub fn build(macho: &MachoFile<'_>) -> Result<Self> {
112        Self::build_limited(macho, usize::MAX)
113    }
114
115    /// Build from one borrowed thin Mach-O byte source.
116    ///
117    /// The source is not copied and may be a byte slice, vector, or
118    /// caller-owned read-only memory map. Universal binaries are rejected so
119    /// callers select an architecture explicitly.
120    pub fn build_from_source<S>(source: &S) -> Result<Self>
121    where
122        S: AsRef<[u8]> + ?Sized,
123    {
124        Self::build_limited_from_source(source, usize::MAX)
125    }
126
127    /// Builds at most `max_vtables` decoded vtables.
128    ///
129    /// The search stops once the output limit is reached. Use
130    /// [`Self::was_truncated`] to distinguish a complete result from a bounded
131    /// one.
132    pub fn build_limited(macho: &MachoFile<'_>, max_vtables: usize) -> Result<Self> {
133        let symtab = macho.ext::<SymbolTable<'_>>()?;
134        let symbols = symtab.symbols();
135
136        let ptr_size: u64 = if macho.is_64bit() { 8 } else { 4 };
137        let image_base = macho.image_base().0;
138
139        // Build a map of VA -> symbol name for resolving slot targets
140        let mut va_to_name: std::collections::HashMap<u64, &str> = std::collections::HashMap::new();
141        for sym in symbols {
142            if sym.is_defined() && sym.value != 0 {
143                va_to_name.insert(sym.value, sym.name);
144            }
145        }
146
147        // Build a fixup map for resolving chained fixup pointers.
148        // On modern arm64/x86_64 binaries, pointer values in __DATA_CONST
149        // are encoded chained fixup entries, not actual VAs.
150        let fixup_map = build_vtable_fixup_map(macho);
151
152        // Find typeinfo symbol VAs (symbols starting with __ZTI)
153        let typeinfo_vas: std::collections::HashSet<u64> = symbols
154            .iter()
155            .filter(|s| s.name.starts_with("__ZTI") || s.name.starts_with("_ZTI"))
156            .filter(|s| s.is_defined() && s.value != 0)
157            .map(|s| s.value)
158            .collect();
159
160        // Collect vtable symbols sorted by VA
161        let mut vtable_syms: Vec<_> = symbols
162            .iter()
163            .filter(|s| {
164                s.is_defined()
165                    && s.value != 0
166                    && (s.name.starts_with("__ZTV") || s.name.starts_with("_ZTV"))
167            })
168            .take(max_vtables.saturating_add(1))
169            .collect();
170        vtable_syms.sort_by_key(|s| s.value);
171        let truncated = vtable_syms.len() > max_vtables;
172        vtable_syms.truncate(max_vtables);
173
174        // Find the next defined symbol after each vtable to determine size bounds
175        let mut all_defined_vas: Vec<u64> = symbols
176            .iter()
177            .filter(|s| s.is_defined() && s.value != 0)
178            .map(|s| s.value)
179            .collect();
180        all_defined_vas.sort();
181        all_defined_vas.dedup();
182
183        let mut vtables = Vec::new();
184
185        for vtable_sym in &vtable_syms {
186            let vtable_va = vtable_sym.value;
187
188            // Determine max size: distance to next symbol
189            let max_size = match all_defined_vas.binary_search(&vtable_va) {
190                Ok(idx) => {
191                    if idx + 1 < all_defined_vas.len() {
192                        all_defined_vas[idx + 1] - vtable_va
193                    } else {
194                        // Last symbol - use a reasonable cap
195                        256 * ptr_size
196                    }
197                }
198                Err(_) => 1024 * ptr_size,
199            };
200
201            // Read vtable slots
202            let ctx = VtableScanContext {
203                image_base,
204                va_to_name: &va_to_name,
205                typeinfo_vas: &typeinfo_vas,
206                fixup_map: &fixup_map,
207            };
208
209            let slots = match read_vtable_slots(macho, Va(vtable_va), ptr_size, max_size, &ctx) {
210                Ok(s) => s,
211                Err(_) => continue,
212            };
213
214            if slots.is_empty() {
215                continue;
216            }
217
218            let size = slots.last().map(|s| s.offset + ptr_size).unwrap_or(0);
219
220            // Demangle the vtable name
221            let demangled = demangle_symbol(vtable_sym.name);
222
223            vtables.push(VtableEntry {
224                name: demangled,
225                mangled_name: Some(vtable_sym.name.to_owned()),
226                va: Va(vtable_va),
227                size,
228                slots,
229            });
230        }
231
232        Ok(Self { vtables, truncated })
233    }
234
235    /// Build at most `max_vtables` entries from a borrowed thin Mach-O source.
236    ///
237    /// This has the same zero-copy source and universal-binary behavior as
238    /// [`Self::build_from_source`].
239    pub fn build_limited_from_source<S>(source: &S, max_vtables: usize) -> Result<Self>
240    where
241        S: AsRef<[u8]> + ?Sized,
242    {
243        let macho = crate::parse_source(source)?;
244        Self::build_limited(&macho, max_vtables)
245    }
246
247    /// Performs find_by_class.
248    pub fn find_by_class(&self, class_name: &str) -> Option<&VtableEntry> {
249        self.vtables
250            .iter()
251            .find(|v| v.name.as_ref().is_some_and(|n| n.contains(class_name)))
252    }
253
254    /// Performs find_by_va.
255    pub fn find_by_va(&self, va: Va) -> Option<&VtableEntry> {
256        self.vtables.iter().find(|v| v.va == va)
257    }
258
259    /// Performs slot_at.
260    pub fn slot_at(&self, va: Va) -> Option<(&VtableEntry, &VtableSlot)> {
261        for vtable in &self.vtables {
262            for slot in &vtable.slots {
263                if slot.va == va {
264                    return Some((vtable, slot));
265                }
266            }
267        }
268        None
269    }
270
271    /// Find all vtable slots whose target points to the given function VA.
272    pub fn slots_targeting_va(&self, target_va: Va) -> Vec<(&VtableEntry, &VtableSlot)> {
273        let mut results = Vec::new();
274        for vtable in &self.vtables {
275            for slot in &vtable.slots {
276                if let SlotTarget::Function { va, .. } = &slot.target {
277                    if *va == target_va {
278                        results.push((vtable, slot));
279                    }
280                }
281            }
282        }
283        results
284    }
285
286    /// Performs vtables.
287    pub fn vtables(&self) -> &[VtableEntry] {
288        &self.vtables
289    }
290
291    /// Returns whether additional vtable candidates were skipped at the
292    /// configured collection bound.
293    pub fn was_truncated(&self) -> bool {
294        self.truncated
295    }
296
297    /// Find a vtable function slot by class name and method name.
298    ///
299    /// The method name is matched against the demangled target function name
300    /// of each slot. The match is substring-based: `"check"` matches a slot
301    /// targeting `"Foo::check()"`.
302    ///
303    /// Returns the vtable entry, the matching slot, and its function-slot
304    /// index (0-based, excluding the header slots).
305    pub fn find_slot_by_method(
306        &self,
307        class_name: &str,
308        method_name: &str,
309    ) -> Option<(&VtableEntry, &VtableSlot, usize)> {
310        let entry = self.find_by_class(class_name)?;
311        entry
312            .find_slot_by_name(method_name)
313            .map(|(slot, idx)| (entry, slot, idx))
314    }
315}
316
317impl VtableEntry {
318    /// Return only the function slots (excluding offset-to-top and typeinfo header slots).
319    pub fn function_slots(&self) -> impl Iterator<Item = (usize, &VtableSlot)> {
320        self.slots
321            .iter()
322            .filter(|s| {
323                matches!(
324                    s.target,
325                    SlotTarget::Function { .. } | SlotTarget::PureVirtual
326                )
327            })
328            .enumerate()
329    }
330
331    /// Number of function slots (excluding header slots).
332    pub fn function_slot_count(&self) -> usize {
333        self.slots
334            .iter()
335            .filter(|s| {
336                matches!(
337                    s.target,
338                    SlotTarget::Function { .. } | SlotTarget::PureVirtual
339                )
340            })
341            .count()
342    }
343
344    /// Find a function slot by matching the demangled target name.
345    ///
346    /// Returns the slot and its function-slot index (0-based, excluding header slots).
347    /// The match checks whether the demangled function name contains `method_name`.
348    pub fn find_slot_by_name(&self, method_name: &str) -> Option<(&VtableSlot, usize)> {
349        for (func_idx, slot) in self.function_slots() {
350            if let SlotTarget::Function { name, .. } = &slot.target {
351                // Try exact leaf match first, then substring.
352                if extract_method_leaf(name) == method_name || name.contains(method_name) {
353                    return Some((slot, func_idx));
354                }
355            }
356        }
357        None
358    }
359
360    /// Get a function slot by its 0-based function-slot index
361    /// (excluding header slots like offset-to-top and typeinfo).
362    pub fn function_slot_at(&self, index: usize) -> Option<&VtableSlot> {
363        self.function_slots()
364            .find(|(i, _)| *i == index)
365            .map(|(_, slot)| slot)
366    }
367}
368
369/// Extract the leaf method name from a demangled C++ name.
370///
371/// `"Foo::Bar::check(int)"` → `"check"`
372/// `"check"` → `"check"`
373fn extract_method_leaf(demangled: &str) -> &str {
374    // Strip everything from '(' onward (parameters).
375    let base = demangled.split('(').next().unwrap_or(demangled);
376    // Take the part after the last "::".
377    base.rsplit("::").next().unwrap_or(base)
378}
379
380/// Resolved fixup at a file offset.
381#[derive(Debug, Clone)]
382enum VtableFixup {
383    /// Rebase: the pointer targets image_base + target.
384    Rebase(u64),
385    /// Bind: the pointer targets an imported symbol.
386    Bind { import_name: String },
387}
388
389/// Build a map from file_offset -> resolved fixup, using chained fixups
390/// if available, otherwise legacy bind/rebase opcodes.
391fn build_vtable_fixup_map(macho: &MachoFile<'_>) -> std::collections::HashMap<u64, VtableFixup> {
392    use macho_dyld::chained::parse_chained_fixups;
393    use macho_dyld::types::FixupKind;
394
395    let mut map = std::collections::HashMap::new();
396
397    match parse_chained_fixups(macho) {
398        Ok(fixups) => {
399            for fixup in &fixups.fixups {
400                let seg = match macho.segments().get(fixup.segment_index) {
401                    Some(s) => s,
402                    None => continue,
403                };
404                let file_offset = seg.file_offset().0 + fixup.segment_offset;
405
406                match &fixup.kind {
407                    FixupKind::Rebase { target } | FixupKind::AuthRebase { target, .. } => {
408                        map.insert(file_offset, VtableFixup::Rebase(*target));
409                    }
410                    FixupKind::Bind { import_index, .. }
411                    | FixupKind::AuthBind { import_index, .. } => {
412                        let name = fixups
413                            .imports
414                            .get(*import_index as usize)
415                            .map(|i| i.name.to_string())
416                            .unwrap_or_default();
417                        map.insert(file_offset, VtableFixup::Bind { import_name: name });
418                    }
419                    _ => continue,
420                }
421            }
422        }
423        Err(_) => {
424            // Try legacy bind/rebase opcodes
425            if let Ok((regular, weak, lazy)) = macho_dyld::bind::parse_bind_entries(macho) {
426                for entry in regular.iter().chain(weak.iter()).chain(lazy.iter()) {
427                    if let Some(seg) = macho.segments().get(entry.segment_index) {
428                        let file_offset = seg.file_offset().0 + entry.segment_offset;
429                        map.insert(
430                            file_offset,
431                            VtableFixup::Bind {
432                                import_name: entry.symbol_name.to_string(),
433                            },
434                        );
435                    }
436                }
437            }
438
439            if let Ok(rebases) = macho_dyld::rebase::parse_rebase_entries(macho) {
440                for entry in &rebases {
441                    if let Some(seg) = macho.segments().get(entry.segment_index) {
442                        let file_offset = seg.file_offset().0 + entry.segment_offset;
443                        map.entry(file_offset).or_insert(VtableFixup::Rebase(0));
444                    }
445                }
446            }
447        }
448    }
449
450    map
451}
452
453/// Resolve the pointer value at a given file offset using the fixup map.
454fn resolve_slot_value(
455    raw_value: u64,
456    file_offset: u64,
457    image_base: u64,
458    fixup_map: &std::collections::HashMap<u64, VtableFixup>,
459    macho: &MachoFile<'_>,
460    endian: crate::format::io::endian::Endian,
461) -> ResolvedSlotValue {
462    if let Some(fixup) = fixup_map.get(&file_offset) {
463        match fixup {
464            VtableFixup::Rebase(target) if *target != 0 => {
465                ResolvedSlotValue::Address(image_base + target)
466            }
467            VtableFixup::Rebase(_) => {
468                // Legacy rebase sentinel -- read the raw pointer directly
469                // (the linker wrote the correct un-slid VA)
470                let raw =
471                    crate::format::io::pod::read_pod::<u64>(macho.bytes(), file_offset as usize)
472                        .map(|v| endian.interpret_u64(v))
473                        .unwrap_or(raw_value);
474                ResolvedSlotValue::Address(raw)
475            }
476            VtableFixup::Bind { import_name } => ResolvedSlotValue::Import {
477                name: import_name.clone(),
478            },
479        }
480    } else {
481        // No fixup -- use raw value as-is (non-fixup binaries or
482        // the slot was not covered by any fixup chain)
483        ResolvedSlotValue::Raw(raw_value)
484    }
485}
486
487struct VtableScanContext<'a> {
488    image_base: u64,
489    va_to_name: &'a std::collections::HashMap<u64, &'a str>,
490    typeinfo_vas: &'a std::collections::HashSet<u64>,
491    fixup_map: &'a std::collections::HashMap<u64, VtableFixup>,
492}
493
494fn read_vtable_slots(
495    macho: &MachoFile<'_>,
496    vtable_va: Va,
497    ptr_size: u64,
498    max_size: u64,
499    ctx: &VtableScanContext<'_>,
500) -> Result<Vec<VtableSlot>> {
501    let endian = macho.endian();
502    let max_slots = max_size / ptr_size;
503    let has_fixups = !ctx.fixup_map.is_empty();
504    let mut slots = Vec::new();
505
506    for i in 0..max_slots {
507        let slot_offset = i * ptr_size;
508        let slot_va = Va(vtable_va.0 + slot_offset);
509
510        let bytes = match macho.read_bytes_at_va(slot_va, ptr_size as usize) {
511            Ok(b) => b,
512            Err(_) => break,
513        };
514
515        let raw_value = if ptr_size == 8 {
516            let arr: [u8; 8] = bytes
517                .try_into()
518                .map_err(|_| Error::format("failed to read 8 bytes for vtable slot"))?;
519            endian.read_u64(arr)
520        } else {
521            let arr: [u8; 4] = bytes
522                .try_into()
523                .map_err(|_| Error::format("failed to read 4 bytes for vtable slot"))?;
524            endian.read_u32(arr) as u64
525        };
526
527        // Resolve the pointer value through the fixup map
528        let file_offset = macho
529            .address_map()
530            .va_to_thin_offset(slot_va)
531            .map(|o| o.0)
532            .unwrap_or(0);
533
534        let resolved = resolve_slot_value(
535            raw_value,
536            file_offset,
537            ctx.image_base,
538            ctx.fixup_map,
539            macho,
540            endian,
541        );
542
543        let target = classify_slot(
544            &resolved,
545            raw_value,
546            i,
547            ctx.va_to_name,
548            ctx.typeinfo_vas,
549            has_fixups,
550        );
551
552        // Stop if we've read past the structural header (offset-to-top +
553        // typeinfo) and hit a clearly invalid entry (zero after the
554        // function pointer region).
555        if i > 2 {
556            match &resolved {
557                ResolvedSlotValue::Address(0) | ResolvedSlotValue::Raw(0) => break,
558                _ => {}
559            }
560        }
561
562        slots.push(VtableSlot {
563            offset: slot_offset,
564            va: slot_va,
565            target,
566        });
567    }
568
569    Ok(slots)
570}
571
572fn classify_slot(
573    resolved: &ResolvedSlotValue,
574    _raw_value: u64,
575    slot_index: u64,
576    va_to_name: &std::collections::HashMap<u64, &str>,
577    typeinfo_vas: &std::collections::HashSet<u64>,
578    has_fixups: bool,
579) -> SlotTarget {
580    // First slot is the offset-to-top value (typically 0 for primary vtables,
581    // or a negative offset for secondary base vtables).
582    if slot_index == 0 {
583        // The offset-to-top is a signed integer, not a pointer.
584        // In non-fixup binaries the raw value is the actual offset-to-top.
585        // In fixup binaries, if this slot has no fixup entry, the raw value
586        // is the offset-to-top. If it does have a fixup, the resolved address
587        // minus image_base is the offset-to-top.
588        let value = match resolved {
589            ResolvedSlotValue::Raw(v) => *v as i64,
590            ResolvedSlotValue::Address(v) => *v as i64,
591            ResolvedSlotValue::Import { .. } => 0,
592        };
593        return SlotTarget::OffsetToTop { value };
594    }
595
596    // Second slot is the typeinfo pointer.
597    if slot_index == 1 {
598        match resolved {
599            ResolvedSlotValue::Address(va) if typeinfo_vas.contains(va) => {
600                return SlotTarget::TypeInfo { va: Va(*va) };
601            }
602            ResolvedSlotValue::Address(va) if *va != 0 => {
603                // Even if we don't recognize this as a typeinfo symbol,
604                // slot 1 is structurally the typeinfo pointer.
605                return SlotTarget::TypeInfo { va: Va(*va) };
606            }
607            ResolvedSlotValue::Import { .. } => {
608                // Typeinfo bound to an external symbol -- still typeinfo
609                return SlotTarget::TypeInfo { va: Va(0) };
610            }
611            ResolvedSlotValue::Raw(v) if !has_fixups => {
612                if typeinfo_vas.contains(v) || *v != 0 {
613                    return SlotTarget::TypeInfo { va: Va(*v) };
614                }
615                return SlotTarget::TypeInfo { va: Va(0) };
616            }
617            _ => {
618                return SlotTarget::TypeInfo { va: Va(0) };
619            }
620        }
621    }
622
623    // For slots beyond the header, resolve the target and classify.
624    let effective_va = match resolved {
625        ResolvedSlotValue::Address(va) => *va,
626        ResolvedSlotValue::Import { name } => {
627            // Check if this is a pure virtual or deleted virtual import
628            if is_pure_virtual_name(name) {
629                return SlotTarget::PureVirtual;
630            }
631            // Other imports -- report as function with the import name
632            let demangled = demangle_symbol(name).unwrap_or_else(|| name.clone());
633            return SlotTarget::Function {
634                name: demangled,
635                va: Va(0),
636            };
637        }
638        ResolvedSlotValue::Raw(v) => *v,
639    };
640
641    // Check for known function symbol
642    if let Some(name) = va_to_name.get(&effective_va) {
643        // Check if this symbol is actually pure virtual
644        if is_pure_virtual_name(name) {
645            return SlotTarget::PureVirtual;
646        }
647        let demangled = demangle_symbol(name).unwrap_or_else(|| (*name).to_owned());
648        return SlotTarget::Function {
649            name: demangled,
650            va: Va(effective_va),
651        };
652    }
653
654    // If the value looks like a reasonable VA (non-zero), treat as unknown pointer
655    SlotTarget::Unknown {
656        value: effective_va,
657    }
658}
659
660/// Check if a symbol name refers to __cxa_pure_virtual or __cxa_deleted_virtual.
661///
662/// Mach-O prepends one underscore to C names, so the standard library symbols
663/// appear as `___cxa_pure_virtual` (3 underscores) in the symbol table. We
664/// strip one leading underscore and match the C-level name with its own leading
665/// double-underscore.
666fn is_pure_virtual_name(name: &str) -> bool {
667    let stripped = name.strip_prefix('_').unwrap_or(name);
668    matches!(stripped, "__cxa_pure_virtual" | "__cxa_deleted_virtual")
669}