diff options
| author | blasty <blasty@local> | 2026-07-26 08:58:31 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-26 08:58:31 +0200 |
| commit | a0cb1a1a6f8aca7a64c102dba388e899267d5bc1 (patch) | |
| tree | 23bd5ffa749f0b988c30878a82c74d03e87a5625 | |
| parent | load dialog: reachable address field, project mode, and a way back from a bad... (diff) | |
| download | ida-tui-a0cb1a1a6f8aca7a64c102dba388e899267d5bc1.tar.gz ida-tui-a0cb1a1a6f8aca7a64c102dba388e899267d5bc1.tar.xz ida-tui-a0cb1a1a6f8aca7a64c102dba388e899267d5bc1.zip | |
loading: a blob that analyses to nothing is still openable
Pick a random .bin, say ARM at 0x4000, and you got two empty panes and
"functions still loading…" — which was a lie; loading had finished. _auto_land
falls back to the symbol picker when there's no entry function, and the picker
answers an empty index with that message. Nothing ever opened.
Zero functions is not a corner case. It's exactly what a real firmware image
looks like when it's described wrongly, and IDA has no complaint of its own to
make about it, so this was the last silent-wrong-answer in the blob path.
Now:
* Land in the listing at the start of the image. The bytes exist even when no
code was recognised, so there is always something to show.
* Say so, in the status bar, for as long as it stays true — an image with no
functions is a property of the database, not an event, and writing it once
meant the next status write erased it (the same clobber that bit the split
view's "decompiling…").
* Ctrl+L re-asks. The .i64 has the old processor and base baked in and takes
precedence over any switches, so reloading means deleting it; the confirmation
says what that costs, and when there are no functions it says nothing is lost.
Verified with real keys on a random 64K blob: ARM @ 0x4000 lands showing
"db 65536 dup(?)" with the hint in the status; Ctrl+L -> confirm -> dialog ->
metapc reloads at 0. The hint survives scrolling.
tests: new tests/test_blob_ui.py (11) driving a real random blob end to end —
lands, has rows, right base, honest status, hint survives navigation, Ctrl+L
offers the reload and declining leaves the binary open. 202/0 scenarios, 30/0
project UI.
(Harness note for future me: tmux send-keys reads "0x4000" as a hex KEY CODE and
sends U+4000. Use send-keys -l for literal text.)
| -rw-r--r-- | docs/PROJECTS.md | 19 | ||||
| -rw-r--r-- | idatui/app.py | 136 | ||||
| -rw-r--r-- | tests/test_blob_ui.py | 106 |
3 files changed, 258 insertions, 3 deletions
diff --git a/docs/PROJECTS.md b/docs/PROJECTS.md index 88ce2f6..10274ab 100644 --- a/docs/PROJECTS.md +++ b/docs/PROJECTS.md @@ -297,6 +297,25 @@ conversion happens in `BinaryRef.load_args`, and a base that isn't 16-byte aligned is rejected rather than silently landing somewhere else. `ida_args` passes anything else through untouched. +### When analysis finds nothing + +Zero functions is what a raw image described wrongly looks like — right file, +wrong architecture, and IDA has no complaint to make about it. The app used to +fall through to the symbol picker, which had nothing to show, so you got empty +panes and a status reading "functions still loading…" long after loading had +finished. + +Now it opens the listing at the start of the image (the bytes are there even when +no code was recognised) and the status bar carries the diagnosis for as long as +it's true: + + seg000 @ 0x0 [listing] — no functions: wrong processor/base? Ctrl+L to reload + +`Ctrl+L` re-asks. The database IDA already built has the old processor and base +baked into it and wins over any switches, so reloading means deleting it — hence +the confirmation, which tells you what you'd lose (and, in this case, that you'd +lose nothing). + Two things worth knowing: * The options apply to the **first** open only. Afterwards the `.i64` records how diff --git a/idatui/app.py b/idatui/app.py index 87d8f70..f1b4dec 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -2757,13 +2757,16 @@ class ConfirmScreen(ModalScreen): Binding("escape,n", "cancel", "Cancel"), ] - def __init__(self, message: str) -> None: + def __init__(self, message: str, note: str = "") -> None: super().__init__() self._message = message + self._note = note def compose(self) -> ComposeResult: with Vertical(id="confirm-box"): - yield Static(self._message, id="confirm-msg") + yield Static(self._message, id="confirm-msg", markup=False) + if self._note: + yield Static(self._note, id="confirm-note", markup=False) yield Static("[Enter/y] confirm [Esc/n] cancel", id="confirm-help") def action_confirm(self) -> None: @@ -3262,6 +3265,7 @@ class IdaTui(App): LoadOptionsScreen #pal-list { max-height: 12; } #load-base { border: none; height: 1; margin: 1 1 0 1; background: $panel; color: $text; } #load-help { height: 1; padding: 0 1; color: $text-muted; } + #confirm-note { height: auto; padding: 0 1; color: $text-muted; } StructEditor { align: center middle; } #se-box { width: 90%; height: 84%; border: thick $accent; background: $panel; } #se-panes { height: 1fr; } @@ -3298,6 +3302,7 @@ class IdaTui(App): Binding("s", "toggle_split", "Split", show=False), Binding("quotation_mark,shift+f12", "strings", "Strings", show=False), Binding("ctrl+o", "switch_binary", "Binaries", show=False), + Binding("ctrl+l", "load_options", "Reload as…", show=False), Binding("f1", "help", "Keys", show=False), Binding("g", "goto", "Goto"), Binding("slash", "filter", "Filter", show=False), @@ -3321,6 +3326,7 @@ class IdaTui(App): self._goto_after_switch = None # cross-binary search hit to land on self._hops: list[str] = [] # binaries a navigation crossed FROM self._load_for_label = None # project binary the dialog is for + self._no_functions = False # analysis produced nothing at all self._pending_switch = None # switch waiting on that answer self._nav_seq = 0 # bumped per navigation; drops stale ones # None = teardown wasn't an explicit quit (crash/kill): save defensively. @@ -3468,6 +3474,91 @@ class IdaTui(App): return False return needs_load_options(self._open_path) + def action_load_options(self) -> None: + """Ctrl+L: re-open this binary with different load options. + + The database IDA already built has the old processor and base baked into + it and takes precedence over any switches, so re-loading means throwing + it away. That destroys names and comments, hence the confirmation — but + for the case this exists for (a blob loaded as the wrong architecture, + which analysed to nothing) there is nothing to lose and no other way + forward. + """ + if not self._can_reload(): + self._status("nothing to reload") + return + n = len(self._func_index) if self._func_index else 0 + note = ("this image has no functions, so nothing is lost" + if n == 0 else + f"discards the database for this binary \u2014 {n} functions, " + f"plus any names and comments you've added") + self.push_screen(ConfirmScreen("Reload with different options?", note), + self._on_reload_confirmed) + + def _on_reload_confirmed(self, yes) -> None: # type: ignore[no-untyped-def] + if not yes: + return + path, label = self._open_path, None + if self._project is not None and self._binary is not None: + ref = self._project.by_label(self._binary) + if ref is not None: + path, label = ref.source, ref.label + # Drop the worker first: it holds the database open, and the .i64 can't + # be removed (or rebuilt) underneath a live one. + self._release_worker() + self._drop_database() + self._reset_for_reload() + self._load_args = "" + if label is not None and self._project is not None: + self._project.set_load(label, processor="", base=0) + i = self._project._refs.index(self._project.by_label(label)) + self._project._entries[i].pop("processor", None) + self._project._entries[i].pop("base", None) + self._project.save() + self._pending_switch = None + self._ask_load_options(path, label=label) + + def _release_worker(self) -> None: + if self._pool is not None and self._binary is not None: + try: + self._pool.evict(self._binary, save=False) + except Exception: # noqa: BLE001 + pass + elif self.client is not None: + try: + self.client.close() + except Exception: # noqa: BLE001 + pass + self.client = None + self.program = None + + def _drop_database(self) -> None: + """Remove the .i64 (and any unpacked scratch) so the next open re-reads + the raw image with new options.""" + base = self._open_path + if self._project is not None and self._binary is not None: + ref = self._project.by_label(self._binary) + if ref is not None: + base = ref.staged + if not base: + return + for suffix in (".i64", ".id0", ".id1", ".id2", ".nam", ".til"): + for cand in (base + suffix, os.path.splitext(base)[0] + suffix): + try: + os.remove(cand) + except OSError: + pass + + def _reset_for_reload(self) -> None: + self._no_functions = False + self._func_index = None + self._cur = None + self._nav = [] + self._did_auto_land = False + self._pending_restore = None + self._split = False + self.query_one(DecompView).loaded_ea = None + def _retry_load_options(self) -> None: """Re-ask after IDA refused what we told it.""" path, label = self._open_path, None @@ -3562,6 +3653,11 @@ class IdaTui(App): def _status(self, text: str) -> None: if self._binary: # project mode: always say which binary you're in text = f"[{self._binary}] {text}" + # An image with no functions at all is nearly always a blob described + # wrongly, and that stays true as you scroll around — so it belongs in + # the status bar, not in a one-off message the next write clobbers. + if self._no_functions: + text += " \u2014 no functions: wrong processor/base? Ctrl+L to reload" try: self.query_one("#status", Static).update(text) except Exception: # noqa: BLE001 -- status bar transiently unavailable @@ -3837,8 +3933,42 @@ class IdaTui(App): fn = self._entry_func() if fn is not None: self._open_function(fn.addr, fn.name) - else: + elif len(self._func_index): self.action_symbols() + else: + self._land_without_functions() + + def _land_without_functions(self) -> None: + """Analysis found nothing. Show the bytes and say so. + + Falling through to the symbol picker here left two empty panes and + "functions still loading…" — which is a lie, loading had finished. There + is always something to look at: the segments exist even when IDA + recognised no code in them, so open the listing at the start of the image. + + Zero functions is also the signal that a blob was described wrongly. It's + exactly what a good image loaded as the wrong processor looks like, so + the status says so rather than leaving you to guess. + """ + start = None + try: + regions = self.program.file_regions() + if regions: + start = regions[0][0] + except Exception: # noqa: BLE001 + pass + self._no_functions = self._can_reload() + if start is None: + self._status("no functions and no segments \u2014 nothing to show") + return + self._open_at(start, self.program.section_of(start) or "image", + cursor=0, push=True, is_region=True) + + def _can_reload(self) -> bool: + """Whether we're able to re-open this binary with different options.""" + if self._project is not None and self._binary is not None: + return True + return bool(self._open_path) def _entry_func(self) -> Func | None: """The best startup landing function (exact-name match against diff --git a/tests/test_blob_ui.py b/tests/test_blob_ui.py new file mode 100644 index 0000000..e8d2533 --- /dev/null +++ b/tests/test_blob_ui.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""A blob that analyses to NOTHING must still be usable. + +Zero functions is the normal outcome for a raw image described wrongly — and it +used to leave two empty panes and a status that said "functions still loading…", +which was a lie: loading had finished, there was simply nothing to land on. + +Needs IDA (spawns a real worker). ~40s. +""" +import asyncio +import os +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from textual.widgets import Static # noqa: E402 + +from idatui.app import ConfirmScreen, IdaTui, ListingView # noqa: E402 + +PASS = FAIL = 0 + + +def check(name, ok, detail=""): + global PASS, FAIL + if ok: + PASS += 1 + print(f" ok {name}") + else: + FAIL += 1 + print(f" FAIL {name} {detail}") + + +async def wait(pred, pilot, t=240.0): + for _ in range(int(t / 0.05)): + await pilot.pause(0.05) + try: + if pred(): + return True + except Exception: # noqa: BLE001 + pass + return False + + +async def run() -> int: + with tempfile.TemporaryDirectory() as tmp: + blob = os.path.join(tmp, "rnd.bin") + with open(blob, "wb") as f: + f.write(os.urandom(64 * 1024)) # random: IDA will find no code + + # Skip the dialog by answering up front; this test is about what + # happens AFTER a described blob turns out to contain nothing. + app = IdaTui(open_path=blob, keepalive=False, load_args="-parm -b400") + async with app.run_test(size=(140, 44)) as pilot: + ok = await wait(lambda: app._func_index is not None + and app._func_index.complete, pilot) + check("a random blob still finishes loading", ok) + check("and really has no functions", len(app._func_index) == 0, + f"n={len(app._func_index)}") + + landed = await wait(lambda: app._cur is not None, pilot, 60) + check("it lands somewhere instead of leaving empty panes", landed, + f"cur={app._cur}") + lst = app.query_one(ListingView) + check("the listing actually has rows to show", + lst.total > 0, f"total={lst.total}") + check("landed at the image base", app._cur is not None + and app._cur.ea == 0x4000, f"{app._cur.ea if app._cur else None:#x}") + + status = str(app.query_one("#status", Static).render()) + check("the status says there are no functions (not 'still loading')", + "no functions" in status and "still loading" not in status, + status[:90]) + check("and points at the likely cause", + "processor" in status and "Ctrl+L" in status, status[:90]) + + # The hint is a property of the database, so it must survive moving + # around — an earlier version wrote it once and the next status + # write erased it. + lst.focus() + await pilot.press("down") + await pilot.press("down") + await pilot.pause(0.3) + status2 = str(app.query_one("#status", Static).render()) + check("the hint survives navigating", "no functions" in status2, + status2[:90]) + + await pilot.press("ctrl+l") + opened = await wait(lambda: isinstance(app.screen, ConfirmScreen), pilot, 20) + check("Ctrl+L offers to reload with different options", opened, + f"screen={type(app.screen).__name__}") + if opened: + note = str(app.screen.query_one("#confirm-note", Static).render()) + check("and says nothing is lost when there's nothing to lose", + "nothing is lost" in note, note[:70]) + await pilot.press("escape") + await pilot.pause(0.3) + check("declining leaves the binary open", + not isinstance(app.screen, ConfirmScreen) and app._cur is not None) + + print(f"\n{PASS} passed, {FAIL} failed") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(run())) |
