diff options
| author | blasty <blasty@local> | 2026-08-07 23:16:30 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-08-07 23:16:30 +0200 |
| commit | 41b3710d5b6f9be24430fd55c46c83f9ae3b8d83 (patch) | |
| tree | ed3517997a3248310c904c3a57ea053257162ac2 /idatui/domain.py | |
| parent | Export findings as markdown (Ctrl+E), and the journal that makes it true (diff) | |
| download | ida-tui-41b3710d5b6f9be24430fd55c46c83f9ae3b8d83.tar.gz ida-tui-41b3710d5b6f9be24430fd55c46c83f9ae3b8d83.tar.xz ida-tui-41b3710d5b6f9be24430fd55c46c83f9ae3b8d83.zip | |
Ctrl+F: search the whole database, by text or by bytes
`/` only ever searched the lines of the view you were in. This adds the
search you actually need on a binary: over the entire database, either
through the rendered disassembly or through the image.
* **text** matches the line as displayed, whitespace-normalised, so
`call cs:` finds `call cs:getenv_ptr` (IDA's column padding is not
something anyone types). Smartcase; `regex` available over RPC.
* **bytes** is IDA's own `find_bytes`, so the pattern language people
already know works unchanged: hex pairs, `?` wildcards for a whole byte
or one nibble (`48 8? ?? 24`), quoted literals (`"Hello", 0`). Commas,
no separators (`488B05C3`) and ragged spacing all normalise.
**Which mode you meant is guessed, and the guess is biased on purpose.**
`dead`, `add`, `cafe` and `ff` are valid hex AND ordinary things to search
for, so a bare hex-looking word stays TEXT; nobody types `48 8b ?? c3`
meaning prose. `hex:`/`text:` prefixes and F2 override it.
The subtle case is a *typo* in a byte pattern. `48 zz c3` first fell
through to a text search and reported "no match" — indistinguishable from
"those bytes are not in this binary", which is the most misleading answer
a search can give. Now any query whose tokens are all byte-sized is
treated as bytes, and a bad token is refused BY NAME. IDA does the same
thing quietly (find_bytes answers a malformed pattern with zero hits and
no error), so the validation lives in Program.search, not just in the UI.
Enter searches, then Enter opens the highlighted hit; the title says which
it will do, because a database-wide scan is far too slow to run on every
keystroke like the other palettes. Navigation goes to the item head — a
byte match can start mid-instruction — and the status names the exact
address.
Also: the `find` RPC verb and `drive find`, which is the one an agent
wants (`drive find '48 8b ?? c3'`).
idatui/search.py holds the classification and is pure, so the whole
question of "what did they mean" is tested offline: tests/test_search.py,
35 checks, 0.1s. Pilot scenario db_search covers the UI end to end.
Full suite: 890 passed, 0 failed, 51.3s.
Diffstat (limited to 'idatui/domain.py')
| -rw-r--r-- | idatui/domain.py | 56 |
1 files changed, 56 insertions, 0 deletions
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") |
