diff options
| -rw-r--r-- | README.md | 13 | ||||
| -rw-r--r-- | docs/PROJECTS.md | 31 | ||||
| -rw-r--r-- | idatui/app.py | 9 | ||||
| -rw-r--r-- | idatui/launch.py | 48 | ||||
| -rw-r--r-- | idatui/pool.py | 2 | ||||
| -rw-r--r-- | idatui/project.py | 70 | ||||
| -rw-r--r-- | idatui/worker.py | 41 | ||||
| -rw-r--r-- | idatui/worker_client.py | 8 | ||||
| -rw-r--r-- | tests/test_project.py | 40 |
9 files changed, 239 insertions, 23 deletions
@@ -90,6 +90,19 @@ It uses `~/ida-venv/bin/python` for the TUI (override with `$IDATUI_PYTHON`) and resolves binary paths against your real cwd. The binary's directory must be writable (idalib writes a `.i64` there). +Headerless blobs need to be told what they are — a raw firmware dump has no +format to detect, and IDA falls back to x86 at address 0, which analyses to +nothing: + +```sh +./ida-tui fw.bin --processor arm --base 0x8000000 +``` + +`--base` is a real address (IDA's own `-b` is in paragraphs; the conversion is +done for you). In a project the options are recorded per binary, which is what a +multi-image firmware wants. They apply to the first open only — after that the +`.i64` records how the image was loaded. See `docs/PROJECTS.md`. + > Recovering a wedged database: if a worker was hard-killed it leaves unpacked > `foo.id0/.id1/.id2/.nam/.til` next to `foo.i64`, and the `.i64` then refuses to > reopen. Delete those stale files (never the `.i64`) and retry — `ida-tui` does diff --git a/docs/PROJECTS.md b/docs/PROJECTS.md index f4acc91..fdd15a1 100644 --- a/docs/PROJECTS.md +++ b/docs/PROJECTS.md @@ -239,6 +239,37 @@ Still open: project-level persistence (remember the active binary and each binary's position across sessions — today a fresh launch auto-lands). +## Headerless blobs + +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. + + ida-tui fw.bin --processor arm --base 0x8000000 + +or per binary in a project, which is where it belongs for a multi-image firmware: + + {"binaries": [ + {"path": "bootloader.bin", "processor": "arm", "base": "0x0"}, + {"path": "app.bin", "processor": "arm", "base": "0x8000000"}, + {"path": "dsp.bin", "processor": "mipsb", "base": "0x10000000"} + ]} + +`base` is written the way you'd say it (`0x8000000`, any base, int or string). +IDA's own `-b` switch is in **paragraphs** — `-b1000` loads at 0x10000 — so the +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. + +Two things worth knowing: + +* The options apply to the **first** open only. Afterwards the `.i64` records how + the image was loaded, and passing the switches again makes IDA refuse to open + it — so the worker skips them once a database exists. +* Changing the processor or base of a binary you've already analysed does + nothing, because the database wins. Delete its `.i64` from the sidecar to + reload with new options. + ## Decisions - Residency is a **memory budget** (default 25% of RAM, `memory_pct`), not a diff --git a/idatui/app.py b/idatui/app.py index ce2c315..7f00f92 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -3162,7 +3162,7 @@ class IdaTui(App): def __init__(self, open_path: str | None = None, keepalive: bool = True, rpc_path: str | None = None, ttl: int = 1800, - project=None) -> None: + project=None, load_args: str = "") -> None: super().__init__() # Project mode is additive: with no project this is the plain # single-binary app, unchanged. @@ -3188,6 +3188,7 @@ class IdaTui(App): open_path = project.refs[0].staged self._open_path = open_path self._ttl = ttl + self._load_args = load_args or "" # IDA switches for a headerless blob self._do_keepalive = keepalive self._rpc_path = rpc_path self._rpc = None @@ -3390,7 +3391,8 @@ class IdaTui(App): self.app.call_from_thread(self._reconnect_failed, "no binary to reopen") return - client = WorkerClient(self._open_path, ttl=self._ttl) + client = WorkerClient(self._open_path, ttl=self._ttl, + load_args=self._load_args) client.connect(progress=lambda m: self.app.call_from_thread( self._conn_note, m)) except Exception as e: # noqa: BLE001 @@ -3456,7 +3458,8 @@ class IdaTui(App): base = os.path.basename(self._open_path) self.app.call_from_thread( self._status, f"starting worker — initial auto-analysis of {base}…") - client = WorkerClient(self._open_path, ttl=self._ttl) + client = WorkerClient(self._open_path, ttl=self._ttl, + load_args=self._load_args) client.connect(progress=lambda m: self.app.call_from_thread( self._status, m)) return client diff --git a/idatui/launch.py b/idatui/launch.py index 9618668..b28559e 100644 --- a/idatui/launch.py +++ b/idatui/launch.py @@ -23,6 +23,18 @@ import sys _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) + + def _log(msg: str) -> None: print(f"ida-tui: {msg}", file=sys.stderr) @@ -57,8 +69,37 @@ def main(argv: list[str] | None = None) -> int: help="do not run the keepalive heartbeat") p.add_argument("--rpc", metavar="PATH", help="listen for RPC on this unix socket (puppeteer the TUI)") + g = p.add_argument_group( + "loading a headerless blob", + "An ELF/PE/Mach-O says what it is. A raw firmware dump doesn't, and IDA " + "falls back to x86 at address 0 — which analyses to nothing. These say " + "how to read it, and are recorded per binary in a project.") + g.add_argument("--processor", metavar="NAME", + help="IDA processor: arm, armb (big-endian), mipsb, metapc, …") + g.add_argument("--base", metavar="ADDR", + help="load address, e.g. 0x8000000 (any base; NOT paragraphs)") + g.add_argument("--ida-args", metavar="STR", dest="ida_args", + help="extra IDA command-line switches, passed through as-is") args = p.parse_args(argv) + load: dict = {} + if args.processor: + load["processor"] = args.processor + if args.base: + try: + base = int(args.base, 0) + except ValueError: + _log(f"--base must be a number (got {args.base!r})") + return 2 + if base % 16: + # -b is in paragraphs, so an unaligned base cannot be expressed and + # would silently load somewhere else. + _log(f"--base must be 16-byte aligned (got {base:#x})") + return 2 + load["base"] = base + if args.ida_args: + load["ida_args"] = args.ida_args + project = None binary = None if args.project: @@ -70,7 +111,7 @@ def main(argv: list[str] | None = None) -> int: if args.binary: # extend an existing project, skipping repeats before = len(project.refs) for b in args.binary: - project.add(b) + project.add(b, load=load or None) added = len(project.refs) - before dupes = len(args.binary) - added if added: @@ -80,7 +121,7 @@ def main(argv: list[str] | None = None) -> int: _log(f"{dupes} already in the project (matched by path) " f"— left alone") elif args.binary: - project = Project.create(ppath, args.binary) + project = Project.create(ppath, args.binary, load=load or None) _log(f"created project {ppath} with {len(project.refs)} binaries") else: _log(f"no such project: {ppath} (pass binaries to create it)") @@ -120,7 +161,8 @@ def main(argv: list[str] | None = None) -> int: return 1 rpc_path = os.path.abspath(os.path.expanduser(args.rpc)) if args.rpc else None IdaTui(open_path=binary, keepalive=not args.no_keepalive, - rpc_path=rpc_path, ttl=args.ttl, project=project).run() + rpc_path=rpc_path, ttl=args.ttl, project=project, + load_args=_load_args(load)).run() return 0 diff --git a/idatui/pool.py b/idatui/pool.py index 25ea879..ae37c25 100644 --- a/idatui/pool.py +++ b/idatui/pool.py @@ -56,7 +56,7 @@ def _pss_mb(pid: int | None) -> int: def _default_spawn(ref: BinaryRef, ttl: int): # pragma: no cover - needs idalib from .worker_client import WorkerClient - return WorkerClient(ref.staged, ttl=ttl) + return WorkerClient(ref.staged, ttl=ttl, load_args=ref.load_args) class WorkerPool: diff --git a/idatui/project.py b/idatui/project.py index ac4f033..249ccd6 100644 --- a/idatui/project.py +++ b/idatui/project.py @@ -52,12 +52,50 @@ class BinaryRef: label: str # unique within the project; names the staged file source: str # absolute path to the original binary staged: str # absolute path IDA actually opens (inside the sidecar) + #: How to LOAD it. Only meaningful for a headerless blob: an ELF/PE says what + #: it is, a raw firmware image doesn't, and IDA defaults to metapc at 0. + processor: str = "" # IDA processor name: arm, armb, mipsb, metapc, … + base: int = 0 # load address (natural, e.g. 0x8000000) + ida_args: str = "" # escape hatch: extra IDA command-line switches @property def db(self) -> str: """The database IDA creates for the staged file.""" return self.staged + ".i64" + @property + def load_args(self) -> str: + """``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. + """ + 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) + + +def _as_addr(v) -> int: + """A load address from JSON: int, or a string in any base ("0x8000000"). + + Addresses are written by hand in a project file, so accept how people write + them rather than demanding decimal. + """ + if v is None or v == "": + return 0 + if isinstance(v, int): + return v + try: + return int(str(v), 0) + except ValueError: + return 0 + def _stat_key(path: str) -> tuple[int, int] | None: """(size, mtime) identity used to spot a source that changed under us.""" @@ -109,7 +147,12 @@ class Project: e = {"path": e} if not isinstance(e, dict) or not e.get("path"): raise ProjectError(f"project {path}: bad binary entry {e!r}") - norm.append({k: e[k] for k in ("path", "label") if e.get(k)}) + # Keep every recognised key: a whitelist of path/label silently + # dropped the load options on the first save, so a blob's processor + # and base vanished the moment the project was reopened. + norm.append({k: e[k] for k in + ("path", "label", "processor", "base", "ida_args") + if e.get(k) not in (None, "")}) name = raw.get("name") or os.path.splitext(os.path.basename(path))[0] try: pct = int(raw.get("memory_pct", DEFAULT_MEMORY_PCT)) @@ -119,8 +162,13 @@ class Project: @classmethod def create(cls, path: str, binaries: list[str], name: str | None = None, - memory_pct: int = DEFAULT_MEMORY_PCT) -> "Project": - """Write a new project file listing ``binaries`` (an ad-hoc project).""" + memory_pct: int = DEFAULT_MEMORY_PCT, load: dict | None = None) -> "Project": + """Write a new project file listing ``binaries`` (an ad-hoc project). + + ``load`` carries per-binary load options (processor/base/ida_args) that + apply to every binary given here — a headerless blob needs them, and one + command line normally adds blobs of the same kind. + """ if not binaries: raise ProjectError("a project needs at least one binary") entries, seen = [], set() @@ -130,7 +178,9 @@ class Project: if key in seen: continue seen.add(key) - entries.append({"path": p}) + e = {"path": p} + e.update({k: v for k, v in (load or {}).items() if v}) + entries.append(e) path = os.path.abspath(os.path.expanduser(path)) proj = cls(path, name or os.path.splitext(os.path.basename(path))[0], entries, memory_pct) @@ -183,8 +233,12 @@ class Project: n += 1 label = f"{label}_{n}" used.add(label) - refs.append(BinaryRef(label=label, source=src, - staged=os.path.join(self.bin_dir, label))) + refs.append(BinaryRef( + label=label, source=src, + staged=os.path.join(self.bin_dir, label), + processor=str(e.get("processor") or ""), + base=_as_addr(e.get("base")), + ida_args=str(e.get("ida_args") or ""))) return tuple(refs) @property @@ -206,7 +260,8 @@ class Project: return next((r for r in self._refs if os.path.realpath(r.source) == key), None) - def add(self, binary: str, label: str | None = None) -> BinaryRef: + def add(self, binary: str, label: str | None = None, + load: dict | None = None) -> BinaryRef: """Add a binary, or return the existing entry if it's already here.""" existing = self.by_source(binary) if existing is not None: @@ -214,6 +269,7 @@ class Project: entry = {"path": os.path.abspath(os.path.expanduser(binary))} if label: entry["label"] = label + entry.update({k: v for k, v in (load or {}).items() if v}) self._entries.append(entry) self._refs = self._build_refs() return self._refs[-1] diff --git a/idatui/worker.py b/idatui/worker.py index bfefa4a..26f0816 100644 --- a/idatui/worker.py +++ b/idatui/worker.py @@ -78,13 +78,39 @@ def _ensure_tools_injected() -> None: sys.stderr.write(f"idatui: tool injection skipped: {e}\n") -def _open_and_register(binpath: str): +def _has_database(binpath: str) -> bool: + """Whether IDA already has a database for ``binpath``. + + IDA names it ``<file>.i64`` (keeping the extension), but a database made + from ``foo.bin`` can also appear as ``foo.i64`` depending on how it was + created — check both, because guessing wrong here means re-passing load + switches to an existing database, which fails the open. + """ + return (os.path.exists(binpath + ".i64") + or os.path.exists(os.path.splitext(binpath)[0] + ".i64")) + + +def _open_and_register(binpath: str, load_args: str = ""): """Open the DB (main thread) then import ida-pro-mcp so every @tool registers - against this live database. Returns (tools_dict, module_name, save_fn).""" + against this live database. Returns (tools_dict, module_name, save_fn). + + ``load_args`` is passed to IDA as command-line switches, which is the only + way to tell it how to read a headerless blob: a raw firmware image has no + format to detect, so without ``-p<processor>`` it loads as metapc at 0 and + finds nothing. Ignored once a database exists — the .i64 already records how + it was loaded, and re-passing conflicting switches is how you corrupt one. + """ _ensure_tools_injected() # before any ida_pro_mcp import import idapro idapro.enable_console_messages(False) - if idapro.open_database(binpath, run_auto_analysis=True): # nonzero == failure + args = load_args or None + if args and _has_database(binpath): + # The .i64 already records how this image was loaded. Passing the + # switches again on reopen makes IDA fail outright (rc != 0) — the load + # options belong to the FIRST open only. + args = None + if idapro.open_database(binpath, run_auto_analysis=True, + args=args): # nonzero == failure raise RuntimeError( f"failed to open {binpath}: the .i64 is likely held by a running " f"ida-mcp worker (try: pkill -f idalib) or wedged from a crash " @@ -109,8 +135,8 @@ def _open_and_register(binpath: str): return MCP_SERVER.tools.methods, module, save -def serve(sockpath: str, binpath: str) -> None: - tools, module, save = _open_and_register(binpath) +def serve(sockpath: str, binpath: str, load_args: str = "") -> None: + tools, module, save = _open_and_register(binpath, load_args) sid = uuid.uuid4().hex[:8] def dispatch(name: str, args: dict): @@ -179,10 +205,11 @@ def serve(sockpath: str, binpath: str) -> None: def main(argv=None) -> None: argv = argv if argv is not None else sys.argv[1:] if len(argv) < 2: - sys.stderr.write("usage: python -m idatui.worker <sock> <binary>\n") + sys.stderr.write( + "usage: python -m idatui.worker <sock> <binary> [ida-load-args]\n") raise SystemExit(2) try: - serve(argv[0], argv[1]) + serve(argv[0], argv[1], argv[2] if len(argv) > 2 else "") except SystemExit: raise except BaseException as e: # noqa: BLE001 -- surface a clean cause + code 1 diff --git a/idatui/worker_client.py b/idatui/worker_client.py index 586ff21..79a6db9 100644 --- a/idatui/worker_client.py +++ b/idatui/worker_client.py @@ -71,8 +71,9 @@ class _NoopKeepAlive: class WorkerClient: def __init__(self, binary_path: str, *, ttl: int = 0, - python: str | None = None) -> None: + python: str | None = None, load_args: str = "") -> None: self._bin = os.path.abspath(os.path.expanduser(binary_path)) + self._load_args = load_args or "" # IDA switches for a headerless blob self._python = python or _find_worker_python() tag = f"{os.getpid()}-{uuid.uuid4().hex[:8]}" self._sock_path = f"/tmp/idatui-worker-{tag}.sock" @@ -93,8 +94,11 @@ class WorkerClient: # run worker.py as a SCRIPT (not -m idatui.worker) so we don't # import the textual-dependent idatui package __init__ under the # IDA python, which usually has no textual. + argv = [self._python, _WORKER_PY, self._sock_path, self._bin] + if self._load_args: + argv.append(self._load_args) self._proc = subprocess.Popen( - [self._python, _WORKER_PY, self._sock_path, self._bin], + argv, stdout=open(self._log_path, "wb"), stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, ) diff --git a/tests/test_project.py b/tests/test_project.py index be46875..91fd250 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -185,6 +185,46 @@ def main() -> int: except ProjectError: check("staging a missing binary raises ProjectError", True) + # -- load options for headerless blobs --------------------------------- # + with tempfile.TemporaryDirectory() as tmp: + src = os.path.join(tmp, "src"); os.makedirs(src) + blob = os.path.join(src, "fw.bin") + with open(blob, "wb") as f: + f.write(b"\x00" * 64) + proj = Project.create(os.path.join(tmp, "p.json"), [blob], name="p", + load={"processor": "arm", "base": 0x8000000}) + r = proj.refs[0] + check("create() records load options per binary", + r.processor == "arm" and r.base == 0x8000000, + f"proc={r.processor!r} base={r.base:#x}") + # -b is PARAGRAPHS: 0x8000000 >> 4 == 0x800000. Getting this wrong loads + # the image 16x too high and every address in the database is wrong. + check("base is converted to IDA's paragraph units", + r.load_args == "-parm -b800000", r.load_args) + + proj2 = Project.load(proj.path) + check("load options survive a round-trip through the file", + proj2.refs[0].load_args == "-parm -b800000", + proj2.refs[0].load_args) + + blob2 = os.path.join(src, "other.bin") + with open(blob2, "wb") as f: + f.write(b"\x00" * 64) + r2 = proj2.add(blob2, load={"processor": "mipsb"}) + check("add() takes load options too", + r2.load_args == "-pmipsb", r2.load_args) + + # a normal ELF needs none of this and must pass nothing + check("a binary with no load options passes no switches", + Project.create(os.path.join(tmp, "q.json"), [blob], + name="q").refs[0].load_args == "") + + # addresses get written by hand, so accept how people write them + proj3 = Project.create(os.path.join(tmp, "r.json"), [blob], name="r", + load={"base": "0x1000"}) + check("a base given as a hex STRING is parsed", + proj3.refs[0].base == 0x1000, f"{proj3.refs[0].base}") + print(f"\n{PASS} passed, {FAIL} failed") return 1 if FAIL else 0 |
