From 7d8da9fd8b7c9b218abc25e20db76a3f6ee8d4d3 Mon Sep 17 00:00:00 2001 From: mahaloz Date: Fri, 17 Jul 2026 02:34:46 +0000 Subject: [PATCH] Add search (bytes/string/instruction) and imports listing to the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents previously had to shell out to nm/strings/objdump/readelf for these. - New `decompiler search bytes|string|instruction`: - `bytes` — native byte-pattern search (IDA find_bytes, Ghidra Memory.findBytes, Binary Ninja find_all_data). angr has no such API. - `string` — searches the string's bytes via the same path. - `instruction` — regex over each function's disassembly, client-side, so it works on every backend (capped by --max). - New `decompiler imports` — enumerate imported symbols (IDA import modules, angr cle loader, Binary Ninja imported-function symbols; Ghidra returns not-implemented for now). - Interface search_bytes / list_imports (base raises NotImplementedError) with per-backend overrides + client proxies. - Tests: search bytes/string (ELF magic, SOSNEAKY), instruction (call), imports (libc symbols). IDA + Ghidra full; angr skips byte-search, runs instruction + imports. SKILL.md / docs updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HfZHdprXg38re1XyKxzGg5 --- declib/api/decompiler_client.py | 8 ++ declib/api/decompiler_interface.py | 17 +++ declib/cli/decompiler_cli.py | 146 +++++++++++++++++++++++++ declib/decompilers/angr/interface.py | 12 ++ declib/decompilers/binja/interface.py | 24 ++++ declib/decompilers/ghidra/interface.py | 23 ++++ declib/decompilers/ida/compat.py | 37 +++++++ declib/decompilers/ida/interface.py | 9 ++ declib/skills/decompiler/SKILL.md | 21 ++++ docs/decompiler_cli.md | 24 ++++ tests/test_decompiler_cli.py | 61 +++++++++++ 11 files changed, 382 insertions(+) diff --git a/declib/api/decompiler_client.py b/declib/api/decompiler_client.py index a9a2aa9..1697811 100644 --- a/declib/api/decompiler_client.py +++ b/declib/api/decompiler_client.py @@ -565,6 +565,14 @@ def read_memory(self, addr: int, size: int) -> Optional[bytes]: """Read raw bytes from the loaded program.""" return self._send_request({"type": "method_call", "method_name": "read_memory", "args": [addr, size]}) + def search_bytes(self, pattern: bytes, max_results: int = 100) -> List[int]: + """Return lifted addresses where the byte pattern occurs.""" + return self._send_request({"type": "method_call", "method_name": "search_bytes", "args": [pattern, max_results]}) + + def list_imports(self) -> List: + """Return (lifted_addr, name, library) tuples for imported symbols.""" + return self._send_request({"type": "method_call", "method_name": "list_imports"}) + def save(self, path: Optional[str] = None) -> bool: """Persist the backend's analysis to disk. Raises NotImplementedError for in-memory backends.""" return self._send_request({"type": "method_call", "method_name": "save", "args": [path]}) diff --git a/declib/api/decompiler_interface.py b/declib/api/decompiler_interface.py index 0b9eb1f..a4d10b0 100644 --- a/declib/api/decompiler_interface.py +++ b/declib/api/decompiler_interface.py @@ -621,6 +621,23 @@ def set_persist_on_close(self, value: bool) -> bool: self.persist_on_close = bool(value) return True + def search_bytes(self, pattern: bytes, max_results: int = 100) -> List[int]: + """Return lifted addresses where the raw byte ``pattern`` occurs. + + Subclasses with a native byte-search API override this. The base + raises ``NotImplementedError`` so the CLI can report the capability as + unsupported for backends (e.g. angr) that lack one. + """ + raise NotImplementedError("Byte search is not implemented for this backend.") + + def list_imports(self) -> List[Tuple[int, str, str]]: + """Return ``(lifted_addr, name, library)`` tuples for imported symbols. + + Subclasses override with a native import enumeration. The base raises + ``NotImplementedError``. + """ + raise NotImplementedError("Import listing is not implemented for this backend.") + def get_callgraph(self, only_names=False) -> nx.DiGraph: """ Returns the callgraph of the binary. This is a dict of function addresses to a list of function addresses diff --git a/declib/cli/decompiler_cli.py b/declib/cli/decompiler_cli.py index 043f039..80d0889 100644 --- a/declib/cli/decompiler_cli.py +++ b/declib/cli/decompiler_cli.py @@ -17,6 +17,8 @@ - xref_to data + code references to a target - xref_from things a function calls (callees) - comment get/set/append/delete/list comments (annotations) +- search find bytes/string/instruction patterns +- imports list imported symbols - global list/get/rename/retype global variables - signature get/set a function's full signature (prototype) - rename rename a function or local variable @@ -1314,6 +1316,113 @@ def cmd_read_memory(args) -> int: return 0 +# --------------------------------------------------------------------------- +# search (bytes / string / instruction) + imports +# --------------------------------------------------------------------------- + +def _enrich_with_function(client, addr: int) -> Optional[str]: + """Best-effort name of the function containing (or at) ``addr``.""" + try: + func = client.functions.get_light(addr) + if func is not None: + return getattr(func, "name", None) + except Exception: + pass + return None + + +def cmd_search(args) -> int: + """Search the binary by raw bytes, string, or instruction text.""" + action = args.search_action + with _with_client(args) as client: + if action in ("bytes", "string"): + if action == "bytes": + cleaned = args.pattern.replace(" ", "").replace("0x", "") + try: + pattern = bytes.fromhex(cleaned) + except ValueError: + raise SystemExit(f"Invalid hex byte pattern: {args.pattern!r}") + else: + pattern = args.text.encode(args.encoding) + if not pattern: + raise SystemExit("Empty search pattern.") + try: + addrs = client.search_bytes(pattern, args.max) + except NotImplementedError as exc: + raise NotImplementedError(str(exc) or "byte search not available on this backend") + results = [{"addr": a} for a in addrs] + if args.json: + _emit(args, {"pattern_hex": pattern.hex(), "count": len(results), "matches": results}) + else: + if not results: + print("No matches.") + return EXIT_OK + for a in addrs: + print(_format_addr_hex(a)) + return EXIT_OK + + if action == "instruction": + # Client-side: grep the disassembly of each function. Works on every + # backend (disassemble is universal) at the cost of decompiling/ + # disassembling as it goes, so it's capped by --max. + try: + pattern = re.compile(args.pattern, re.IGNORECASE) + except re.error as exc: + raise SystemExit(f"Invalid regex: {exc}") + matches: List[Dict] = [] + for addr, func in sorted(client.functions.items(), key=lambda kv: kv[0]): + disasm = client.disassemble(addr) or "" + for line in disasm.splitlines(): + if pattern.search(line): + matches.append({ + "func_addr": addr, + "func": getattr(func, "name", None), + "line": line.strip(), + }) + if len(matches) >= args.max: + break + if len(matches) >= args.max: + break + if args.json: + _emit(args, {"pattern": args.pattern, "count": len(matches), "matches": matches}) + else: + if not matches: + print("No matching instructions.") + return EXIT_OK + for m in matches: + print(f"{_format_addr_hex(m['func_addr'])}\t{m['func'] or ''}\t{m['line']}") + return EXIT_OK + + raise SystemExit(f"Unknown search action: {action}") + + +def cmd_imports(args) -> int: + """List imported symbols (external functions/data).""" + with _with_client(args) as client: + try: + imps = client.list_imports() + except NotImplementedError as exc: + raise NotImplementedError(str(exc) or "import listing not available on this backend") + pattern = re.compile(args.filter) if args.filter else None + entries = [] + for item in imps: + addr, name, lib = (list(item) + [None, None])[:3] + if pattern and not (name and pattern.search(name)): + continue + entries.append({"addr": addr, "name": name, "library": lib}) + entries.sort(key=lambda e: (e.get("library") or "", e.get("name") or "")) + if args.json: + _emit_list(args, entries) + else: + if not entries: + print("No imports.") + return EXIT_OK + for e in entries: + lib = f" [{e['library']}]" if e.get("library") else "" + print(f"{_format_addr_hex(e['addr']) if isinstance(e['addr'], int) else '?'}\t{e['name']}{lib}") + return EXIT_OK + + # --------------------------------------------------------------------------- # read (typed: int / string / struct) # --------------------------------------------------------------------------- @@ -1892,6 +2001,43 @@ def _add_comment_addr_action(name, help_text, with_text=False): _add_output_args(p_gc) p_gc.set_defaults(func=cmd_get_callers) + # search + p_search = sub.add_parser( + "search", + help="Search the binary by raw bytes, string, or instruction text.", + ) + search_sub = p_search.add_subparsers(dest="search_action", required=True) + + p_sb = search_sub.add_parser("bytes", help="Find a raw byte pattern (hex).") + p_sb.add_argument("pattern", help='Hex bytes, e.g. "7f454c46" or "7f 45 4c 46".') + p_sb.add_argument("--max", type=int, default=100, help="Max matches (default 100).") + _add_server_filter_args(p_sb) + _add_output_args(p_sb) + p_sb.set_defaults(func=cmd_search) + + p_ss = search_sub.add_parser("string", help="Find a string's bytes in memory.") + p_ss.add_argument("text", help="Text to search for.") + p_ss.add_argument("--encoding", default="utf-8", help="Encoding (default utf-8).") + p_ss.add_argument("--max", type=int, default=100, help="Max matches (default 100).") + _add_server_filter_args(p_ss) + _add_output_args(p_ss) + p_ss.set_defaults(func=cmd_search) + + p_si = search_sub.add_parser("instruction", + help="Regex-search disassembly across all functions.") + p_si.add_argument("pattern", help="Regex matched against disassembly lines.") + p_si.add_argument("--max", type=int, default=100, help="Max matches (default 100).") + _add_server_filter_args(p_si) + _add_output_args(p_si) + p_si.set_defaults(func=cmd_search) + + # imports + p_imp = sub.add_parser("imports", help="List imported symbols (external functions/data).") + p_imp.add_argument("--filter", dest="filter", help="Regex to filter import names.") + _add_server_filter_args(p_imp) + _add_output_args(p_imp) + p_imp.set_defaults(func=cmd_imports) + # read (typed) p_read = sub.add_parser( "read", diff --git a/declib/decompilers/angr/interface.py b/declib/decompilers/angr/interface.py index 5003815..80763f2 100644 --- a/declib/decompilers/angr/interface.py +++ b/declib/decompilers/angr/interface.py @@ -245,6 +245,18 @@ def read_memory(self, addr: int, size: int) -> Optional[bytes]: return None return bytes(data) + def list_imports(self) -> List[tuple]: + out = [] + try: + imports = self.main_instance.project.loader.main_object.imports + except Exception: + return out + for name, reloc in imports.items(): + addr = getattr(reloc, "rebased_addr", 0) or 0 + lifted = self.art_lifter.lift_addr(addr) if addr else 0 + out.append((lifted, str(name), "")) + return out + def disassemble(self, addr: int, **kwargs) -> Optional[str]: lowered = self.art_lifter.lower_addr(addr) func = self.main_instance.project.kb.functions.get(lowered, None) diff --git a/declib/decompilers/binja/interface.py b/declib/decompilers/binja/interface.py index c3f7157..439d539 100644 --- a/declib/decompilers/binja/interface.py +++ b/declib/decompilers/binja/interface.py @@ -358,6 +358,30 @@ def read_memory(self, addr: int, size: int) -> Optional[bytes]: return None return bytes(data) + def search_bytes(self, pattern: bytes, max_results: int = 100) -> List[int]: + results = [] + try: + for match in self.bv.find_all_data(self.bv.start, self.bv.end, pattern): + # find_all_data yields (addr, DataBuffer); older versions yield addr. + addr = match[0] if isinstance(match, (tuple, list)) else match + results.append(self.art_lifter.lift_addr(int(addr))) + if len(results) >= max_results: + break + except Exception as e: + l.warning("Binary Ninja find_all_data failed: %s", e) + return results + + def list_imports(self) -> List[tuple]: + out = [] + try: + syms = self.bv.get_symbols_of_type(SymbolType.ImportedFunctionSymbol) or [] + except Exception as e: + l.warning("Binary Ninja import listing failed: %s", e) + return out + for sym in syms: + out.append((self.art_lifter.lift_addr(int(sym.address)), str(sym.name), "")) + return out + def start_artifact_watchers(self): if not self.artifact_watchers_started: from .hooks import DataMonitor diff --git a/declib/decompilers/ghidra/interface.py b/declib/decompilers/ghidra/interface.py index 41cca25..08240c4 100644 --- a/declib/decompilers/ghidra/interface.py +++ b/declib/decompilers/ghidra/interface.py @@ -538,6 +538,29 @@ def disassemble(self, addr: int, **kwargs) -> Optional[str]: return None return "\n".join(lines) if lines else None + def search_bytes(self, pattern: bytes, max_results: int = 100) -> List[int]: + import jpype + from ghidra.util.task import TaskMonitor + + memory = self.currentProgram.getMemory() + # Java bytes are signed; map 0..255 -> -128..127. Masks of 0xFF (-1) + # request an exact match on every byte. + byte_pat = jpype.JArray(jpype.JByte)([b - 256 if b >= 128 else b for b in pattern]) + masks = jpype.JArray(jpype.JByte)([-1] * len(pattern)) + results: List[int] = [] + start = memory.getMinAddress() + while start is not None and len(results) < max_results: + try: + found = memory.findBytes(start, byte_pat, masks, True, TaskMonitor.DUMMY) + except Exception as exc: + _l.warning("Ghidra findBytes failed: %s", exc) + break + if found is None: + break + results.append(self.art_lifter.lift_addr(int(found.getOffset()))) + start = found.add(1) + return results + def read_memory(self, addr: int, size: int) -> Optional[bytes]: if size <= 0: return b"" diff --git a/declib/decompilers/ida/compat.py b/declib/decompilers/ida/compat.py index 633ad6f..e3bd89b 100644 --- a/declib/decompilers/ida/compat.py +++ b/declib/decompilers/ida/compat.py @@ -1976,6 +1976,43 @@ def wait_for_idc_initialization(): idc.auto_wait() +@execute_write +def search_bytes(pattern, max_results=100): + """Return every EA where ``pattern`` (a bytes object) occurs. + + Walks the whole database with ``ida_bytes.find_bytes``, advancing one byte + past each hit, capped at ``max_results``. + """ + results = [] + lo = idaapi.inf_get_min_ea() + hi = idaapi.inf_get_max_ea() + ea = lo + while ea < hi and len(results) < max_results: + found = ida_bytes.find_bytes(pattern, ea) + if found is None or found == idaapi.BADADDR or found >= hi: + break + results.append(int(found)) + ea = found + 1 + return results + + +@execute_write +def list_imports(): + """Return ``(ea, name, library)`` tuples for every imported symbol.""" + imports = [] + qty = idaapi.get_import_module_qty() + for i in range(qty): + modname = idaapi.get_import_module_name(i) or "" + + def _cb(ea, name, ordn, _mod=modname): + if name: + imports.append((int(ea), str(name), _mod)) + return True + + idaapi.enum_import_names(i, _cb) + return imports + + @execute_write def set_function_prototype(func_addr, prototype): """Apply a full function prototype atomically via ``idc.SetType``. diff --git a/declib/decompilers/ida/interface.py b/declib/decompilers/ida/interface.py index e9339bc..a165d58 100755 --- a/declib/decompilers/ida/interface.py +++ b/declib/decompilers/ida/interface.py @@ -332,6 +332,15 @@ def read_memory(self, addr: int, size: int) -> Optional[bytes]: lowered = self.art_lifter.lower_addr(addr) return compat.read_memory(lowered, size) + def search_bytes(self, pattern: bytes, max_results: int = 100) -> List[int]: + return [self.art_lifter.lift_addr(ea) for ea in compat.search_bytes(pattern, max_results)] + + def list_imports(self) -> List[tuple]: + out = [] + for ea, name, lib in compat.list_imports(): + out.append((self.art_lifter.lift_addr(ea), name, lib)) + return out + def _collect_xrefs_to(self, lowered_addr: int, only_code: bool, _max_chase: int = 2) -> List[Artifact]: """Collect function-level xrefs to ``lowered_addr``. diff --git a/declib/skills/decompiler/SKILL.md b/declib/skills/decompiler/SKILL.md index 6bc858d..8d2da5f 100644 --- a/declib/skills/decompiler/SKILL.md +++ b/declib/skills/decompiler/SKILL.md @@ -150,6 +150,10 @@ same binary. | `sync --from-id ` | Copy a function's work (names, return/arg types, stack-var names+types, referenced user types) from one running server into another for the same binary. | dest: `--id`/`--binary`/`--backend`; `--json` | | `list_strings` | Strings the decompiler found (may be incomplete — see below). | `--filter`, `--min-length N`, same | | `get_callers ` | Call-sites only — subset of `xref_to`. | same | +| `search bytes ` | Find a raw byte pattern. | `--max`, same | +| `search string ` | Find a string's bytes in memory. | `--encoding`, `--max`, same | +| `search instruction ` | Regex-search disassembly across all functions. | `--max`, same | +| `imports` | List imported symbols (external functions/data). | `--filter REGEX`, same | | `read int/string/struct [...]` | Typed reads: decode memory as an integer, C string, or defined struct. | `--size`, `--signed`, `--endian`, `--max-len`, `--encoding`, same + `--json` | | `read_memory ` | Read raw bytes from the binary at ``. Default output is a hexdump. | `--format {hexdump,hex,raw}`, same + `--json` (base64-encoded bytes) | | `install-skill` | Install this file for Claude Code or Codex. | `--agent`, `--dest`, `--force`, `--json` | @@ -235,6 +239,23 @@ purely in-memory: `save` exits `2` (`not implemented`) and there is nothing to reload. IDA reopens the saved `.i64` directly (no re-analysis), so a reload after `save` is fast and lossless. +### `search` and `imports` — discovery + +```bash +decompiler search bytes "7f454c46" # raw byte pattern (hex) +decompiler search bytes "48 89 e5" # spaces are ignored +decompiler search string "SOSNEAKY" # a string's bytes in memory +decompiler search instruction "call.*puts" # regex over disassembly +decompiler imports --filter 'alloc|free' # imported symbols +``` + +`search bytes`/`string` use the backend's native memory search (IDA, Ghidra, +Binary Ninja); `angr` has no byte-search API, so those exit `2` +(`not implemented`). `search instruction` is client-side (it greps each +function's disassembly), so it works on **every** backend — but it disassembles +as it goes, so keep `--max` modest on large binaries. `imports` is available on +IDA, angr, and Binary Ninja; Ghidra returns `not implemented` for now. + ### `read` — typed reads (int / string / struct) `read_memory` gives you raw bytes; `read` decodes them so you don't have to diff --git a/docs/decompiler_cli.md b/docs/decompiler_cli.md index e38911c..2173576 100644 --- a/docs/decompiler_cli.md +++ b/docs/decompiler_cli.md @@ -439,6 +439,30 @@ decompiler get_callers [--id ID] [--binary PATH] [--backend BACKEND] [- Unlike `xref_to`, this never returns globals or other data refs. Rows are always of kind `Function`. +### `search` and `imports` + +Find byte/string/instruction patterns and enumerate imported symbols. + +```bash +decompiler search bytes [--max N] [--id ID] [--json] +decompiler search string [--encoding ENC] [--max N] [--json] +decompiler search instruction [--max N] [--json] +decompiler imports [--filter REGEX] [--id ID] [--json] +``` + +```bash +decompiler search bytes "7f454c46" # -> 0x0 (ELF magic at the base) +decompiler search string "SOSNEAKY" +decompiler search instruction "call.*authenticate" +decompiler imports --filter 'puts|read' +``` + +`search bytes`/`string` use each backend's native memory search (IDA, Ghidra, +Binary Ninja); `angr` lacks one and returns exit `2`. `search instruction` is +client-side (greps disassembly), so it works everywhere but disassembles as it +goes — keep `--max` modest. `imports` is implemented on IDA, angr, and Binary +Ninja; Ghidra returns `not implemented`. + ### `read` (typed) and address semantics `read_memory` returns raw bytes; `read` decodes them. Decoding happens diff --git a/tests/test_decompiler_cli.py b/tests/test_decompiler_cli.py index 7b09692..43835b6 100644 --- a/tests/test_decompiler_cli.py +++ b/tests/test_decompiler_cli.py @@ -350,6 +350,64 @@ def test_read_memory_invalid_address(self): combined = result.stdout + result.stderr self.assertNotIn("|.ELF", combined) + # ------------------------------------------------------------------- + # search + imports + # ------------------------------------------------------------------- + + #: Backends with a native byte-search API (angr has none). + _searches_bytes: bool = True + #: Backends that enumerate imports. + _lists_imports: bool = True + + def test_search_bytes_elf_magic(self): + if not self._searches_bytes: + self.skipTest(f"{self.backend} has no byte-search API") + self._load_fauxware_isolated() + result = _run_cli("search", "bytes", "7f454c46", "--json", check=False) + if result.returncode == 2: + self.skipTest(f"{self.backend}: search bytes unsupported") + payload = json.loads(result.stdout) + self.assertGreaterEqual(payload["count"], 1, + f"{self.backend}: ELF magic not found by search bytes") + # The ELF magic sits at the image base -> lifted 0x0. + addrs = {m["addr"] for m in payload["matches"]} + self.assertIn(0, addrs, f"{self.backend}: expected a match at lifted 0x0: {addrs}") + + def test_search_string(self): + if not self._searches_bytes: + self.skipTest(f"{self.backend} has no byte-search API") + self._load_fauxware_isolated() + result = _run_cli("search", "string", "SOSNEAKY", "--json", check=False) + if result.returncode == 2: + self.skipTest(f"{self.backend}: search string unsupported") + payload = json.loads(result.stdout) + self.assertGreaterEqual(payload["count"], 1, + f"{self.backend}: 'SOSNEAKY' not found in memory") + + def test_search_instruction(self): + """Instruction search is client-side (disasm grep) — works on all backends.""" + self._load_fauxware_isolated() + result = _run_cli("search", "instruction", r"call", "--max", "50", "--json") + payload = json.loads(result.stdout) + self.assertGreaterEqual(payload["count"], 1, + f"{self.backend}: no 'call' instructions found") + for m in payload["matches"]: + self.assertIn("call", m["line"].lower()) + + def test_imports_list(self): + if not self._lists_imports: + self.skipTest(f"{self.backend} does not enumerate imports") + self._load_fauxware_isolated() + result = _run_cli("imports", "--json", check=False) + if result.returncode == 2: + self.skipTest(f"{self.backend}: imports unsupported") + entries = json.loads(result.stdout) + names = {e["name"] for e in entries} + # fauxware imports libc functions; at least one of these is always present. + self.assertTrue(any(n and any(k in n for k in ("puts", "read", "printf", "strcmp")) + for n in names), + f"{self.backend}: expected a libc import in {names}") + # ------------------------------------------------------------------- # typed reads + address semantics # ------------------------------------------------------------------- @@ -770,6 +828,9 @@ class TestDecompilerCLIAngr(_CLIBackendTestBase): _reads_globals = False # angr's signature set applies argument names but not types/return type. _sets_signature_types = False + # angr has no native byte-search API (instruction search still works — + # it's client-side). It does enumerate imports via the cle loader. + _searches_bytes = False # angr-specific sanity checks that don't map cleanly to the other # backends live here.