diff options
| -rw-r--r-- | docs/PROJECTS.md | 27 | ||||
| -rw-r--r-- | idatui/app.py | 174 | ||||
| -rw-r--r-- | idatui/formats.py | 122 | ||||
| -rw-r--r-- | idatui/launch.py | 11 | ||||
| -rw-r--r-- | idatui/project.py | 14 | ||||
| -rw-r--r-- | tests/test_formats.py | 93 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 21 |
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 |
