aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/PROJECTS.md27
-rw-r--r--idatui/app.py174
-rw-r--r--idatui/formats.py122
-rw-r--r--idatui/launch.py11
-rw-r--r--idatui/project.py14
-rw-r--r--tests/test_formats.py93
-rw-r--r--tests/test_scenarios.py21
7 files changed, 441 insertions, 21 deletions
diff --git a/docs/PROJECTS.md b/docs/PROJECTS.md
index fdd15a1..5f8e327 100644
--- a/docs/PROJECTS.md
+++ b/docs/PROJECTS.md
@@ -245,6 +245,33 @@ An ELF/PE/Mach-O says what it is, so IDA figures it out. A raw firmware dump
says nothing, and IDA falls back to **x86 at address 0** — which on an ARM image
analyses to zero functions. It doesn't fail; it just quietly gives you nothing.
+Open one without saying anything and idatui asks, the way interactive IDA does
+when no loader matches:
+
+ ┌ unrecognised file — processor? (20) ────────────────┐
+ │ fw.bin (32,768 bytes) — no loader matched; without │
+ │ a processor IDA assumes x86 at 0 │
+ │ arm │
+ │ arm ARM / AArch64 — little-endian │
+ │ armb ARM — big-endian │
+ │ load address, e.g. 0x8000000 (blank = 0) │
+ │ Enter accept · Tab base address · Esc load as IDA │
+ └─────────────────────────────────────────────────────┘
+
+`formats.sniff()` decides whether to ask, and only recognises formats IDA
+definitely handles (ELF, PE, Mach-O, dex, wasm, ar, COFF, Intel HEX, S-records).
+Being cautious the wrong way costs one dismissible dialog; being cautious the
+other way is the silent-nothing case we're trying to kill. Esc loads it the way
+IDA would have, because the sniff can be wrong.
+
+A text container is only accepted when the whole header is printable — a raw
+image whose first byte happens to be `:` is far likelier than Intel HEX.
+
+The dialog is skipped when the answer already exists: options on the command
+line, options in the project, or a database that already records them.
+
+Or say it up front:
+
ida-tui fw.bin --processor arm --base 0x8000000
or per binary in a project, which is where it belongs for a multi-image firmware:
diff --git a/idatui/app.py b/idatui/app.py
index 7f00f92..f861f42 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -2517,6 +2517,127 @@ class HelpScreen(ModalScreen):
self.dismiss(None)
+class LoadOptionsScreen(ModalScreen):
+ """Ask how to load a file no loader recognised.
+
+ IDA's own answer to an unidentified file is a dialog; ours is this. Without
+ it the fallback is x86 at address 0, which doesn't fail — it analyses to
+ nothing, and you're left wondering why a firmware image has no functions.
+
+ Returns ``{"processor": str, "base": int}``, or ``{}`` to load it the way
+ IDA would have anyway (that IS the right answer sometimes: our sniff only
+ recognises formats we're sure about, so it says "unknown" for things IDA
+ can in fact handle).
+ """
+
+ BINDINGS = [
+ Binding("escape", "close", "Close"),
+ Binding("down,ctrl+n", "cursor_down", show=False),
+ Binding("up,ctrl+p", "cursor_up", show=False),
+ Binding("enter", "choose", show=False, priority=True),
+ ]
+
+ def __init__(self, path: str, size: int = 0) -> None:
+ super().__init__()
+ self._path = path
+ # NOT self._size: that is Textual's own backing field for outer_size, and
+ # assigning an int to it crashes the layout with a bewildering
+ # "'int' object has no attribute 'region'" from deep inside _set_dirty.
+ self._nbytes = size
+ self._results: list[tuple[str, str]] = []
+
+ def compose(self) -> ComposeResult:
+ from .formats import PROCESSORS
+ self._all = list(PROCESSORS)
+ with Vertical(id="pal-box"):
+ yield Static(" unrecognised file \u2014 how should IDA load it?",
+ id="pal-title", markup=False)
+ yield Static(f" {os.path.basename(self._path)} ({self._nbytes:,} bytes) "
+ f"\u2014 no loader matched; without a processor IDA "
+ f"assumes x86 at 0", id="load-note", markup=False)
+ yield Input(placeholder="filter processors\u2026", id="pal-input")
+ yield OptionList(id="pal-list")
+ yield Input(placeholder="load address, e.g. 0x8000000 (blank = 0)",
+ id="load-base")
+ yield Static(" Enter accept \u00b7 Tab base address \u00b7 "
+ "Esc load as IDA would", id="load-help", markup=False)
+
+ 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()
+ if event.input.id == "pal-input":
+ 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 = [(name, desc) for name, desc in self._all
+ if not q or q in name.lower() or q in desc.lower()]
+ # An unlisted processor is still valid: IDA has 73 modules and this
+ # offers 20, so a typed name that matches nothing is taken literally
+ # rather than refused.
+ if not rows and q:
+ rows = [(query.strip(), "use this processor name as typed")]
+ self._results = rows
+ ol = self.query_one(OptionList)
+ ol.clear_options()
+ opts = []
+ for name, desc in rows:
+ label = Text()
+ label.append(f" {name:<12}", _S_LABEL)
+ label.append(desc, _S_DIM)
+ opts.append(Option(label))
+ ol.add_options(opts)
+ if rows:
+ ol.highlighted = 0
+ self.query_one("#pal-title", Static).update(
+ f" unrecognised file \u2014 processor? ({len(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 None or not (0 <= i < len(self._results)):
+ self.dismiss({})
+ return
+ raw = self.query_one("#load-base", Input).value.strip()
+ base = 0
+ if raw:
+ try:
+ base = int(raw, 0)
+ except ValueError:
+ self.query_one("#load-help", Static).update(
+ f" {raw!r} is not an address \u2014 try 0x8000000")
+ self.query_one("#load-base", Input).focus()
+ return
+ if base % 16:
+ # IDA's -b is in paragraphs, so an unaligned base can't be
+ # expressed and would quietly load somewhere else.
+ self.query_one("#load-help", Static).update(
+ f" {base:#x} must be 16-byte aligned")
+ self.query_one("#load-base", Input).focus()
+ return
+ self.dismiss({"processor": self._results[i][0], "base": base})
+
+ def action_close(self) -> None:
+ self.dismiss({})
+
+
class ProjectPalette(ModalScreen):
"""The project's binaries; Enter switches to one. Shows which are resident
(a live worker, so switching is instant) vs cold (needs an open)."""
@@ -3105,7 +3226,8 @@ class IdaTui(App):
#xref-title { dock: top; height: 1; background: $accent; color: $background; text-style: bold; padding: 0 1; }
#xref-list { height: auto; max-height: 100%; }
/* every #pal-box palette centres, not just the symbol one */
- SymbolPalette, StringsPalette, ProjectPalette { align: center middle; }
+ SymbolPalette, StringsPalette, ProjectPalette,
+ LoadOptionsScreen { align: center middle; }
/* Give the stock Ctrl+P command palette side padding instead of full width;
the input + results inherit this width (results is an overlay, so pin it). */
CommandPalette > Vertical { width: 80%; max-width: 120; }
@@ -3115,6 +3237,9 @@ class IdaTui(App):
#pal-title { dock: top; height: 1; background: $accent; color: $background; text-style: bold; padding: 0 1; }
#pal-input { border: none; height: 1; margin: 0 1; background: $panel; color: $text; }
#pal-list { height: auto; max-height: 24; }
+ #load-note { height: 2; padding: 1 1 0 1; color: $text-muted; }
+ #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; }
StructEditor { align: center middle; }
#se-box { width: 90%; height: 84%; border: thick $accent; background: $panel; }
#se-panes { height: 1fr; }
@@ -3282,13 +3407,56 @@ class IdaTui(App):
# The names pane is an overlay now (Ctrl+N); focus the default code view
# (pseudocode) so app bindings work before anything is opened.
self.query_one(ListingView).focus()
+ if self._rpc_path:
+ self._start_rpc()
+ # A file no loader recognises has to be described before it can be
+ # opened, so ask BEFORE the worker starts — once IDA has made a database
+ # the answer is baked in and changing it means deleting the .i64.
+ if self._should_ask_load_options():
+ self._ask_load_options()
+ return
# Show a loading overlay immediately so a slow open/analysis (big binary)
# isn't just dead air behind empty panes; dismissed once we land.
self._loading_screen = LoadingScreen(self._loading_title())
self.push_screen(self._loading_screen)
self._connect()
- if self._rpc_path:
- self._start_rpc()
+
+ def _should_ask_load_options(self) -> bool:
+ """Ask only when nobody has already answered, and only when it matters.
+
+ Skipped when: options came from the command line or the project (the
+ user already said); a database exists (the answer is recorded in it, and
+ re-passing switches fails the open); or the file is a format IDA
+ recognises, which is nearly always.
+ """
+ if self._project is not None or not self._open_path:
+ return False # project mode carries per-binary options already
+ if self._load_args:
+ return False
+ from .formats import needs_load_options
+ if os.path.exists(self._open_path + ".i64") or os.path.exists(
+ os.path.splitext(self._open_path)[0] + ".i64"):
+ return False
+ return needs_load_options(self._open_path)
+
+ def _ask_load_options(self) -> None:
+ try:
+ size = os.path.getsize(self._open_path)
+ except OSError:
+ size = 0
+ self.push_screen(LoadOptionsScreen(self._open_path, size),
+ self._on_load_options)
+
+ def _on_load_options(self, choice) -> None: # type: ignore[no-untyped-def]
+ from .formats import load_args
+ choice = choice or {}
+ if choice.get("processor"):
+ self._load_args = load_args(choice["processor"], choice.get("base", 0))
+ self._status(f"loading as {choice['processor']} "
+ f"@ {choice.get('base', 0):#x}")
+ self._loading_screen = LoadingScreen(self._loading_title())
+ self.push_screen(self._loading_screen)
+ self._connect()
def _loading_title(self) -> str:
return os.path.basename(self._open_path) if self._open_path else "database"
diff --git a/idatui/formats.py b/idatui/formats.py
new file mode 100644
index 0000000..744276d
--- /dev/null
+++ b/idatui/formats.py
@@ -0,0 +1,122 @@
+"""Recognising what a file is, and what to ask when we can't.
+
+IDA picks a loader by looking at the file. When one matches, it knows the
+processor, the load address and often the entry point, and there is nothing to
+ask. When none matches — a raw firmware dump, a flash image, a decompressed blob
+— IDA falls back to a plain binary load: **x86 at address 0**. That is a silent
+wrong answer, not an error; the database opens, analysis runs, and finds nothing.
+
+Interactive IDA handles this by asking. This module is the "should we ask, and
+what are the sensible answers" half of doing the same.
+
+The sniff deliberately only recognises formats IDA definitely has a loader for.
+Being wrong in the cautious direction costs one dismissible dialog; being wrong
+the other way is the silent-nothing case we're trying to kill.
+"""
+
+from __future__ import annotations
+
+import os
+
+#: (magic, offset, name) for formats IDA loads without help.
+_MAGIC: tuple[tuple[bytes, int, str], ...] = (
+ (b"\x7fELF", 0, "ELF"),
+ (b"MZ", 0, "PE/MZ"),
+ (b"\xfe\xed\xfa\xce", 0, "Mach-O"),
+ (b"\xfe\xed\xfa\xcf", 0, "Mach-O 64"),
+ (b"\xce\xfa\xed\xfe", 0, "Mach-O (LE)"),
+ (b"\xcf\xfa\xed\xfe", 0, "Mach-O 64 (LE)"),
+ (b"\xca\xfe\xba\xbe", 0, "Mach-O fat/Java class"),
+ (b"dex\n", 0, "Dalvik"),
+ (b"\x00asm", 0, "WebAssembly"),
+ (b"!<arch>", 0, "ar archive"),
+ (b"\x4c\x01", 0, "COFF i386"),
+ (b"\x64\x86", 0, "COFF x64"),
+ (b"\x00\x00\x03\xf3", 0, "Amiga hunk"),
+ (b"\x7fCGC", 0, "CGC"),
+)
+
+#: Text-ish container formats: recognised by their first line, not a magic word.
+_TEXT_PREFIX: tuple[tuple[bytes, str], ...] = (
+ (b":", "Intel HEX"),
+ (b"S0", "Motorola S-record"),
+ (b"S1", "Motorola S-record"),
+ (b"S2", "Motorola S-record"),
+ (b"S3", "Motorola S-record"),
+)
+
+
+def sniff(path: str) -> str | None:
+ """The container format of ``path``, or None when nothing recognises it.
+
+ None is the interesting answer: it means IDA will guess, and its guess is
+ x86-at-zero regardless of what the bytes actually are.
+ """
+ try:
+ with open(path, "rb") as f:
+ head = f.read(64)
+ except OSError:
+ return None
+ if not head:
+ return None
+ for magic, off, name in _MAGIC:
+ if head[off:off + len(magic)] == magic:
+ return name
+ # Only treat a text prefix as a format if the whole head is printable —
+ # a raw blob starting with 0x3a (':') is far more likely than Intel HEX.
+ if all(32 <= b < 127 or b in (9, 10, 13) for b in head):
+ for prefix, name in _TEXT_PREFIX:
+ if head.startswith(prefix):
+ return name
+ return None
+
+
+def needs_load_options(path: str) -> bool:
+ """True when we should ask how to load ``path`` rather than let IDA guess."""
+ return os.path.isfile(path) and sniff(path) is None
+
+
+#: Processors worth offering, as (IDA -p name, human label).
+#:
+#: IDA ships 73 processor modules, most of which are museum pieces; a list that
+#: long is a worse answer than a short one plus free text. These are the targets
+#: that actually turn up in firmware work, endianness spelled out because
+#: getting it wrong is the most common way to end up with zero functions.
+PROCESSORS: tuple[tuple[str, str], ...] = (
+ ("arm", "ARM / AArch64 — little-endian"),
+ ("armb", "ARM — big-endian"),
+ ("metapc", "x86 / x86-64"),
+ ("mipsl", "MIPS — little-endian"),
+ ("mipsb", "MIPS — big-endian"),
+ ("ppc", "PowerPC — big-endian"),
+ ("ppcl", "PowerPC — little-endian"),
+ ("sh4", "SuperH SH-4"),
+ ("68k", "Motorola 68000"),
+ ("riscv", "RISC-V"),
+ ("tricore", "Infineon TriCore"),
+ ("xtensa", "Tensilica Xtensa (ESP32 etc.)"),
+ ("avr", "Atmel AVR"),
+ ("z80", "Zilog Z80"),
+ ("tms320c6", "TI TMS320C6x DSP"),
+ ("m32r", "Renesas M32R"),
+ ("arc", "Synopsys ARC"),
+ ("h8", "Renesas H8"),
+ ("sparc", "SPARC"),
+ ("s390", "IBM S/390"),
+)
+
+
+def load_args(processor: str = "", base: int = 0, extra: str = "") -> str:
+ """``processor``/``base`` as IDA command-line switches.
+
+ ``-b`` is in PARAGRAPHS, not bytes: ``-b1000`` loads at 0x10000. Every caller
+ goes through here so that conversion is done in exactly one place.
+ """
+ parts = []
+ if processor:
+ parts.append(f"-p{processor}")
+ if base:
+ parts.append(f"-b{int(base) >> 4:x}")
+ if extra:
+ parts.append(extra)
+ return " ".join(parts)
diff --git a/idatui/launch.py b/idatui/launch.py
index b28559e..f51e5af 100644
--- a/idatui/launch.py
+++ b/idatui/launch.py
@@ -25,14 +25,9 @@ _LOCK_SUFFIXES = (".id0", ".id1", ".id2", ".nam", ".til")
def _load_args(load: dict) -> str:
"""``load`` as IDA switches, for the single-binary path (no project ref)."""
- parts = []
- if load.get("processor"):
- parts.append(f"-p{load['processor']}")
- if load.get("base"):
- parts.append(f"-b{int(load['base']) >> 4:x}") # -b is PARAGRAPHS
- if load.get("ida_args"):
- parts.append(str(load["ida_args"]))
- return " ".join(parts)
+ from .formats import load_args
+ return load_args(load.get("processor", ""), int(load.get("base", 0) or 0),
+ str(load.get("ida_args", "") or ""))
def _log(msg: str) -> None:
diff --git a/idatui/project.py b/idatui/project.py
index 249ccd6..8f2674d 100644
--- a/idatui/project.py
+++ b/idatui/project.py
@@ -68,17 +68,11 @@ class BinaryRef:
"""``processor``/``base`` as IDA command-line switches.
``-b`` is in PARAGRAPHS, not bytes — ``-b1000`` loads at 0x10000. That
- is a trap worth hiding: projects say ``"base": "0x8000000"`` and the
- conversion happens here.
+ trap is worth hiding: projects say ``"base": "0x8000000"`` and the one
+ conversion lives in ``formats.load_args``.
"""
- parts = []
- if self.processor:
- parts.append(f"-p{self.processor}")
- if self.base:
- parts.append(f"-b{self.base >> 4:x}")
- if self.ida_args:
- parts.append(self.ida_args)
- return " ".join(parts)
+ from .formats import load_args
+ return load_args(self.processor, self.base, self.ida_args)
def _as_addr(v) -> int:
diff --git a/tests/test_formats.py b/tests/test_formats.py
new file mode 100644
index 0000000..f88021c
--- /dev/null
+++ b/tests/test_formats.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""Format sniffing + load-switch construction (pure stdlib, no IDA)."""
+import os
+import sys
+import tempfile
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from idatui.formats import (PROCESSORS, load_args, # noqa: E402
+ needs_load_options, sniff)
+
+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}")
+
+
+def main() -> int:
+ with tempfile.TemporaryDirectory() as tmp:
+ def w(name, data):
+ p = os.path.join(tmp, name)
+ with open(p, "wb") as f:
+ f.write(data)
+ return p
+
+ # -- formats IDA can load on its own: never interrupt the user -------- #
+ for name, head in (("elf", b"\x7fELF\x02\x01\x01"), ("pe", b"MZ\x90\x00"),
+ ("macho", b"\xcf\xfa\xed\xfe"), ("dex", b"dex\n035\x00"),
+ ("wasm", b"\x00asm\x01\x00")):
+ p = w(name, head + bytes(60))
+ check(f"{name} is recognised (no dialog)",
+ sniff(p) is not None and not needs_load_options(p), f"{sniff(p)}")
+
+ # -- the case this exists for ----------------------------------------- #
+ blob = w("fw.bin", bytes(range(256)) * 4)
+ check("a headerless blob is not recognised (ask)",
+ sniff(blob) is None and needs_load_options(blob))
+
+ # Intel HEX / S-records are text containers IDA does load.
+ hexf = w("f.hex", b":10010000214601360121470136007EFE09D2190140\n")
+ check("Intel HEX is recognised", sniff(hexf) == "Intel HEX", f"{sniff(hexf)}")
+ srec = w("f.s19", b"S00600004844521B\nS1130000285F245F2212226A000424290008237C\n")
+ check("Motorola S-records are recognised",
+ sniff(srec) == "Motorola S-record", f"{sniff(srec)}")
+
+ # A binary that merely STARTS with ':' is not Intel HEX. Text formats are
+ # only accepted when the whole head is printable, or half the firmware in
+ # the world gets mis-detected on one byte.
+ colon = w("colon.bin", b":\x00\xff\xfe\x01\x02" + bytes(58))
+ check("a blob starting with ':' is not mistaken for Intel HEX",
+ sniff(colon) is None, f"{sniff(colon)}")
+
+ check("an empty file is not recognised", sniff(w("empty", b"")) is None)
+ check("a missing file never asks (nothing to load)",
+ not needs_load_options(os.path.join(tmp, "nope")))
+ check("a directory never asks", not needs_load_options(tmp))
+
+ # -- switch construction ------------------------------------------------- #
+ # -b is in PARAGRAPHS: 0x8000000 >> 4 == 0x800000. Getting this wrong loads
+ # the image 16x off and every address in the database is wrong.
+ check("base is converted to paragraphs",
+ load_args("arm", 0x8000000) == "-parm -b800000",
+ load_args("arm", 0x8000000))
+ check("processor alone", load_args("mipsb") == "-pmipsb", load_args("mipsb"))
+ check("base alone", load_args("", 0x10000) == "-b1000", load_args("", 0x10000))
+ check("base 0 emits no switch (it's the default)",
+ load_args("arm", 0) == "-parm", load_args("arm", 0))
+ check("nothing in, nothing out", load_args() == "")
+ check("extra switches pass through",
+ load_args("arm", 0, "-T binary") == "-parm -T binary")
+
+ check("the processor list leads with the common targets",
+ [n for n, _ in PROCESSORS[:3]] == ["arm", "armb", "metapc"],
+ f"{[n for n, _ in PROCESSORS[:3]]}")
+ check("every processor entry has a human label",
+ all(n and d for n, d in PROCESSORS))
+ check("endianness is spelled out where it matters",
+ all(any(w in d.lower() for w in ("endian",))
+ for n, d in PROCESSORS if n in ("arm", "armb", "mipsb", "mipsl")))
+
+ print(f"\n{PASS} passed, {FAIL} failed")
+ return 1 if FAIL else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index b7520d9..ee2f77b 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -349,6 +349,27 @@ async def s_palette(c: Ctx):
c.check("Esc closes the palette", not isinstance(app.screen, SymbolPalette))
+@scenario("load_options")
+async def s_load_options(c: Ctx):
+ """The dialog must never appear for a file IDA can load itself — the whole
+ suite runs on an ELF, so a false positive here would block every run."""
+ from idatui.app import LoadOptionsScreen
+ app = c.app
+ c.check("no load dialog for a recognised binary",
+ not isinstance(app.screen, LoadOptionsScreen),
+ f"screen={type(app.screen).__name__}")
+ c.check("and the app agrees it shouldn't ask",
+ not app._should_ask_load_options())
+ # The dialog itself, driven directly: it has to come back with switches the
+ # worker can use, and -b has to be paragraphs.
+ from idatui.formats import load_args, needs_load_options, sniff
+ c.check("the running target sniffs as a real format",
+ sniff(app._open_path) is not None and not needs_load_options(app._open_path),
+ f"{sniff(app._open_path)}")
+ c.check("dialog output converts a base to paragraphs",
+ load_args("arm", 0x8000000) == "-parm -b800000")
+
+
@scenario("command_palette")
async def s_command_palette(c: Ctx):
app = c.app