diff options
| -rw-r--r-- | docs/PROJECTS.md | 14 | ||||
| -rw-r--r-- | experiments/cortexm.bin | bin | 0 -> 1024 bytes | |||
| -rwxr-xr-x | experiments/fibonacci.bin | bin | 0 -> 7016 bytes | |||
| -rw-r--r-- | experiments/fibonacci.c | 43 | ||||
| -rwxr-xr-x | experiments/fibonacci.elf | bin | 0 -> 109028 bytes | |||
| -rw-r--r-- | experiments/fibonacci.o | bin | 0 -> 3040 bytes | |||
| -rw-r--r-- | idatui/app.py | 61 | ||||
| -rw-r--r-- | idatui/domain.py | 9 | ||||
| -rw-r--r-- | server/patch_server.py | 120 | ||||
| -rw-r--r-- | tests/test_thumb_ui.py | 47 |
10 files changed, 253 insertions, 41 deletions
diff --git a/docs/PROJECTS.md b/docs/PROJECTS.md index eae860b..fe6c2a8 100644 --- a/docs/PROJECTS.md +++ b/docs/PROJECTS.md @@ -335,6 +335,20 @@ 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`. +### Thumb entry points from a vector table + +`Shift+T` scans forward from the cursor for **odd pointers** — an ARM function +pointer carries the mode in bit 0, so a Cortex-M vector table is a list of Thumb +entry points. IDA won't follow them on a headerless image, because nothing tells +it those words are pointers at all: + + 3 Thumb entries found, 3 disassembled + +A word only counts when it is odd, lands inside a loaded segment, and its target +is executable and not already data. The even words in a vector table (the initial +stack pointer) fail the first test, which is the point — marking a data word as +code corrupts the listing, so a false positive costs more than a miss. + ### When analysis finds nothing Zero functions is what a raw image described wrongly looks like — right file, diff --git a/experiments/cortexm.bin b/experiments/cortexm.bin Binary files differnew file mode 100644 index 0000000..173bb10 --- /dev/null +++ b/experiments/cortexm.bin diff --git a/experiments/fibonacci.bin b/experiments/fibonacci.bin Binary files differnew file mode 100755 index 0000000..6f7b470 --- /dev/null +++ b/experiments/fibonacci.bin diff --git a/experiments/fibonacci.c b/experiments/fibonacci.c new file mode 100644 index 0000000..26fca0c --- /dev/null +++ b/experiments/fibonacci.c @@ -0,0 +1,43 @@ +#include <stdint.h> + +/* Iterative Fibonacci. */ +uint32_t fib_iter(uint32_t n) +{ + uint32_t a = 0, b = 1; + for (uint32_t i = 0; i < n; i++) { + uint32_t t = a + b; + a = b; + b = t; + } + return a; +} + +/* Naive recursive Fibonacci. */ +uint32_t fib_rec(uint32_t n) +{ + if (n < 2) + return n; + return fib_rec(n - 1) + fib_rec(n - 2); +} + +/* Memoized Fibonacci (static table). */ +uint32_t fib_memo(uint32_t n) +{ + static uint32_t cache[48]; + if (n < 2) + return n; + if (n >= sizeof(cache) / sizeof(cache[0])) + return fib_iter(n); + if (cache[n]) + return cache[n]; + return cache[n] = fib_memo(n - 1) + fib_memo(n - 2); +} + +volatile uint32_t sink; + +int main(void) +{ + for (uint32_t n = 0; n < 20; n++) + sink = fib_iter(n) + fib_rec(n) + fib_memo(n); + return 0; +} diff --git a/experiments/fibonacci.elf b/experiments/fibonacci.elf Binary files differnew file mode 100755 index 0000000..05ac21d --- /dev/null +++ b/experiments/fibonacci.elf diff --git a/experiments/fibonacci.o b/experiments/fibonacci.o Binary files differnew file mode 100644 index 0000000..c474d49 --- /dev/null +++ b/experiments/fibonacci.o diff --git a/idatui/app.py b/idatui/app.py index 1ea630e..ea68620 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -741,6 +741,7 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru Binding("p", "define_func", "Func", show=False), Binding("u", "undefine", "Undef", show=False), Binding("t", "toggle_thumb", "ARM/Thumb", show=False), + Binding("T", "thumb_scan", "Scan vectors", show=False), *SearchMixin.SEARCH_BINDINGS, *NavMixin.NAV_BINDINGS, *ColumnCursor.COL_BINDINGS, @@ -1132,6 +1133,9 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru def action_toggle_thumb(self) -> None: self.post_message(EditItemRequested(self, "thumb")) + def action_thumb_scan(self) -> None: + self.post_message(EditItemRequested(self, "thumbscan")) + def action_make_data(self) -> None: self.post_message(MakeDataRequested(self)) @@ -3092,6 +3096,7 @@ class IdaTui(App): self._load_for_label = None # project binary the dialog is for self._no_functions = False # analysis produced nothing at all self._flash: str | None = None # message a pending reload must keep + self._flash_until = 0.0 # ...until this monotonic time self._pending_switch = None # switch waiting on that answer self._nav_seq = 0 # bumped per navigation; drops stale ones # None = teardown wasn't an explicit quit (crash/kill): save defensively. @@ -3416,7 +3421,23 @@ class IdaTui(App): await self._rpc.stop() # -- status helper ----------------------------------------------------- # - def _status(self, text: str) -> None: + def _status(self, text: str, priority: bool = False) -> None: + """Write the status bar. ``priority`` marks the RESULT of something the + user did. + + An action's result is written, and then the reload it triggered writes + its own idle status on top — cursor moved, filter re-applied, functions + re-counted. I patched that at five separate call sites before admitting + it's one problem: routine chatter must not outrank an answer. A priority + message holds the bar briefly and is cleared by the next keypress, i.e. + when the user has read it and moved on. + """ + import time as _time + if priority: + self._flash = text + self._flash_until = _time.monotonic() + 8.0 + elif self._flash and _time.monotonic() < self._flash_until: + text = self._flash if self._binary: # project mode: always say which binary you're in text = f"[{self._binary}] {text}" # An image with no functions at all is nearly always a blob described @@ -5163,7 +5184,7 @@ class IdaTui(App): assert self.program is not None verb = {"code": "defined code", "func": "created function", "undef": "undefined", "string": "made string", - "thumb": "switched decoding"}[kind] + "thumb": "switched decoding", "thumbscan": "scanned"}[kind] try: if kind == "code": # Keep going until something stops it: one instruction is rarely @@ -5188,6 +5209,19 @@ 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 == "thumbscan": + # A vector table is a list of Thumb entry points that IDA won't + # follow on a headerless image, because nothing tells it those + # words are pointers. Scan from the cursor. + anchor.refresh_functions = True + r = self.program.thumb_scan(ea, ea + 0x400) + n, applied = int(r.get("n", 0)), int(r.get("applied", 0)) + if not n: + verb = (f"no Thumb entry pointers in {ea:#x}\u2013{ea+0x400:#x}" + " (odd words pointing into the image)") + else: + verb = (f"{n} Thumb entr{'y' if n == 1 else 'ies'} found, " + f"{applied} disassembled") elif kind == "thumb": # Switch the mode, then disassemble in it: flipping T and # leaving the bytes undefined shows nothing, and the reason you @@ -5257,9 +5291,8 @@ class IdaTui(App): written and lost. """ self._dirty = True - self._flash = anchor.flash if anchor.flash: - self._status(anchor.flash) # and again from the reload, via _flash + self._status(anchor.flash, priority=True) if anchor.refresh_functions: # Creating (or destroying) a function changes the index that the # names pane, Ctrl+N and the "no functions" hint all read. Without @@ -5936,13 +5969,6 @@ class IdaTui(App): self._goto_ea(msg.va, push=True) def _status_for_cur(self, mode: str) -> None: - if self._flash: - # Shown, NOT consumed. A reload emits several of these (prime, then - # cursor), so consuming on the first one meant the second erased the - # message the user was meant to read. It clears on the next keypress - # instead — i.e. when they've moved on. - self._status(self._flash) - return if self._cur is not None: self._status(f"{self._cur.name} @ {self._cur.ea:#x} [{mode}]") @@ -5981,15 +6007,12 @@ class IdaTui(App): # status afterwards — which is precisely how "F5 does nothing" # looked like nothing at all. msg = f"{name}: cannot decompile{detail}" - self._flash = msg + self._status(msg, priority=True) self._open_entry(ret, push=False) - self._status(msg) return self._active = "disasm" - msg = f"{name}: cannot decompile{detail}" - self._flash = msg + self._status(f"{name}: cannot decompile{detail}", priority=True) self._show_active() - self._status(msg) return if self._active == "decomp": view.focus() # loading cover had blurred it; restore focus @@ -6196,12 +6219,6 @@ class IdaTui(App): return ea = msg.ea if ea is not None: - # A pending flash (the result of an edit that caused this reload) - # outranks the idle hint: the cursor lands here as part of the - # reload, so this handler would otherwise always have the last word. - if self._flash: - self._status(self._flash) - return sec = self.program.section_of(ea) if self.program else None self._status(f"{sec or '?'} @ {ea:#x} [listing] " "(c code · p func · u undefine · Enter follow)") diff --git a/idatui/domain.py b/idatui/domain.py index 084312a..97b042d 100644 --- a/idatui/domain.py +++ b/idatui/domain.py @@ -1393,6 +1393,15 @@ class Program: return "this database is 64-bit \u2014 Ctrl+L, pick arm:ARMv7-A" return reason + def thumb_scan(self, start: int, end: int, apply: bool = True) -> dict: + """Find Thumb entry points from odd pointers in ``[start, end)``.""" + r = self.client.call("thumb_scan", start=hex(start), end=hex(end), + apply=bool(apply)) + if not isinstance(r, dict) or r.get("error"): + raise IDAToolError("thumb_scan", + f"@ {start:#x}: {(r or {}).get('error', 'failed')}") + return r + 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) diff --git a/server/patch_server.py b/server/patch_server.py index 2de852f..160b15b 100644 --- a/server/patch_server.py +++ b/server/patch_server.py @@ -1033,6 +1033,31 @@ def set_thumb( # discover that F5 does nothing. "db_64bit": bool(db64 and want)} +def _idatui_add_func(ea): + """add_func at ``ea``, falling back to an explicit end. + + ida_funcs.add_func(ea) asks IDA to find the end and on carved or + freshly-marked code it often can't, failing with no reason given.""" + import ida_bytes + import ida_funcs + import ida_segment + import idaapi + + if idaapi.get_func(ea) is not None: + return True + if ida_funcs.add_func(ea): + return True + 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 + return bool(end > ea and ida_funcs.add_func(ea, end)) + + @tool @idasync def define_func_run( @@ -1061,28 +1086,15 @@ def define_func_run( 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: + auto = ida_funcs.add_func(ea) + if not auto and not _idatui_add_func(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}"} + "error": f"IDA refused a function at {ea:#x}"} f = idaapi.get_func(ea) + if f is None: + return {"addr": hex(ea), "ok": False, "error": "function did not stick"} return {"addr": hex(ea), "ok": True, "start": hex(f.start_ea), - "end": hex(f.end_ea), "how": "explicit-end"} + "end": hex(f.end_ea), "how": "auto" if auto else "explicit-end"} @tool @idasync @@ -1124,6 +1136,76 @@ def decomp_error( except Exception as e: # noqa: BLE001 out["reason"] = f"{type(e).__name__}: {e}" return out + +@tool +@idasync +def thumb_scan( + start: Annotated[str, "Start of the range to scan for entry pointers"] = "", + end: Annotated[str, "Exclusive end of the range (default: 1KB from start)"] = "", + apply: Annotated[bool, "Mark the targets as Thumb and disassemble them"] = True, + limit: Annotated[int, "Max entries to act on"] = 512, +) -> dict: + """Find Thumb entry points from ODD pointers, e.g. a Cortex-M vector table. + + An ARM function pointer carries the mode in bit 0: odd means Thumb. A vector + table is therefore a list of Thumb entry points that IDA won't follow on a + headerless image, because nothing tells it those words are pointers at all. + + Being wrong here is expensive — marking a data word as code corrupts the + listing — so a word only counts when it is odd, lands inside a loaded + segment, and its target is EXECUTABLE and not already defined as data. The + even words in a vector table (the initial stack pointer) fail the first test, + which is the point.""" + import ida_bytes + import ida_funcs + import ida_idp + import ida_segment + import ida_segregs + import ida_ua + + seg0 = ida_segment.getseg(parse_address(start)) if start else None + if seg0 is None: + seg0 = ida_segment.getnseg(0) + if seg0 is None: + return {"error": "no segments", "found": [], "applied": 0} + try: + lo = parse_address(start) if start else seg0.start_ea + hi = parse_address(end) if end else min(lo + 0x400, seg0.end_ea) + except Exception as e: + return {"error": str(e), "found": [], "applied": 0} + + treg = ida_idp.str2reg("T") + found, applied = [], 0 + ea = lo + while ea + 4 <= hi and len(found) < limit: + w = ida_bytes.get_dword(ea) + ea += 4 + if not (w & 1): + continue # even: not a Thumb pointer + tgt = w & ~1 + seg = ida_segment.getseg(tgt) + if seg is None or not (seg.perm & ida_segment.SEGPERM_EXEC or seg.perm == 0): + continue # points outside the image, or at data + f = ida_bytes.get_flags(tgt) + if ida_bytes.is_data(f): + continue # already something else; don't fight it + rec = {"at": hex(ea - 4), "value": hex(w), "target": hex(tgt), + "was_code": bool(ida_bytes.is_code(f))} + found.append(rec) + if not apply: + continue + if treg is not None and treg >= 0: + ida_segregs.split_sreg_range(tgt, treg, 1, ida_segregs.SR_user) + if not ida_bytes.is_code(ida_bytes.get_flags(tgt)): + ida_bytes.del_items(tgt, 0, 2) + if ida_ua.create_insn(tgt) <= 0: + rec["decoded"] = False + continue + rec["decoded"] = True + rec["function"] = _idatui_add_func(tgt) + applied += 1 + return {"start": hex(lo), "end": hex(hi), "found": found, + "applied": applied, "n": len(found)} ''' SNIPPET = f"{BEGIN}\n{BODY.strip()}\n{END}\n" diff --git a/tests/test_thumb_ui.py b/tests/test_thumb_ui.py index 84afcaf..b0ca00b 100644 --- a/tests/test_thumb_ui.py +++ b/tests/test_thumb_ui.py @@ -202,6 +202,53 @@ async def run() -> int: any("(" in t and ")" in t for t in (dec._texts or [])[:3]), f"{(dec._texts or [])[:3]}") + # -- Thumb entry points from a vector table ----------------------------- # + # An ARM function pointer carries the mode in bit 0: odd means Thumb. A + # Cortex-M vector table is therefore a list of Thumb entry points, and IDA + # won't follow them on a headerless image because nothing says those words + # are pointers at all. + vec = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "experiments", "cortexm.bin") + if not os.path.isfile(vec): + check("the cortexm fixture exists", False, vec) + else: + for ext in (".i64", ".id0", ".id1", ".id2", ".nam", ".til"): + try: + os.remove(vec + ext) + except OSError: + pass + app = IdaTui(open_path=vec, keepalive=False, load_args="-parm:ARMv7-M") + 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) + check("a bare vector table gives IDA nothing to go on", + len(app._func_index) == 0, f"n={len(app._func_index)}") + if type(app.screen).__name__ != "Screen": + await pilot.press("escape") + await pilot.pause(0.5) + app._goto_ea(0, push=True) + lst = app.query_one(ListingView) + await wait(lambda: lst.model is not None, pilot, 60) + lst.focus() + lst.cursor = lst.model.index_of_ea(0) + lst._scroll_cursor_into_view() + await pilot.pause(0.3) + await pilot.press("T") + await wait(lambda: app._func_index is not None + and len(app._func_index) >= 3, pilot, 90) + names = sorted(f.name for f in app._func_index.all_loaded()) + check("scanning the table finds the Thumb handlers", + names == ["sub_200", "sub_240", "sub_280"], f"{names}") + # The table also holds an even word (the initial stack pointer), an + # even in-range word and an odd word pointing outside the image. All + # three must be ignored — marking a data word as code corrupts the + # listing, so the cost of a false positive is high. + check("and ignores the words that aren't Thumb pointers", + len(app._func_index) == 3, f"n={len(app._func_index)}") + status = str(app.query_one("#status", Static).render()) + check("the result survives the reload AND the reindex", + "3 Thumb entries" in status, status[:90]) + print(f"\n{PASS} passed, {FAIL} failed") return 1 if FAIL else 0 |
