Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions declib/api/decompiler_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]})
Expand Down
17 changes: 17 additions & 0 deletions declib/api/decompiler_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
146 changes: 146 additions & 0 deletions declib/cli/decompiler_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions declib/decompilers/angr/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions declib/decompilers/binja/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions declib/decompilers/ghidra/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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""
Expand Down
37 changes: 37 additions & 0 deletions declib/decompilers/ida/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand Down
9 changes: 9 additions & 0 deletions declib/decompilers/ida/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand Down
21 changes: 21 additions & 0 deletions declib/skills/decompiler/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ same binary.
| `sync <func> --from-id <src>` | 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 <target>` | Call-sites only — subset of `xref_to`. | same |
| `search bytes <hex>` | Find a raw byte pattern. | `--max`, same |
| `search string <text>` | Find a string's bytes in memory. | `--encoding`, `--max`, same |
| `search instruction <regex>` | Regex-search disassembly across all functions. | `--max`, same |
| `imports` | List imported symbols (external functions/data). | `--filter REGEX`, same |
| `read int/string/struct <addr> [...]` | Typed reads: decode memory as an integer, C string, or defined struct. | `--size`, `--signed`, `--endian`, `--max-len`, `--encoding`, same + `--json` |
| `read_memory <addr> <size>` | Read raw bytes from the binary at `<addr>`. 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` |
Expand Down Expand Up @@ -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
Expand Down
Loading