aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/launch.py
diff options
context:
space:
mode:
Diffstat (limited to 'idatui/launch.py')
-rw-r--r--idatui/launch.py181
1 files changed, 181 insertions, 0 deletions
diff --git a/idatui/launch.py b/idatui/launch.py
new file mode 100644
index 0000000..1f9c51c
--- /dev/null
+++ b/idatui/launch.py
@@ -0,0 +1,181 @@
+"""One-shot launcher: ``ida-tui foo.elf`` and you're in the TUI.
+
+Spawns a private idalib worker (``idatui.worker``) that opens + auto-analyzes
+THIS binary in its own process, talking to the TUI over a unix socket. No shared
+supervisor, no HTTP: everything slow (open + analysis) happens behind the TUI's
+loading overlay.
+
+Usage:
+
+ ida-tui /path/to/binary # open a binary and drive it
+
+Extras: --ttl, --no-keepalive, --rpc (all forwarded to the TUI).
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import sys
+
+# The unpacked working-copy files IDA writes next to a `.i64` while a database is
+# open. A hard-killed worker leaves them behind and the `.i64` then refuses to
+# reopen ("Failed to open database"). Safe to delete when nothing holds the DB.
+_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)."""
+ 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:
+ print(f"ida-tui: {msg}", file=sys.stderr)
+
+
+def _sweep_locks(binary: str) -> int:
+ """Remove stale unpacked DB files next to ``binary``. Returns how many."""
+ stem = os.path.splitext(binary)[0]
+ n = 0
+ for base in (binary, stem): # IDA may key on the full name or the stem
+ for suf in _LOCK_SUFFIXES:
+ try:
+ os.remove(base + suf)
+ n += 1
+ except OSError:
+ pass
+ return n
+
+
+def main(argv: list[str] | None = None) -> int:
+ p = argparse.ArgumentParser(
+ prog="ida-tui",
+ description="Open a binary in the IDA TUI (private idalib worker).")
+ p.add_argument("binary", nargs="*",
+ help="binary to open and analyze (several with --project "
+ "creates/extends that project)")
+ p.add_argument("--project", metavar="FILE",
+ help="open a multi-binary project (created from the given "
+ "binaries if FILE doesn't exist)")
+ p.add_argument("--ttl", type=int, default=1800,
+ help="worker idle-TTL seconds (default 1800)")
+ p.add_argument("--no-keepalive", action="store_true",
+ help="do not run the keepalive heartbeat")
+ p.add_argument("--rpc", metavar="PATH",
+ help="listen for RPC on this unix socket (puppeteer the TUI)")
+ p.add_argument("--trace", metavar="FILE",
+ help="Tenet execution trace to explore alongside the binary")
+ 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:
+ from .project import Project, ProjectError
+ ppath = os.path.abspath(os.path.expanduser(args.project))
+ try:
+ if os.path.isfile(ppath):
+ project = Project.load(ppath)
+ if args.binary: # extend an existing project, skipping repeats
+ before = len(project.refs)
+ for b in args.binary:
+ project.add(b, load=load or None)
+ added = len(project.refs) - before
+ dupes = len(args.binary) - added
+ if added:
+ project.save()
+ _log(f"added {added} binary(ies) to {ppath}")
+ if dupes:
+ _log(f"{dupes} already in the project (matched by path) "
+ f"— left alone")
+ elif 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)")
+ return 2
+ except ProjectError as e:
+ _log(str(e))
+ return 2
+ # Everything IDA writes lives in the project's sidecar, so the source
+ # tree is never touched and never needs to be writable.
+ try:
+ project.stage_all(progress=lambda m: _log(m))
+ except ProjectError as e:
+ _log(str(e))
+ return 2
+ else:
+ if len(args.binary) != 1:
+ _log("give exactly one binary, or use --project for several")
+ return 2
+ binary = os.path.abspath(os.path.expanduser(args.binary[0]))
+ if not os.path.isfile(binary):
+ _log(f"no such file: {binary}")
+ return 2
+ if not os.access(os.path.dirname(binary), os.W_OK):
+ _log(f"directory not writable (IDA writes a .i64 there): "
+ f"{os.path.dirname(binary)}")
+ return 2
+ swept = _sweep_locks(binary) # a crashed worker can leave the DB wedged
+ if swept:
+ _log(f"cleared {swept} stale lock file(s) from a crashed worker")
+
+ # Hand off to the TUI (imported late so --help works without textual). It
+ # spawns the worker behind its loading overlay while auto-analysis runs.
+ try:
+ from .app import IdaTui
+ except ImportError as e:
+ _log(f"the TUI needs textual; run with ~/ida-venv/bin/python ({e})")
+ return 1
+ # Ask the terminal about graphics support NOW: the query needs a reply from
+ # stdin, and once Textual starts it reads stdin on its own thread and would
+ # swallow it. Only the ANSWER is wanted here -- the image itself is uploaded
+ # later, by the splash, because an image uploaded to the primary screen
+ # cannot be placed once Textual has switched to the alternate one. Costs one
+ # round trip, and only when attached to a tty.
+ try:
+ from . import kittygfx
+ kittygfx.supported()
+ except Exception: # noqa: BLE001 -- graphics are decoration, never fatal
+ pass
+
+ 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,
+ load_args=_load_args(load),
+ trace_path=(os.path.abspath(os.path.expanduser(args.trace))
+ if args.trace else "")).run()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())