diff --git a/findfunc/fftabs.py b/findfunc/fftabs.py index 614bb66..016cae9 100644 --- a/findfunc/fftabs.py +++ b/findfunc/fftabs.py @@ -43,6 +43,14 @@ def setupUi(self, fftabs): self.btnsavesess.setSizePolicy(sizePolicy) self.btnsavesess.setObjectName("btnsavesess") self.horizontalLayout.addWidget(self.btnsavesess) + self.btnexportcsv = QtWidgets.QPushButton(fftabs) + sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.btnexportcsv.sizePolicy().hasHeightForWidth()) + self.btnexportcsv.setSizePolicy(sizePolicy) + self.btnexportcsv.setObjectName("btnexportcsv") + self.horizontalLayout.addWidget(self.btnexportcsv) self.linklabel = QtWidgets.QLabel(fftabs) self.linklabel.setAlignment(QtCore.Qt.AlignRight|QtCore.Qt.AlignTrailing|QtCore.Qt.AlignVCenter) self.linklabel.setOpenExternalLinks(True) @@ -60,4 +68,5 @@ def retranslateUi(self, fftabs): self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab), _translate("fftabs", "Tab 1")) self.btnloadsess.setText(_translate("fftabs", "load session")) self.btnsavesess.setText(_translate("fftabs", "save session")) + self.btnexportcsv.setText(_translate("fftabs", "export csv")) self.linklabel.setText(_translate("fftabs", "ff")) diff --git a/findfunc/fftabs.ui b/findfunc/fftabs.ui index 196b11f..b8a9944 100644 --- a/findfunc/fftabs.ui +++ b/findfunc/fftabs.ui @@ -60,6 +60,19 @@ + + + + + 0 + 0 + + + + export csv + + + diff --git a/findfunc/findfunc_gui.py b/findfunc/findfunc_gui.py index d5d2ddb..c144038 100644 --- a/findfunc/findfunc_gui.py +++ b/findfunc/findfunc_gui.py @@ -6,6 +6,7 @@ import io import copy import traceback +import csv from PyQt5 import QtWidgets from PyQt5.QtWidgets import QWidget, QMessageBox, QLineEdit, QApplication, QTabBar, QMenu, QFileDialog @@ -443,6 +444,8 @@ def __init__(self, asplugin=False, parent=None): self.ui.btnsavesess.clicked.connect(self.savesessionclicked) self.ui.btnsavesess.setToolTip("save all tabs to file") self.ui.btnsavesess.setShortcut("ctrl+shift+s") + self.ui.btnexportcsv.clicked.connect(self.exportcsvclicked) + self.ui.btnexportcsv.setToolTip("export results of current tab to csv") self.clearAll() self.addNewTab() for r in [RuleImmediate(9), RuleCode("xor eax,r32"), RuleNameRef("mem*")]: @@ -450,6 +453,38 @@ def __init__(self, asplugin=False, parent=None): self.lastsessionsaved = self.session_to_text() # last saved session data, used for checking on close print("init with config:" + str(self.ui.tabWidget.widget(0).matcher.info)) + def exportcsvclicked(self): + """Export current tab's result table to CSV (comma-separated).""" + tab = self.ui.tabWidget.currentWidget() + if not tab or not hasattr(tab, "resultmodel"): + QMessageBox.warning(self, "Nothing", "No active results tab selected") + return + results = getattr(tab.resultmodel, "mydata", None) + if not results: + QMessageBox.warning(self, "Nothing", "No results to export") + return + + path, _ = QFileDialog.getSaveFileName(self, 'Export Results to CSV', "findfunc_results.csv", + "CSV (*.csv) ;; Any (*.*)") + if not path: + return + + try: + with open(path, 'w', newline='', encoding='utf-8') as handle: + writer = csv.writer(handle, delimiter=',') + writer.writerow(["VA", "Size", "Chunks", "Name", "LastMatch"]) + for r in results: + writer.writerow([ + hex(r.va) if r.va is not None else "", + r.size if r.size is not None else "", + r.chunks if r.chunks is not None else "", + r.name if r.name is not None else "", + hex(r.lastmatch) if getattr(r, "lastmatch", None) is not None else "", + ]) + QMessageBox.information(self, "Success", "Exported successfully to " + path) + except Exception as ex: + QMessageBox.warning(self, "Error exporting file", str(ex)) + def closeEvent(self, event): # when running as script, we need to handle it here # when running as plugin, this is handled in findfuncmain.py diff --git a/findfunc/matcher_ida.py b/findfunc/matcher_ida.py index 6ddb5b5..616e28a 100644 --- a/findfunc/matcher_ida.py +++ b/findfunc/matcher_ida.py @@ -1,4 +1,5 @@ import copy +import time from typing import Iterable from findfunc.backbone import * @@ -266,6 +267,44 @@ def __init__(self): self.info = Config() self.idastrings = None self.wascancelled = False + self._progress_last_t = 0.0 + self._progress_last_msg = "" + + def _progress(self, stage: str, current: int | None = None, total: int | None = None, extra: str | None = None, + force: bool = False): + """Best-effort progress reporting in IDA's wait box. + + Uses throttling to avoid slowing down matching. + Safe when running outside IDA. + """ + if not inida: + return + if not hasattr(idaapi, "replace_wait_box"): + return + msg = f"FindFunc: {stage}" + if current is not None: + if total is not None and total > 0: + msg += f" ({current}/{total})" + else: + msg += f" ({current}/?)" + if extra: + msg += f"\n{extra}" + + now = time.perf_counter() + if not force: + # Throttle UI updates; too frequent replace_wait_box calls slow down matching. + if msg == self._progress_last_msg: + return + if (now - self._progress_last_t) < 0.2: + return + + try: + idaapi.replace_wait_box(msg) + self._progress_last_t = now + self._progress_last_msg = msg + except Exception: + # Never let progress reporting break matching. + return @staticmethod def _bin_search_compat(start_ea, end_ea, pattern, flags): @@ -351,9 +390,13 @@ def match_initial_pos_fsize(rules: List[RuleFuncSize]): # generated initial matches. # To keep memoryp ressure low, ideally this all works as a geneartor-pipeline - @staticmethod - def refine_match_string(funcs: Iterable[Func], rules: List[RuleStrRef]): + def refine_match_string(self, funcs: Iterable[Func], rules: List[RuleStrRef]): + checked = 0 + kept = 0 for func in funcs: + checked += 1 + if (checked % 50) == 0: + self._progress("Refining string refs", checked, extra=f"kept {kept}") for r in rules: for ref in r.refs: isinfunc = func.contains_adr(ref) @@ -368,21 +411,31 @@ def refine_match_string(funcs: Iterable[Func], rules: List[RuleStrRef]): func = None break if func: + kept += 1 yield func - @staticmethod - def refine_match_fsize(funcs: Iterable[Func], rules: List[RuleFuncSize]): + def refine_match_fsize(self, funcs: Iterable[Func], rules: List[RuleFuncSize]): + checked = 0 + kept = 0 for fnc in funcs: + checked += 1 + if (checked % 100) == 0: + self._progress("Refining function size", checked, extra=f"kept {kept}") for rule in rules: isinfunc = rule.checksize(fnc.size) if isinfunc == rule.inverted: break # one rule mismatch is enough else: + kept += 1 yield fnc - @staticmethod - def refine_match_name(funcs: Iterable[Func], rules: List[RuleNameRef]): + def refine_match_name(self, funcs: Iterable[Func], rules: List[RuleNameRef]): + checked = 0 + kept = 0 for func in funcs: + checked += 1 + if (checked % 50) == 0: + self._progress("Refining name refs", checked, extra=f"kept {kept}") for r in rules: for ref in r.refs: isinfunc = func.contains_adr(ref) @@ -397,12 +450,18 @@ def refine_match_name(funcs: Iterable[Func], rules: List[RuleNameRef]): func = None break if func: + kept += 1 yield func def refine_match_bytes(self, funcs: Iterable[Func], rules: List[RuleBytePattern]): + checked = 0 + kept = 0 for func in funcs: + checked += 1 if self.iscancelled(): return + if (checked % 25) == 0: + self._progress("Scanning byte patterns", checked, extra=f"kept {kept}") for r in rules: for chunk in func.get_as_chunks(): hit = self._bin_search_compat(chunk[0], chunk[1], r.patterncompiled, idaapi.BIN_SEARCH_FORWARD) @@ -418,6 +477,7 @@ def refine_match_bytes(self, funcs: Iterable[Func], rules: List[RuleBytePattern] func = None break if func: + kept += 1 yield func # CodeRule matching is the most complicated matching. @@ -627,9 +687,14 @@ def refine_match_code_and_imm(self, funcs: Iterable[Func], rcode: List[RuleCode] :param rimm: imm rules :return: yield functions satisfying all rules """ + checked = 0 + kept = 0 for func in funcs: + checked += 1 if self.iscancelled(): return + if (checked % 10) == 0: + self._progress("Scanning code/immediates", checked, extra=f"kept {kept}") disasm = list(func.disasm()) for r in rimm: for ins in disasm: @@ -648,6 +713,7 @@ def refine_match_code_and_imm(self, funcs: Iterable[Func], rcode: List[RuleCode] continue if not rcode: + kept += 1 yield func # no code rules and passed imm check continue if self.info.debug: @@ -672,6 +738,7 @@ def refine_match_code_and_imm(self, funcs: Iterable[Func], rcode: List[RuleCode] if r.is_satisfied() == r.inverted: break # one mismatching rule is enough else: + kept += 1 yield func # reset rules for r in rcode: @@ -707,6 +774,9 @@ def do_match(self, rules: List[Rule], limitto: List[int] = None) -> List[Func]: # rc.set_data("mov cl, [eax+ebx*4+9]") # print(rc.instr[0]) self.wascancelled = False + self._progress_last_t = 0.0 + self._progress_last_msg = "" + self._progress("Starting", force=True) activerules = copy.deepcopy([x for x in rules if x.enabled]) if not activerules: return [] @@ -731,18 +801,28 @@ def do_match(self, rules: List[Rule], limitto: List[int] = None) -> List[Func]: # cheap if pos_str_rules or neg_str_rules: + self._progress("Preprocessing strings", force=True) if not self.idastrings: self.idastrings = idautils.Strings(False) self.idastrings.setup(self.info.strtypes) # print(f"{hex(i.ea)}: len {i.length}, type {i.strtype}, {str(i)}") + s_checked = 0 for string in self.idastrings: + s_checked += 1 + if (s_checked % 500) == 0: + self._progress("Preprocessing strings", s_checked) strval = str(string) for rule in pos_str_rules + neg_str_rules: if rule.matches(strval): rule.refs += list(idautils.DataRefsTo(string.ea)) # almost free if pos_name_rules or neg_name_rules: + self._progress("Preprocessing names", force=True) + n_checked = 0 for name in idautils.Names(): + n_checked += 1 + if (n_checked % 2000) == 0: + self._progress("Preprocessing names", n_checked) va, n = name if not n: if self.info.debug: @@ -753,6 +833,8 @@ def do_match(self, rules: List[Rule], limitto: List[int] = None) -> List[Func]: rule.refs += list(idautils.CodeRefsTo(va, False)) rule.refs += list(idautils.DataRefsTo(va)) # free + if pos_byte_rules or neg_byte_rules: + self._progress("Compiling byte patterns", force=True) for rule in pos_byte_rules + neg_byte_rules: rule.patterncompiled = ida_bytes.compiled_binpat_vec_t() ida_bytes.parse_binpat_str(rule.patterncompiled, self.info.startva, rule.pattern, 16) @@ -764,20 +846,26 @@ def do_match(self, rules: List[Rule], limitto: List[int] = None) -> List[Func]: # pick a positive rule to cut down initial matches as drastically as possible if limitto: + self._progress("Using refine candidate set", len(limitto), force=True) candidatas = limitto elif pos_name_rules: + self._progress("Initial: name refs", force=True) candidatas = self.match_initial_pos_names([pos_name_rules[0]]) pos_name_rules = pos_name_rules[1:] elif pos_fsize_rules: + self._progress("Initial: function size", force=True) candidatas = self.match_initial_pos_fsize([pos_fsize_rules[0]]) pos_fsize_rules = pos_fsize_rules[1:] elif pos_str_rules: + self._progress("Initial: string refs", force=True) candidatas = self.match_initial_pos_strings([pos_str_rules[0]]) pos_str_rules = pos_str_rules[1:] elif pos_byte_rules: + self._progress("Initial: byte pattern", force=True) candidatas = self.match_initial_pos_bytes([pos_byte_rules[0]]) pos_byte_rules = pos_byte_rules[1:] elif pos_imm_rules: + self._progress("Initial: immediate", force=True) candidatas = self.match_initial_pos_imm([pos_imm_rules[0]]) pos_imm_rules = pos_imm_rules[1:] else: @@ -788,6 +876,8 @@ def do_match(self, rules: List[Rule], limitto: List[int] = None) -> List[Func]: "This can be slow. To speed up the process add positiv name > string > function size > bytes > immediate constraints.") candidatas = idautils.Functions() + self._progress("Mapping candidates to functions", force=True) + # refinement # refine against all positive and negative functions @@ -803,6 +893,7 @@ def do_match(self, rules: List[Rule], limitto: List[int] = None) -> List[Func]: if coderules or pos_imm_rules or neg_imm_rules: candidatas = self.refine_match_code_and_imm(candidatas, coderules, pos_imm_rules + neg_imm_rules) + self._progress("Finalizing results", force=True) candidatas = list(candidatas) for c in candidatas: