diff --git a/plugin/lighthouse/core.py b/plugin/lighthouse/core.py index d88ecf08..f552a364 100644 --- a/plugin/lighthouse/core.py +++ b/plugin/lighthouse/core.py @@ -208,6 +208,8 @@ def open_coverage_overview(self): # create a new coverage overview if there is not one visible self._ui_coverage_overview = CoverageOverview(self) + if disassembler.NAME == "CUTTER": + self._ui_coverage_overview.setmain(disassembler.main) self._ui_coverage_overview.show() def open_coverage_xref(self, address): diff --git a/plugin/lighthouse/cutter_integration.py b/plugin/lighthouse/cutter_integration.py new file mode 100644 index 00000000..657a285d --- /dev/null +++ b/plugin/lighthouse/cutter_integration.py @@ -0,0 +1,56 @@ +import logging + +import cutter +from lighthouse.core import Lighthouse +from lighthouse.util.disassembler import disassembler as disas +from lighthouse.util.qt import * + +logger = logging.getLogger("Lighthouse.Cutter.Integration") + + +#------------------------------------------------------------------------------ +# Lighthouse Cutter Integration +#------------------------------------------------------------------------------ + +class LighthouseCutter(Lighthouse): + """ + Lighthouse UI Integration for Cutter. + """ + + def __init__(self, plugin, main): + super(LighthouseCutter, self).__init__() + self.plugin = plugin + self.main = main + # Small hack to give main window to DockWidget + disas.main = main + + def interactive_load_file(self, unk): + super(LighthouseCutter, self).interactive_load_file() + + def interactive_load_batch(self, unk): + super(LighthouseCutter, self).interactive_load_batch() + + def _install_load_file(self): + action = QtWidgets.QAction("Lighthouse - Load code coverage file...", self.main) + action.triggered.connect(self.interactive_load_file) + self.main.addMenuFileAction(action) + logger.info("Installed the 'Code coverage file' menu entry") + + def _install_load_batch(self): + action = QtWidgets.QAction("Lighthouse - Load code coverage batch...", self.main) + action.triggered.connect(self.interactive_load_batch) + self.main.addMenuFileAction(action) + logger.info("Installed the 'Code coverage batch' menu entry") + + def _install_open_coverage_overview(self): + logger.info("TODO - Coverage Overview menu entry?") + + def _uninstall_load_file(self): + pass + + def _uninstall_load_batch(self): + pass + + def _uninstall_open_coverage_overview(self): + pass + diff --git a/plugin/lighthouse/cutter_loader.py b/plugin/lighthouse/cutter_loader.py new file mode 100644 index 00000000..4976d94d --- /dev/null +++ b/plugin/lighthouse/cutter_loader.py @@ -0,0 +1,46 @@ +import logging + +import CutterBindings +from lighthouse.cutter_integration import LighthouseCutter + +logger = logging.getLogger('Lighthouse.Cutter.Loader') + +#------------------------------------------------------------------------------ +# Lighthouse Cutter Loader +#------------------------------------------------------------------------------ +# +# The Cutter plugin loading process is quite easy. All we need is a function +# create_cutter_plugin that returns an instance of CutterBindings.CutterPlugin + +class LighthouseCutterPlugin(CutterBindings.CutterPlugin): + name = 'Ligthouse' + description = 'Lighthouse plugin for Cutter.' + version = '1.0' + author = 'xarkes' + + def __init__(self): + super(LighthouseCutterPlugin, self).__init__() + self.ui = None + + def setupPlugin(self): + pass + + def setupInterface(self, main): + self.main = main + self.ui = LighthouseCutter(self, main) + self.ui.load() + + def terminate(self): + if self.ui: + self.ui.unload() + + +def create_cutter_plugin(): + try: + return LighthouseCutterPlugin() + except Exception as e: + print('ERROR ---- ', e) + import sys, traceback + traceback.print_exc() + raise e + diff --git a/plugin/lighthouse/director.py b/plugin/lighthouse/director.py index e0e57f98..dc804a68 100644 --- a/plugin/lighthouse/director.py +++ b/plugin/lighthouse/director.py @@ -574,7 +574,7 @@ def _optimize_coverage_data(self, coverage_addresses): if not instructions: logger.debug("No mappable instruction addresses in coverage data") - return None + return [] # # TODO/COMMENT @@ -653,8 +653,9 @@ def _find_fuzzy_name(self, coverage_file, target_name): """ # attempt lookup using case-insensitive filename + target_module_name = os.path.split(target_name)[-1] for module_name in coverage_file.modules: - if module_name.lower() in target_name.lower(): + if target_module_name.lower() in module_name.lower(): return module_name # diff --git a/plugin/lighthouse/exceptions.py b/plugin/lighthouse/exceptions.py index 2a61a080..f05fe7d4 100644 --- a/plugin/lighthouse/exceptions.py +++ b/plugin/lighthouse/exceptions.py @@ -12,6 +12,7 @@ class LighthouseError(Exception): """ def __init__(self, *args, **kwargs): super(LighthouseError, self).__init__(*args, **kwargs) + self.message = "" #------------------------------------------------------------------------------ # Coverage File Exceptions diff --git a/plugin/lighthouse/metadata.py b/plugin/lighthouse/metadata.py index 238fd646..8cae6f71 100644 --- a/plugin/lighthouse/metadata.py +++ b/plugin/lighthouse/metadata.py @@ -462,6 +462,7 @@ def _core_refresh(self, function_addresses=None, progress_callback=None, is_asyn # created, and able to load plugins on such events. # + #---------------------------------------------------------------------- # create the disassembler hooks to listen for rename events @@ -589,7 +590,10 @@ def _update_functions(self, fresh_metadata): # if new_metadata.empty: - del self.functions[function_address] + try: + del self.functions[function_address] + except KeyError: + logger.error('Error: Excepted a function at {}'.format(hex(function_address))) continue # add or overwrite the new/updated basic blocks @@ -863,6 +867,51 @@ def _binja_refresh_nodes(self): for edge in node.outgoing_edges: function_metadata.edges[edge_src].append(edge.target.start) + def _cutter_refresh_nodes(self): + """ + Refresh function node metadata using Cutter/radare2 API + """ + function_metadata = self + function_metadata.nodes = {} + + # get the function from the Cutter database + # TODO Use Cutter cache/API + #function = cutter.get_function_at(self.address) + function = cutter.cmdj('afbj @ ' + str(self.address)) + + # + # now we will walk the flowchart for this function, collecting + # information on each of its nodes (basic blocks) and populating + # the function & node metadata objects. + # + + for bb in function: + + # create a new metadata object for this node + node_metadata = NodeMetadata(bb['addr'], bb['addr'] + bb['size'], None) + # + # establish a relationship between this node (basic block) and + # this function metadata (its parent) + # + + node_metadata.function = function_metadata + function_metadata.nodes[bb['addr']] = node_metadata + + # + # TODO/CUTTER: is there a better api for this? like xref from edge_src? + # we have to do it down here (unlike binja) because radare does not + # guarantee a its edges will land within the current function CFG... + # + + # compute all of the edges between nodes in the current function + for node_metadata in itervalues(function_metadata.nodes): + edge_src = node_metadata.edge_out + for bb in function: + if bb.get('jump', -1) in function_metadata.nodes: + function_metadata.edges[edge_src].append(bb.get('jump')) + if bb.get('fail', -1) in function_metadata.nodes: + function_metadata.edges[edge_src].append(bb.get('fail')) + def _compute_complexity(self): """ Walk the function CFG to determine approximate cyclomatic complexity. @@ -1036,6 +1085,25 @@ def _binja_build_metadata(self): # save the number of instructions in this block self.instruction_count = len(self.instructions) + def _cutter_build_metadata(self): + """ + Collect node metadata from the underlying database. + """ + current_address = self.address + node_end = self.address + self.size + + while current_address < node_end: + # TODO Use/implement Cutter API for both commands (that's very dirty) + instruction_size = cutter.cmdj('aoj')[0]['size'] + self.instructions[current_address] = instruction_size + current_address += int(cutter.cmd('?v $l @ ' + str(current_address)), 16) + + # the source of the outward edge + self.edge_out = current_address - instruction_size + + ## save the number of instructions in this block + self.instruction_count = len(self.instructions) + #-------------------------------------------------------------------------- # Operator Overloads #-------------------------------------------------------------------------- @@ -1085,7 +1153,16 @@ def collect_function_metadata(function_addresses): """ Collect function metadata for a list of addresses. """ - return { ea: FunctionMetadata(ea) for ea in function_addresses } + output = {} + for ea in function_addresses: + try: + logger.debug(f"Collecting {ea:08X}") + output[ea] = FunctionMetadata(ea) + except Exception as e: + import traceback + logger.debug(traceback.format_exc()) + return output + #return { ea: FunctionMetadata(ea) for ea in function_addresses } @disassembler.execute_ui def metadata_progress(completed, total): @@ -1121,5 +1198,11 @@ def metadata_progress(completed, total): FunctionMetadata._refresh_nodes = FunctionMetadata._binja_refresh_nodes NodeMetadata._build_metadata = NodeMetadata._binja_build_metadata +elif disassembler.NAME == "CUTTER": + import cutter + import CutterBindings + FunctionMetadata._refresh_nodes = FunctionMetadata._cutter_refresh_nodes + NodeMetadata._build_metadata = NodeMetadata._cutter_build_metadata + else: raise NotImplementedError("DISASSEMBLER-SPECIFIC SHIM MISSING") diff --git a/plugin/lighthouse/painting/__init__.py b/plugin/lighthouse/painting/__init__.py index 548affae..cf03f4f1 100644 --- a/plugin/lighthouse/painting/__init__.py +++ b/plugin/lighthouse/painting/__init__.py @@ -5,5 +5,7 @@ from .ida_painter import IDAPainter as CoveragePainter elif disassembler.NAME == "BINJA": from .binja_painter import BinjaPainter as CoveragePainter +elif disassembler.NAME == "CUTTER": + from .cutter_painter import CutterPainter as CoveragePainter else: raise NotImplementedError("DISASSEMBLER-SPECIFIC SHIM MISSING") diff --git a/plugin/lighthouse/painting/cutter_painter.py b/plugin/lighthouse/painting/cutter_painter.py new file mode 100644 index 00000000..488e7ede --- /dev/null +++ b/plugin/lighthouse/painting/cutter_painter.py @@ -0,0 +1,74 @@ +import logging + +import cutter + +from lighthouse.util.qt import QtGui +from lighthouse.palette import to_rgb +from lighthouse.painting import DatabasePainter +from lighthouse.util.disassembler import disassembler + +logger = logging.getLogger("Lighthouse.Painting.Cutter") + +#------------------------------------------------------------------------------ +# Cutter Painter +#------------------------------------------------------------------------------ + +class CutterPainter(DatabasePainter): + """ + Asynchronous Cutter database painter. + """ + PAINTER_SLEEP = 0.01 + + def __init__(self, director, palette): + super(CutterPainter, self).__init__(director, palette) + + #-------------------------------------------------------------------------- + # Paint Primitives + #-------------------------------------------------------------------------- + + # + # NOTE: + # due to the manner in which Cutter implements basic block + # (node) highlighting, I am not sure it is worth it to paint individual + # instructions. for now we, will simply make the instruction + # painting functions no-op's + # + + def _paint_instructions(self, instructions): + self._action_complete.set() + + def _clear_instructions(self, instructions): + self._action_complete.set() + + def _paint_nodes(self, nodes_coverage): + b, g, r = to_rgb(self.palette.coverage_paint) + color = QtGui.QColor(r, g, b) + for node_coverage in nodes_coverage: + node_metadata = node_coverage.database._metadata.nodes[node_coverage.address] + disassembler._core.getBBHighlighter().highlight(node_coverage.address, color) + self._painted_nodes.add(node_metadata.address) + self._action_complete.set() + + def _clear_nodes(self, nodes_metadata): + for node_metadata in nodes_metadata: + disassembler._core.getBBHighlighter().clear(node_metadata.address) + self._painted_nodes.discard(node_metadata.address) + self._action_complete.set() + + def _refresh_ui(self): + cutter.refresh() # TODO/CUTTER: Need a graph specific refresh... + + def _cancel_action(self, job): + pass + + #-------------------------------------------------------------------------- + # Priority Painting + #-------------------------------------------------------------------------- + + def _priority_paint(self): + current_address = disassembler.get_current_address() + current_function = disassembler.get_function_at(current_address) + if current_function: + self._paint_function(current_function['offset']) + return True + diff --git a/plugin/lighthouse/util/disassembler/__init__.py b/plugin/lighthouse/util/disassembler/__init__.py index 0d6eba94..263716fa 100644 --- a/plugin/lighthouse/util/disassembler/__init__.py +++ b/plugin/lighthouse/util/disassembler/__init__.py @@ -32,6 +32,19 @@ except ImportError: pass +#-------------------------------------------------------------------------- +# Cutter API Shim +#-------------------------------------------------------------------------- + +if disassembler == None: + try: + from .cutter_api import CutterAPI, DockableWindow + disassembler = CutterAPI() + except ImportError as e: + print('ERROR ---- ', e) + raise e + pass + #-------------------------------------------------------------------------- # Unknown Disassembler #-------------------------------------------------------------------------- diff --git a/plugin/lighthouse/util/disassembler/cutter_api.py b/plugin/lighthouse/util/disassembler/cutter_api.py new file mode 100644 index 00000000..d26fd384 --- /dev/null +++ b/plugin/lighthouse/util/disassembler/cutter_api.py @@ -0,0 +1,255 @@ +# -*- coding: utf-8 -*- +import os +import sys +import logging +import functools +import threading + +import cutter +import CutterBindings + +from .api import DisassemblerAPI, DockableShim +from ..qt import * +from ..misc import is_mainthread, not_mainthread + +logger = logging.getLogger("Lighthouse.API.Cutter") + +#------------------------------------------------------------------------------ +# Utils +#------------------------------------------------------------------------------ + +def execute_sync(function): + """ + TODO/CUTTER: Synchronize with the disassembler for safe database access. + """ + + @functools.wraps(function) + def wrapper(*args, **kwargs): + return function(*args, **kwargs) + return wrapper + +#------------------------------------------------------------------------------ +# Disassembler API +#------------------------------------------------------------------------------ + +class CutterAPI(DisassemblerAPI): + """ + The Cutter implementation of the disassembler API abstraction. + """ + NAME = "CUTTER" + + def __init__(self): + super(CutterAPI, self).__init__() + self._init_version() + + def _init_version(self): + version_string = cutter.version() + major, minor, patch = map(int, version_string.split('.')) + + # save the version number components for later use + self._version_major = major + self._version_minor = minor + self._version_patch = patch + + # export Cutter Core + self._core = CutterBindings.CutterCore.instance() + self._config = CutterBindings.Configuration.instance() + + #-------------------------------------------------------------------------- + # Properties + #-------------------------------------------------------------------------- + + @property + def headless(self): + return False + + #-------------------------------------------------------------------------- + # Synchronization Decorators + #-------------------------------------------------------------------------- + + @staticmethod + def execute_read(function): + return execute_sync(function) + + @staticmethod + def execute_write(function): + return execute_sync(function) + + @staticmethod + def execute_ui(function): + @functools.wraps(function) + def wrapper(*args, **kwargs): + ff = functools.partial(function, *args, **kwargs) + qt_mainthread.execute_fast(ff) + return wrapper + + #-------------------------------------------------------------------------- + # API Shims + #-------------------------------------------------------------------------- + + def create_rename_hooks(self): + class RenameHooks(object): + def __init__(self, core): + self._core = core + + def hook(self): + self._core.functionRenamed.connect(self.update) + + def unhook(self): + self._core.functionRenamed.disconnect(self.update) + + def update(self, old_name, new_name): + logger.debug('Received update event!', old_name, new_name) + # TODO/CUTTER: HOW DO I GET A FUNCITON'S ADDRESS BY NAME?? + #self.renamed(address, new_name) + + # placeholder, this gets replaced in metadata.py + def renamed(self, address, new_name): + pass + + return RenameHooks(self._core) + + def get_current_address(self): + return self._core.getOffset() + + def get_function_at(self, address): + # TODO/CUTTER: Use Cutter API + try: + return cutter.cmdj('afij @ ' + str(address))[0] + except IndexError: + return None + + def get_database_directory(self): + # TODO/CUTTER: Use Cutter API + return cutter.cmdj('ij')['core']['file'] + + def get_disassembler_user_directory(self): + + # TODO/CUTTER: is there an API for this yet?!? or at least the plugin dir... + if sys.platform == "linux" or sys.platform == "linux2": + return os.path.expanduser("~/.local/share/RadareOrg/Cutter") + elif sys.platform == "darwin": + raise RuntimeError("TODO OSX") + elif sys.platform == "win32": + return os.path.join(os.getenv("APPDATA"), "RadareOrg", "Cutter") + + raise RuntimeError("Unknown operating system") + + def get_function_addresses(self): + + # + # TODO/CUTTER: Use Cutter API + # + # TODO/CUTTER: Apparently, some of the addresses returned by this are + # ***NOT*** valid function addresses. they fail when passed into get_function_at() + # + + maybe_functions = [x['offset'] for x in cutter.cmdj('aflj')] + + # + # TODO/CUTTER/HACK: this is a gross hack to ensure lighthouse wont choke on *non* + # function addresses given in maybe_functions + # + + good = set() + for address in maybe_functions: + if self.get_function_at(address): + good.add(address) + + # return a list of *ALL FUNCTION ADDRESSES* in the database + return list(good) + + def get_function_name_at(self, address): + # TODO/CUTTER: Use Cutter API + func = self.get_function_at(address) + if not func: + return None + return func['name'] + + def get_function_raw_name_at(self, address): + return self.get_function_at(address)['name'] + + def get_imagebase(self): + # TODO/CUTTER: Use Cutter API + return cutter.cmdj('ij')['bin']['baddr'] + + def get_root_filename(self): + # TODO/CUTTER: Use Cutter API + return os.path.basename(cutter.cmdj('ij')['core']['file']) + + def navigate(self, address): + return self._core.seek(address) + + def set_function_name_at(self, function_address, new_name): + old_name = self.get_function_raw_name_at(function_address) + self._core.renameFunction(old_name, new_name) + + def message(self, message): + cutter.message(message) + + #-------------------------------------------------------------------------- + # UI API Shims + #-------------------------------------------------------------------------- + + def get_disassembly_background_color(self): + return self._config.getColor("gui.background") + + def is_msg_inited(self): + return True + + def warning(self, text): + self.main.messageBoxWarning('Lighthouse warning', text) + + #-------------------------------------------------------------------------- + # Function Prefix API + #-------------------------------------------------------------------------- + + PREFIX_SEPARATOR = "▁" # Unicode 0x2581 + +#------------------------------------------------------------------------------ +# UI +#------------------------------------------------------------------------------ + +class DockableWindow(DockableShim): + """ + A dockable Qt widget for Cutter. + """ + + def __init__(self, window_title, icon_path): + super(DockableWindow, self).__init__(window_title, icon_path) + + # configure dockable widget container + self._widget = QtWidgets.QWidget() + self._dockable = QtWidgets.QDockWidget(window_title) + self._dockable.setWidget(self._widget) + self._dockable.setWindowIcon(self._window_icon) + self._dockable.setAttribute(QtCore.Qt.WA_DeleteOnClose) + self._dockable.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding + ) + self._widget.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding + ) + + def show(self): + self._dockable.show() + self._dockable.raise_() + + def setmain(self, main): + + # + # NOTE HACK: + # this is a little dirty, but it's needed because it's not as + # easy as get_qt_main_window() to get the main dock in Cutter + # + + self._main = main + # self._widget.setParent(main) + + # dock the widget inside Cutter main dock + self._action = QtWidgets.QAction('Lighthouse coverage table') + self._action.setCheckable(True) + main.addPluginDockWidget(self._dockable, self._action) + diff --git a/plugin/lighthouse/util/qt/shim.py b/plugin/lighthouse/util/qt/shim.py index 2ac1a431..6ac31b13 100644 --- a/plugin/lighthouse/util/qt/shim.py +++ b/plugin/lighthouse/util/qt/shim.py @@ -22,6 +22,32 @@ USING_PYQT5 = False USING_PYSIDE2 = False +USING_PYSIDE2 = False + +#------------------------------------------------------------------------------ +# PySide2 Compatibility +#------------------------------------------------------------------------------ + +# if PyQt5 did not import, try to load PySide +if QT_AVAILABLE == False: + try: + import PySide2.QtGui as QtGui + import PySide2.QtCore as QtCore + import PySide2.QtWidgets as QtWidgets + + # alias for less PySide2 <--> PyQt5 shimming + QtCore.pyqtSignal = QtCore.Signal + QtCore.pyqtSlot = QtCore.Slot + + # importing went okay, PySide must be available for use + QT_AVAILABLE = True + USING_PYSIDE2 = True + USING_PYQT5 = True + + # import failed. No Qt / UI bindings available... + except ImportError: + pass + #------------------------------------------------------------------------------ # PyQt5 Compatibility #------------------------------------------------------------------------------ diff --git a/plugin/lighthouse_plugin.py b/plugin/lighthouse_plugin.py index a96be38a..8dc74060 100644 --- a/plugin/lighthouse_plugin.py +++ b/plugin/lighthouse_plugin.py @@ -22,6 +22,10 @@ logger.info("Selecting Binary Ninja loader...") from lighthouse.binja_loader import * +elif disassembler.NAME == "CUTTER": + logger.info("Selecting Cutter loader...") + from lighthouse.cutter_loader import * + else: raise NotImplementedError("DISASSEMBLER-SPECIFIC SHIM MISSING")