diff --git a/README.md b/README.md index 6e3c762b..75dd0f1a 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ rule: The [github.com/fireeye/capa-rules](https://github.com/fireeye/capa-rules) repository contains hundreds of standard library rules that are distributed with capa. Please learn to write rules and contribute new entries as you find interesting techniques in malware. -If you use IDA Pro, then you use can use the [IDA Pro plugin for capa](./capa/ida/ida_capa_explorer.py). +If you use IDA Pro, then you use can use the [IDA Pro plugin for capa](capa/ida/plugin/). This script adds new user interface elements to IDA, including an interactive tree view of rule matches and their locations within the current database. As you select the checkboxes, the plugin will highlight the addresses associated with the features. We use this plugin all the time to quickly jump to interesting parts of a program. diff --git a/capa/ida/explorer/__init__.py b/capa/ida/explorer/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/capa/ida/helpers/__init__.py b/capa/ida/helpers/__init__.py index d035c7b2..04c681ea 100644 --- a/capa/ida/helpers/__init__.py +++ b/capa/ida/helpers/__init__.py @@ -46,7 +46,6 @@ def is_supported_ida_version(): logger.warning( "Your IDA Pro version is: %s. Supported versions are: %s." % (version, ", ".join(SUPPORTED_IDA_VERSIONS)) ) - capa.ida.helpers.inform_user_ida_ui(warning_msg) return False return True @@ -62,7 +61,6 @@ def is_supported_file_type(): ) logger.error(" If you don't know the input file type, you can try using the `file` utility to guess it.") logger.error("-" * 80) - inform_user_ida_ui("capa does not support the format of this file") return False return True diff --git a/capa/ida/plugin/__init__.py b/capa/ida/plugin/__init__.py new file mode 100644 index 00000000..d75cfc1e --- /dev/null +++ b/capa/ida/plugin/__init__.py @@ -0,0 +1,66 @@ +# Copyright (C) 2020 FireEye, Inc. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: [package root]/LICENSE.txt +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and limitations under the License. + +import logging + +import idaapi + +from capa.ida.helpers import is_supported_file_type, is_supported_ida_version +from capa.ida.plugin.form import CapaExplorerForm + +logger = logging.getLogger("capa") + + +class CapaExplorerPlugin(idaapi.plugin_t): + + # Mandatory definitions + PLUGIN_NAME = "capa explorer" + PLUGIN_VERSION = "1.0.0" + PLUGIN_AUTHORS = "" + + wanted_name = PLUGIN_NAME + comment = "IDA plugin for capa analysis framework" + version = "" + website = "" + help = "" + wanted_hotkey = "" + flags = 0 + + def __init__(self): + """ """ + self.form = None + + def init(self): + """ + called when IDA is loading the plugin + """ + logging.basicConfig(level=logging.INFO) + + # check IDA version and database compat + if not is_supported_ida_version(): + return idaapi.PLUGIN_SKIP + if not is_supported_file_type(): + return idaapi.PLUGIN_SKIP + + logger.info("plugin initialized.") + + return idaapi.PLUGIN_KEEP + + def term(self): + """ + called when IDA is unloading the plugin + """ + logger.info("plugin closed.") + + def run(self, arg): + """ + called when IDA is running the plugin as a script + """ + self.form = CapaExplorerForm(self.PLUGIN_NAME) + self.form.Show() + return True diff --git a/capa/ida/plugin/capa_plugin_ida.py b/capa/ida/plugin/capa_plugin_ida.py new file mode 100644 index 00000000..c0854c0f --- /dev/null +++ b/capa/ida/plugin/capa_plugin_ida.py @@ -0,0 +1,14 @@ +# Copyright (C) 2020 FireEye, Inc. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: [package root]/LICENSE.txt +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and limitations under the License. + +from capa.ida.plugin import CapaExplorerPlugin + + +def PLUGIN_ENTRY(): + """ Mandatory entry point for IDAPython plugins """ + return CapaExplorerPlugin() diff --git a/capa/ida/ida_capa_explorer.py b/capa/ida/plugin/form.py similarity index 84% rename from capa/ida/ida_capa_explorer.py rename to capa/ida/plugin/form.py index ee634587..26498370 100644 --- a/capa/ida/ida_capa_explorer.py +++ b/capa/ida/plugin/form.py @@ -19,73 +19,21 @@ import capa.rules import capa.ida.helpers import capa.render.utils as rutils import capa.features.extractors.ida -from capa.ida.explorer.view import CapaExplorerQtreeView -from capa.ida.explorer.model import CapaExplorerDataModel -from capa.ida.explorer.proxy import CapaExplorerSortFilterProxyModel - -PLUGIN_NAME = "capa explorer" +from capa.ida.plugin.view import CapaExplorerQtreeView +from capa.ida.plugin.hooks import CapaExplorerIdaHooks +from capa.ida.plugin.model import CapaExplorerDataModel +from capa.ida.plugin.proxy import CapaExplorerSortFilterProxyModel logger = logging.getLogger("capa") -class CapaExplorerIdaHooks(idaapi.UI_Hooks): - def __init__(self, screen_ea_changed_hook, action_hooks): - """facilitate IDA UI hooks - - @param screen_ea_changed_hook: function hook for IDA screen ea changed - @param action_hooks: dict of IDA action handles - """ - super(CapaExplorerIdaHooks, self).__init__() - - self.screen_ea_changed_hook = screen_ea_changed_hook - self.process_action_hooks = action_hooks - self.process_action_handle = None - self.process_action_meta = {} - - def preprocess_action(self, name): - """called prior to action completed - - @param name: name of action defined by idagui.cfg - - @retval must be 0 - """ - self.process_action_handle = self.process_action_hooks.get(name, None) - - if self.process_action_handle: - self.process_action_handle(self.process_action_meta) - - # must return 0 for IDA - return 0 - - def postprocess_action(self): - """ called after action completed """ - if not self.process_action_handle: - return - - self.process_action_handle(self.process_action_meta, post=True) - self.reset() - - def screen_ea_changed(self, curr_ea, prev_ea): - """called after screen location is changed - - @param curr_ea: current location - @param prev_ea: prev location - """ - self.screen_ea_changed_hook(idaapi.get_current_widget(), curr_ea, prev_ea) - - def reset(self): - """ reset internal state """ - self.process_action_handle = None - self.process_action_meta.clear() - - class CapaExplorerForm(idaapi.PluginForm): - def __init__(self): + def __init__(self, name): """ """ super(CapaExplorerForm, self).__init__() - self.form_title = PLUGIN_NAME - self.file_loc = __file__ + self.form_title = name + self.rule_path = "" self.parent = None self.ida_hooks = None @@ -116,6 +64,7 @@ class CapaExplorerForm(idaapi.PluginForm): def Show(self): """ """ + logger.info("form show.") return idaapi.PluginForm.Show( self, self.form_title, options=(idaapi.PluginForm.WOPN_TAB | idaapi.PluginForm.WCLS_CLOSE_LATER) ) @@ -124,7 +73,6 @@ class CapaExplorerForm(idaapi.PluginForm): """ form is closed """ self.unload_ida_hooks() self.ida_reset() - logger.info("form closed.") def load_interface(self): @@ -262,6 +210,7 @@ class CapaExplorerForm(idaapi.PluginForm): actions = ( ("Reset view", "Reset plugin view", self.reset), ("Run analysis", "Run capa analysis on current database", self.reload), + ("Change rules directory...", "Select new rules directory", self.change_rules_dir), ("Export results...", "Export capa results as JSON file", self.export_json), ) @@ -276,9 +225,15 @@ class CapaExplorerForm(idaapi.PluginForm): if not self.doc: idaapi.info("No capa results to export.") return + path = idaapi.ask_file(True, "*.json", "Choose file") + + if not path: + return + if os.path.exists(path) and 1 != idaapi.ask_yn(1, "File already exists. Overwrite?"): return + with open(path, "wb") as export_file: export_file.write( json.dumps(self.doc, sort_keys=True, cls=capa.render.CapaJsonObjectEncoder).encode("utf-8") @@ -347,16 +302,29 @@ class CapaExplorerForm(idaapi.PluginForm): def load_capa_results(self): """ run capa analysis and render results in UI """ + if not self.rule_path: + rule_path = self.ask_user_directory() + if not rule_path: + capa.ida.helpers.inform_user_ida_ui("You must select a rules directory to use for analysis.") + logger.warning("no rules directory selected. nothing to do.") + return + self.rule_path = rule_path + logger.info("-" * 80) - logger.info(" Using default embedded rules.") + logger.info(" Using rules from %s." % self.rule_path) logger.info(" ") logger.info(" You can see the current default rule set here:") logger.info(" https://github.com/fireeye/capa-rules") logger.info("-" * 80) - rules_path = os.path.join(os.path.dirname(self.file_loc), "../..", "rules") - rules = capa.main.get_rules(rules_path) - rules = capa.rules.RuleSet(rules) + try: + rules = capa.main.get_rules(self.rule_path) + rules = capa.rules.RuleSet(rules) + except (IOError, capa.rules.InvalidRule, capa.rules.InvalidRuleSet) as e: + capa.ida.helpers.inform_user_ida_ui("Failed to load rules from %s" % self.rule_path) + logger.error("failed to load rules from %s (%s)" % (self.rule_path, e)) + self.rule_path = "" + return meta = capa.ida.helpers.collect_metadata() @@ -495,9 +463,9 @@ class CapaExplorerForm(idaapi.PluginForm): self.load_capa_results() logger.info("reload complete.") - idaapi.info("%s reload completed." % PLUGIN_NAME) + idaapi.info("%s reload completed." % self.form_title) - def reset(self): + def reset(self, checked): """reset UI elements e.g. checkboxes and IDA highlighting @@ -505,7 +473,7 @@ class CapaExplorerForm(idaapi.PluginForm): self.ida_reset() logger.info("reset completed.") - idaapi.info("%s reset completed." % PLUGIN_NAME) + idaapi.info("%s reset completed." % self.form_title) def slot_menu_bar_hovered(self, action): """display menu action tooltip @@ -518,13 +486,13 @@ class CapaExplorerForm(idaapi.PluginForm): QtGui.QCursor.pos(), action.toolTip(), self.view_menu_bar, self.view_menu_bar.actionGeometry(action) ) - def slot_checkbox_limit_by_changed(self): + def slot_checkbox_limit_by_changed(self, state): """slot activated if checkbox clicked if checked, configure function filter if screen location is located in function, otherwise clear filter """ - if self.view_limit_results_by_function.isChecked(): + if state == QtCore.Qt.Checked: self.limit_results_to_function(idaapi.get_func(idaapi.get_screen_ea())) else: self.model_proxy.reset_address_range_filter() @@ -542,30 +510,16 @@ class CapaExplorerForm(idaapi.PluginForm): # if function not exists don't display any results (address should not be -1) self.model_proxy.add_address_range_filter(-1, -1) + def ask_user_directory(self): + """ create Qt dialog to ask user for a directory """ + return str(QtWidgets.QFileDialog.getExistingDirectory(self.parent, "Select rules directory")) -def main(): - """ TODO: move to idaapi.plugin_t class """ - logging.basicConfig(level=logging.INFO) - - if not capa.ida.helpers.is_supported_ida_version(): - return -1 - - if not capa.ida.helpers.is_supported_file_type(): - return -1 - - global CAPA_EXPLORER_FORM - - try: - # there is an instance, reload it - CAPA_EXPLORER_FORM - CAPA_EXPLORER_FORM.Close() - CAPA_EXPLORER_FORM = CapaExplorerForm() - except Exception: - # there is no instance yet - CAPA_EXPLORER_FORM = CapaExplorerForm() - - CAPA_EXPLORER_FORM.Show() - - -if __name__ == "__main__": - main() + def change_rules_dir(self): + """ allow user to change rules directory """ + rule_path = self.ask_user_directory() + if not rule_path: + logger.warning("no rules directory selected. nothing to do.") + return + self.rule_path = rule_path + if 1 == idaapi.ask_yn(1, "Run analysis now?"): + self.reload() diff --git a/capa/ida/plugin/hooks.py b/capa/ida/plugin/hooks.py new file mode 100644 index 00000000..4ad688cc --- /dev/null +++ b/capa/ida/plugin/hooks.py @@ -0,0 +1,60 @@ +# Copyright (C) 2020 FireEye, Inc. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: [package root]/LICENSE.txt +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and limitations under the License. + +import idaapi + + +class CapaExplorerIdaHooks(idaapi.UI_Hooks): + def __init__(self, screen_ea_changed_hook, action_hooks): + """facilitate IDA UI hooks + + @param screen_ea_changed_hook: function hook for IDA screen ea changed + @param action_hooks: dict of IDA action handles + """ + super(CapaExplorerIdaHooks, self).__init__() + + self.screen_ea_changed_hook = screen_ea_changed_hook + self.process_action_hooks = action_hooks + self.process_action_handle = None + self.process_action_meta = {} + + def preprocess_action(self, name): + """called prior to action completed + + @param name: name of action defined by idagui.cfg + + @retval must be 0 + """ + self.process_action_handle = self.process_action_hooks.get(name, None) + + if self.process_action_handle: + self.process_action_handle(self.process_action_meta) + + # must return 0 for IDA + return 0 + + def postprocess_action(self): + """ called after action completed """ + if not self.process_action_handle: + return + + self.process_action_handle(self.process_action_meta, post=True) + self.reset() + + def screen_ea_changed(self, curr_ea, prev_ea): + """called after screen location is changed + + @param curr_ea: current location + @param prev_ea: prev location + """ + self.screen_ea_changed_hook(idaapi.get_current_widget(), curr_ea, prev_ea) + + def reset(self): + """ reset internal state """ + self.process_action_handle = None + self.process_action_meta.clear() diff --git a/capa/ida/explorer/item.py b/capa/ida/plugin/item.py similarity index 100% rename from capa/ida/explorer/item.py rename to capa/ida/plugin/item.py diff --git a/capa/ida/explorer/model.py b/capa/ida/plugin/model.py similarity index 99% rename from capa/ida/explorer/model.py rename to capa/ida/plugin/model.py index 3050e263..7c951da4 100644 --- a/capa/ida/explorer/model.py +++ b/capa/ida/plugin/model.py @@ -9,13 +9,12 @@ from collections import deque import idc -import six import idaapi -from PyQt5 import Qt, QtGui, QtCore +from PyQt5 import QtGui, QtCore import capa.ida.helpers import capa.render.utils as rutils -from capa.ida.explorer.item import ( +from capa.ida.plugin.item import ( CapaExplorerDataItem, CapaExplorerRuleItem, CapaExplorerBlockItem, diff --git a/capa/ida/explorer/proxy.py b/capa/ida/plugin/proxy.py similarity index 98% rename from capa/ida/explorer/proxy.py rename to capa/ida/plugin/proxy.py index fc996c1f..fceb1d41 100644 --- a/capa/ida/explorer/proxy.py +++ b/capa/ida/plugin/proxy.py @@ -8,7 +8,7 @@ from PyQt5 import QtCore -from capa.ida.explorer.model import CapaExplorerDataModel +from capa.ida.plugin.model import CapaExplorerDataModel class CapaExplorerSortFilterProxyModel(QtCore.QSortFilterProxyModel): diff --git a/capa/ida/explorer/view.py b/capa/ida/plugin/view.py similarity index 97% rename from capa/ida/explorer/view.py rename to capa/ida/plugin/view.py index a1ec22d9..c4dd86a6 100644 --- a/capa/ida/explorer/view.py +++ b/capa/ida/plugin/view.py @@ -7,11 +7,10 @@ # See the License for the specific language governing permissions and limitations under the License. import idc -import idaapi -from PyQt5 import QtGui, QtCore, QtWidgets +from PyQt5 import QtCore, QtWidgets -from capa.ida.explorer.item import CapaExplorerRuleItem, CapaExplorerFunctionItem -from capa.ida.explorer.model import CapaExplorerDataModel +from capa.ida.plugin.item import CapaExplorerFunctionItem +from capa.ida.plugin.model import CapaExplorerDataModel class CapaExplorerQtreeView(QtWidgets.QTreeView): diff --git a/capa/ida/plugin_helpers.py b/capa/ida/plugin_helpers.py deleted file mode 100644 index 67d65a05..00000000 --- a/capa/ida/plugin_helpers.py +++ /dev/null @@ -1,99 +0,0 @@ -# Copyright (C) 2020 FireEye, Inc. All Rights Reserved. -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at: [package root]/LICENSE.txt -# Unless required by applicable law or agreed to in writing, software distributed under the License -# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and limitations under the License. - -import os -import logging - -import idc -import idaapi -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QTreeWidgetItem, QTreeWidgetItemIterator - -CAPA_EXTENSION = ".capas" - - -logger = logging.getLogger("capa_ida") - - -def get_input_file(freeze=True): - """ - get input file path - - freeze (bool): if True, get freeze file if it exists - """ - # try original file in same directory as idb/i64 without idb/i64 file extension - input_file = idc.get_idb_path()[:-4] - - if freeze: - # use frozen file if it exists - freeze_file_cand = "%s%s" % (input_file, CAPA_EXTENSION) - if os.path.isfile(freeze_file_cand): - return freeze_file_cand - - if not os.path.isfile(input_file): - # TM naming - input_file = "%s.mal_" % idc.get_idb_path()[:-4] - if not os.path.isfile(input_file): - input_file = idaapi.ask_file(0, "*.*", "Please specify input file.") - if not input_file: - raise ValueError("could not find input file") - return input_file - - -def get_orig_color_feature_vas(vas): - orig_colors = {} - for va in vas: - orig_colors[va] = idc.get_color(va, idc.CIC_ITEM) - return orig_colors - - -def reset_colors(orig_colors): - if orig_colors: - for va, color in orig_colors.iteritems(): - idc.set_color(va, idc.CIC_ITEM, orig_colors[va]) - - -def reset_selection(tree): - iterator = QTreeWidgetItemIterator(tree, QTreeWidgetItemIterator.Checked) - while iterator.value(): - item = iterator.value() - item.setCheckState(0, Qt.Unchecked) # column, state - iterator += 1 - - -def get_disasm_line(va): - return idc.generate_disasm_line(va, idc.GENDSM_FORCE_CODE) - - -def get_selected_items(tree, skip_level_1=False): - selected = [] - iterator = QTreeWidgetItemIterator(tree, QTreeWidgetItemIterator.Checked) - while iterator.value(): - item = iterator.value() - if skip_level_1: - # hacky way to check if item is at level 1, if so, skip - # alternative, check if text in disasm column - if item.parent() and item.parent().parent() is None: - iterator += 1 - continue - if item.text(1): - # logger.debug('selected %s, %s', item.text(0), item.text(1)) - selected.append(int(item.text(1), 0x10)) - iterator += 1 - return selected - - -def add_child_item(parent, values, feature=None): - child = QTreeWidgetItem(parent) - child.setFlags(child.flags() | Qt.ItemIsTristate | Qt.ItemIsUserCheckable) - for i, v in enumerate(values): - child.setText(i, v) - if feature: - child.setData(0, 0x100, feature) - child.setCheckState(0, Qt.Unchecked) - return child diff --git a/doc/usage.md b/doc/usage.md index 15dba784..750721f1 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -22,7 +22,7 @@ IDA's analysis is generally a bit faster and more thorough than vivisect's, so y When run under IDA, capa supports both Python 2 and Python 3 interpreters. If you encounter issues with your specific setup, please open a new [Issue](https://github.com/fireeye/capa/issues). -Additionally, capa comes with an IDA Pro plugin located in the `capa/ida` directory: the explorer. +Additionally, capa comes with an IDA Pro plugin located in the `capa/ida/plugin` directory: the explorer. #### capa explorer The capa explorer allows you to interactively display and browse capabilities capa identified in a binary. @@ -31,9 +31,8 @@ We like to use capa to help find the most interesting parts of a program, such a ![capa explorer](img/capa_explorer.png) -To install the plugin, you'll need to be running IDA Pro 7.4 or 7.5 with either Python 2 or Python 3. -Next make sure pip commands are run using the Python install that is configured for your IDA install: - - 1. Run `$ pip install .` from capa root directory - 2. Open IDA and navigate to `File > Script file…` or `Alt+F7` - 3. Navigate to `\capa\ida\` and choose `ida_capa_explorer.py` +The plugin currently supports IDA Pro 7.1 through 7.5 with either Python 2 or Python 3. To use the plugin, install capa +by following method 2 or 3 from the [installation guide](installation.md) and copy [capa_plugin_ida.py](../capa/ida/plugin/capa_plugin_ida.py) +to the plugins directory of your IDA Pro installation. Following these steps you can run capa explorer in IDA Pro by navigating +to `Edit > Plugins > capa explorer`. The plugin will prompt you to select a rules directory to use for analysis. You can +use the [default rule set](https://github.com/fireeye/capa-rules/) or point the plugin to your own directory of rules.