From 20cb3b9b1579f9e5519d9304785eb30978417c1a Mon Sep 17 00:00:00 2001 From: brandon Date: Sun, 7 Jun 2026 21:23:11 -0400 Subject: [PATCH 1/2] Add C++ RTTI extraction for Ghidra (dragon) and Rizin backends Implements get_classes() on the dragon and rizin disassembler backends, exposing vtable addresses, virtual method slots, base class chains, and multiple/virtual inheritance flags via structural Itanium ABI parsing. Adds C++ test binaries (no-inheritance, 3-level chain, diamond) and a full test suite covering all three scenarios on both backends. Co-Authored-By: Claude Sonnet 4.6 --- binocular/__init__.py | 4 + binocular/binja.py | 235 +++++++++++++++++++++++++++++ binocular/disassembler.py | 22 +++ binocular/ghidra_impl/dragon.py | 252 ++++++++++++++++++++++++++++++- binocular/primitives.py | 33 ++++ binocular/rizin.py | 239 +++++++++++++++++++++++++++++ test/.gitignore | 3 + test/Makefile | 18 ++- test/conftest.py | 9 ++ test/rtti_diamond.cpp | 84 +++++++++++ test/rtti_inherit.cpp | 71 +++++++++ test/rtti_simple.cpp | 42 ++++++ test/test_rtti.py | 259 ++++++++++++++++++++++++++++++++ 13 files changed, 1268 insertions(+), 3 deletions(-) create mode 100644 test/rtti_diamond.cpp create mode 100644 test/rtti_inherit.cpp create mode 100644 test/rtti_simple.cpp create mode 100644 test/test_rtti.py 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..0138afa 100644 --- a/binocular/binja.py +++ b/binocular/binja.py @@ -414,6 +414,241 @@ 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"): + return self._bn_itanium_name(mangled[4:]) + return None + + def _bn_itanium_name(self, suffix: str) -> str | None: + if not suffix: + return None + if suffix[0] == "N": + parts, 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 + n = int(suffix[i:j]) + if j + n > len(suffix): + break + parts.append(suffix[j : j + n]) + i = j + n + return "::".join(parts) if parts else None + elif suffix[0].isdigit(): + i = 0 + while i < len(suffix) and suffix[i].isdigit(): + i += 1 + n = int(suffix[:i]) + return suffix[i : i + n] if i + n <= len(suffix) else None + 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 + + entries: list = [] + slot = 0 + addr = vtable_addr + start_slot * ptr_size + consecutive_bad = 0 + + while consecutive_bad < 3: + 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..ad931ca 100644 --- a/binocular/ghidra_impl/dragon.py +++ b/binocular/ghidra_impl/dragon.py @@ -24,7 +24,16 @@ 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, +) logger = logging.getLogger("BINocular") @@ -631,6 +640,247 @@ 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 self._rtti_itanium_name(mangled[4:]) + return None + + def _rtti_itanium_name(self, suffix: str) -> str | None: + """Manually decode an Itanium ABI class name from the post-_ZTV suffix.""" + if not suffix: + return None + if suffix[0] == "N": + parts, 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 + n = int(suffix[i:j]) + if j + n > len(suffix): + break + parts.append(suffix[j : j + n]) + i = j + n + return "::".join(parts) if parts else None + elif suffix[0].isdigit(): + i = 0 + while i < len(suffix) and suffix[i].isdigit(): + i += 1 + n = int(suffix[:i]) + return suffix[i : i + n] if i + n <= len(suffix) else None + 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 + + entries: list = [] + slot = 0 + addr = vtable_addr.add(start_slot * ptr_size) + consecutive_bad = 0 + + while consecutive_bad < 3: + 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 > 7: + 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..42dd9e1 100644 --- a/binocular/rizin.py +++ b/binocular/rizin.py @@ -488,6 +488,245 @@ def get_bb_instructions( return instrs + @override + def get_classes(self) -> Iterable[Any]: + import struct + from .primitives import ClassInfo, VTableEntry + + 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" + + def read_chunk(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 read_ptr(addr: int) -> int | None: + data = read_chunk(addr, ptr_size) + return struct.unpack(ptr_fmt, data)[0] if data else None + + def read_u32(addr: int) -> int | None: + data = read_chunk(addr, 4) + return struct.unpack(u32_fmt, data)[0] if data else None + + def read_u64(addr: int) -> int | None: + data = read_chunk(addr, 8) + return struct.unpack(u64_fmt, data)[0] if data else None + + # Collect all symbols from the symbol table. + try: + sym_list = self.pipe.cmdj("isj") or [] + except Exception: + return + + syms_by_name: Dict[str, int] = {} # realname -> vaddr + syms_by_addr: Dict[int, str] = {} # vaddr -> realname + sym_sizes: Dict[str, int] = {} # realname -> byte size + + 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 + + # Build executable address ranges from section info. + 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 + + def addr_is_exec(addr: int) -> bool: + return bool(addr) and any(lo <= addr < hi for lo, hi in exec_ranges) + + def itanium_name(suffix: str) -> str | None: + """Manually decode an Itanium ABI class name from the post-_ZTI/_ZTV suffix.""" + if not suffix: + return None + if suffix[0] == "N": + parts, 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 + + def resolve_zti(ptr_val: int) -> str | None: + sname = syms_by_addr.get(ptr_val, "") + if sname.startswith("_ZTI"): + return itanium_name(sname[4:]) + return None + + def read_vtable(vtable_addr: int, vtable_size: 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) + """ + total_slots = vtable_size // ptr_size + # Detect where function pointers start (first exec slot in first 4 header slots). + start_slot = 2 # default: skip offset-to-top + typeinfo + for i in range(min(4, total_slots)): + val = read_ptr(vtable_addr + i * ptr_size) + if val and addr_is_exec(val): + start_slot = i + break + + entries: list = [] + for slot in range(total_slots - start_slot): + val = read_ptr(vtable_addr + (start_slot + slot) * ptr_size) + if val is None: + break + if val == 0: + continue # abstract-class dtor slot with no RELA in file + if addr_is_exec(val): + 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 parse_gcc_rtti(zti_name: str) -> tuple: + """ + Structural RTTI parsing identical to the Ghidra backend's approach. + + Uses symbol size to distinguish typeinfo subtypes without chasing the + vptr (which points to external libstdc++ symbols with fake addresses): + - 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 = read_ptr(slot2_addr) + if not base_ptr: + return [], False, False + base = resolve_zti(base_ptr) + return ([base] if base else []), False, False + + # __vmi_class_type_info: slot 2 = flags(u32) + base_count(u32) + flags = read_u32(slot2_addr) + base_count = read_u32(slot2_addr + 4) + 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 = read_ptr(pair_start + i * pair_stride) + if not base_ptr: + continue + off_flags = read_u64(pair_start + i * pair_stride + ptr_size) + if off_flags is not None and (off_flags & 1): + has_virtual = True + base = resolve_zti(base_ptr) + if base: + base_names.append(base) + + return base_names, len(base_names) > 1, has_virtual + + # Iterate over _ZTV* symbols (vtables), skipping external typeinfo vtables. + 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 = read_vtable(vaddr, vtable_size) + zti_name = "_ZTI" + realname[4:] + base_classes, has_multi, has_virtual = parse_gcc_rtti(zti_name) + + 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 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/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..91e528c 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -11,3 +11,12 @@ 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) + + 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..8201d15 --- /dev/null +++ b/test/test_rtti.py @@ -0,0 +1,259 @@ +# 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 + + with Rizin("rtti_simple") as g: + if not g.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) From 4708badd48ce00b6c6f377f3c6877e1d5c67ebb5 Mon Sep 17 00:00:00 2001 From: brandon Date: Mon, 8 Jun 2026 01:03:49 -0400 Subject: [PATCH 2/2] Fix RTTI correctness and code quality issues from PR review - Extract shared itanium_name() decoder into binocular/rtti_util.py, removing duplicated implementations in dragon.py and binja.py - Refactor rizin.py get_classes() nested functions into private _rz_* methods for readability and testability - Add symbol-size-based slot cap in Ghidra and Binja vtable readers to prevent over-reading into the VTT in multiple-inheritance layouts - Fix VMI flags bound: flags > 7 -> flags > 3 (only bits 0-1 are ABI-defined) - Fix TestRTTIRizin skip check to call Rizin.is_installed() as a class method, avoiding a FileNotFoundError before make_cpp runs - Assert make returncode in make_cpp fixture for a clear failure message Co-Authored-By: Claude Sonnet 4.6 --- binocular/binja.py | 39 ++-- binocular/ghidra_impl/dragon.py | 42 ++-- binocular/rizin.py | 363 ++++++++++++++++---------------- binocular/rtti_util.py | 41 ++++ test/conftest.py | 1 + test/test_rtti.py | 5 +- 6 files changed, 250 insertions(+), 241 deletions(-) create mode 100644 binocular/rtti_util.py diff --git a/binocular/binja.py b/binocular/binja.py index 0138afa..c26b5b8 100644 --- a/binocular/binja.py +++ b/binocular/binja.py @@ -506,33 +506,9 @@ def _bn_demangle(self, mangled: str, is_gcc: bool) -> str | None: except Exception: pass if is_gcc and mangled.startswith("_ZTV"): - return self._bn_itanium_name(mangled[4:]) - return None + from .rtti_util import itanium_name - def _bn_itanium_name(self, suffix: str) -> str | None: - if not suffix: - return None - if suffix[0] == "N": - parts, 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 - n = int(suffix[i:j]) - if j + n > len(suffix): - break - parts.append(suffix[j : j + n]) - i = j + n - return "::".join(parts) if parts else None - elif suffix[0].isdigit(): - i = 0 - while i < len(suffix) and suffix[i].isdigit(): - i += 1 - n = int(suffix[:i]) - return suffix[i : i + n] if i + n <= len(suffix) else None + return itanium_name(mangled[4:]) return None def _bn_read_vtable( @@ -552,12 +528,21 @@ def _bn_read_vtable( 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: + while consecutive_bad < 3 and slot < max_slots: val = self._bn_read_ptr(addr, ptr_size, is_big_endian) if val is None: break diff --git a/binocular/ghidra_impl/dragon.py b/binocular/ghidra_impl/dragon.py index ad931ca..f111162 100644 --- a/binocular/ghidra_impl/dragon.py +++ b/binocular/ghidra_impl/dragon.py @@ -34,6 +34,7 @@ Variable, VTableEntry, ) +from ..rtti_util import itanium_name as _itanium_name logger = logging.getLogger("BINocular") @@ -729,34 +730,7 @@ def _rtti_demangle(self, mangled: str) -> str | None: except Exception: pass if mangled.startswith("_ZTV"): - return self._rtti_itanium_name(mangled[4:]) - return None - - def _rtti_itanium_name(self, suffix: str) -> str | None: - """Manually decode an Itanium ABI class name from the post-_ZTV suffix.""" - if not suffix: - return None - if suffix[0] == "N": - parts, 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 - n = int(suffix[i:j]) - if j + n > len(suffix): - break - parts.append(suffix[j : j + n]) - i = j + n - return "::".join(parts) if parts else None - elif suffix[0].isdigit(): - i = 0 - while i < len(suffix) and suffix[i].isdigit(): - i += 1 - n = int(suffix[:i]) - return suffix[i : i + n] if i + n <= len(suffix) else None + return _itanium_name(mangled[4:]) return None def _rtti_read_vtable(self, vtable_addr, ptr_size: int, is_gcc: bool) -> list: @@ -771,12 +745,20 @@ def _rtti_read_vtable(self, vtable_addr, ptr_size: int, is_gcc: bool) -> list: 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: + while consecutive_bad < 3 and slot < max_slots: val = self._rtti_read_ptr(addr, ptr_size) if val is None: break @@ -855,7 +837,7 @@ def _rtti_parse_gcc( except Exception: return [], False, False - if base_count == 0 or base_count > 64 or flags > 7: + if base_count == 0 or base_count > 64 or flags > 3: return [], False, False has_virtual = False diff --git a/binocular/rizin.py b/binocular/rizin.py index 42dd9e1..b374020 100644 --- a/binocular/rizin.py +++ b/binocular/rizin.py @@ -490,8 +490,8 @@ def get_bb_instructions( @override def get_classes(self) -> Iterable[Any]: - import struct - from .primitives import ClassInfo, VTableEntry + 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 @@ -499,36 +499,14 @@ def get_classes(self) -> Iterable[Any]: u32_fmt = ("<" if is_le else ">") + "I" u64_fmt = "Q" - def read_chunk(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 read_ptr(addr: int) -> int | None: - data = read_chunk(addr, ptr_size) - return struct.unpack(ptr_fmt, data)[0] if data else None - - def read_u32(addr: int) -> int | None: - data = read_chunk(addr, 4) - return struct.unpack(u32_fmt, data)[0] if data else None - - def read_u64(addr: int) -> int | None: - data = read_chunk(addr, 8) - return struct.unpack(u64_fmt, data)[0] if data else None - - # Collect all symbols from the symbol table. try: sym_list = self.pipe.cmdj("isj") or [] except Exception: return - syms_by_name: Dict[str, int] = {} # realname -> vaddr - syms_by_addr: Dict[int, str] = {} # vaddr -> realname - sym_sizes: Dict[str, int] = {} # realname -> byte size + 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 "" @@ -540,7 +518,6 @@ def read_u64(addr: int) -> int | None: if rn and sz: sym_sizes[rn] = sz - # Build executable address ranges from section info. exec_ranges: List[Tuple[int, int]] = [] try: for sec in self.pipe.cmdj("iSj") or []: @@ -553,157 +530,6 @@ def read_u64(addr: int) -> int | None: except Exception: pass - def addr_is_exec(addr: int) -> bool: - return bool(addr) and any(lo <= addr < hi for lo, hi in exec_ranges) - - def itanium_name(suffix: str) -> str | None: - """Manually decode an Itanium ABI class name from the post-_ZTI/_ZTV suffix.""" - if not suffix: - return None - if suffix[0] == "N": - parts, 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 - - def resolve_zti(ptr_val: int) -> str | None: - sname = syms_by_addr.get(ptr_val, "") - if sname.startswith("_ZTI"): - return itanium_name(sname[4:]) - return None - - def read_vtable(vtable_addr: int, vtable_size: 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) - """ - total_slots = vtable_size // ptr_size - # Detect where function pointers start (first exec slot in first 4 header slots). - start_slot = 2 # default: skip offset-to-top + typeinfo - for i in range(min(4, total_slots)): - val = read_ptr(vtable_addr + i * ptr_size) - if val and addr_is_exec(val): - start_slot = i - break - - entries: list = [] - for slot in range(total_slots - start_slot): - val = read_ptr(vtable_addr + (start_slot + slot) * ptr_size) - if val is None: - break - if val == 0: - continue # abstract-class dtor slot with no RELA in file - if addr_is_exec(val): - 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 parse_gcc_rtti(zti_name: str) -> tuple: - """ - Structural RTTI parsing identical to the Ghidra backend's approach. - - Uses symbol size to distinguish typeinfo subtypes without chasing the - vptr (which points to external libstdc++ symbols with fake addresses): - - 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 = read_ptr(slot2_addr) - if not base_ptr: - return [], False, False - base = resolve_zti(base_ptr) - return ([base] if base else []), False, False - - # __vmi_class_type_info: slot 2 = flags(u32) + base_count(u32) - flags = read_u32(slot2_addr) - base_count = read_u32(slot2_addr + 4) - 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 = read_ptr(pair_start + i * pair_stride) - if not base_ptr: - continue - off_flags = read_u64(pair_start + i * pair_stride + ptr_size) - if off_flags is not None and (off_flags & 1): - has_virtual = True - base = resolve_zti(base_ptr) - if base: - base_names.append(base) - - return base_names, len(base_names) > 1, has_virtual - - # Iterate over _ZTV* symbols (vtables), skipping external typeinfo vtables. for realname, vaddr in syms_by_name.items(): if not realname.startswith("_ZTV") or realname.startswith("_ZTVN"): continue @@ -714,9 +540,20 @@ def parse_gcc_rtti(zti_name: str) -> tuple: if not class_name: continue - entries = read_vtable(vaddr, vtable_size) + 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 = parse_gcc_rtti(zti_name) + 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, @@ -727,6 +564,170 @@ def parse_gcc_rtti(zti_name: str) -> tuple: 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/conftest.py b/test/conftest.py index 91e528c..f6aeee5 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -17,6 +17,7 @@ def make(): 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/test_rtti.py b/test/test_rtti.py index 8201d15..de0a3fa 100644 --- a/test/test_rtti.py +++ b/test/test_rtti.py @@ -198,9 +198,8 @@ class TestRTTIRizin: def _skip_if_missing(self): from binocular import Rizin - with Rizin("rtti_simple") as g: - if not g.is_installed(): - pytest.skip("Rizin not installed") + if not Rizin.is_installed(): + pytest.skip("Rizin not installed") def test_simple(self, make_cpp): from binocular import Rizin