aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/codemode_client.py
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-08-07 23:16:30 +0200
committerblasty <blasty@local>2026-08-07 23:16:30 +0200
commit41b3710d5b6f9be24430fd55c46c83f9ae3b8d83 (patch)
treeed3517997a3248310c904c3a57ea053257162ac2 /idatui/codemode_client.py
parentExport findings as markdown (Ctrl+E), and the journal that makes it true (diff)
downloadida-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/codemode_client.py')
-rw-r--r--idatui/codemode_client.py87
1 files changed, 87 insertions, 0 deletions
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]