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,756 @@
|
|
|
1
|
+
"""Live dynamic analysis providers (gdb, frida): probing and script generation.
|
|
2
|
+
|
|
3
|
+
Dynamic analysis needs a debugger, and a debugger needs a host. This module
|
|
4
|
+
therefore does the one part that is *always* safe and deterministic: it decides
|
|
5
|
+
whether a provider can run here, and it writes the session script that provider
|
|
6
|
+
should execute. Nothing in this module starts a process, imports a provider
|
|
7
|
+
package, or touches the target file.
|
|
8
|
+
|
|
9
|
+
Three questions, three answers
|
|
10
|
+
------------------------------
|
|
11
|
+
*Can a provider run here?*
|
|
12
|
+
:func:`available_backends` (always ordered ``gdb`` then ``frida``) and
|
|
13
|
+
:func:`provider_status` answer with :class:`DynamicAvailability`. The probe
|
|
14
|
+
uses ``shutil.which`` for the CLI and ``importlib.util.find_spec`` for the
|
|
15
|
+
Frida bindings, and nothing else: Frida is never imported, so probing cannot
|
|
16
|
+
load a native runtime and cannot fail. :attr:`DynamicAvailability.version`
|
|
17
|
+
is consequently always ``None`` -- learning a version would mean running
|
|
18
|
+
``gdb --version`` or importing ``frida``.
|
|
19
|
+
*What should be run?*
|
|
20
|
+
:func:`build_gdb_script` and :func:`build_frida_script` return a
|
|
21
|
+
:class:`DynamicScript`: the script ``text``, the ``entry_commands`` it issues
|
|
22
|
+
at session start, and the ``notes`` that state its limits. Generation is
|
|
23
|
+
pure -- same inputs, same bytes, no file is written -- so a caller can
|
|
24
|
+
snapshot the text in a test or show it in the GUI before anything runs.
|
|
25
|
+
*How does the user start it?*
|
|
26
|
+
:func:`attach_command` returns the exact ``argv`` (a tuple, never a shell
|
|
27
|
+
string) for the provider binary, so the CLI, the MCP server and the GUI do
|
|
28
|
+
not each grow their own command line.
|
|
29
|
+
|
|
30
|
+
Bounded by construction
|
|
31
|
+
-----------------------
|
|
32
|
+
Both scripts stop after ``max_hits`` hits: gdb increments a convenience counter
|
|
33
|
+
and ``quit``s, Frida detaches every listener it installed. gdb prints a fixed
|
|
34
|
+
number of backtrace frames (:data:`BACKTRACE_DEPTH`), Frida snapshots a fixed
|
|
35
|
+
number of argument registers (:data:`MAX_FRIDA_ARGS`). ``max_hits`` is validated
|
|
36
|
+
against :data:`MAX_MAX_HITS` so a typo cannot ask for an unbounded session.
|
|
37
|
+
|
|
38
|
+
Caller mistakes -- an unknown provider, an empty address list, an address that
|
|
39
|
+
is not inside an executable section, an out-of-range ``max_hits`` -- raise
|
|
40
|
+
:class:`~codevanta.core.report.AnalysisError` naming the bad input. A missing
|
|
41
|
+
debugger is *not* an error: it is reported as ``available=False`` with a
|
|
42
|
+
non-empty reason, which is exactly what happens on a machine without gdb or
|
|
43
|
+
Frida.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import importlib.util
|
|
50
|
+
import shutil
|
|
51
|
+
from collections.abc import Iterable
|
|
52
|
+
from dataclasses import dataclass
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
from typing import Any
|
|
55
|
+
|
|
56
|
+
from codevanta.core.loader import BinaryInfo
|
|
57
|
+
from codevanta.core.report import AnalysisError
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"BACKTRACE_DEPTH",
|
|
61
|
+
"DEFAULT_MAX_HITS",
|
|
62
|
+
"MAX_FRIDA_ARGS",
|
|
63
|
+
"MAX_MAX_HITS",
|
|
64
|
+
"PROVIDERS",
|
|
65
|
+
"SCRIPT_FILE_NAMES",
|
|
66
|
+
"DynamicAvailability",
|
|
67
|
+
"DynamicScript",
|
|
68
|
+
"attach_command",
|
|
69
|
+
"available_backends",
|
|
70
|
+
"build_frida_script",
|
|
71
|
+
"build_gdb_script",
|
|
72
|
+
"provider_status",
|
|
73
|
+
"script_file_name",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
#: Supported providers, in the order :func:`available_backends` reports them.
|
|
77
|
+
PROVIDERS: tuple[str, ...] = ("gdb", "frida")
|
|
78
|
+
|
|
79
|
+
#: Default ceiling on recorded hits for a generated script.
|
|
80
|
+
DEFAULT_MAX_HITS = 64
|
|
81
|
+
|
|
82
|
+
#: Hard ceiling on ``max_hits``; a session may never be unbounded.
|
|
83
|
+
MAX_MAX_HITS = 100_000
|
|
84
|
+
|
|
85
|
+
#: Backtrace frames the generated gdb script prints per hit.
|
|
86
|
+
BACKTRACE_DEPTH = 8
|
|
87
|
+
|
|
88
|
+
#: Argument registers the generated Frida hook snapshots per hit.
|
|
89
|
+
MAX_FRIDA_ARGS = 4
|
|
90
|
+
|
|
91
|
+
#: File name each provider's script is expected to be written to. Fixed (not
|
|
92
|
+
#: derived from the target) so :func:`attach_command` stays deterministic.
|
|
93
|
+
SCRIPT_FILE_NAMES: dict[str, str] = {
|
|
94
|
+
"gdb": "codevanta-session.gdb",
|
|
95
|
+
"frida": "codevanta-hooks.js",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#: Registers gdb knows on every target, whatever the architecture.
|
|
99
|
+
_STANDARD_REGISTERS: tuple[str, ...] = ("pc", "sp", "fp", "ps")
|
|
100
|
+
|
|
101
|
+
#: Architecture-specific registers worth snapshotting, keyed by the
|
|
102
|
+
#: ``BinaryInfo.architecture`` vocabulary (``x86_64``, ``x86``, ``aarch64``,
|
|
103
|
+
#: ``arm``, ``mips``). An unknown architecture adds nothing, so the script falls
|
|
104
|
+
#: back to the standard registers alone.
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class DynamicAvailability:
|
|
109
|
+
"""Whether one dynamic provider can run in this environment.
|
|
110
|
+
|
|
111
|
+
Attributes:
|
|
112
|
+
name: Provider identifier, one of :data:`PROVIDERS`.
|
|
113
|
+
available: ``True`` only when the provider binary the generated script is
|
|
114
|
+
meant for could be located without running it.
|
|
115
|
+
reason: Human-readable explanation. Always non-empty: for an available
|
|
116
|
+
provider it states what was detected (and where), for an unavailable
|
|
117
|
+
one it names the missing piece and how to install it.
|
|
118
|
+
requires: Packages or installations the provider needs, in the order they
|
|
119
|
+
are probed.
|
|
120
|
+
version: Provider version when it can be learned without executing the
|
|
121
|
+
provider. Always ``None`` today, deliberately: probing a version
|
|
122
|
+
would mean running ``gdb`` or importing ``frida``, which is exactly
|
|
123
|
+
what this probe must not do.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
name: str
|
|
127
|
+
available: bool
|
|
128
|
+
reason: str
|
|
129
|
+
requires: tuple[str, ...]
|
|
130
|
+
version: str | None = None
|
|
131
|
+
|
|
132
|
+
def to_dict(self) -> dict[str, Any]:
|
|
133
|
+
"""Serialize the probe for the JSON report."""
|
|
134
|
+
return {
|
|
135
|
+
"name": self.name,
|
|
136
|
+
"available": self.available,
|
|
137
|
+
"reason": self.reason,
|
|
138
|
+
"requires": list(self.requires),
|
|
139
|
+
"version": self.version,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class DynamicScript:
|
|
145
|
+
"""The script a provider should execute, plus what it will do.
|
|
146
|
+
|
|
147
|
+
Attributes:
|
|
148
|
+
provider: Provider the script is written for, one of :data:`PROVIDERS`.
|
|
149
|
+
text: The script itself, ready to be written to
|
|
150
|
+
:data:`SCRIPT_FILE_NAMES` ``[provider]``. Deterministic: the same
|
|
151
|
+
inputs always produce byte-identical text.
|
|
152
|
+
entry_commands: The statements the script issues at session start, in
|
|
153
|
+
order: for gdb the ``set``/``break`` command lines it applies before
|
|
154
|
+
the first breakpoint body, for Frida the per-address
|
|
155
|
+
``Interceptor.attach`` expression. This is what a user would paste
|
|
156
|
+
into an interactive session to reproduce the script's setup.
|
|
157
|
+
notes: Everything the caller should know before trusting the run: address
|
|
158
|
+
semantics (absolute vs module-relative), hit and backtrace bounds,
|
|
159
|
+
and what the script deliberately does *not* do.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
provider: str
|
|
163
|
+
text: str
|
|
164
|
+
entry_commands: tuple[str, ...]
|
|
165
|
+
notes: tuple[str, ...]
|
|
166
|
+
|
|
167
|
+
def to_dict(self) -> dict[str, Any]:
|
|
168
|
+
"""Serialize the script for the JSON report."""
|
|
169
|
+
return {
|
|
170
|
+
"provider": self.provider,
|
|
171
|
+
"text": self.text,
|
|
172
|
+
"entry_commands": list(self.entry_commands),
|
|
173
|
+
"notes": list(self.notes),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --------------------------------------------------------------------------- #
|
|
178
|
+
# Availability probing
|
|
179
|
+
# --------------------------------------------------------------------------- #
|
|
180
|
+
|
|
181
|
+
_FRIDA_INSTALL_HINT = "'pip install frida-tools frida'"
|
|
182
|
+
_GDB_INSTALL_HINT = "'apt install gdb' (Linux) or 'brew install gdb' (macOS)"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _frida_spec() -> object | None:
|
|
186
|
+
"""Return the ``frida`` module spec without importing the module."""
|
|
187
|
+
try:
|
|
188
|
+
return importlib.util.find_spec("frida")
|
|
189
|
+
except (ImportError, ValueError): # pragma: no cover - defensive only
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _probe_gdb() -> DynamicAvailability:
|
|
194
|
+
executable = shutil.which("gdb")
|
|
195
|
+
if executable:
|
|
196
|
+
return DynamicAvailability(
|
|
197
|
+
name="gdb",
|
|
198
|
+
available=True,
|
|
199
|
+
reason=(
|
|
200
|
+
f"gdb found on PATH ({executable}); the version is not probed, "
|
|
201
|
+
"because probing would run the debugger"
|
|
202
|
+
),
|
|
203
|
+
requires=("gdb",),
|
|
204
|
+
)
|
|
205
|
+
return DynamicAvailability(
|
|
206
|
+
name="gdb",
|
|
207
|
+
available=False,
|
|
208
|
+
reason=(
|
|
209
|
+
f"gdb is not on PATH; install it with {_GDB_INSTALL_HINT}. "
|
|
210
|
+
"Script generation works without it, only the live session does not"
|
|
211
|
+
),
|
|
212
|
+
requires=("gdb",),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _probe_frida() -> DynamicAvailability:
|
|
217
|
+
executable = shutil.which("frida")
|
|
218
|
+
bindings = _frida_spec()
|
|
219
|
+
requires = ("frida", "frida-tools")
|
|
220
|
+
if executable and bindings is not None:
|
|
221
|
+
return DynamicAvailability(
|
|
222
|
+
name="frida",
|
|
223
|
+
available=True,
|
|
224
|
+
reason=(
|
|
225
|
+
f"frida CLI found on PATH ({executable}) and the python bindings are "
|
|
226
|
+
"importable; the version is not probed, because probing would load frida"
|
|
227
|
+
),
|
|
228
|
+
requires=requires,
|
|
229
|
+
)
|
|
230
|
+
missing: list[str] = []
|
|
231
|
+
if not executable:
|
|
232
|
+
missing.append("the frida CLI")
|
|
233
|
+
if bindings is None:
|
|
234
|
+
missing.append("the frida python bindings")
|
|
235
|
+
return DynamicAvailability(
|
|
236
|
+
name="frida",
|
|
237
|
+
available=False,
|
|
238
|
+
reason=(
|
|
239
|
+
f"frida is not installed: missing {' and '.join(missing)}; "
|
|
240
|
+
f"install with {_FRIDA_INSTALL_HINT}. Script generation works without it, "
|
|
241
|
+
"only the live session does not"
|
|
242
|
+
),
|
|
243
|
+
requires=requires,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
_PROBES = {"gdb": _probe_gdb, "frida": _probe_frida}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def available_backends() -> tuple[DynamicAvailability, ...]:
|
|
251
|
+
"""Probe every provider, in :data:`PROVIDERS` order (``gdb`` then ``frida``).
|
|
252
|
+
|
|
253
|
+
Never raises and never executes a provider: an absent debugger is reported as
|
|
254
|
+
``available=False`` with a non-empty :attr:`DynamicAvailability.reason`.
|
|
255
|
+
"""
|
|
256
|
+
return tuple(_PROBES[name]() for name in PROVIDERS)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def provider_status(provider: str) -> DynamicAvailability:
|
|
260
|
+
"""Probe one provider by name.
|
|
261
|
+
|
|
262
|
+
Raises:
|
|
263
|
+
AnalysisError: ``provider`` is not one of :data:`PROVIDERS`.
|
|
264
|
+
"""
|
|
265
|
+
probe = _PROBES.get(provider)
|
|
266
|
+
if probe is None:
|
|
267
|
+
raise AnalysisError(
|
|
268
|
+
f"unknown provider {provider!r}; supported providers: {', '.join(PROVIDERS)}"
|
|
269
|
+
)
|
|
270
|
+
return probe()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# --------------------------------------------------------------------------- #
|
|
274
|
+
# Input validation
|
|
275
|
+
# --------------------------------------------------------------------------- #
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _require_target(path: str | Path) -> str:
|
|
279
|
+
"""Return ``path`` as text, rejecting an empty target."""
|
|
280
|
+
target = str(path)
|
|
281
|
+
if not target.strip():
|
|
282
|
+
raise AnalysisError("path must be a non-empty target path")
|
|
283
|
+
return target
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _require_max_hits(max_hits: int) -> int:
|
|
287
|
+
"""Return ``max_hits`` after checking it is a sane positive bound."""
|
|
288
|
+
if isinstance(max_hits, bool) or not isinstance(max_hits, int):
|
|
289
|
+
raise AnalysisError(
|
|
290
|
+
f"max_hits must be an int, got {type(max_hits).__name__}: {max_hits!r}"
|
|
291
|
+
)
|
|
292
|
+
if max_hits < 1:
|
|
293
|
+
raise AnalysisError(f"max_hits must be >= 1, got {max_hits}")
|
|
294
|
+
if max_hits > MAX_MAX_HITS:
|
|
295
|
+
raise AnalysisError(f"max_hits must be <= {MAX_MAX_HITS}, got {max_hits}")
|
|
296
|
+
return max_hits
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _validate_addresses(
|
|
300
|
+
addresses: Iterable[int], info: BinaryInfo | None
|
|
301
|
+
) -> tuple[int, ...]:
|
|
302
|
+
"""Return ``addresses`` deduplicated, in order, after full validation.
|
|
303
|
+
|
|
304
|
+
Every address must be a positive ``int`` and, when ``info`` is supplied, must
|
|
305
|
+
fall inside a section marked executable. Duplicates collapse to their first
|
|
306
|
+
occurrence so a session installs one breakpoint per address.
|
|
307
|
+
"""
|
|
308
|
+
if isinstance(addresses, str | bytes):
|
|
309
|
+
raise AnalysisError("addresses must be an iterable of ints, not text")
|
|
310
|
+
try:
|
|
311
|
+
items = list(addresses)
|
|
312
|
+
except TypeError as exc:
|
|
313
|
+
raise AnalysisError(
|
|
314
|
+
f"addresses must be an iterable of ints, got {type(addresses).__name__}"
|
|
315
|
+
) from exc
|
|
316
|
+
if not items:
|
|
317
|
+
raise AnalysisError("addresses must not be empty: at least one address is required")
|
|
318
|
+
unique: list[int] = []
|
|
319
|
+
for position, address in enumerate(items):
|
|
320
|
+
if isinstance(address, bool) or not isinstance(address, int):
|
|
321
|
+
raise AnalysisError(
|
|
322
|
+
f"addresses[{position}] must be an int, "
|
|
323
|
+
f"got {type(address).__name__}: {address!r}"
|
|
324
|
+
)
|
|
325
|
+
if address <= 0:
|
|
326
|
+
raise AnalysisError(f"addresses[{position}] must be positive, got {address}")
|
|
327
|
+
if info is not None:
|
|
328
|
+
section = info.section_for_va(address)
|
|
329
|
+
if section is None or not section.is_executable:
|
|
330
|
+
where = "no section" if section is None else f"section {section.name!r}"
|
|
331
|
+
raise AnalysisError(
|
|
332
|
+
f"addresses[{position}] 0x{address:x} is not inside an executable "
|
|
333
|
+
f"section ({where})"
|
|
334
|
+
)
|
|
335
|
+
if address not in unique:
|
|
336
|
+
unique.append(address)
|
|
337
|
+
return tuple(unique)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# --------------------------------------------------------------------------- #
|
|
341
|
+
# gdb
|
|
342
|
+
# --------------------------------------------------------------------------- #
|
|
343
|
+
|
|
344
|
+
#: How many breakpoints the script header lists before summarising the rest.
|
|
345
|
+
_HEADER_ADDRESS_LIMIT = 8
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _summarize_addresses(points: tuple[int, ...]) -> str:
|
|
349
|
+
"""Return a short ``0x..., 0x... (+N more)`` summary for a script header."""
|
|
350
|
+
shown = ", ".join(f"0x{address:x}" for address in points[:_HEADER_ADDRESS_LIMIT])
|
|
351
|
+
remainder = len(points) - _HEADER_ADDRESS_LIMIT
|
|
352
|
+
if remainder > 0:
|
|
353
|
+
shown = f"{shown}, ... (+{remainder} more)"
|
|
354
|
+
return shown
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def build_gdb_script(
|
|
358
|
+
path: str | Path,
|
|
359
|
+
info: BinaryInfo | None,
|
|
360
|
+
addresses: Iterable[int],
|
|
361
|
+
*,
|
|
362
|
+
max_hits: int = DEFAULT_MAX_HITS,
|
|
363
|
+
follow_forks: bool = False,
|
|
364
|
+
) -> DynamicScript:
|
|
365
|
+
"""Generate a deterministic, non-interactive gdb command script.
|
|
366
|
+
|
|
367
|
+
The script disables pagination, confirmation prompts and ASLR randomisation,
|
|
368
|
+
sets one breakpoint per address, and for every hit prints a line
|
|
369
|
+
|
|
370
|
+
``HIT <address> <reg>=<value> ...``
|
|
371
|
+
|
|
372
|
+
(the address is the literal breakpoint address, the register snapshot uses
|
|
373
|
+
gdb's portable ``pc``/``sp``/``fp``/``ps`` plus the binary's own registers),
|
|
374
|
+
then ``BACKTRACE`` followed by :data:`BACKTRACE_DEPTH` ``bt`` frames, and
|
|
375
|
+
finally ``continue``. It exits after ``max_hits`` hits, printing ``LIMIT`` so a
|
|
376
|
+
parser can flag a truncated session, and quits with ``DONE <n> hits``.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
path: Target binary. Recorded in the header only; the file is not opened.
|
|
380
|
+
info: Binary metadata. Used to pick architecture-specific registers and to
|
|
381
|
+
check that every address lies in an executable section. ``None`` keeps
|
|
382
|
+
the standard registers and skips the section check.
|
|
383
|
+
addresses: Breakpoint addresses, in file virtual-address space.
|
|
384
|
+
max_hits: Hit ceiling, ``1 <= max_hits <=`` :data:`MAX_MAX_HITS`.
|
|
385
|
+
follow_forks: Follow the child across a ``fork`` instead of staying in the
|
|
386
|
+
parent.
|
|
387
|
+
|
|
388
|
+
Raises:
|
|
389
|
+
AnalysisError: An empty address list, a non-``int``/non-positive address,
|
|
390
|
+
an address outside an executable section, an out-of-range ``max_hits``,
|
|
391
|
+
a non-``bool`` ``follow_forks`` or an empty ``path`` -- each message
|
|
392
|
+
names the offending input.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
The script, its ``entry_commands`` (the ``set``/``break`` lines and
|
|
396
|
+
``run``) and the notes that state its limits.
|
|
397
|
+
"""
|
|
398
|
+
target = _require_target(path)
|
|
399
|
+
limit = _require_max_hits(max_hits)
|
|
400
|
+
if not isinstance(follow_forks, bool):
|
|
401
|
+
raise AnalysisError(
|
|
402
|
+
f"follow_forks must be a bool, got {type(follow_forks).__name__}: {follow_forks!r}"
|
|
403
|
+
)
|
|
404
|
+
points = _validate_addresses(addresses, info)
|
|
405
|
+
|
|
406
|
+
registers: tuple[str, ...] = _STANDARD_REGISTERS
|
|
407
|
+
if info is not None:
|
|
408
|
+
registers += _ARCH_REGISTERS.get(info.architecture, ())
|
|
409
|
+
|
|
410
|
+
fork_mode = "child" if follow_forks else "parent"
|
|
411
|
+
notes = (
|
|
412
|
+
"the script is non-interactive: it starts the target, stops at each breakpoint, "
|
|
413
|
+
"prints one HIT line with a register snapshot plus a short backtrace, and quits",
|
|
414
|
+
"pagination, confirmation prompts and ASLR randomisation are turned off up front "
|
|
415
|
+
"(set pagination off, set confirm off, set disable-randomization on), so nothing "
|
|
416
|
+
"waits for input and the loader does not move the image",
|
|
417
|
+
f"the run stops after {limit} hit(s) and prints a LIMIT line, so raise max_hits to "
|
|
418
|
+
f"record more; at most {BACKTRACE_DEPTH} backtrace frame(s) are printed per hit",
|
|
419
|
+
"breakpoints are absolute file virtual addresses: a PIE/shared image that is not "
|
|
420
|
+
"loaded at its link-time base will not be hit",
|
|
421
|
+
f"forks use set follow-fork-mode {fork_mode} and set detach-on-fork on, so "
|
|
422
|
+
+ (
|
|
423
|
+
"the child is followed and the parent detached"
|
|
424
|
+
if follow_forks
|
|
425
|
+
else "gdb stays in the parent and detaches the child"
|
|
426
|
+
),
|
|
427
|
+
"the register snapshot uses gdb's portable names "
|
|
428
|
+
f"({', '.join(_STANDARD_REGISTERS)})"
|
|
429
|
+
+ (
|
|
430
|
+
f" plus the binary's own ({', '.join(registers[len(_STANDARD_REGISTERS):])})"
|
|
431
|
+
if len(registers) > len(_STANDARD_REGISTERS)
|
|
432
|
+
else " only, because no binary metadata was supplied"
|
|
433
|
+
)
|
|
434
|
+
+ "; a name gdb does not know aborts that printf line",
|
|
435
|
+
"gdb must be on PATH to run this; available_backends() reports whether it is",
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
header = [
|
|
439
|
+
"# CODEVANTA gdb session script -- generated by codevanta.analysis.dynamic",
|
|
440
|
+
"# Do not edit: regenerate with build_gdb_script().",
|
|
441
|
+
f"# target : {target}",
|
|
442
|
+
"# provider : gdb",
|
|
443
|
+
]
|
|
444
|
+
if info is not None:
|
|
445
|
+
header.append(f"# format : {info.format} {info.architecture} {info.bitness}-bit")
|
|
446
|
+
header.append(f"# image base : 0x{info.image_base:x}")
|
|
447
|
+
header.extend(
|
|
448
|
+
[
|
|
449
|
+
f"# breakpoints : {len(points)} ({_summarize_addresses(points)})",
|
|
450
|
+
f"# max hits : {limit}",
|
|
451
|
+
f"# backtrace : {BACKTRACE_DEPTH} frame(s) per hit",
|
|
452
|
+
f"# follow forks: {'yes (child)' if follow_forks else 'no (parent)'}",
|
|
453
|
+
"# registers : " + " ".join(registers),
|
|
454
|
+
]
|
|
455
|
+
)
|
|
456
|
+
header.extend(f"# note: {note}" for note in notes)
|
|
457
|
+
header.append("")
|
|
458
|
+
|
|
459
|
+
setup = [
|
|
460
|
+
"set pagination off",
|
|
461
|
+
"set confirm off",
|
|
462
|
+
"set width 0",
|
|
463
|
+
"set height 0",
|
|
464
|
+
"set print elements 0",
|
|
465
|
+
"set breakpoint pending on",
|
|
466
|
+
"set disable-randomization on",
|
|
467
|
+
f"set follow-fork-mode {fork_mode}",
|
|
468
|
+
"set detach-on-fork on",
|
|
469
|
+
"set $cv_hits = 0",
|
|
470
|
+
]
|
|
471
|
+
breakpoints = [f"break *0x{address:x}" for address in points]
|
|
472
|
+
format_spec = " ".join(f"{name}=%#lx" for name in registers)
|
|
473
|
+
arguments = ", ".join(f"(unsigned long)${name}" for name in registers)
|
|
474
|
+
|
|
475
|
+
body = list(setup)
|
|
476
|
+
for address, breakpoint in zip(points, breakpoints, strict=True):
|
|
477
|
+
body.extend(
|
|
478
|
+
[
|
|
479
|
+
breakpoint,
|
|
480
|
+
"commands",
|
|
481
|
+
" silent",
|
|
482
|
+
" set $cv_hits = $cv_hits + 1",
|
|
483
|
+
f' printf "HIT 0x{address:x} {format_spec}\\n", {arguments}',
|
|
484
|
+
' printf "BACKTRACE\\n"',
|
|
485
|
+
f" bt {BACKTRACE_DEPTH}",
|
|
486
|
+
f" if $cv_hits >= {limit}",
|
|
487
|
+
f' printf "LIMIT {limit} hits reached\\n"',
|
|
488
|
+
" quit",
|
|
489
|
+
" end",
|
|
490
|
+
" continue",
|
|
491
|
+
"end",
|
|
492
|
+
]
|
|
493
|
+
)
|
|
494
|
+
body.extend(
|
|
495
|
+
[
|
|
496
|
+
"run",
|
|
497
|
+
'printf "DONE %d hits\\n", $cv_hits',
|
|
498
|
+
"quit",
|
|
499
|
+
]
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
return DynamicScript(
|
|
503
|
+
provider="gdb",
|
|
504
|
+
text="\n".join([*header, *body]) + "\n",
|
|
505
|
+
entry_commands=tuple([*setup, *breakpoints, "run"]),
|
|
506
|
+
notes=notes,
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
_ARCH_REGISTERS: dict[str, tuple[str, ...]] = {
|
|
511
|
+
"x86_64": ("rip", "rax", "rbx", "rcx", "rdx", "rsi", "rdi"),
|
|
512
|
+
"x86": ("eip", "eax", "ebx", "ecx", "edx", "esi", "edi"),
|
|
513
|
+
"aarch64": ("x0", "x1", "x2", "x3", "x8", "x29", "x30"),
|
|
514
|
+
"arm": ("r0", "r1", "r2", "r3", "r7", "r12"),
|
|
515
|
+
"mips": ("a0", "a1", "a2", "a3", "v0", "v1"),
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# --------------------------------------------------------------------------- #
|
|
521
|
+
# frida
|
|
522
|
+
# --------------------------------------------------------------------------- #
|
|
523
|
+
|
|
524
|
+
def build_frida_script(
|
|
525
|
+
path: str | Path,
|
|
526
|
+
info: BinaryInfo | None,
|
|
527
|
+
addresses: Iterable[int],
|
|
528
|
+
*,
|
|
529
|
+
max_hits: int = DEFAULT_MAX_HITS,
|
|
530
|
+
) -> DynamicScript:
|
|
531
|
+
"""Generate a deterministic Frida (JavaScript) instrumentation script.
|
|
532
|
+
|
|
533
|
+
Addresses are module-relative: each file virtual address is reduced by the
|
|
534
|
+
image base, and the script re-adds ``Module.getBaseAddress(MODULE_NAME)`` at
|
|
535
|
+
run time, so ASLR cannot move a hook off its target. Every hit sends one JSON
|
|
536
|
+
message ``{hit, address, tid, args[0..3]}``; after ``max_hits`` hits every
|
|
537
|
+
listener is detached and an ``{event: "limit"}`` message is sent. The target is
|
|
538
|
+
never killed, and a module that cannot be resolved produces an
|
|
539
|
+
``{event: "error"}`` message instead of hooking garbage.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
path: Target binary. Only its basename is used, as the module name, and the
|
|
543
|
+
path is recorded in the header; the file is not opened.
|
|
544
|
+
info: Binary metadata. **Required** here: the module-relative offset is
|
|
545
|
+
``address - info.image_base`` and cannot be derived without it.
|
|
546
|
+
addresses: Hook addresses, in file virtual-address space.
|
|
547
|
+
max_hits: Hit ceiling, ``1 <= max_hits <=`` :data:`MAX_MAX_HITS`.
|
|
548
|
+
|
|
549
|
+
Raises:
|
|
550
|
+
AnalysisError: ``info`` is ``None``, an address below the image base (so no
|
|
551
|
+
non-negative offset exists), an empty address list, a non-``int`` or
|
|
552
|
+
non-positive address, an address outside an executable section, an
|
|
553
|
+
out-of-range ``max_hits`` or an empty ``path``.
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
The script, its ``entry_commands`` (one ``Interceptor.attach`` expression
|
|
557
|
+
per address) and the notes that state its limits.
|
|
558
|
+
"""
|
|
559
|
+
target = _require_target(path)
|
|
560
|
+
limit = _require_max_hits(max_hits)
|
|
561
|
+
if info is None:
|
|
562
|
+
raise AnalysisError(
|
|
563
|
+
"build_frida_script needs binary metadata: offsets are module-relative and "
|
|
564
|
+
"cannot be computed without the image base"
|
|
565
|
+
)
|
|
566
|
+
points = _validate_addresses(addresses, info)
|
|
567
|
+
base = info.image_base
|
|
568
|
+
module = Path(target).name
|
|
569
|
+
if not module:
|
|
570
|
+
raise AnalysisError(f"cannot derive a module name from path {target!r}")
|
|
571
|
+
for address in points:
|
|
572
|
+
if address < base:
|
|
573
|
+
raise AnalysisError(
|
|
574
|
+
f"address 0x{address:x} is below the image base 0x{base:x}; it cannot be "
|
|
575
|
+
"expressed as a module-relative offset"
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
offsets = {address: address - base for address in points}
|
|
579
|
+
notes = (
|
|
580
|
+
"offsets are module-relative (address - image base) and the script re-adds "
|
|
581
|
+
f"Module.getBaseAddress({module!r}) at run time, so ASLR cannot move a hook",
|
|
582
|
+
"each hit sends one JSON message {hit, address, tid, args} with "
|
|
583
|
+
f"{MAX_FRIDA_ARGS} argument registers; those messages are the only output",
|
|
584
|
+
f"after {limit} hit(s) every listener is detached and an "
|
|
585
|
+
'{event: "limit"} message is sent; the target process is never killed',
|
|
586
|
+
"a module that cannot be resolved produces an "
|
|
587
|
+
'{event: "error"} message instead of hooking garbage',
|
|
588
|
+
"run this with the frida CLI; the python bindings are not needed to generate the "
|
|
589
|
+
"script, only by the live session layer",
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
header = [
|
|
593
|
+
"// CODEVANTA Frida session script -- generated by codevanta.analysis.dynamic",
|
|
594
|
+
"// Do not edit: regenerate with build_frida_script().",
|
|
595
|
+
f"// target : {target}",
|
|
596
|
+
"// provider : frida",
|
|
597
|
+
f"// format : {info.format} {info.architecture} {info.bitness}-bit",
|
|
598
|
+
f"// module : {module}",
|
|
599
|
+
f"// image base : 0x{base:x}",
|
|
600
|
+
f"// max hits : {limit}",
|
|
601
|
+
"// hooks : " + _summarize_addresses(points),
|
|
602
|
+
]
|
|
603
|
+
header.extend(
|
|
604
|
+
f"// offset 0x{offset:x} <- 0x{address:x}" for address, offset in offsets.items()
|
|
605
|
+
)
|
|
606
|
+
header.extend(f"// note: {note}" for note in notes)
|
|
607
|
+
header.append("")
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
script = [
|
|
611
|
+
"'use strict';",
|
|
612
|
+
"",
|
|
613
|
+
f'var MODULE_NAME = "{module}";',
|
|
614
|
+
f"var MAX_HITS = {limit};",
|
|
615
|
+
f"var ARG_COUNT = {MAX_FRIDA_ARGS};",
|
|
616
|
+
"",
|
|
617
|
+
"var hits = 0;",
|
|
618
|
+
"var base = null;",
|
|
619
|
+
"var listeners = [];",
|
|
620
|
+
"",
|
|
621
|
+
"// Stop observing without touching the target process.",
|
|
622
|
+
"function detachAll() {",
|
|
623
|
+
" while (listeners.length > 0) {",
|
|
624
|
+
" var listener = listeners.pop();",
|
|
625
|
+
" try {",
|
|
626
|
+
" listener.detach();",
|
|
627
|
+
" } catch (error) {",
|
|
628
|
+
" // The hook may already be gone (module unloaded): nothing to undo.",
|
|
629
|
+
" }",
|
|
630
|
+
" }",
|
|
631
|
+
"}",
|
|
632
|
+
"",
|
|
633
|
+
"function makeHandler(address) {",
|
|
634
|
+
" return {",
|
|
635
|
+
" onEnter: function (args) {",
|
|
636
|
+
" if (hits >= MAX_HITS) {",
|
|
637
|
+
" return;",
|
|
638
|
+
" }",
|
|
639
|
+
" hits += 1;",
|
|
640
|
+
" var values = [];",
|
|
641
|
+
" for (var index = 0; index < ARG_COUNT; index += 1) {",
|
|
642
|
+
" values.push(args[index].toString());",
|
|
643
|
+
" }",
|
|
644
|
+
" send({",
|
|
645
|
+
" hit: hits,",
|
|
646
|
+
" address: address,",
|
|
647
|
+
" tid: Process.getCurrentThreadId(),",
|
|
648
|
+
" args: values",
|
|
649
|
+
" });",
|
|
650
|
+
" if (hits >= MAX_HITS) {",
|
|
651
|
+
' send({ event: "limit", hits: hits });',
|
|
652
|
+
" detachAll();",
|
|
653
|
+
" }",
|
|
654
|
+
" }",
|
|
655
|
+
" };",
|
|
656
|
+
"}",
|
|
657
|
+
"",
|
|
658
|
+
"try {",
|
|
659
|
+
" base = Module.getBaseAddress(MODULE_NAME);",
|
|
660
|
+
"} catch (error) {",
|
|
661
|
+
' send({ event: "error", message: "module " + MODULE_NAME + " not found: " + error });',
|
|
662
|
+
" base = null;",
|
|
663
|
+
"}",
|
|
664
|
+
"",
|
|
665
|
+
"if (base !== null) {",
|
|
666
|
+
]
|
|
667
|
+
hooks = [
|
|
668
|
+
f'Interceptor.attach(base.add(0x{offsets[address]:x}), makeHandler("0x{address:x}"))'
|
|
669
|
+
for address in points
|
|
670
|
+
]
|
|
671
|
+
script.extend(f" listeners.push({hook});" for hook in hooks)
|
|
672
|
+
script.append("}")
|
|
673
|
+
|
|
674
|
+
return DynamicScript(
|
|
675
|
+
provider="frida",
|
|
676
|
+
text="\n".join([*header, *script]) + "\n",
|
|
677
|
+
entry_commands=tuple(hooks),
|
|
678
|
+
notes=notes,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
# --------------------------------------------------------------------------- #
|
|
683
|
+
# Command lines
|
|
684
|
+
# --------------------------------------------------------------------------- #
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def script_file_name(provider: str) -> str:
|
|
688
|
+
"""Return the fixed file name the provider's script should be written to.
|
|
689
|
+
|
|
690
|
+
Raises:
|
|
691
|
+
AnalysisError: ``provider`` is not one of :data:`PROVIDERS`.
|
|
692
|
+
"""
|
|
693
|
+
name = SCRIPT_FILE_NAMES.get(provider)
|
|
694
|
+
if name is None:
|
|
695
|
+
raise AnalysisError(
|
|
696
|
+
f"unknown provider {provider!r}; supported providers: {', '.join(PROVIDERS)}"
|
|
697
|
+
)
|
|
698
|
+
return name
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def attach_command(
|
|
702
|
+
provider: str,
|
|
703
|
+
path: str | Path,
|
|
704
|
+
script: DynamicScript,
|
|
705
|
+
pid: int | None = None,
|
|
706
|
+
) -> tuple[str, ...]:
|
|
707
|
+
"""Return the exact ``argv`` that runs ``script`` against ``path``.
|
|
708
|
+
|
|
709
|
+
The tuple is ready for ``subprocess.run(..., shell=False)``; it is never a
|
|
710
|
+
shell string, so a path with spaces or quotes cannot be reinterpreted.
|
|
711
|
+
|
|
712
|
+
Args:
|
|
713
|
+
provider: ``"gdb"`` or ``"frida"``.
|
|
714
|
+
path: Target binary, spelled the way the provider should see it.
|
|
715
|
+
script: The script this command runs; its provider must match ``provider``
|
|
716
|
+
so a gdb script can never be handed to frida.
|
|
717
|
+
pid: Attach to a running process instead of launching one. Supported for
|
|
718
|
+
``frida`` only: the generated gdb script ends with ``run``, so a gdb
|
|
719
|
+
command that attaches would not match it.
|
|
720
|
+
|
|
721
|
+
Raises:
|
|
722
|
+
AnalysisError: An unknown provider, a script generated for another
|
|
723
|
+
provider, an empty script, an empty path, a non-positive ``pid``, or
|
|
724
|
+
``pid`` used with ``gdb``.
|
|
725
|
+
|
|
726
|
+
Returns:
|
|
727
|
+
``("gdb", "-q", "-batch", "-x", <script>, "--args", <path>)`` for gdb, or
|
|
728
|
+
``("frida", "-l", <script>, "-f", <path>)`` (``"-p", <pid>`` when attaching)
|
|
729
|
+
for frida.
|
|
730
|
+
"""
|
|
731
|
+
script_file = script_file_name(provider)
|
|
732
|
+
if not isinstance(script, DynamicScript):
|
|
733
|
+
raise AnalysisError(f"script must be a DynamicScript, got {type(script).__name__}")
|
|
734
|
+
if script.provider != provider:
|
|
735
|
+
raise AnalysisError(
|
|
736
|
+
f"script was generated for {script.provider!r}, not {provider!r}; "
|
|
737
|
+
"build the script with the matching builder"
|
|
738
|
+
)
|
|
739
|
+
if not script.text.strip():
|
|
740
|
+
raise AnalysisError(f"script for {provider!r} must not be empty")
|
|
741
|
+
target = _require_target(path)
|
|
742
|
+
if pid is not None:
|
|
743
|
+
if isinstance(pid, bool) or not isinstance(pid, int):
|
|
744
|
+
raise AnalysisError(f"pid must be an int, got {type(pid).__name__}: {pid!r}")
|
|
745
|
+
if pid <= 0:
|
|
746
|
+
raise AnalysisError(f"pid must be positive, got {pid}")
|
|
747
|
+
if provider == "gdb":
|
|
748
|
+
raise AnalysisError(
|
|
749
|
+
"gdb sessions are launch scripts (the script ends with 'run'), so a pid "
|
|
750
|
+
"cannot be used; pass the target path instead"
|
|
751
|
+
)
|
|
752
|
+
return ("frida", "-l", script_file, "-p", str(pid))
|
|
753
|
+
if provider == "gdb":
|
|
754
|
+
return ("gdb", "-q", "-batch", "-x", script_file, "--args", target)
|
|
755
|
+
return ("frida", "-l", script_file, "-f", target)
|
|
756
|
+
|