diff options
Diffstat (limited to 'idatui')
| -rw-r--r-- | idatui/app.py | 211 | ||||
| -rw-r--r-- | idatui/codemode_client.py | 87 | ||||
| -rw-r--r-- | idatui/domain.py | 56 | ||||
| -rw-r--r-- | idatui/drive.py | 16 | ||||
| -rw-r--r-- | idatui/rpc.py | 26 | ||||
| -rw-r--r-- | idatui/search.py | 112 |
6 files changed, 506 insertions, 2 deletions
diff --git a/idatui/app.py b/idatui/app.py index 4a603f3..9082d99 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -50,7 +50,7 @@ from . import kittygfx from .edit_ctl import EditController from .prompt import PromptBar from .trace_ctl import TraceController -from . import findings +from . import findings, search from .highlight import CTextArea, highlight_c from .journal import Journal @@ -3293,6 +3293,182 @@ def _str_display(text: str, limit: int = 200) -> str: return out[:limit] + ("\u2026" if len(out) > limit else "") +class SearchPalette(ModalScreen): + """Ctrl+F: search the whole database, by text or by bytes. + + Unlike every other palette here this does NOT filter as you type: each + search walks the image or the listing in the database process, so it runs + on Enter. That gives Enter two jobs, which is fine as long as it is never + ambiguous: while the query differs from what was last searched, Enter + searches; once the results on screen belong to the query in the box, Enter + opens the highlighted one. The title says which it will do. + + Mode is guessed from the query (idatui/search.py) because asking first is a + tax on every search; F2 overrides the guess and a `hex:`/`text:` prefix + settles it outright. + """ + + BINDINGS = [ + Binding("escape", "close", "Close"), + Binding("down,ctrl+n", "cursor_down", show=False), + Binding("up,ctrl+p", "cursor_up", show=False), + Binding("f2", "mode", "Text / bytes", show=False), + ] + LIMIT = 500 + + def __init__(self, program, initial: str = "") -> None: + super().__init__() + self._program = program + self._initial = initial + self._forced: str | None = None # F2: pin the mode + self._hits: list = [] + self._searched: tuple[str, str] | None = None # (mode, query) on screen + self._busy = False + + def compose(self) -> ComposeResult: + with Vertical(id="pal-box") as box: + box.border_title = Text("search") + yield Input(placeholder="text, or bytes like 48 8b ?? c3 \u00b7 " + "Enter search \u00b7 F2 mode \u00b7 Esc close", + id="pal-input") + yield OptionList(id="pal-list") + + def on_mount(self) -> None: + inp = self.query_one("#pal-input", Input) + inp.value = self._initial + inp.focus() + self._retitle() + if self._initial: + self._run() + + # -- mode / title ------------------------------------------------------- # + def _query(self) -> str: + return self.query_one("#pal-input", Input).value.strip() + + def _mode_query(self) -> tuple[str, str]: + return search.classify(self._query(), self._forced) + + def _retitle(self, note: str = "") -> None: + mode, q = self._mode_query() + pinned = "" if self._forced is None else "*" + state = note + if not state: + if self._busy: + state = "searching\u2026" + elif self._searched == (mode, q) and q: + n = len(self._hits) + state = f"{n} hit{'' if n == 1 else 's'} \u2014 Enter opens" + elif q: + state = "Enter searches" + self.query_one("#pal-box").border_title = Text( + f"search [{mode}{pinned}]" + (f": {state}" if state else "")) + + def action_mode(self) -> None: + mode, _ = self._mode_query() + self._forced = search.TEXT if mode == search.BYTES else search.BYTES + self._searched = None # the results on screen are for the old mode + self._retitle() + + def on_input_changed(self, event: Input.Changed) -> None: + event.stop() # modal inputs bubble to the app's own #search handler + self._retitle() + + def on_input_submitted(self, event: Input.Submitted) -> None: + event.stop() + mode, q = self._mode_query() + if self._searched == (mode, q) and self._hits: + self.action_choose() + else: + self._run() + + # -- searching ---------------------------------------------------------- # + def _run(self) -> None: + mode, q = self._mode_query() + if not q: + return + if mode == search.BYTES: + problem = search.pattern_problem(q) + if problem: + # Refuse here rather than round-tripping: IDA's own message for + # a bad pattern is empty about half the time. + self._hits, self._searched = [], None + self.query_one(OptionList).clear_options() + self._retitle(problem) + return + q = search.normalise_pattern(q) + self._busy = True + self._retitle() + self._search(mode, q) + + @work(thread=True, exclusive=True, group="dbsearch") + def _search(self, mode: str, query: str) -> None: + try: + hits, err, truncated = self._program.search(query, mode, + limit=self.LIMIT) + except Exception as e: # noqa: BLE001 -- a search must not kill the app + hits, err, truncated = [], str(e), False + self.app.call_from_thread(self._present, mode, query, hits, err, truncated) + + def _present(self, mode: str, query: str, hits: list, err: str | None, + truncated: bool) -> None: + self._busy = False + self._hits = hits + # Remember what these results ARE, not what the box says now: the user + # may have typed on while the search ran, and then Enter must search + # again rather than open a hit from the previous query. + self._searched = (mode, query) if err is None else None + ol = self.query_one(OptionList) + ol.clear_options() + opts = [] + for h in hits: + label = Text() + label.append(f"{h.addr:08X} ", _S_ADDR) + label.append(f"{(h.func or h.seg or ''):<22.22} ", _S_DIM) + body = Text(h.line or "") + if mode == search.TEXT and query: + low, ql = (h.line or "").lower(), query.lower() + at = low.find(ql) + if at >= 0: + body.stylize(_S_NAME_MATCH, at, at + len(query)) + label.append_text(body) + opts.append(Option(label)) + ol.add_options(opts) + if hits: + ol.highlighted = 0 + if err: + self._retitle(err) + elif not hits: + self._retitle("no match") + else: + n = len(hits) + self._retitle(f"{n}{'+' if truncated else ''} " + f"hit{'' if n == 1 else 's'} \u2014 Enter opens") + + # -- moving / choosing --------------------------------------------------- # + def action_cursor_down(self) -> None: + ol = self.query_one(OptionList) + if ol.option_count: + ol.highlighted = min((ol.highlighted or 0) + 1, ol.option_count - 1) + + def action_cursor_up(self) -> None: + ol = self.query_one(OptionList) + if ol.option_count: + ol.highlighted = max((ol.highlighted or 0) - 1, 0) + + def action_choose(self) -> None: + ol = self.query_one(OptionList) + i = ol.highlighted + if i is not None and 0 <= i < len(self._hits): + self.dismiss(self._hits[i]) + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + if 0 <= event.option_index < len(self._hits): + self.dismiss(self._hits[event.option_index]) + + def action_close(self) -> None: + self.dismiss(None) + + class StringsPalette(ModalScreen): """Every string in the binary (IDA's Shift+F12), filterable; Enter jumps to it in the unified listing.""" @@ -3446,6 +3622,7 @@ _HELP = ( ("B", "cycle the opcode-bytes column"), ("Ctrl+B", "show/hide the names pane"), ("Ctrl+T", "structs / types editor"), + ("Ctrl+F", "search the database: text or bytes"), ("Ctrl+E", "export findings as markdown"), ("Ctrl+P", "command palette"), )), @@ -4670,6 +4847,8 @@ class IdaCommands(Provider): app.action_strings), ("Switch binary…", "another binary in the project (Ctrl+O)", app.action_switch_binary), + ("Search database…", "disassembly text or a byte pattern with " + "wildcards (Ctrl+F)", app.action_find), ("Export findings…", "your comments, names and types as markdown " "(Ctrl+E)", app.action_export), ("Keyboard shortcuts", "the key cheatsheet (F1 or H)", @@ -4894,6 +5073,7 @@ class IdaTui(App): Binding("q", "quit", "Quit"), Binding("ctrl+n", "symbols", "Symbols"), Binding("ctrl+t", "structs", "Structs"), + Binding("ctrl+f", "find", "Find"), Binding("ctrl+e", "export", "Export", show=False), Binding("backslash", "hex", "Hex"), Binding("s", "toggle_split", "Split", show=False), @@ -6009,6 +6189,35 @@ class IdaTui(App): binary=self._binary), self._on_string_chosen) + def action_find(self) -> None: + """Ctrl+F: search the whole database — disassembly text, or bytes.""" + if self.program is None: + self._status("not connected yet") + return + if self._prompt_active(): + return + # Seed it with the word under the cursor: the search you want is + # usually about the thing you are looking at. + seed = "" + view = self._active_code_view() + if view is not None: + try: + seed = view.word_under_cursor() or "" + except Exception: # noqa: BLE001 -- a seed is a nicety, never a + seed = "" # reason not to open the search + self.push_screen(SearchPalette(self.program, seed), self._on_hit_chosen) + + def _on_hit_chosen(self, hit) -> None: # type: ignore[no-untyped-def] + if hit is None: + return + # Navigate to the ITEM, not the matched byte: a pattern can start in + # the middle of an instruction, and there is nothing to put a cursor on + # there. The status names the exact address so it isn't lost. + self._goto_ea(hit.head, push=True) + if hit.addr != hit.head: + self._status(f"match at {hit.addr:#x} (inside {hit.head:#x})", + priority=True) + def _on_string_chosen(self, choice) -> None: # type: ignore[no-untyped-def] if choice is None: return diff --git a/idatui/codemode_client.py b/idatui/codemode_client.py index 4c3ec99..1ac995e 100644 --- a/idatui/codemode_client.py +++ b/idatui/codemode_client.py @@ -602,6 +602,93 @@ n.setblob(payload, 0, "I") result = {"ok": True, "bytes": len(payload)} result ''', + # Database-wide search (Ctrl+F), two kinds. + # + # BYTES uses IDA's own `find_bytes`, which already understands the pattern + # language people expect -- "B8 ? ? ? ? 90", nibble wildcards ("48 8? ??") + # and quoted literals -- so we neither parse nor match anything ourselves. + # Iterating is match+1, per its documented contract. + "search_bytes": r''' +import ida_bytes, ida_funcs, ida_idaapi, ida_lines, ida_segment +pat = str(a.get("pattern", "")).strip() +limit = max(1, int(a.get("limit", 500))) +lo = int(a.get("start", 0)) +hi = int(a.get("end", 0)) or ida_idaapi.BADADDR +flags = ida_bytes.BIN_SEARCH_FORWARD | ida_bytes.BIN_SEARCH_NOSHOW +if a.get("case"): + flags |= ida_bytes.BIN_SEARCH_CASE +rows, err, ea = [], None, lo +while len(rows) < limit: + try: + hit = ida_bytes.find_bytes(pat, range_start=ea, range_end=hi, flags=flags) + except Exception as exc: + err = str(exc) or exc.__class__.__name__ + break + if hit is None or hit == ida_idaapi.BADADDR: + break + head = ida_bytes.get_item_head(hit) + fn = ida_funcs.get_func(hit) + seg = ida_segment.getseg(hit) + try: + line = ida_lines.generate_disasm_line(head, ida_lines.GENDSM_REMOVE_TAGS) or "" + except Exception: + line = "" + rows.append({"addr": hex(int(hit)), "head": hex(int(head)), + "line": " ".join(line.split()), + "func": (ida_funcs.get_func_name(fn.start_ea) if fn else None), + "func_addr": (hex(int(fn.start_ea)) if fn else None), + "seg": (ida_segment.get_segm_name(seg) if seg else "")}) + ea = int(hit) + 1 +result = {"hits": rows, "error": err, "truncated": len(rows) >= limit} +result +''', + # TEXT walks the listing the way a person reads it: every head's rendered + # disassembly line, which is why it finds "call cs:__isoc99_scanf" and + # "0deadbeefh" alike. Bounded by max_scan, so a 400MB image reports partial + # results instead of stalling. + "search_text": r''' +import ida_lines, ida_funcs, ida_segment, idautils +import re as _re +q = str(a.get("query", "")) +limit = max(1, int(a.get("limit", 500))) +max_scan = max(1000, int(a.get("max_scan", 3000000))) +ci = (not a.get("case")) and q.islower() # smartcase, like the in-view search +rx, err = None, None +if a.get("regex"): + try: + rx = _re.compile(q, _re.I if ci else 0) + except Exception as exc: + err = "bad regex: " + str(exc) +needle = q.lower() if ci else q +rows, scanned = [], 0 +if err is None and q: + for i in range(ida_segment.get_segm_qty()): + seg = ida_segment.getnseg(i) + if seg is None or len(rows) >= limit or scanned >= max_scan: + continue + for ea in idautils.Heads(seg.start_ea, seg.end_ea): + scanned += 1 + if len(rows) >= limit or scanned >= max_scan: + break + try: + line = ida_lines.generate_disasm_line(ea, ida_lines.GENDSM_REMOVE_TAGS) or "" + except Exception: + continue + # Match what the user SEES, not IDA's column padding: nobody types + # "call" + four spaces + "cs:getenv_ptr". + line = " ".join(line.split()) + hay = line.lower() if ci else line + if (rx.search(line) if rx is not None else (needle in hay)): + fn = ida_funcs.get_func(ea) + rows.append({"addr": hex(int(ea)), "head": hex(int(ea)), + "line": line, + "func": (ida_funcs.get_func_name(fn.start_ea) if fn else None), + "func_addr": (hex(int(fn.start_ea)) if fn else None), + "seg": ida_segment.get_segm_name(seg)}) +result = {"hits": rows, "error": err, "scanned": scanned, + "truncated": len(rows) >= limit or scanned >= max_scan} +result +''', "list_linkage": r''' imports = [{"addr": hex(int(item.address)), "name": item.name, "module": item.module_name} for item in db.imports.get_all_imports() if item.name] diff --git a/idatui/domain.py b/idatui/domain.py index 15f4ef4..d65c473 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -252,6 +252,22 @@ def link_name(raw: str) -> str: @dataclass(frozen=True) +class SearchHit: + """One database-wide search result (Ctrl+F). + + ``addr`` is where the match starts -- for a byte pattern that can be inside + an instruction, so ``head`` is the item to navigate to and ``line`` is what + that item renders as. + """ + addr: int + head: int + line: str = "" + func: str | None = None + func_addr: int | None = None + seg: str = "" + + +@dataclass(frozen=True) class Comment: """One comment somebody wrote into the database. @@ -1964,6 +1980,46 @@ class Program: return (comments, names) + def search(self, query: str, mode: str = "text", *, limit: int = 500, + regex: bool = False, case: bool = False, + ) -> tuple[list["SearchHit"], str | None, bool]: + """Search the whole database. Returns ``(hits, error, truncated)``. + + A failed search is DATA (a message to show), not an exception: a bad + regex or an unparsable byte pattern is something the user typed, and + the palette wants to say so without unwinding. + """ + op = "search_bytes" if mode == "bytes" else "search_text" + args: dict = {"limit": int(limit), "case": bool(case)} + if mode == "bytes": + # Validate HERE, not just in the UI: IDA's find_bytes answers a + # malformed pattern with zero hits and no error, which reads as + # "not present" -- the most misleading answer a search can give. + from .search import normalise_pattern, pattern_problem + problem = pattern_problem(query) + if problem: + return ([], problem, False) + args["pattern"] = normalise_pattern(query) + else: + args["query"] = query + args["regex"] = bool(regex) + try: + payload = self.client.invoke(op, **args) + except IDAToolError as e: + return ([], str(e), False) + if not isinstance(payload, dict): + return ([], "the backend returned nothing searchable", False) + hits = [ + SearchHit(addr=_as_int(r.get("addr", 0)), + head=_as_int(r.get("head", r.get("addr", 0))), + line=str(r.get("line", "") or ""), + func=(r.get("func") or None), + func_addr=(_as_int(r["func_addr"]) if r.get("func_addr") + else None), + seg=str(r.get("seg", "") or "")) + for r in payload.get("hits", []) if isinstance(r, dict)] + return (hits, payload.get("error") or None, bool(payload.get("truncated"))) + def journal_get(self) -> str: """The findings journal blob stored in this database ('' if none).""" payload = self.client.invoke("journal_get") diff --git a/idatui/drive.py b/idatui/drive.py index 6ba13c6..6e5c21a 100644 --- a/idatui/drive.py +++ b/idatui/drive.py @@ -296,6 +296,21 @@ def cmd_save(c, args): return " saved" +def cmd_find(c, args): + """find <query...> -- search the database; bytes if it looks like bytes.""" + if not args: + raise SystemExit("usage: find <text | 48 8b ?? c3 | hex:...>") + r = c.call("find", query=" ".join(args)) + hits = r.get("hits", []) + out = [f" [{r.get('mode')}] {len(hits)}{'+' if r.get('truncated') else ''} hits"] + for h in hits[:40]: + out.append(f" {h['addr']} {(h.get('func') or h.get('seg') or ''):<20.20} " + f"{h.get('line', '')}") + if len(hits) > 40: + out.append(f" … {len(hits) - 40} more") + return "\n".join(out) + + def cmd_export(c, args): """export [path] -- write the session's findings as markdown.""" r = c.call("export", **({"path": args[0]} if args else {})) @@ -324,6 +339,7 @@ COMMANDS = { "rename": cmd_rename, "mv": cmd_mv, "note": cmd_note, "retype": cmd_retype, "save": cmd_save, "screen": cmd_screen, "raw": cmd_raw, "define": cmd_define, "syms": cmd_syms, "fmt": cmd_fmt, "export": cmd_export, + "find": cmd_find, "binaries": cmd_binaries, "switch": cmd_switch, } diff --git a/idatui/rpc.py b/idatui/rpc.py index 98ff90f..1262ebb 100644 --- a/idatui/rpc.py +++ b/idatui/rpc.py @@ -39,7 +39,7 @@ _PROGRAM_METHODS = { "goto", "open", "rename", "comment", "retype", "follow", "xrefs", "symbols", "structs", "search", "select", "save", "hex", "toggle_view", "pseudocode", "disassembly", "xrefs_to", "xrefs_from", "resolve", - "define", "rename_many", "opfmt", "graph", "export", + "define", "rename_many", "opfmt", "graph", "export", "find", } # Self-documenting method table (returned by the 'methods' verb). @@ -77,6 +77,9 @@ METHODS = { "structs": "open the struct editor", "export": "{path?,types?=true} write the session's comments/names/types as " "a markdown report -> {path,comments,names,types}", + "find": "{query,mode?=auto|text|bytes,limit?=500,regex?,case?} search the " + "WHOLE database: disassembly text, or a byte pattern with " + "wildcards (48 8b ?? c3) -> {mode,hits:[{addr,head,line,func}]}", "search": "{term,direction?=1} incremental search in the code view", "select": "{index?} choose the highlighted/nth item in the open modal", "save": "persist the .i64 (Ctrl+S)", @@ -1175,6 +1178,27 @@ class RpcServer: return await self._press( ["ctrl+t"], lambda: type(app.screen).__name__ == "StructEditor", timeout, "structs") + if method == "find": + from . import search as _search + q = str(params.get("query", "")) + forced = params.get("mode") + forced = None if forced in (None, "auto") else str(forced) + mode, cleaned = _search.classify(q, forced) + if mode == _search.BYTES: + problem = _search.pattern_problem(cleaned) + if problem: + raise ValueError(f"find: {problem}") + cleaned = _search.normalise_pattern(cleaned) + hits, err, truncated = await asyncio.to_thread( + app.program.search, cleaned, mode, + limit=int(params.get("limit", 500)), + regex=bool(params.get("regex")), case=bool(params.get("case"))) + if err: + raise ValueError(f"find: {err}") + return {"mode": mode, "query": cleaned, "truncated": truncated, + "hits": [{"addr": hex(h.addr), "head": hex(h.head), + "line": h.line, "func": h.func, + "seg": h.seg} for h in hits]} if method == "export": # Deliberately NOT driven through the prompt: this is the one verb # whose whole point is the file it leaves behind, and a driver needs diff --git a/idatui/search.py b/idatui/search.py new file mode 100644 index 0000000..feb7e3d --- /dev/null +++ b/idatui/search.py @@ -0,0 +1,112 @@ +"""What did the user mean by that query? (Ctrl+F.) + +Database-wide search comes in two kinds -- **text** through the disassembly and +**bytes** through the image -- and asking people to pick a mode before they type +is a tax on every search. So the query decides, and the rule has to be +conservative in one specific direction: a hex-looking word is often real text +(``add``, ``dec``, ``dead``, ``beef``, ``cafe`` are all valid hex AND things you +would search for), while nobody types ``48 8b ?? c3`` meaning prose. + +Hence: bytes only when the query is unambiguously a byte pattern -- several +whitespace/comma separated tokens that are all hex pairs or wildcards, or any +query containing a ``?``. Everything else is text, and the two explicit prefixes +(``hex:`` / ``text:``) settle any argument, as does F2 in the palette. + +Pure: no IDA, no Textual, so ``tests/test_search.py`` runs it offline. +""" + +from __future__ import annotations + +import re + +TEXT = "text" +BYTES = "bytes" + +#: One token of a byte pattern: a hex pair, a wildcard nibble ("8?"), or a bare +#: "?" standing for a whole byte. IDA's find_bytes accepts all three. +_TOKEN = re.compile(r"^(?:[0-9A-Fa-f?]{2}|\?)$") + +#: A quoted literal inside a pattern ('"Hello", 0'), which IDA also accepts. +_QUOTED = re.compile(r'"[^"]*"') + + +def looks_like_bytes(query: str) -> bool: + """True when ``query`` can only sensibly be a byte pattern.""" + q = (query or "").strip() + if not q: + return False + if _QUOTED.search(q): + return True + tokens = [t for t in re.split(r"[\s,]+", q) if t] + if not all(_TOKEN.match(t) for t in tokens): + return False + # A single token is ambiguous ("ff" is also a word); a wildcard never is. + return len(tokens) > 1 or "?" in q + + +def probably_meant_bytes(query: str) -> bool: + """True for a query that is *shaped* like bytes but does not parse. + + ``48 zz c3`` is a typo in a byte pattern, and treating it as a text search + answers "no match" -- the most misleading thing a search can say, because it + is indistinguishable from "those bytes are not in this binary". Every token + being byte-sized is the tell; ``add ff`` (a three-letter token) is not, and + stays text. + """ + tokens = [t for t in re.split(r"[\s,]+", (query or "").strip()) if t] + if len(tokens) < 2 or any(len(t) > 2 for t in tokens): + return False + return any(_TOKEN.match(t) for t in tokens) + + +def classify(query: str, forced: str | None = None) -> tuple[str, str]: + """Return ``(mode, cleaned_query)``. + + An explicit ``hex:``/``bytes:``/``text:`` prefix wins, then ``forced`` (the + palette's F2), then the shape of the query. + """ + q = (query or "").strip() + low = q.lower() + for prefix, mode in (("hex:", BYTES), ("bytes:", BYTES), ("text:", TEXT)): + if low.startswith(prefix): + return (mode, q[len(prefix):].strip()) + if forced in (TEXT, BYTES): + return (forced, q) + if looks_like_bytes(q) or probably_meant_bytes(q): + return (BYTES, q) + return (TEXT, q) + + +def normalise_pattern(pattern: str) -> str: + """Tidy a byte pattern for IDA: single spaces, commas as separators. + + ``48 8B?? C3``, ``48,8b,??,c3`` and ``48 8b ?? c3`` are the same search; + people paste all three (the middle one out of a signature file). + """ + q = (pattern or "").strip() + if _QUOTED.search(q): + return q # a quoted literal owns its own spacing + q = q.replace(",", " ") + # "488B??C3" -- a bare hex run with no separators at all. + if " " not in q and len(q) > 2 and len(q) % 2 == 0: + q = " ".join(q[i:i + 2] for i in range(0, len(q), 2)) + return " ".join(q.split()) + + +def pattern_problem(pattern: str) -> str | None: + """A human explanation if this cannot be a byte pattern, else ``None``. + + Checked before the round trip, because IDA's own message for a bad pattern + is empty about half the time. + """ + q = normalise_pattern(pattern) + if not q: + return "type some bytes, e.g. 48 8b ?? c3" + if _QUOTED.search(q): + return None + tokens = [t for t in q.split() if t] + bad = [t for t in tokens if not _TOKEN.match(t)] + if bad: + return (f"{bad[0]!r} is not a byte: use hex pairs, ? wildcards " + 'or a "quoted string"') + return None |
