Skip to content

Commit d3fac36

Browse files
authored
Merge pull request #10 from buzzer-re/dev/binja
Binary Ninja support over RPC
2 parents 5c6f356 + 81b5b99 commit d3fac36

11 files changed

Lines changed: 1753 additions & 10 deletions

File tree

AGENTS.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ This repository contains ToCode, a Python-only binary exporter. ToCode takes one
1313
## Project Layout
1414

1515
- `src/tocode/cli.py`: command-line entry point for `tocode`.
16+
- `src/tocode/__init__.py`: package version and `export_from_binaryview()`, the in-UI Binary Ninja library entry.
1617
- `src/tocode/analysis.py`: backend-neutral binary inventory and call graph normalization.
17-
- `src/tocode/backends/`: IDA Domain and radare2 session adapters.
18+
- `src/tocode/backends/`: IDA Domain, radare2, angr, and Binary Ninja (`binja.py`) session adapters.
1819
- `src/tocode/exporter.py`: project writer, function rendering, worker-session rendering, generated export `AGENTS.md`.
1920
- `src/tocode/metadata.py`: JSON metadata and triage documents.
2021
- `src/tocode/cluster.py`: call-graph clustering.
@@ -72,8 +73,38 @@ When `--tree` is passed, the export also contains:
7273
- Runtime dependencies belong in `pyproject.toml` and `uv.lock`.
7374
- IDA Domain is the preferred backend when available.
7475
- radare2/r2pipe is a fallback backend.
76+
- angr is the optional pure-Python fallback backend (`[angr]` extra).
77+
- Binary Ninja is an opt-in backend (`--backend binja`, never auto-selected). The
78+
`binaryninja` module is supplied by the Binary Ninja install (in-UI) or the
79+
remote VM, so it is not a pip dependency. `rpyc`, the client used for the
80+
headless path via [binja-headless](https://github.com/hugsy/binja-headless),
81+
is a core runtime dependency so the backend works out of the box.
82+
- Pin new dependencies exactly (`==`) and capture them in `uv.lock`.
7583
- Keep dependencies minimal and tied to exporting a project.
7684

85+
## Binary Ninja backend
86+
87+
- Headless (no enterprise license): run binja-headless inside a running Binary
88+
Ninja, then `tocode --backend binja --binja-host <ip>`. It exports an already
89+
**loaded** view (the binary positional is optional). Configure the connection
90+
with `--binja-host`/`--binja-port` or `TOCODE_BINJA_HOST` / `TOCODE_BINJA_PORT`
91+
(defaults `127.0.0.1:18812`).
92+
- Choosing the view (headless): default is the focused view (the one binja-headless
93+
exposes as `conn.root.bv`). `--list-binja` prints every open view with an index;
94+
`--binja-view N` exports that index; `--all-views` exports every open view, each
95+
into its own folder under `-o` (or the cwd). Active-context lookup is Qt-main-
96+
thread bound and unavailable over RPyc, so the focused view comes from
97+
`conn.root.bv`; open views are enumerated through `binaryninjaui.UIContext` via
98+
the service's remote `eval`. If no view is open, a binary path argument is
99+
remote-loaded as a fallback.
100+
- In the Binary Ninja UI: call `tocode.export_from_binaryview(bv, out_dir)` from
101+
the scripting console (`bv` is the live view; `binaryninja` is imported
102+
locally). It runs the same export pipeline as the CLI.
103+
- The backend renders serially against the live view (`parallel_safe = False` and
104+
excluded from `exporter.TIMEOUT_WORKER_BACKENDS`): a `BinaryView` / RPyc
105+
connection cannot be pickled into a worker process. Do not add it to the
106+
spawned-worker path.
107+
77108
## Verification
78109

79110
Run focused checks after changes:

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,11 @@ Three backends are supported, selected with `--backend` (default `auto`, which p
7272

7373
The angr export is structurally identical to the others (same files and metadata); its pseudo-C is lower quality than Hex-Rays or r2ghidra.
7474

75+
4. **Binary Ninja**, is opt-in (`--backend binja`) and never chosen by `auto`, because it drives a running Binary Ninja instead of reading a file. See [Binary Ninja](#binary-ninja) below.
76+
7577
Other disassemblers may be added in the future.
7678

79+
7780
### Using
7881

7982
ToCode supports Windows, Linux, and macOS with Python 3.10 or newer.
@@ -123,6 +126,54 @@ tocode firmwareX.bin.i64 -o firmwareX_decompiled/
123126
...
124127
```
125128

129+
### Binary Ninja
130+
131+
The Binary Ninja backend exports the binary you already have **open** in Binary Ninja, emitting its Pseudo C. It does **not** need a Binary Ninja *headless* license: ToCode connects to the running GUI over a small RPC bridge.
132+
133+
Since I don't have access to Binary Ninja headless, run this script once in Binary Ninja's Python console to open an RPC server ToCode can connect to:
134+
135+
```python
136+
import threading, rpyc, binaryninja
137+
from rpyc.utils.server import ThreadedServer
138+
139+
class ToCodeService(rpyc.Service):
140+
exposed_binaryninja = binaryninja
141+
def exposed_bv(self):
142+
return bv # the currently focused view
143+
def exposed_eval(self, cmd):
144+
return eval(cmd)
145+
146+
server = ThreadedServer(
147+
ToCodeService,
148+
hostname="127.0.0.1",
149+
port=18812,
150+
protocol_config={"allow_all_attrs": True},
151+
)
152+
threading.Thread(target=server.start, daemon=True).start()
153+
print("ToCode RPC server listening on 127.0.0.1:18812")
154+
```
155+
156+
It exposes the whole Binary Ninja Python VM with no authentication, so keep it bound to `127.0.0.1`. The [binja-headless](https://github.com/hugsy/binja-headless) plugin exposes the same interface if you prefer a packaged option.
157+
158+
Then drive it from ToCode:
159+
160+
```bash
161+
tocode --backend binja --list-binja # list open views with an index
162+
tocode --backend binja -o out/ # export the focused view
163+
tocode --backend binja --binja-view 1 -o out/ # export a specific view by index
164+
tocode --backend binja --all-views -o out/ # export every open view, one folder each
165+
```
166+
167+
Use `--binja-host` / `--binja-port` (or `TOCODE_BINJA_HOST` / `TOCODE_BINJA_PORT`; default `127.0.0.1:18812`) to reach Binary Ninja on another machine.
168+
169+
Already scripting inside Binary Ninja? Skip the server and export the live view directly:
170+
171+
```python
172+
import sys; sys.path.insert(0, "/path/to/ToCode/src")
173+
from tocode import export_from_binaryview
174+
export_from_binaryview(bv, "out/")
175+
```
176+
126177
## Development
127178

128179
This tool was built using agentic coding, so if you plan to help, I strongly advise doing the same.

pyproject.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ dependencies = [
1212
"ida-domain==0.5.0",
1313
"idapro==0.0.9",
1414
"r2pipe==1.9.8",
15+
"rpyc==6.0.2",
1516
"tqdm==4.67.3",
1617
]
1718
license = "MIT"
@@ -35,6 +36,10 @@ dev = [
3536
angr = [
3637
"angr>=9.2",
3738
]
39+
# The Binary Ninja backend's headless client (rpyc) is a core runtime dependency
40+
# above so `tocode --backend binja` works out of the box. The binaryninja module
41+
# itself is provided by the Binary Ninja install (in-UI) or the remote VM via
42+
# binja-headless, so it is intentionally not a pip dependency.
3843

3944
# angr is an optional backend dependency, so it may be absent from the type
4045
# checking environment. Ignore its missing stubs/imports rather than failing.
@@ -48,6 +53,12 @@ ignore_missing_imports = true
4853
module = ["elftools.*"]
4954
ignore_missing_imports = true
5055

56+
# binaryninja is supplied by the Binary Ninja install, so it is absent from the
57+
# type checking environment. Ignore its missing imports.
58+
[[tool.mypy.overrides]]
59+
module = ["binaryninja.*"]
60+
ignore_missing_imports = true
61+
5162
[tool.setuptools]
5263
package-dir = { "" = "src" }
5364

src/tocode/__init__.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,60 @@
11
"""ToCode binary exporter."""
22

3-
__all__ = ["__version__"]
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from typing import Any
7+
8+
__all__ = ["__version__", "export_from_binaryview"]
49

510
__version__ = "0.1.0"
11+
12+
13+
def export_from_binaryview(
14+
bv: Any,
15+
out_dir: str | Path,
16+
*,
17+
bn: Any = None,
18+
tree: bool = False,
19+
entropy: bool = False,
20+
jobs: int | None = None,
21+
quiet: bool = True,
22+
) -> Any:
23+
"""Export a project tree directly from a live Binary Ninja ``BinaryView``.
24+
25+
Intended for the Binary Ninja UI scripting console, where ``bv`` already
26+
exists. A complete run is then a few pasted lines::
27+
28+
import sys; sys.path.insert(0, "/path/to/ToCode/src")
29+
from tocode import export_from_binaryview
30+
export_from_binaryview(bv, "/tmp/out")
31+
32+
``bn`` is the ``binaryninja`` module; leave it ``None`` to import it locally
33+
(the in-UI case). Returns the :class:`~tocode.exporter.ExportSummary`.
34+
"""
35+
# Imported lazily so importing the package never drags in the backend stack.
36+
from .analysis import create_analyzer
37+
from .exporter import export_binary
38+
from .progress import Progress
39+
40+
progress = Progress(enabled=not quiet)
41+
view_file = getattr(bv, "file", None)
42+
filename = getattr(view_file, "filename", None) or getattr(
43+
view_file, "original_filename", None
44+
)
45+
input_path = Path(str(filename)) if filename else Path("binaryview")
46+
with create_analyzer(
47+
input_path,
48+
backend="binja",
49+
progress=progress,
50+
binja_bv=bv,
51+
binja_bn=bn,
52+
) as analyzer:
53+
return export_binary(
54+
analyzer,
55+
out_dir=Path(out_dir),
56+
progress=progress,
57+
jobs=jobs,
58+
tree=tree,
59+
entropy=entropy,
60+
)

src/tocode/analysis.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,10 @@ def create_analyzer(
468468
idadir: Path | None = None,
469469
ida_domain_path: Path | None = None,
470470
purge_cache: bool = False,
471+
binja_host: str = "127.0.0.1",
472+
binja_port: int = 18812,
473+
binja_bv: Any = None,
474+
binja_bn: Any = None,
471475
) -> BinaryAnalyzer:
472476
choice = choose_backend(
473477
backend,
@@ -502,6 +506,17 @@ def create_analyzer(
502506
session=AngrSession(binary),
503507
progress=progress,
504508
)
509+
if choice.selected == "binja":
510+
# Imported lazily so non-binja exports never need the binaryninja/rpyc
511+
# stack. With an injected view (in the Binary Ninja UI) the session wraps
512+
# it directly; otherwise connect headless via binja-headless (RPyc).
513+
from .backends.binja import BinjaSession
514+
515+
if binja_bv is not None:
516+
session: DecompilerSession = BinjaSession(bv=binja_bv, bn=binja_bn)
517+
else:
518+
session = BinjaSession.connect_headless(binja_host, binja_port, path=binary)
519+
return BinaryAnalyzer(binary, session=session, progress=progress)
505520
return BinaryAnalyzer(
506521
binary,
507522
session=R2Session(binary, analysis_command=analysis_command),

src/tocode/backends/base.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
from ..errors import ToCodeError
1313

1414

15-
BackendRequest = Literal["auto", "ida", "r2", "angr"]
16-
BackendName = Literal["ida", "r2", "angr"]
15+
BackendRequest = Literal["auto", "ida", "r2", "angr", "binja"]
16+
BackendName = Literal["ida", "r2", "angr", "binja"]
1717
IDA_DATABASE_SUFFIXES = frozenset({".i64", ".idb"})
1818

1919

@@ -102,10 +102,19 @@ def choose_backend(
102102
ida_domain_path: Path | None = None,
103103
) -> BackendChoice:
104104
if input_path is not None and is_ida_database(input_path):
105-
if requested in {"r2", "angr"}:
105+
if requested in {"r2", "angr", "binja"}:
106106
raise ToCodeError("IDA database input requires the IDA backend")
107107
requested = "ida"
108108

109+
if requested == "binja":
110+
# binja is satisfied either by a local ``binaryninja`` (running inside the
111+
# Binary Ninja UI) or by an RPyc connection to binja-headless. Neither is
112+
# checked here: the in-UI session is constructed with an injected view,
113+
# and the headless connection's real failure surfaces with a clear
114+
# message at connect time. ``auto`` never picks binja -- it requires a
115+
# running Binary Ninja, so it stays opt-in.
116+
return BackendChoice(requested, "binja", "selected by CLI")
117+
109118
if requested == "r2":
110119
if not probe_r2():
111120
raise ToCodeError(
@@ -158,6 +167,13 @@ def probe_angr() -> bool:
158167
return importlib.util.find_spec("angr") is not None
159168

160169

170+
def probe_binja() -> bool:
171+
# Advisory only: true when running inside (or alongside) a Binary Ninja that
172+
# exposes the ``binaryninja`` module locally. The headless path needs ``rpyc``
173+
# instead, so binja selection is explicit rather than auto-probed.
174+
return importlib.util.find_spec("binaryninja") is not None
175+
176+
161177
def is_ida_database(path: Path) -> bool:
162178
return path.suffix.lower() in IDA_DATABASE_SUFFIXES
163179

0 commit comments

Comments
 (0)