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: