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.
Files changed (65) hide show
  1. codevanta/__init__.py +1 -0
  2. codevanta/analysis/__init__.py +1 -0
  3. codevanta/analysis/binary_diff.py +1168 -0
  4. codevanta/analysis/callgraph.py +542 -0
  5. codevanta/analysis/cfg.py +1201 -0
  6. codevanta/analysis/dataflow.py +762 -0
  7. codevanta/analysis/debugger.py +289 -0
  8. codevanta/analysis/debuginfo.py +304 -0
  9. codevanta/analysis/decompile.py +1094 -0
  10. codevanta/analysis/decompilers/__init__.py +250 -0
  11. codevanta/analysis/decompilers/angr_backend.py +251 -0
  12. codevanta/analysis/decompilers/base.py +278 -0
  13. codevanta/analysis/decompilers/ghidra_backend.py +316 -0
  14. codevanta/analysis/dynamic.py +756 -0
  15. codevanta/analysis/emulate.py +1349 -0
  16. codevanta/analysis/evidence_bundle.py +736 -0
  17. codevanta/analysis/function_hash.py +562 -0
  18. codevanta/analysis/graph_export.py +441 -0
  19. codevanta/analysis/html_view.py +695 -0
  20. codevanta/analysis/jump_tables.py +546 -0
  21. codevanta/analysis/solver.py +715 -0
  22. codevanta/analysis/symbolic.py +1809 -0
  23. codevanta/analysis/trace.py +646 -0
  24. codevanta/analysis/type_propagation.py +1055 -0
  25. codevanta/analysis/type_recovery.py +1214 -0
  26. codevanta/analysis/vtables.py +572 -0
  27. codevanta/analysis/xrefs.py +434 -0
  28. codevanta/api/__init__.py +1 -0
  29. codevanta/cli/__init__.py +1 -0
  30. codevanta/cli/main.py +3038 -0
  31. codevanta/core/__init__.py +31 -0
  32. codevanta/core/disassembler.py +121 -0
  33. codevanta/core/entropy.py +237 -0
  34. codevanta/core/functions.py +176 -0
  35. codevanta/core/loader.py +1351 -0
  36. codevanta/core/report.py +306 -0
  37. codevanta/core/strings.py +109 -0
  38. codevanta/evidence/__init__.py +7 -0
  39. codevanta/evidence/confidence.py +36 -0
  40. codevanta/evidence/models.py +43 -0
  41. codevanta/evidence/provenance.py +37 -0
  42. codevanta/gui/__init__.py +46 -0
  43. codevanta/gui/layout.py +1384 -0
  44. codevanta/gui/server.py +1305 -0
  45. codevanta/ir/__init__.py +6 -0
  46. codevanta/ir/adapters/__init__.py +31 -0
  47. codevanta/ir/base.py +31 -0
  48. codevanta/ir/instruction.py +42 -0
  49. codevanta/mcp_server/__init__.py +5 -0
  50. codevanta/mcp_server/server.py +710 -0
  51. codevanta/mcp_server/tools.py +1364 -0
  52. codevanta/plugins/__init__.py +748 -0
  53. codevanta/setup/__init__.py +28 -0
  54. codevanta/setup/apply.py +22 -0
  55. codevanta/setup/clients.py +285 -0
  56. codevanta/setup/plan.py +79 -0
  57. codevanta/setup/verify.py +43 -0
  58. codevanta/storage/__init__.py +1 -0
  59. codevanta/storage/session.py +514 -0
  60. codevanta-0.1.0.dist-info/METADATA +688 -0
  61. codevanta-0.1.0.dist-info/RECORD +65 -0
  62. codevanta-0.1.0.dist-info/WHEEL +5 -0
  63. codevanta-0.1.0.dist-info/entry_points.txt +2 -0
  64. codevanta-0.1.0.dist-info/licenses/LICENSE +21 -0
  65. codevanta-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1809 @@
