mirror of
https://github.com/gaasedelen/lighthouse
synced 2026-06-08 14:19:10 +00:00
614 lines
20 KiB
Python
614 lines
20 KiB
Python
import os
|
|
import sys
|
|
import time
|
|
import logging
|
|
import binascii
|
|
import tempfile
|
|
import functools
|
|
|
|
import idaapi
|
|
import idautils
|
|
|
|
if int(idaapi.get_kernel_version()[0]) < 7:
|
|
idaapi.warning("Lighthouse has deprecated support for IDA 6, please upgrade.")
|
|
raise ImportError
|
|
|
|
from .api import DisassemblerCoreAPI, DisassemblerContextAPI
|
|
from ..qt import *
|
|
from ..misc import is_mainthread, get_string_between
|
|
|
|
logger = logging.getLogger("Lighthouse.API.IDA")
|
|
|
|
#------------------------------------------------------------------------------
|
|
# Utils
|
|
#------------------------------------------------------------------------------
|
|
|
|
def execute_sync(function, sync_type):
|
|
"""
|
|
Synchronize with the disassembler for safe database access.
|
|
|
|
Modified from https://github.com/vrtadmin/FIRST-plugin-ida
|
|
"""
|
|
|
|
@functools.wraps(function)
|
|
def wrapper(*args, **kwargs):
|
|
output = [None]
|
|
|
|
#
|
|
# this inline function definition is technically what will execute
|
|
# in the context of the main thread. we use this thunk to capture
|
|
# any output the function may want to return to the user.
|
|
#
|
|
|
|
def thunk():
|
|
output[0] = function(*args, **kwargs)
|
|
return 1
|
|
|
|
if is_mainthread():
|
|
thunk()
|
|
else:
|
|
idaapi.execute_sync(thunk, sync_type)
|
|
|
|
# return the output of the synchronized execution
|
|
return output[0]
|
|
return wrapper
|
|
|
|
#------------------------------------------------------------------------------
|
|
# Disassembler Core API (universal)
|
|
#------------------------------------------------------------------------------
|
|
|
|
class IDACoreAPI(DisassemblerCoreAPI):
|
|
NAME = "IDA"
|
|
|
|
def __init__(self):
|
|
super(IDACoreAPI, self).__init__()
|
|
self._dockable_factory = {}
|
|
self._dockable_widgets = {}
|
|
self._init_version()
|
|
|
|
def _init_version(self):
|
|
|
|
# retrieve IDA's version #
|
|
disassembler_version = idaapi.get_kernel_version()
|
|
major, minor = map(int, disassembler_version.split("."))
|
|
|
|
# save the version number components for later use
|
|
self._version_major = major
|
|
self._version_minor = minor
|
|
self._version_patch = 0
|
|
|
|
#--------------------------------------------------------------------------
|
|
# Properties
|
|
#--------------------------------------------------------------------------
|
|
|
|
@property
|
|
def headless(self):
|
|
return idaapi.cvar.batch
|
|
|
|
#--------------------------------------------------------------------------
|
|
# Synchronization Decorators
|
|
#--------------------------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def execute_read(function):
|
|
return execute_sync(function, idaapi.MFF_READ)
|
|
|
|
@staticmethod
|
|
def execute_write(function):
|
|
return execute_sync(function, idaapi.MFF_WRITE)
|
|
|
|
@staticmethod
|
|
def execute_ui(function):
|
|
return execute_sync(function, idaapi.MFF_FAST)
|
|
|
|
#--------------------------------------------------------------------------
|
|
# API Shims
|
|
#--------------------------------------------------------------------------
|
|
|
|
def get_disassembler_user_directory(self):
|
|
return idaapi.get_user_idadir()
|
|
|
|
def get_disassembly_background_color(self):
|
|
"""
|
|
Get the background color of the IDA disassembly view.
|
|
|
|
Since there is no supported way to probe the palette & colors in use by
|
|
IDA, we must get creative. This function attempts to locate an IDA
|
|
disassembly view, and take a screenshot of said widget. It will then
|
|
attempt to extract the color of a single background pixel (hopefully).
|
|
"""
|
|
|
|
# method one
|
|
color = self._get_ida_bg_color_from_file()
|
|
if color:
|
|
return color
|
|
|
|
# method two, fallback
|
|
color = self._get_ida_bg_color_from_view()
|
|
if not color:
|
|
return None
|
|
|
|
# return the found background color
|
|
return color
|
|
|
|
def is_msg_inited(self):
|
|
return idaapi.is_msg_inited()
|
|
|
|
@execute_ui.__func__
|
|
def warning(self, text):
|
|
super(IDACoreAPI, self).warning(text)
|
|
|
|
@execute_ui.__func__
|
|
def message(self, message):
|
|
print(message)
|
|
|
|
#--------------------------------------------------------------------------
|
|
# UI API Shims
|
|
#--------------------------------------------------------------------------
|
|
|
|
def register_dockable(self, dockable_name, create_widget_callback):
|
|
self._dockable_factory[dockable_name] = create_widget_callback
|
|
|
|
def create_dockable_widget(self, parent, dockable_name):
|
|
# IDA 9.2 SDK fix: migrate from PyQt5 to PySide6
|
|
# https://docs.hex-rays.com/user-guide/plugins/migrating-pyqt5-code-to-pyside6
|
|
if USING_PYSIDE6:
|
|
from shiboken6 import wrapInstance
|
|
else:
|
|
import sip
|
|
def wrapInstance(ptr, base=None):
|
|
return sip.wrapinstance(int(ptr), base)
|
|
|
|
# create a dockable widget, and save a reference to it for later use
|
|
twidget = idaapi.create_empty_widget(dockable_name)
|
|
self._dockable_widgets[dockable_name] = twidget
|
|
|
|
# cast the IDA 'twidget' as a Qt widget for use
|
|
widget = wrapInstance(int(twidget), QtWidgets.QWidget)
|
|
widget.name = dockable_name
|
|
widget.visible = False
|
|
|
|
# return the dockable QtWidget / container
|
|
return widget
|
|
|
|
def show_dockable(self, dockable_name):
|
|
try:
|
|
make_dockable = self._dockable_factory[dockable_name]
|
|
except KeyError:
|
|
return False
|
|
|
|
parent, dctx = None, None # not used for IDA's integration
|
|
widget = make_dockable(dockable_name, parent, dctx)
|
|
|
|
# get the original twidget, so we can use it with the IDA API's
|
|
#twidget = idaapi.TWidget__from_ptrval__(widget) NOTE: IDA 7.2+ only...
|
|
twidget = self._dockable_widgets.pop(dockable_name)
|
|
if not twidget:
|
|
self.warning("Could not open dockable window, because its reference is gone?!?")
|
|
return
|
|
|
|
# show the dockable widget
|
|
flags = idaapi.PluginForm.WOPN_TAB | idaapi.PluginForm.WOPN_RESTORE | idaapi.PluginForm.WOPN_PERSIST
|
|
idaapi.display_widget(twidget, flags)
|
|
widget.visible = True
|
|
|
|
# attempt to 'dock' the widget in a reasonable location
|
|
for target in ["IDA View-A", "Pseudocode-A"]:
|
|
dwidget = idaapi.find_widget(target)
|
|
if dwidget:
|
|
idaapi.set_dock_pos(dockable_name, 'IDA View-A', idaapi.DP_RIGHT)
|
|
break
|
|
|
|
def hide_dockable(self, dockable_name):
|
|
pass # TODO/IDA: this should never actually be called by lighthouse right now
|
|
|
|
#--------------------------------------------------------------------------
|
|
# Theme Prediction Helpers (Internal)
|
|
#--------------------------------------------------------------------------
|
|
|
|
def _get_ida_bg_color_from_file(self):
|
|
"""
|
|
Get the background color of the IDA disassembly views via HTML export.
|
|
"""
|
|
logger.debug("Attempting to get IDA disassembly background color from HTML...")
|
|
|
|
#
|
|
# TODO/IDA: we need better early detection for if IDA is fully ready,
|
|
# this isn't effective and this func theme func can crash IDA if
|
|
# called too early (eg, during db load...).
|
|
#
|
|
# this isn't a problem now... but I don't want us to be at risk of
|
|
# hard crashing people's IDA in the future should we change something.
|
|
#
|
|
|
|
imagebase = idaapi.get_imagebase()
|
|
#if imagebase == idaapi.BADADDR:
|
|
# logger.debug(" - No imagebase...")
|
|
# return None
|
|
|
|
# create a temp file that we can write to
|
|
handle, path = tempfile.mkstemp()
|
|
os.close(handle)
|
|
|
|
# attempt to generate an 'html' dump of the first 0x20 bytes (instructions)
|
|
ida_fd = idaapi.fopenWT(path)
|
|
idaapi.gen_file(idaapi.OFILE_LST, ida_fd, imagebase, imagebase+0x20, idaapi.GENFLG_GENHTML)
|
|
# IDA 9.x SDK fix: removed `idaapi.eclose`, added `ida_fpro.qfclose`
|
|
if int(idaapi.get_kernel_version()[0]) >= 9:
|
|
import ida_fpro
|
|
ida_fpro.qfclose(ida_fd)
|
|
else:
|
|
idaapi.eclose(ida_fd)
|
|
|
|
# read the dumped text
|
|
with open(path, "r") as fd:
|
|
html = fd.read()
|
|
|
|
# delete the temp file from disk
|
|
try:
|
|
os.remove(path)
|
|
except OSError:
|
|
pass
|
|
|
|
# attempt to parse the user's disassembly background color from the html (7.0?)
|
|
bg_color_text = get_string_between(html, '<body bgcolor="', '">')
|
|
if bg_color_text:
|
|
logger.debug(" - Extracted bgcolor '%s' from regex!" % bg_color_text)
|
|
return QtGui.QColor(bg_color_text)
|
|
|
|
#
|
|
# sometimes the above one isn't present... so try this one (7.1 - 7.4 maybe?)
|
|
#
|
|
# TODO: IDA 7.5 says c1 is /* line-fg-default */ ... but it's possible c1
|
|
# had the bg color of the line in other builds of 7.x? I'm not sure but
|
|
# this should be double checked at some point and can maybe just be removed
|
|
# in favor of c41 (line-bg-default) as that's what we really want
|
|
#
|
|
|
|
bg_color_text = get_string_between(html, '.c1 \{ background-color: ', ';')
|
|
if bg_color_text:
|
|
logger.debug(" - Extracted background-color '%s' from line-fg-default!" % bg_color_text)
|
|
return QtGui.QColor(bg_color_text)
|
|
|
|
# -- IDA 7.5 says c41 is /* line-bg-default */, a.k.a the bg color for disassembly text
|
|
bg_color_text = get_string_between(html, '.c41 \{ background-color: ', ';')
|
|
if bg_color_text:
|
|
logger.debug(" - Extracted background-color '%s' from line-bg-default!" % bg_color_text)
|
|
return QtGui.QColor(bg_color_text)
|
|
|
|
logger.debug(" - HTML color regex failed...")
|
|
logger.debug(html)
|
|
return None
|
|
|
|
def _get_ida_bg_color_from_view(self):
|
|
"""
|
|
Get the background color of the IDA disassembly views via widget inspection.
|
|
"""
|
|
logger.debug("Attempting to get IDA disassembly background color from view...")
|
|
|
|
names = ["Enums", "Structures"]
|
|
names += ["Hex View-%u" % i for i in range(5)]
|
|
names += ["IDA View-%c" % chr(ord('A') + i) for i in range(5)]
|
|
|
|
# find a form (eg, IDA view) to analyze colors from
|
|
for window_name in names:
|
|
twidget = idaapi.find_widget(window_name)
|
|
if twidget:
|
|
break
|
|
else:
|
|
logger.debug(" - Failed to find donor view...")
|
|
return None
|
|
|
|
# touch the target form so we know it is populated
|
|
self._touch_ida_window(twidget)
|
|
|
|
# IDA 9.2 SDK fix: migrate from PyQt5 to PySide6
|
|
# https://docs.hex-rays.com/user-guide/plugins/migrating-pyqt5-code-to-pyside6
|
|
if USING_PYSIDE6:
|
|
from shiboken6 import wrapInstance
|
|
else:
|
|
import sip
|
|
def wrapInstance(ptr, base=None):
|
|
return sip.wrapinstance(int(ptr), base)
|
|
|
|
widget = wrapInstance(int(twidget), QtWidgets.QWidget)
|
|
pixmap = widget.grab(QtCore.QRect(0, 10, widget.width(), 1))
|
|
|
|
# convert the raw pixmap into an image (easier to interface with)
|
|
image = QtGui.QImage(pixmap.toImage())
|
|
|
|
# return the predicted background color
|
|
return QtGui.QColor(predict_bg_color(image))
|
|
|
|
def _touch_ida_window(self, target):
|
|
"""
|
|
Touch a window/widget/form to ensure it gets drawn by IDA.
|
|
|
|
XXX/HACK:
|
|
|
|
We need to ensure that widget we will analyze actually gets drawn
|
|
so that there are colors for us to steal.
|
|
|
|
To do this, we switch to it, and switch back. I tried a few different
|
|
ways to trigger this from Qt, but could only trigger the full
|
|
painting by going through the IDA routines.
|
|
|
|
"""
|
|
|
|
# get the currently active widget/form title (the form itself seems transient...)
|
|
twidget = idaapi.get_current_widget()
|
|
title = idaapi.get_widget_title(twidget)
|
|
|
|
# touch the target window by switching to it
|
|
idaapi.activate_widget(target, True)
|
|
flush_qt_events()
|
|
|
|
# locate our previous selection
|
|
previous_twidget = idaapi.find_widget(title)
|
|
|
|
# return us to our previous selection
|
|
idaapi.activate_widget(previous_twidget, True)
|
|
flush_qt_events()
|
|
|
|
#------------------------------------------------------------------------------
|
|
# Disassembler Context API (database-specific)
|
|
#------------------------------------------------------------------------------
|
|
|
|
class IDAContextAPI(DisassemblerContextAPI):
|
|
|
|
def __init__(self, dctx):
|
|
super(IDAContextAPI, self).__init__(dctx)
|
|
|
|
@property
|
|
def busy(self):
|
|
return not(idaapi.auto_is_ok())
|
|
|
|
#--------------------------------------------------------------------------
|
|
# API Shims
|
|
#--------------------------------------------------------------------------
|
|
|
|
@IDACoreAPI.execute_read
|
|
def get_current_address(self):
|
|
return idaapi.get_screen_ea()
|
|
|
|
def get_database_directory(self):
|
|
return idautils.GetIdbDir()
|
|
|
|
def get_function_addresses(self):
|
|
return list(idautils.Functions())
|
|
|
|
def get_function_name_at(self, address):
|
|
return idaapi.get_short_name(address)
|
|
|
|
def get_function_raw_name_at(self, function_address):
|
|
return idaapi.get_name(function_address)
|
|
|
|
def get_imagebase(self):
|
|
return idaapi.get_imagebase()
|
|
|
|
def get_root_filename(self):
|
|
return idaapi.get_root_filename()
|
|
|
|
def navigate(self, address):
|
|
return idaapi.jumpto(address)
|
|
|
|
def navigate_to_function(self, function_address, address):
|
|
return self.navigate(address)
|
|
|
|
def set_function_name_at(self, function_address, new_name):
|
|
idaapi.set_name(function_address, new_name, idaapi.SN_NOWARN)
|
|
|
|
#--------------------------------------------------------------------------
|
|
# Hooks API
|
|
#--------------------------------------------------------------------------
|
|
|
|
def create_rename_hooks(self):
|
|
return RenameHooks()
|
|
|
|
#------------------------------------------------------------------------------
|
|
# Function Prefix API
|
|
#------------------------------------------------------------------------------
|
|
|
|
PREFIX_SEPARATOR = "%"
|
|
|
|
#------------------------------------------------------------------------------
|
|
# Hooking
|
|
#------------------------------------------------------------------------------
|
|
|
|
class RenameHooks(idaapi.IDB_Hooks):
|
|
|
|
def renamed(self, address, new_name, local_name):
|
|
"""
|
|
Capture all IDA rename events.
|
|
"""
|
|
|
|
# we should never care about local renames (eg, loc_40804b), ignore
|
|
if local_name or new_name.startswith("loc_"):
|
|
return 0
|
|
|
|
rendered_name = idaapi.get_short_name(address)
|
|
|
|
# call the 'renamed' callback, that will get hooked by a listener
|
|
self.name_changed(address, rendered_name)
|
|
|
|
# must return 0 to keep IDA happy...
|
|
return 0
|
|
|
|
def name_changed(self, address, new_name):
|
|
"""
|
|
A placeholder callback, which will get hooked / replaced once live.
|
|
"""
|
|
pass
|
|
|
|
#------------------------------------------------------------------------------
|
|
# HexRays Util
|
|
#------------------------------------------------------------------------------
|
|
|
|
def hexrays_available():
|
|
"""
|
|
Return True if an IDA decompiler is loaded and available for use.
|
|
"""
|
|
try:
|
|
import ida_hexrays
|
|
return ida_hexrays.init_hexrays_plugin()
|
|
except ImportError:
|
|
return False
|
|
|
|
def map_line2citem(decompilation_text):
|
|
"""
|
|
Map decompilation line numbers to citems.
|
|
|
|
This function allows us to build a relationship between citems in the
|
|
ctree and specific lines in the hexrays decompilation text.
|
|
|
|
Output:
|
|
|
|
+- line2citem:
|
|
| a map keyed with line numbers, holding sets of citem indexes
|
|
|
|
|
| eg: { int(line_number): sets(citem_indexes), ... }
|
|
'
|
|
|
|
"""
|
|
line2citem = {}
|
|
|
|
#
|
|
# it turns out that citem indexes are actually stored inline with the
|
|
# decompilation text output, hidden behind COLOR_ADDR tokens.
|
|
#
|
|
# here we pass each line of raw decompilation text to our crappy lexer,
|
|
# extracting any COLOR_ADDR tokens as citem indexes
|
|
#
|
|
|
|
for line_number in xrange(decompilation_text.size()):
|
|
line_text = decompilation_text[line_number].line
|
|
line2citem[line_number] = lex_citem_indexes(line_text)
|
|
#logger.debug("Line Text: %s" % binascii.hexlify(line_text))
|
|
|
|
return line2citem
|
|
|
|
def map_line2node(cfunc, metadata, line2citem):
|
|
"""
|
|
Map decompilation line numbers to node (basic blocks) addresses.
|
|
|
|
This function allows us to build a relationship between graph nodes
|
|
(basic blocks) and specific lines in the hexrays decompilation text.
|
|
|
|
Output:
|
|
|
|
+- line2node:
|
|
| a map keyed with line numbers, holding sets of node addresses
|
|
|
|
|
| eg: { int(line_number): set(nodes), ... }
|
|
'
|
|
|
|
"""
|
|
line2node = {}
|
|
treeitems = cfunc.treeitems
|
|
function_address = cfunc.entry_ea
|
|
|
|
#
|
|
# prior to this function, a line2citem map was built to tell us which
|
|
# citems reside on any given line of text in the decompilation output.
|
|
#
|
|
# now, we walk through this line2citem map one 'line_number' at a time in
|
|
# an effort to resolve the set of graph nodes associated with its citems.
|
|
#
|
|
|
|
for line_number, citem_indexes in iteritems(line2citem):
|
|
nodes = set()
|
|
|
|
#
|
|
# we are at the level of a single line (line_number). we now consume
|
|
# its set of citems (citem_indexes) and attempt to identify explicit
|
|
# graph nodes they claim to be sourced from (by their reported EA)
|
|
#
|
|
|
|
for index in citem_indexes:
|
|
|
|
# get the code address of the given citem
|
|
try:
|
|
item = treeitems[index]
|
|
address = item.ea
|
|
|
|
# apparently this is a thing on IDA 6.95
|
|
except IndexError as e:
|
|
continue
|
|
|
|
# find the graph node (eg, basic block) that generated this citem
|
|
node = metadata.get_node(address)
|
|
|
|
# address not mapped to a node... weird. continue to the next citem
|
|
if not node:
|
|
#logger.warning("Failed to map node to basic block")
|
|
continue
|
|
|
|
#
|
|
# we made it this far, so we must have found a node that contains
|
|
# this citem. save the computed node_id to the list of known
|
|
# nodes we have associated with this line of text
|
|
#
|
|
|
|
nodes.add(node.address)
|
|
|
|
#
|
|
# finally, save the completed list of node ids as identified for this
|
|
# line of decompilation text to the line2node map that we are building
|
|
#
|
|
|
|
line2node[line_number] = nodes
|
|
|
|
# all done, return the computed map
|
|
return line2node
|
|
|
|
def lex_citem_indexes(line):
|
|
"""
|
|
Lex all ctree item indexes from a given line of text.
|
|
|
|
The HexRays decompiler output contains invisible text tokens that can
|
|
be used to attribute spans of text to the ctree items that produced them.
|
|
|
|
This function will simply scrape and return a list of all the these
|
|
tokens (COLOR_ADDR) which contain item indexes into the ctree.
|
|
|
|
"""
|
|
i = 0
|
|
indexes = []
|
|
line_length = len(line)
|
|
|
|
# lex COLOR_ADDR tokens from the line of text
|
|
while i < line_length:
|
|
|
|
# does this character mark the start of a new COLOR_* token?
|
|
if line[i] == idaapi.COLOR_ON:
|
|
|
|
# yes, so move past the COLOR_ON byte
|
|
i += 1
|
|
|
|
# is this sequence for a COLOR_ADDR?
|
|
if ord(line[i]) == idaapi.COLOR_ADDR:
|
|
|
|
# yes, so move past the COLOR_ADDR byte
|
|
i += 1
|
|
|
|
#
|
|
# A COLOR_ADDR token is followed by either 8, or 16 characters
|
|
# (a hex encoded number) that represents an address/pointer.
|
|
# in this context, it is actually the index number of a citem
|
|
#
|
|
|
|
citem_index = int(line[i:i+idaapi.COLOR_ADDR_SIZE], 16)
|
|
i += idaapi.COLOR_ADDR_SIZE
|
|
|
|
# save the extracted citem index
|
|
indexes.append(citem_index)
|
|
|
|
# skip to the next iteration as i has moved
|
|
continue
|
|
|
|
# nothing we care about happened, keep lexing forward
|
|
i += 1
|
|
|
|
# return all the citem indexes extracted from this line of text
|
|
return indexes
|