diff options
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | docs/RPC.md | 28 | ||||
| -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 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 88 | ||||
| -rw-r--r-- | tests/test_search.py | 101 |
10 files changed, 728 insertions, 4 deletions
@@ -100,6 +100,13 @@ a 400 MB binary scrolls like a text file. **Decompiler** — Hex-Rays pseudocode with syntax highlighting, per-line address anchors, and rename/retype/comment that write back. +**Search** (`ctrl+f`) — the whole database, two ways: **text** through the +rendered disassembly (`call cs:`, `xor eax, eax`) and **bytes** with IDA's +pattern language, wildcards included (`48 8b ?? c3`, nibbles like `8?`, quoted +literals). Which one you meant is guessed from the query — a hex-looking *word* +like `dead` stays a text search — and `hex:`/`text:` or F2 override the guess. +Enter searches, then Enter opens the hit. + **Findings export** (`ctrl+e`) — the session as a markdown writeup: your comments grouped by function, the names and prototypes you set, the types you declared. A `.i64` does not record *who* wrote a comment — IDA's own analyzer diff --git a/docs/RPC.md b/docs/RPC.md index 4e736f6..e8dc76b 100644 --- a/docs/RPC.md +++ b/docs/RPC.md @@ -102,6 +102,7 @@ predicate so the returned state is final. | `xrefs` | — | `x`: open the xref picker. | | `symbols` | `query?` | Ctrl+N palette, optionally pre-typed. | | `structs` | — | Ctrl+T struct editor. | +| `find` | `query`, `mode?=auto\|text\|bytes`, `limit?=500`, `regex?`, `case?` | search the **whole database** and return `{mode, query, truncated, hits:[{addr, head, line, func, seg}]}`. `mode=auto` (the default) guesses from the query. A byte pattern that does not parse is an error naming the bad token, never an empty result. | | `export` | `path?`, `types?=true` | write the session's findings as **markdown** and return `{path, comments, names, types, functions, bytes}`. Not typed through a prompt: the point of this verb is the file it leaves behind, so a driver gets the path back rather than a screenshot. Defaults to `<binary>.findings.md`. See *Findings export* below. | | `search` | `term`, `direction?=1` | `/` (or `?`) incremental search in the active code view. | | `select` | `index?` | in an open modal list (xrefs/symbols) choose the highlighted (or nth) item and activate it. | @@ -132,6 +133,33 @@ symbol file: each one costs a navigation (listing page + decompile) plus two prompt round-trips, i.e. tens of minutes for a few hundred symbols, where `rename_many` is one call and a few seconds. +### Database-wide search + +`find` is Ctrl+F: two searches over the whole binary, not the current view. + +* **text** matches the rendered disassembly line, whitespace-normalised — so + `call cs:` matches `call cs:getenv_ptr`. `regex=true` switches to a Python + regex. Smartcase: an all-lowercase query is case-insensitive. +* **bytes** is IDA's own pattern language via `find_bytes`: hex pairs, `?` + wildcards (whole byte *or* one nibble, `48 8? ?? 24`), and quoted literals + (`"Hello", 0`). Commas, no separators at all (`488B05C3`) and mixed spacing + all normalise to the same pattern. + +Mode is guessed unless you say otherwise, and the guess is deliberately biased: +a hex-looking word (`dead`, `add`, `cafe`) is a *text* search, because those are +words. A query whose tokens are all byte-sized but one is malformed (`48 zz c3`) +is treated as bytes and **refused by name** — answering "no match" there would +be indistinguishable from "not present". + +`head` is the item to navigate to (a byte match can land mid-instruction); +`addr` is the exact match. + +```sh +python -m idatui.drive find 'call cs:' +python -m idatui.drive find '48 8b ?? c3' +python -m idatui.drive raw find query='mov e?x' regex=true limit=20 +``` + ### Findings export `export` writes what the session **worked out** -- comments, names, prototypes 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 diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 0b004b3..6d95564 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -34,8 +34,9 @@ from _fixtures import fast_keys, staged # noqa: E402 fast_keys() # ~85ms -> ~2ms per keypress; see _fixtures.fast_keys from idatui.app import ( # noqa: E402 ConfirmScreen, DecompView, FunctionsPanel, GraphView, HexView, IdaTui, - HelpScreen, ListingView, QuitScreen, StringsPalette, StructEditor, - SymbolPalette, XrefsScreen, _HELP, _str_display, _word_occurrences, + HelpScreen, ListingView, QuitScreen, SearchPalette, StringsPalette, + StructEditor, SymbolPalette, XrefsScreen, _HELP, _str_display, + _word_occurrences, ) from idatui.errors import IDAToolError # noqa: E402 from textual.widgets import ( # noqa: E402 @@ -1093,6 +1094,89 @@ async def s_structs(c: Ctx): c.check("Esc closes the struct editor", not isinstance(app.screen, StructEditor)) +@scenario("db_search") +async def s_db_search(c: Ctx): + """Ctrl+F: search the whole database, by disassembly text or by bytes.""" + app = c.app + await c.open("main", "listing") + await c.press("ctrl+f") + opened = await c.wait(lambda: isinstance(app.screen, SearchPalette), 10) + c.check("Ctrl+F opens the search palette", opened, + f"screen={type(app.screen).__name__}") + if not opened: + return + pal = app.screen + inp = pal.query_one("#pal-input", Input) + + # -- text: a mnemonic every x86-64 function starts with ------------------ # + inp.value = "endbr64" + await c.press("enter") + await c.wait(lambda: bool(pal._hits), 30) + c.check("a text search finds instructions", len(pal._hits) > 1, + f"n={len(pal._hits)}") + c.check("and it was classified as text", + pal._searched and pal._searched[0] == "text", f"{pal._searched}") + c.check("hits carry the line they matched", + all("endbr64" in h.line for h in pal._hits[:5]), + [h.line for h in pal._hits[:3]]) + + # -- text with padding: match what is SEEN, not IDA's column spacing ----- # + inp.value = "call cs:" + await c.press("enter") + found = await c.wait(lambda: pal._searched == ("text", "call cs:"), 30) + c.check("a query spanning IDA's column padding still matches", + found and len(pal._hits) > 0, f"n={len(pal._hits)}") + + # -- bytes: the same endbr64, as a pattern ------------------------------- # + inp.value = "f3 0f 1e fa" + await c.press("enter") + await c.wait(lambda: pal._searched and pal._searched[0] == "bytes", 30) + c.check("a hex query is classified as bytes", + pal._searched and pal._searched[0] == "bytes", f"{pal._searched}") + c.check("and finds the same instruction", len(pal._hits) > 1, + f"n={len(pal._hits)}") + + # -- wildcards ----------------------------------------------------------- # + inp.value = "f3 0f ?? fa" + await c.press("enter") + await c.wait(lambda: pal._searched == ("bytes", "f3 0f ?? fa"), 30) + c.check("a wildcard byte matches", len(pal._hits) > 1, f"n={len(pal._hits)}") + + # -- a bad pattern must SAY so, not answer "no matches" ------------------ # + inp.value = "48 zz c3" + await c.press("enter") + await c.pause(0.1) + title = str(app.screen.query_one("#pal-box").border_title) + c.check("a malformed byte pattern is refused with a reason", + "not a byte" in title, f"title={title!r}") + + # -- F2 pins the mode against the guess ---------------------------------- # + inp.value = "dead" + await c.pause(0.05) + c.check("a hex-looking WORD still searches text", + pal._mode_query()[0] == "text", f"{pal._mode_query()}") + await c.press("f2") + c.check("F2 forces it to bytes", pal._mode_query()[0] == "bytes", + f"{pal._mode_query()}") + + # -- Enter on a result navigates ----------------------------------------- # + inp.value = "endbr64" + await c.press("f2") # back to text + await c.press("enter") + await c.wait(lambda: bool(pal._hits) and pal._searched + and pal._searched[0] == "text", 30) + target = pal._hits[1] if len(pal._hits) > 1 else pal._hits[0] + pal.query_one(OptionList).highlighted = 1 if len(pal._hits) > 1 else 0 + await c.press("enter") + closed = await c.wait(lambda: not isinstance(app.screen, SearchPalette), 10) + c.check("Enter on a hit closes the palette", closed, + f"screen={type(app.screen).__name__}") + landed = await c.wait( + lambda: app._cur is not None and c.lst._cursor_ea() == target.head, 30) + c.check("and lands the cursor on it", landed, + f"cursor={c.lst._cursor_ea()} want={target.head:#x}") + + @scenario("export_findings") async def s_export_findings(c: Ctx): """Ctrl+E writes a markdown report of what this session worked out. diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..6fd1a25 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Query classification for Ctrl+F: is that text, or is it bytes? + +The whole risk of a mode-guessing search is guessing wrong in the direction +that loses work: deciding a word like `dead` or `add` is a byte pattern, and +silently searching the image instead of the disassembly. These checks pin that +asymmetry down. Pure -- no IDA, no worker. +""" + +#: pure: stdlib only. +#: Read by tests/run.py (--fast skips every NEEDS_IDA file). +NEEDS_IDA = False +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from idatui.search import ( # noqa: E402 + BYTES, TEXT, classify, looks_like_bytes, normalise_pattern, + pattern_problem, probably_meant_bytes, +) + +PASS = FAIL = 0 + + +def check(name, cond, detail=""): + global PASS, FAIL + if cond: + PASS += 1 + print(f" ok {name}") + else: + FAIL += 1 + print(f" FAIL {name} {detail}") + + +def main() -> int: + # -- the asymmetry: hex-looking WORDS must stay text --------------------- # + for word in ("add", "dead", "beef", "cafe", "ff", "0", "abcdef", + "decode", "face"): + check(f"{word!r} searches text, not bytes", + classify(word)[0] == TEXT, classify(word)) + + # -- unambiguous byte patterns ------------------------------------------ # + for pat in ("48 8b ?? c3", "B8 ? ? ? ? 90", "48,8b,05", "de ad be ef", + "48 8? ?? 24", "??"): + check(f"{pat!r} searches bytes", classify(pat)[0] == BYTES, + classify(pat)) + + check("a quoted literal is a byte pattern", + classify('"Hello", 0')[0] == BYTES) + + # A TYPO in a byte pattern must stay a byte pattern, so it can be refused + # with a reason. Falling back to text answers "no match", which is + # indistinguishable from "those bytes are not in this binary". + check("a typo'd byte pattern is still a byte pattern", + classify("48 zz c3")[0] == BYTES, classify("48 zz c3")) + check("and it is refused by name", + "'zz'" in (pattern_problem("48 zz c3") or "")) + check("but a word among bytes is prose", + classify("add ff")[0] == TEXT and classify("mov rdi, rax")[0] == TEXT, + classify("add ff")) + check("prose stays text", classify("mov rdi, rax")[0] == TEXT) + check("a call target stays text", classify("call cs:__isoc99_scanf")[0] == TEXT) + check("an empty query is text (nothing to search yet)", + classify("")[0] == TEXT and not looks_like_bytes("")) + + # -- explicit wins over any guess --------------------------------------- # + check("hex: forces bytes", classify("hex: dead") == (BYTES, "dead")) + check("bytes: forces bytes too", classify("bytes:dead") == (BYTES, "dead")) + check("text: forces text", classify("text: 48 8b c3") == (TEXT, "48 8b c3")) + check("F2's forced mode beats the shape", + classify("dead", forced=BYTES) == (BYTES, "dead") + and classify("48 8b c3", forced=TEXT) == (TEXT, "48 8b c3")) + check("a prefix beats even the forced mode", + classify("text:48 8b c3", forced=BYTES)[0] == TEXT) + + # -- the shapes people paste -------------------------------------------- # + check("commas become spaces", normalise_pattern("48,8b,05") == "48 8b 05") + check("a run with no separators is split into bytes", + normalise_pattern("488B05C3") == "48 8B 05 C3") + check("whitespace is squeezed", normalise_pattern(" 48 8b\t05 ") == "48 8b 05") + check("a quoted literal keeps its own spacing", + normalise_pattern('"Hello, world", 0') == '"Hello, world", 0') + + # -- refusing a bad pattern with a reason -------------------------------- # + check("an empty pattern says what to type", + "48 8b" in (pattern_problem("") or "")) + check("a non-hex token is named", + "'zz'" in (pattern_problem("48 zz c3") or ""), pattern_problem("48 zz c3")) + check("a good pattern has no complaint", + pattern_problem("48 8b ?? c3") is None + and pattern_problem('"Hi", 0') is None) + check("an odd-length run is refused rather than silently split", + pattern_problem("488B0") is not None, pattern_problem("488B0")) + + print(f"\n{PASS} passed, {FAIL} failed") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) |
