mirror of
https://github.com/mandiant/capa.git
synced 2026-01-03 00:11:26 -08:00
1094 lines
36 KiB
Python
1094 lines
36 KiB
Python
# 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 re
|
|
from collections import Counter
|
|
|
|
import idc
|
|
from PyQt5 import QtGui, QtCore, QtWidgets
|
|
|
|
import capa.rules
|
|
import capa.engine
|
|
import capa.ida.helpers
|
|
import capa.features.basicblock
|
|
from capa.ida.plugin.item import CapaExplorerFunctionItem
|
|
from capa.ida.plugin.model import CapaExplorerDataModel
|
|
|
|
MAX_SECTION_SIZE = 750
|
|
|
|
# default colors used in views
|
|
COLOR_GREEN_RGB = (79, 121, 66)
|
|
COLOR_BLUE_RGB = (37, 147, 215)
|
|
|
|
|
|
def calc_level_by_indent(line, prev_level=0):
|
|
""" """
|
|
if not len(line.strip()):
|
|
# blank line, which may occur for comments so we simply use the last level
|
|
return prev_level
|
|
stripped = line.lstrip()
|
|
if stripped.startswith("description"):
|
|
# need to adjust two spaces when encountering string description
|
|
line = line[2:]
|
|
# calc line level based on preceding whitespace
|
|
return len(line) - len(stripped)
|
|
|
|
|
|
def parse_feature_for_node(feature):
|
|
""" """
|
|
description = ""
|
|
comment = ""
|
|
|
|
if feature.startswith("- count"):
|
|
# count is weird, we need to handle special
|
|
# first, we need to grab the comment, if exists
|
|
# next, we need to check for an embedded description
|
|
feature, _, comment = feature.partition("#")
|
|
m = re.search(r"- count\(([a-zA-Z]+)\((.+)\s+=\s+(.+)\)\):\s*(.+)", feature)
|
|
if m:
|
|
# reconstruct count without description
|
|
feature, value, description, count = m.groups()
|
|
feature = "- count(%s(%s)): %s" % (feature, value, count)
|
|
elif not feature.startswith("#"):
|
|
feature, _, comment = feature.partition("#")
|
|
feature, _, description = feature.partition("=")
|
|
|
|
return map(lambda o: o.strip(), (feature, description, comment))
|
|
|
|
|
|
def parse_node_for_feature(feature, description, comment, depth):
|
|
""" """
|
|
depth = (depth * 2) + 4
|
|
display = ""
|
|
|
|
if feature.startswith("#"):
|
|
display += "%s%s\n" % (" " * depth, feature)
|
|
elif description:
|
|
if feature.startswith(("- and", "- or", "- optional", "- basic block", "- not")):
|
|
display += "%s%s" % (" " * depth, feature)
|
|
if comment:
|
|
display += " # %s" % comment
|
|
display += "\n%s- description: %s\n" % (" " * (depth + 2), description)
|
|
elif feature.startswith("- string"):
|
|
display += "%s%s" % (" " * depth, feature)
|
|
if comment:
|
|
display += " # %s" % comment
|
|
display += "\n%sdescription: %s\n" % (" " * (depth + 2), description)
|
|
elif feature.startswith("- count"):
|
|
# count is weird, we need to format description based on feature type, so we parse with regex
|
|
# assume format - count(<feature_name>(<feature_value>)): <count>
|
|
m = re.search(r"- count\(([a-zA-Z]+)\((.+)\)\): (.+)", feature)
|
|
if m:
|
|
name, value, count = m.groups()
|
|
if name in ("string",):
|
|
display += "%s%s" % (" " * depth, feature)
|
|
if comment:
|
|
display += " # %s" % comment
|
|
display += "\n%sdescription: %s\n" % (" " * (depth + 2), description)
|
|
else:
|
|
display += "%s- count(%s(%s = %s)): %s" % (
|
|
" " * depth,
|
|
name,
|
|
value,
|
|
description,
|
|
count,
|
|
)
|
|
if comment:
|
|
display += " # %s\n" % comment
|
|
else:
|
|
display += "%s%s = %s" % (" " * depth, feature, description)
|
|
if comment:
|
|
display += " # %s\n" % comment
|
|
else:
|
|
display += "%s%s" % (" " * depth, feature)
|
|
if comment:
|
|
display += " # %s\n" % comment
|
|
|
|
return display if display.endswith("\n") else display + "\n"
|
|
|
|
|
|
def yaml_to_nodes(s):
|
|
level = 0
|
|
for line in s.splitlines():
|
|
feature, description, comment = parse_feature_for_node(line.strip())
|
|
|
|
o = QtWidgets.QTreeWidgetItem(None)
|
|
|
|
# set node attributes
|
|
setattr(o, "capa_level", calc_level_by_indent(line, level))
|
|
|
|
if feature.startswith(("- and:", "- or:", "- not:", "- basic block:", "- optional:")):
|
|
setattr(o, "capa_type", CapaExplorerRulgenEditor.get_node_type_expression())
|
|
elif feature.startswith("#"):
|
|
setattr(o, "capa_type", CapaExplorerRulgenEditor.get_node_type_comment())
|
|
else:
|
|
setattr(o, "capa_type", CapaExplorerRulgenEditor.get_node_type_feature())
|
|
|
|
# set node text
|
|
for (i, v) in enumerate((feature, description, comment)):
|
|
o.setText(i, v)
|
|
|
|
yield o
|
|
|
|
|
|
def iterate_tree(o):
|
|
""" """
|
|
itr = QtWidgets.QTreeWidgetItemIterator(o)
|
|
while itr.value():
|
|
yield itr.value()
|
|
itr += 1
|
|
|
|
|
|
def calc_item_depth(o):
|
|
""" """
|
|
depth = 0
|
|
while True:
|
|
if not o.parent():
|
|
break
|
|
depth += 1
|
|
o = o.parent()
|
|
return depth
|
|
|
|
|
|
def build_action(o, display, data, slot):
|
|
""" """
|
|
action = QtWidgets.QAction(display, o)
|
|
|
|
action.setData(data)
|
|
action.triggered.connect(lambda checked: slot(action))
|
|
|
|
return action
|
|
|
|
|
|
def build_context_menu(o, actions):
|
|
""" """
|
|
menu = QtWidgets.QMenu()
|
|
|
|
for action in actions:
|
|
if isinstance(action, QtWidgets.QMenu):
|
|
menu.addMenu(action)
|
|
else:
|
|
menu.addAction(build_action(o, *action))
|
|
|
|
return menu
|
|
|
|
|
|
class CapaExplorerRulgenPreview(QtWidgets.QTextEdit):
|
|
def __init__(self, parent=None):
|
|
""" """
|
|
super(CapaExplorerRulgenPreview, self).__init__(parent)
|
|
|
|
self.setFont(QtGui.QFont("Courier", weight=QtGui.QFont.Bold))
|
|
self.setLineWrapMode(QtWidgets.QTextEdit.NoWrap)
|
|
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
|
|
|
|
def reset_view(self):
|
|
""" """
|
|
self.clear()
|
|
|
|
def load_preview_meta(self, ea, author, scope):
|
|
""" """
|
|
metadata_default = [
|
|
"# generated using capa explorer for IDA Pro",
|
|
"rule:",
|
|
" meta:",
|
|
" name: <insert_name>",
|
|
" namespace: <insert_namespace>",
|
|
" author: %s" % author,
|
|
" scope: %s" % scope,
|
|
" references: <insert_references>",
|
|
" examples:",
|
|
" - %s:0x%X" % (capa.ida.helpers.get_file_md5().upper(), ea)
|
|
if ea
|
|
else " - %s" % (capa.ida.helpers.get_file_md5().upper()),
|
|
" features:",
|
|
]
|
|
self.setText("\n".join(metadata_default))
|
|
|
|
def keyPressEvent(self, e):
|
|
""" """
|
|
if e.key() == QtCore.Qt.Key_Tab:
|
|
self.insertPlainText(" " * 2)
|
|
else:
|
|
super(CapaExplorerRulgenPreview, self).keyPressEvent(e)
|
|
|
|
|
|
class CapaExplorerRulgenEditor(QtWidgets.QTreeWidget):
|
|
|
|
updated = QtCore.pyqtSignal()
|
|
|
|
def __init__(self, preview, parent=None):
|
|
""" """
|
|
super(CapaExplorerRulgenEditor, self).__init__(parent)
|
|
|
|
self.preview = preview
|
|
|
|
self.setHeaderLabels(["Feature", "Description", "Comment"])
|
|
self.header().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
|
|
self.header().setStretchLastSection(False)
|
|
self.setExpandsOnDoubleClick(False)
|
|
self.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
|
|
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
|
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
|
|
self.setStyleSheet("QTreeView::item {padding-right: 15 px;padding-bottom: 2 px;}")
|
|
|
|
# enable drag and drop
|
|
self.setDragEnabled(True)
|
|
self.setAcceptDrops(True)
|
|
self.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
|
|
|
|
# connect slots
|
|
self.itemChanged.connect(self.slot_item_changed)
|
|
self.customContextMenuRequested.connect(self.slot_custom_context_menu_requested)
|
|
self.itemDoubleClicked.connect(self.slot_item_double_clicked)
|
|
|
|
self.root = None
|
|
self.reset_view()
|
|
|
|
self.is_editing = False
|
|
|
|
@staticmethod
|
|
def get_column_feature_index():
|
|
""" """
|
|
return 0
|
|
|
|
@staticmethod
|
|
def get_column_description_index():
|
|
""" """
|
|
return 1
|
|
|
|
@staticmethod
|
|
def get_column_comment_index():
|
|
""" """
|
|
return 2
|
|
|
|
@staticmethod
|
|
def get_node_type_expression():
|
|
""" """
|
|
return 0
|
|
|
|
@staticmethod
|
|
def get_node_type_feature():
|
|
""" """
|
|
return 1
|
|
|
|
@staticmethod
|
|
def get_node_type_comment():
|
|
""" """
|
|
return 2
|
|
|
|
def dragMoveEvent(self, e):
|
|
""" """
|
|
super(CapaExplorerRulgenEditor, self).dragMoveEvent(e)
|
|
|
|
def dragEventEnter(self, e):
|
|
""" """
|
|
super(CapaExplorerRulgenEditor, self).dragEventEnter(e)
|
|
|
|
def dropEvent(self, e):
|
|
""" """
|
|
if not self.indexAt(e.pos()).isValid():
|
|
return
|
|
|
|
super(CapaExplorerRulgenEditor, self).dropEvent(e)
|
|
|
|
# self.prune_expressions()
|
|
self.update_preview()
|
|
self.expandAll()
|
|
|
|
def reset_view(self):
|
|
""" """
|
|
self.root = None
|
|
self.clear()
|
|
|
|
def slot_item_changed(self, item, column):
|
|
""" """
|
|
if self.is_editing:
|
|
self.update_preview()
|
|
self.is_editing = False
|
|
|
|
def slot_remove_selected(self, action):
|
|
""" """
|
|
for o in self.selectedItems():
|
|
if o == self.root:
|
|
self.takeTopLevelItem(self.indexOfTopLevelItem(o))
|
|
self.root = None
|
|
continue
|
|
o.parent().removeChild(o)
|
|
|
|
def slot_nest_features(self, action):
|
|
""" """
|
|
# create a new parent under root node, by default; new node added last position in tree
|
|
new_parent = self.new_expression_node(self.root, (action.data()[0], ""))
|
|
|
|
for o in self.get_features(selected=True):
|
|
# take child from its parent by index, add to new parent
|
|
new_parent.addChild(o.parent().takeChild(o.parent().indexOfChild(o)))
|
|
|
|
# ensure new parent expanded
|
|
new_parent.setExpanded(True)
|
|
|
|
def slot_edit_expression(self, action):
|
|
""" """
|
|
expression, o = action.data()
|
|
o.setText(CapaExplorerRulgenEditor.get_column_feature_index(), expression)
|
|
|
|
def slot_clear_all(self, action):
|
|
""" """
|
|
self.reset_view()
|
|
|
|
def slot_custom_context_menu_requested(self, pos):
|
|
""" """
|
|
if not self.indexAt(pos).isValid():
|
|
# user selected invalid index
|
|
self.load_custom_context_menu_invalid_index(pos)
|
|
elif self.itemAt(pos).capa_type == CapaExplorerRulgenEditor.get_node_type_expression():
|
|
# user selected expression node
|
|
self.load_custom_context_menu_expression(pos)
|
|
else:
|
|
# user selected feature node
|
|
self.load_custom_context_menu_feature(pos)
|
|
|
|
self.update_preview()
|
|
|
|
def slot_item_double_clicked(self, o, column):
|
|
""" """
|
|
if column in (
|
|
CapaExplorerRulgenEditor.get_column_comment_index(),
|
|
CapaExplorerRulgenEditor.get_column_description_index(),
|
|
):
|
|
o.setFlags(o.flags() | QtCore.Qt.ItemIsEditable)
|
|
self.editItem(o, column)
|
|
o.setFlags(o.flags() & ~QtCore.Qt.ItemIsEditable)
|
|
self.is_editing = True
|
|
|
|
def update_preview(self):
|
|
""" """
|
|
rule_text = self.preview.toPlainText()
|
|
|
|
if -1 != rule_text.find("features:"):
|
|
rule_text = rule_text[: rule_text.find("features:") + len("features:")]
|
|
rule_text += "\n"
|
|
else:
|
|
rule_text = rule_text.rstrip()
|
|
rule_text += "\n features:\n"
|
|
|
|
for o in iterate_tree(self):
|
|
feature, description, comment = map(lambda o: o.strip(), tuple(o.text(i) for i in range(3)))
|
|
rule_text += parse_node_for_feature(feature, description, comment, calc_item_depth(o))
|
|
|
|
# FIXME we avoid circular update by disabling signals when updating
|
|
# the preview. Preferably we would refactor the code to avoid this
|
|
# in the first place
|
|
self.preview.blockSignals(True)
|
|
self.preview.setPlainText(rule_text)
|
|
self.preview.blockSignals(False)
|
|
|
|
# emit signal so views can update
|
|
self.updated.emit()
|
|
|
|
def load_custom_context_menu_invalid_index(self, pos):
|
|
""" """
|
|
actions = (("Remove all", (), self.slot_clear_all),)
|
|
|
|
menu = build_context_menu(self.parent(), actions)
|
|
menu.exec_(self.viewport().mapToGlobal(pos))
|
|
|
|
def load_custom_context_menu_feature(self, pos):
|
|
""" """
|
|
actions = (("Remove selection", (), self.slot_remove_selected),)
|
|
|
|
sub_actions = (
|
|
("and", ("- and:",), self.slot_nest_features),
|
|
("or", ("- or:",), self.slot_nest_features),
|
|
("not", ("- not:",), self.slot_nest_features),
|
|
("optional", ("- optional:",), self.slot_nest_features),
|
|
("basic block", ("- basic block:",), self.slot_nest_features),
|
|
)
|
|
|
|
# build submenu with modify actions
|
|
sub_menu = build_context_menu(self.parent(), sub_actions)
|
|
sub_menu.setTitle("Nest feature%s" % ("" if len(tuple(self.get_features(selected=True))) == 1 else "s"))
|
|
|
|
# build main menu with submenu + main actions
|
|
menu = build_context_menu(self.parent(), (sub_menu,) + actions)
|
|
|
|
menu.exec_(self.viewport().mapToGlobal(pos))
|
|
|
|
def load_custom_context_menu_expression(self, pos):
|
|
""" """
|
|
actions = (("Remove expression", (), self.slot_remove_selected),)
|
|
|
|
sub_actions = (
|
|
("and", ("- and:", self.itemAt(pos)), self.slot_edit_expression),
|
|
("or", ("- or:", self.itemAt(pos)), self.slot_edit_expression),
|
|
("not", ("- not:", self.itemAt(pos)), self.slot_edit_expression),
|
|
("optional", ("- optional:", self.itemAt(pos)), self.slot_edit_expression),
|
|
("basic block", ("- basic block:", self.itemAt(pos)), self.slot_edit_expression),
|
|
)
|
|
|
|
# build submenu with modify actions
|
|
sub_menu = build_context_menu(self.parent(), sub_actions)
|
|
sub_menu.setTitle("Modify")
|
|
|
|
# build main menu with submenu + main actions
|
|
menu = build_context_menu(self.parent(), (sub_menu,) + actions)
|
|
|
|
menu.exec_(self.viewport().mapToGlobal(pos))
|
|
|
|
def style_expression_node(self, o):
|
|
""" """
|
|
font = QtGui.QFont()
|
|
font.setBold(True)
|
|
|
|
o.setFont(CapaExplorerRulgenEditor.get_column_feature_index(), font)
|
|
|
|
def style_feature_node(self, o):
|
|
""" """
|
|
font = QtGui.QFont()
|
|
brush = QtGui.QBrush()
|
|
|
|
font.setFamily("Courier")
|
|
font.setWeight(QtGui.QFont.Medium)
|
|
brush.setColor(QtGui.QColor(*COLOR_GREEN_RGB))
|
|
|
|
o.setFont(CapaExplorerRulgenEditor.get_column_feature_index(), font)
|
|
o.setForeground(CapaExplorerRulgenEditor.get_column_feature_index(), brush)
|
|
|
|
def style_comment_node(self, o):
|
|
""" """
|
|
font = QtGui.QFont()
|
|
font.setBold(True)
|
|
font.setFamily("Courier")
|
|
|
|
o.setFont(CapaExplorerRulgenEditor.get_column_feature_index(), font)
|
|
|
|
def set_expression_node(self, o):
|
|
""" """
|
|
setattr(o, "capa_type", CapaExplorerRulgenEditor.get_node_type_expression())
|
|
self.style_expression_node(o)
|
|
|
|
def set_feature_node(self, o):
|
|
""" """
|
|
setattr(o, "capa_type", CapaExplorerRulgenEditor.get_node_type_feature())
|
|
o.setFlags(o.flags() & ~QtCore.Qt.ItemIsDropEnabled)
|
|
self.style_feature_node(o)
|
|
|
|
def set_comment_node(self, o):
|
|
""" """
|
|
setattr(o, "capa_type", CapaExplorerRulgenEditor.get_node_type_comment())
|
|
o.setFlags(o.flags() & ~QtCore.Qt.ItemIsDropEnabled)
|
|
|
|
self.style_comment_node(o)
|
|
|
|
def new_expression_node(self, parent, values=()):
|
|
""" """
|
|
o = QtWidgets.QTreeWidgetItem(parent)
|
|
self.set_expression_node(o)
|
|
for (i, v) in enumerate(values):
|
|
o.setText(i, v)
|
|
return o
|
|
|
|
def new_feature_node(self, parent, values=()):
|
|
""" """
|
|
o = QtWidgets.QTreeWidgetItem(parent)
|
|
self.set_feature_node(o)
|
|
for (i, v) in enumerate(values):
|
|
o.setText(i, v)
|
|
return o
|
|
|
|
def new_comment_node(self, parent, values=()):
|
|
""" """
|
|
o = QtWidgets.QTreeWidgetItem(parent)
|
|
self.set_comment_node(o)
|
|
for (i, v) in enumerate(values):
|
|
o.setText(i, v)
|
|
return o
|
|
|
|
def update_features(self, features):
|
|
""" """
|
|
if not self.root:
|
|
# root node does not exist, create default node, set expanded
|
|
self.root = self.new_expression_node(self, ("- or:", ""))
|
|
|
|
# build feature counts
|
|
counted = list(zip(Counter(features).keys(), Counter(features).values()))
|
|
|
|
# single features
|
|
for (k, v) in filter(lambda t: t[1] == 1, counted):
|
|
self.new_feature_node(self.root, ("- %s: %s" % (k.name.lower(), k.get_value_str()), ""))
|
|
|
|
# n > 1 features
|
|
for (k, v) in filter(lambda t: t[1] > 1, counted):
|
|
self.new_feature_node(self.root, ("- count(%s): %d" % (str(k), v), ""))
|
|
|
|
self.expandAll()
|
|
self.update_preview()
|
|
|
|
def load_features_from_yaml(self, rule_text, update_preview=False):
|
|
""" """
|
|
|
|
def add_node(parent, node):
|
|
if node.text(0).startswith("description:"):
|
|
if parent.childCount():
|
|
parent.child(parent.childCount() - 1).setText(1, node.text(0).lstrip("description:").lstrip())
|
|
else:
|
|
parent.setText(1, node.text(0).lstrip("description:").lstrip())
|
|
elif node.text(0).startswith("- description:"):
|
|
parent.setText(1, node.text(0).lstrip("- description:").lstrip())
|
|
else:
|
|
parent.addChild(node)
|
|
|
|
def build(parent, nodes):
|
|
if nodes:
|
|
child_lvl = nodes[0].capa_level
|
|
while nodes:
|
|
node = nodes.pop(0)
|
|
if node.capa_level == child_lvl:
|
|
add_node(parent, node)
|
|
elif node.capa_level > child_lvl:
|
|
nodes.insert(0, node)
|
|
build(parent.child(parent.childCount() - 1), nodes)
|
|
else:
|
|
parent = parent.parent() if parent.parent() else parent
|
|
add_node(parent, node)
|
|
|
|
self.reset_view()
|
|
|
|
# check for lack of features block
|
|
if -1 == rule_text.find("features:"):
|
|
return
|
|
|
|
rule_features = rule_text[rule_text.find("features:") + len("features:") :].strip()
|
|
rule_nodes = list(yaml_to_nodes(rule_features))
|
|
|
|
# check for lack of nodes
|
|
if not rule_nodes:
|
|
return
|
|
|
|
for o in rule_nodes:
|
|
(self.set_expression_node, self.set_feature_node, self.set_comment_node)[o.capa_type](o)
|
|
|
|
self.root = rule_nodes.pop(0)
|
|
self.addTopLevelItem(self.root)
|
|
|
|
if update_preview:
|
|
self.preview.blockSignals(True)
|
|
self.preview.setPlainText(rule_text)
|
|
self.preview.blockSignals(False)
|
|
|
|
build(self.root, rule_nodes)
|
|
|
|
self.expandAll()
|
|
|
|
def get_features(self, selected=False, ignore=()):
|
|
""" """
|
|
for feature in filter(
|
|
lambda o: o.capa_type
|
|
in (CapaExplorerRulgenEditor.get_node_type_feature(), CapaExplorerRulgenEditor.get_node_type_comment()),
|
|
tuple(iterate_tree(self)),
|
|
):
|
|
if feature in ignore:
|
|
continue
|
|
if selected and not feature.isSelected():
|
|
continue
|
|
yield feature
|
|
|
|
def get_expressions(self, selected=False, ignore=()):
|
|
""" """
|
|
for expression in filter(
|
|
lambda o: o.capa_type == CapaExplorerRulgenEditor.get_node_type_expression(), tuple(iterate_tree(self))
|
|
):
|
|
if expression in ignore:
|
|
continue
|
|
if selected and not expression.isSelected():
|
|
continue
|
|
yield expression
|
|
|
|
|
|
class CapaExplorerRulegenFeatures(QtWidgets.QTreeWidget):
|
|
def __init__(self, editor, parent=None):
|
|
""" """
|
|
super(CapaExplorerRulegenFeatures, self).__init__(parent)
|
|
|
|
self.parent_items = {}
|
|
self.editor = editor
|
|
|
|
self.setHeaderLabels(["Feature", "Virtual Address"])
|
|
self.header().setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents)
|
|
self.setStyleSheet("QTreeView::item {padding-right: 15 px;padding-bottom: 2 px;}")
|
|
|
|
self.setExpandsOnDoubleClick(False)
|
|
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
|
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
|
|
|
|
# connect slots
|
|
self.itemDoubleClicked.connect(self.slot_item_double_clicked)
|
|
self.customContextMenuRequested.connect(self.slot_custom_context_menu_requested)
|
|
|
|
self.reset_view()
|
|
|
|
@staticmethod
|
|
def get_column_feature_index():
|
|
""" """
|
|
return 0
|
|
|
|
@staticmethod
|
|
def get_column_address_index():
|
|
""" """
|
|
return 1
|
|
|
|
@staticmethod
|
|
def get_node_type_parent():
|
|
""" """
|
|
return 0
|
|
|
|
@staticmethod
|
|
def get_node_type_leaf():
|
|
""" """
|
|
return 1
|
|
|
|
def reset_view(self):
|
|
""" """
|
|
self.clear()
|
|
|
|
def slot_add_selected_features(self, action):
|
|
""" """
|
|
selected = [item.data(0, 0x100) for item in self.selectedItems()]
|
|
if selected:
|
|
self.editor.update_features(selected)
|
|
|
|
def slot_custom_context_menu_requested(self, pos):
|
|
""" """
|
|
actions = []
|
|
action_add_features_fmt = ""
|
|
|
|
selected_items_count = len(self.selectedItems())
|
|
if selected_items_count == 0:
|
|
return
|
|
|
|
if selected_items_count == 1:
|
|
action_add_features_fmt = "Add feature"
|
|
else:
|
|
action_add_features_fmt = "Add %d features" % selected_items_count
|
|
|
|
actions.append((action_add_features_fmt, (), self.slot_add_selected_features))
|
|
|
|
menu = build_context_menu(self.parent(), actions)
|
|
menu.exec_(self.viewport().mapToGlobal(pos))
|
|
|
|
def slot_item_double_clicked(self, o, column):
|
|
""" """
|
|
if column == CapaExplorerRulegenFeatures.get_column_address_index() and o.text(column):
|
|
idc.jumpto(int(o.text(column), 0x10))
|
|
elif o.capa_type == CapaExplorerRulegenFeatures.get_node_type_leaf():
|
|
self.editor.update_features([o.data(0, 0x100)])
|
|
|
|
def show_all_items(self):
|
|
""" """
|
|
for o in iterate_tree(self):
|
|
o.setHidden(False)
|
|
o.setExpanded(False)
|
|
|
|
def filter_items_by_text(self, text):
|
|
""" """
|
|
if text:
|
|
for o in iterate_tree(self):
|
|
data = o.data(0, 0x100)
|
|
if data and text.lower() not in data.get_value_str().lower():
|
|
o.setHidden(True)
|
|
continue
|
|
o.setHidden(False)
|
|
o.setExpanded(True)
|
|
else:
|
|
self.show_all_items()
|
|
|
|
def style_parent_node(self, o):
|
|
""" """
|
|
font = QtGui.QFont()
|
|
font.setBold(True)
|
|
|
|
o.setFont(CapaExplorerRulegenFeatures.get_column_feature_index(), font)
|
|
|
|
def style_leaf_node(self, o):
|
|
""" """
|
|
font = QtGui.QFont("Courier", weight=QtGui.QFont.Bold)
|
|
brush = QtGui.QBrush()
|
|
|
|
o.setFont(CapaExplorerRulegenFeatures.get_column_feature_index(), font)
|
|
o.setFont(CapaExplorerRulegenFeatures.get_column_address_index(), font)
|
|
|
|
brush.setColor(QtGui.QColor(*COLOR_GREEN_RGB))
|
|
o.setForeground(CapaExplorerRulegenFeatures.get_column_feature_index(), brush)
|
|
|
|
brush.setColor(QtGui.QColor(*COLOR_BLUE_RGB))
|
|
o.setForeground(CapaExplorerRulegenFeatures.get_column_address_index(), brush)
|
|
|
|
def set_parent_node(self, o):
|
|
""" """
|
|
o.setFlags(o.flags() & ~QtCore.Qt.ItemIsSelectable)
|
|
setattr(o, "capa_type", CapaExplorerRulegenFeatures.get_node_type_parent())
|
|
self.style_parent_node(o)
|
|
|
|
def set_leaf_node(self, o):
|
|
""" """
|
|
setattr(o, "capa_type", CapaExplorerRulegenFeatures.get_node_type_leaf())
|
|
self.style_leaf_node(o)
|
|
|
|
def new_parent_node(self, parent, data, feature=None):
|
|
""" """
|
|
o = QtWidgets.QTreeWidgetItem(parent)
|
|
|
|
self.set_parent_node(o)
|
|
for (i, v) in enumerate(data):
|
|
o.setText(i, v)
|
|
if feature:
|
|
o.setData(0, 0x100, feature)
|
|
|
|
return o
|
|
|
|
def new_leaf_node(self, parent, data, feature=None):
|
|
""" """
|
|
o = QtWidgets.QTreeWidgetItem(parent)
|
|
|
|
self.set_leaf_node(o)
|
|
for (i, v) in enumerate(data):
|
|
o.setText(i, v)
|
|
if feature:
|
|
o.setData(0, 0x100, feature)
|
|
|
|
return o
|
|
|
|
def load_features(self, file_features, func_features={}):
|
|
""" """
|
|
self.parse_features_for_tree(self.new_parent_node(self, ("File Scope",)), file_features)
|
|
if func_features:
|
|
self.parse_features_for_tree(self.new_parent_node(self, ("Function/Basic Block Scope",)), func_features)
|
|
|
|
def parse_features_for_tree(self, parent, features):
|
|
""" """
|
|
self.parent_items = {}
|
|
|
|
def format_address(e):
|
|
return "%X" % e if e else ""
|
|
|
|
for (feature, eas) in sorted(features.items(), key=lambda k: sorted(k[1])):
|
|
if isinstance(feature, capa.features.basicblock.BasicBlock):
|
|
# filter basic blocks for now, we may want to add these back in some time
|
|
# in the future
|
|
continue
|
|
|
|
if isinstance(feature, capa.features.String):
|
|
# strip string for display
|
|
feature.value = feature.value.strip()
|
|
|
|
# level 0
|
|
if type(feature) not in self.parent_items:
|
|
self.parent_items[type(feature)] = self.new_parent_node(parent, (feature.name.lower(),))
|
|
|
|
# level 1
|
|
if feature not in self.parent_items:
|
|
if len(eas) > 1:
|
|
self.parent_items[feature] = self.new_parent_node(
|
|
self.parent_items[type(feature)], (str(feature),), feature=feature
|
|
)
|
|
else:
|
|
self.parent_items[feature] = self.new_leaf_node(
|
|
self.parent_items[type(feature)], (str(feature),), feature=feature
|
|
)
|
|
|
|
# level n > 1
|
|
if len(eas) > 1:
|
|
for ea in sorted(eas):
|
|
self.new_leaf_node(self.parent_items[feature], (str(feature), format_address(ea)), feature=feature)
|
|
else:
|
|
ea = eas.pop()
|
|
for (i, v) in enumerate((str(feature), format_address(ea))):
|
|
self.parent_items[feature].setText(i, v)
|
|
self.parent_items[feature].setData(0, 0x100, feature)
|
|
|
|
|
|
class CapaExplorerQtreeView(QtWidgets.QTreeView):
|
|
"""tree view used to display hierarchical capa results
|
|
|
|
view controls UI action responses and displays data from CapaExplorerDataModel
|
|
|
|
view does not modify CapaExplorerDataModel directly - data modifications should be implemented
|
|
in CapaExplorerDataModel
|
|
"""
|
|
|
|
def __init__(self, model, parent=None):
|
|
"""initialize view"""
|
|
super(CapaExplorerQtreeView, self).__init__(parent)
|
|
|
|
self.setModel(model)
|
|
|
|
self.model = model
|
|
self.parent = parent
|
|
|
|
# control when we resize columns
|
|
self.should_resize_columns = True
|
|
|
|
# configure custom UI controls
|
|
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
|
self.setExpandsOnDoubleClick(False)
|
|
self.setSortingEnabled(True)
|
|
self.model.setDynamicSortFilter(False)
|
|
|
|
# configure view columns to auto-resize
|
|
for idx in range(CapaExplorerDataModel.COLUMN_COUNT):
|
|
self.header().setSectionResizeMode(idx, QtWidgets.QHeaderView.Interactive)
|
|
|
|
# disable stretch to enable horizontal scroll for last column, when needed
|
|
self.header().setStretchLastSection(False)
|
|
|
|
# connect slots to resize columns when expanded or collapsed
|
|
self.expanded.connect(self.slot_resize_columns_to_content)
|
|
self.collapsed.connect(self.slot_resize_columns_to_content)
|
|
|
|
# connect slots
|
|
self.customContextMenuRequested.connect(self.slot_custom_context_menu_requested)
|
|
self.doubleClicked.connect(self.slot_double_click)
|
|
|
|
self.setStyleSheet("QTreeView::item {padding-right: 15 px;padding-bottom: 2 px;}")
|
|
|
|
def reset_ui(self, should_sort=True):
|
|
"""reset user interface changes
|
|
|
|
called when view should reset UI display e.g. expand items, resize columns
|
|
|
|
@param should_sort: True, sort results after reset, False don't sort results after reset
|
|
"""
|
|
if should_sort:
|
|
self.sortByColumn(CapaExplorerDataModel.COLUMN_INDEX_RULE_INFORMATION, QtCore.Qt.AscendingOrder)
|
|
|
|
self.should_resize_columns = False
|
|
self.expandToDepth(0)
|
|
self.should_resize_columns = True
|
|
|
|
self.slot_resize_columns_to_content()
|
|
|
|
def slot_resize_columns_to_content(self):
|
|
"""reset view columns to contents"""
|
|
if self.should_resize_columns:
|
|
self.header().resizeSections(QtWidgets.QHeaderView.ResizeToContents)
|
|
|
|
# limit size of first section
|
|
if self.header().sectionSize(0) > MAX_SECTION_SIZE:
|
|
self.header().resizeSection(0, MAX_SECTION_SIZE)
|
|
|
|
def map_index_to_source_item(self, model_index):
|
|
"""map proxy model index to source model item
|
|
|
|
@param model_index: QModelIndex
|
|
|
|
@retval QObject
|
|
"""
|
|
# assume that self.model here is either:
|
|
# - CapaExplorerDataModel, or
|
|
# - QSortFilterProxyModel subclass
|
|
#
|
|
# The ProxyModels may be chained,
|
|
# so keep resolving the index the CapaExplorerDataModel.
|
|
|
|
model = self.model
|
|
while not isinstance(model, CapaExplorerDataModel):
|
|
if not model_index.isValid():
|
|
raise ValueError("invalid index")
|
|
|
|
model_index = model.mapToSource(model_index)
|
|
model = model.sourceModel()
|
|
|
|
if not model_index.isValid():
|
|
raise ValueError("invalid index")
|
|
|
|
return model_index.internalPointer()
|
|
|
|
def send_data_to_clipboard(self, data):
|
|
"""copy data to the clipboard
|
|
|
|
@param data: data to be copied
|
|
"""
|
|
clip = QtWidgets.QApplication.clipboard()
|
|
clip.clear(mode=clip.Clipboard)
|
|
clip.setText(data, mode=clip.Clipboard)
|
|
|
|
def new_action(self, display, data, slot):
|
|
"""create action for context menu
|
|
|
|
@param display: text displayed to user in context menu
|
|
@param data: data passed to slot
|
|
@param slot: slot to connect
|
|
|
|
@retval QAction
|
|
"""
|
|
action = QtWidgets.QAction(display, self.parent)
|
|
action.setData(data)
|
|
action.triggered.connect(lambda checked: slot(action))
|
|
|
|
return action
|
|
|
|
def load_default_context_menu_actions(self, data):
|
|
"""yield actions specific to function custom context menu
|
|
|
|
@param data: tuple
|
|
|
|
@yield QAction
|
|
"""
|
|
default_actions = (
|
|
("Copy column", data, self.slot_copy_column),
|
|
("Copy row", data, self.slot_copy_row),
|
|
)
|
|
|
|
# add default actions
|
|
for action in default_actions:
|
|
yield self.new_action(*action)
|
|
|
|
def load_function_context_menu_actions(self, data):
|
|
"""yield actions specific to function custom context menu
|
|
|
|
@param data: tuple
|
|
|
|
@yield QAction
|
|
"""
|
|
function_actions = (("Rename function", data, self.slot_rename_function),)
|
|
|
|
# add function actions
|
|
for action in function_actions:
|
|
yield self.new_action(*action)
|
|
|
|
# add default actions
|
|
for action in self.load_default_context_menu_actions(data):
|
|
yield action
|
|
|
|
def load_default_context_menu(self, pos, item, model_index):
|
|
"""create default custom context menu
|
|
|
|
creates custom context menu containing default actions
|
|
|
|
@param pos: cursor position
|
|
@param item: CapaExplorerDataItem
|
|
@param model_index: QModelIndex
|
|
|
|
@retval QMenu
|
|
"""
|
|
menu = QtWidgets.QMenu()
|
|
|
|
for action in self.load_default_context_menu_actions((pos, item, model_index)):
|
|
menu.addAction(action)
|
|
|
|
return menu
|
|
|
|
def load_function_item_context_menu(self, pos, item, model_index):
|
|
"""create function custom context menu
|
|
|
|
creates custom context menu with both default actions and function actions
|
|
|
|
@param pos: cursor position
|
|
@param item: CapaExplorerDataItem
|
|
@param model_index: QModelIndex
|
|
|
|
@retval QMenu
|
|
"""
|
|
menu = QtWidgets.QMenu()
|
|
|
|
for action in self.load_function_context_menu_actions((pos, item, model_index)):
|
|
menu.addAction(action)
|
|
|
|
return menu
|
|
|
|
def show_custom_context_menu(self, menu, pos):
|
|
"""display custom context menu in view
|
|
|
|
@param menu: QMenu to display
|
|
@param pos: cursor position
|
|
"""
|
|
if menu:
|
|
menu.exec_(self.viewport().mapToGlobal(pos))
|
|
|
|
def slot_copy_column(self, action):
|
|
"""slot connected to custom context menu
|
|
|
|
allows user to select a column and copy the data to clipboard
|
|
|
|
@param action: QAction
|
|
"""
|
|
_, item, model_index = action.data()
|
|
self.send_data_to_clipboard(item.data(model_index.column()))
|
|
|
|
def slot_copy_row(self, action):
|
|
"""slot connected to custom context menu
|
|
|
|
allows user to select a row and copy the space-delimited data to clipboard
|
|
|
|
@param action: QAction
|
|
"""
|
|
_, item, _ = action.data()
|
|
self.send_data_to_clipboard(str(item))
|
|
|
|
def slot_rename_function(self, action):
|
|
"""slot connected to custom context menu
|
|
|
|
allows user to select a edit a function name and push changes to IDA
|
|
|
|
@param action: QAction
|
|
"""
|
|
_, item, model_index = action.data()
|
|
|
|
# make item temporary edit, reset after user is finished
|
|
item.setIsEditable(True)
|
|
self.edit(model_index)
|
|
item.setIsEditable(False)
|
|
|
|
def slot_custom_context_menu_requested(self, pos):
|
|
"""slot connected to custom context menu request
|
|
|
|
displays custom context menu to user containing action relevant to the item selected
|
|
|
|
@param pos: cursor position
|
|
"""
|
|
model_index = self.indexAt(pos)
|
|
|
|
if not model_index.isValid():
|
|
return
|
|
|
|
item = self.map_index_to_source_item(model_index)
|
|
|
|
column = model_index.column()
|
|
menu = None
|
|
|
|
if CapaExplorerDataModel.COLUMN_INDEX_RULE_INFORMATION == column and isinstance(item, CapaExplorerFunctionItem):
|
|
# user hovered function item
|
|
menu = self.load_function_item_context_menu(pos, item, model_index)
|
|
else:
|
|
# user hovered default item
|
|
menu = self.load_default_context_menu(pos, item, model_index)
|
|
|
|
# show custom context menu at view position
|
|
self.show_custom_context_menu(menu, pos)
|
|
|
|
def slot_double_click(self, model_index):
|
|
"""slot connected to double-click event
|
|
|
|
if address column clicked, navigate IDA to address, else un/expand item clicked
|
|
|
|
@param model_index: QModelIndex
|
|
"""
|
|
if not model_index.isValid():
|
|
return
|
|
|
|
item = self.map_index_to_source_item(model_index)
|
|
column = model_index.column()
|
|
|
|
if CapaExplorerDataModel.COLUMN_INDEX_VIRTUAL_ADDRESS == column and item.location:
|
|
# user double-clicked virtual address column - navigate IDA to address
|
|
idc.jumpto(item.location)
|
|
|
|
if CapaExplorerDataModel.COLUMN_INDEX_RULE_INFORMATION == column:
|
|
# user double-clicked information column - un/expand
|
|
self.collapse(model_index) if self.isExpanded(model_index) else self.expand(model_index)
|