diff options
| -rw-r--r-- | idatui/app.py | 138 | ||||
| -rw-r--r-- | idatui/domain.py | 45 | ||||
| -rw-r--r-- | server/patch_server.py | 69 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 45 |
4 files changed, 296 insertions, 1 deletions
diff --git a/idatui/app.py b/idatui/app.py index 860c501..2bfda88 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -2079,6 +2079,107 @@ class SymbolPalette(ModalScreen): self.dismiss(None) +def _str_display(text: str, limit: int = 200) -> str: + """One-line, printable rendering of a string literal for the browser: escape + the common control chars, drop the rest, and clip long bodies.""" + out = (text.replace("\\", "\\\\").replace("\n", "\\n") + .replace("\r", "\\r").replace("\t", "\\t")) + out = "".join(ch if ch.isprintable() else "." for ch in out) + return out[:limit] + ("\u2026" if len(out) > limit else "") + + +class StringsPalette(ModalScreen): + """Every string in the binary (IDA's Shift+F12), filterable; Enter jumps to + it in the unified listing.""" + + BINDINGS = [ + Binding("escape", "close", "Close"), + Binding("down,ctrl+n", "cursor_down", show=False), + Binding("up,ctrl+p", "cursor_up", show=False), + ] + LIMIT = 500 + + def __init__(self, strings: list) -> None: + super().__init__() + # Pre-render + pre-lower once: filtering runs on every keystroke and a + # big binary has tens of thousands of strings. + self._rows = [(s, d, d.lower()) + for s in strings for d in (_str_display(s.text),)] + self._results: list = [] + + def compose(self) -> ComposeResult: + with Vertical(id="pal-box"): + yield Static(" strings", id="pal-title") + yield Input(placeholder="filter strings\u2026 \u2191\u2193 select \u00b7 " + "Enter jump \u00b7 Esc close", id="pal-input") + yield OptionList(id="pal-list") + + def on_mount(self) -> None: + self._apply("") + self.query_one("#pal-input", Input).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + event.stop() # don't leak to the app's #search/#filter handlers + self._apply(event.value.strip()) + + def on_input_submitted(self, event: Input.Submitted) -> None: + event.stop() + self.action_choose() + + def _apply(self, query: str) -> None: + q = query.lower() + rows = [] + for s, disp, low in self._rows: + hit = low.find(q) if q else -1 + if q and hit < 0: + continue + rows.append((s, disp, hit)) + if len(rows) >= self.LIMIT: + break + self._results = [s for s, _, _ in rows] + ol = self.query_one(OptionList) + ol.clear_options() + opts = [] + for s, disp, hit in rows: + label = Text() + label.append(f"{s.addr:08X} ", _S_ADDR) + label.append(f"{s.length:>5} ", _S_DIM) + body = Text(disp) + if hit >= 0: + body.stylize(_S_NAME_MATCH, hit, hit + len(q)) + label.append_text(body) + opts.append(Option(label)) + ol.add_options(opts) + if self._results: + ol.highlighted = 0 + more = "+" if len(rows) == self.LIMIT else "" + self.query_one("#pal-title", Static).update( + f" strings: {len(self._results)}{more} of {len(self._rows)}") + + 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._results): + self.dismiss(self._results[i].addr) + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + if 0 <= event.option_index < len(self._results): + self.dismiss(self._results[event.option_index].addr) + + def action_close(self) -> None: + self.dismiss(None) + + # --------------------------------------------------------------------------- # # Confirmation dialog # --------------------------------------------------------------------------- # @@ -2435,6 +2536,8 @@ class IdaCommands(Provider): ("Goto address / symbol…", "jump to an address or name (g)", app.action_goto), ("Find symbol…", "fuzzy function finder (Ctrl+N)", app.action_symbols), + ("Strings…", "browse every string in the binary (\")", + app.action_strings), ("Follow symbol under cursor", "jump to the referenced symbol (Enter)", lambda: va("follow")), ("Show xrefs to symbol", "cross-references to the cursor symbol (x)", @@ -2576,6 +2679,7 @@ class IdaTui(App): Binding("ctrl+t", "structs", "Structs"), Binding("backslash", "hex", "Hex"), Binding("s", "toggle_split", "Split", show=False), + Binding("quotation_mark,shift+f12", "strings", "Strings", show=False), Binding("g", "goto", "Goto"), Binding("slash", "filter", "Filter", show=False), Binding("ctrl+b", "toggle_functions", "Names", show=False), @@ -3028,6 +3132,40 @@ class IdaTui(App): f = self._func_index.by_addr(addr) if self._func_index else None self._open_function(addr, f.name if f else hex(addr)) + def action_strings(self) -> None: + """'\"' / Shift+F12: browse every string in the binary (filterable); + Enter jumps to it in the unified listing.""" + if self.program is None: + self._status("not connected yet") + return + if self._prompt_active(): + return + self._status("collecting strings\u2026") + self._load_strings() + + @work(thread=True, exclusive=True, group="strings") + def _load_strings(self) -> None: + assert self.program is not None + try: + items, err = self.program.strings(), None + except Exception as e: # noqa: BLE001 -- report, never kill the app + items, err = [], str(e) + self.app.call_from_thread(self._present_strings, items, err) + + def _present_strings(self, items: list, err: str | None) -> None: + if err: + self._status(f"strings failed: {err}") + return + if not items: + self._status("no strings found (needs the list_strings tool)") + return + self._status(f"strings: {len(items)}") + self.push_screen(StringsPalette(items), self._on_string_chosen) + + def _on_string_chosen(self, addr: int | None) -> None: + if addr is not None: # land on the literal in the unified listing + self._goto_ea(addr, push=True) + def action_toggle_view(self) -> None: """Tab: switch the code pane between disassembly and pseudocode (or leave the hex view back to the preferred code view).""" diff --git a/idatui/domain.py b/idatui/domain.py index 1bf62ed..33c44b6 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -167,6 +167,15 @@ class Struct: ) +@dataclass(frozen=True) +class StrLit: + """A string literal IDA found in the binary (the Shift+F12 list).""" + addr: int + text: str + length: int + type: str = "" + + @dataclass class Decompilation: ea: int @@ -808,6 +817,7 @@ class Program: self._listings: dict[int, ListingModel] = {} # keyed by segment start self._decomp: dict[int, tuple[Decompilation, int]] = {} self._decomp_maps: dict[int, tuple[list[list[int]], int]] = {} # line->ea sets + self._strings: list["StrLit"] | None = None # whole-binary string literals self._name_gen = 0 # bumped on rename; invalidates stale name caches self._segments_cache: list[tuple[int, int, int, str]] | None = None self._sections: list[tuple[int, int, str]] | None = None @@ -1246,6 +1256,41 @@ class Program: except Exception: # noqa: BLE001 -- fall back to the truncated preview return None + def strings(self, min_len: int = 4, refresh: bool = False) -> list[StrLit]: + """Every string literal in the binary (IDA's Shift+F12 list), paged in + full and cached. ``[]`` if the tool is unavailable.""" + if not refresh: + with self._lock: + hit = self._strings + if hit is not None: + return hit + out: list[StrLit] = [] + offset, page = 0, 2000 + while True: + try: + payload = self.client.call( + "list_strings", offset=offset, count=page, min_len=min_len, + refresh=(refresh and offset == 0)) + except IDAToolError: + return [] + rows = payload.get("strings", []) if isinstance(payload, dict) else [] + for r in rows: + if not isinstance(r, dict): + continue + out.append(StrLit( + addr=_as_int(r.get("addr", 0)), + text=r.get("text", ""), + length=int(r.get("len", 0) or 0), + type=r.get("type", "") or "", + )) + total = int(payload.get("total", 0) or 0) if isinstance(payload, dict) else 0 + if len(rows) < page or len(out) >= total: + break + offset += len(rows) + with self._lock: + self._strings = out + return out + def decomp_map(self, ea: int) -> list[list[int]]: """Per-pseudocode-line instruction coverage for the split-view region highlight: a list aligned to the decompiled lines, each the EAs the diff --git a/server/patch_server.py b/server/patch_server.py index c217561..96c4689 100644 --- a/server/patch_server.py +++ b/server/patch_server.py @@ -635,6 +635,75 @@ def decomp_map( eas.append(hex(e)) lines.append({"ea": eas[0] if eas else None, "eas": eas}) return {"addr": hex(func.start_ea), "lines": lines} + + +_idatui_strings_cache = {} + + +def _idatui_build_strings(min_len): + """[(ea, text, length, typename)] for every string IDA found, cached by + min_len (rebuilding the list is O(n) and the browser pages through it).""" + import idautils + import ida_nalt + hit = _idatui_strings_cache.get(min_len) + if hit is not None: + return hit + tnames = {} + for nm, lbl in (("STRTYPE_C", "C"), ("STRTYPE_C_16", "utf16"), + ("STRTYPE_C_32", "utf32"), ("STRTYPE_PASCAL", "pascal")): + v = getattr(ida_nalt, nm, None) + if v is not None: + tnames[v & 0xFF] = lbl + items = [] + for s in idautils.Strings(): + if s is None: + continue + try: + text = str(s) + except Exception: # noqa: BLE001 -- undecodable literal + continue + if len(text) < min_len: + continue + st = getattr(s, "strtype", 0) & 0xFF + items.append((s.ea, text, getattr(s, "length", len(text)), + tnames.get(st, "t%d" % st))) + _idatui_strings_cache[min_len] = items + return items + + +@tool +@idasync +def list_strings( + offset: Annotated[int, "Start index into the strings list"] = 0, + count: Annotated[int, "Max strings to return (page size)"] = 2000, + min_len: Annotated[int, "Minimum string length to include"] = 4, + refresh: Annotated[bool, "Rebuild the cached strings list"] = False, +) -> dict: + """Every string literal IDA found in the binary (IDA's Shift+F12 window), + paginated: {strings:[{addr,text,len,type}], total, next_offset}. Feeds the + TUI's strings browser.""" + try: + min_len = max(int(min_len), 1) + except (TypeError, ValueError): + min_len = 4 + try: + offset = max(int(offset), 0) + except (TypeError, ValueError): + offset = 0 + try: + count = max(int(count), 1) + except (TypeError, ValueError): + count = 2000 + if refresh: + _idatui_strings_cache.pop(min_len, None) + items = _idatui_build_strings(min_len) + page = items[offset:offset + count] + return { + "strings": [{"addr": hex(ea), "text": text, "len": ln, "type": ty} + for (ea, text, ln, ty) in page], + "total": len(items), + "next_offset": offset + len(page), + } ''' SNIPPET = f"{BEGIN}\n{BODY.strip()}\n{END}\n" diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index fad54ec..2476b21 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -25,7 +25,8 @@ import traceback sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from idatui.app import ( # noqa: E402 ConfirmScreen, DecompView, DisasmView, FunctionsPanel, HexView, IdaTui, - ListingView, StructEditor, SymbolPalette, XrefsScreen, + ListingView, StringsPalette, StructEditor, SymbolPalette, XrefsScreen, + _str_display, ) from textual.widgets import ( # noqa: E402 DataTable, Footer, Input, OptionList, Static, TextArea, @@ -339,6 +340,48 @@ async def s_command_palette(c: Ctx): await c.wait(lambda: app._active != "hex", 5) +@scenario("strings") +async def s_strings(c: Ctx): + app = c.app + await c.open_biggest("listing") + items = app.program.strings() + c.check("program.strings() lists the binary's literals", len(items) > 3, + f"n={len(items)}") + if not items: + return + c.check("strings carry addr/text/length", + all(s.addr > 0 and s.text and s.length > 0 for s in items[:5]), + f"first={items[0]}") + await c.press("quotation_mark") + opened = await c.wait(lambda: isinstance(app.screen, StringsPalette), 25) + c.check('\'"\' opens the strings browser', opened, + f"screen={type(app.screen).__name__}") + if not opened: + return + pal = app.screen + c.check("the browser lists strings", len(pal._results) > 0, + f"results={len(pal._results)}") + # filter on a fragment of a real (unescaped) literal + target = next((s for s in items + if len(s.text) >= 6 and _str_display(s.text) == s.text), None) + if target is not None: + frag = target.text[:6] + pal.query_one(Input).value = frag + await c.pause(0.2) + ok = (pal._results + and all(frag.lower() in s.text.lower() for s in pal._results)) + c.check("filtering narrows to matching strings", bool(ok), + f"frag={frag!r} n={len(pal._results)}") + want = pal._results[0].addr + await c.press("enter") + await c.wait(lambda: not isinstance(app.screen, StringsPalette), 10) + landed = await c.wait(lambda: c.lst._cursor_ea() == want, 20) + c.check("Enter jumps to the string in the unified listing", landed, + f"cursor={c.lst._cursor_ea()} want={want:#x}") + else: + await c.press("escape") + + @scenario("split_view") async def s_split_view(c: Ctx): app, lst, dec = c.app, c.lst, c.dec |
