aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/PROJECTS.md14
-rw-r--r--experiments/cortexm.binbin0 -> 1024 bytes
-rwxr-xr-xexperiments/fibonacci.binbin0 -> 7016 bytes
-rw-r--r--experiments/fibonacci.c43
-rwxr-xr-xexperiments/fibonacci.elfbin0 -> 109028 bytes
-rw-r--r--experiments/fibonacci.obin0 -> 3040 bytes
-rw-r--r--idatui/app.py61
-rw-r--r--idatui/domain.py9
-rw-r--r--server/patch_server.py120
-rw-r--r--tests/test_thumb_ui.py47
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
new file mode 100644
index 0000000..173bb10
--- /dev/null
+++ b/experiments/cortexm.bin
Binary files differ
diff --git a/experiments/fibonacci.bin b/experiments/fibonacci.bin
new file mode 100755
index 0000000..6f7b470
--- /dev/null
+++ b/experiments/fibonacci.bin
Binary files differ
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
new file mode 100755
index 0000000..05ac21d
--- /dev/null
+++ b/experiments/fibonacci.elf
Binary files differ
diff --git a/experiments/fibonacci.o b/experiments/fibonacci.o
new file mode 100644
index 0000000..c474d49
--- /dev/null
+++ b/experiments/fibonacci.o
Binary files differ
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