diff options
Diffstat (limited to '')
| -rw-r--r-- | server/patch_server.py | 1030 |
1 files changed, 1029 insertions, 1 deletions
diff --git a/server/patch_server.py b/server/patch_server.py index f42cc3a..160b15b 100644 --- a/server/patch_server.py +++ b/server/patch_server.py @@ -1,6 +1,10 @@ #!/usr/bin/env python3 """Inject idatui's extra ida-pro-mcp tools into the installed server package. +DEPRECATED along with the ida-pro-mcp transport: the default backend is now the +idalib worker (idatui/worker.py), which registers these same tools in-process and +needs no patching. Kept only for `--backend mcp`; slated for removal. + ida-pro-mcp lacks a few tools idatui needs. Rather than vendor/fork the server, we keep the tool source here and inject it (idempotently) into the installed ``api_types.py``. That module is imported by every worker @@ -42,6 +46,25 @@ def _idatui_lv_get(x): @tool @idasync +def resolve_names( + queries: Annotated[list, "Symbol name(s) to resolve to their OWN address"], +) -> list: + """Resolve named locations (functions, labels like loc_/locret_, data) to the + exact address the NAME denotes, via get_name_ea. Unlike lookup_funcs, a + mid-function label resolves to the label's address, not the containing + function's entry.""" + import idaapi + qs = queries if isinstance(queries, list) else [queries] + out = [] + for q in qs: + q = str(q).strip() + ea = idaapi.get_name_ea(idaapi.BADADDR, q) + out.append({"query": q, "ea": (hex(ea) if ea != idaapi.BADADDR else None)}) + return out + + +@tool +@idasync def del_type( name: Annotated[str, "Local type name to delete (struct/union/enum/typedef)"], ) -> dict: @@ -175,9 +198,1014 @@ def file_regions() -> dict: fo = -1 if fo < 0 or fo >= (1 << 48): fo = -1 - out.append({"start": hex(seg.start_ea), "end": hex(seg.end_ea), "file_off": fo}) + try: + nm = ida_segment.get_segm_name(seg) or "" + except Exception: + nm = "" + out.append({"start": hex(seg.start_ea), "end": hex(seg.end_ea), + "file_off": fo, "name": nm}) seg = ida_segment.get_next_seg(seg.start_ea) return {"regions": out} + + +@tool +@idasync +def make_string( + addr: Annotated[str, "Address of the string start"], + length: Annotated[int, "Length in bytes (0 = auto-detect to the terminator)"] = 0, + kind: Annotated[str, "String kind: c | c16 | c32 | pascal"] = "c", +) -> dict: + """Create a string literal at ``addr`` (IDA's 'A'). ``length`` 0 auto-detects + to the terminator. Undefines any items in the way first, like the UI does. + Returns the created byte size and the decoded contents.""" + import ida_bytes + import ida_nalt + + ea = parse_address(addr) + strtype = { + "c": ida_nalt.STRTYPE_C, + "c16": ida_nalt.STRTYPE_C_16, + "c32": ida_nalt.STRTYPE_C_32, + "pascal": ida_nalt.STRTYPE_PASCAL, + }.get(str(kind).lower(), ida_nalt.STRTYPE_C) + n = max(int(length), 0) + # Free any existing item(s) so create_strlit can carve the literal. + ida_bytes.del_items(ea, ida_bytes.DELIT_SIMPLE, n if n > 0 else 1) + ok = bool(ida_bytes.create_strlit(ea, n, strtype)) + if not ok: + return {"addr": addr, "ok": False, "error": "create_strlit failed"} + size = int(ida_bytes.get_item_size(ea)) + try: + raw = ida_bytes.get_strlit_contents(ea, -1, strtype) + text = raw.decode("utf-8", "replace") if raw else "" + except Exception: + text = "" + return {"addr": addr, "ok": True, "size": size, "text": text} + + +@tool +@idasync +def read_raw( + addr: Annotated[str, "Start address (hex or name)"], + size: Annotated[int, "Number of bytes to read"], +) -> dict: + """Read ``size`` bytes at ``addr`` as ONE contiguous lowercase hex string + (no per-byte '0x'/spaces). The hot path for the hex view and disasm opcode + bytes. + + Fast: does a single bulk ``ida_bytes.get_bytes`` (C-speed) instead of the + per-byte read_bytes_bss_safe loop (2 IDA calls/byte). Unloaded bytes come + back from IDA as the 0xFF sentinel, so we only re-check is_loaded for the + (usually sparse) 0xFF bytes and zero the genuinely-unloaded ones — matching + get_bytes' bss semantics without paying per-byte for the whole range. + + Encoding is compact hex (~2.5x smaller than get_bytes' '0x..'-with-spaces) + and, unlike get_bytes, does not truncate on large reads.""" + import ida_bytes + + ea = parse_address(addr) + n = max(int(size), 0) + if n == 0: + return {"addr": addr, "hex": "", "n": 0} + raw = ida_bytes.get_bytes(ea, n) + if raw is None or len(raw) < n: # nothing (or not all) mapped + base = bytearray(raw or b"") + base.extend(b"\\xff" * (n - len(base))) + raw = bytes(base) + ba = bytearray(raw) + # Only unloaded bytes read as 0xFF; correct just those to 0 (bss => zero). + i = ba.find(0xFF) + while i != -1: + if not ida_bytes.is_loaded(ea + i): + ba[i] = 0 + i = ba.find(0xFF, i + 1) + return {"addr": addr, "hex": bytes(ba).hex(), "n": len(ba)} + + +def _idatui_head_row(ea): + """One flat-listing row for the head at ``ea``: kind (code/data/unknown), + byte size, rendered text, and any symbol name.""" + import ida_bytes + import ida_lines + import ida_name + + f = ida_bytes.get_flags(ea) + if ida_bytes.is_code(f): + kind = "code" + elif ida_bytes.is_data(f): + kind = "data" + else: + kind = "unknown" + line = ida_lines.generate_disasm_line(ea, 0) + text = ida_lines.tag_remove(line) if line else "" + text = " ".join(text.split()) # collapse IDA's column padding + row = { + "ea": hex(ea), + "kind": kind, + "size": int(ida_bytes.get_item_size(ea)), + "text": text, + } + if line: + # Keep IDA's own token classification for syntax highlighting. Built from + # the SAME line as `text`, then whitespace-collapsed identically so the + # two never disagree about what the row says. + spans = _idatui_spans(line) + joined = "".join(t for _k, t in spans) + if " ".join(joined.split()) == text: + row["spans"] = spans + nm = ida_name.get_ea_name(ea) + if nm: + row["name"] = nm + return row + + +#: IDA colour tag -> the semantic kind the TUI styles. IDA already classifies +#: every token in a disassembly line, for every processor it supports, so there +#: is nothing to lex: generate_disasm_line emits \x01<tag>text\x02<tag> and the +#: tag says what the text IS. A pygments assembly lexer would be a worse guess at +#: this and would need one dialect per architecture. +_IDATUI_SPAN_KINDS = { + "insn": ("SCOLOR_INSN", "SCOLOR_KEYWORD", "SCOLOR_ASMDIR", "SCOLOR_MACRO"), + "reg": ("SCOLOR_REG",), + "num": ("SCOLOR_NUMBER", "SCOLOR_CHAR", "SCOLOR_BINPREF"), + "str": ("SCOLOR_STRING",), + # NB the real constant names: DATNAME/CODNAME, not "DNAME". Guessing here + # fails silently — an unmapped tag renders as plain body text, so symbols + # just quietly aren't blue and nothing tells you why. + "name": ("SCOLOR_DATNAME", "SCOLOR_CODNAME", "SCOLOR_LOCNAME", + "SCOLOR_IMPNAME", "SCOLOR_DEMNAME", "SCOLOR_LIBNAME", + "SCOLOR_CNAME", "SCOLOR_DNAME", + "SCOLOR_CREF", "SCOLOR_DREF", "SCOLOR_CREFTAIL", "SCOLOR_DREFTAIL"), + "seg": ("SCOLOR_SEGNAME",), + "cmt": ("SCOLOR_AUTOCMT", "SCOLOR_REGCMT", "SCOLOR_RPTCMT", "SCOLOR_VOIDOP"), + "punct": ("SCOLOR_SYMBOL", "SCOLOR_ALTOP", "SCOLOR_HIDNAME"), + "err": ("SCOLOR_ERROR",), +} + + +def _idatui_tag_map(): + """{tag character: kind}, built once from whatever this IDA actually has.""" + import ida_lines + out = {} + for kind, names in _IDATUI_SPAN_KINDS.items(): + for n in names: + v = getattr(ida_lines, n, None) + if isinstance(v, str) and v: + out[v[0]] = kind + elif isinstance(v, int): + out[chr(v)] = kind + return out + + +_IDATUI_TAGS = None + + +def _idatui_spans(line): + """A tagged disasm line as [[kind, text], ...], colour tags resolved. + + Unknown tags become 'text' rather than being dropped: a processor module can + emit a colour we don't classify, and losing the characters would corrupt the + line.""" + global _IDATUI_TAGS + import ida_lines + if _IDATUI_TAGS is None: + _IDATUI_TAGS = _idatui_tag_map() + on, off, esc = "\x01", "\x02", "\x03" + addr_tag = chr(getattr(ida_lines, "COLOR_ADDR", 0x28)) + addr_len = int(getattr(ida_lines, "COLOR_ADDR_SIZE", 16)) + spans, stack, buf = [], [], [] + i, n = 0, len(line) + + def flush(): + if buf: + spans.append([stack[-1] if stack else "text", "".join(buf)]) + del buf[:] + + while i < n: + ch = line[i] + if ch == on and i + 1 < n: + tag = line[i + 1] + if tag == addr_tag: + # An embedded target address, not display text: 16 hex digits + # that must not reach the screen. + i += 2 + addr_len + continue + flush() + stack.append(_IDATUI_TAGS.get(tag, "text")) + i += 2 + continue + if ch == off and i + 1 < n: + flush() + if stack: + stack.pop() + i += 2 + continue + if ch == esc and i + 1 < n: # escaped literal + buf.append(line[i + 1]) + i += 2 + continue + buf.append(ch) + i += 1 + flush() + # Collapse IDA's column padding EXACTLY as the plain text does. A run of + # spaces can straddle two spans, so this walks characters rather than + # collapsing each span on its own — otherwise the spans and `text` disagree + # about the line and the row silently loses its highlighting. + out, prev_space = [], False + for kind, txt in spans: + acc = [] + for ch in txt: + if ch.isspace(): + if prev_space: + continue + acc.append(" ") + prev_space = True + else: + acc.append(ch) + prev_space = False + if acc: + out.append([kind, "".join(acc)]) + while out and out[0][1] == " ": + out.pop(0) + while out and out[-1][1] == " ": + out.pop() + if out and out[0][1].startswith(" "): + out[0][1] = out[0][1].lstrip() + if out and out[-1][1].endswith(" "): + out[-1][1] = out[-1][1].rstrip() + return [[k, t] for k, t in out if t] + + +def _idatui_unknown_row(ea, size): + """One collapsed row for a run of ``size`` undefined bytes starting at + ``ea``. A single byte is rendered normally (shows its value); a longer run + collapses to ``db N dup(?)`` so a big .bss/gap doesn't explode into millions + of one-byte rows.""" + import ida_name + + if size <= 1: + return _idatui_head_row(ea) + row = {"ea": hex(ea), "kind": "unknown", "size": int(size), + "text": f"db {size} dup(?)"} + nm = ida_name.get_ea_name(ea) + if nm: + row["name"] = nm + return row + + +def _idatui_struct_member_rows(ea): + """Indented member rows for a struct-typed data item at ``ea`` (expansion), + or [] if it isn't a struct. Top-level fields only.""" + import ida_nalt + import ida_typeinf + import idaapi + + tif = ida_typeinf.tinfo_t() + if not (ida_nalt.get_tinfo(tif, ea) and tif.is_udt()): + return [] + udt = ida_typeinf.udt_type_data_t() + if not tif.get_udt_details(udt): + return [] + rows = [] + for m in udt: + off = m.begin() // 8 + try: + mtype = m.type._print() or "" + except Exception: + mtype = "" + try: + sz = int(m.type.get_size()) + if sz == idaapi.BADSIZE: + sz = 0 + except Exception: + sz = 0 + name = m.name or "" + text = f"+{off:X} {name}" + (f" {mtype}" if mtype else "") + rows.append({"ea": hex(ea + off), "kind": "member", "size": sz, + "text": text}) + return rows + + +def _idatui_func_header_rows(ea): + """IDA-style subroutine banner rows shown just before a function's entry.""" + import ida_funcs + + name = ida_funcs.get_func_name(ea) or "sub_%X" % ea + bar = "=" * 15 + " S U B R O U T I N E " + "=" * 15 + return [ + {"ea": hex(ea), "kind": "sep", "size": 0, "text": ""}, + {"ea": hex(ea), "kind": "sep", "size": 0, "text": "; " + bar}, + {"ea": hex(ea), "kind": "funchdr", "size": 0, + "text": name + " proc", "name": name}, + ] + + +def _idatui_func_footer_rows(ea, func): + """End-of-function marker shown just after a function's last item.""" + import ida_funcs + + name = ida_funcs.get_func_name(func.start_ea) or "sub_%X" % func.start_ea + return [ + {"ea": hex(ea), "kind": "funchdr", "size": 0, + "text": name + " endp", "name": name}, + {"ea": hex(ea), "kind": "sep", "size": 0, "text": "; " + "-" * 60}, + ] + + +@tool +@idasync +def heads( + addr: Annotated[str, "Start address or name to walk from"], + count: Annotated[int, "Max heads to return (default 200, max 2000)"] = 200, + offset: Annotated[int, "Skip first N heads from addr (default 0)"] = 0, + end: Annotated[str, "Optional exclusive end address; default = segment end"] = "", + back: Annotated[bool, "Walk backwards: return the count heads ENDING just before addr, in forward order"] = False, + annotate: Annotated[bool, "Emit IDA-style function boundary banner rows (kind sep/funchdr)"] = False, +) -> dict: + """Walk item heads from ``addr`` as a flat listing: every head is rendered + (code OR data OR undefined) via generate_disasm_line and stepped with + next_head/prev_head. Unlike ``disasm`` (code-only, bails at the first data + byte) this shows db/dw/dd/... lines for data and undefined regions — IDA's + real disassembly view. Address-paged: page forward by re-calling with + ``addr`` = the returned cursor.next; page up with ``back=true``.""" + import ida_bytes + import ida_segment + import idaapi + + count = 2000 if count > 2000 else (1 if count < 1 else count) + offset = max(int(offset), 0) + try: + start = parse_address(addr) + except Exception as e: + return {"addr": str(addr), "error": str(e), "heads": [], "cursor": {"done": True}} + seg = ida_segment.getseg(start) + if not seg: + return {"addr": str(addr), "error": "no segment", "heads": [], "cursor": {"done": True}} + lo, hi = seg.start_ea, seg.end_ea + if end: + try: + hi = min(hi, parse_address(end)) + except Exception: + pass + + rows = [] + if back: + # Collect up to (count+offset) heads strictly before `start`, then take + # the window closest to `start`, returned in forward order. + walk = [] + cur = ida_bytes.prev_head(start, lo) + while cur != idaapi.BADADDR and cur >= lo and len(walk) < count + offset: + walk.append(cur) + cur = ida_bytes.prev_head(cur, lo) + walk.reverse() + chosen = walk[: len(walk) - offset] if offset else walk + chosen = chosen[-count:] + rows = [_idatui_head_row(e) for e in chosen] + first = chosen[0] if chosen else start + pea = ida_bytes.prev_head(first, lo) + cursor = {"done": True} if pea == idaapi.BADADDR or pea < lo else {"prev": hex(pea)} + return {"addr": str(addr), "heads": rows, "cursor": cursor} + + # Walk by item END (not next_head): next_head SKIPS undefined bytes, but a + # flat listing must show them (IDA renders undefined as `db ?` lines, and + # navigating to an unmarked address must land ON it). Defined items advance + # by get_item_end; a run of undefined bytes is COLLAPSED into one row (its + # end found in O(1) via next_head, which skips undefined) so a large .bss or + # gap doesn't explode into millions of one-byte rows. + def _is_unknown(e): + f = ida_bytes.get_flags(e) + return not (ida_bytes.is_code(f) or ida_bytes.is_data(f)) + + def _run_end(e): + """End (exclusive) of the undefined run starting at ``e``.""" + nh = ida_bytes.next_head(e, hi) + return nh if (nh != idaapi.BADADDR and e < nh <= hi) else hi + + def _advance(e): + if _is_unknown(e): + return _run_end(e) + nxt = ida_bytes.get_item_end(e) + return nxt if nxt > e else e + 1 + + def _rows_for(e): + if _is_unknown(e): + return [_idatui_unknown_row(e, _run_end(e) - e)] + func = idaapi.get_func(e) if annotate else None + at_start = func is not None and func.start_ea == e + out = [] + if at_start: + out.extend(_idatui_func_header_rows(e)) + row = _idatui_head_row(e) + if at_start: + row = dict(row) + row["name"] = None # the name is shown on the proc header line + elif annotate and row.get("kind") == "code" and row.get("name"): + # A code label (loc_XXX/jump target) gets its OWN line at depth 0, + # like IDA; strip it from the instruction row below. + nm = row["name"] + out.append({"ea": hex(e), "kind": "label", "size": 0, + "text": nm + ":", "name": nm}) + row = dict(row) + row["name"] = None + out.append(row) + if row.get("kind") == "data": + out.extend(_idatui_struct_member_rows(e)) # expand struct fields + if func is not None and ida_bytes.get_item_end(e) >= func.end_ea: + out.extend(_idatui_func_footer_rows(e, func)) + return out + + ea = ida_bytes.get_item_head(start) + for _ in range(offset): + if ea >= hi or ea == idaapi.BADADDR: + break + ea = _advance(ea) + more = False + while ea != idaapi.BADADDR and ea < hi: + if len(rows) >= count: + more = True + break + rows.extend(_rows_for(ea)) # a struct head expands into member rows + ea = _advance(ea) + cursor = {"next": hex(ea)} if more else {"done": True} + return {"addr": str(addr), "heads": rows, "cursor": cursor} + + +@tool +@idasync +def xref_types( + queries: Annotated[list, "[{addr, direction:'to'|'from'|'both', include_fn, dedup, count}]"], +) -> dict: + """Like xref_query, but every row carries a fine-grained ``kind`` derived from + the IDA xref type \u2014 call/jump/flow for code, read/write/offset/text/info for + data \u2014 alongside the coarse ``type`` (code/data). Feeds the xref dialog's + r/w/call badges. Same query/envelope shape as xref_query.""" + import idaapi, idautils, ida_funcs, ida_bytes, ida_xref + code_kind = {ida_xref.fl_CF: "call", ida_xref.fl_CN: "call", + ida_xref.fl_JF: "jump", ida_xref.fl_JN: "jump", + ida_xref.fl_F: "flow"} + data_kind = {ida_xref.dr_O: "offset", ida_xref.dr_W: "write", + ida_xref.dr_R: "read", ida_xref.dr_T: "text", ida_xref.dr_I: "info"} + + def _kind(xr): + table = code_kind if xr.iscode else data_kind + return table.get(xr.type, "code" if xr.iscode else "data") + + def _fn(ea): + f = ida_funcs.get_func(ea) + if not f: + return None + return {"addr": hex(f.start_ea), "name": ida_funcs.get_func_name(f.start_ea)} + + def _resolve(raw): + raw = str(raw).strip() + try: + return int(raw, 16) # handles '0x2490' and '2490' + except ValueError: + return idaapi.get_name_ea(idaapi.BADADDR, raw) + + qs = queries if isinstance(queries, list) else [queries] + result = [] + for q in qs: + q = q if isinstance(q, dict) else {"addr": q} + raw = str(q.get("addr", "")).strip() + direction = str(q.get("direction", "to") or "to").lower() + include_fn = bool(q.get("include_fn", True)) + dedup = bool(q.get("dedup", True)) + try: + count = int(q.get("count", 2000) or 2000) + except (TypeError, ValueError): + count = 2000 + target = _resolve(raw) + rows = [] + if target is not None and target != idaapi.BADADDR and ida_bytes.is_mapped(target): + if direction in ("to", "both"): + for xr in idautils.XrefsTo(target, 0): + row = {"direction": "to", "addr": hex(xr.frm), "from": hex(xr.frm), + "to": hex(target), "type": "code" if xr.iscode else "data", + "kind": _kind(xr)} + if include_fn: + row["fn"] = _fn(xr.frm) + rows.append(row) + if direction in ("from", "both"): + for xr in idautils.XrefsFrom(target, 0): + row = {"direction": "from", "addr": hex(xr.to), "from": hex(target), + "to": hex(xr.to), "type": "code" if xr.iscode else "data", + "kind": _kind(xr)} + if include_fn: + row["fn"] = _fn(xr.to) + rows.append(row) + if dedup: + seen = set() + deduped = [] + for r in rows: + k = (r["direction"], r["from"], r["to"], r["kind"]) + if k in seen: + continue + seen.add(k) + deduped.append(r) + rows = deduped + rows = rows[:count] + result.append({"query": raw, "data": rows, "next_offset": None}) + return {"result": result} + + +@tool +@idasync +def data_type( + addr: Annotated[str, "Address or name of a data item / global"], +) -> dict: + """The current C type of a data item, for prefilling a retype prompt: + {addr, name, type, size, is_func}. ``type`` is empty when the item is + untyped; ``is_func`` distinguishes a global from a function so the caller + knows which flavour of set_type to use.""" + import idaapi + import ida_bytes + import ida_name + import idc + raw = str(addr).strip() + try: + ea = int(raw, 16) + except ValueError: + ea = idaapi.get_name_ea(idaapi.BADADDR, raw) + if ea == idaapi.BADADDR or not ida_bytes.is_mapped(ea): + return {"addr": raw, "error": f"not a mapped address: {raw}"} + return { + "addr": hex(ea), + "name": ida_name.get_name(ea) or "", + "type": idc.get_type(ea) or "", + "size": int(ida_bytes.get_item_size(ea) or 0), + "is_func": bool(idaapi.get_func(ea)), + } + + +@tool +@idasync +def decomp_map( + addr: Annotated[str, "Function address or name"], +) -> dict: + """Per-pseudocode-line instruction coverage for the split view's region + highlight: for each line, the set of EAs the decompiler attributes to it, + swept across the line's columns via get_line_item. Shape: + {addr, lines:[{ea: primary|None, eas:[hex,...]}, ...]}.""" + import ida_hexrays + import idaapi + try: + ea = int(str(addr), 16) + except ValueError: + ea = idaapi.get_name_ea(idaapi.BADADDR, str(addr).strip()) + func = idaapi.get_func(ea) + if not func: + return {"error": f"no function at {addr}"} + try: + cfunc = ida_hexrays.decompile(func.start_ea) + except Exception as e: # noqa: BLE001 + return {"error": f"decompile failed: {e}"} + if cfunc is None: + return {"error": "decompile failed"} + lines = [] + for sl in cfunc.get_pseudocode(): + line = sl.line + eas, seen = [], set() + for x in range(len(line) + 1): + head = ida_hexrays.ctree_item_t() + item = ida_hexrays.ctree_item_t() + tail = ida_hexrays.ctree_item_t() + if not cfunc.get_line_item(line, x, False, head, item, tail): + continue + # Match the /*ea*/ marker's source (decompile_function_safe): the + # item's dstr() is 'EA: description'; get_ea() reports a different ea. + dstr = item.dstr() + if not dstr: + continue + parts = dstr.split(": ", 1) + if len(parts) != 2: + continue + try: + e = int(parts[0], 16) + except ValueError: + continue + if e not in seen: + seen.add(e) + eas.append(hex(e)) + lines.append({"ea": eas[0] if eas else None, "eas": eas}) + return {"addr": hex(func.start_ea), "lines": lines} + + +_idatui_strings_cache = {} + + +def _idatui_build_strings(min_len): + """[(ea, text, length, typename)] for every string IDA found, cached by + min_len (rebuilding the list is O(n) and the browser pages through it).""" + import idautils + import ida_nalt + hit = _idatui_strings_cache.get(min_len) + if hit is not None: + return hit + tnames = {} + for nm, lbl in (("STRTYPE_C", "C"), ("STRTYPE_C_16", "utf16"), + ("STRTYPE_C_32", "utf32"), ("STRTYPE_PASCAL", "pascal")): + v = getattr(ida_nalt, nm, None) + if v is not None: + tnames[v & 0xFF] = lbl + items = [] + for s in idautils.Strings(): + if s is None: + continue + try: + text = str(s) + except Exception: # noqa: BLE001 -- undecodable literal + continue + if len(text) < min_len: + continue + st = getattr(s, "strtype", 0) & 0xFF + items.append((s.ea, text, getattr(s, "length", len(text)), + tnames.get(st, "t%d" % st))) + _idatui_strings_cache[min_len] = items + return items + + +@tool +@idasync +def list_strings( + offset: Annotated[int, "Start index into the strings list"] = 0, + count: Annotated[int, "Max strings to return (page size)"] = 2000, + min_len: Annotated[int, "Minimum string length to include"] = 4, + refresh: Annotated[bool, "Rebuild the cached strings list"] = False, +) -> dict: + """Every string literal IDA found in the binary (IDA's Shift+F12 window), + paginated: {strings:[{addr,text,len,type}], total, next_offset}. Feeds the + TUI's strings browser.""" + try: + min_len = max(int(min_len), 1) + except (TypeError, ValueError): + min_len = 4 + try: + offset = max(int(offset), 0) + except (TypeError, ValueError): + offset = 0 + try: + count = max(int(count), 1) + except (TypeError, ValueError): + count = 2000 + if refresh: + _idatui_strings_cache.pop(min_len, None) + items = _idatui_build_strings(min_len) + page = items[offset:offset + count] + return { + "strings": [{"addr": hex(ea), "text": text, "len": ln, "type": ty} + for (ea, text, ln, ty) in page], + "total": len(items), + "next_offset": offset + len(page), + } + +@tool +@idasync +def list_linkage( + kind: Annotated[str, "'import', 'export' or 'both'"] = "both", +) -> dict: + """What this binary imports from, and exports to, other modules: + {imports:[{addr,name,module}], exports:[{addr,name,ordinal}]}. Feeds the + project-wide import/export join, which resolves a PLT stub in one binary to + the real implementation in another.""" + import idaapi + import idautils + import ida_nalt + want = str(kind or "both").lower() + imports = [] + exports = [] + if want in ("import", "both"): + n = ida_nalt.get_import_module_qty() + for i in range(n): + mod = ida_nalt.get_import_module_name(i) or "" + + def _cb(ea, name, ordinal, _mod=mod): + # An ordinal-only import has no name; skip rather than invent one. + if name: + imports.append({"addr": hex(ea), "name": name, "module": _mod}) + return True + + ida_nalt.enum_import_names(i, _cb) + if want in ("export", "both"): + for index, ordinal, ea, name in idautils.Entries(): + if name: + exports.append({"addr": hex(ea), "name": name, + "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} + +@tool +@idasync +def set_thumb( + addr: Annotated[str, "Address to change the ARM decoding mode at"], + mode: Annotated[str, "'toggle', 'on' (Thumb) or 'off' (ARM)"] = "toggle", + end: Annotated[str, "Optional exclusive end address (default: this item)"] = "", +) -> dict: + """Switch ARM/Thumb decoding at ``addr`` (IDA's T segment register). + + Thumb is not a property of the bytes, it's a mode the CPU is in, so a raw + image gives IDA no way to know: at a Thumb entry point it decodes 16-bit + instructions as 32-bit ARM and produces confident nonsense + (``push {r3,lr}`` reads as ``SVCLT 0xBF00``). + + Also forces the segment to 32-bit when turning Thumb ON. Thumb does not + exist in AArch64, and a headerless blob loaded with -parm defaults to + 64-bit — so setting T alone changes nothing and looks broken. Asking for + Thumb IS asking for ARM32.""" + import ida_bytes + import ida_idp + import ida_segment + import ida_segregs + + try: + ea = parse_address(addr) + except Exception as e: + return {"addr": str(addr), "error": str(e)} + treg = ida_idp.str2reg("T") + if treg is None or treg < 0: + return {"addr": hex(ea), "error": "no T register (not an ARM database)"} + seg = ida_segment.getseg(ea) + 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) + + changed_bits = False + if want and seg.bitness != 1: + ida_segment.set_segm_addressing(seg, 1) + changed_bits = True + + try: + stop = parse_address(end) if end else 0 + except Exception: + stop = 0 + size = max(int(stop) - ea, 0) or max(ida_bytes.get_item_size(ea), 2) + # The bytes are currently decoded in the OLD mode; leaving that item defined + # pins the wrong instruction length and the new mode has nothing to apply to. + ida_bytes.del_items(ea, 0, size) + ok = bool(ida_segregs.split_sreg_range(ea, treg, want, ida_segregs.SR_user)) + 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, + # 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)} + +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( + 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"} + auto = ida_funcs.add_func(ea) + if not auto and not _idatui_add_func(ea): + return {"addr": hex(ea), "ok": False, + "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": "auto" if auto else "explicit-end"} + +@tool +@idasync +def decomp_error( + addr: Annotated[str, "Address of the function that failed to decompile"], +) -> dict: + """Why Hex-Rays refused this function, in its own words. + + The plain decompile tool reports "Decompilation failed at 0x0" and drops the + reason, which is the only useful part. Hex-Rays fills in a hexrays_failure_t + saying things like "only 64-bit functions can be decompiled in the current + database" — that one is unfixable in place (the database's bitness is set at + load), so a user who can't see it has no way to know they must reload.""" + import ida_funcs + import ida_hexrays + import ida_ida + + try: + ea = parse_address(addr) + except Exception as e: + return {"addr": str(addr), "error": str(e)} + out = {"addr": hex(ea), "bitness": ida_ida.inf_get_app_bitness()} + fn = ida_funcs.get_func(ea) + if fn is None: + out["reason"] = "no function here" + return out + try: + if not ida_hexrays.init_hexrays_plugin(): + out["reason"] = "the decompiler is not available for this processor" + return out + hf = ida_hexrays.hexrays_failure_t() + cf = ida_hexrays.decompile_func(fn, hf) + if cf is not None: + out["reason"] = "" # it decompiles now + return out + out["reason"] = hf.desc() or f"error {hf.code}" + out["code"] = int(hf.code) + out["errea"] = hex(hf.errea) + 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" |
