aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-26 09:30:12 +0200
committerblasty <blasty@local>2026-07-26 09:30:12 +0200
commit4e65c3ba16b13c3bb042830b020788a745cee9a9 (patch)
tree791b3a94aec4e57801377943088a1dc0870704b4
parentlisting: make every undefined byte its own row, so you can carve anywhere (diff)
downloadida-tui-4e65c3ba16b13c3bb042830b020788a745cee9a9.tar.gz
ida-tui-4e65c3ba16b13c3bb042830b020788a745cee9a9.tar.xz
ida-tui-4e65c3ba16b13c3bb042830b020788a745cee9a9.zip
listing: `c` disassembles until something stops it
One instruction per keypress means pressing `c` once per opcode for the length of a routine, which on a raw image is the whole job. IDA's `c` runs; ours now does too. New define_code_run tool: create instructions consecutively and report why it stopped — 'undecodable' (bytes aren't an instruction), 'flow' (control flow ends here), 'defined' (ran into existing code/data), 'segment' or 'limit'. It loops inside the worker; from the client this would be one round trip per instruction, minutes on a real image. Stops AT a ret rather than past it: beyond the end of a routine the bytes are usually padding or data, and running on turns a clean carve into something you have to undo by hand. Stopping at already-defined items is the same principle — undefining someone's existing work to keep a speculative run going isn't a trade the user asked for. The ret test is ida_idp.is_ret_insn, NOT canonical features: on AArch64 insn.get_canon_feature() returns 0 for RET, so a CF_STOP check silently never fires and the run walks straight through the end of the function. Verified against a live IDA before relying on it. `c` on something already defined now says "already defined @ addr" instead of claiming the instruction failed to be created — count==0 from a run means two very different things. Also: the result message survives the reload. Defining an item rebuilds the view, and the reload's own cursor handler had the last word, so every edit reported itself as "ROM @ 0x4040 [listing]". A one-shot _flash is handed to whichever status write lands first after the edit. (Third time this clobber pattern has turned up: split view, the no-functions hint, now this.) Verified: nop/nop/nop/ret at 0x4040 -> "defined 4 instructions (0x4040–0x4050) — control flow ends here", with 0x4050 left as an undefined byte. Starting on existing code -> no-op. Random bytes -> stops at the first that won't decode. tests: +4 blob UI (runs to the end of flow, stops at the ret, doesn't touch the junk after it, and the status reports it). 22/0 blob, 202/0 scenarios, 30/0 project UI.
-rw-r--r--idatui/app.py42
-rw-r--r--idatui/domain.py16
-rw-r--r--server/patch_server.py74
-rw-r--r--tests/test_blob_ui.py18
4 files changed, 147 insertions, 3 deletions
diff --git a/idatui/app.py b/idatui/app.py
index 4cf0ee0..90678d3 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -3338,6 +3338,7 @@ class IdaTui(App):
self._hops: list[str] = [] # binaries a navigation crossed FROM
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._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.
@@ -5407,7 +5408,28 @@ class IdaTui(App):
"undef": "undefined", "string": "made string"}[kind]
try:
if kind == "code":
- self.program.define_code(ea)
+ # Keep going until something stops it: one instruction is rarely
+ # what you want, and on a raw image it means pressing `c` once
+ # per opcode for the length of a function.
+ r = self.program.define_code_run(ea)
+ n, why = int(r.get("count", 0)), r.get("stopped", "")
+ if n == 0 and why == "defined":
+ # Already code/data here — a no-op, not a failure. Saying
+ # "failed to create instruction" for it would be a lie.
+ self.app.call_from_thread(
+ self._status, f"already defined @ {ea:#x}")
+ return
+ if n == 0:
+ raise IDAToolError("define_code",
+ f"@ {ea:#x}: Failed to create instruction")
+ end = int(str(r.get("end", hex(ea))), 0)
+ reason = {"undecodable": "hit bytes that don't decode",
+ "flow": "control flow ends here",
+ "defined": "ran into existing code/data",
+ "segment": "end of segment",
+ "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 == "func":
self.program.define_func(ea)
elif kind == "string":
@@ -5438,7 +5460,12 @@ class IdaTui(App):
def _edit_item_done(self, verb: str, ea: int) -> None:
self._dirty = True
- self._status(f"{verb} @ {ea:#x} (Ctrl+S to save)")
+ # Defining an item reloads the view, and that reload writes its own
+ # status when it lands — after this one. Hand the message over as a
+ # flash so the result of the edit is what you actually read, instead of
+ # "ROM @ 0x4040 [listing]" every time.
+ self._flash = f"{verb} @ {ea:#x} (Ctrl+S to save)"
+ self._status(self._flash)
@work(thread=True, exclusive=True, group="save")
def _save(self) -> None:
@@ -6038,6 +6065,10 @@ class IdaTui(App):
self._goto_ea(msg.va, push=True)
def _status_for_cur(self, mode: str) -> None:
+ flash, self._flash = self._flash, None
+ if flash:
+ self._status(flash) # the edit that caused this reload wins
+ return
if self._cur is not None:
self._status(f"{self._cur.name} @ {self._cur.ea:#x} [{mode}]")
@@ -6274,6 +6305,13 @@ 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.
+ flash, self._flash = self._flash, None
+ if flash:
+ self._status(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 846af89..e4fbec9 100644
--- a/idatui/domain.py
+++ b/idatui/domain.py
@@ -1366,6 +1366,22 @@ class Program:
if res.get("error"):
raise IDAToolError("define_code", f"@ {ea:#x}: {res['error']}")
+ def define_code_run(self, ea: int, limit: int = 20000) -> dict:
+ """Disassemble consecutively from ``ea`` until something stops it.
+
+ Falls back to a single instruction when the worker predates the tool, so
+ an old worker degrades to the previous behaviour instead of failing.
+ """
+ try:
+ r = self.client.call("define_code_run", addr=hex(ea), limit=int(limit))
+ except IDAToolError:
+ self.define_code(ea)
+ return {"count": 1, "stopped": "single", "end": hex(ea)}
+ if not isinstance(r, dict) or r.get("error"):
+ raise IDAToolError("define_code_run",
+ 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(
diff --git a/server/patch_server.py b/server/patch_server.py
index 0e43ea5..4c806a7 100644
--- a/server/patch_server.py
+++ b/server/patch_server.py
@@ -768,6 +768,80 @@ def list_linkage(
"ordinal": int(ordinal)})
return {"imports": imports, "exports": exports,
"n_imports": len(imports), "n_exports": len(exports)}
+
+@tool
+@idasync
+def define_code_run(
+ addr: Annotated[str, "Address to start disassembling from"],
+ limit: Annotated[int, "Max instructions to create (safety stop)"] = 20000,
+) -> dict:
+ """Disassemble CONSECUTIVELY from ``addr`` until something stops it, the way
+ IDA's 'c' does — one instruction is rarely what you want when carving a raw
+ image. Returns {start,end,count,stopped} where ``stopped`` says why:
+ 'undecodable' (bytes aren't an instruction), 'flow' (the last instruction
+ doesn't fall through, e.g. RET/B), 'defined' (ran into existing code/data),
+ 'segment' (hit the end) or 'limit'.
+
+ Runs in-process: doing this from the client would be one round trip per
+ instruction, which is minutes on a real firmware image."""
+ import ida_bytes
+ import ida_idp
+ import ida_segment
+ import ida_ua
+ import idaapi
+
+ try:
+ ea = parse_address(addr)
+ except Exception as e:
+ return {"addr": str(addr), "error": str(e), "count": 0}
+
+ seg = ida_segment.getseg(ea)
+ if not seg:
+ return {"addr": str(addr), "error": "no segment", "count": 0}
+ hi = seg.end_ea
+ try:
+ limit = max(1, min(int(limit), 200000))
+ except (TypeError, ValueError):
+ limit = 20000
+
+ start, count, stopped = ea, 0, "limit"
+ while count < limit:
+ if ea >= hi:
+ stopped = "segment"
+ break
+ flags = ida_bytes.get_flags(ea)
+ if ida_bytes.is_code(flags) or ida_bytes.is_data(flags):
+ # Already defined: stop rather than clobber. Undefining someone's
+ # existing work to keep a speculative run going is not a trade the
+ # user asked for.
+ stopped = "defined"
+ break
+ n = ida_ua.create_insn(ea)
+ if n <= 0:
+ stopped = "undecodable"
+ break
+ count += 1
+ # Stop where control flow stops. Past a RET the next bytes are usually
+ # padding or a new function's data, and running on turns a clean carve
+ # into a mess that has to be undone by hand.
+ #
+ # Ask ida_idp.is_ret_insn, NOT the canonical feature bits: on AArch64
+ # get_canon_feature() returns 0 for RET, so a CF_STOP test silently never
+ # fires and the run walks straight through the end of the routine.
+ insn = ida_ua.insn_t()
+ if ida_ua.decode_insn(insn, ea) > 0:
+ try:
+ is_ret = ida_idp.is_ret_insn(insn)
+ except Exception:
+ is_ret = False
+ if is_ret or (insn.get_canon_feature() & idaapi.CF_STOP):
+ ea += n
+ stopped = "flow"
+ break
+ ea += n
+
+ return {"start": hex(start), "end": hex(ea), "count": count,
+ "stopped": stopped}
'''
SNIPPET = f"{BEGIN}\n{BODY.strip()}\n{END}\n"
diff --git a/tests/test_blob_ui.py b/tests/test_blob_ui.py
index 400c9ab..9161f44 100644
--- a/tests/test_blob_ui.py
+++ b/tests/test_blob_ui.py
@@ -55,7 +55,7 @@ async def run() -> int:
planted = 0x40 # file offset -> ea 0x4040
for k, insn in enumerate((0xD503201F, # nop
0xD503201F, # nop
- 0xD65F03C0)): # ret
+ 0xD65F03C0)): # ret <- the run must stop here
data[planted + k * 4:planted + k * 4 + 4] = insn.to_bytes(4, "little")
blob = os.path.join(tmp, "rnd.bin")
with open(blob, "wb") as f:
@@ -141,6 +141,22 @@ async def run() -> int:
f"kind={h.kind if h else None} text={h.text if h else None!r}")
if h is not None and h.kind == "code":
print(f" carved {target:#x}: {h.text}")
+ # `c` runs until something stops it, like IDA — one instruction
+ # at a time means pressing it once per opcode for the length of
+ # a routine. We planted nop/nop/nop/ret, so it must take all
+ # four and stop AT the ret, not run on into the random bytes
+ # after it.
+ run = [m.get(m.index_of_ea(target + k * 4)) for k in range(3)]
+ check("`c` keeps going until control flow ends",
+ all(x is not None and x.kind == "code" for x in run),
+ f"{[(hex(x.ea), x.kind) for x in run if x]}")
+ check("and stops at the ret instead of running into junk",
+ m.get(m.index_of_ea(target + 12)).kind == "unknown",
+ f"{m.get(m.index_of_ea(target + 12)).text!r}")
+ status = str(app.query_one("#status", Static).render())
+ check("the status reports what the run did",
+ "3 instructions" in status and "control flow" in status,
+ status[:80])
check("the carved row spans the instruction, not one byte",
h.size == 4, f"size={h.size}")
check("bytes before it stay individually addressable",