aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-26 15:04:51 +0200
committerblasty <blasty@local>2026-07-26 15:04:51 +0200
commit2c2124aff2f4ed6df23512603b6e1ecdcd4b699b (patch)
tree2b2c2afe69e7f33e87ccb58e3bc466a94a4e689e
parentarm: switch ARM/Thumb decoding with `t` (diff)
downloadida-tui-2c2124aff2f4ed6df23512603b6e1ecdcd4b699b.tar.gz
ida-tui-2c2124aff2f4ed6df23512603b6e1ecdcd4b699b.tar.xz
ida-tui-2c2124aff2f4ed6df23512603b6e1ecdcd4b699b.zip
arm: offer 32-bit ARM at load, and fix `p` on carved code
Reported as "after c a few times and p at the entry point, Tab just flashes and nothing decompiles". Three separate things, found by following it down: **1. `p` failed on hand-carved code.** ida_funcs.add_func(ea) asks IDA to find the function's end and on carved code it often can't — a run ending in a tail call, or whose last instruction isn't recognised as a return, fails with no reason given. add_func(ea, end) with an explicit end succeeds. define_func_run tries IDA's way first, then falls back to the end of the contiguous instruction run, and says which it used. **2. The database was 64-bit, so Hex-Rays refused it regardless.** Bare `-parm` gives an AArch64 database. Ask Hex-Rays for the failure object rather than reading None as "dunno" and it says exactly what's wrong: "only 64-bit functions can be decompiled in the current database". So the disassembly looked right and F5 could never work. That is decided at LOAD and cannot be corrected — inf_set_app_bitness(32) afterwards makes the decompiler INTERR 50735. The fix is at the load dialog: arm:ARMv7-A (most firmware), arm:ARMv7-M / arm:ARMv6-M (Cortex-M, Thumb only) and arm:ARMv5TE now sit alongside 64-bit `arm`, labelled with their bitness. With arm:ARMv7-A, experiments/fibonacci.bin decompiles: void __fastcall __noreturn sub_0(int a1) { int v2; v2 = sub_E3C(a1, 0); ... } — and IDA's own auto-analysis finds 54 Thumb functions on load, versus none as plain `arm`. **3. `t` was silently building an undecompilable state.** It forced the SEGMENT to 32-bit in a 64-bit database, which produces correct-looking disassembly that F5 will never touch. It now says so and names the fix (Ctrl+L, arm:ARMv7-A) rather than leaving you to discover it. tools/verify_procs.py now reports each processor's resulting bitness, since that is the reason the variants exist — and it compares against the base module name, because a variant reports "ARM". tests: test_thumb_ui.py +5 (13 total) — a 64-bit database warns and names the fix, a 32-bit one finds functions by itself, Tab decompiles a Thumb function and the result reads like C. test_formats.py +2 (34) pinning that a 32-bit variant is offered and the ARM labels state their bitness. 209/0 scenarios, 26/0 blob, 30/0 project UI.
-rw-r--r--docs/PROJECTS.md18
-rw-r--r--idatui/app.py11
-rw-r--r--idatui/domain.py24
-rw-r--r--idatui/formats.py15
-rw-r--r--server/patch_server.py61
-rw-r--r--tests/test_formats.py18
-rw-r--r--tests/test_thumb_ui.py63
-rw-r--r--tools/verify_procs.py13
8 files changed, 208 insertions, 15 deletions
diff --git a/docs/PROJECTS.md b/docs/PROJECTS.md
index 38d3262..eae860b 100644
--- a/docs/PROJECTS.md
+++ b/docs/PROJECTS.md
@@ -317,6 +317,24 @@ ARM32.
`t` again toggles back — the mode is a guess, and guesses get revised. Both
states are saved in the `.i64`.
+**Pick a 32-bit processor at load, or none of this decompiles.** Bare `arm`
+gives a 64-BIT database (AArch64), and that is decided at load time and cannot be
+changed afterwards — setting the bitness post-hoc makes the decompiler INTERR.
+In a 64-bit database:
+
+* Thumb doesn't exist, so `t` sets a segment flag that means nothing; and
+* Hex-Rays refuses a 32-bit function outright — *"only 64-bit functions can be
+ decompiled in the current database"* — so the disassembly looks right and F5
+ silently produces nothing.
+
+So the list offers `arm:ARMv7-A` (most firmware), `arm:ARMv7-M` / `arm:ARMv6-M`
+(Cortex-M, Thumb only) and `arm:ARMv5TE` alongside 64-bit `arm`. `t` warns when
+it notices the database is 64-bit and points at `Ctrl+L`.
+
+Worth knowing: a correctly-chosen 32-bit ARM database also lets IDA's own
+auto-analysis find Thumb functions — `experiments/fibonacci.bin` yields 54
+functions on load with `arm:ARMv7-A` and none with `arm`.
+
### When analysis finds nothing
Zero functions is what a raw image described wrongly looks like — right file,
diff --git a/idatui/app.py b/idatui/app.py
index ff67eef..ec55911 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -5191,13 +5191,22 @@ class IdaTui(App):
verb = f"{mode} @ {ea:#x}"
if r.get("forced_32bit"):
verb += " (segment set to 32-bit; Thumb needs ARM32)"
+ if r.get("db_64bit"):
+ # Disassembly will look right and F5 will never work.
+ verb += (" \u26a0 this database is 64-bit, so Hex-Rays "
+ "won't decompile it \u2014 Ctrl+L and pick "
+ "arm:ARMv7-A")
verb += (f" \u2014 {n} instruction{'s' if n != 1 else ''}"
if n else " \u2014 still doesn't decode")
# falls through to the shared reload: same cache bump, same
# anchor restore, same flash. That is the whole point of having
# one path.
elif kind == "func":
- self.program.define_func(ea)
+ r = self.program.define_func(ea)
+ if r.get("start") and r.get("end"):
+ verb = (f"created function {r['start']}\u2013{r['end']}"
+ + (" (end worked out from the code)"
+ if r.get("how") == "explicit-end" else ""))
elif kind == "string":
s = self.program.make_string(ea)
verb = f"made string ({s[:24]!r})" if s else verb
diff --git a/idatui/domain.py b/idatui/domain.py
index 8787431..9047d82 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -1397,12 +1397,24 @@ class Program:
f"@ {ea:#x}: {(r or {}).get('error', 'failed')}")
return r
- def define_func(self, ea: int) -> None:
- """Create a function starting at ``ea`` (IDA's 'p')."""
- res = self._first_result(
- self.client.call("define_func", items=[{"addr": hex(ea)}]))
- if res.get("error"):
- raise IDAToolError("define_func", f"@ {ea:#x}: {res['error']}")
+ def define_func(self, ea: int) -> dict:
+ """Create a function starting at ``ea`` (IDA's 'p').
+
+ Prefers the injected tool, which works out the end when IDA can't;
+ falls back to the plain one for an older worker.
+ """
+ try:
+ r = self.client.call("define_func_run", addr=hex(ea))
+ except IDAToolError:
+ res = self._first_result(
+ self.client.call("define_func", items=[{"addr": hex(ea)}]))
+ if res.get("error"):
+ raise IDAToolError("define_func", f"@ {ea:#x}: {res['error']}")
+ return {"ok": True, "how": "legacy"}
+ if not isinstance(r, dict) or not r.get("ok"):
+ raise IDAToolError("define_func",
+ f"@ {ea:#x}: {(r or {}).get('error', 'failed')}")
+ return r
def undefine(self, ea: int, size: int | None = None) -> None:
"""Undefine the item at ``ea`` back to raw bytes (IDA's 'u')."""
diff --git a/idatui/formats.py b/idatui/formats.py
index b2f784d..f09bb99 100644
--- a/idatui/formats.py
+++ b/idatui/formats.py
@@ -92,9 +92,18 @@ def needs_load_options(path: str) -> bool:
#: reach for first — arm64, aarch64, mips, m68k — are all invalid. Re-run the
#: script before adding to this list.
PROCESSORS: tuple[tuple[str, str], ...] = (
- # 'arm' covers AArch64 too; arm64/aarch64 are NOT valid -p names, so they
- # live in the label where the filter can still find them.
- ("arm", "ARM / AArch64 / arm64 — little-endian"),
+ # Bare 'arm' gives a 64-BIT database in IDA 9 (AArch64). That matters far
+ # more than it looks: Hex-Rays refuses a 32-bit function in a 64-bit database
+ # ("only 64-bit functions can be decompiled in the current database"), and
+ # Thumb doesn't exist in AArch64 at all — so a 32-bit ARM image loaded as
+ # plain 'arm' disassembles wrongly and can never be decompiled. The database
+ # bitness is fixed at load; it cannot be corrected afterwards (setting it
+ # post-hoc makes the decompiler INTERR). Pick the right one here.
+ ("arm", "ARM64 / AArch64 / arm64 — 64-bit, little-endian"),
+ ("arm:ARMv7-A", "ARM 32-bit (ARMv7-A) — Thumb capable, most firmware"),
+ ("arm:ARMv7-M", "ARM 32-bit (ARMv7-M) — Cortex-M, Thumb only"),
+ ("arm:ARMv6-M", "ARM 32-bit (ARMv6-M) — Cortex-M0/M0+"),
+ ("arm:ARMv5TE", "ARM 32-bit (ARMv5TE) — older SoCs"),
("armb", "ARM — big-endian"),
("metapc", "x86 / x86-64"),
("mipsl", "MIPS — little-endian"),
diff --git a/server/patch_server.py b/server/patch_server.py
index e40310a..ee7600a 100644
--- a/server/patch_server.py
+++ b/server/patch_server.py
@@ -1002,6 +1002,8 @@ def set_thumb(
if not seg:
return {"addr": hex(ea), "error": "no segment"}
+ import ida_ida
+ db64 = ida_ida.inf_get_app_bitness() == 64
cur = ida_segregs.get_sreg(ea, treg)
cur = 0 if cur in (None, 0xFFFFFFFF, -1) else int(cur)
want = {"on": 1, "off": 0}.get(str(mode).lower(), 0 if cur else 1)
@@ -1023,7 +1025,64 @@ def set_thumb(
now = ida_segregs.get_sreg(ea, treg)
return {"addr": hex(ea), "thumb": bool(now), "was": bool(cur), "ok": ok,
"bitness": ida_segment.getseg(ea).bitness,
- "forced_32bit": changed_bits}
+ "forced_32bit": changed_bits,
+ # The DATABASE's bitness is fixed at load and can't be corrected
+ # here (setting it post-hoc makes the decompiler INTERR). In a
+ # 64-bit database a 32-bit function disassembles but Hex-Rays
+ # refuses it outright, so say so instead of leaving the user to
+ # discover that F5 does nothing.
+ "db_64bit": bool(db64 and want)}
+
+@tool
+@idasync
+def define_func_run(
+ addr: Annotated[str, "Entry point of the function to create"],
+) -> dict:
+ """Create a function at ``addr``, working out its end if IDA can't.
+
+ ida_funcs.add_func(ea) asks IDA to find the end itself, and on hand-carved
+ code it often can't — a run that ends in a tail call, or whose last
+ instruction isn't recognised as a return, simply fails with no reason given.
+ You then have a disassembled routine that refuses to become a function, and
+ F5 has nothing to work with.
+
+ So: try IDA's way, and if that fails, use the end of the contiguous
+ instruction run starting at ``addr``."""
+ import ida_bytes
+ import ida_funcs
+ import ida_segment
+ import idaapi
+
+ try:
+ ea = parse_address(addr)
+ except Exception as e:
+ return {"addr": str(addr), "error": str(e), "ok": False}
+ fn = idaapi.get_func(ea)
+ if fn is not None and fn.start_ea == ea:
+ return {"addr": hex(ea), "ok": True, "start": hex(fn.start_ea),
+ "end": hex(fn.end_ea), "how": "existed"}
+ if ida_funcs.add_func(ea):
+ f = idaapi.get_func(ea)
+ return {"addr": hex(ea), "ok": True, "start": hex(f.start_ea),
+ "end": hex(f.end_ea), "how": "auto"}
+
+ seg = ida_segment.getseg(ea)
+ hi = seg.end_ea if seg else ea
+ end = ea
+ while end < hi and ida_bytes.is_code(ida_bytes.get_flags(end)):
+ nxt = ida_bytes.get_item_end(end)
+ if nxt <= end:
+ break
+ end = nxt
+ if end <= ea:
+ return {"addr": hex(ea), "ok": False,
+ "error": "no code here to make a function from"}
+ if not ida_funcs.add_func(ea, end):
+ return {"addr": hex(ea), "ok": False,
+ "error": f"IDA refused a function at {ea:#x}..{end:#x}"}
+ f = idaapi.get_func(ea)
+ return {"addr": hex(ea), "ok": True, "start": hex(f.start_ea),
+ "end": hex(f.end_ea), "how": "explicit-end"}
'''
SNIPPET = f"{BEGIN}\n{BODY.strip()}\n{END}\n"
diff --git a/tests/test_formats.py b/tests/test_formats.py
index ab72e8d..1045703 100644
--- a/tests/test_formats.py
+++ b/tests/test_formats.py
@@ -77,9 +77,22 @@ def main() -> int:
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"],
+ [n for n, _ in PROCESSORS[:2]] == ["arm", "arm:ARMv7-A"],
f"{[n for n, _ in PROCESSORS[:3]]}")
+ # 32-bit ARM has to be offered SEPARATELY from bare 'arm', which gives a
+ # 64-bit database. That isn't cosmetic: Hex-Rays refuses a 32-bit function
+ # in a 64-bit database, and Thumb doesn't exist in AArch64 at all, so a
+ # firmware image loaded as plain 'arm' can never be decompiled — and the
+ # database's bitness cannot be corrected after load.
+ check("a 32-bit ARM variant is offered",
+ any(n.startswith("arm:ARMv") for n, _ in PROCESSORS),
+ f"{[n for n, _ in PROCESSORS if n.startswith('arm')]}")
+ check("and the labels say which is 32- vs 64-bit",
+ all(("32-bit" in d or "64-bit" in d)
+ for n, d in PROCESSORS if n == "arm" or n.startswith("arm:")),
+ f"{[(n, d) for n, d in PROCESSORS if n.startswith('arm')]}")
+
# Every offered name must have been checked against a real IDA, because a
# wrong one is REJECTED (rc=4) with nothing useful said — handing the user a
# dead end from inside the dialog meant to rescue them. This list is the
@@ -89,6 +102,9 @@ def main() -> int:
"arm", "armb", "metapc", "mipsl", "mipsb", "ppc", "ppcl", "sh4", "68k",
"riscv", "tricore", "xtensa", "avr", "z80", "tms320c6", "m32r", "arc",
"h8300", "sparcb", "sparcl", "s390",
+ # variants: tools/verify_procs.py checks these report the base module
+ # AND change the database bitness, which is the reason they exist
+ "arm:ARMv7-A", "arm:ARMv7-M", "arm:ARMv6-M", "arm:ARMv5TE",
}
offered = {n for n, _ in PROCESSORS}
check("every offered processor name is IDA-verified",
diff --git a/tests/test_thumb_ui.py b/tests/test_thumb_ui.py
index dce1635..090dc2a 100644
--- a/tests/test_thumb_ui.py
+++ b/tests/test_thumb_ui.py
@@ -17,7 +17,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from textual.widgets import Static # noqa: E402
-from idatui.app import IdaTui, ListingView # noqa: E402
+from idatui.app import DecompView, IdaTui, ListingView # noqa: E402
PASS = FAIL = 0
BIN = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
@@ -115,6 +115,67 @@ async def run() -> int:
status = str(app.query_one("#status", Static).render())
check("`t` toggles back to ARM", "ARM @" in status, status[:80])
+ # -- and the reason a carved function wouldn't decompile ---------------- #
+ # Bare 'arm' gives a 64-BIT database. Thumb doesn't exist there, and
+ # Hex-Rays refuses a 32-bit function outright ("only 64-bit functions can be
+ # decompiled in the current database") — so `t` produces correct-looking
+ # disassembly that F5 can never turn into pseudocode. The database's bitness
+ # is fixed at load and cannot be corrected afterwards, so the only honest
+ # thing is to say so.
+ for ext in (".i64", ".id0", ".id1", ".id2", ".nam", ".til"):
+ try:
+ os.remove(BIN + ext)
+ except OSError:
+ pass
+ app = IdaTui(open_path=BIN, keepalive=False, load_args="-parm") # 64-bit
+ async with app.run_test(size=(140, 44)) as pilot:
+ await wait(lambda: app._func_index is not None
+ and app._func_index.complete, pilot)
+ await wait(lambda: app._cur is not None, pilot, 60)
+ lst = app.query_one(ListingView)
+ lst.focus()
+ lst.cursor = lst.model.index_of_ea(0)
+ lst._scroll_cursor_into_view()
+ await pilot.pause(0.3)
+ m = lst.model
+ await pilot.press("t")
+ await wait(lambda: lst.model is not m and lst.model is not None, pilot, 60)
+ await pilot.pause(0.5)
+ status = str(app.query_one("#status", Static).render())
+ check("a 64-bit database warns that Hex-Rays won't decompile",
+ "64-bit" in status and "decompile" in status, status[:120])
+ check("and names the fix", "ARMv7-A" in status, status[:120])
+
+ # -- the whole point: a 32-bit database decompiles ---------------------- #
+ for ext in (".i64", ".id0", ".id1", ".id2", ".nam", ".til"):
+ try:
+ os.remove(BIN + ext)
+ except OSError:
+ pass
+ app = IdaTui(open_path=BIN, keepalive=False, load_args="-parm:ARMv7-A")
+ async with app.run_test(size=(140, 44)) as pilot:
+ await wait(lambda: app._func_index is not None
+ and app._func_index.complete, pilot)
+ # A 32-bit ARM database also lets auto-analysis do its job on Thumb code,
+ # which is why this one lands in the symbol picker rather than nowhere.
+ check("a 32-bit ARM database finds functions by itself",
+ len(app._func_index) > 5, f"n={len(app._func_index)}")
+ await pilot.press("escape")
+ await pilot.pause(0.5)
+ f = app._func_index.all_loaded()[0]
+ app._goto_ea(f.addr, push=True)
+ await wait(lambda: app._cur is not None
+ and app.query_one(ListingView).model is not None, pilot, 60)
+ app.query_one(ListingView).focus()
+ await pilot.press("tab")
+ dec = app.query_one(DecompView)
+ got = await wait(lambda: dec.display and dec._texts, pilot, 90)
+ check("Tab decompiles a Thumb function", got and len(dec._texts) > 3,
+ f"lines={len(dec._texts or [])}")
+ check("and it reads like C",
+ any("(" in t and ")" in t for t in (dec._texts or [])[:3]),
+ f"{(dec._texts or [])[:3]}")
+
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
diff --git a/tools/verify_procs.py b/tools/verify_procs.py
index 8012931..323bf1f 100644
--- a/tools/verify_procs.py
+++ b/tools/verify_procs.py
@@ -48,9 +48,18 @@ def main() -> int:
if rc == 0:
ida_auto.auto_wait()
got = ida_ida.inf_get_procname()
+ # "arm:ARMv7-A" selects a VARIANT: inf_get_procname() reports the base
+ # module ("ARM"), so compare that and check the bitness separately —
+ # the variant is only worth offering if it actually changes something.
+ base = name.split(":", 1)[0]
+ bits = ""
+ if rc == 0:
+ import ida_ida
+ bits = f" bitness={ida_ida.inf_get_app_bitness()}"
+ ok = rc == 0 and got.lower() == base.lower()
+ print(f" {'ok ' if ok else 'BAD '} {name:<14} rc={rc} -> {got!r}{bits} {desc}")
+ if rc == 0:
idapro.close_database(save=False)
- ok = rc == 0 and got.lower() == name.lower()
- print(f" {'ok ' if ok else 'BAD '} {name:<12} rc={rc} -> {got!r} {desc}")
if not ok:
bad.append((name, rc, got))