From 7bdf3741c91f76bfff3f3e054a5cf41b7f476d0b Mon Sep 17 00:00:00 2001 From: blasty Date: Sun, 26 Jul 2026 14:51:07 +0200 Subject: arm: switch ARM/Thumb decoding with `t` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `c` could not carve Thumb code. Thumb isn't a property of the bytes — it's a mode the CPU is in — so a raw image gives IDA nothing to detect: at a Thumb entry point it decodes 16-bit instructions as 32-bit ARM and produces confident nonsense. experiments/fibonacci.bin starts with `08 b5` = push {r3,lr}, which IDA reads as SVCLT 0xBF00. `t` on the listing switches the mode at the cursor and disassembles in it: Thumb @ 0x0 (segment set to 32-bit; Thumb needs ARM32) — 10 instructions 0x0 PUSH {R3,LR} 0x2 MOVS R1, #0 0x4 MOV R4, R0 0x6 BL unk_E3C Setting the T segment register is only half of it. Thumb does not exist in AArch64, and a headerless blob loaded with -parm comes up 64-bit, so T alone changes nothing and looks broken — I watched exactly that happen while probing the API. Asking for Thumb IS asking for ARM32, so set_thumb forces the segment to 32-bit and says so rather than doing it silently. It also has to del_items over the range first: the bytes are currently decoded in the old mode, and leaving that item defined pins the wrong instruction length so the new mode has nothing to apply to. Implemented as a `thumb` kind in the existing edit-item flow, so it inherits the shared reload — same cache bump, same ViewAnchor restore, same status flash. It switches AND disassembles, because flipping T and leaving the bytes undefined shows you nothing and reading the code was the point. tests: new tests/test_thumb_ui.py (8) driving the real Thumb binary — `c` alone does NOT produce the prologue, `t` does, the instructions are 16-bit wide (in ARM mode those three rows would be one 4-byte instruction), the run continues, the status explains the 32-bit forcing, and `t` toggles back. Deletes the .i64 first, because T and the segment's addressing mode are saved in it and a stale database would answer the question for us. 209/0 scenarios, 26/0 blob, 8/0 thumb. --- idatui/app.py | 23 ++++++++++++++++++++++- idatui/domain.py | 8 ++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) (limited to 'idatui') diff --git a/idatui/app.py b/idatui/app.py index 2190643..ff67eef 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -738,6 +738,7 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru Binding("a", "make_string", "Str", show=False), Binding("p", "define_func", "Func", show=False), Binding("u", "undefine", "Undef", show=False), + Binding("t", "toggle_thumb", "ARM/Thumb", show=False), *SearchMixin.SEARCH_BINDINGS, *NavMixin.NAV_BINDINGS, *ColumnCursor.COL_BINDINGS, @@ -1126,6 +1127,9 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru def action_undefine(self) -> None: self.post_message(EditItemRequested(self, "undef")) + def action_toggle_thumb(self) -> None: + self.post_message(EditItemRequested(self, "thumb")) + def action_make_data(self) -> None: self.post_message(MakeDataRequested(self)) @@ -5150,7 +5154,8 @@ class IdaTui(App): anchor: ViewAnchor | None = None) -> None: # worker context assert self.program is not None verb = {"code": "defined code", "func": "created function", - "undef": "undefined", "string": "made string"}[kind] + "undef": "undefined", "string": "made string", + "thumb": "switched decoding"}[kind] try: if kind == "code": # Keep going until something stops it: one instruction is rarely @@ -5175,6 +5180,22 @@ class IdaTui(App): "limit": "instruction limit"}.get(why, why) verb = (f"defined {n} instruction{'s' if n != 1 else ''} " f"({ea:#x}\u2013{end:#x}) \u2014 {reason}") + elif kind == "thumb": + # Switch the mode, then disassemble in it: flipping T and + # leaving the bytes undefined shows nothing, and the reason you + # flipped it was to read the code. + r = self.program.set_thumb(ea) + run = self.program.define_code_run(ea) + n = int(run.get("count", 0)) + mode = "Thumb" if r.get("thumb") else "ARM" + verb = f"{mode} @ {ea:#x}" + if r.get("forced_32bit"): + verb += " (segment set to 32-bit; Thumb needs ARM32)" + 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) elif kind == "string": diff --git a/idatui/domain.py b/idatui/domain.py index 606cf42..8787431 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -1373,6 +1373,14 @@ class Program: if res.get("error"): raise IDAToolError("define_code", f"@ {ea:#x}: {res['error']}") + def set_thumb(self, ea: int, mode: str = "toggle") -> dict: + """Switch ARM/Thumb decoding at ``ea``. Returns the resulting state.""" + r = self.client.call("set_thumb", addr=hex(ea), mode=mode) + if not isinstance(r, dict) or r.get("error"): + raise IDAToolError("set_thumb", + f"@ {ea:#x}: {(r or {}).get('error', 'failed')}") + return r + def define_code_run(self, ea: int, limit: int = 20000) -> dict: """Disassemble consecutively from ``ea`` until something stops it. -- cgit v1.3.1-sl0p