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
4 changes: 4 additions & 0 deletions declib/api/decompiler_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,10 @@ 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 backend_eval(self, code: str, mode: str = "eval") -> Dict:
"""UNSAFE: run arbitrary Python in the backend process. See the interface docstring."""
return self._send_request({"type": "method_call", "method_name": "backend_eval", "args": [code], "kwargs": {"mode": mode}})

def define_function(self, addr: int) -> bool:
"""Create a function at ``addr`` (lifted)."""
return self._send_request({"type": "method_call", "method_name": "define_function", "args": [addr]})
Expand Down
35 changes: 35 additions & 0 deletions declib/api/decompiler_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,41 @@ def list_imports(self) -> List[Tuple[int, str, str]]:
"""
raise NotImplementedError("Import listing is not implemented for this backend.")

def backend_eval(self, code: str, mode: str = "eval") -> Dict:
"""UNSAFE escape hatch: run arbitrary Python inside the backend process.

The code executes with this interface bound to ``deci`` and full access
to the backend's own Python API (``idaapi``, the Ghidra ``flat_api`` via
``deci.flat_api``, ``deci.project`` for angr, ``deci.bv`` for Binary
Ninja, ...). This is deliberately backend-specific and not portable — it
exists for the cases the abstracted API doesn't cover.

