codevanta 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codevanta/__init__.py +1 -0
- codevanta/analysis/__init__.py +1 -0
- codevanta/analysis/binary_diff.py +1168 -0
- codevanta/analysis/callgraph.py +542 -0
- codevanta/analysis/cfg.py +1201 -0
- codevanta/analysis/dataflow.py +762 -0
- codevanta/analysis/debugger.py +289 -0
- codevanta/analysis/debuginfo.py +304 -0
- codevanta/analysis/decompile.py +1094 -0
- codevanta/analysis/decompilers/__init__.py +250 -0
- codevanta/analysis/decompilers/angr_backend.py +251 -0
- codevanta/analysis/decompilers/base.py +278 -0
- codevanta/analysis/decompilers/ghidra_backend.py +316 -0
- codevanta/analysis/dynamic.py +756 -0
- codevanta/analysis/emulate.py +1349 -0
- codevanta/analysis/evidence_bundle.py +736 -0
- codevanta/analysis/function_hash.py +562 -0
- codevanta/analysis/graph_export.py +441 -0
- codevanta/analysis/html_view.py +695 -0
- codevanta/analysis/jump_tables.py +546 -0
- codevanta/analysis/solver.py +715 -0
- codevanta/analysis/symbolic.py +1809 -0
- codevanta/analysis/trace.py +646 -0
- codevanta/analysis/type_propagation.py +1055 -0
- codevanta/analysis/type_recovery.py +1214 -0
- codevanta/analysis/vtables.py +572 -0
- codevanta/analysis/xrefs.py +434 -0
- codevanta/api/__init__.py +1 -0
- codevanta/cli/__init__.py +1 -0
- codevanta/cli/main.py +3038 -0
- codevanta/core/__init__.py +31 -0
- codevanta/core/disassembler.py +121 -0
- codevanta/core/entropy.py +237 -0
- codevanta/core/functions.py +176 -0
- codevanta/core/loader.py +1351 -0
- codevanta/core/report.py +306 -0
- codevanta/core/strings.py +109 -0
- codevanta/evidence/__init__.py +7 -0
- codevanta/evidence/confidence.py +36 -0
- codevanta/evidence/models.py +43 -0
- codevanta/evidence/provenance.py +37 -0
- codevanta/gui/__init__.py +46 -0
- codevanta/gui/layout.py +1384 -0
- codevanta/gui/server.py +1305 -0
- codevanta/ir/__init__.py +6 -0
- codevanta/ir/adapters/__init__.py +31 -0
- codevanta/ir/base.py +31 -0
- codevanta/ir/instruction.py +42 -0
- codevanta/mcp_server/__init__.py +5 -0
- codevanta/mcp_server/server.py +710 -0
- codevanta/mcp_server/tools.py +1364 -0
- codevanta/plugins/__init__.py +748 -0
- codevanta/setup/__init__.py +28 -0
- codevanta/setup/apply.py +22 -0
- codevanta/setup/clients.py +285 -0
- codevanta/setup/plan.py +79 -0
- codevanta/setup/verify.py +43 -0
- codevanta/storage/__init__.py +1 -0
- codevanta/storage/session.py +514 -0
- codevanta-0.1.0.dist-info/METADATA +688 -0
- codevanta-0.1.0.dist-info/RECORD +65 -0
- codevanta-0.1.0.dist-info/WHEEL +5 -0
- codevanta-0.1.0.dist-info/entry_points.txt +2 -0
- codevanta-0.1.0.dist-info/licenses/LICENSE +21 -0
- codevanta-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Backend-neutral primitives shared by every decompiler provider (phase 3).
|
|
2
|
+
|
|
3
|
+
The package :mod:`codevanta.analysis.decompilers` lets CODEVANTA use a *real*
|
|
4
|
+
decompiler when one happens to be installed and its own heuristic renderer
|
|
5
|
+
otherwise. This module holds the parts that must never depend on an optional
|
|
6
|
+
third-party package:
|
|
7
|
+
|
|
8
|
+
* :class:`BackendAvailability` -- the answer to "can this backend run here, and
|
|
9
|
+
if not, why not". A probe never raises; a missing dependency becomes an
|
|
10
|
+
``available=False`` record with a human-readable :attr:`BackendAvailability.reason`.
|
|
11
|
+
* :class:`BackendResult` -- the backend-neutral rendering handed back to the CLI,
|
|
12
|
+
the MCP server and the report: ``text``, which backend produced it, the source
|
|
13
|
+
``language``, the ``warnings`` collected on the way and the :class:`Evidence`
|
|
14
|
+
that justifies it. :meth:`BackendResult.to_dict` is JSON-safe.
|
|
15
|
+
* :class:`DecompilerBackend` -- the structural protocol every provider
|
|
16
|
+
implements (``name``, ``requires``, ``availability()``, ``decompile(...)``).
|
|
17
|
+
* :class:`NativeBackend` -- the fallback provider. It forwards to
|
|
18
|
+
:func:`codevanta.analysis.decompile.decompile_function` and can therefore
|
|
19
|
+
always answer, because that renderer only needs the required ``capstone``
|
|
20
|
+
dependency and the loaded binary metadata.
|
|
21
|
+
* :func:`require_budget` -- the single validation path for
|
|
22
|
+
``max_instructions`` / ``max_blocks``, so every backend rejects the same
|
|
23
|
+
out-of-range requests with the same :class:`AnalysisError` messages as the
|
|
24
|
+
native renderer.
|
|
25
|
+
|
|
26
|
+
Heavy modules are imported lazily: this file imports only the standard library
|
|
27
|
+
plus in-project modules that are already required to run CODEVANTA, and the
|
|
28
|
+
optional backends (:mod:`codevanta.analysis.decompilers.angr_backend`,
|
|
29
|
+
:mod:`codevanta.analysis.decompilers.ghidra_backend`) import their dependency
|
|
30
|
+
inside ``availability()`` / ``decompile()`` only.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from dataclasses import dataclass, replace
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, Protocol, runtime_checkable
|
|
38
|
+
|
|
39
|
+
from codevanta.analysis.decompile import (
|
|
40
|
+
DEFAULT_MAX_BLOCKS,
|
|
41
|
+
DEFAULT_MAX_INSTRUCTIONS,
|
|
42
|
+
MAX_MAX_BLOCKS,
|
|
43
|
+
MAX_MAX_INSTRUCTIONS,
|
|
44
|
+
)
|
|
45
|
+
from codevanta.analysis.decompile import (
|
|
46
|
+
decompile_function as render_pseudo_c,
|
|
47
|
+
)
|
|
48
|
+
from codevanta.core.loader import BinaryInfo
|
|
49
|
+
from codevanta.core.report import AnalysisError
|
|
50
|
+
from codevanta.evidence.models import Evidence
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"BackendAvailability",
|
|
54
|
+
"BackendResult",
|
|
55
|
+
"DecompilerBackend",
|
|
56
|
+
"NativeBackend",
|
|
57
|
+
"require_budget",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
#: Source language of every rendering this layer produces. All three backends
|
|
62
|
+
#: emit C (the native one emits *heuristic* pseudo-C), so the tag is shared.
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class BackendAvailability:
|
|
65
|
+
"""Whether one decompiler backend can run in this environment.
|
|
66
|
+
|
|
67
|
+
Attributes:
|
|
68
|
+
name: Backend identifier, one of ``ghidra``, ``angr`` or ``native``.
|
|
69
|
+
available: ``True`` only when :meth:`DecompilerBackend.decompile` can be
|
|
70
|
+
expected to run without installing anything else.
|
|
71
|
+
reason: Human-readable explanation. Always non-empty: for an available
|
|
72
|
+
backend it states what was detected, for an unavailable one it names
|
|
73
|
+
the missing piece and, where possible, how to install it.
|
|
74
|
+
requires: Packages or installations the backend needs, in the order they
|
|
75
|
+
are probed. Empty for the built-in backend.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
name: str
|
|
79
|
+
available: bool
|
|
80
|
+
reason: str
|
|
81
|
+
requires: tuple[str, ...]
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> dict[str, Any]:
|
|
84
|
+
"""Serialize the probe for the JSON report."""
|
|
85
|
+
return {
|
|
86
|
+
"name": self.name,
|
|
87
|
+
"available": self.available,
|
|
88
|
+
"reason": self.reason,
|
|
89
|
+
"requires": list(self.requires),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class BackendResult:
|
|
95
|
+
"""One decompilation rendered by a backend.
|
|
96
|
+
|
|
97
|
+
Attributes:
|
|
98
|
+
text: The rendering itself. Never empty: a backend that produced nothing
|
|
99
|
+
still returns an explanatory ``/* ... */`` comment plus a warning.
|
|
100
|
+
backend: Name of the backend that produced ``text`` (not the one that was
|
|
101
|
+
requested, which matters for ``auto``).
|
|
102
|
+
language: Source language tag of ``text``; always :data:`LANGUAGE` today.
|
|
103
|
+
warnings: Everything the backend wants the caller to know: budget
|
|
104
|
+
overruns it could not prevent, fallbacks, empty renderings.
|
|
105
|
+
evidence: How the text was produced, including which backend and which
|
|
106
|
+
budgets were in effect.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
text: str
|
|
110
|
+
backend: str
|
|
111
|
+
language: str
|
|
112
|
+
warnings: tuple[str, ...]
|
|
113
|
+
evidence: Evidence
|
|
114
|
+
|
|
115
|
+
def to_dict(self) -> dict[str, Any]:
|
|
116
|
+
"""Serialize the rendering for the JSON report (JSON-safe values only)."""
|
|
117
|
+
return {
|
|
118
|
+
"text": self.text,
|
|
119
|
+
"backend": self.backend,
|
|
120
|
+
"language": self.language,
|
|
121
|
+
"warnings": list(self.warnings),
|
|
122
|
+
"evidence": self.evidence.to_dict(),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@runtime_checkable
|
|
127
|
+
class DecompilerBackend(Protocol):
|
|
128
|
+
"""Structural interface every provider implements.
|
|
129
|
+
|
|
130
|
+
The protocol is ``runtime_checkable`` so callers can assert that a registry
|
|
131
|
+
entry really exposes ``name``, ``requires``, ``availability`` and
|
|
132
|
+
``decompile`` (attribute presence only; signatures are checked statically).
|
|
133
|
+
|
|
134
|
+
Attributes:
|
|
135
|
+
name: Short backend identifier used by :func:`get_backend`.
|
|
136
|
+
requires: Hard requirements, reported verbatim by
|
|
137
|
+
:class:`BackendAvailability`.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
name: str
|
|
141
|
+
requires: tuple[str, ...]
|
|
142
|
+
|
|
143
|
+
def availability(self) -> BackendAvailability:
|
|
144
|
+
"""Probe the environment. Must never raise and never install anything."""
|
|
145
|
+
...
|
|
146
|
+
|
|
147
|
+
def decompile(
|
|
148
|
+
self,
|
|
149
|
+
path: str | Path,
|
|
150
|
+
info: BinaryInfo,
|
|
151
|
+
address: int,
|
|
152
|
+
max_instructions: int = ...,
|
|
153
|
+
max_blocks: int = ...,
|
|
154
|
+
) -> BackendResult:
|
|
155
|
+
"""Render the function at ``address``.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
AnalysisError: When the backend is unavailable, when the request is
|
|
159
|
+
out of range, or when the backend itself fails. Never raises
|
|
160
|
+
``ImportError`` for a missing optional dependency.
|
|
161
|
+
"""
|
|
162
|
+
...
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
LANGUAGE = "c"
|
|
166
|
+
|
|
167
|
+
#: Prefix of a warning line inside the native renderer's header comment block.
|
|
168
|
+
_HEADER_WARNING_PREFIX = " * warning: "
|
|
169
|
+
|
|
170
|
+
#: Prefix of the native renderer's "more warnings follow" summary line.
|
|
171
|
+
_HEADER_WARNING_OVERFLOW = "(+"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def require_budget(name: str, value: int, maximum: int) -> int:
|
|
175
|
+
"""Validate one budget argument against its hard limit.
|
|
176
|
+
|
|
177
|
+
Mirrors the native renderer's validation so the provider layer and the
|
|
178
|
+
renderer reject the same requests.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
name: Argument name, used in the error message.
|
|
182
|
+
value: Requested budget.
|
|
183
|
+
maximum: Largest accepted value.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
``value``, unchanged, for convenient inlining.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
AnalysisError: When ``value`` is not an ``int`` or is out of range.
|
|
190
|
+
"""
|
|
191
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
192
|
+
raise AnalysisError(f"{name} must be an int, not {type(value).__name__}")
|
|
193
|
+
if value < 1:
|
|
194
|
+
raise AnalysisError(f"{name} must be >= 1, got {value}")
|
|
195
|
+
if value > maximum:
|
|
196
|
+
raise AnalysisError(f"{name} too large ({value}); limit is {maximum}")
|
|
197
|
+
return value
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _header_warnings(body: str) -> tuple[str, ...]:
|
|
201
|
+
"""Lift the native renderer's ``warning:`` header lines out of the body."""
|
|
202
|
+
warnings: list[str] = []
|
|
203
|
+
for line in body.splitlines():
|
|
204
|
+
if line == " */":
|
|
205
|
+
break
|
|
206
|
+
if not line.startswith(_HEADER_WARNING_PREFIX):
|
|
207
|
+
continue
|
|
208
|
+
text = line[len(_HEADER_WARNING_PREFIX) :].strip()
|
|
209
|
+
if text and not text.startswith(_HEADER_WARNING_OVERFLOW):
|
|
210
|
+
warnings.append(text)
|
|
211
|
+
return tuple(warnings)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class NativeBackend:
|
|
215
|
+
"""The built-in heuristic pseudo-C renderer, always available.
|
|
216
|
+
|
|
217
|
+
The rendering itself lives in :mod:`codevanta.analysis.decompile`; this
|
|
218
|
+
provider only adapts its :class:`~codevanta.analysis.decompile.PseudoFunction`
|
|
219
|
+
to the backend-neutral :class:`BackendResult` (``body`` becomes ``text``) and
|
|
220
|
+
re-publishes the renderer's header warnings as structured warnings.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
name: str = "native"
|
|
224
|
+
requires: tuple[str, ...] = ()
|
|
225
|
+
|
|
226
|
+
def availability(self) -> BackendAvailability:
|
|
227
|
+
"""Report the built-in renderer; it needs no optional dependency."""
|
|
228
|
+
return BackendAvailability(
|
|
229
|
+
name=self.name,
|
|
230
|
+
available=True,
|
|
231
|
+
reason=(
|
|
232
|
+
"built-in mnemonic-level pseudo-C renderer; needs no optional "
|
|
233
|
+
"dependency and always runs"
|
|
234
|
+
),
|
|
235
|
+
requires=self.requires,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def decompile(
|
|
239
|
+
self,
|
|
240
|
+
path: str | Path,
|
|
241
|
+
info: BinaryInfo,
|
|
242
|
+
address: int,
|
|
243
|
+
max_instructions: int = DEFAULT_MAX_INSTRUCTIONS,
|
|
244
|
+
max_blocks: int = DEFAULT_MAX_BLOCKS,
|
|
245
|
+
) -> BackendResult:
|
|
246
|
+
"""Render ``address`` with the native heuristic renderer.
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
AnalysisError: When the address is not executable code, when a budget
|
|
250
|
+
is out of range, or when the region decodes to nothing.
|
|
251
|
+
"""
|
|
252
|
+
instruction_budget = require_budget(
|
|
253
|
+
"max_instructions", max_instructions, MAX_MAX_INSTRUCTIONS
|
|
254
|
+
)
|
|
255
|
+
block_budget = require_budget("max_blocks", max_blocks, MAX_MAX_BLOCKS)
|
|
256
|
+
rendered = render_pseudo_c(
|
|
257
|
+
path,
|
|
258
|
+
info,
|
|
259
|
+
address,
|
|
260
|
+
max_instructions=instruction_budget,
|
|
261
|
+
max_blocks=block_budget,
|
|
262
|
+
)
|
|
263
|
+
evidence = replace(
|
|
264
|
+
rendered.evidence,
|
|
265
|
+
extra={
|
|
266
|
+
**rendered.evidence.extra,
|
|
267
|
+
"backend": self.name,
|
|
268
|
+
"max_instructions": instruction_budget,
|
|
269
|
+
"max_blocks": block_budget,
|
|
270
|
+
},
|
|
271
|
+
)
|
|
272
|
+
return BackendResult(
|
|
273
|
+
text=rendered.body,
|
|
274
|
+
backend=self.name,
|
|
275
|
+
language=LANGUAGE,
|
|
276
|
+
warnings=_header_warnings(rendered.body),
|
|
277
|
+
evidence=evidence,
|
|
278
|
+
)
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Ghidra decompiler backend via ``pyghidra`` (optional, imported lazily).
|
|
2
|
+
|
|
3
|
+
Ghidra is the strongest free decompiler, but it needs two independent pieces: the
|
|
4
|
+
``pyghidra`` Python bridge *and* an actual Ghidra installation. Both are probed --
|
|
5
|
+
``pyghidra`` by importing it, the installation through the ``GHIDRA_INSTALL_DIR``
|
|
6
|
+
environment variable -- and the backend only reports ``available=True`` when both
|
|
7
|
+
are present. Nothing is ever downloaded, extracted or launched by the probe: a
|
|
8
|
+
machine without Ghidra simply gets ``available=False`` and a reason saying which
|
|
9
|
+
half is missing.
|
|
10
|
+
|
|
11
|
+
Rendering uses pyghidra's headless entry point, ``pyghidra.open_program``: it
|
|
12
|
+
starts the JVM, imports and analyses the file into an in-memory project and hands
|
|
13
|
+
back a ``FlatProgramAPI``. Inside that context ``ghidra.app.decompiler`` is
|
|
14
|
+
importable through JPype, so :class:`DecompInterface` decompiles the function at
|
|
15
|
+
the requested address and returns C text.
|
|
16
|
+
|
|
17
|
+
Budgeting: Ghidra's decompiler has no instruction or block budget either. The
|
|
18
|
+
instruction budget is projected onto the decompiler *timeout* (roughly one
|
|
19
|
+
simulated second per 200 instructions, clamped to 5..300 s) and the resulting
|
|
20
|
+
basic-block count is compared with ``max_blocks`` afterwards, which can only be
|
|
21
|
+
reported as a warning. Both budgets travel into the evidence record.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
import os
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from codevanta.analysis.decompile import (
|
|
32
|
+
DEFAULT_MAX_BLOCKS,
|
|
33
|
+
DEFAULT_MAX_INSTRUCTIONS,
|
|
34
|
+
MAX_MAX_BLOCKS,
|
|
35
|
+
MAX_MAX_INSTRUCTIONS,
|
|
36
|
+
)
|
|
37
|
+
from codevanta.core.loader import BinaryInfo
|
|
38
|
+
from codevanta.core.report import AnalysisError
|
|
39
|
+
from codevanta.evidence.confidence import Confidence
|
|
40
|
+
from codevanta.evidence.models import Evidence
|
|
41
|
+
|
|
42
|
+
from .base import (
|
|
43
|
+
LANGUAGE,
|
|
44
|
+
BackendAvailability,
|
|
45
|
+
BackendResult,
|
|
46
|
+
require_budget,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
__all__ = ["GhidraBackend", "INSTALL_ENV", "INSTALL_HINT", "NAME"]
|
|
50
|
+
|
|
51
|
+
#: Backend identifier.
|
|
52
|
+
NAME = "ghidra"
|
|
53
|
+
|
|
54
|
+
#: Environment variable that points at a Ghidra installation.
|
|
55
|
+
INSTALL_ENV = "GHIDRA_INSTALL_DIR"
|
|
56
|
+
|
|
57
|
+
#: What a user has to install for this backend to become available.
|
|
58
|
+
INSTALL_HINT = (
|
|
59
|
+
f"pip install pyghidra and download Ghidra, then set {INSTALL_ENV} to the "
|
|
60
|
+
"installation directory"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
#: Evidence source label.
|
|
64
|
+
_SOURCE = "ghidra.decompiler"
|
|
65
|
+
|
|
66
|
+
#: Evidence method label.
|
|
67
|
+
_METHOD = "pyghidra.headless.DecompInterface"
|
|
68
|
+
|
|
69
|
+
#: Entries that mark a directory as a Ghidra installation.
|
|
70
|
+
_INSTALL_MARKERS = ("support", "ghidraRun", "ghidraRun.bat")
|
|
71
|
+
|
|
72
|
+
#: Smallest and largest decompiler timeout, in seconds.
|
|
73
|
+
_MIN_TIMEOUT = 5
|
|
74
|
+
_MAX_TIMEOUT = 300
|
|
75
|
+
|
|
76
|
+
#: Instructions per projected timeout second.
|
|
77
|
+
_INSTRUCTIONS_PER_SECOND = 200
|
|
78
|
+
|
|
79
|
+
#: Comment emitted when Ghidra's decompiler produced no code.
|
|
80
|
+
_EMPTY_TEXT = "/* ghidra produced no pseudocode for 0x{address:x} */"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _unavailable(availability: BackendAvailability) -> AnalysisError:
|
|
84
|
+
"""Build the error raised when a caller asked for an unavailable backend."""
|
|
85
|
+
return AnalysisError(
|
|
86
|
+
f"decompiler backend {availability.name!r} is unavailable: {availability.reason}; "
|
|
87
|
+
"use backend='native' or backend='auto' instead"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _timeout_seconds(instruction_budget: int) -> int:
|
|
92
|
+
"""Project an instruction budget onto a decompiler timeout."""
|
|
93
|
+
seconds = instruction_budget // _INSTRUCTIONS_PER_SECOND
|
|
94
|
+
return max(_MIN_TIMEOUT, min(_MAX_TIMEOUT, seconds))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _installation_problem(install_dir: str) -> str | None:
|
|
98
|
+
"""Return why ``install_dir`` is not usable, or ``None`` when it looks right."""
|
|
99
|
+
if not install_dir:
|
|
100
|
+
return f"no Ghidra installation is configured ({INSTALL_ENV} is not set)"
|
|
101
|
+
root = Path(install_dir)
|
|
102
|
+
if not root.is_dir():
|
|
103
|
+
return f"{INSTALL_ENV}={install_dir!r} is not a directory"
|
|
104
|
+
if not any((root / marker).exists() for marker in _INSTALL_MARKERS):
|
|
105
|
+
return (
|
|
106
|
+
f"{INSTALL_ENV}={install_dir!r} does not look like a Ghidra installation "
|
|
107
|
+
"(no support/ directory and no ghidraRun launcher)"
|
|
108
|
+
)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class GhidraBackend:
|
|
113
|
+
"""Decompilation through a local Ghidra installation driven by pyghidra.
|
|
114
|
+
|
|
115
|
+
Attributes:
|
|
116
|
+
name: ``"ghidra"``.
|
|
117
|
+
requires: ``("pyghidra", "ghidra")``.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
name: str = NAME
|
|
121
|
+
requires: tuple[str, ...] = ("pyghidra", "ghidra")
|
|
122
|
+
|
|
123
|
+
def availability(self) -> BackendAvailability:
|
|
124
|
+
"""Probe ``pyghidra`` and :data:`INSTALL_ENV`; never raises, never launches."""
|
|
125
|
+
problems: list[str] = []
|
|
126
|
+
version = "unknown"
|
|
127
|
+
try:
|
|
128
|
+
import pyghidra
|
|
129
|
+
|
|
130
|
+
version = str(getattr(pyghidra, "__version__", "unknown"))
|
|
131
|
+
except ImportError as exc:
|
|
132
|
+
problems.append(
|
|
133
|
+
f"the 'pyghidra' package is not installed ({type(exc).__name__}: {exc})"
|
|
134
|
+
)
|
|
135
|
+
except Exception as exc: # a broken install must not break discovery
|
|
136
|
+
problems.append(f"importing 'pyghidra' failed ({type(exc).__name__}: {exc})")
|
|
137
|
+
problem = _installation_problem(os.environ.get(INSTALL_ENV, "").strip())
|
|
138
|
+
if problem is not None:
|
|
139
|
+
problems.append(problem)
|
|
140
|
+
if problems:
|
|
141
|
+
return BackendAvailability(
|
|
142
|
+
name=self.name,
|
|
143
|
+
available=False,
|
|
144
|
+
reason="; ".join(problems) + f"; {INSTALL_HINT}",
|
|
145
|
+
requires=self.requires,
|
|
146
|
+
)
|
|
147
|
+
return BackendAvailability(
|
|
148
|
+
name=self.name,
|
|
149
|
+
available=True,
|
|
150
|
+
reason=(
|
|
151
|
+
f"pyghidra {version} is importable and {INSTALL_ENV} points at a Ghidra "
|
|
152
|
+
"installation; the headless decompiler can run"
|
|
153
|
+
),
|
|
154
|
+
requires=self.requires,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def decompile(
|
|
158
|
+
self,
|
|
159
|
+
path: str | Path,
|
|
160
|
+
info: BinaryInfo,
|
|
161
|
+
address: int,
|
|
162
|
+
max_instructions: int = DEFAULT_MAX_INSTRUCTIONS,
|
|
163
|
+
max_blocks: int = DEFAULT_MAX_BLOCKS,
|
|
164
|
+
) -> BackendResult:
|
|
165
|
+
"""Decompile ``address`` with the local Ghidra installation.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
path: Binary to import into a temporary Ghidra project (read-only use
|
|
169
|
+
of the file; the target is analysed, never executed).
|
|
170
|
+
info: Loaded metadata; Ghidra imports the file itself, so only the
|
|
171
|
+
path is used. Kept for a uniform backend signature.
|
|
172
|
+
address: Virtual address of the function to decompile.
|
|
173
|
+
max_instructions: Projected onto the decompiler timeout; Ghidra has no
|
|
174
|
+
instruction budget.
|
|
175
|
+
max_blocks: Compared with the function's block count afterwards; an
|
|
176
|
+
overrun becomes a warning, not an error.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
The rendering plus any warnings Ghidra could not prevent.
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
AnalysisError: When pyghidra or the Ghidra installation is missing,
|
|
183
|
+
when a budget is out of range, when no function exists at
|
|
184
|
+
``address``, or when the decompiler reports a failure.
|
|
185
|
+
"""
|
|
186
|
+
instruction_budget = require_budget(
|
|
187
|
+
"max_instructions", max_instructions, MAX_MAX_INSTRUCTIONS
|
|
188
|
+
)
|
|
189
|
+
block_budget = require_budget("max_blocks", max_blocks, MAX_MAX_BLOCKS)
|
|
190
|
+
availability = self.availability()
|
|
191
|
+
if not availability.available:
|
|
192
|
+
raise _unavailable(availability)
|
|
193
|
+
|
|
194
|
+
import pyghidra
|
|
195
|
+
|
|
196
|
+
opener = getattr(pyghidra, "open_program", None)
|
|
197
|
+
if not callable(opener):
|
|
198
|
+
raise AnalysisError(
|
|
199
|
+
"the installed pyghidra exposes no open_program() headless entry point; "
|
|
200
|
+
f"{INSTALL_HINT}"
|
|
201
|
+
)
|
|
202
|
+
timeout = _timeout_seconds(instruction_budget)
|
|
203
|
+
_ensure_started(pyghidra)
|
|
204
|
+
try:
|
|
205
|
+
program_context = opener(str(path), analyze=True)
|
|
206
|
+
except TypeError:
|
|
207
|
+
program_context = opener(str(path))
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
with program_context as flat_api:
|
|
211
|
+
text, block_count = _decompile_in_program(flat_api, address, timeout)
|
|
212
|
+
except AnalysisError:
|
|
213
|
+
raise
|
|
214
|
+
except Exception as exc: # JPype surfaces Java failures as generic errors
|
|
215
|
+
raise AnalysisError(
|
|
216
|
+
f"ghidra could not decompile 0x{address:x}: {type(exc).__name__}: {exc}"
|
|
217
|
+
) from exc
|
|
218
|
+
|
|
219
|
+
warnings: list[str] = []
|
|
220
|
+
if not text.strip():
|
|
221
|
+
warnings.append(
|
|
222
|
+
f"ghidra returned no decompilation for 0x{address:x}: the decompiler "
|
|
223
|
+
"completed without C text, so the rendering is a placeholder comment"
|
|
224
|
+
)
|
|
225
|
+
text = _EMPTY_TEXT.format(address=address)
|
|
226
|
+
if block_count > block_budget:
|
|
227
|
+
warnings.append(
|
|
228
|
+
f"ghidra decompiled {block_count} block(s), above max_blocks={block_budget}; "
|
|
229
|
+
"Ghidra exposes no block budget, so the text is complete rather than truncated"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
evidence = Evidence(
|
|
233
|
+
source=_SOURCE,
|
|
234
|
+
method=_METHOD,
|
|
235
|
+
confidence=Confidence.MEDIUM,
|
|
236
|
+
address=address,
|
|
237
|
+
detail=(
|
|
238
|
+
f"ghidra decompiled the function at 0x{address:x} ({block_count} block(s)) "
|
|
239
|
+
f"headlessly through pyghidra with a {timeout}s timeout projected from "
|
|
240
|
+
f"max_instructions={instruction_budget}; Ghidra has no instruction or block "
|
|
241
|
+
f"budget, so max_blocks={block_budget} is checked afterwards only"
|
|
242
|
+
),
|
|
243
|
+
extra={
|
|
244
|
+
"backend": self.name,
|
|
245
|
+
"install_dir": os.environ.get(INSTALL_ENV, ""),
|
|
246
|
+
"timeout_seconds": timeout,
|
|
247
|
+
"max_instructions": instruction_budget,
|
|
248
|
+
"max_blocks": block_budget,
|
|
249
|
+
"block_count": block_count,
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
return BackendResult(
|
|
253
|
+
text=text,
|
|
254
|
+
backend=self.name,
|
|
255
|
+
language=LANGUAGE,
|
|
256
|
+
warnings=tuple(warnings),
|
|
257
|
+
evidence=evidence,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _ensure_started(pyghidra: Any) -> None:
|
|
262
|
+
"""Start the JVM the way pyghidra expects, ignoring "already started"."""
|
|
263
|
+
start = getattr(pyghidra, "start", None)
|
|
264
|
+
if not callable(start):
|
|
265
|
+
return
|
|
266
|
+
# pyghidra raises when the JVM is already up, which is exactly the state we
|
|
267
|
+
# want; any other failure resurfaces from open_program() below.
|
|
268
|
+
with contextlib.suppress(Exception):
|
|
269
|
+
start()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _decompile_in_program(
|
|
273
|
+
flat_api: Any,
|
|
274
|
+
address: int,
|
|
275
|
+
timeout_seconds: int,
|
|
276
|
+
) -> tuple[str, int]:
|
|
277
|
+
"""Decompile one function inside an open pyghidra program context."""
|
|
278
|
+
from ghidra.app.decompiler import DecompileOptions, DecompInterface
|
|
279
|
+
|
|
280
|
+
program = flat_api.getCurrentProgram()
|
|
281
|
+
target = flat_api.toAddr(address)
|
|
282
|
+
function = flat_api.getFunctionAt(target)
|
|
283
|
+
if function is None:
|
|
284
|
+
function = flat_api.getFunctionContaining(target)
|
|
285
|
+
if function is None:
|
|
286
|
+
raise AnalysisError(
|
|
287
|
+
f"ghidra found no function at 0x{address:x}; decompilation needs a defined function"
|
|
288
|
+
)
|
|
289
|
+
# Ghidra's Function API has no ``getBasicBlocks``; the body of a function is a
|
|
290
|
+
# set of address ranges, and one range is the closest portable stand-in for a
|
|
291
|
+
# block count. ``getBody`` is present across the versions pyghidra ships, and
|
|
292
|
+
# any failure here must not sink the decompilation itself, so it degrades to 0
|
|
293
|
+
# (reported as "unknown") instead of raising.
|
|
294
|
+
block_count = 0
|
|
295
|
+
try:
|
|
296
|
+
body = function.getBody()
|
|
297
|
+
ranges = body.getAddressRanges() if body is not None else None
|
|
298
|
+
block_count = len(list(ranges)) if ranges is not None else 0
|
|
299
|
+
except Exception: # pragma: no cover - defensive across Ghidra versions
|
|
300
|
+
block_count = 0
|
|
301
|
+
decompiler = DecompInterface()
|
|
302
|
+
try:
|
|
303
|
+
decompiler.setOptions(DecompileOptions())
|
|
304
|
+
decompiler.openProgram(program)
|
|
305
|
+
# A ``None`` task monitor is accepted and is what headless scripting uses.
|
|
306
|
+
result = decompiler.decompileFunction(function, timeout_seconds, None)
|
|
307
|
+
if result is None:
|
|
308
|
+
raise AnalysisError(f"ghidra returned no decompiler result for 0x{address:x}")
|
|
309
|
+
if not result.decompileCompleted():
|
|
310
|
+
message = str(result.getErrorMessage() or "no error message reported")
|
|
311
|
+
raise AnalysisError(f"ghidra could not decompile 0x{address:x}: {message}")
|
|
312
|
+
code = result.getDecompiledFunction()
|
|
313
|
+
text = "" if code is None else str(code.getC() or "")
|
|
314
|
+
finally:
|
|
315
|
+
decompiler.dispose()
|
|
316
|
+
return text, block_count
|