diff --git a/binocular/__init__.py b/binocular/__init__.py index b80c648..69a913b 100644 --- a/binocular/__init__.py +++ b/binocular/__init__.py @@ -12,11 +12,13 @@ BasicBlock, Binary, Branch, + ClassInfo, Instruction, NativeFunction, Reference, SourceFunction, Variable, + VTableEntry, ) from .rizin import Rizin from .binja import BinaryNinja @@ -48,4 +50,6 @@ "Variable", "IndirectToken", "Reference", + "ClassInfo", + "VTableEntry", ] diff --git a/binocular/binja.py b/binocular/binja.py index b50cf5d..c26b5b8 100644 --- a/binocular/binja.py +++ b/binocular/binja.py @@ -414,6 +414,226 @@ def get_ir_from_instruction(self, instr_addr: int, instr: Instruction) -> IR | N return instr.vex() + @override + def get_classes(self) -> Iterable[typing.Any]: + from .primitives import ClassInfo + + bn = _import_binja() + ptr_size = self.get_bitness() // 8 + is_big_endian = self.bv.endianness == bn.Endianness.BigEndian + + for name, sym_list in self.bv.symbols.items(): + is_gcc = name.startswith("_ZTV") + is_msvc = not is_gcc and ( + "vftable" in name.lower() + or (name.startswith("??_7") and name.endswith("@@6B@")) + ) + if not (is_gcc or is_msvc): + continue + + for sym in sym_list: + class_name = self._bn_demangle(name, is_gcc) + if not class_name: + continue + + vtable_addr = sym.address + entries = self._bn_read_vtable( + vtable_addr, ptr_size, is_big_endian, is_gcc + ) + + if is_gcc: + zti_name = "_ZTI" + name[4:] + base_classes, has_multi, has_virtual = self._bn_parse_gcc_rtti( + zti_name, ptr_size, is_big_endian + ) + else: + base_classes, has_multi, has_virtual = [], False, False + + yield ClassInfo( + name=class_name, + vtable_addr=vtable_addr, + vtable=entries, + base_classes=base_classes, + has_multiple_inheritance=has_multi, + has_virtual_inheritance=has_virtual, + ) + + def _bn_read_ptr(self, addr: int, ptr_size: int, is_big_endian: bool) -> int | None: + import struct + + raw = self.bv.read(addr, ptr_size) + if not raw or len(raw) < ptr_size: + return None + endian = ">" if is_big_endian else "<" + fmt = f"{endian}{'Q' if ptr_size == 8 else 'I'}" + return struct.unpack(fmt, raw)[0] + + def _bn_is_exec(self, addr_val: int) -> bool: + if not addr_val: + return False + seg = self.bv.get_segment_at(addr_val) + return seg is not None and seg.executable + + def _bn_is_pure_virtual(self, addr_val: int) -> bool: + syms = self.bv.get_symbols_at(addr_val) + for sym in syms: + if any( + kw in sym.name for kw in ("__cxa_pure_virtual", "_purecall", "purevirt") + ): + return True + return False + + def _bn_demangle(self, mangled: str, is_gcc: bool) -> str | None: + bn = _import_binja() + try: + if is_gcc: + _type, parts = bn.demangle_gnu3(self.bv.arch, mangled, simplify=True) + if parts: + full = "::".join(parts) if isinstance(parts, list) else str(parts) + if "vtable for " in full: + return full.split("vtable for ", 1)[1].strip() + # parts may already be [class_name] without the "vtable for" prefix + return full + else: + _type, parts = bn.demangle_ms(self.bv.arch, mangled, simplify=True) + if parts: + full = "::".join(parts) if isinstance(parts, list) else str(parts) + if "::`vftable'" in full: + name = full.split("::`vftable'")[0].strip() + if name.startswith("const "): + name = name[6:] + return name + except Exception: + pass + if is_gcc and mangled.startswith("_ZTV"): + from .rtti_util import itanium_name + + return itanium_name(mangled[4:]) + return None + + def _bn_read_vtable( + self, vtable_addr: int, ptr_size: int, is_big_endian: bool, is_gcc: bool + ) -> list: + from .primitives import VTableEntry + + start_slot = 0 + if is_gcc: + for i in range(4): + val = self._bn_read_ptr( + vtable_addr + i * ptr_size, ptr_size, is_big_endian + ) + if val is not None and self._bn_is_exec(val): + start_slot = i + break + else: + start_slot = 2 + + # Cap slots using BN's data-variable width to avoid over-reading into the VTT. + try: + dv = self.bv.get_data_var_at(vtable_addr) + max_slots = ( + (dv.type.width // ptr_size) if dv and dv.type and dv.type.width else 512 + ) + except Exception: + max_slots = 512 + + entries: list = [] + slot = 0 + addr = vtable_addr + start_slot * ptr_size + consecutive_bad = 0 + + while consecutive_bad < 3 and slot < max_slots: + val = self._bn_read_ptr(addr, ptr_size, is_big_endian) + if val is None: + break + is_pure = self._bn_is_pure_virtual(val) + if not is_pure and not self._bn_is_exec(val): + consecutive_bad += 1 + addr += ptr_size + slot += 1 + continue + consecutive_bad = 0 + entries.append( + VTableEntry( + slot=slot, + byte_offset=(start_slot + slot) * ptr_size, + func_addr=None if is_pure else val, + ) + ) + slot += 1 + addr += ptr_size + + return entries + + def _bn_resolve_zti(self, zti_ptr: int) -> str | None: + syms = self.bv.get_symbols_at(zti_ptr) + for sym in syms: + if sym.name.startswith("_ZTI"): + return self._bn_demangle("_ZTV" + sym.name[4:], is_gcc=True) + return None + + def _bn_parse_gcc_rtti( + self, zti_name: str, ptr_size: int, is_big_endian: bool + ) -> tuple[list[str], bool, bool]: + import struct + + zti_syms = self.bv.symbols.get(zti_name, []) + if not zti_syms: + return [], False, False + + ti_addr = zti_syms[0].address + vptr_val = self._bn_read_ptr(ti_addr, ptr_size, is_big_endian) + if vptr_val is None: + return [], False, False + + is_si = is_vmi = False + for sym in self.bv.get_symbols_at(vptr_val): + if "vmi_class_type_info" in sym.name: + is_vmi = True + elif "si_class_type_info" in sym.name: + is_si = True + + if not is_si and not is_vmi: + return [], False, False + + if is_si: + base_ptr = self._bn_read_ptr( + ti_addr + 2 * ptr_size, ptr_size, is_big_endian + ) + if not base_ptr: + return [], False, False + base = self._bn_resolve_zti(base_ptr) + return ([base] if base else []), False, False + + endian = ">" if is_big_endian else "<" + flags_off = ti_addr + 2 * ptr_size + raw4 = self.bv.read(flags_off, 4) + raw4b = self.bv.read(flags_off + 4, 4) + if not raw4 or not raw4b: + return [], False, False + + flags = struct.unpack(f"{endian}I", raw4)[0] + base_count = struct.unpack(f"{endian}I", raw4b)[0] + if base_count > 64: + return [], False, False + + has_virtual = bool(flags & 1) + base_names: list[str] = [] + pair_start = flags_off + 8 + pair_stride = ptr_size + 8 + + for i in range(base_count): + base_ptr = self._bn_read_ptr( + pair_start + i * pair_stride, ptr_size, is_big_endian + ) + if not base_ptr: + continue + base = self._bn_resolve_zti(base_ptr) + if base: + base_names.append(base) + + return base_names, len(base_names) > 1, has_virtual + @override def get_instruction_comment(self, instr_addr: int) -> str | None: funcs = self.bv.get_functions_containing(instr_addr) diff --git a/binocular/disassembler.py b/binocular/disassembler.py index 44bb3ef..8552532 100644 --- a/binocular/disassembler.py +++ b/binocular/disassembler.py @@ -19,6 +19,7 @@ Binary, Branch, Argument, + ClassInfo, Variable, Reference, BasicBlock, @@ -130,6 +131,10 @@ def get_strings(self) -> Iterable[str]: """ return self._strings() + def get_classes(self) -> Iterable[ClassInfo]: + """Returns C++ class information recovered from RTTI and vtable analysis.""" + return [] + def get_binary_name(self) -> str: """Returns the name of the binary loaded""" return self.binary_filepath.name @@ -350,11 +355,28 @@ def _load(self) -> None: self._binary.functions = self._functions self._binary.build_indexes() self._binary._disassembler = self + self._load_classes() except Exception as e: logger.critical(f"Failed to load binary: {e}") self.is_loaded = False raise + def _load_classes(self) -> None: + if self._binary is None: + return + try: + for cls in self.get_classes(): + self._binary.classes[cls.name] = cls + except Exception as e: + logger.warning(f"[{self.name}] RTTI extraction failed: {e}") + return + # Back-fill derived_classes by inverting the base_classes relationships. + for cls in self._binary.classes.values(): + for base_name in cls.base_classes: + base = self._binary.classes.get(base_name) + if base is not None and cls.name not in base.derived_classes: + base.derived_classes.append(cls.name) + def _load_binary(self) -> Binary: b = Binary( filename=os.path.basename(self.binary_filepath), diff --git a/binocular/ghidra_impl/dragon.py b/binocular/ghidra_impl/dragon.py index e548956..f111162 100644 --- a/binocular/ghidra_impl/dragon.py +++ b/binocular/ghidra_impl/dragon.py @@ -24,7 +24,17 @@ from .core import GhidraBase from ..consts import IL, BranchType, Endian, RefType from ..disassembler import Disassembler -from ..primitives import IR, Argument, Branch, Instruction, Reference, Variable +from ..primitives import ( + IR, + Argument, + Branch, + ClassInfo, + Instruction, + Reference, + Variable, + VTableEntry, +) +from ..rtti_util import itanium_name as _itanium_name logger = logging.getLogger("BINocular") @@ -631,6 +641,228 @@ def _register_java_bundle(self, script: str, bundle_host): bundle = bundle_host.getGhidraBundle(bundle_file) bundle_host.activateAll([bundle], self.monitor, PrintWriter(StringWriter())) + @typing_extensions.override + def get_classes(self) -> typing.Iterable[ClassInfo]: + ptr_size = self.get_bitness() // 8 + symtab = self.program.getSymbolTable() + + for sym in symtab.getSymbolIterator(): + name = str(sym.getName()) + is_gcc = name.startswith("_ZTV") + is_msvc = not is_gcc and ( + "vftable" in name.lower() + or (name.startswith("??_7") and name.endswith("@@6B@")) + ) + if not (is_gcc or is_msvc): + continue + + class_name = self._rtti_demangle(name) + if not class_name: + continue + + vtable_addr = sym.getAddress() + entries = self._rtti_read_vtable(vtable_addr, ptr_size, is_gcc) + + if is_gcc: + zti_name = "_ZTI" + name[4:] + base_classes, has_multi, has_virtual = self._rtti_parse_gcc( + zti_name, ptr_size + ) + else: + base_classes, has_multi, has_virtual = [], False, False + + yield ClassInfo( + name=class_name, + vtable_addr=vtable_addr.getOffset(), + vtable=entries, + base_classes=base_classes, + has_multiple_inheritance=has_multi, + has_virtual_inheritance=has_virtual, + ) + + def _rtti_read_ptr(self, addr, ptr_size: int) -> int | None: + """Read a native-width pointer from addr; returns unsigned value or None.""" + memory = self.program.getMemory() + try: + if ptr_size == 8: + return int(memory.getLong(addr)) & 0xFFFFFFFFFFFFFFFF + else: + return int(memory.getInt(addr)) & 0xFFFFFFFF + except Exception: + return None + + def _rtti_is_exec(self, addr_val: int) -> bool: + if not addr_val: + return False + try: + block = self.program.getMemory().getBlock(self._mk_addr(addr_val)) + return block is not None and block.isExecute() + except Exception: + return False + + def _rtti_is_pure_virtual(self, addr_val: int) -> bool: + try: + for sym in self.program.getSymbolTable().getSymbols( + self._mk_addr(addr_val) + ): + n = str(sym.getName()) + if "__cxa_pure_virtual" in n or "_purecall" in n or "purevirt" in n: + return True + except Exception: + pass + return False + + def _rtti_demangle(self, mangled: str) -> str | None: + """Return the demangled class name from a vtable symbol name.""" + try: + from ghidra.app.util import DemanglerUtil + + result = DemanglerUtil.demangle(self.program, mangled) + if result is not None: + sig = str(result.getSignature(False)) + if "vtable for " in sig: + return sig.split("vtable for ", 1)[1].strip() + if "::`vftable'" in sig: + name = sig.split("::`vftable'")[0].strip() + if name.startswith("const "): + name = name[6:] + return name + except Exception: + pass + if mangled.startswith("_ZTV"): + return _itanium_name(mangled[4:]) + return None + + def _rtti_read_vtable(self, vtable_addr, ptr_size: int, is_gcc: bool) -> list: + # Find the first slot that points to executable code. + start_slot = 0 + if is_gcc: + for i in range(4): + val = self._rtti_read_ptr(vtable_addr.add(i * ptr_size), ptr_size) + if val is not None and self._rtti_is_exec(val): + start_slot = i + break + else: + start_slot = 2 # default: skip offset-to-top + typeinfo ptr + + # Use Ghidra's data-type length as a hard slot cap to avoid over-reading + # into the VTT (Virtual Table Table) that follows in multiple-inheritance layouts. + try: + data = self.program.getListing().getDataAt(vtable_addr) + max_slots = (data.getLength() // ptr_size) if data else 512 + except Exception: + max_slots = 512 + + entries: list = [] + slot = 0 + addr = vtable_addr.add(start_slot * ptr_size) + consecutive_bad = 0 + + while consecutive_bad < 3 and slot < max_slots: + val = self._rtti_read_ptr(addr, ptr_size) + if val is None: + break + is_pure = self._rtti_is_pure_virtual(val) + if not is_pure and not self._rtti_is_exec(val): + consecutive_bad += 1 + addr = addr.add(ptr_size) + slot += 1 + continue + consecutive_bad = 0 + entries.append( + VTableEntry( + slot=slot, + byte_offset=(start_slot + slot) * ptr_size, + func_addr=None if is_pure else val, + ) + ) + slot += 1 + addr = addr.add(ptr_size) + + return entries + + def _rtti_resolve_zti(self, zti_ptr_val: int) -> str | None: + """Given a pointer to a _ZTI structure, return the demangled class name.""" + try: + addr = self._mk_addr(zti_ptr_val) + for sym in self.program.getSymbolTable().getSymbols(addr): + sname = str(sym.getName()) + if sname.startswith("_ZTI"): + return self._rtti_demangle("_ZTV" + sname[4:]) + except Exception: + pass + return None + + def _rtti_parse_gcc( + self, zti_name: str, ptr_size: int + ) -> tuple[list[str], bool, bool]: + """ + Parse a GCC __class_type_info to extract base class names and flags. + Returns (base_classes, has_multiple_inheritance, has_virtual_inheritance). + + Detection strategy (avoids resolving the vptr, which points into external + libstdc++ symbols that Ghidra can't look up by address): + + - __class_type_info (no bases): struct ends at slot 1; slot 2 read returns None. + - __si_class_type_info (1 base): slot 2 holds a pointer to the base _ZTI*. + - __vmi_class_type_info (N bases): slot 2 holds flags(u32) || base_count(u32). + + A vmi flags value (0–7) is too small to collide with any valid _ZTI address. + Uses memory.getInt/getLong (not getBytes) since JPype doesn't update Python + bytearrays in-place when passed to Java byte[] parameters. + """ + symtab = self.program.getSymbolTable() + syms = list(symtab.getGlobalSymbols(zti_name)) + if not syms: + return [], False, False + + ti_addr = syms[0].getAddress() + memory = self.program.getMemory() + + slot2_addr = ti_addr.add(2 * ptr_size) + slot2_val = self._rtti_read_ptr(slot2_addr, ptr_size) + if slot2_val is None: + return [], False, False # __class_type_info: no bases + + # Si check: does slot 2 hold a valid _ZTI* address? + base = self._rtti_resolve_zti(slot2_val) + if base is not None: + return [base], False, False + + # Vmi: slot 2 encodes flags(u32) + base_count(u32). + # getInt reads in program endianness, so the first u32 is always flags. + try: + flags = int(memory.getInt(slot2_addr)) & 0xFFFFFFFF + base_count = int(memory.getInt(slot2_addr.add(4))) & 0xFFFFFFFF + except Exception: + return [], False, False + + if base_count == 0 or base_count > 64 or flags > 3: + return [], False, False + + has_virtual = False + base_names: list[str] = [] + pair_start = slot2_addr.add(8) + pair_stride = ptr_size + 8 # base_ti_ptr + offset_flags (8-byte long) + + for i in range(base_count): + base_ptr = self._rtti_read_ptr(pair_start.add(i * pair_stride), ptr_size) + if not base_ptr: + continue + # Bit 0 of the per-base offset_flags signals virtual inheritance for this base. + off_flags_addr = pair_start.add(i * pair_stride + ptr_size) + try: + per_flags = int(memory.getLong(off_flags_addr)) & 0xFFFFFFFFFFFFFFFF + if per_flags & 1: + has_virtual = True + except Exception: + pass + base_name = self._rtti_resolve_zti(base_ptr) + if base_name: + base_names.append(base_name) + + return base_names, len(base_names) > 1, has_virtual + def run_script( self, script: str, timeout: int, script_args: typing.List[str] | None = None ) -> str | None: diff --git a/binocular/primitives.py b/binocular/primitives.py index f4494e3..9fb602e 100644 --- a/binocular/primitives.py +++ b/binocular/primitives.py @@ -112,6 +112,36 @@ def __repr__(self) -> str: return f"{hex(self.from_)} -{self.type.name}-> {hex(self.to)}" +class VTableEntry(BaseModel): + """A single slot in a C++ virtual function table.""" + + slot: int + """0-based virtual function index within this vtable.""" + byte_offset: int + """Byte offset from the start of the vtable to this slot.""" + func_addr: int | None = None + """Address of the virtual function implementation, or None if pure virtual.""" + + +class ClassInfo(BaseModel): + """Recovered C++ class information including vtable layout and inheritance.""" + + name: str + """Demangled class name.""" + vtable_addr: int | None = None + """Address of the primary vtable, or None if the class has no vtable.""" + vtable: List[VTableEntry] = [] + """Ordered list of virtual function table entries.""" + base_classes: List[str] = [] + """Demangled names of direct base classes, in declaration order.""" + derived_classes: List[str] = [] + """Demangled names of known direct derived classes (populated after all classes load).""" + has_multiple_inheritance: bool = False + """True if this class inherits from more than one direct base class.""" + has_virtual_inheritance: bool = False + """True if any base class is inherited virtually.""" + + class Argument(BaseModel): """Represents a single argument in a function""" @@ -731,6 +761,9 @@ class NoDataException(Exception): # Strings from String table if they exists, otherwise strings detected in the binary (like unix `strings`` command) strings: Set[str] = set([]) + classes: Dict[str, "ClassInfo"] = {} + """Recovered C++ class information keyed by demangled class name.""" + def __len__(self) -> int: """returns the size of the binary in bytes""" if self._size is None: diff --git a/binocular/rizin.py b/binocular/rizin.py index 30a124a..b374020 100644 --- a/binocular/rizin.py +++ b/binocular/rizin.py @@ -488,6 +488,246 @@ def get_bb_instructions( return instrs + @override + def get_classes(self) -> Iterable[Any]: + from .primitives import ClassInfo + from .rtti_util import itanium_name + + ptr_size = max(self.get_bitness() // 8, 1) + is_le = self.get_endianness() != Endian.BIG + ptr_fmt = ("<" if is_le else ">") + ("Q" if ptr_size == 8 else "I") + u32_fmt = ("<" if is_le else ">") + "I" + u64_fmt = "Q" + + try: + sym_list = self.pipe.cmdj("isj") or [] + except Exception: + return + + syms_by_name: Dict[str, int] = {} + syms_by_addr: Dict[int, str] = {} + sym_sizes: Dict[str, int] = {} + + for s in sym_list: + rn = s.get("realname") or "" + va = s.get("vaddr", 0) + sz = s.get("size", 0) + if rn and va: + syms_by_name[rn] = va + syms_by_addr[va] = rn + if rn and sz: + sym_sizes[rn] = sz + + exec_ranges: List[Tuple[int, int]] = [] + try: + for sec in self.pipe.cmdj("iSj") or []: + perm = sec.get("perm") or "" + if "x" in perm: + lo = sec.get("vaddr", 0) + hi = lo + sec.get("vsize", sec.get("size", 0)) + if lo and hi > lo: + exec_ranges.append((lo, hi)) + except Exception: + pass + + for realname, vaddr in syms_by_name.items(): + if not realname.startswith("_ZTV") or realname.startswith("_ZTVN"): + continue + vtable_size = sym_sizes.get(realname, 0) + if vtable_size == 0: + continue + class_name = itanium_name(realname[4:]) + if not class_name: + continue + + entries = self._rz_read_vtable( + vaddr, vtable_size, ptr_size, ptr_fmt, exec_ranges + ) + zti_name = "_ZTI" + realname[4:] + base_classes, has_multi, has_virtual = self._rz_parse_gcc_rtti( + zti_name, + syms_by_name, + syms_by_addr, + sym_sizes, + ptr_size, + ptr_fmt, + u32_fmt, + u64_fmt, + ) + + yield ClassInfo( + name=class_name, + vtable_addr=vaddr, + vtable=entries, + base_classes=base_classes, + has_multiple_inheritance=has_multi, + has_virtual_inheritance=has_virtual, + ) + + def _rz_read_chunk(self, addr: int, size: int) -> bytes | None: + try: + raw = self.pipe.cmdj(f"pxj {size} @ {addr}") + if not raw or len(raw) < size: + return None + return bytes(raw[:size]) + except Exception: + return None + + def _rz_read_ptr(self, addr: int, ptr_size: int, ptr_fmt: str) -> int | None: + import struct + + data = self._rz_read_chunk(addr, ptr_size) + return struct.unpack(ptr_fmt, data)[0] if data else None + + def _rz_read_u32(self, addr: int, u32_fmt: str) -> int | None: + import struct + + data = self._rz_read_chunk(addr, 4) + return struct.unpack(u32_fmt, data)[0] if data else None + + def _rz_read_u64(self, addr: int, u64_fmt: str) -> int | None: + import struct + + data = self._rz_read_chunk(addr, 8) + return struct.unpack(u64_fmt, data)[0] if data else None + + @staticmethod + def _rz_addr_is_exec(addr: int, exec_ranges: List[Tuple[int, int]]) -> bool: + return bool(addr) and any(lo <= addr < hi for lo, hi in exec_ranges) + + def _rz_resolve_zti(self, ptr_val: int, syms_by_addr: Dict[int, str]) -> str | None: + from .rtti_util import itanium_name + + sname = syms_by_addr.get(ptr_val, "") + if sname.startswith("_ZTI"): + return itanium_name(sname[4:]) + return None + + def _rz_read_vtable( + self, + vtable_addr: int, + vtable_size: int, + ptr_size: int, + ptr_fmt: str, + exec_ranges: List[Tuple[int, int]], + ) -> list: + """ + Read vtable function slots using the known byte size of the vtable symbol. + + In a PIE ELF, Rizin reads raw file bytes for .data.rel.ro: + - R_X86_64_RELATIVE slots store the addend (= function vaddr at base 0) → exec + - R_X86_64_64 for __cxa_pure_virtual → non-exec, non-zero fake address + - Abstract-class dtor slots with no relocation → 0 (skip silently) + + Classification after skipping the header (offset-to-top + typeinfo ptr): + - addr is exec → regular virtual function + - addr is 0 → unresolved slot (abstract dtor), skip + - addr is non-zero non-exec → pure virtual marker (func_addr=None) + """ + from .primitives import VTableEntry + + total_slots = vtable_size // ptr_size + start_slot = 2 # default: skip offset-to-top + typeinfo + for i in range(min(4, total_slots)): + val = self._rz_read_ptr(vtable_addr + i * ptr_size, ptr_size, ptr_fmt) + if val and self._rz_addr_is_exec(val, exec_ranges): + start_slot = i + break + + entries: list = [] + for slot in range(total_slots - start_slot): + val = self._rz_read_ptr( + vtable_addr + (start_slot + slot) * ptr_size, ptr_size, ptr_fmt + ) + if val is None: + break + if val == 0: + continue # abstract-class dtor slot with no RELA in file + if self._rz_addr_is_exec(val, exec_ranges): + entries.append( + VTableEntry( + slot=slot, + byte_offset=(start_slot + slot) * ptr_size, + func_addr=val, + ) + ) + else: + # Non-zero, non-exec: Rizin resolved an external symbol reference + # (e.g. __cxa_pure_virtual via R_X86_64_64) to a fake address. + entries.append( + VTableEntry( + slot=slot, + byte_offset=(start_slot + slot) * ptr_size, + func_addr=None, + ) + ) + return entries + + def _rz_parse_gcc_rtti( + self, + zti_name: str, + syms_by_name: Dict[str, int], + syms_by_addr: Dict[int, str], + sym_sizes: Dict[str, int], + ptr_size: int, + ptr_fmt: str, + u32_fmt: str, + u64_fmt: str, + ) -> tuple[list[str], bool, bool]: + """ + Structural RTTI parsing using symbol size to distinguish typeinfo subtypes + without chasing the vptr (which points to external libstdc++ symbols with + fake addresses in Rizin): + - size == 2*ptr_size → __class_type_info: no bases + - size == 3*ptr_size → __si_class_type_info: 1 base at slot 2 + - size > 3*ptr_size → __vmi_class_type_info: flags+count at slot 2 + """ + zti_addr = syms_by_name.get(zti_name, 0) + if not zti_addr: + return [], False, False + + zti_size = sym_sizes.get(zti_name, 0) + if zti_size <= 2 * ptr_size: + return [], False, False # __class_type_info: no bases + + slot2_addr = zti_addr + 2 * ptr_size + + if zti_size == 3 * ptr_size: + # __si_class_type_info: slot 2 is the base _ZTI* pointer + base_ptr = self._rz_read_ptr(slot2_addr, ptr_size, ptr_fmt) + if not base_ptr: + return [], False, False + base = self._rz_resolve_zti(base_ptr, syms_by_addr) + return ([base] if base else []), False, False + + # __vmi_class_type_info: slot 2 = flags(u32) + base_count(u32) + flags = self._rz_read_u32(slot2_addr, u32_fmt) + base_count = self._rz_read_u32(slot2_addr + 4, u32_fmt) + if flags is None or base_count is None or base_count == 0 or base_count > 64: + return [], False, False + + has_virtual = False + base_names: List[str] = [] + pair_start = slot2_addr + 8 + pair_stride = ptr_size + 8 # base_ti_ptr + offset_flags (8-byte long) + + for i in range(base_count): + base_ptr = self._rz_read_ptr( + pair_start + i * pair_stride, ptr_size, ptr_fmt + ) + if not base_ptr: + continue + off_flags = self._rz_read_u64( + pair_start + i * pair_stride + ptr_size, u64_fmt + ) + if off_flags is not None and (off_flags & 1): + has_virtual = True + base = self._rz_resolve_zti(base_ptr, syms_by_addr) + if base: + base_names.append(base) + + return base_names, len(base_names) > 1, has_virtual + def get_ir_from_instruction(self, instr_addr: int, instr: Instruction) -> IR | None: """ Returns the Intermediate Representation data based on the instruction given diff --git a/binocular/rtti_util.py b/binocular/rtti_util.py new file mode 100644 index 0000000..3801149 --- /dev/null +++ b/binocular/rtti_util.py @@ -0,0 +1,41 @@ +from __future__ import annotations + + +def itanium_name(suffix: str) -> str | None: + """Decode an Itanium ABI class name from the post-_ZTV/_ZTI suffix. + + Handles two forms: + N...E — nested name (e.g. N3Foo3BarE → Foo::Bar) + — simple name (e.g. 6Logger → Logger) + """ + if not suffix: + return None + if suffix[0] == "N": + parts: list[str] = [] + i = 1 + while i < len(suffix) and suffix[i] != "E": + if not suffix[i].isdigit(): + i += 1 + continue + j = i + while j < len(suffix) and suffix[j].isdigit(): + j += 1 + try: + n = int(suffix[i:j]) + except ValueError: + break + if j + n > len(suffix): + break + parts.append(suffix[j : j + n]) + i = j + n + return "::".join(parts) if parts else None + if suffix[0].isdigit(): + i = 0 + while i < len(suffix) and suffix[i].isdigit(): + i += 1 + try: + n = int(suffix[:i]) + return suffix[i : i + n] if i + n <= len(suffix) else None + except ValueError: + return None + return None diff --git a/test/.gitignore b/test/.gitignore index 82ef6ba..b23480a 100644 --- a/test/.gitignore +++ b/test/.gitignore @@ -3,3 +3,6 @@ example2 example3 example_stripped __pycache__/ +rtti_diamond +rtti_inherit +rtti_simple diff --git a/test/Makefile b/test/Makefile index 809b731..a8c8335 100644 --- a/test/Makefile +++ b/test/Makefile @@ -1,8 +1,22 @@ -CC = gcc +CC = gcc +CXX = g++ -all: +all: c_examples cpp_rtti + +c_examples: $(CC) example.c -o example -g $(CC) example2.c -o example2 -g arm-linux-gnueabihf-gcc example2.c -o example3 -g $(CC) example.c -o example_stripped strip --strip-all example_stripped + +cpp_rtti: rtti_simple rtti_inherit rtti_diamond + +rtti_simple: rtti_simple.cpp + $(CXX) -std=c++17 -g -frtti -o rtti_simple rtti_simple.cpp + +rtti_inherit: rtti_inherit.cpp + $(CXX) -std=c++17 -g -frtti -o rtti_inherit rtti_inherit.cpp -lm + +rtti_diamond: rtti_diamond.cpp + $(CXX) -std=c++17 -g -frtti -o rtti_diamond rtti_diamond.cpp diff --git a/test/conftest.py b/test/conftest.py index 4aac874..f6aeee5 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -11,3 +11,13 @@ def make(): assert os.path.exists("example") assert os.path.exists("example_stripped") + + +@pytest.fixture(scope="module") +def make_cpp(): + p = subprocess.Popen(["make", "cpp_rtti"]) + p.communicate(timeout=60) + assert p.returncode == 0, "make cpp_rtti failed" + + for name in ("rtti_simple", "rtti_inherit", "rtti_diamond"): + assert os.path.exists(name), f"C++ test binary '{name}' was not built" diff --git a/test/rtti_diamond.cpp b/test/rtti_diamond.cpp new file mode 100644 index 0000000..ed10add --- /dev/null +++ b/test/rtti_diamond.cpp @@ -0,0 +1,84 @@ +// Diamond inheritance with virtual base classes. +// +// Vehicle (base) +// / \ +// Car Boat (virtual inheritance from Vehicle) +// \ / +// Amphibious (inherits from Car and Boat) +// +// Virtual inheritance ensures Vehicle's subobject is shared, producing: +// __vmi_class_type_info for Car, Boat, and Amphibious +// __si_class_type_info for Vehicle (or __class_type_info if truly standalone) +// +// Tests: has_multiple_inheritance, has_virtual_inheritance flags, vptr layout +// (Amphibious has two vptrs — one for Car, one for Boat), and that +// base_classes for Amphibious lists both Car and Boat. +#include + +class Vehicle { +public: + int max_speed; + explicit Vehicle(int spd) : max_speed(spd) {} + virtual ~Vehicle() {} + virtual const char* kind() const { return "Vehicle"; } + virtual void move() const { printf("Vehicle moving at %d\n", max_speed); } +}; + +class Car : public virtual Vehicle { +public: + int num_wheels; + Car(int spd, int wheels) : Vehicle(spd), num_wheels(wheels) {} + virtual ~Car() {} + const char* kind() const override { return "Car"; } + void move() const override { printf("Car driving at %d on %d wheels\n", max_speed, num_wheels); } + virtual void honk() const { printf("Beep!\n"); } +}; + +class Boat : public virtual Vehicle { +public: + double displacement; + Boat(int spd, double disp) : Vehicle(spd), displacement(disp) {} + virtual ~Boat() {} + const char* kind() const override { return "Boat"; } + void move() const override { printf("Boat sailing at %d, displacement=%.1f t\n", max_speed, displacement); } + virtual void horn() const { printf("Honk!\n"); } +}; + +class Amphibious : public Car, public Boat { +public: + Amphibious(int spd, int wheels, double disp) + : Vehicle(spd), Car(spd, wheels), Boat(spd, disp) {} + virtual ~Amphibious() {} + const char* kind() const override { return "Amphibious"; } + void move() const override { + printf("Amphibious at %d: wheels=%d disp=%.1f\n", max_speed, num_wheels, displacement); + } +}; + +static void describe(Vehicle& v) { + printf("kind=%s\n", v.kind()); + v.move(); +} + +int main() { + Vehicle veh(60); + Car car(120, 4); + Boat boat(40, 1.5); + Amphibious amph(80, 4, 2.0); + + describe(veh); + describe(car); + describe(boat); + describe(amph); + + car.honk(); + boat.horn(); + + // Access via Car* and Boat* base pointers to exercise both vptrs + Car* as_car = &h; + Boat* as_boat = &h; + as_car->honk(); + as_boat->horn(); + + return 0; +} diff --git a/test/rtti_inherit.cpp b/test/rtti_inherit.cpp new file mode 100644 index 0000000..2dee3da --- /dev/null +++ b/test/rtti_inherit.cpp @@ -0,0 +1,71 @@ +// Three-level inheritance chain with polymorphism. +// Shape -> Polygon -> Rectangle -> Square +// Tests: _ZTI chain depth, base_classes at each level, vtable slot inheritance, +// pure virtual methods, and override resolution. +#include +#include + +class Shape { +public: + virtual ~Shape() {} + virtual const char* name() const = 0; // pure virtual + virtual double area() const = 0; // pure virtual + virtual double perimeter() const = 0; // pure virtual + virtual void describe() const { + printf("%s: area=%.2f perimeter=%.2f\n", name(), area(), perimeter()); + } +}; + +class Polygon : public Shape { +public: + int sides; + explicit Polygon(int s) : sides(s) {} + virtual ~Polygon() {} + const char* name() const override { return "Polygon"; } + double area() const override { return 0.0; } + double perimeter() const override { return 0.0; } + virtual int num_sides() const { return sides; } +}; + +class Rectangle : public Polygon { +public: + double width, height; + Rectangle(double w, double h) : Polygon(4), width(w), height(h) {} + virtual ~Rectangle() {} + const char* name() const override { return "Rectangle"; } + double area() const override { return width * height; } + double perimeter() const override { return 2.0 * (width + height); } + virtual double diagonal() const { return sqrt(width * width + height * height); } +}; + +class Square : public Rectangle { +public: + explicit Square(double side) : Rectangle(side, side) {} + virtual ~Square() {} + const char* name() const override { return "Square"; } + // diagonal() inherited from Rectangle — vtable slot shared, not overridden +}; + +static void print_shape(const Shape& s) { + s.describe(); +} + +int main() { + Polygon poly(6); + Rectangle rect(3.0, 4.0); + Square sq(5.0); + + print_shape(poly); + print_shape(rect); + print_shape(sq); + + printf("rect diagonal=%.2f\n", rect.diagonal()); + printf("sq diagonal=%.2f\n", sq.diagonal()); + + // Polymorphic array exercises vtable dispatch + Shape* shapes[3] = { &poly, &rect, &sq }; + for (int i = 0; i < 3; ++i) + shapes[i]->describe(); + + return 0; +} diff --git a/test/rtti_simple.cpp b/test/rtti_simple.cpp new file mode 100644 index 0000000..399867f --- /dev/null +++ b/test/rtti_simple.cpp @@ -0,0 +1,42 @@ +// Single class with virtual methods, no inheritance. +// Tests baseline vtable extraction: one class, one vtable, no RTTI type hierarchy. +#include +#include + +class Logger { +public: + char tag[32]; + int level; + + Logger(const char* t, int lvl) : level(lvl) { + strncpy(tag, t, sizeof(tag) - 1); + tag[sizeof(tag) - 1] = '\0'; + } + + virtual ~Logger() {} + + virtual void log(const char* msg) { + printf("[%s] %s\n", tag, msg); + } + + virtual void warn(const char* msg) { + printf("[%s] WARN: %s\n", tag, msg); + } + + virtual void error(const char* msg) { + printf("[%s] ERROR: %s\n", tag, msg); + } + + virtual int get_level() const { + return level; + } +}; + +int main() { + Logger l("app", 1); + l.log("started"); + l.warn("low memory"); + l.error("disk full"); + printf("level=%d\n", l.get_level()); + return 0; +} diff --git a/test/test_rtti.py b/test/test_rtti.py new file mode 100644 index 0000000..de0a3fa --- /dev/null +++ b/test/test_rtti.py @@ -0,0 +1,258 @@ +# Tests for C++ RTTI and vtable extraction across all disassembler backends. +# +# Three scenarios, each compiled into its own binary by `make cpp_rtti`: +# +# rtti_simple — Logger: one class, no inheritance +# rtti_inherit — Shape → Polygon → Rectangle → Square (3-level single inheritance) +# rtti_diamond — Vehicle ←(virtual) Car, Vehicle ←(virtual) Boat, Car+Boat → Amphibious +# +# Each scenario section has three assertion helpers (one per binary) that are +# called from per-backend test functions so failures pinpoint both the scenario +# and the backend. + +import pytest + +from binocular import ClassInfo + + +# --------------------------------------------------------------------------- +# Shared assertion helpers +# --------------------------------------------------------------------------- + + +def _assert_vtable(cls: ClassInfo, min_entries: int = 1) -> None: + assert cls.vtable_addr is not None and cls.vtable_addr > 0, ( + f"{cls.name}: expected non-zero vtable_addr" + ) + assert len(cls.vtable) >= min_entries, ( + f"{cls.name}: expected at least {min_entries} vtable entries, got {len(cls.vtable)}" + ) + slots = [e.slot for e in cls.vtable] + assert slots == sorted(slots), f"{cls.name}: vtable slots are not in order: {slots}" + + +def _assert_simple_rtti(binary) -> None: + """Logger: one class, virtual methods, no inheritance.""" + classes = binary.classes + assert "Logger" in classes, f"expected 'Logger' in classes, got: {list(classes)}" + + logger = classes["Logger"] + assert logger.base_classes == [], "Logger should have no base classes" + assert logger.derived_classes == [], "Logger should have no derived classes" + assert not logger.has_multiple_inheritance + assert not logger.has_virtual_inheritance + + # Logger has: ~Logger (x2 dtor slots), log, warn, error, get_level — at least 4 + _assert_vtable(logger, min_entries=4) + + # No pure-virtual methods in Logger + for entry in logger.vtable: + assert entry.func_addr is not None, ( + f"Logger vtable slot {entry.slot} should not be pure virtual" + ) + assert entry.func_addr > 0 + + +def _assert_inherit_rtti(binary) -> None: + """Shape → Polygon → Rectangle → Square: 3-level single inheritance chain.""" + classes = binary.classes + for name in ("Shape", "Polygon", "Rectangle", "Square"): + assert name in classes, f"expected '{name}' in classes, got: {list(classes)}" + + shape = classes["Shape"] + polygon = classes["Polygon"] + rectangle = classes["Rectangle"] + square = classes["Square"] + + # Base-class chain + assert shape.base_classes == [] + assert "Shape" in polygon.base_classes + assert "Polygon" in rectangle.base_classes + assert "Rectangle" in square.base_classes + + # No multiple/virtual inheritance anywhere in this hierarchy + for cls in (shape, polygon, rectangle, square): + assert not cls.has_multiple_inheritance, ( + f"{cls.name}.has_multiple_inheritance should be False" + ) + assert not cls.has_virtual_inheritance, ( + f"{cls.name}.has_virtual_inheritance should be False" + ) + + # Derived-class back-fill (computed in _load_classes after all classes load) + assert "Polygon" in shape.derived_classes, ( + "Shape.derived_classes should include Polygon" + ) + assert "Rectangle" in polygon.derived_classes, ( + "Polygon.derived_classes should include Rectangle" + ) + assert "Square" in rectangle.derived_classes, ( + "Rectangle.derived_classes should include Square" + ) + assert square.derived_classes == [] + + # All four classes have vtables; leaf class (Square) has at least as many + # slots as the root (Shape) since it inherits all virtual methods. + for cls in (shape, polygon, rectangle, square): + _assert_vtable(cls, min_entries=1) + + assert len(square.vtable) >= len(shape.vtable), ( + "Square should have at least as many vtable slots as Shape" + ) + + # Shape has pure-virtual methods; confirm at least one pure-virtual slot + pure_slots = [e for e in shape.vtable if e.func_addr is None] + assert len(pure_slots) >= 1, ( + "Shape should have at least one pure-virtual vtable slot" + ) + + +def _assert_diamond_rtti(binary) -> None: + """Vehicle ←(virt) Car, Vehicle ←(virt) Boat, Car+Boat → Amphibious.""" + classes = binary.classes + for name in ("Vehicle", "Car", "Boat", "Amphibious"): + assert name in classes, f"expected '{name}' in classes, got: {list(classes)}" + + vehicle = classes["Vehicle"] + car = classes["Car"] + boat = classes["Boat"] + amphibious = classes["Amphibious"] + + # Base-class relationships + assert vehicle.base_classes == [] + assert "Vehicle" in car.base_classes + assert "Vehicle" in boat.base_classes + assert "Car" in amphibious.base_classes + assert "Boat" in amphibious.base_classes + + # Amphibious inherits from two direct bases → multiple inheritance + assert amphibious.has_multiple_inheritance, ( + "Amphibious should have has_multiple_inheritance=True" + ) + + # Car and Boat use `virtual Vehicle` → virtual inheritance + assert car.has_virtual_inheritance, "Car should have has_virtual_inheritance=True" + assert boat.has_virtual_inheritance, "Boat should have has_virtual_inheritance=True" + + # Derived-class back-fill + assert "Car" in vehicle.derived_classes, ( + "Vehicle.derived_classes should include Car" + ) + assert "Boat" in vehicle.derived_classes, ( + "Vehicle.derived_classes should include Boat" + ) + assert "Amphibious" in car.derived_classes, ( + "Car.derived_classes should include Amphibious" + ) + assert "Amphibious" in boat.derived_classes, ( + "Boat.derived_classes should include Amphibious" + ) + + # All four classes have vtables + for cls in (vehicle, car, boat, amphibious): + _assert_vtable(cls, min_entries=1) + + +# --------------------------------------------------------------------------- +# Ghidra (pyghidra / dragon backend, Ghidra >= 12) +# --------------------------------------------------------------------------- + + +class TestRTTIGhidra: + @pytest.fixture(autouse=True) + def _skip_if_missing(self): + from binocular import Ghidra + + if not Ghidra.is_installed(): + pytest.skip("Ghidra not installed") + + def test_simple(self, make_cpp): + from binocular import Ghidra + + with Ghidra("rtti_simple") as g: + g.analyze() + _assert_simple_rtti(g.binary) + + def test_inherit(self, make_cpp): + from binocular import Ghidra + + with Ghidra("rtti_inherit") as g: + g.analyze() + _assert_inherit_rtti(g.binary) + + def test_diamond(self, make_cpp): + from binocular import Ghidra + + with Ghidra("rtti_diamond") as g: + g.analyze() + _assert_diamond_rtti(g.binary) + + +# --------------------------------------------------------------------------- +# Rizin +# --------------------------------------------------------------------------- + + +class TestRTTIRizin: + @pytest.fixture(autouse=True) + def _skip_if_missing(self): + from binocular import Rizin + + if not Rizin.is_installed(): + pytest.skip("Rizin not installed") + + def test_simple(self, make_cpp): + from binocular import Rizin + + with Rizin("rtti_simple") as g: + g.analyze() + _assert_simple_rtti(g.binary) + + def test_inherit(self, make_cpp): + from binocular import Rizin + + with Rizin("rtti_inherit") as g: + g.analyze() + _assert_inherit_rtti(g.binary) + + def test_diamond(self, make_cpp): + from binocular import Rizin + + with Rizin("rtti_diamond") as g: + g.analyze() + _assert_diamond_rtti(g.binary) + + +# --------------------------------------------------------------------------- +# Binary Ninja +# --------------------------------------------------------------------------- + + +class TestRTTIBinaryNinja: + @pytest.fixture(autouse=True) + def _skip_if_missing(self): + from binocular import BinaryNinja + + if not BinaryNinja.is_installed(): + pytest.skip("Binary Ninja not installed") + + def test_simple(self, make_cpp): + from binocular import BinaryNinja + + with BinaryNinja("rtti_simple") as g: + g.analyze() + _assert_simple_rtti(g.binary) + + def test_inherit(self, make_cpp): + from binocular import BinaryNinja + + with BinaryNinja("rtti_inherit") as g: + g.analyze() + _assert_inherit_rtti(g.binary) + + def test_diamond(self, make_cpp): + from binocular import BinaryNinja + + with BinaryNinja("rtti_diamond") as g: + g.analyze() + _assert_diamond_rtti(g.binary)