@param code: Python source. ``mode='eval'`` evaluates a single
expression and returns its ``repr``; ``mode='exec'`` runs
statements, capturing stdout and any variable named ``result``.
@return: ``{"ok", "result", "stdout", "traceback"}``. (The failure key is
``traceback`` rather than ``error`` on purpose — the client/server
wire protocol reserves a top-level ``error`` key for RPC failures.)
"""
import io
import contextlib
import traceback as _traceback

env = {"deci": self, "__name__": "__declib_backend_eval__"}
out = io.StringIO()
try:
with contextlib.redirect_stdout(out):
if mode == "exec":
exec(code, env) # noqa: S102 - intentional escape hatch
value = env.get("result", None)
result = repr(value) if value is not None else ""
else:
result = repr(eval(code, env)) # noqa: S307 - intentional escape hatch
return {"ok": True, "result": result, "stdout": out.getvalue(), "traceback": None}
except Exception: # noqa: BLE001 - surface any backend error verbatim
return {"ok": False, "result": None, "stdout": out.getvalue(),
"traceback": _traceback.format_exc()}

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
66 changes: 66 additions & 0 deletions declib/cli/decompiler_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
- define define a function/code/data at an address
- undefine clear code/data at an address
- patch set/get/list/delete byte patches
- eval / exec UNSAFE: run Python in the backend process (escape hatch)
- 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 @@ -1319,6 +1320,49 @@ def cmd_read_memory(args) -> int:
return 0


# ---------------------------------------------------------------------------
# eval / exec — UNSAFE backend scripting escape hatch
# ---------------------------------------------------------------------------

def _run_backend_script(args, code: str, mode: str) -> int:
with _with_client(args) as client:
res = client.backend_eval(code, mode=mode)
if not res.get("ok"):
if res.get("stdout"):
sys.stdout.write(res["stdout"])
print(res.get("traceback") or f"{mode} failed", file=sys.stderr)
if args.json:
_emit(args, res)
return EXIT_RUNTIME_ERROR
if args.json:
_emit(args, res)
else:
if res.get("stdout"):
sys.stdout.write(res["stdout"])
if res.get("result"):
print(res["result"])
return EXIT_OK


def cmd_eval(args) -> int:
"""UNSAFE: evaluate a Python expression inside the backend process."""
return _run_backend_script(args, args.expression, "eval")


def cmd_exec(args) -> int:
"""UNSAFE: execute Python code inside the backend process."""
if args.file:
try:
code = Path(args.file).read_text()
except OSError as exc:
raise SystemExit(f"Could not read {args.file!r}: {exc}")
else:
code = args.code
if not code:
raise SystemExit("Provide Python code as an argument or via --file.")
return _run_backend_script(args, code, "exec")


# ---------------------------------------------------------------------------
# patch (set / get / list / delete)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2113,6 +2157,28 @@ def _add_comment_addr_action(name, help_text, with_text=False):
_add_output_args(p_gc)
p_gc.set_defaults(func=cmd_get_callers)

# eval / exec (UNSAFE backend scripting)
p_eval = sub.add_parser(
"eval",
help="UNSAFE, backend-specific: evaluate a Python expression in the backend process.",
)
p_eval.add_argument("expression",
help="Python expression; `deci` is the DecompilerInterface (idaapi, "
"deci.flat_api, deci.project, deci.bv are reachable).")
_add_server_filter_args(p_eval)
_add_output_args(p_eval)
p_eval.set_defaults(func=cmd_eval)

p_exec = sub.add_parser(
"exec",
help="UNSAFE, backend-specific: execute Python code in the backend process.",
)
p_exec.add_argument("code", nargs="?", help="Python source (or use --file).")
p_exec.add_argument("--file", dest="file", help="Read Python source from a file instead.")
_add_server_filter_args(p_exec)
_add_output_args(p_exec)
p_exec.set_defaults(func=cmd_exec)

# patch
p_patch = sub.add_parser("patch", help="Apply, inspect, list, or revert byte patches.")
patch_sub = p_patch.add_subparsers(dest="patch_action", required=True)
Expand Down
23 changes: 23 additions & 0 deletions declib/skills/decompiler/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ same binary.
| `patch set <addr> <hex>` | Patch bytes at an address. | same |
| `patch get/delete <addr>` | Show or revert the patch at an address (IDA). | same |
| `patch list` | List all byte patches (IDA). | same |
| `eval "<expr>"` | **UNSAFE**: evaluate a Python expression in the backend process. | same |
| `exec "<code>"` / `exec --file <p>` | **UNSAFE**: run Python in the backend process. | `--file`, 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 @@ -244,6 +246,27 @@ 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.

### `eval` / `exec` — UNSAFE backend scripting (escape hatch)

When the abstracted API doesn't cover what you need, drop to the backend's own
Python. **This runs arbitrary code inside the backend process** — it is not
portable and not sandboxed. `deci` is the live DecompilerInterface; the backend
API is reachable through it (`idaapi` importable; `deci.flat_api` on Ghidra;
`deci.project` on angr; `deci.bv` on Binary Ninja).

```bash
decompiler eval "deci.name" # -> 'ida'
decompiler eval "len(list(deci.functions.keys()))" # count functions
decompiler exec "print(hex(deci.binary_base_addr))" # captured stdout
decompiler exec "result = [f.name for _,f in deci.functions.items()][:5]"
decompiler exec --file ./my_ida_script.py # run a whole script
```

`eval` returns the expression's `repr`; `exec` captures stdout and the value of
a variable named `result`. On error, the CLI prints the traceback and exits
non-zero. Prefer the first-class commands (`rename`, `retype`, `comment`, ...)
when they exist — reach for `eval`/`exec` only for backend-specific gaps.

### `patch` — modify bytes

```bash
Expand Down
24 changes: 24 additions & 0 deletions docs/decompiler_cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,30 @@ decompiler get_callers <target> [--id ID] [--binary PATH] [--backend BACKEND] [-
Unlike `xref_to`, this never returns globals or other data refs. Rows are
always of kind `Function`.

### `eval` / `exec` (UNSAFE backend scripting)

An escape hatch for backend-specific work the abstracted API doesn't cover.
**This executes arbitrary Python inside the backend process** — not portable,
not sandboxed.

```bash
decompiler eval "<expression>" [--id ID] [--json]
decompiler exec "<code>" [--id ID] [--json]
decompiler exec --file <path.py> [--id ID] [--json]
```

`deci` is the live `DecompilerInterface`; the backend's own API is reachable
through it (`idaapi` importable, `deci.flat_api` on Ghidra, `deci.project` on
angr, `deci.bv` on Binary Ninja). `eval` returns the expression's `repr`;
`exec` captures stdout and the value of a variable named `result`. Errors print
a traceback and exit non-zero.

```bash
decompiler eval "deci.name"
decompiler exec "print(hex(deci.binary_base_addr))"
decompiler exec --file ./analysis.py
```

### `patch`

Apply, inspect, list, or revert byte patches.
Expand Down
45 changes: 45 additions & 0 deletions tests/test_decompiler_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,51 @@ def test_read_memory_invalid_address(self):
combined = result.stdout + result.stderr
self.assertNotIn("|.ELF", combined)

# -------------------------------------------------------------------
# eval / exec (backend scripting escape hatch)
# -------------------------------------------------------------------

def test_eval_expression(self):
self._load_fauxware_isolated()
result = _run_cli("eval", "1 + 2", "--json")
payload = json.loads(result.stdout)
self.assertTrue(payload["ok"])
self.assertEqual(payload["result"], "3")

def test_eval_has_backend_access(self):
"""`deci` is bound and the backend is reachable from eval."""
self._load_fauxware_isolated()
result = _run_cli("eval", "len(list(deci.functions.keys())) > 0", "--json")
payload = json.loads(result.stdout)
self.assertTrue(payload["ok"], f"{self.backend}: eval failed: {payload}")
self.assertEqual(payload["result"], "True")
# deci.name reflects the running backend.
name = _run_cli("eval", "deci.name", "--json")
self.assertIn(self.backend, json.loads(name.stdout)["result"])

def test_exec_captures_stdout(self):
self._load_fauxware_isolated()
result = _run_cli("exec", "print('hello from', deci.name)", "--json")
payload = json.loads(result.stdout)
self.assertTrue(payload["ok"])
self.assertIn("hello from", payload["stdout"])
self.assertIn(self.backend, payload["stdout"])

def test_exec_result_variable(self):
self._load_fauxware_isolated()
result = _run_cli("exec", "result = 40 + 2", "--json")
payload = json.loads(result.stdout)
self.assertTrue(payload["ok"])
self.assertEqual(payload["result"], "42")

def test_eval_error_is_reported(self):
self._load_fauxware_isolated()
result = _run_cli("eval", "1/0", "--json", check=False)
self.assertNotEqual(result.returncode, 0)
payload = json.loads(result.stdout)
self.assertFalse(payload["ok"])
self.assertIn("ZeroDivisionError", payload["traceback"])

# -------------------------------------------------------------------
# patching
# -------------------------------------------------------------------
Expand Down