diff --git a/declib/api/decompiler_client.py b/declib/api/decompiler_client.py index 47b0f54..0ccd9bf 100644 --- a/declib/api/decompiler_client.py +++ b/declib/api/decompiler_client.py @@ -572,6 +572,14 @@ def save(self, path: Optional[str] = None) -> bool: def set_persist_on_close(self, value: bool) -> bool: """Control whether the backend flushes to disk on teardown.""" return self._send_request({"type": "method_call", "method_name": "set_persist_on_close", "args": [value]}) + + def get_comment(self, addr: int) -> Optional[Comment]: + """Return the Comment at ``addr`` (lifted), or None. Direct read, no cache.""" + return self._send_request({"type": "method_call", "method_name": "get_comment", "args": [addr]}) + + def delete_comment(self, addr: int) -> bool: + """Delete any comment at ``addr`` (lifted).""" + return self._send_request({"type": "method_call", "method_name": "delete_comment", "args": [addr]}) def get_callgraph(self, only_names=False): """Get the call graph""" diff --git a/declib/api/decompiler_interface.py b/declib/api/decompiler_interface.py index 80c392d..4825ef8 100644 --- a/declib/api/decompiler_interface.py +++ b/declib/api/decompiler_interface.py @@ -528,6 +528,21 @@ def read_memory(self, addr: int, size: int) -> Optional[bytes]: """ raise NotImplementedError + def get_comment(self, addr) -> Optional[Comment]: + """Return the lifted Comment at ``addr`` (lifted), or None. + + Reads the backend directly rather than through the light-artifact + cache, so a comment set moments earlier is immediately visible. + """ + lowered = self.art_lifter.lower_addr(addr) + cmt = self._get_comment(lowered) + return self.art_lifter.lift(cmt) if cmt is not None else None + + def delete_comment(self, addr) -> bool: + """Delete any comment at ``addr`` (lifted). True if one was removed.""" + lowered = self.art_lifter.lower_addr(addr) + return self._del_comment(lowered) + # ------------------------------------------------------------------ # Persistence # ------------------------------------------------------------------ diff --git a/declib/cli/decompiler_cli.py b/declib/cli/decompiler_cli.py index d65e41e..f032268 100644 --- a/declib/cli/decompiler_cli.py +++ b/declib/cli/decompiler_cli.py @@ -16,6 +16,7 @@ - disassemble disassemble a function by name or address - xref_to data + code references to a target - xref_from things a function calls (callees) +- comment get/set/append/delete/list comments (annotations) - rename rename a function or local variable - create-type define a new struct/enum/typedef from a C string - retype change the type of a function's variable or argument @@ -117,6 +118,28 @@ def _resolve_function_addr(client, target: str) -> Optional[int]: return addr # let the caller raise if it's truly invalid +def _to_lifted_addr(client, addr: int) -> int: + """Best-effort: normalize an absolute address into the server's lifted form. + + The server keys artifacts (comments, patches, ...) by lifted address — + relative to the image base. Users routinely paste absolute addresses, so + when the value is clearly absolute (>= base and not already a known lifted + function start) we subtract the base to land it where they expect. + """ + try: + if addr in set(client.functions.keys()): + return addr + except Exception: + pass + try: + base = client.binary_base_addr + except Exception: + base = 0 + if base and addr >= base: + return addr - base + return addr + + def _select_server( server_id: Optional[str], binary_path: Optional[str], @@ -766,6 +789,88 @@ def cmd_rename(args) -> int: raise SystemExit(f"Unknown rename kind: {kind}") +# --------------------------------------------------------------------------- +# comment (get / set / append / delete / list) +# --------------------------------------------------------------------------- + +def cmd_comment(args) -> int: + """CRUD for comments/annotations, keyed by address. + + ``set`` replaces, ``append`` concatenates onto whatever is already there, + ``get``/``delete`` operate on a single address, and ``list`` enumerates + every comment the backend knows about (optionally regex-filtered). + """ + from declib.artifacts import Comment + + action = args.comment_action + with _with_client(args) as client: + if action == "list": + pattern = re.compile(args.filter) if args.filter else None + entries: List[Dict] = [] + for addr, cmt in sorted(client.comments.items(), key=lambda kv: kv[0]): + text = getattr(cmt, "comment", None) or "" + if pattern and not pattern.search(text): + continue + entries.append({ + "addr": addr, + "decompiled": bool(getattr(cmt, "decompiled", False)), + "comment": text, + }) + if args.json: + _emit_list(args, entries) + else: + if not entries: + print("No comments.") + return EXIT_OK + for e in entries: + print(f"{_format_addr_hex(e['addr'])}\t{e['comment']}") + return EXIT_OK + + addr_value, _ = _parse_target(args.addr) + if addr_value is None: + raise SystemExit(f"Invalid address {args.addr!r}; expected hex (0x..) or decimal.") + lifted = _to_lifted_addr(client, addr_value) + + if action == "get": + cmt = client.get_comment(lifted) + if cmt is None: + raise SystemExit(f"No comment at {_format_addr_hex(lifted)}") + _emit(args, { + "addr": lifted, + "decompiled": bool(getattr(cmt, "decompiled", False)), + "comment": cmt.comment, + }, text_field="comment") + return EXIT_OK + + if action == "delete": + ok = bool(client.delete_comment(lifted)) + _emit(args, {"addr": lifted, "deleted": ok}) + return EXIT_OK if ok else EXIT_USER_ERROR + + # set / append + new_text = args.text + if action == "append": + existing = client.get_comment(lifted) + if existing is not None and existing.comment: + new_text = f"{existing.comment}\n{new_text}" + + comment = Comment(addr=lifted, comment=new_text, decompiled=bool(args.decompiled)) + ok = bool(client.set_artifact(comment)) + if not ok: + raise SystemExit( + f"Backend rejected the comment at {_format_addr_hex(lifted)}. " + "Some backends (IDA) only accept comments at addresses inside a " + "function — check the address with `list_functions`/`disassemble`." + ) + _emit(args, { + "addr": lifted, + "decompiled": bool(args.decompiled), + "comment": new_text, + "success": ok, + }) + return EXIT_OK + + # --------------------------------------------------------------------------- # create-type / retype # --------------------------------------------------------------------------- @@ -1400,6 +1505,36 @@ def build_parser() -> argparse.ArgumentParser: _add_output_args(p_ren) p_ren.set_defaults(func=cmd_rename) + # comment + p_cmt = sub.add_parser( + "comment", + help="Get/set/append/delete/list comments (annotations) by address.", + ) + cmt_sub = p_cmt.add_subparsers(dest="comment_action", required=True) + + def _add_comment_addr_action(name, help_text, with_text=False): + p = cmt_sub.add_parser(name, help=help_text) + p.add_argument("addr", help="Address (hex 0x.., decimal, lifted or absolute).") + if with_text: + p.add_argument("text", help="Comment text.") + p.add_argument("--decompiled", action="store_true", + help="Attach to the decompiler view (pseudocode) rather than disassembly.") + _add_server_filter_args(p) + _add_output_args(p) + p.set_defaults(func=cmd_comment) + return p + + _add_comment_addr_action("set", "Set a comment at an address (replaces existing).", with_text=True) + _add_comment_addr_action("append", "Append text to the comment at an address.", with_text=True) + _add_comment_addr_action("get", "Print the comment at an address.") + _add_comment_addr_action("delete", "Delete any comment at an address.") + + p_clist = cmt_sub.add_parser("list", help="List every comment in the binary.") + p_clist.add_argument("--filter", dest="filter", help="Regex to filter comment text.") + _add_server_filter_args(p_clist) + _add_output_args(p_clist) + p_clist.set_defaults(func=cmd_comment) + # create-type p_ct = sub.add_parser( "create-type", diff --git a/declib/decompilers/binja/interface.py b/declib/decompilers/binja/interface.py index 2562c9f..c3f7157 100644 --- a/declib/decompilers/binja/interface.py +++ b/declib/decompilers/binja/interface.py @@ -655,6 +655,19 @@ def _set_comment(self, comment: Comment, **kwargs) -> bool: # func exists for commenting bn_func = self.addr_to_bn_func(self.bv, comment.addr) bn_func.set_comment_at(comment.addr, comment.comment) + return True + + def _del_comment(self, addr) -> bool: + """Clear any comment at ``addr`` (function-local and address-space).""" + removed = False + if self.bv.get_comment_at(addr): + self.bv.set_comment_at(addr, "") + removed = True + for bn_func in self.bv.get_functions_containing(addr) or []: + if bn_func.get_comment_at(addr): + bn_func.set_comment_at(addr, "") + removed = True + return removed def _get_comment(self, addr) -> Optional[Comment]: non_func_cmt = self.bv.get_comment_at(addr) @@ -686,7 +699,13 @@ def _comments(self) -> Dict[int, Comment]: if not bn_func.symbol.type in VALID_FUNC_SYM_TYPES: continue - comments.update(bn_func.comments) + for cmt_addr, cmt_text in bn_func.comments.items(): + if not cmt_text: + continue + comments[cmt_addr] = Comment( + addr=cmt_addr, comment=cmt_text, + func_addr=bn_func.start, decompiled=True, + ) # TODO: show non-function based comments return comments diff --git a/declib/decompilers/ghidra/interface.py b/declib/decompilers/ghidra/interface.py index 5595786..a14a61f 100644 --- a/declib/decompilers/ghidra/interface.py +++ b/declib/decompilers/ghidra/interface.py @@ -859,6 +859,23 @@ def _get_comment(self, addr) -> Optional[Comment]: comments = self._comments() return comments.get(addr, None) + @ghidra_transaction + def _del_comment(self, addr) -> bool: + from .compat.imports import CodeUnit, SetCommentCmd + + gaddr = self._to_gaddr(addr) + listing = self.currentProgram.getListing() + code_unit = listing.getCodeUnitAt(gaddr) + removed = False + for cmt_type in ( + CodeUnit.PLATE_COMMENT, CodeUnit.PRE_COMMENT, CodeUnit.EOL_COMMENT, + CodeUnit.POST_COMMENT, CodeUnit.REPEATABLE_COMMENT, + ): + if code_unit is not None and code_unit.getComment(cmt_type): + SetCommentCmd(gaddr, cmt_type, None).applyTo(self.currentProgram) + removed = True + return removed + def _comments(self) -> Dict[int, Comment]: from .compat.imports import CodeUnit diff --git a/declib/decompilers/ida/compat.py b/declib/decompilers/ida/compat.py index fa2fcf9..08ca2f7 100644 --- a/declib/decompilers/ida/compat.py +++ b/declib/decompilers/ida/compat.py @@ -40,7 +40,7 @@ import declib from declib.artifacts import ( Struct, FunctionHeader, FunctionArgument, StackVariable, Function, GlobalVariable, Enum, Artifact, Context, Typedef, - StructMember, Segment + StructMember, Segment, Comment ) from .artifact_lifter import IDAArtifactLifter @@ -1086,18 +1086,162 @@ def set_ida_comment(addr, cmt, decompiled=False): def get_ida_comment(addr, decompiled=True): - # TODO: support more than just functions - # TODO: support more than just function headers - if decompiled and not ida_hexrays.init_hexrays_plugin(): - raise ValueError("Decompiler is not available, but you are requesting a decompiled comment") + """Return the comment text at ``addr`` (any slot), or None. + Checks, in order: function header comment (when ``addr`` is a function + start), persisted decompiler user comment, then the disassembly comment. + """ func = idaapi.get_func(addr) - if func is None: - return None - if func.start_ea == addr: + # function header/range comment + if func is not None and func.start_ea == addr: cmt = idc.get_func_cmt(addr, 1) - return cmt if cmt else None + if cmt: + return cmt + + # decompiler user comment (read from the netnode, no re-decompile) + if func is not None and ida_hexrays.init_hexrays_plugin(): + try: + uc = ida_hexrays.restore_user_cmts(func.start_ea) + if uc: + try: + it = ida_hexrays.user_cmts_begin(uc) + while it != ida_hexrays.user_cmts_end(uc): + tl = ida_hexrays.user_cmts_first(it) + if int(tl.ea) == addr: + cmt = ida_hexrays.user_cmts_second(it) + return str(cmt) if cmt is not None else None + it = ida_hexrays.user_cmts_next(it) + finally: + ida_hexrays.user_cmts_free(uc) + except Exception as e: + _l.debug("Failed reading decompiler comment at %s: %s", hex(addr), e) + + # disassembly comment (regular or repeatable) + return _get_disasm_comment(addr) + + +def _get_disasm_comment(addr): + """Return the regular or repeatable disassembly comment at ``addr``, or None.""" + for rpt in (False, True): + cmt = ida_bytes.get_cmt(addr, rpt) + if cmt: + return cmt + return None + + +@execute_write +def del_ida_comment(addr, decompiled=False): + """Delete comments at ``addr`` across every slot IDA might store them in. + + Clears the disassembly comment (regular + repeatable), the function + header/range comment when ``addr`` is a function start, and — when a + decompiler is present — the persisted Hexrays user comment. Returns True if + anything was removed. + """ + removed = False + + # disassembly comments + for rpt in (0, 1): + if ida_bytes.get_cmt(addr, bool(rpt)): + ida_bytes.set_cmt(addr, "", rpt) + removed = True + + # function header / range comment + func = ida_funcs.get_func(addr) + if func is not None and func.start_ea == addr: + for rpt in (0, 1): + if idc.get_func_cmt(addr, rpt): + idc.set_func_cmt(addr, "", rpt) + removed = True + + # decompiler user comment + if func is not None and ida_hexrays.init_hexrays_plugin(): + try: + uc = ida_hexrays.restore_user_cmts(func.start_ea) + if uc: + it = ida_hexrays.user_cmts_begin(uc) + to_delete = [] + while it != ida_hexrays.user_cmts_end(uc): + tl = ida_hexrays.user_cmts_first(it) + if int(tl.ea) == addr: + to_delete.append(tl) + it = ida_hexrays.user_cmts_next(it) + for tl in to_delete: + ida_hexrays.user_cmts_erase(uc, tl) + removed = True + if to_delete: + ida_hexrays.save_user_cmts(func.start_ea, uc) + ida_hexrays.user_cmts_free(uc) + except Exception as e: + _l.debug("Failed clearing decompiler comment at %s: %s", hex(addr), e) + + return removed + + +@execute_write +def comments(): + """Enumerate every comment in the database as ``{ea: Comment}``. + + Covers three sources: disassembly comments (regular + repeatable) at each + head, function header/range comments at each function start, and persisted + Hexrays decompiler user comments. Decompiler comments are read straight from + the saved netnode via ``restore_user_cmts`` so we never have to re-decompile + a function to list them. + """ + results: typing.Dict[int, Comment] = {} + + def _add(ea, text, decompiled): + if not text: + return + ea = int(ea) + existing = results.get(ea) + if existing is None: + results[ea] = Comment(addr=ea, comment=text, decompiled=decompiled) + return + if text not in (existing.comment or ""): + existing.comment = f"{existing.comment}\n{text}" + if decompiled: + existing.decompiled = True + + # 1. disassembly comments at each head of each segment + for seg_ea in idautils.Segments(): + seg = ida_segment.getseg(seg_ea) + if seg is None: + continue + for head in idautils.Heads(seg.start_ea, seg.end_ea): + for rpt in (False, True): + cmt = ida_bytes.get_cmt(head, rpt) + if cmt: + _add(head, cmt, False) + + # 2. per-function: header comment + persisted decompiler comments + hexrays_ok = ida_hexrays.init_hexrays_plugin() + for func_ea in idautils.Functions(): + for rpt in (0, 1): + fcmt = idc.get_func_cmt(func_ea, rpt) + if fcmt: + _add(func_ea, fcmt, False) + if not hexrays_ok: + continue + try: + uc = ida_hexrays.restore_user_cmts(func_ea) + except Exception: + uc = None + if not uc: + continue + try: + it = ida_hexrays.user_cmts_begin(uc) + while it != ida_hexrays.user_cmts_end(uc): + tl = ida_hexrays.user_cmts_first(it) + cmt = ida_hexrays.user_cmts_second(it) + text = str(cmt) if cmt is not None else "" + _add(int(tl.ea), text, True) + it = ida_hexrays.user_cmts_next(it) + finally: + ida_hexrays.user_cmts_free(uc) + + return results @execute_write diff --git a/declib/decompilers/ida/interface.py b/declib/decompilers/ida/interface.py index d4592e0..e6bad0a 100755 --- a/declib/decompilers/ida/interface.py +++ b/declib/decompilers/ida/interface.py @@ -611,12 +611,13 @@ def _get_comment(self, addr) -> Optional[Comment]: if ida_cmt is None: return None - # TODO: need to be better implemented! return Comment(addr=addr, comment=str(ida_cmt), decompiled=True) + def _del_comment(self, addr) -> bool: + return compat.del_ida_comment(addr) + def _comments(self) -> Dict[int, Comment]: - # TODO: implement me! - return {} + return compat.comments() # segments def _set_segment(self, segment: Segment, **kwargs) -> bool: diff --git a/declib/skills/decompiler/SKILL.md b/declib/skills/decompiler/SKILL.md index 89323d2..792fa94 100644 --- a/declib/skills/decompiler/SKILL.md +++ b/declib/skills/decompiler/SKILL.md @@ -136,6 +136,9 @@ same binary. | `disassemble ` | Assembly for a function. | `--raw`, same | | `xref_to ` | Every reference (code + data) to the target. | `--decompile`, same | | `xref_from ` | Functions that `target` calls. | same | +| `comment set/append ` | Set (replace) or append a comment at an address. `--decompiled` attaches it to the pseudocode view. | `--decompiled`, same + `--json` | +| `comment get/delete ` | Read or remove the comment at an address. | same | +| `comment list` | List every comment in the binary. | `--filter REGEX`, same | | `rename func ` | Rename a function. | same + `--json` | | `rename var --function ` | Rename a local variable inside a function. | same | | `create-type ""` | Define a new `struct`/`enum`/`typedef` from a C string and add it to the type database. | same + `--json` | @@ -161,6 +164,27 @@ same binary. for `get_callers`; when you want "who touches this in any way?" reach for `xref_to`. +### `comment` — annotate addresses + +Comments are the primary way to leave durable notes for later (or for another +agent). They are keyed by address and come in two flavors: disassembly comments +(default) and decompiler/pseudocode comments (`--decompiled`). + +```bash +decompiler comment set 0x71d "entry point; parses argv" # replace +decompiler comment append 0x71d "calls authenticate()" # add a line +decompiler comment set 0x664 "SOSNEAKY backdoor" --decompiled +decompiler comment get 0x71d # print it (exit 1 if none) +decompiler comment list --filter backdoor # find comments by text +decompiler comment delete 0x71d +``` + +Addresses accept the usual lifted/absolute/decimal forms. `comment set` writes +through the backend, so on IDA the address must be **inside a function** +(disassembly comments elsewhere aren't supported by IDA's API). `angr` +implements comment writes but not reads/enumeration yet — `comment get`/`list` +return nothing there. Pair with `save` to make comments durable across reloads. + ### Persistence — durable artifacts (`save`, `stop --save`) By default a server holds the binary open in memory; IDA discards edits on diff --git a/docs/decompiler_cli.md b/docs/decompiler_cli.md index 11e0745..32f89b1 100644 --- a/docs/decompiler_cli.md +++ b/docs/decompiler_cli.md @@ -324,6 +324,35 @@ decompiler rename var --function [--decompiled] [--id ID] [--json] +decompiler comment append [--decompiled] [--id ID] [--json] +decompiler comment get [--id ID] [--json] +decompiler comment delete [--id ID] [--json] +decompiler comment list [--filter REGEX] [--id ID] [--json] +``` + +`--decompiled` attaches the comment to the decompiler/pseudocode view instead +of the disassembly. `set` replaces any existing comment; `append` concatenates. +`get` exits `1` when there is no comment at the address. + +```bash +decompiler comment set 0x71d "parses argv, calls authenticate" +decompiler comment append 0x71d "SOSNEAKY is the backdoor password" +decompiler comment get 0x71d +# {"addr": 1821, "decompiled": false, "comment": "parses argv, calls authenticate\nSOSNEAKY is the backdoor password", "addr_hex": "0x71d"} +decompiler comment list --filter backdoor --json +``` + +Backend notes: IDA accepts comments only at addresses **inside a function**; +Ghidra and Binary Ninja are more permissive. `angr` implements comment writes +but not reads/enumeration, so `get`/`list` return nothing there. Use `save` +(or `stop --save`) to persist comments across a reload. + ### `list_strings` List strings the decompiler's own string detector has identified in the diff --git a/tests/test_decompiler_cli.py b/tests/test_decompiler_cli.py index 1e9666d..4057da5 100644 --- a/tests/test_decompiler_cli.py +++ b/tests/test_decompiler_cli.py @@ -458,6 +458,81 @@ def test_stop_save_and_discard_are_mutually_exclusive(self): self.assertNotEqual(result.returncode, 0) self.assertIn("mutually exclusive", result.stdout + result.stderr) + # ------------------------------------------------------------------- + # comment CRUD + # ------------------------------------------------------------------- + + #: Backends that can read comments back (angr only implements set today). + _reads_comments: bool = True + + def _main_start_addr(self): + """Lifted start address of fauxware's main across backends.""" + entries = json.loads(_run_cli("list_functions", "--json").stdout) + for e in entries: + if e.get("name") in ("main", "_main"): + return e["addr"] + for e in entries: + if e.get("addr") == 0x71d: + return e["addr"] + self.fail("couldn't find main for comment test") + + def test_comment_set_get(self): + self._load_fauxware_isolated() + addr = self._main_start_addr() + text = "declib annotated this" + setr = _run_cli("comment", "set", _format_hex(addr), text, "--json", check=False) + if setr.returncode != 0: + self.skipTest(f"{self.backend}: comment set unsupported here: {setr.stdout + setr.stderr}") + self.assertTrue(json.loads(setr.stdout)["success"]) + + if not self._reads_comments: + self.skipTest(f"{self.backend} does not implement comment reads") + got = _run_cli("comment", "get", _format_hex(addr), "--json") + self.assertIn(text, json.loads(got.stdout)["comment"], + f"{self.backend}: comment get did not return the set text") + + def test_comment_append(self): + if not self._reads_comments: + self.skipTest(f"{self.backend} does not implement comment reads") + self._load_fauxware_isolated() + addr = self._main_start_addr() + first = _run_cli("comment", "set", _format_hex(addr), "first line", "--json", check=False) + if first.returncode != 0: + self.skipTest(f"{self.backend}: comment set unsupported here") + _run_cli("comment", "append", _format_hex(addr), "second line", "--json") + got = json.loads(_run_cli("comment", "get", _format_hex(addr), "--json").stdout) + self.assertIn("first line", got["comment"]) + self.assertIn("second line", got["comment"]) + + def test_comment_list_and_delete(self): + if not self._reads_comments: + self.skipTest(f"{self.backend} does not implement comment reads") + self._load_fauxware_isolated() + addr = self._main_start_addr() + marker = "UNIQUE_COMMENT_MARKER_XYZ" + setr = _run_cli("comment", "set", _format_hex(addr), marker, "--json", check=False) + if setr.returncode != 0: + self.skipTest(f"{self.backend}: comment set unsupported here") + + listing = json.loads(_run_cli("comment", "list", "--filter", marker, "--json").stdout) + self.assertTrue(any(marker in e["comment"] for e in listing), + f"{self.backend}: set comment not enumerated by `comment list`") + + deleted = _run_cli("comment", "delete", _format_hex(addr), "--json") + self.assertTrue(json.loads(deleted.stdout)["deleted"]) + after = _run_cli("comment", "get", _format_hex(addr), check=False) + self.assertNotEqual(after.returncode, 0, + f"{self.backend}: comment still present after delete") + + def test_comment_get_missing_exits_nonzero(self): + self._load_fauxware_isolated() + addr = self._main_start_addr() + # Clear anything at main's entry, then a get there must report "missing". + # (Deterministic across backends without depending on auto-comment layout.) + _run_cli("comment", "delete", _format_hex(addr), check=False) + result = _run_cli("comment", "get", _format_hex(addr), check=False) + self.assertNotEqual(result.returncode, 0) + # ------------------------------------------------------------------- # create-type / retype (run against every backend) # ------------------------------------------------------------------- @@ -552,6 +627,8 @@ def test_retype_missing_var_exits_1(self): class TestDecompilerCLIAngr(_CLIBackendTestBase): """angr backend: always available (pure-Python dependency).""" backend = "angr" + # angr implements comment *writes* but not reads/enumeration yet. + _reads_comments = False # angr-specific sanity checks that don't map cleanly to the other # backends live here.