aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-25 23:49:14 +0200
committerblasty <blasty@local>2026-07-25 23:49:14 +0200
commit57b09047881d4bb42b2b98e4bb0739d5bc5e5af8 (patch)
treef55ca12add86873a875fe70cf318524fca694d95
parentxrefs: show project binaries that import this export (diff)
downloadida-tui-57b09047881d4bb42b2b98e4bb0739d5bc5e5af8.tar.gz
ida-tui-57b09047881d4bb42b2b98e4bb0739d5bc5e5af8.tar.xz
ida-tui-57b09047881d4bb42b2b98e4bb0739d5bc5e5af8.zip
loading: say how to read a headerless blob (processor, base)
A raw firmware dump has no format to detect, so IDA fell back to x86 at address 0. It doesn't fail — it opens, analyses, and finds nothing. An AArch64 image loaded this way gave 0 functions; told the truth it gives 35. ida-tui fw.bin --processor arm --base 0x8000000 and per binary in a project, which is what a multi-image firmware actually needs: {"path": "app.bin", "processor": "arm", "base": "0x8000000"} idapro.open_database() already accepted IDA command-line switches; nothing was passing any. Plumbed BinaryRef -> WorkerPool -> WorkerClient -> worker argv, plus a load_args for the single-binary path that has no project ref. base is written the way people say it (0x8000000, int or string, any base). IDA's -b is in PARAGRAPHS — -b1000 loads at 0x10000 — so BinaryRef.load_args converts, and a base that isn't 16-byte aligned is refused rather than silently landing 16x off. ida_args passes anything else through. Two bugs found by testing the whole path rather than the happy one: * Project.load() whitelisted path/label when normalising entries, so the load options were dropped the first time a project was reopened — set a processor, come back tomorrow, it's gone. * Re-passing the switches to an EXISTING database makes IDA refuse the open (rc != 0, no functions). The .i64 already records how the image was loaded, so the worker skips them once a database exists. My first guard checked splitext(path) + ".i64" and never fired, because IDA names it "<file>.i64" — keeping the extension. It checks both spellings now. Verified on a real AArch64 blob: fresh load 35 functions based at 0x8002440, reopen 35 again, and the CLI rejects an unaligned or non-numeric --base. tests: +6 project (options recorded, paragraph conversion, file round-trip, add() takes them, an ELF passes nothing, hex-string base). 39/0 project, 195/0 scenarios, 30/0 project UI, 36/0 index, 27/0 pool.
-rw-r--r--README.md13
-rw-r--r--docs/PROJECTS.md31
-rw-r--r--idatui/app.py9
-rw-r--r--idatui/launch.py48
-rw-r--r--idatui/pool.py2
-rw-r--r--idatui/project.py70
-rw-r--r--idatui/worker.py41
-rw-r--r--idatui/worker_client.py8
-rw-r--r--tests/test_project.py40
9 files changed, 239 insertions, 23 deletions
diff --git a/README.md b/README.md
index eb9f05b..8fc59fd 100644
--- a/README.md
+++ b/README.md
@@ -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