aboutsummaryrefslogtreecommitdiffstats
path: root/idatui
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-25 00:51:28 +0200
committerblasty <blasty@local>2026-07-25 00:51:28 +0200
commitbe23d13852f4efd062641957b4dcec10622fc0c6 (patch)
treeb2f020fca47fc0afb472d317e91e60ba468a514f /idatui
parentsplit: follow the decomp across functions as the listing cursor crosses bounds (diff)
downloadida-tui-be23d13852f4efd062641957b4dcec10622fc0c6.tar.gz
ida-tui-be23d13852f4efd062641957b4dcec10622fc0c6.tar.xz
ida-tui-be23d13852f4efd062641957b4dcec10622fc0c6.zip
strings: browse every string in the binary and jump to it (IDA's Shift+F12)
ida-pro-mcp exposes no full strings list (only a filtered/capped "interesting" survey), so this is a new injected tool plus a filterable browser. * server/patch_server.py: list_strings(offset,count,min_len,refresh) — every literal from idautils.Strings() as {addr,text,len,type}, paginated, with a module-level cache keyed by min_len (rebuilding is O(n) and the browser pages the whole list). * domain: StrLit dataclass + Program.strings() — pages the full list once and caches it. * app: StringsPalette modal (mirrors SymbolPalette) — case-insensitive substring filter with the match highlighted, addr/len/text columns, ↑↓/Enter/Esc. Bodies are sanitized to one printable line (\n/\r/\t escaped, non-printables dropped, long strings clipped) so control chars can't break the layout; display strings are pre-rendered+pre-lowered once since filtering runs per keystroke. Enter jumps to the literal in the unified listing via _goto_ea. Bound to '"' and Shift+F12, plus a "Strings…" command-palette entry. Verified on echo: 150 strings listed with addr/len/text, filtering 'usage' narrows to 2 (case-insensitive), Enter lands the listing cursor on the literal. Pilot `strings` scenario 6/6; full suite 165/2-flake.
Diffstat (limited to 'idatui')
-rw-r--r--idatui/app.py138
-rw-r--r--idatui/domain.py45
2 files changed, 183 insertions, 0 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