Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions binocular/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
BasicBlock,
Binary,
Branch,
ClassInfo,
Instruction,
NativeFunction,
Reference,
SourceFunction,
Variable,
VTableEntry,
)
from .rizin import Rizin
from .binja import BinaryNinja
Expand Down Expand Up @@ -48,4 +50,6 @@
"Variable",
"IndirectToken",
"Reference",
"ClassInfo",
"VTableEntry",
]
220 changes: 220 additions & 0 deletions binocular/binja.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions binocular/disassembler.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
Binary,
Branch,
Argument,
ClassInfo,
Variable,
Reference,
BasicBlock,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Loading