aboutsummaryrefslogtreecommitdiffstats
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
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.
-rw-r--r--idatui/app.py138
-rw-r--r--idatui/domain.py45
-rw-r--r--server/patch_server.py69
-rw-r--r--tests/test_scenarios.py45
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