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,715 @@
|
|
|
1
|
+
"""Solver-based symbolic execution for one function: concrete inputs per path.
|
|
2
|
+
|
|
3
|
+
The unicorn explorer (:mod:`codevanta.analysis.symbolic`) enumerates branch
|
|
4
|
+
decisions and replays them on a virtual CPU, deliberately solving nothing. This
|
|
5
|
+
module is the complement: it hands the function to `angr <https://angr.io/>`_,
|
|
6
|
+
which runs the code on *symbolic* states, forks at every conditional branch,
|
|
7
|
+
and accumulates one constraint per fork. Each reported path therefore carries
|
|
8
|
+
the *condition* that reaches it plus a concrete assignment of the six integer
|
|
9
|
+
argument registers that satisfies it (claripy solves the entry register
|
|
10
|
+
variables under the path's constraints). Nothing is executed on the host: angr
|
|
11
|
+
loads only the file bytes into its own simulation (``auto_load_libs`` is always
|
|
12
|
+
false), and hard caps bound the work.
|
|
13
|
+
|
|
14
|
+
Guarantees
|
|
15
|
+
----------
|
|
16
|
+
* **Lazy.** angr is *optional* and imported inside :func:`explore_symbolic`,
|
|
17
|
+
never at module import time. :data:`SOLVER_AVAILABLE` only records whether a
|
|
18
|
+
module called ``angr`` is findable -- the same probe
|
|
19
|
+
:mod:`codevanta.analysis.symbolic` uses -- and :func:`solver_status` returns
|
|
20
|
+
the reason when it is absent. Without angr the function raises
|
|
21
|
+
:class:`~codevanta.core.report.AnalysisError` naming angr and the extra that
|
|
22
|
+
installs it. Only x86_64 is supported: the register vocabulary, the ABI seed
|
|
23
|
+
list and the capstone sweep are all 64-bit x86.
|
|
24
|
+
* **Bounded.** At most ``max_paths`` states stay active at any tick (extras are
|
|
25
|
+
dropped and the result says so), the run stops once ``max_steps`` executed
|
|
26
|
+
instructions are accounted for -- the simulation manager is stepped one
|
|
27
|
+
*instruction* per state per tick and every tick's instruction executions are
|
|
28
|
+
charged to the budget -- and at most ``max_paths`` paths are reported.
|
|
29
|
+
:attr:`SymbolicResult.truncated` is true exactly when a cap cut the
|
|
30
|
+
exploration short.
|
|
31
|
+
* **Honest.** Every path ends with one of three ``stopped_reason`` values:
|
|
32
|
+
``return`` (a ``ret`` was reached), ``budget`` (the step budget ran out while
|
|
33
|
+
the state was still going) or ``error`` (the state faulted under angr). A
|
|
34
|
+
``budget``/``error`` path reports ``return_value=None`` because it never
|
|
35
|
+
reached a ``ret`` -- a register snapshot would be dressed up as a return
|
|
36
|
+
value. And the evidence states the real limit of the method: the paths angr
|
|
37
|
+
found are real, but *finding every* path is not guaranteed.
|
|
38
|
+
|
|
39
|
+
Path semantics
|
|
40
|
+
--------------
|
|
41
|
+
The simulation manager is stepped tick by tick with ``find`` semantics, but the
|
|
42
|
+
ticks are applied here rather than delegated, so the instruction budget is
|
|
43
|
+
exact and the active-state cap is enforced between ticks. One tick executes one
|
|
44
|
+
instruction in every active state; afterwards every state whose instruction
|
|
45
|
+
pointer is (possibly, under its own constraints) a ``ret`` address is harvested
|
|
46
|
+
as a ``return`` path -- the same thing ``SimulationManager.explore`` does with
|
|
47
|
+
``find``, with ``avoid`` being nothing. States that fault move to angr's
|
|
48
|
+
errored records and become ``error`` paths; states that land on a symbolic jump
|
|
49
|
+
or return target become *unconstrained*, cannot be enumerated this way, and are
|
|
50
|
+
counted in a warning. What remains stays active until a cap or the budget ends
|
|
51
|
+
it as a ``budget`` path.
|
|
52
|
+
|
|
53
|
+
The ``find`` set is the ``ret`` instructions of a bounded linear sweep from the
|
|
54
|
+
start address (the same sweep the angr bridge in ``symbolic`` uses), so a
|
|
55
|
+
``ret`` the sweep cannot see is never treated as found and the sweep's limits
|
|
56
|
+
are written into the evidence.
|
|
57
|
+
|
|
58
|
+
Constraint and input extraction
|
|
59
|
+
-------------------------------
|
|
60
|
+
Every state starts from :meth:`angr.project.Project.factory.entry_state` with
|
|
61
|
+
each of the six SysV x86-64 integer argument registers seeded: concretely when
|
|
62
|
+
the caller passed ``initial_registers``, otherwise as a named symbolic variable
|
|
63
|
+
(``codevanta_initial_<register>``). At each stopped state the path's
|
|
64
|
+
``constraints`` are ``state.solver.constraints`` stringified (capped at
|
|
65
|
+
:data:`_MAX_CONSTRAINTS` entries), and ``input_registers`` solves each entry
|
|
66
|
+
variable under *that state's* constraints -- so the values are concrete inputs
|
|
67
|
+
that genuinely reach the path (a path constrained to ``rdi == 0`` reports a
|
|
68
|
+
zero ``rdi``). ``return_value`` is one concrete solution for ``rax`` at the
|
|
69
|
+
``ret``, not every value ``rax`` can take; the evidence says so.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
from __future__ import annotations
|
|
73
|
+
|
|
74
|
+
import importlib.util
|
|
75
|
+
from collections.abc import Mapping
|
|
76
|
+
from dataclasses import dataclass
|
|
77
|
+
from pathlib import Path
|
|
78
|
+
from typing import Any
|
|
79
|
+
|
|
80
|
+
import capstone
|
|
81
|
+
|
|
82
|
+
from codevanta.core.loader import BinaryInfo
|
|
83
|
+
from codevanta.core.report import AnalysisError, read_code
|
|
84
|
+
from codevanta.evidence.confidence import Confidence
|
|
85
|
+
from codevanta.evidence.models import Evidence
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"DEFAULT_MAX_PATHS",
|
|
89
|
+
"DEFAULT_MAX_STEPS",
|
|
90
|
+
"MAX_MAX_PATHS",
|
|
91
|
+
"MAX_MAX_STEPS",
|
|
92
|
+
"SOLVER_AVAILABLE",
|
|
93
|
+
"SolverPath",
|
|
94
|
+
"SymbolicResult",
|
|
95
|
+
"explore_symbolic",
|
|
96
|
+
"solver_status",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
# ---- limits ---------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
#: Paths reported when the caller does not pass ``max_paths``.
|
|
102
|
+
DEFAULT_MAX_PATHS = 8
|
|
103
|
+
|
|
104
|
+
#: Hard ceiling on ``max_paths`` (bounded work, whatever the caller asks).
|
|
105
|
+
MAX_MAX_PATHS = 4096
|
|
106
|
+
|
|
107
|
+
#: Instruction budget used when the caller does not pass ``max_steps``.
|
|
108
|
+
DEFAULT_MAX_STEPS = 2000
|
|
109
|
+
|
|
110
|
+
#: Hard ceiling on ``max_steps``.
|
|
111
|
+
MAX_MAX_STEPS = 200_000
|
|
112
|
+
|
|
113
|
+
#: Instructions a linear sweep reads at most when collecting a function's
|
|
114
|
+
#: ``ret`` sites for the find set.
|
|
115
|
+
MAX_SCAN_INSTRUCTIONS = 4096
|
|
116
|
+
|
|
117
|
+
#: Solver constraints stringified per path before the rest are dropped.
|
|
118
|
+
_MAX_CONSTRAINTS = 32
|
|
119
|
+
|
|
120
|
+
#: Bytes read per disassembly step in the ``ret`` sweep.
|
|
121
|
+
_MAX_INSTRUCTION_BYTES = 16
|
|
122
|
+
|
|
123
|
+
#: Cap on the raw section copy angr falls back to when it cannot load the file.
|
|
124
|
+
_MAX_BLOB_BYTES = 1 << 20
|
|
125
|
+
|
|
126
|
+
#: Errored states described individually in warnings; the rest stay counted.
|
|
127
|
+
_MAX_ERROR_NOTES = 4
|
|
128
|
+
|
|
129
|
+
#: The six SysV x86-64 integer argument registers -- the only seed names this
|
|
130
|
+
#: module accepts, and the registers solved concretely for every path.
|
|
131
|
+
_ARG_REGISTERS_64 = ("rdi", "rsi", "rdx", "rcx", "r8", "r9")
|
|
132
|
+
|
|
133
|
+
#: ``BinaryInfo.architecture`` spellings accepted as 64-bit x86.
|
|
134
|
+
_X86_64_NAMES = frozenset({"x86_64", "x86-64", "amd64", "em_amd64"})
|
|
135
|
+
|
|
136
|
+
#: Instruction mnemonics that leave the explored function (Capstone's naming).
|
|
137
|
+
_RET_MNEMONICS = frozenset({"ret", "retf", "retn", "iret", "iretd", "iretq"})
|
|
138
|
+
|
|
139
|
+
# ---- evidence identity ------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
#: Evidence identity for the solver-based explorer.
|
|
142
|
+
_EVIDENCE_SOURCE = "solver.angr"
|
|
143
|
+
_EVIDENCE_METHOD = "solver_symbolic_execution"
|
|
144
|
+
|
|
145
|
+
#: The constraint solver behind claripy on every angr this module supports.
|
|
146
|
+
_SOLVER_NAME = "z3"
|
|
147
|
+
|
|
148
|
+
#: What a caller needs to install angr.
|
|
149
|
+
_ANGR_HINT = "pip install 'codevanta[decompilers]'"
|
|
150
|
+
|
|
151
|
+
#: Every AnalysisError from this module names angr and the way to install it.
|
|
152
|
+
_ANGR_SUFFIX = f"angr is required for solver-based exploration: {_ANGR_HINT}"
|
|
153
|
+
|
|
154
|
+
#: What solver-based exploration proved versus what it only reports.
|
|
155
|
+
_ASSUMPTIONS: tuple[str, ...] = (
|
|
156
|
+
"solver-based exploration; concrete inputs extracted per path; no proof all paths were found",
|
|
157
|
+
"each path carries the constraints angr accumulated up to its end, stringified and capped at "
|
|
158
|
+
f"{_MAX_CONSTRAINTS} entries",
|
|
159
|
+
"return_value is one concrete solution for rax, not every value rax can take on that path",
|
|
160
|
+
"the find set is the ret instructions of a bounded linear sweep; a ret the sweep cannot see is "
|
|
161
|
+
"never treated as found, so a path can end as 'budget' although a ret exists ahead of it",
|
|
162
|
+
"input_registers solve the entry values of the six integer argument registers under the "
|
|
163
|
+
"path's constraints; the code may overwrite them before it returns",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# ---- stop reasons -----------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
#: Why one path ended. ``return``: the state reached a ``ret``; ``budget``: the
|
|
169
|
+
#: step budget ran out while the state was still going; ``error``: the state
|
|
170
|
+
#: faulted under angr.
|
|
171
|
+
_STOP_RETURN = "return"
|
|
172
|
+
_STOP_BUDGET = "budget"
|
|
173
|
+
_STOP_ERROR = "error"
|
|
174
|
+
|
|
175
|
+
# ---- availability -----------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _probe_angr() -> bool:
|
|
179
|
+
"""True when a module called ``angr`` is findable; never imports angr."""
|
|
180
|
+
try:
|
|
181
|
+
return importlib.util.find_spec("angr") is not None
|
|
182
|
+
except Exception: # pragma: no cover - depends on the installation
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
#: True when a module named ``angr`` is findable. This is a *probe*, not an
|
|
187
|
+
#: import: angr is imported only inside :func:`explore_symbolic`, so importing
|
|
188
|
+
#: this module stays cheap and keeps working on a machine without angr
|
|
189
|
+
#: (mirrors :data:`codevanta.analysis.symbolic.ANGR_AVAILABLE`).
|
|
190
|
+
SOLVER_AVAILABLE = _probe_angr()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def solver_status() -> str:
|
|
194
|
+
"""Return why solver-based exploration is (un)available on this machine.
|
|
195
|
+
|
|
196
|
+
A sentence either way: with angr installed it says so; without, it is the
|
|
197
|
+
exact reason, naming angr and the extra that installs it.
|
|
198
|
+
"""
|
|
199
|
+
if SOLVER_AVAILABLE:
|
|
200
|
+
return (
|
|
201
|
+
"ok: 'angr' is findable, so explore_symbolic can run solver-based "
|
|
202
|
+
f"symbolic execution (claripy/{_SOLVER_NAME})"
|
|
203
|
+
)
|
|
204
|
+
return (
|
|
205
|
+
"'angr' is not installed, so explore_symbolic cannot run: solver-based exploration is "
|
|
206
|
+
"optional in CODEVANTA and angr is never imported at module import time; install it "
|
|
207
|
+
f"with {_ANGR_HINT}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _import_angr() -> Any:
|
|
212
|
+
"""Import angr lazily, or raise :class:`AnalysisError` naming it and the extra."""
|
|
213
|
+
if not SOLVER_AVAILABLE:
|
|
214
|
+
raise AnalysisError(
|
|
215
|
+
"'angr' is not installed, so explore_symbolic cannot run: solver-based exploration "
|
|
216
|
+
"is optional in CODEVANTA and angr is never imported at module import time; "
|
|
217
|
+
f"install it with {_ANGR_HINT}"
|
|
218
|
+
)
|
|
219
|
+
try:
|
|
220
|
+
import angr
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
raise AnalysisError(
|
|
223
|
+
f"importing 'angr' failed ({type(exc).__name__}: {exc}); reinstall it with {_ANGR_HINT}"
|
|
224
|
+
) from exc
|
|
225
|
+
return angr
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---- results ---------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass(frozen=True)
|
|
232
|
+
class SolverPath:
|
|
233
|
+
"""One path the solver explored, with the concrete inputs that reach it.
|
|
234
|
+
|
|
235
|
+
Attributes:
|
|
236
|
+
return_value: One concrete solution for ``rax`` at the ``ret`` -- or
|
|
237
|
+
``None`` when the path never reached a ``ret``, because a register
|
|
238
|
+
snapshot must not be dressed up as a return value.
|
|
239
|
+
constraints: The solver constraints angr accumulated up to the path's
|
|
240
|
+
end, stringified, capped at :data:`_MAX_CONSTRAINTS` entries.
|
|
241
|
+
input_registers: Concrete entry values of the six integer argument
|
|
242
|
+
registers, solved under *this path's* constraints, so the values
|
|
243
|
+
genuinely reach the path.
|
|
244
|
+
stopped_reason: ``"return"``, ``"budget"`` or ``"error"``.
|
|
245
|
+
steps: Instructions executed along this path: one per tick angr stepped
|
|
246
|
+
it, the ``ret`` itself never executed.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
return_value: int | None
|
|
250
|
+
constraints: tuple[str, ...]
|
|
251
|
+
input_registers: Mapping[str, int]
|
|
252
|
+
stopped_reason: str
|
|
253
|
+
steps: int
|
|
254
|
+
|
|
255
|
+
def to_dict(self) -> dict[str, Any]:
|
|
256
|
+
"""Serialize to a JSON-safe dict (hex values, lists, no solver objects)."""
|
|
257
|
+
return {
|
|
258
|
+
"return_value": (
|
|
259
|
+
f"0x{self.return_value:x}" if self.return_value is not None else None
|
|
260
|
+
),
|
|
261
|
+
"constraints": list(self.constraints),
|
|
262
|
+
"input_registers": {
|
|
263
|
+
name: f"0x{value:x}" for name, value in sorted(self.input_registers.items())
|
|
264
|
+
},
|
|
265
|
+
"stopped_reason": self.stopped_reason,
|
|
266
|
+
"steps": self.steps,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclass(frozen=True)
|
|
271
|
+
class SymbolicResult:
|
|
272
|
+
"""Every path one solver-based exploration produced, plus its limits.
|
|
273
|
+
|
|
274
|
+
Attributes:
|
|
275
|
+
address: Virtual address the exploration started at.
|
|
276
|
+
paths: The paths, in the order angr's solver stopped on them.
|
|
277
|
+
solver: Which constraint solver produced the concrete models (``z3``).
|
|
278
|
+
truncated: ``True`` exactly when a cap -- the path cap, the
|
|
279
|
+
active-state cap or the step budget -- cut the exploration short.
|
|
280
|
+
warnings: What was left unexplored, dropped or unconstrained.
|
|
281
|
+
evidence: Provenance for the whole exploration, including what the
|
|
282
|
+
solver proved and what it only reports.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
address: int
|
|
286
|
+
paths: tuple[SolverPath, ...]
|
|
287
|
+
solver: str
|
|
288
|
+
truncated: bool
|
|
289
|
+
warnings: tuple[str, ...]
|
|
290
|
+
evidence: Evidence
|
|
291
|
+
|
|
292
|
+
def to_dict(self) -> dict[str, Any]:
|
|
293
|
+
"""Serialize to a JSON-safe dict (hex address, lists, no enums)."""
|
|
294
|
+
return {
|
|
295
|
+
"address": f"0x{self.address:x}",
|
|
296
|
+
"solver": self.solver,
|
|
297
|
+
"truncated": self.truncated,
|
|
298
|
+
"path_count": len(self.paths),
|
|
299
|
+
"paths": [entry.to_dict() for entry in self.paths],
|
|
300
|
+
"warnings": list(self.warnings),
|
|
301
|
+
"evidence": self.evidence.to_dict(),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ---- small helpers ---------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _validated_address(address: int) -> int:
|
|
309
|
+
"""Reject a negative start address; everything else is the loader's job."""
|
|
310
|
+
if address < 0:
|
|
311
|
+
raise AnalysisError(f"address must be non-negative, got {address}; {_ANGR_SUFFIX}")
|
|
312
|
+
return address
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _validated_limit(name: str, value: int, ceiling: int) -> int:
|
|
316
|
+
"""Validate one caller-supplied budget: at least 1, at most ``ceiling``."""
|
|
317
|
+
if value < 1:
|
|
318
|
+
raise AnalysisError(f"{name} must be at least 1, got {value}; {_ANGR_SUFFIX}")
|
|
319
|
+
if value > ceiling:
|
|
320
|
+
raise AnalysisError(f"{name} too large ({value}); limit is {ceiling}; {_ANGR_SUFFIX}")
|
|
321
|
+
return value
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _validated_registers(initial_registers: Mapping[str, int] | None) -> dict[str, int]:
|
|
325
|
+
"""Validate concrete seeds: only the six ABI registers, ints in [0, 2**64)."""
|
|
326
|
+
if initial_registers is None:
|
|
327
|
+
return {}
|
|
328
|
+
seeds: dict[str, int] = {}
|
|
329
|
+
for name, value in initial_registers.items():
|
|
330
|
+
if name not in _ARG_REGISTERS_64:
|
|
331
|
+
raise AnalysisError(
|
|
332
|
+
f"unknown seed register {name!r}: explore_symbolic seeds only the six x86-64 "
|
|
333
|
+
f"integer argument registers {list(_ARG_REGISTERS_64)}; {_ANGR_SUFFIX}"
|
|
334
|
+
)
|
|
335
|
+
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value < (1 << 64):
|
|
336
|
+
raise AnalysisError(
|
|
337
|
+
f"seed value for {name!r} must be an int in [0, 2**64), got {value!r}; "
|
|
338
|
+
f"{_ANGR_SUFFIX}"
|
|
339
|
+
)
|
|
340
|
+
seeds[name] = value
|
|
341
|
+
return seeds
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _is_x86_64(info: BinaryInfo) -> bool:
|
|
345
|
+
"""True when the binary is 64-bit x86, the only architecture this module runs."""
|
|
346
|
+
return info.bitness == 64 and str(info.architecture).lower() in _X86_64_NAMES
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _ret_addresses(target: Path, info: BinaryInfo, start: int, section: Any) -> tuple[int, ...]:
|
|
350
|
+
"""Collect ``ret`` addresses in ``section`` by a bounded linear sweep.
|
|
351
|
+
|
|
352
|
+
The sweep only builds the find set, so a byte that cannot be decoded costs
|
|
353
|
+
one resynchronisation step rather than a wrong target -- the same approach
|
|
354
|
+
the angr bridge in :mod:`codevanta.analysis.symbolic` takes.
|
|
355
|
+
"""
|
|
356
|
+
decoder = capstone.Cs(capstone.CS_ARCH_X86, capstone.CS_MODE_64)
|
|
357
|
+
found: list[int] = []
|
|
358
|
+
end = int(section.virtual_address) + int(section.size)
|
|
359
|
+
pc = start
|
|
360
|
+
for _ in range(MAX_SCAN_INSTRUCTIONS):
|
|
361
|
+
if pc >= end:
|
|
362
|
+
break
|
|
363
|
+
try:
|
|
364
|
+
data = read_code(target, info, pc, _MAX_INSTRUCTION_BYTES)
|
|
365
|
+
except AnalysisError:
|
|
366
|
+
break
|
|
367
|
+
insn = next(iter(decoder.disasm(bytes(data), pc)), None)
|
|
368
|
+
if insn is None:
|
|
369
|
+
pc += 1
|
|
370
|
+
continue
|
|
371
|
+
if str(insn.mnemonic).lower() in _RET_MNEMONICS:
|
|
372
|
+
found.append(pc)
|
|
373
|
+
pc += int(insn.size)
|
|
374
|
+
return tuple(found)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _build_project(module: Any, target: Path, info: BinaryInfo, section: Any) -> tuple[Any, list[str]]:
|
|
378
|
+
"""Create the angr project, falling back to a raw section copy if needed.
|
|
379
|
+
|
|
380
|
+
The file is loaded as an executable first (full fidelity, ``auto_load_libs``
|
|
381
|
+
always false). Hand-written ELFs without program headers can defeat angr's
|
|
382
|
+
loader, so on failure the module retries with the section's bytes mapped as
|
|
383
|
+
a raw blob at the section's virtual address -- and says so in a warning.
|
|
384
|
+
"""
|
|
385
|
+
warnings: list[str] = []
|
|
386
|
+
try:
|
|
387
|
+
return module.Project(str(target), auto_load_libs=False), warnings
|
|
388
|
+
except Exception as exc:
|
|
389
|
+
warnings.append(
|
|
390
|
+
f"angr could not load {target.name} as an executable ({type(exc).__name__}: {exc}); "
|
|
391
|
+
f"exploring a raw copy of section {section.name!r} instead"
|
|
392
|
+
)
|
|
393
|
+
base = int(section.virtual_address)
|
|
394
|
+
size = min(int(section.size), _MAX_BLOB_BYTES)
|
|
395
|
+
if size <= 0:
|
|
396
|
+
raise AnalysisError(f"section {section.name!r} has no bytes to map for angr; {_ANGR_SUFFIX}")
|
|
397
|
+
try:
|
|
398
|
+
blob = read_code(target, info, base, size)
|
|
399
|
+
except AnalysisError as exc:
|
|
400
|
+
raise AnalysisError(
|
|
401
|
+
f"could not read the bytes of section {section.name!r} for the angr blob fallback "
|
|
402
|
+
f"({exc}); {_ANGR_SUFFIX}"
|
|
403
|
+
) from exc
|
|
404
|
+
try:
|
|
405
|
+
project = module.Project(
|
|
406
|
+
blob,
|
|
407
|
+
main_kwargs={"backend": "blob", "arch": "AMD64", "base_addr": base},
|
|
408
|
+
auto_load_libs=False,
|
|
409
|
+
)
|
|
410
|
+
except Exception as exc:
|
|
411
|
+
raise AnalysisError(
|
|
412
|
+
f"creating the angr project for {target.name} failed ({type(exc).__name__}: {exc}); "
|
|
413
|
+
f"{_ANGR_SUFFIX}"
|
|
414
|
+
) from exc
|
|
415
|
+
if size < int(section.size):
|
|
416
|
+
warnings.append(
|
|
417
|
+
f"only the first {size} byte(s) of section {section.name!r} were mapped for angr"
|
|
418
|
+
)
|
|
419
|
+
return project, warnings
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _seed_registers(module: Any, state: Any, seeds: Mapping[str, int]) -> dict[str, Any]:
|
|
423
|
+
"""Seed the entry state; return, per register, what to solve per path.
|
|
424
|
+
|
|
425
|
+
Registers the caller seeded concretely keep their int; the rest get a named
|
|
426
|
+
symbolic variable (``codevanta_initial_<register>``) so every stopped state
|
|
427
|
+
can solve for a concrete entry value under its own constraints.
|
|
428
|
+
"""
|
|
429
|
+
entry_values: dict[str, Any] = {}
|
|
430
|
+
for name in _ARG_REGISTERS_64:
|
|
431
|
+
if name in seeds:
|
|
432
|
+
entry_values[name] = int(seeds[name])
|
|
433
|
+
setattr(state.regs, name, entry_values[name])
|
|
434
|
+
else:
|
|
435
|
+
entry_values[name] = module.claripy.BVS(f"codevanta_initial_{name}", 64)
|
|
436
|
+
setattr(state.regs, name, entry_values[name])
|
|
437
|
+
return entry_values
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _at_ret(state: Any, find_set: frozenset[int]) -> bool:
|
|
441
|
+
"""True when reaching a ``ret`` is possible for ``state`` (find semantics).
|
|
442
|
+
|
|
443
|
+
Concrete instruction pointers are compared directly; a symbolic pointer
|
|
444
|
+
counts when one of its first two solutions is a ``ret`` address, which is
|
|
445
|
+
how ``SimulationManager.explore`` treats a find set.
|
|
446
|
+
"""
|
|
447
|
+
try:
|
|
448
|
+
values = state.solver.eval_upto(state.ip, 2)
|
|
449
|
+
except Exception: # pragma: no cover - angr owns the solver vocabulary
|
|
450
|
+
return False
|
|
451
|
+
return any(int(value) in find_set for value in values)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _path_from_state(
|
|
455
|
+
state: Any, stopped_reason: str, steps: int, entry_values: Mapping[str, Any]
|
|
456
|
+
) -> SolverPath:
|
|
457
|
+
"""Extract one :class:`SolverPath` from a state angr stopped on.
|
|
458
|
+
|
|
459
|
+
``constraints`` stringifies ``state.solver.constraints`` (capped);
|
|
460
|
+
``input_registers`` solves each entry register under this state's solver, so
|
|
461
|
+
the concrete values satisfy the path's constraints; ``return_value`` is one
|
|
462
|
+
concrete solution for ``rax``, and only for ``return`` paths.
|
|
463
|
+
"""
|
|
464
|
+
constraints = tuple(str(item) for item in tuple(state.solver.constraints)[:_MAX_CONSTRAINTS])
|
|
465
|
+
inputs: dict[str, int] = {}
|
|
466
|
+
for name, entry in entry_values.items():
|
|
467
|
+
if isinstance(entry, int):
|
|
468
|
+
inputs[name] = entry
|
|
469
|
+
continue
|
|
470
|
+
try:
|
|
471
|
+
inputs[name] = int(state.solver.eval(entry))
|
|
472
|
+
except Exception: # pragma: no cover - angr owns the solver vocabulary
|
|
473
|
+
continue
|
|
474
|
+
return_value: int | None = None
|
|
475
|
+
if stopped_reason == _STOP_RETURN:
|
|
476
|
+
try:
|
|
477
|
+
return_value = int(state.solver.eval(state.regs.rax))
|
|
478
|
+
except Exception: # pragma: no cover - angr owns the solver vocabulary
|
|
479
|
+
return_value = None
|
|
480
|
+
return SolverPath(
|
|
481
|
+
return_value=return_value,
|
|
482
|
+
constraints=constraints,
|
|
483
|
+
input_registers=inputs,
|
|
484
|
+
stopped_reason=stopped_reason,
|
|
485
|
+
steps=steps,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _result_evidence(
|
|
490
|
+
*, start: int, paths: list[SolverPath], truncated: bool, max_paths: int, max_steps: int
|
|
491
|
+
) -> Evidence:
|
|
492
|
+
"""Describe what the solver explored versus what it only reports."""
|
|
493
|
+
stopped: dict[str, int] = {}
|
|
494
|
+
for entry in paths:
|
|
495
|
+
stopped[entry.stopped_reason] = stopped.get(entry.stopped_reason, 0) + 1
|
|
496
|
+
return Evidence(
|
|
497
|
+
source=_EVIDENCE_SOURCE,
|
|
498
|
+
method=_EVIDENCE_METHOD,
|
|
499
|
+
confidence=Confidence.MEDIUM,
|
|
500
|
+
address=start,
|
|
501
|
+
detail=(
|
|
502
|
+
"solver-based exploration; concrete inputs extracted per path; no proof all paths "
|
|
503
|
+
"were found"
|
|
504
|
+
),
|
|
505
|
+
extra={
|
|
506
|
+
"engine": f"angr/claripy with the {_SOLVER_NAME} solver",
|
|
507
|
+
"paths": len(paths),
|
|
508
|
+
"truncated": truncated,
|
|
509
|
+
"max_paths": max_paths,
|
|
510
|
+
"max_steps": max_steps,
|
|
511
|
+
"stopped_reasons": stopped,
|
|
512
|
+
"seed_registers": list(_ARG_REGISTERS_64),
|
|
513
|
+
"assumptions": list(_ASSUMPTIONS),
|
|
514
|
+
},
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# ---- the explorer ----------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def explore_symbolic(
|
|
522
|
+
path: str | Path,
|
|
523
|
+
info: BinaryInfo,
|
|
524
|
+
address: int,
|
|
525
|
+
max_paths: int = DEFAULT_MAX_PATHS,
|
|
526
|
+
max_steps: int = DEFAULT_MAX_STEPS,
|
|
527
|
+
initial_registers: Mapping[str, int] | None = None,
|
|
528
|
+
) -> SymbolicResult:
|
|
529
|
+
"""Explore one function with solver-based symbolic execution (angr).
|
|
530
|
+
|
|
531
|
+
angr is imported here, never at module import time, and only x86_64 is
|
|
532
|
+
supported. The simulation manager is stepped one instruction per state per
|
|
533
|
+
tick with ``find`` semantics -- a state whose instruction pointer is
|
|
534
|
+
(possibly) a ``ret`` address becomes a path -- and ``avoid`` is nothing.
|
|
535
|
+
At most ``max_paths`` states stay active at any tick, the instruction
|
|
536
|
+
executions of every tick are charged against ``max_steps``, and every path
|
|
537
|
+
carries its solver constraints plus concrete argument-register values that
|
|
538
|
+
reach it.
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
path: Binary file.
|
|
542
|
+
info: Loader metadata for that file.
|
|
543
|
+
address: Start address of the function.
|
|
544
|
+
max_paths: Cap on reported paths *and* on concurrently active states.
|
|
545
|
+
max_steps: Instruction budget, charged per executed instruction.
|
|
546
|
+
initial_registers: Concrete seeds for up to six ABI argument registers
|
|
547
|
+
(``rdi``, ``rsi``, ``rdx``, ``rcx``, ``r8``, ``r9``), each an int
|
|
548
|
+
in ``[0, 2**64)``. Unseeded registers become symbolic variables.
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
A :class:`SymbolicResult`; ``truncated`` is true when a cap cut the
|
|
552
|
+
exploration short.
|
|
553
|
+
|
|
554
|
+
Raises:
|
|
555
|
+
AnalysisError: When angr is not installed (the message names angr and
|
|
556
|
+
the extra that installs it), when angr fails to load or explore the
|
|
557
|
+
file, when the machine is not x86_64, on a bad address, seed or
|
|
558
|
+
limit, or when the address is not in an executable section.
|
|
559
|
+
"""
|
|
560
|
+
target = Path(path)
|
|
561
|
+
start = _validated_address(address)
|
|
562
|
+
path_limit = _validated_limit("max_paths", max_paths, MAX_MAX_PATHS)
|
|
563
|
+
step_budget = _validated_limit("max_steps", max_steps, MAX_MAX_STEPS)
|
|
564
|
+
if not _is_x86_64(info):
|
|
565
|
+
raise AnalysisError(
|
|
566
|
+
"solver-based symbolic execution supports x86_64 only: "
|
|
567
|
+
f"{info.architecture!r} (bitness {info.bitness}) is not supported, so nothing was "
|
|
568
|
+
f"explored; {_ANGR_SUFFIX}"
|
|
569
|
+
)
|
|
570
|
+
seeds = _validated_registers(initial_registers)
|
|
571
|
+
module = _import_angr()
|
|
572
|
+
section = info.section_for_va(start)
|
|
573
|
+
if section is None:
|
|
574
|
+
raise AnalysisError(
|
|
575
|
+
f"address 0x{start:x} is not mapped by any section, so angr has nothing to explore; "
|
|
576
|
+
f"{_ANGR_SUFFIX}"
|
|
577
|
+
)
|
|
578
|
+
if not section.is_executable:
|
|
579
|
+
raise AnalysisError(
|
|
580
|
+
f"address 0x{start:x} is inside section {section.name!r}, which is not executable, "
|
|
581
|
+
f"so angr cannot execute it; {_ANGR_SUFFIX}"
|
|
582
|
+
)
|
|
583
|
+
find_set = frozenset(_ret_addresses(target, info, start, section))
|
|
584
|
+
if not find_set:
|
|
585
|
+
raise AnalysisError(
|
|
586
|
+
f"no ret instruction was found scanning section {section.name!r} from 0x{start:x}, "
|
|
587
|
+
f"so explore_symbolic has nothing to use as find; {_ANGR_SUFFIX}"
|
|
588
|
+
)
|
|
589
|
+
project, notes = _build_project(module, target, info, section)
|
|
590
|
+
try:
|
|
591
|
+
state = project.factory.entry_state(addr=start)
|
|
592
|
+
entry_values = _seed_registers(module, state, seeds)
|
|
593
|
+
simgr = project.factory.simgr(state)
|
|
594
|
+
except AnalysisError: # pragma: no cover - nothing above raises this
|
|
595
|
+
raise
|
|
596
|
+
except Exception as exc:
|
|
597
|
+
raise AnalysisError(
|
|
598
|
+
f"angr could not prepare an entry state at 0x{start:x} in {target.name} "
|
|
599
|
+
f"({type(exc).__name__}: {exc}); {_ANGR_SUFFIX}"
|
|
600
|
+
) from exc
|
|
601
|
+
|
|
602
|
+
paths: list[SolverPath] = []
|
|
603
|
+
truncated = False
|
|
604
|
+
steps_used = 0
|
|
605
|
+
tick = 0
|
|
606
|
+
error_cursor = 0
|
|
607
|
+
dropped_found = 0
|
|
608
|
+
dropped_errors = 0
|
|
609
|
+
error_notes: list[str] = []
|
|
610
|
+
unconstrained_before = len(simgr.stashes.get("unconstrained", ()))
|
|
611
|
+
try:
|
|
612
|
+
while simgr.active:
|
|
613
|
+
if len(paths) >= path_limit:
|
|
614
|
+
truncated = True
|
|
615
|
+
notes.append(
|
|
616
|
+
f"path cap reached (max_paths={path_limit}) with {len(simgr.active)} "
|
|
617
|
+
"state(s) still unexplored; those paths are not reported"
|
|
618
|
+
)
|
|
619
|
+
break
|
|
620
|
+
if steps_used >= step_budget:
|
|
621
|
+
truncated = True
|
|
622
|
+
take = min(len(simgr.active), path_limit - len(paths))
|
|
623
|
+
for stopped_state in simgr.active[:take]:
|
|
624
|
+
paths.append(
|
|
625
|
+
_path_from_state(stopped_state, _STOP_BUDGET, tick, entry_values)
|
|
626
|
+
)
|
|
627
|
+
notes.append(
|
|
628
|
+
f"step budget ran out (max_steps={step_budget}) after {tick} tick(s) with "
|
|
629
|
+
f"{len(simgr.active)} state(s) still active; they are reported as "
|
|
630
|
+
"'budget' paths and never reached a ret"
|
|
631
|
+
)
|
|
632
|
+
if len(simgr.active) > take:
|
|
633
|
+
notes.append(
|
|
634
|
+
f"{len(simgr.active) - take} budget-stopped state(s) were not reported "
|
|
635
|
+
f"because the path cap ({path_limit}) was hit"
|
|
636
|
+
)
|
|
637
|
+
break
|
|
638
|
+
if len(simgr.active) > path_limit:
|
|
639
|
+
dropped = len(simgr.active) - path_limit
|
|
640
|
+
truncated = True
|
|
641
|
+
simgr.stashes["active"] = simgr.stashes["active"][:path_limit]
|
|
642
|
+
notes.append(
|
|
643
|
+
f"the active-state cap (max_paths={path_limit}) dropped {dropped} state(s) "
|
|
644
|
+
"to bound memory; those paths were not explored"
|
|
645
|
+
)
|
|
646
|
+
running = len(simgr.active)
|
|
647
|
+
tick += 1
|
|
648
|
+
try:
|
|
649
|
+
simgr.step(num_inst=1)
|
|
650
|
+
except TypeError: # pragma: no cover - angr version drift
|
|
651
|
+
simgr.step() # one basic block per tick; the budget still bounds the run
|
|
652
|
+
steps_used += running
|
|
653
|
+
still_active: list[Any] = []
|
|
654
|
+
for found_state in simgr.active:
|
|
655
|
+
if _at_ret(found_state, find_set):
|
|
656
|
+
if len(paths) < path_limit:
|
|
657
|
+
paths.append(
|
|
658
|
+
_path_from_state(found_state, _STOP_RETURN, tick, entry_values)
|
|
659
|
+
)
|
|
660
|
+
else:
|
|
661
|
+
dropped_found += 1
|
|
662
|
+
truncated = True
|
|
663
|
+
else:
|
|
664
|
+
still_active.append(found_state)
|
|
665
|
+
simgr.stashes["active"] = still_active
|
|
666
|
+
new_errors = simgr.errored[error_cursor:]
|
|
667
|
+
error_cursor = len(simgr.errored)
|
|
668
|
+
for record in new_errors:
|
|
669
|
+
if len(paths) < path_limit:
|
|
670
|
+
paths.append(_path_from_state(record.state, _STOP_ERROR, tick, entry_values))
|
|
671
|
+
else:
|
|
672
|
+
dropped_errors += 1
|
|
673
|
+
truncated = True
|
|
674
|
+
if len(error_notes) < _MAX_ERROR_NOTES:
|
|
675
|
+
error_notes.append(f"{type(record.error).__name__}: {record.error}")
|
|
676
|
+
except AnalysisError: # pragma: no cover - nothing above raises this
|
|
677
|
+
raise
|
|
678
|
+
except Exception as exc:
|
|
679
|
+
raise AnalysisError(
|
|
680
|
+
f"angr exploration of 0x{start:x} in {target.name} failed after {steps_used} "
|
|
681
|
+
f"executed instruction(s) ({type(exc).__name__}: {exc}); {_ANGR_SUFFIX}"
|
|
682
|
+
) from exc
|
|
683
|
+
|
|
684
|
+
unconstrained_delta = len(simgr.stashes.get("unconstrained", ())) - unconstrained_before
|
|
685
|
+
if unconstrained_delta > 0:
|
|
686
|
+
notes.append(
|
|
687
|
+
f"{unconstrained_delta} state(s) became unconstrained (a symbolic jump or return "
|
|
688
|
+
"target angr cannot enumerate here) and are not reported"
|
|
689
|
+
)
|
|
690
|
+
if dropped_found:
|
|
691
|
+
notes.append(
|
|
692
|
+
f"{dropped_found} found path(s) were dropped because the path cap "
|
|
693
|
+
f"(max_paths={path_limit}) was reached"
|
|
694
|
+
)
|
|
695
|
+
if dropped_errors:
|
|
696
|
+
notes.append(
|
|
697
|
+
f"{dropped_errors} errored state(s) were not reported because the path cap "
|
|
698
|
+
f"(max_paths={path_limit}) was reached"
|
|
699
|
+
)
|
|
700
|
+
for note in error_notes[:_MAX_ERROR_NOTES]:
|
|
701
|
+
notes.append(f"a state errored ({note}); that path is reported with stopped_reason 'error'")
|
|
702
|
+
if not paths and not truncated and not notes:
|
|
703
|
+
notes.append(
|
|
704
|
+
"angr stopped without finding a ret, an error or an active state; nothing to report"
|
|
705
|
+
)
|
|
706
|
+
return SymbolicResult(
|
|
707
|
+
address=start,
|
|
708
|
+
paths=tuple(paths),
|
|
709
|
+
solver=_SOLVER_NAME,
|
|
710
|
+
truncated=truncated,
|
|
711
|
+
warnings=tuple(notes),
|
|
712
|
+
evidence=_result_evidence(
|
|
713
|
+
start=start, paths=paths, truncated=truncated, max_paths=path_limit, max_steps=step_budget
|
|
714
|
+
),
|
|
715
|
+
)
|