1
+ """Path exploration for one function, without installing a solver.
2
+
3
+ Two questions motivate this module: *which distinct routes through this function
4
+ exist*, and *what does each route return*. It answers them by **enumerating
5
+ branch decisions**, replaying each decision sequence on a private unicorn CPU
6
+ (the same bounded, offline emulator :mod:`codevanta.analysis.emulate` uses), and
7
+ reporting one :class:`Entry` per path. Nothing is executed on the host, no
8
+ symbolic variable is created, and no constraint is ever solved -- angr/Z3 are
9
+ *optional* here and only used by :func:`explore_with_angr`.
10
+
11
+ Path semantics
12
+ --------------
13
+ A path is a tuple of decisions, one per *conditional* branch met on the way.
14
+ Exploration is breadth first over that decision tree:
15
+
16
+ 1. Replay the decision tuple from the entry point on a freshly initialised CPU.
17
+ 2. At the ``k``-th conditional branch, if a decision is recorded use it, else
18
+ follow the **observed** direction -- the one the real flags produced -- and
19
+ enqueue ``walked + (not observed,)`` as a child.
20
+ 3. A ``ret`` whose popped address lies outside the explored section completes
21
+ the path (``stopped_reason="return"``, ``return_value`` = ``rax``/``eax`` at
22
+ that ``ret``). An intra-section ``ret`` -- the return of a callee this path
23
+ called -- is executed and exploration continues, exactly as in ``emulate``.
24
+ 4. Paths stop for one of four reasons: ``return``, ``budget`` (the shared
25
+ ``max_steps`` instruction budget ran out), ``blocked`` (this module cannot
26
+ justify a successor) or ``unmapped`` (the emulated code touched an address
27
+ that is not mapped).
28
+
29
+ Explored versus assumed
30
+ -----------------------
31
+ That distinction is the whole point of the module, so every branch carries a
32
+ :data:`Basis`:
33
+
34
+ ``observed``
35
+ The direction was produced by real emulation: the flags (or the counter
36
+ register) were computed by the instructions that ran before the branch, and
37
+ the branch instruction itself executed on the CPU. Nothing was forced.
38
+ ``seeded``
39
+ The *opposite* direction of a conditional branch. The flags are rewritten to
40
+ the nearest assignment that satisfies that condition (``rflags`` bits are
41
+ searched exhaustively -- 64 combinations -- and the minimum Hamming distance
42
+ from the observed value is chosen), or -- for ``jrcxz``/``jecxz``/``jcxz`` --
43
+ the counter register is written. The branch instruction then executes, and
44
+ the explorer checks that it really landed on the intended successor. **This
45
+ is an assumption about feasibility, not a solved constraint**: no path
46
+ predicate is built, no model is checked, and a ``seeded`` side may well be
47
+ unreachable for the real program.
48
+
49
+ Unconditional ``jmp``/``call`` transfers carry no decision, so they are neither
50
+ ``observed`` nor ``seeded``: they are *executed*, and the address the CPU landed
51
+ on is recorded in :attr:`Entry.taken_targets`. An indirect transfer contributes
52
+ only the target its operand held at that point -- other targets it could hold
53
+ are not explored, and the entry says so in a warning.
54
+
55
+ A branch this module cannot decide is **never** guessed: ``loop``/``loope``/
56
+ ``loopne`` (they decrement ``rcx`` before testing it), an unknown ``j*``
57
+ mnemonic, an indirect transfer leaving the section, an undecodable instruction,
58
+ or a seed that failed to steer the branch all end the path with
59
+ ``stopped_reason="blocked"`` and a note saying exactly what was not resolved.
60
+ If a seed fails to steer the branch, the path is discarded rather than reported
61
+ as if it had reached the intended successor.
62
+
63
+ What is *not* explored: memory state is shared within one replay only, and every
64
+ path starts from the same ABI-seeded state (``initial_registers`` plus the
65
+ ``rsp``/``rbp`` defaults from ``emulate``), so a path is not a proof of
66
+ reachability for arbitrary inputs -- it is the set of routes this explorer could
67
+ walk with the given seeds, with the ``seeded`` sides flagged. Two paths that
68
+ rejoin are reported separately: exploration is path-sensitive over decisions,
69
+ not a state merge.
70
+
71
+ Cost and caps
72
+ -------------
73
+ ``max_paths`` (default :data:`DEFAULT_MAX_PATHS`) caps emitted paths and
74
+ ``max_steps`` (default :data:`DEFAULT_MAX_STEPS`) caps emulator instructions
75
+ *including replays*, so the work is bounded no matter how branchy the function
76
+ is. Because each path is replayed from the entry point, real cost is
77
+ ``O(paths x path length)``; the emulator instance and its mappings are created
78
+ once and re-initialised between replays.
79
+
80
+ Supported machines
81
+ ------------------
82
+ x86 and x86-64 only, like ``emulate``: the return-value, stack and register
83
+ vocabulary here is x86-specific. Anything else raises
84
+ :class:`~codevanta.core.report.AnalysisError` instead of guessing, as does a
85
+ missing unicorn build, a bad address, or an out-of-range limit.
86
+
87
+ The angr bridge
88
+ ---------------
89
+ :func:`explore_with_angr` is **optional** and never imported at module import
90
+ time: :data:`ANGR_AVAILABLE` only records whether a module called ``angr`` is
91
+ findable, and the import happens inside the call. Without angr the function
92
+ raises :class:`~codevanta.core.report.AnalysisError` naming angr and the extra
93
+ that installs it. When angr *is* present the paths come from angr's symbolic
94
+ execution, so the evidence says so and no ``observed``/``seeded`` basis is
95
+ claimed for them -- that distinction belongs to the unicorn explorer.
96
+ """
97
+
98
+
99
+ from __future__ import annotations
100
+
101
+ import faulthandler as _faulthandler
102
+ import importlib.util as _importlib_util
103
+ from collections import deque
104
+ from collections.abc import Callable, Iterator, Mapping, Sequence
105
+ from contextlib import contextmanager
106
+ from dataclasses import dataclass
107
+ from pathlib import Path
108
+ from typing import Any, Literal
109
+
110
+ import capstone
111
+
112
+ from codevanta.analysis import emulate
113
+ from codevanta.core.loader import BinaryInfo
114
+ from codevanta.core.report import AnalysisError, read_code
115
+ from codevanta.evidence.confidence import Confidence
116
+ from codevanta.evidence.models import Evidence
117
+
118
+ __all__ = [
119
+ "ANGR_AVAILABLE",
120
+ "DEFAULT_MAX_PATHS",
121
+ "DEFAULT_MAX_STEPS",
122
+ "EMULATOR_AVAILABLE",
123
+ "MAX_BLOCK_INSTRUCTIONS",
124
+ "MAX_MAX_PATHS",
125
+ "MAX_MAX_STEPS",
126
+ "Basis",
127
+ "Branch",
128
+ "Entry",
129
+ "PathExploration",
130
+ "StopReason",
131
+ "angr_available",
132
+ "explore_paths",
133
+ "explore_with_angr",
134
+ ]
135
+
136
+ _unicorn: Any = None
137
+ _x86_const: Any = None
138
+
139
+ try: # optional: the unicorn explorer needs it, the module must import without it
140
+ import unicorn as _unicorn
141
+ import unicorn.x86_const as _x86_const
142
+ except Exception: # pragma: no cover - depends on the environment
143
+ pass
144
+
145
+
146
+ # ---- limits ---------------------------------------------------------------
147
+
148
+ #: Paths reported when the caller does not pass ``max_paths``.
149
+ DEFAULT_MAX_PATHS = 64
150
+
151
+ #: Hard ceiling on ``max_paths`` (bounded work, whatever the caller asks).
152
+ MAX_MAX_PATHS = 4096
153
+
154
+ #: Instruction budget used when the caller does not pass ``max_steps``.
155
+ DEFAULT_MAX_STEPS = 2000
156
+
157
+ #: Hard ceiling on ``max_steps``.
158
+ MAX_MAX_STEPS = 200_000
159
+
160
+ #: A single basic block is scanned for at most this many instructions before the
161
+ #: explorer re-scans from the next address (a long straight-line tail still runs).
162
+ MAX_BLOCK_INSTRUCTIONS = 64
163
+
164
+ #: Instructions a linear scan reads when collecting a function's ``ret`` sites
165
+ #: for the angr bridge.
166
+ MAX_SCAN_INSTRUCTIONS = 4096
167
+
168
+ #: True when a unicorn build is importable; the unicorn explorer is refused
169
+ #: otherwise. Mirrors :data:`codevanta.analysis.emulate.EMULATOR_AVAILABLE`.
170
+ EMULATOR_AVAILABLE = emulate.EMULATOR_AVAILABLE
171
+
172
+ #: Evidence identity for the unicorn explorer.
173
+ _EVIDENCE_SOURCE = "symbolic.unicorn"
174
+ _EVIDENCE_METHOD = "branch_decision_replay"
175
+
176
+ #: Evidence identity for the angr bridge.
177
+ _ANGR_SOURCE = "symbolic.angr"
178
+ _ANGR_METHOD = "symbolic_execution"
179
+
180
+ #: What a caller needs to install angr.
181
+ _ANGR_HINT = "pip install 'codevanta[decompilers]'"
182
+
183
+ # ---- stop reasons and bases -----------------------------------------------
184
+
185
+ #: Why one path ended (see the module docstring).
186
+ StopReason = Literal["return", "budget", "blocked", "unmapped"]
187
+
188
+ #: How one conditional branch direction was justified (see the module docstring).
189
+ Basis = Literal["observed", "seeded"]
190
+
191
+ _STOP_RETURN: StopReason = "return"
192
+ _STOP_BUDGET: StopReason = "budget"
193
+ _STOP_BLOCKED: StopReason = "blocked"
194
+ _STOP_UNMAPPED: StopReason = "unmapped"
195
+
196
+ _BASIS_OBSERVED: Basis = "observed"
197
+ _BASIS_SEEDED: Basis = "seeded"
198
+
199
+ #: Instruction mnemonics that leave the explored function (Capstone's naming).
200
+ _RET_MNEMONICS = frozenset({"ret", "retf", "retn", "retq", "iret", "iretd", "iretq"})
201
+
202
+ #: ``BinaryInfo.architecture`` spellings accepted as 64-bit x86.
203
+ _X86_64_NAMES = frozenset({"x86_64", "x86-64", "amd64", "em_amd64"})
204
+
205
+ #: ``BinaryInfo.architecture`` spellings accepted as 32-bit x86.
206
+ _X86_32_NAMES = frozenset({"x86", "i386", "i686", "em_386"})
207
+
208
+ #: Registers snapshotted per bitness (the same vocabulary as ``emulate``).
209
+ _REGISTERS_64 = (
210
+ "rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
211
+ "r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15",
212
+ "rip", "rflags",
213
+ )
214
+ _REGISTERS_32 = (
215
+ "eax", "ebx", "ecx", "edx", "esi", "edi", "ebp", "esp", "eip", "eflags",
216
+ )
217
+
218
+ #: Room left between the initial stack pointer and the top of the stack region,
219
+ #: so a prologue can push without leaving the mapping (mirrors ``emulate``).
220
+ _STACK_HEADROOM = 0x100
221
+
222
+ #: Bytes a linear scan may read per block (the widest x86 instruction is 15).
223
+ _MAX_INSTRUCTION_BYTES = 15
224
+
225
+ #: ``loop``/``loope``/``loopne`` decrement the counter before testing it, which
226
+ #: this explorer does not model; they end the path as ``blocked``.
227
+ _LOOP_MNEMONICS = frozenset({"loop", "loope", "loopz", "loopne", "loopnz"})
228
+
229
+
230
+ # ---- results --------------------------------------------------------------
231
+
232
+
233
+ @dataclass(frozen=True)
234
+ class Branch:
235
+ """One conditional branch a path met, and how its direction was justified.
236
+
237
+ Attributes:
238
+ address: Address of the branch instruction.
239
+ mnemonic: Capstone mnemonic (``"jz"``, ``"jle"``, ``"jrcxz"``, ...).
240
+ taken: ``True`` when the path took the branch target, ``False`` when it
241
+ fell through.
242
+ target: Address the path actually continued at (the branch target, or
243
+ the fall-through address when ``taken`` is ``False``).
244
+ basis: ``"observed"`` when real emulation produced this direction,
245
+ ``"seeded"`` when the flags/counter were rewritten to force it.
246
+ note: Exact description of the seed, empty for an observed direction.
247
+ """
248
+
249
+ address: int
250
+ mnemonic: str
251
+ taken: bool
252
+ target: int
253
+ basis: Basis
254
+ note: str = ""
255
+
256
+ def to_dict(self) -> dict[str, Any]:
257
+ """Serialize to a JSON-safe dict."""
258
+ return {
259
+ "address": f"0x{self.address:x}",
260
+ "mnemonic": self.mnemonic,
261
+ "taken": self.taken,
262
+ "target": f"0x{self.target:x}",
263
+ "basis": self.basis,
264
+ "note": self.note,
265
+ }
266
+
267
+
268
+ @dataclass(frozen=True)
269
+ class Entry:
270
+ """One explored path: where it went, how it ended, what it left behind.
271
+
272
+ Attributes:
273
+ index: Position in :attr:`PathExploration.entries` (deterministic).
274
+ decisions: The conditional branches on this path, in the order met.
275
+ taken_targets: Addresses control transferred to, in order: taken
276
+ conditional targets and every unconditional ``jmp``/``call`` target.
277
+ registers: Register snapshot taken at the end of the path.
278
+ return_value: ``rax``/``eax`` when the path reached a ``ret`` that left
279
+ the section, otherwise ``None`` -- a value left in ``rax`` by a path
280
+ stopped for any other reason is not a return value.
281
+ stopped_reason: Why the path ended (see the module docstring).
282
+ instructions: Instructions this path executed, replayed instructions
283
+ included (the explorer's work unit, not a property of the code).
284
+ warnings: Notes explaining every degradation, including the seed used to
285
+ force a direction and anything that could not be resolved.
286
+ """
287
+
288
+ index: int
289
+ decisions: tuple[Branch, ...]
290
+ taken_targets: tuple[int, ...]
291
+ registers: Mapping[str, int]
292
+ return_value: int | None
293
+ stopped_reason: StopReason
294
+ instructions: int
295
+ warnings: tuple[str, ...]
296
+
297
+ @property
298
+ def seeded(self) -> int:
299
+ """Number of branches on this path whose direction was forced."""
300
+ return sum(1 for branch in self.decisions if branch.basis == _BASIS_SEEDED)
301
+
302
+ def to_dict(self) -> dict[str, Any]:
303
+ """Serialize to a JSON-safe dict (addresses and values become hex)."""
304
+ return {
305
+ "index": self.index,
306
+ "decisions": [branch.to_dict() for branch in self.decisions],
307
+ "taken_targets": [f"0x{target:x}" for target in self.taken_targets],
308
+ "registers": {
309
+ name: f"0x{value:x}" for name, value in sorted(self.registers.items())
310
+ },
311
+ "return_value": (
312
+ f"0x{self.return_value:x}" if self.return_value is not None else None
313
+ ),
314
+ "stopped_reason": self.stopped_reason,
315
+ "instructions": self.instructions,
316
+ "seeded_branches": self.seeded,
317
+ "warnings": list(self.warnings),
318
+ }
319
+
320
+
321
+ @dataclass(frozen=True)
322
+ class PathExploration:
323
+ """Every path one exploration produced, plus what it did and did not prove.
324
+
325
+ Attributes:
326
+ address: Virtual address the exploration started at.
327
+ explorer: ``"unicorn"`` or ``"angr"`` -- which engine produced the paths.
328
+ architecture: ``BinaryInfo.architecture`` of the analysed binary.
329
+ bitness: 32 or 64.
330
+ max_paths: Path cap the caller asked for.
331
+ max_steps: Instruction budget the caller asked for (``0`` for the angr
332
+ bridge, which explores until its own state manager stops).
333
+ steps: Emulator instructions actually executed, replays included.
334
+ complete: ``True`` only when nothing was left unexplored *and* every
335
+ reported path reached a ``ret``. ``False`` means something is
336
+ missing -- paths, returns, or both -- and ``warnings`` says what.
337
+ entries: The paths, in deterministic breadth-first order.
338
+ warnings: Exploration-level notes (truncation, limits, engine caveats).
339
+ evidence: Provenance for the whole exploration, including what was
340
+ explored and what was only assumed.
341
+ """
342
+
343
+ address: int
344
+ explorer: str
345
+ architecture: str
346
+ bitness: int
347
+ max_paths: int
348
+ max_steps: int
349
+ steps: int
350
+ complete: bool
351
+ entries: tuple[Entry, ...]
352
+ warnings: tuple[str, ...]
353
+ evidence: Evidence
354
+
355
+ @property
356
+ def paths(self) -> int:
357
+ """Number of paths reported."""
358
+ return len(self.entries)
359
+
360
+ @property
361
+ def unresolved(self) -> int:
362
+ """Paths that ended without a return or a budget stop."""
363
+ return sum(
364
+ 1
365
+ for entry in self.entries
366
+ if entry.stopped_reason in (_STOP_BLOCKED, _STOP_UNMAPPED)
367
+ )
368
+
369
+ @property
370
+ def return_values(self) -> tuple[int, ...]:
371
+ """Return values of the paths that reached a ``ret``, in path order."""
372
+ return tuple(
373
+ entry.return_value
374
+ for entry in self.entries
375
+ if entry.return_value is not None and entry.stopped_reason == _STOP_RETURN
376
+ )
377
+
378
+ def to_dict(self) -> dict[str, Any]:
379
+ """Serialize to a JSON-safe dict (hex addresses, lists, no enums)."""
380
+ return {
381
+ "address": f"0x{self.address:x}",
382
+ "explorer": self.explorer,
383
+ "architecture": self.architecture,
384
+ "bitness": self.bitness,
385
+ "max_paths": self.max_paths,
386
+ "max_steps": self.max_steps,
387
+ "steps": self.steps,
388
+ "complete": self.complete,
389
+ "paths": self.paths,
390
+ "unresolved": self.unresolved,
391
+ "return_values": [f"0x{value:x}" for value in self.return_values],
392
+ "entries": [entry.to_dict() for entry in self.entries],
393
+ "warnings": list(self.warnings),
394
+ "evidence": self.evidence.to_dict(),
395
+ }
396
+
397
+
398
+ # ---- small helpers --------------------------------------------------------
399
+
400
+
401
+ def _align_up(value: int) -> int:
402
+ """Round ``value`` up to the next page boundary (mirrors ``emulate``)."""
403
+ if value <= 0:
404
+ return 0
405
+ page = emulate.PAGE_SIZE
406
+ return (value + page - 1) & ~(page - 1)
407
+
408
+
409
+ def _merge_spans(spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
410
+ """Merge overlapping/adjacent page spans so each page is mapped once."""
411
+ merged: list[tuple[int, int]] = []
412
+ for start, end in sorted(spans):
413
+ if merged and start <= merged[-1][1]:
414
+ previous_start, previous_end = merged[-1]
415
+ merged[-1] = (previous_start, max(previous_end, end))
416
+ else:
417
+ merged.append((start, end))
418
+ return merged
419
+
420
+
421
+ def _machine_bits(info: BinaryInfo) -> int | None:
422
+ """Return 32 or 64 when this module can explore ``info``, else ``None``.
423
+
424
+ Mirrors ``emulate._machine_bits`` (a private helper there, so it is restated
425
+ here rather than imported): only x86 modes are accepted, because the return
426
+ register, stack and register vocabulary of this module is x86-specific.
427
+ """
428
+ name = (info.architecture or "").strip().lower()
429
+ if name in _X86_64_NAMES:
430
+ bits = 64
431
+ elif name in _X86_32_NAMES:
432
+ bits = 32
433
+ else:
434
+ return None
435
+ if int(info.bitness) in (32, 64):
436
+ bits = int(info.bitness)
437
+ if (info.endianness or "little").strip().lower() == "big":
438
+ return None # x86 is little-endian only
439
+ return bits
440
+
441
+
442
+ def _reg_const(name: str) -> int | None:
443
+ """Map a unicorn register name (``"rax"``, ``"rflags"``) to its constant."""
444
+ if _x86_const is None:
445
+ return None
446
+ value = getattr(_x86_const, f"UC_X86_REG_{name.upper()}", None)
447
+ return int(value) if isinstance(value, int) else None
448
+
449
+
450
+ def _validated_address(address: int) -> int:
451
+ """Validate a start address (positive int) and return it."""
452
+ if isinstance(address, bool) or not isinstance(address, int):
453
+ raise AnalysisError(f"address must be an int, not {type(address).__name__}")
454
+ if address <= 0:
455
+ raise AnalysisError(f"address must be positive, got 0x{address:x}")
456
+ return address
457
+
458
+
459
+ def _validated_limit(name: str, value: int, ceiling: int) -> int:
460
+ """Validate one exploration cap against its ceiling."""
461
+ if isinstance(value, bool) or not isinstance(value, int):
462
+ raise AnalysisError(f"{name} must be an int")
463
+ if value < 1:
464
+ raise AnalysisError(f"{name} must be >= 1")
465
+ if value > ceiling:
466
+ raise AnalysisError(f"{name} too large ({value}); limit is {ceiling}")
467
+ return value
468
+
469
+
470
+ def _validated_registers(
471
+ initial_registers: Mapping[str, int] | None, bits: int
472
+ ) -> dict[str, int]:
473
+ """Normalize and validate caller-supplied register seeds.
474
+
475
+ Raises:
476
+ AnalysisError: On an unknown register name, a non-int value, or a value
477
+ that does not fit the machine word.
478
+ """
479
+ seeds: dict[str, int] = {}
480
+ if not initial_registers:
481
+ return seeds
482
+ limit = 1 << bits
483
+ for name, value in initial_registers.items():
484
+ key = str(name).strip().lower()
485
+ if _reg_const(key) is None:
486
+ raise AnalysisError(f"unknown {bits}-bit register name {name!r}")
487
+ if isinstance(value, bool) or not isinstance(value, int):
488
+ raise AnalysisError(f"register {key!r} must be seeded with an int")
489
+ if value < 0 or value >= limit:
490
+ raise AnalysisError(f"value for register {key!r} does not fit in {bits} bits")
491
+ seeds[key] = int(value)
492
+ return seeds
493
+
494
+
495
+ # ---- flags and branch conditions ------------------------------------------
496
+
497
+ #: The six x86 condition flags, with their ``rflags`` bit positions. Only these
498
+ #: bits are ever rewritten (``IF``, ``DF`` and the reserved bit are preserved).
499
+ _FLAG_BITS: tuple[tuple[str, int], ...] = (
500
+ ("cf", 0x0001),
501
+ ("pf", 0x0004),
502
+ ("af", 0x0010),
503
+ ("zf", 0x0040),
504
+ ("sf", 0x0080),
505
+ ("of", 0x0800),
506
+ )
507
+
508
+ #: Mask covering exactly :data:`_FLAG_BITS`.
509
+ _FLAG_MASK = 0x0001 | 0x0004 | 0x0010 | 0x0040 | 0x0080 | 0x0800
510
+
511
+ #: ``rflags`` value a fresh x86 CPU has (bit 1 is always set).
512
+ _DEFAULT_EFLAGS = 0x2
513
+
514
+
515
+ @dataclass(frozen=True)
516
+ class _Flags:
517
+ """The six condition flags, decoded from ``rflags``."""
518
+
519
+ cf: bool
520
+ pf: bool
521
+ af: bool
522
+ zf: bool
523
+ sf: bool
524
+ of: bool
525
+
526
+ @classmethod
527
+ def from_eflags(cls, value: int) -> _Flags:
528
+ """Decode the condition flags out of an ``rflags`` value."""
529
+ return cls(
530
+ cf=bool(value & 0x0001),
531
+ pf=bool(value & 0x0004),
532
+ af=bool(value & 0x0010),
533
+ zf=bool(value & 0x0040),
534
+ sf=bool(value & 0x0080),
535
+ of=bool(value & 0x0800),
536
+ )
537
+
538
+ def to_bits(self) -> int:
539
+ """Encode these flags as ``rflags`` bits."""
540
+ return (
541
+ (0x0001 if self.cf else 0)
542
+ | (0x0004 if self.pf else 0)
543
+ | (0x0010 if self.af else 0)
544
+ | (0x0040 if self.zf else 0)
545
+ | (0x0080 if self.sf else 0)
546
+ | (0x0800 if self.of else 0)
547
+ )
548
+
549
+ def distance(self, other: _Flags) -> int:
550
+ """Number of condition flags that differ from ``other``."""
551
+ return sum(
552
+ 1 for name, _bit in _FLAG_BITS if getattr(self, name) != getattr(other, name)
553
+ )
554
+
555
+
556
+ #: Canonical condition predicates, named after the ``jcc`` that tests them.
557
+ #: Aliases are folded onto these by :data:`_CONDITION_ALIASES`.
558
+ _CONDITIONS: dict[str, Callable[[_Flags], bool]] = {
559
+ "jz": lambda f: f.zf,
560
+ "jnz": lambda f: not f.zf,
561
+ "jc": lambda f: f.cf,
562
+ "jnc": lambda f: not f.cf,
563
+ "jbe": lambda f: f.cf or f.zf,
564
+ "ja": lambda f: not f.cf and not f.zf,
565
+ "js": lambda f: f.sf,
566
+ "jns": lambda f: not f.sf,
567
+ "jo": lambda f: f.of,
568
+ "jno": lambda f: not f.of,
569
+ "jp": lambda f: f.pf,
570
+ "jnp": lambda f: not f.pf,
571
+ "jl": lambda f: f.sf != f.of,
572
+ "jge": lambda f: f.sf == f.of,
573
+ "jle": lambda f: f.zf or (f.sf != f.of),
574
+ "jg": lambda f: not f.zf and f.sf == f.of,
575
+ }
576
+
577
+ #: Every ``jcc`` spelling Capstone produces, folded onto :data:`_CONDITIONS`.
578
+ _CONDITION_ALIASES: dict[str, str] = {
579
+ "jz": "jz", "je": "jz",
580
+ "jnz": "jnz", "jne": "jnz",
581
+ "jc": "jc", "jb": "jc", "jnae": "jc",
582
+ "jnc": "jnc", "jae": "jnc", "jnb": "jnc",
583
+ "jbe": "jbe", "jna": "jbe",
584
+ "ja": "ja", "jnbe": "ja",
585
+ "js": "js", "jns": "jns",
586
+ "jo": "jo", "jno": "jno",
587
+ "jp": "jp", "jpe": "jp",
588
+ "jnp": "jnp", "jpo": "jnp",
589
+ "jl": "jl", "jnge": "jl",
590
+ "jge": "jge", "jnl": "jge",
591
+ "jle": "jle", "jng": "jle",
592
+ "jg": "jg", "jnle": "jg",
593
+ }
594
+
595
+ #: ``jcc`` forms that test a counter register instead of a flag. Seeding them
596
+ #: means writing the counter, which is exact for these three: unlike ``loop``
597
+ #: they do not decrement it.
598
+ _COUNTER_JUMPS: dict[str, str] = {"jrcxz": "rcx", "jecxz": "ecx", "jcxz": "cx"}
599
+
600
+
601
+ def _condition_holds(mnemonic: str, flags: _Flags) -> bool | None:
602
+ """Evaluate a ``jcc`` against ``flags``.
603
+
604
+ Returns:
605
+ The condition's value, or ``None`` when this module does not know the
606
+ mnemonic (the caller then blocks the path instead of guessing).
607
+ """
608
+ canonical = _CONDITION_ALIASES.get(mnemonic)
609
+ if canonical is None:
610
+ return None
611
+ return bool(_CONDITIONS[canonical](flags))
612
+
613
+
614
+ def _flags_from_combination(combination: int) -> _Flags:
615
+ """Build a flag set from the six bits of ``combination`` (:data:`_FLAG_BITS` order)."""
616
+ return _Flags(
617
+ cf=bool(combination & 0b000001),
618
+ pf=bool(combination & 0b000010),
619
+ af=bool(combination & 0b000100),
620
+ zf=bool(combination & 0b001000),
621
+ sf=bool(combination & 0b010000),
622
+ of=bool(combination & 0b100000),
623
+ )
624
+
625
+
626
+ def _seeded_flag_bits(canonical: str, desired: bool, observed: int) -> tuple[int, str]:
627
+ """Pick the ``rflags`` bits that satisfy ``canonical == desired``.
628
+
629
+ The six condition flags are enumerated exhaustively (64 combinations) and the
630
+ assignment closest to the observed value wins, ties broken by the lowest bit
631
+ pattern, so the result is deterministic. This is a *seed*: it says nothing
632
+ about whether real inputs can produce these flags.
633
+
634
+ Returns:
635
+ ``(rflags_bits, description)``. The bits keep every non-flag bit of
636
+ ``observed``; the description names exactly which flags changed.
637
+ """
638
+ predicate = _CONDITIONS[canonical]
639
+ current = _Flags.from_eflags(observed)
640
+ best: tuple[int, int] | None = None
641
+ for combination in range(1 << len(_FLAG_BITS)):
642
+ candidate = _flags_from_combination(combination)
643
+ if bool(predicate(candidate)) != desired:
644
+ continue
645
+ distance = candidate.distance(current)
646
+ if best is None or distance < best[0]:
647
+ best = (distance, combination)
648
+ if best is None: # pragma: no cover - every predicate here is satisfiable
649
+ return observed, "no flag assignment satisfies the condition"
650
+ chosen = _flags_from_combination(best[1])
651
+ changed = ", ".join(
652
+ f"{name.upper()} {int(getattr(current, name))}->{int(getattr(chosen, name))}"
653
+ for name, _bit in _FLAG_BITS
654
+ if getattr(chosen, name) != getattr(current, name)
655
+ )
656
+ return (observed & ~_FLAG_MASK) | chosen.to_bits(), changed or "no flag changed"
657
+
658
+
659
+ # ---- decoding -------------------------------------------------------------
660
+
661
+
662
+ class _Decoder:
663
+ """One-instruction Capstone decoder for the supported x86 modes."""
664
+
665
+ def __init__(self, mode64: bool) -> None:
666
+ mode = capstone.CS_MODE_64 if mode64 else capstone.CS_MODE_32
667
+ self._cs = capstone.Cs(capstone.CS_ARCH_X86, mode)
668
+ self._cs.detail = True
669
+
670
+ def decode(self, code: bytes, address: int) -> Any:
671
+ """Decode one instruction from ``code``; ``None`` when it cannot be decoded."""
672
+ if not code:
673
+ return None
674
+ for insn in self._cs.disasm(code, address, 1):
675
+ return insn
676
+ return None
677
+
678
+
679
+ @dataclass(frozen=True)
680
+ class _Scan:
681
+ """A linearly scanned block: the instructions, and how the block ends.
682
+
683
+ Attributes:
684
+ instructions: Decoded instructions in address order, the terminator
685
+ included when :attr:`terminator` is set.
686
+ terminator: The final instruction when it transfers control, else ``None``.
687
+ error: Why scanning stopped early (undecodable bytes, end of section),
688
+ or ``None`` when the block ended on a terminator or a full scan.
689
+ """
690
+
691
+ instructions: tuple[Any, ...]
692
+ terminator: Any | None
693
+ error: str | None
694
+
695
+ @property
696
+ def body(self) -> tuple[Any, ...]:
697
+ """Instructions before the terminator (the part that always runs)."""
698
+ return self.instructions[:-1] if self.terminator is not None else self.instructions
699
+
700
+ @property
701
+ def end(self) -> int:
702
+ """Address right after the last instruction of the scan."""
703
+ if not self.instructions:
704
+ return 0
705
+ last = self.instructions[-1]
706
+ return int(last.address) + int(last.size)
707
+
708
+
709
+ def _ends_block(insn: Any) -> bool:
710
+ """True when ``insn`` transfers control, so the linear scan must stop there."""
711
+ mnemonic = str(insn.mnemonic).lower()
712
+ if mnemonic in _RET_MNEMONICS or mnemonic in _LOOP_MNEMONICS:
713
+ return True
714
+ if insn.group(capstone.CS_GRP_RET) or insn.group(capstone.CS_GRP_IRET):
715
+ return True
716
+ return bool(insn.group(capstone.CS_GRP_JUMP) or insn.group(capstone.CS_GRP_CALL))
717
+
718
+
719
+ class _Scanner:
720
+ """Reads a block of code from the file and decodes it up to a control transfer."""
721
+
722
+ def __init__(self, path: Path, info: BinaryInfo, decoder: _Decoder) -> None:
723
+ self.path = path
724
+ self.info = info
725
+ self.decoder = decoder
726
+
727
+ def scan(self, pc: int) -> _Scan:
728
+ """Scan the block starting at ``pc``; never raises."""
729
+ section = self.info.section_for_va(pc)
730
+ if section is None:
731
+ return _Scan((), None, f"address 0x{pc:x} is not mapped by any section")
732
+ available = (section.virtual_address + section.size) - pc
733
+ if available <= 0:
734
+ return _Scan((), None, f"address 0x{pc:x} is past the end of {section.name!r}")
735
+ if not section.is_executable:
736
+ return _Scan(
737
+ (),
738
+ None,
739
+ f"address 0x{pc:x} is inside {section.name!r}, which is not executable",
740
+ )
741
+ want = min(available, MAX_BLOCK_INSTRUCTIONS * _MAX_INSTRUCTION_BYTES)
742
+ try:
743
+ data = read_code(self.path, self.info, pc, want)
744
+ except AnalysisError as exc:
745
+ return _Scan((), None, f"no code bytes at 0x{pc:x}: {exc}")
746
+ instructions: list[Any] = []
747
+ offset = 0
748
+ while offset < len(data) and len(instructions) < MAX_BLOCK_INSTRUCTIONS:
749
+ insn = self.decoder.decode(bytes(data[offset:]), pc + offset)
750
+ if insn is None:
751
+ break
752
+ instructions.append(insn)
753
+ offset += int(insn.size)
754
+ if _ends_block(insn):
755
+ return _Scan(tuple(instructions), insn, None)
756
+ if not instructions:
757
+ return _Scan((), None, f"address 0x{pc:x} does not decode as an x86 instruction")
758
+ if len(instructions) >= MAX_BLOCK_INSTRUCTIONS:
759
+ # The scan cap, not the code: the caller continues from ``end``.
760
+ return _Scan(tuple(instructions), None, None)
761
+ if offset >= len(data) and offset >= available:
762
+ return _Scan(
763
+ tuple(instructions),
764
+ None,
765
+ f"the block at 0x{pc:x} runs past the readable bytes of {section.name!r}",
766
+ )
767
+ return _Scan(tuple(instructions), None, None)
768
+
769
+
770
+ # ---- the emulated machine -------------------------------------------------
771
+
772
+
773
+ @contextmanager
774
+ def _quiet_faulthandler() -> Iterator[None]:
775
+ """Silence CPython's faulthandler around unicorn's guest-fault machinery.
776
+
777
+ Unicorn resolves guest faults (``UC_ERR_READ_UNMAPPED`` and friends) with its
778
+ own handler. On Windows CPython's ``faulthandler`` prints a first-chance
779
+ "Windows fatal exception" line before that handler runs, which looks like a
780
+ crash in the log even though nothing failed. Mirrors ``emulate._Runner.run``.
781
+ """
782
+ was_enabled = _faulthandler.is_enabled()
783
+ if was_enabled:
784
+ _faulthandler.disable()
785
+ try:
786
+ yield
787
+ finally:
788
+ if was_enabled:
789
+ _faulthandler.enable()
790
+
791
+
792
+ class _Machine:
793
+ """One unicorn CPU for one function, re-initialised between path replays.
794
+
795
+ The emulator and its mappings are created once (:meth:`open`) and every path
796
+ starts from the same state because :meth:`reset` rewrites the image bytes,
797
+ zeroes the stack and re-seeds the registers. That is what makes a replay
798
+ deterministic and the paths independent of each other.
799
+ """
800
+
801
+ def __init__(
802
+ self,
803
+ path: Path,
804
+ info: BinaryInfo,
805
+ address: int,
806
+ *,
807
+ bits: int,
808
+ stack_size: int,
809
+ ) -> None:
810
+ self.path = path
811
+ self.info = info
812
+ self.address = address
813
+ self.bits = bits
814
+ self.stack_size = stack_size
815
+ self.mode64 = bits == 64
816
+ self.uc: Any = None
817
+ self.ip = "rip" if self.mode64 else "eip"
818
+ self.sp = "rsp" if self.mode64 else "esp"
819
+ self.flags = "rflags" if self.mode64 else "eflags"
820
+ self.return_register = "rax" if self.mode64 else "eax"
821
+ self.names = _REGISTERS_64 if self.mode64 else _REGISTERS_32
822
+ self._images: list[tuple[int, bytes]] = []
823
+ self._mapped_pages: set[int] = set()
824
+ self._mapped_bytes = 0
825
+ self.regions: list[tuple[str, int, int]] = []
826
+ self.stack_base = 0
827
+ self.stack_top = 0
828
+ section = info.section_for_va(address)
829
+ if section is not None:
830
+ self.region = (section.virtual_address, section.virtual_address + section.size)
831
+ self.section_name = section.name
832
+ else: # pragma: no cover - the public API rejects this first
833
+ self.region = (address, address + 1)
834
+ self.section_name = "?"
835
+
836
+ # ---- setup ------------------------------------------------------------
837
+
838
+ def open(self) -> None:
839
+ """Create the CPU and map the image and the stack."""
840
+ uni = _unicorn
841
+ arch = uni.UC_ARCH_X86
842
+ mode = uni.UC_MODE_64 if self.mode64 else uni.UC_MODE_32
843
+ with _quiet_faulthandler():
844
+ self.uc = uni.Uc(arch, mode)
845
+ self._map_image()
846
+ self._map_stack()
847
+
848
+ def _map_image(self) -> None:
849
+ """Map every allocated section (page aligned, bounded), then copy its bytes."""
850
+ spans: list[tuple[int, int]] = []
851
+ sections: list[Any] = []
852
+ for section in self.info.sections:
853
+ if section.size <= 0 or section.virtual_address <= 0:
854
+ continue
855
+ if section.size > emulate.MAX_SECTION_BYTES:
856
+ continue
857
+ start = section.virtual_address & ~(emulate.PAGE_SIZE - 1)
858
+ end = _align_up(section.virtual_address + section.size)
859
+ if self._mapped_bytes + (end - start) > emulate.MAX_TOTAL_MAPPED:
860
+ break
861
+ self._mapped_bytes += end - start
862
+ spans.append((start, end))
863
+ sections.append(section)
864
+ for start, end in _merge_spans(spans):
865
+ self._map_pages(start, end, "image")
866
+ for section in sections:
867
+ size = section.raw_size or section.size
868
+ try:
869
+ data = read_code(self.path, self.info, section.virtual_address, size)
870
+ except AnalysisError:
871
+ continue
872
+ if not data:
873
+ continue
874
+ self._images.append((section.virtual_address, bytes(data)))
875
+ self.uc.mem_write(section.virtual_address, data)
876
+
877
+ def _map_stack(self) -> None:
878
+ """Map the stack region where ``emulate`` puts it."""
879
+ base = emulate.STACK_BASE_64 if self.mode64 else emulate.STACK_BASE_32
880
+ start = base & ~(emulate.PAGE_SIZE - 1)
881
+ end = _align_up(base + self.stack_size)
882
+ self._mapped_bytes += end - start
883
+ self.stack_base, self.stack_top = start, end
884
+ self._map_pages(start, end, "stack")
885
+
886
+ def _map_pages(self, start: int, end: int, kind: str) -> None:
887
+ """Map the page range ``[start, end)``, skipping pages already mapped."""
888
+ run_start: int | None = None
889
+ page = start
890
+ while page < end:
891
+ if page in self._mapped_pages:
892
+ if run_start is not None:
893
+ self._commit(run_start, page, kind)
894
+ run_start = None
895
+ else:
896
+ self._mapped_pages.add(page)
897
+ if run_start is None:
898
+ run_start = page
899
+ page += emulate.PAGE_SIZE
900
+ if run_start is not None:
901
+ self._commit(run_start, end, kind)
902
+
903
+ def _commit(self, start: int, end: int, kind: str) -> None:
904
+ """Ask unicorn for one contiguous page run and record it."""
905
+ self.uc.mem_map(start, end - start)
906
+ self.regions.append((kind, start, end))
907
+
908
+ def reset(self, seeds: Mapping[str, int]) -> None:
909
+ """Restore the pristine state: image bytes, zeroed stack, seeded registers."""
910
+ for address, data in self._images:
911
+ self.uc.mem_write(address, data)
912
+ with _quiet_faulthandler():
913
+ self.uc.mem_write(self.stack_base, bytes(self.stack_top - self.stack_base))
914
+ for name in self.names:
915
+ self.write_register(name, 0)
916
+ stack_pointer = self.stack_top - _STACK_HEADROOM
917
+ self.write_register(self.ip, self.address)
918
+ self.write_register(self.sp, stack_pointer)
919
+ self.write_register("rbp" if self.mode64 else "ebp", stack_pointer)
920
+ self.write_register(self.flags, _DEFAULT_EFLAGS)
921
+ for name, value in seeds.items():
922
+ self.write_register(name, value)
923
+
924
+ # ---- state access -----------------------------------------------------
925
+
926
+ def read_register(self, name: str) -> int | None:
927
+ """Read one register, or ``None`` when the name is unknown here."""
928
+ constant = _reg_const(name)
929
+ if constant is None:
930
+ return None
931
+ try:
932
+ return int(self.uc.reg_read(constant))
933
+ except Exception: # pragma: no cover - a writable register reads back fine
934
+ return None
935
+
936
+ def write_register(self, name: str, value: int) -> None:
937
+ """Write one register, ignoring names this mode does not expose."""
938
+ constant = _reg_const(name)
939
+ if constant is None:
940
+ return
941
+ self.uc.reg_write(constant, int(value))
942
+
943
+ def snapshot(self) -> dict[str, int]:
944
+ """Snapshot the general-purpose registers of this mode."""
945
+ state: dict[str, int] = {}
946
+ for name in self.names:
947
+ value = self.read_register(name)
948
+ if value is not None:
949
+ state[name] = value
950
+ return state
951
+
952
+ def read_pointer(self, name: str) -> int | None:
953
+ """Read the machine word ``name`` points at, or ``None`` when unreadable."""
954
+ address = self.read_register(name)
955
+ if address is None:
956
+ return None
957
+ try:
958
+ raw = bytes(self.uc.mem_read(address, self.bits // 8))
959
+ except Exception:
960
+ return None
961
+ return int.from_bytes(raw, "little")
962
+
963
+ def eflags(self) -> int:
964
+ """Read ``rflags``/``eflags`` (the default value when it cannot be read)."""
965
+ value = self.read_register(self.flags)
966
+ return _DEFAULT_EFLAGS if value is None else value
967
+
968
+ def in_region(self, address: int) -> bool:
969
+ """True when ``address`` is inside the section being explored."""
970
+ low, high = self.region
971
+ return low <= address < high
972
+
973
+ # ---- execution --------------------------------------------------------
974
+
975
+ def run_to(self, begin: int, until: int, count: int) -> None:
976
+ """Emulate ``begin`` until ``until`` (exclusive) or ``count`` instructions."""
977
+ with _quiet_faulthandler():
978
+ self.uc.emu_start(begin, until, 0, count)
979
+
980
+ def step(self, address: int) -> int:
981
+ """Execute exactly one instruction at ``address`` and return the new ``ip``."""
982
+ with _quiet_faulthandler():
983
+ self.uc.emu_start(address, 0, 0, 1)
984
+ current = self.read_register(self.ip)
985
+ return -1 if current is None else current
986
+
987
+ def close(self) -> None:
988
+ """Drop the emulator so unicorn can free its native handle.
989
+
990
+ ``Uc`` registers a ``weakref.finalize`` that closes the handle when the
991
+ instance is collected, so dropping the reference is all this has to do
992
+ (calling ``release_handle`` could close the same handle twice).
993
+ """
994
+ self.uc = None
995
+
996
+
997
+ def _classify_fault(exc: Any, pc: int | None) -> tuple[StopReason, str]:
998
+ """Turn one unicorn exception into a stop reason plus an exact note."""
999
+ uni = _unicorn
1000
+ errno = getattr(exc, "errno", None)
1001
+ at = f" at 0x{pc:x}" if pc is not None else ""
1002
+ unmapped = {
1003
+ getattr(uni, "UC_ERR_READ_UNMAPPED", -6),
1004
+ getattr(uni, "UC_ERR_WRITE_UNMAPPED", -7),
1005
+ getattr(uni, "UC_ERR_FETCH_UNMAPPED", -8),
1006
+ }
1007
+ if errno in unmapped:
1008
+ return (
1009
+ _STOP_UNMAPPED,
1010
+ f"the path touched an address that is not mapped{at}: emulation stopped "
1011
+ f"there, so this path is incomplete (not a return)",
1012
+ )
1013
+ protection = {
1014
+ getattr(uni, "UC_ERR_READ_PROT", -9),
1015
+ getattr(uni, "UC_ERR_WRITE_PROT", -10),
1016
+ getattr(uni, "UC_ERR_FETCH_PROT", -11),
1017
+ getattr(uni, "UC_ERR_READ_UNALIGNED", -12),
1018
+ getattr(uni, "UC_ERR_WRITE_UNALIGNED", -13),
1019
+ getattr(uni, "UC_ERR_FETCH_UNALIGNED", -14),
1020
+ }
1021
+ if errno in protection:
1022
+ return (_STOP_UNMAPPED, f"memory protection violation{at}: {exc}")
1023
+ if errno == getattr(uni, "UC_ERR_INSN_INVALID", -15):
1024
+ return (
1025
+ _STOP_BLOCKED,
1026
+ f"unicorn could not decode the instruction{at}: {exc}; the path is unresolved",
1027
+ )
1028
+ return (_STOP_BLOCKED, f"emulation failed{at}: {exc}; the path is unresolved")
1029
+
1030
+
1031
+ def _direct_target(insn: Any) -> int | None:
1032
+ """Return the constant operand of an instruction (its branch target), if any."""
1033
+ for operand in getattr(insn, "operands", ()):
1034
+ value = getattr(operand, "imm", None)
1035
+ if isinstance(value, int):
1036
+ return value
1037
+ return None
1038
+
1039
+
1040
+ # ---- the explorer ---------------------------------------------------------
1041
+
1042
+
1043
+ class _Explorer:
1044
+ """Breadth-first enumeration of branch decisions, one replay per path."""
1045
+
1046
+ def __init__(
1047
+ self,
1048
+ machine: _Machine,
1049
+ scanner: _Scanner,
1050
+ *,
1051
+ max_paths: int,
1052
+ max_steps: int,
1053
+ seeds: Mapping[str, int],
1054
+ ) -> None:
1055
+ self.machine = machine
1056
+ self.scanner = scanner
1057
+ self.max_paths = max_paths
1058
+ self.max_steps = max_steps
1059
+ self.seeds = seeds
1060
+ self.entries: list[Entry] = []
1061
+ self.steps = 0
1062
+ self.warnings: list[str] = []
1063
+
1064
+ # ---- driver -----------------------------------------------------------
1065
+
1066
+ def run(self) -> None:
1067
+ """Enumerate paths until the tree, the path cap or the budget ends."""
1068
+ queue: deque[tuple[bool, ...]] = deque([()])
1069
+ truncated = False
1070
+ budget_stop = False
1071
+ while queue:
1072
+ if len(self.entries) >= self.max_paths:
1073
+ truncated = True
1074
+ break
1075
+ if self.steps >= self.max_steps:
1076
+ budget_stop = True
1077
+ break
1078
+ entry, children, used = self._walk(queue.popleft())
1079
+ self.steps += used
1080
+ self.entries.append(entry)
1081
+ queue.extend(children)
1082
+ if truncated:
1083
+ self.warnings.append(
1084
+ f"path limit reached: {self.max_paths} path(s) were reported and the branch "
1085
+ "tree is not exhausted; raise max_paths to explore further"
1086
+ )
1087
+ if budget_stop:
1088
+ self.warnings.append(
1089
+ f"step budget exhausted after {self.steps} emulated instruction(s); the "
1090
+ "remaining paths were not explored"
1091
+ )
1092
+ if truncated or budget_stop:
1093
+ self.warnings.append(f"{len(queue)} path(s) were left unexplored")
1094
+ incomplete = sum(
1095
+ 1 for entry in self.entries if entry.stopped_reason != _STOP_RETURN
1096
+ )
1097
+ if incomplete and not truncated and not budget_stop:
1098
+ self.warnings.append(
1099
+ f"{incomplete} path(s) ended without a return (blocked, unmapped or budget); "
1100
+ "the branch tree is not fully resolved"
1101
+ )
1102
+
1103
+ @property
1104
+ def complete(self) -> bool:
1105
+ """True when nothing was left unexplored and every path reached a ``ret``."""
1106
+ return not self.warnings and all(
1107
+ entry.stopped_reason == _STOP_RETURN for entry in self.entries
1108
+ )
1109
+
1110
+ # ---- one path ---------------------------------------------------------
1111
+
1112
+ def _walk(self, plan: tuple[bool, ...]) -> tuple[Entry, list[tuple[bool, ...]], int]:
1113
+ """Replay one decision plan and report exactly where it ended.
1114
+
1115
+ Returns:
1116
+ ``(entry, children, instructions)``: the path's entry, the decision
1117
+ tuples still to explore, and the instructions this replay executed.
1118
+ """
1119
+ machine = self.machine
1120
+ machine.reset(self.seeds)
1121
+ pc = machine.address
1122
+ walked: list[bool] = []
1123
+ taken: list[int] = []
1124
+ branches: list[Branch] = []
1125
+ notes: list[str] = []
1126
+ children: list[tuple[bool, ...]] = []
1127
+ used = 0
1128
+
1129
+ def finish(
1130
+ reason: StopReason, value: int | None
1131
+ ) -> tuple[Entry, list[tuple[bool, ...]], int]:
1132
+ return (
1133
+ self._entry(branches, taken, reason, value, notes, used),
1134
+ children,
1135
+ used,
1136
+ )
1137
+
1138
+ while True:
1139
+ if self.steps + used >= self.max_steps:
1140
+ notes.append(
1141
+ f"step budget exhausted after {self.max_steps} instruction(s); the path "
1142
+ f"is truncated at 0x{pc:x} and has no return value"
1143
+ )
1144
+ return finish(_STOP_BUDGET, None)
1145
+ scan = self.scanner.scan(pc)
1146
+ if not scan.instructions:
1147
+ notes.append(scan.error or f"nothing to emulate at 0x{pc:x}")
1148
+ return finish(_STOP_BLOCKED, None)
1149
+ body = scan.body
1150
+ terminator = scan.terminator
1151
+ until = int(terminator.address) if terminator is not None else scan.end
1152
+ if body:
1153
+ try:
1154
+ machine.run_to(pc, until, len(body))
1155
+ except Exception as exc: # a unicorn fault, classified by errno
1156
+ return finish(*self._fault(exc, notes))
1157
+ used += len(body)
1158
+ if terminator is None:
1159
+ if scan.error is not None:
1160
+ notes.append(scan.error)
1161
+ return finish(_STOP_BLOCKED, None)
1162
+ pc = scan.end
1163
+ continue
1164
+ address = int(terminator.address)
1165
+ mnemonic = str(terminator.mnemonic).lower()
1166
+ if (
1167
+ mnemonic in _RET_MNEMONICS
1168
+ or terminator.group(capstone.CS_GRP_RET)
1169
+ or terminator.group(capstone.CS_GRP_IRET)
1170
+ ):
1171
+ target = machine.read_pointer(machine.sp)
1172
+ if target is not None and machine.in_region(target):
1173
+ try:
1174
+ landed = machine.step(address)
1175
+ except Exception as exc: # pragma: no cover - a mapped stack
1176
+ return finish(*self._fault(exc, notes))
1177
+ used += 1
1178
+ taken.append(landed)
1179
+ pc = landed
1180
+ continue
1181
+ if target is None:
1182
+ notes.append(
1183
+ f"the return address at {machine.sp} could not be read at "
1184
+ f"0x{address:x}; the value in {machine.return_register} is reported "
1185
+ "as this path's return value"
1186
+ )
1187
+ return finish(_STOP_RETURN, machine.read_register(machine.return_register))
1188
+ if mnemonic in _LOOP_MNEMONICS:
1189
+ counter = "rcx" if machine.mode64 else "ecx"
1190
+ notes.append(
1191
+ f"the {mnemonic!r} instruction at 0x{address:x} decrements {counter} "
1192
+ "before testing it, which this explorer does not model; the path is "
1193
+ "unresolved"
1194
+ )
1195
+ return finish(_STOP_BLOCKED, None)
1196
+ canonical = _CONDITION_ALIASES.get(mnemonic)
1197
+ counter_name = _COUNTER_JUMPS.get(mnemonic)
1198
+ if canonical is not None or counter_name is not None:
1199
+ observed: bool | None = None
1200
+ if canonical is not None:
1201
+ observed = _condition_holds(
1202
+ canonical, _Flags.from_eflags(machine.eflags())
1203
+ )
1204
+ else:
1205
+ counter_value = machine.read_register(str(counter_name))
1206
+ if counter_value is not None:
1207
+ observed = counter_value == 0
1208
+ if observed is None:
1209
+ notes.append(
1210
+ f"the {mnemonic!r} instruction at 0x{address:x} cannot be evaluated by "
1211
+ "this explorer; the path is unresolved"
1212
+ )
1213
+ return finish(_STOP_BLOCKED, None)
1214
+ index = len(walked)
1215
+ if index < len(plan):
1216
+ chosen = plan[index]
1217
+ else:
1218
+ chosen = observed
1219
+ children.append(tuple(walked) + (not observed,))
1220
+ basis: Basis = _BASIS_OBSERVED
1221
+ note = ""
1222
+ if chosen != observed:
1223
+ basis = _BASIS_SEEDED
1224
+ note = self._seed_branch(
1225
+ mnemonic, canonical, counter_name, chosen, machine.eflags()
1226
+ )
1227
+ notes.append(f"0x{address:x}: {note}")
1228
+ short = "taken" if chosen else "fall-through"
1229
+ expected = (
1230
+ _direct_target(terminator)
1231
+ if chosen
1232
+ else address + int(terminator.size)
1233
+ )
1234
+ try:
1235
+ landed = machine.step(address)
1236
+ except Exception as exc:
1237
+ return finish(*self._fault(exc, notes))
1238
+ used += 1
1239
+ if expected is not None and landed != expected:
1240
+ notes.append(
1241
+ f"{mnemonic!r} at 0x{address:x} continued at 0x{landed:x} but "
1242
+ f"0x{expected:x} was expected for the {short} side, so the seed did not "
1243
+ "steer it and the path is unresolved"
1244
+ )
1245
+ return finish(_STOP_BLOCKED, None)
1246
+ walked.append(chosen)
1247
+ if chosen:
1248
+ taken.append(landed)
1249
+ branches.append(Branch(address, mnemonic, chosen, landed, basis, note))
1250
+ if not machine.in_region(landed):
1251
+ notes.append(
1252
+ f"control left section {machine.section_name!r} at 0x{address:x} for "
1253
+ f"0x{landed:x}; the path is unresolved"
1254
+ )
1255
+ return finish(_STOP_BLOCKED, None)
1256
+ pc = landed
1257
+ continue
1258
+ # -- unconditional jmp/call: executed by the CPU, never assumed
1259
+ is_call = bool(terminator.group(capstone.CS_GRP_CALL))
1260
+ indirect = _direct_target(terminator) is None
1261
+ try:
1262
+ landed = machine.step(address)
1263
+ except Exception as exc:
1264
+ return finish(*self._fault(exc, notes))
1265
+ used += 1
1266
+ taken.append(landed)
1267
+ if indirect:
1268
+ notes.append(
1269
+ f"the {'call' if is_call else 'jump'} at 0x{address:x} is indirect: only "
1270
+ f"0x{landed:x}, the target of the register/memory value at that point, was "
1271
+ "explored; any other target it could hold was not"
1272
+ )
1273
+ if not machine.in_region(landed):
1274
+ notes.append(
1275
+ f"the {'call' if is_call else 'jump'} at 0x{address:x} left section "
1276
+ f"{machine.section_name!r} for 0x{landed:x} (a tail call or an import "
1277
+ "thunk); the path is unresolved"
1278
+ )
1279
+ return finish(_STOP_BLOCKED, None)
1280
+ pc = landed
1281
+ continue
1282
+
1283
+ # ---- helpers ----------------------------------------------------------
1284
+
1285
+ def _seed_branch(
1286
+ self,
1287
+ mnemonic: str,
1288
+ canonical: str | None,
1289
+ counter_name: str | None,
1290
+ chosen: bool,
1291
+ observed_bits: int,
1292
+ ) -> str:
1293
+ """Force one ``jcc`` direction, and describe the seed in a note."""
1294
+ machine = self.machine
1295
+ short = "taken" if chosen else "falls through"
1296
+ if canonical is not None:
1297
+ seeded, changed = _seeded_flag_bits(canonical, chosen, observed_bits)
1298
+ machine.write_register(machine.flags, seeded)
1299
+ return (
1300
+ f"{machine.flags} 0x{observed_bits:x} -> 0x{seeded:x} ({changed}) so "
1301
+ f"{mnemonic!r} {short}; that direction is assumed feasible, not proven "
1302
+ "(no constraint was solved)"
1303
+ )
1304
+ name = str(counter_name)
1305
+ previous = machine.read_register(name)
1306
+ machine.write_register(name, 0 if chosen else 1)
1307
+ return (
1308
+ f"{name} {previous} -> {0 if chosen else 1} so {mnemonic!r} {short}; that "
1309
+ "direction is assumed feasible, not proven (no constraint was solved)"
1310
+ )
1311
+
1312
+ def _fault(self, exc: Any, notes: list[str]) -> tuple[StopReason, None]:
1313
+ """Classify a unicorn fault, note it, and make it the path's stop reason."""
1314
+ pc = self.machine.read_register(self.machine.ip)
1315
+ reason, note = _classify_fault(exc, pc)
1316
+ notes.append(note)
1317
+ return reason, None
1318
+
1319
+ def _entry(
1320
+ self,
1321
+ branches: list[Branch],
1322
+ taken: list[int],
1323
+ reason: StopReason,
1324
+ value: int | None,
1325
+ notes: list[str],
1326
+ used: int,
1327
+ ) -> Entry:
1328
+ """Build the immutable entry for the path that just stopped."""
1329
+ return Entry(
1330
+ index=len(self.entries),
1331
+ decisions=tuple(branches),
1332
+ taken_targets=tuple(taken),
1333
+ registers=self.machine.snapshot(),
1334
+ return_value=value if reason == _STOP_RETURN else None,
1335
+ stopped_reason=reason,
1336
+ instructions=used,
1337
+ warnings=tuple(notes),
1338
+ )
1339
+
1340
+
1341
+ # ---- public API: the unicorn explorer -------------------------------------
1342
+
1343
+ #: What the unicorn explorer explored versus what it merely assumed. Kept in the
1344
+ #: evidence so no caller can read the paths as something stronger.
1345
+ _UNICORN_ASSUMPTIONS: tuple[str, ...] = (
1346
+ "every path is a sequence of branch decisions replayed on a fresh unicorn CPU, "
1347
+ "and each replay starts from the same seeded state",
1348
+ "the observed side of a conditional branch is the real emulation result: no flag "
1349
+ "or register was touched before it ran",
1350
+ "the seeded side is a flag/counter rewrite that satisfies the branch condition; it "
1351
+ "is assumed feasible, never proven -- no path predicate was built, no constraint "
1352
+ "was solved, no symbolic variable exists",
1353
+ "unconditional jumps and calls are executed, so their targets come from the CPU; an "
1354
+ "indirect transfer contributes only the target seen at that point",
1355
+ "register and memory values are concrete, so a path is not a proof of reachability "
1356
+ "for arbitrary inputs",
1357
+ )
1358
+
1359
+ #: What the angr bridge explored versus what the unicorn explorer would report.
1360
+ _ANGR_ASSUMPTIONS: tuple[str, ...] = (
1361
+ "paths come from angr exploring the function until a ret was found; angr owns the "
1362
+ "constraint solver, this module does not",
1363
+ "the observed/seeded distinction of the unicorn explorer does not apply here: every "
1364
+ "path angr reported is feasible under the constraints it solved",
1365
+ "taken_targets lists angr's basic-block destinations; the branch kinds behind them "
1366
+ "are not reconstructed by this bridge",
1367
+ )
1368
+
1369
+
1370
+ def _branch_counts(entries: Sequence[Entry]) -> dict[str, Any]:
1371
+ """Count stopped reasons and observed/seeded branch decisions."""
1372
+ reasons: dict[str, int] = {}
1373
+ observed = 0
1374
+ seeded = 0
1375
+ for entry in entries:
1376
+ reasons[entry.stopped_reason] = reasons.get(entry.stopped_reason, 0) + 1
1377
+ for branch in entry.decisions:
1378
+ if branch.basis == _BASIS_SEEDED:
1379
+ seeded += 1
1380
+ else:
1381
+ observed += 1
1382
+ return {
1383
+ "stopped_reasons": dict(sorted(reasons.items())),
1384
+ "observed_branches": observed,
1385
+ "seeded_branches": seeded,
1386
+ }
1387
+
1388
+
1389
+ def _exploration_evidence(
1390
+ *,
1391
+ explorer: str,
1392
+ address: int,
1393
+ info: BinaryInfo,
1394
+ bitness: int,
1395
+ entries: Sequence[Entry],
1396
+ steps: int,
1397
+ complete: bool,
1398
+ max_paths: int,
1399
+ max_steps: int,
1400
+ assumptions: Sequence[str],
1401
+ regions: Sequence[tuple[str, int, int]] = (),
1402
+ ) -> Evidence:
1403
+ """Build the provenance record for one exploration."""
1404
+ counts = _branch_counts(entries)
1405
+ unresolved = sum(
1406
+ 1 for entry in entries if entry.stopped_reason in (_STOP_BLOCKED, _STOP_UNMAPPED)
1407
+ )
1408
+ trusted = complete and not counts["seeded_branches"] and not unresolved
1409
+ extra: dict[str, Any] = {
1410
+ "explorer": explorer,
1411
+ "paths": len(entries),
1412
+ "complete": complete,
1413
+ "unresolved_paths": unresolved,
1414
+ "steps": steps,
1415
+ "max_paths": max_paths,
1416
+ "max_steps": max_steps,
1417
+ "architecture": info.architecture,
1418
+ "bitness": bitness,
1419
+ "assumptions": list(assumptions),
1420
+ **counts,
1421
+ }
1422
+ if regions:
1423
+ extra["regions"] = [
1424
+ {"kind": kind, "start": f"0x{start:x}", "end": f"0x{end:x}"}
1425
+ for kind, start, end in regions
1426
+ ]
1427
+ counted = (
1428
+ f"{steps} emulated instruction(s)"
1429
+ if explorer == "unicorn"
1430
+ else f"{steps} instruction(s) counted from the blocks angr visited"
1431
+ )
1432
+ return Evidence(
1433
+ source=_EVIDENCE_SOURCE if explorer == "unicorn" else _ANGR_SOURCE,
1434
+ method=_EVIDENCE_METHOD if explorer == "unicorn" else _ANGR_METHOD,
1435
+ confidence=Confidence.MEDIUM if trusted else Confidence.LOW,
1436
+ address=address,
1437
+ detail=(
1438
+ f"{len(entries)} path(s) from {counted}; "
1439
+ f"{counts['observed_branches']} observed and {counts['seeded_branches']} "
1440
+ f"seeded branch decision(s)"
1441
+ ),
1442
+ extra=extra,
1443
+ )
1444
+
1445
+
1446
+ def explore_paths(
1447
+ path: str | Path,
1448
+ info: BinaryInfo,
1449
+ address: int,
1450
+ max_paths: int = DEFAULT_MAX_PATHS,
1451
+ max_steps: int = DEFAULT_MAX_STEPS,
1452
+ initial_registers: Mapping[str, int] | None = None,
1453
+ *,
1454
+ stack_size: int = emulate.DEFAULT_STACK_SIZE,
1455
+ ) -> PathExploration:
1456
+ """Explore the paths through one function with the unicorn explorer.
1457
+
1458
+ This is the default explorer and needs nothing beyond the optional unicorn
1459
+ build the emulator already uses. Every path is a sequence of branch
1460
+ decisions replayed on a freshly initialised CPU; see the module docstring
1461
+ for the guarantees and for exactly what is *observed* versus *seeded*.
1462
+
1463
+ Args:
1464
+ path: Binary file (only its bytes are read; nothing is executed on the host).
1465
+ info: Loader metadata for that file.
1466
+ address: Start address, inside an executable section with file bytes.
1467
+ max_paths: Maximum number of paths to report.
1468
+ max_steps: Instruction budget for the whole exploration, replays included.
1469
+ initial_registers: Register seeds for every path (``{"edi": 0}``).
1470
+ stack_size: Size of the emulated stack region.
1471
+
1472
+ Returns:
1473
+ A :class:`PathExploration` with up to ``max_paths`` entries in
1474
+ deterministic breadth-first order.
1475
+
1476
+ Raises:
1477
+ AnalysisError: On an unusable address, an out-of-range limit, an unknown
1478
+ register name, a missing unicorn build, or an architecture this
1479
+ module does not emulate (x86 and x86-64 only).
1480
+ """
1481
+ target = Path(path)
1482
+ start = _validated_address(address)
1483
+ path_limit = _validated_limit("max_paths", max_paths, MAX_MAX_PATHS)
1484
+ step_limit = _validated_limit("max_steps", max_steps, MAX_MAX_STEPS)
1485
+ if isinstance(stack_size, bool) or not isinstance(stack_size, int):
1486
+ raise AnalysisError("stack_size must be an int")
1487
+ if not emulate.PAGE_SIZE <= stack_size <= emulate.MAX_STACK_SIZE:
1488
+ raise AnalysisError(
1489
+ f"stack_size must be between {emulate.PAGE_SIZE} and "
1490
+ f"{emulate.MAX_STACK_SIZE} byte(s)"
1491
+ )
1492
+ if not EMULATOR_AVAILABLE:
1493
+ raise AnalysisError(
1494
+ "path exploration needs the optional unicorn engine: "
1495
+ f"{emulate.emulator_status()}; install it with "
1496
+ "'pip install codevanta[emulation]'"
1497
+ )
1498
+ bits = _machine_bits(info)
1499
+ if bits is None:
1500
+ raise AnalysisError(
1501
+ f"path exploration supports x86 and x86-64 only, not "
1502
+ f"{info.architecture!r} ({info.bitness}-bit, {info.endianness}); no code was run"
1503
+ )
1504
+ seeds = _validated_registers(initial_registers, bits)
1505
+ section = info.section_for_va(start)
1506
+ if section is None:
1507
+ raise AnalysisError(f"address 0x{start:x} is not mapped by any section")
1508
+ if not section.is_executable:
1509
+ raise AnalysisError(
1510
+ f"address 0x{start:x} is inside section {section.name!r}, which is not executable"
1511
+ )
1512
+ if info.va_to_offset(start) is None:
1513
+ raise AnalysisError(f"address 0x{start:x} has no file bytes to execute")
1514
+ machine = _Machine(target, info, start, bits=bits, stack_size=stack_size)
1515
+ try:
1516
+ machine.open()
1517
+ except AnalysisError:
1518
+ machine.close()
1519
+ raise
1520
+ except Exception as exc: # pragma: no cover - depends on the unicorn build
1521
+ machine.close()
1522
+ raise AnalysisError(f"the emulator could not be initialised: {exc}") from exc
1523
+ explorer = _Explorer(
1524
+ machine,
1525
+ _Scanner(target, info, _Decoder(bits == 64)),
1526
+ max_paths=path_limit,
1527
+ max_steps=step_limit,
1528
+ seeds=seeds,
1529
+ )
1530
+ try:
1531
+ explorer.run()
1532
+ entries = tuple(explorer.entries)
1533
+ complete = explorer.complete
1534
+ evidence = _exploration_evidence(
1535
+ explorer="unicorn",
1536
+ address=start,
1537
+ info=info,
1538
+ bitness=bits,
1539
+ entries=entries,
1540
+ steps=explorer.steps,
1541
+ complete=complete,
1542
+ max_paths=path_limit,
1543
+ max_steps=step_limit,
1544
+ assumptions=_UNICORN_ASSUMPTIONS,
1545
+ regions=tuple(machine.regions),
1546
+ )
1547
+ return PathExploration(
1548
+ address=start,
1549
+ explorer="unicorn",
1550
+ architecture=info.architecture,
1551
+ bitness=bits,
1552
+ max_paths=path_limit,
1553
+ max_steps=step_limit,
1554
+ steps=explorer.steps,
1555
+ complete=complete,
1556
+ entries=entries,
1557
+ warnings=tuple(explorer.warnings),
1558
+ evidence=evidence,
1559
+ )
1560
+ finally:
1561
+ machine.close()
1562
+
1563
+
1564
+ # ---- optional angr bridge -------------------------------------------------
1565
+
1566
+
1567
+ def _probe_angr() -> bool:
1568
+ """True when a module called ``angr`` is findable; never imports angr."""
1569
+ try:
1570
+ return _importlib_util.find_spec("angr") is not None
1571
+ except Exception: # pragma: no cover - depends on the installation
1572
+ return False
1573
+
1574
+
1575
+ #: True when a module named ``angr`` is findable. This is a *probe*, not an
1576
+ #: import: angr is imported only inside :func:`explore_with_angr`, so importing
1577
+ #: this module stays cheap and keeps working on a machine without angr.
1578
+ ANGR_AVAILABLE = _probe_angr()
1579
+
1580
+
1581
+ def angr_available() -> bool:
1582
+ """Return whether the optional angr bridge can be used on this machine."""
1583
+ return ANGR_AVAILABLE
1584
+
1585
+
1586
+ def _import_angr() -> Any:
1587
+ """Import angr lazily, or raise :class:`AnalysisError` naming it and the extra."""
1588
+ if not ANGR_AVAILABLE:
1589
+ raise AnalysisError(
1590
+ "'angr' is not installed, so explore_with_angr cannot run: the angr bridge is "
1591
+ "optional in CODEVANTA and angr is never imported at module import time; "
1592
+ f"install it with '{_ANGR_HINT}' (angr is large, which is why "
1593
+ "explore_paths needs only unicorn)"
1594
+ )
1595
+ try:
1596
+ import angr
1597
+ except Exception as exc:
1598
+ raise AnalysisError(
1599
+ f"importing 'angr' failed ({type(exc).__name__}: {exc}); reinstall it with "
1600
+ f"'{_ANGR_HINT}', or use explore_paths, which needs only unicorn"
1601
+ ) from exc
1602
+ return angr
1603
+
1604
+
1605
+ def _ret_addresses(
1606
+ path: Path, info: BinaryInfo, address: int, decoder: _Decoder, section: Any
1607
+ ) -> tuple[int, ...]:
1608
+ """Collect the ``ret`` addresses in ``section`` by a bounded linear sweep.
1609
+
1610
+ The sweep is only used to build angr's ``find`` set, so a byte that cannot be
1611
+ decoded costs one resynchronisation step rather than a wrong target.
1612
+ """
1613
+ found: list[int] = []
1614
+ end = section.virtual_address + section.size
1615
+ pc = address
1616
+ for _ in range(MAX_SCAN_INSTRUCTIONS):
1617
+ if pc >= end:
1618
+ break
1619
+ try:
1620
+ data = read_code(path, info, pc, _MAX_INSTRUCTION_BYTES)
1621
+ except AnalysisError:
1622
+ break
1623
+ insn = decoder.decode(bytes(data), pc)
1624
+ if insn is None:
1625
+ pc += 1
1626
+ continue
1627
+ if str(insn.mnemonic).lower() in _RET_MNEMONICS:
1628
+ found.append(pc)
1629
+ pc += int(insn.size)
1630
+ return tuple(found)
1631
+
1632
+
1633
+ def _count_path_instructions(
1634
+ path: Path, info: BinaryInfo, decoder: _Decoder, blocks: Sequence[int]
1635
+ ) -> int:
1636
+ """Count the instructions of an angr path from the block addresses it visited."""
1637
+ total = 0
1638
+ for block in blocks:
1639
+ pc = block
1640
+ for _ in range(MAX_BLOCK_INSTRUCTIONS):
1641
+ try:
1642
+ data = read_code(path, info, pc, _MAX_INSTRUCTION_BYTES)
1643
+ except AnalysisError:
1644
+ break
1645
+ insn = decoder.decode(bytes(data), pc)
1646
+ if insn is None:
1647
+ break
1648
+ total += 1
1649
+ if _ends_block(insn):
1650
+ break
1651
+ pc += int(insn.size)
1652
+ return total
1653
+
1654
+
1655
+ def _angr_entry(
1656
+ index: int,
1657
+ state: Any,
1658
+ *,
1659
+ names: Sequence[str],
1660
+ return_register: str,
1661
+ path: Path,
1662
+ info: BinaryInfo,
1663
+ decoder: _Decoder,
1664
+ ) -> Entry:
1665
+ """Build one :class:`Entry` from an angr found state."""
1666
+ registers: dict[str, int] = {}
1667
+ for name in names:
1668
+ try:
1669
+ registers[name] = int(getattr(state.regs, name))
1670
+ except Exception: # pragma: no cover - angr owns the register vocabulary
1671
+ continue
1672
+ blocks = tuple(int(block) for block in getattr(state.history, "bbl_addrs", ()))
1673
+ return Entry(
1674
+ index=index,
1675
+ decisions=(),
1676
+ taken_targets=blocks[1:],
1677
+ registers=registers,
1678
+ return_value=registers.get(return_register),
1679
+ stopped_reason=_STOP_RETURN,
1680
+ instructions=_count_path_instructions(path, info, decoder, blocks),
1681
+ warnings=(
1682
+ "this path comes from angr's symbolic execution; the branch decisions the "
1683
+ "unicorn explorer would record were not reconstructed, so decisions is empty "
1684
+ "and taken_targets lists angr's basic-block destinations",
1685
+ ),
1686
+ )
1687
+
1688
+
1689
+ def explore_with_angr(
1690
+ path: str | Path,
1691
+ info: BinaryInfo,
1692
+ address: int,
1693
+ max_paths: int = DEFAULT_MAX_PATHS,
1694
+ initial_registers: Mapping[str, int] | None = None,
1695
+ ) -> PathExploration:
1696
+ """Explore the same paths with angr, when angr is installed.
1697
+
1698
+ angr is **optional** and imported here, never at module import time. Explore
1699
+ the function symbolically until a ``ret`` (``find=ret``), and report up to
1700
+ ``max_paths`` found paths. The ``observed``/``seeded`` distinction of the
1701
+ unicorn explorer does not apply: these paths are whatever angr's simulation
1702
+ manager could solve, and the evidence says so.
1703
+
1704
+ Args:
1705
+ path: Binary file.
1706
+ info: Loader metadata for that file.
1707
+ address: Start address of the function.
1708
+ max_paths: Maximum number of paths to report.
1709
+ initial_registers: Concrete register seeds applied to the entry state.
1710
+
1711
+ Returns:
1712
+ A :class:`PathExploration` whose ``explorer`` is ``"angr"``.
1713
+
1714
+ Raises:
1715
+ AnalysisError: When angr is not installed (the message names angr and the
1716
+ extra that installs it), when angr fails to explore, when the machine
1717
+ is not x86/x86-64, or on a bad address or limit.
1718
+ """
1719
+ target = Path(path)
1720
+ start = _validated_address(address)
1721
+ path_limit = _validated_limit("max_paths", max_paths, MAX_MAX_PATHS)
1722
+ bits = _machine_bits(info)
1723
+ if bits is None:
1724
+ raise AnalysisError(
1725
+ "the angr bridge reports x86 register names, so it supports x86 and x86-64 "
1726
+ f"only, not {info.architecture!r}; nothing was explored"
1727
+ )
1728
+ seeds = _validated_registers(initial_registers, bits)
1729
+ module = _import_angr()
1730
+ section = info.section_for_va(start)
1731
+ if section is None:
1732
+ raise AnalysisError(f"address 0x{start:x} is not mapped by any section")
1733
+ if not section.is_executable:
1734
+ raise AnalysisError(
1735
+ f"address 0x{start:x} is inside section {section.name!r}, which is not executable"
1736
+ )
1737
+ decoder = _Decoder(bits == 64)
1738
+ rets = _ret_addresses(target, info, start, decoder, section)
1739
+ if not rets:
1740
+ raise AnalysisError(
1741
+ f"no ret instruction was found scanning {section.name!r} from 0x{start:x}, so "
1742
+ "the angr bridge has nothing to use as find; nothing was explored"
1743
+ )
1744
+ try:
1745
+ project = module.Project(str(target), auto_load_libs=False)
1746
+ state = project.factory.entry_state(addr=start)
1747
+ for name, value in seeds.items():
1748
+ setattr(state.regs, name, value)
1749
+ simgr = project.factory.simgr(state)
1750
+ simgr.explore(find=rets, num_find=path_limit)
1751
+ found = list(simgr.found)[:path_limit]
1752
+ remaining = len(simgr.active) + len(simgr.errored)
1753
+ except AnalysisError: # pragma: no cover - nothing above raises this
1754
+ raise
1755
+ except Exception as exc:
1756
+ raise AnalysisError(
1757
+ f"angr exploration of 0x{start:x} in {target.name} failed: "
1758
+ f"{type(exc).__name__}: {exc}"
1759
+ ) from exc
1760
+ names = _REGISTERS_64 if bits == 64 else _REGISTERS_32
1761
+ entries = tuple(
1762
+ _angr_entry(
1763
+ index,
1764
+ found_state,
1765
+ names=names,
1766
+ return_register="rax" if bits == 64 else "eax",
1767
+ path=target,
1768
+ info=info,
1769
+ decoder=decoder,
1770
+ )
1771
+ for index, found_state in enumerate(found)
1772
+ )
1773
+ warnings: list[str] = []
1774
+ if remaining:
1775
+ warnings.append(
1776
+ f"{remaining} state(s) were still active or errored when angr stopped; those "
1777
+ "paths are not reported"
1778
+ )
1779
+ if len(found) >= path_limit:
1780
+ warnings.append(
1781
+ f"path limit reached: {path_limit} path(s) were reported, so more may exist"
1782
+ )
1783
+ steps = sum(entry.instructions for entry in entries)
1784
+ complete = not warnings and not remaining
1785
+ evidence = _exploration_evidence(
1786
+ explorer="angr",
1787
+ address=start,
1788
+ info=info,
1789
+ bitness=bits,
1790
+ entries=entries,
1791
+ steps=steps,
1792
+ complete=complete,
1793
+ max_paths=path_limit,
1794
+ max_steps=0,
1795
+ assumptions=_ANGR_ASSUMPTIONS,
1796
+ )
1797
+ return PathExploration(
1798
+ address=start,
1799
+ explorer="angr",
1800
+ architecture=info.architecture,
1801
+ bitness=bits,
1802
+ max_paths=path_limit,
1803
+ max_steps=0,
1804
+ steps=steps,
1805
+ complete=complete,
1806
+ entries=entries,
1807
+ warnings=tuple(warnings),
1808
+ evidence=evidence,
1809
+ )