From 41b3710d5b6f9be24430fd55c46c83f9ae3b8d83 Mon Sep 17 00:00:00 2001 From: blasty Date: Fri, 7 Aug 2026 23:16:30 +0200 Subject: Ctrl+F: search the whole database, by text or by bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/` 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. --- idatui/codemode_client.py | 87 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) (limited to 'idatui/codemode_client.py') 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 @@ -601,6 +601,93 @@ payload = (a.get("data") or "").encode("utf-8") 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} -- cgit v1.3.1-sl0p