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,1349 @@
1
+ """Bounded, offline CPU emulation for questions static analysis cannot answer.
2
+
3
+ Three questions motivate this module: *what does this function return for these
4
+ inputs*, *where does this indirect jump land*, and *what does this XOR/ADD
5
+ string loop decode to*. Static decoders answer none of them: they cannot
6
+ propagate values, cannot follow a register-indirect branch, and cannot run a
7
+ decode loop. This module answers them by emulating the target code inside
8
+ `unicorn <https://www.unicorn-engine.org/>`_ -- the bytes are copied into an
9
+ emulator that runs on its own virtual CPU, so **nothing is ever executed on the
10
+ host** and a hard instruction budget bounds the work.
11
+
12
+ Guarantees
13
+ ----------
14
+ * **Offline.** Only file bytes are read (through
15
+ :func:`codevanta.core.report.read_code`) and written into emulator memory.
16
+ No host process, syscall, or library is ever invoked, and an instruction that
17
+ would call into an import simply runs off the mapped image (see the stop
18
+ reasons below) instead of importing anything.
19
+ * **Bounded.** ``max_instructions`` (default
20
+ :data:`DEFAULT_MAX_INSTRUCTIONS`, ceiling :data:`MAX_MAX_INSTRUCTIONS`) is
21
+ enforced in the per-instruction hook, and every mapping is capped
22
+ (:data:`MAX_SECTION_BYTES`, :data:`MAX_TOTAL_MAPPED`,
23
+ :data:`MAX_INITIAL_BLOCKS`, :data:`MAX_INITIAL_BLOCK_BYTES`).
24
+ * **Honest.** Every run ends with one of the
25
+ :data:`StopReason` values and a warning that says *why*. A partial answer is
26
+ never dressed up as a complete one: a value read from ``rax`` after a budget
27
+ stop is reported as a register, not as the function's return value.
28
+
29
+ Stop reasons
30
+ ------------
31
+ ``return``
32
+ Execution reached a ``ret`` whose popped address lies outside the region of
33
+ the section holding ``address``; the return register (``rax``/``eax``) is
34
+ reported as :attr:`EmulationResult.return_value`. A ``ret`` that pops an
35
+ address *inside* the same region is an ordinary intra-region return and
36
+ emulation continues (tail calls, callbacks pushed by the code itself).
37
+ ``budget``
38
+ ``max_instructions`` instructions completed and the next one was stopped
39
+ before it ran, so :attr:`EmulationResult.instructions` equals the budget.
40
+ ``unmapped``
41
+ The emulated code touched an address that is not mapped. The exact faulting
42
+ address comes from unicorn's unmapped-access hook and is written into
43
+ ``warnings``. Memory-protection faults are reported here too, because from
44
+ the caller's point of view they are the same class of failure.
45
+ ``unsupported``
46
+ The architecture/mode is not one this module emulates (x86 and x86-64 only,
47
+ see :func:`emulator_status`), unicorn cannot decode the instruction, or the
48
+ emulator is unavailable. No emulation happened.
49
+ ``error``
50
+ unicorn raised an exception the emulated code caused (for example
51
+ ``UC_ERR_EXCEPTION`` from a division by zero) or the emulator failed
52
+ internally. The run is over.
53
+
54
+ Nothing else escapes: the only exception this module raises is
55
+ :class:`~codevanta.core.report.AnalysisError`, and only for caller mistakes
56
+ (address outside an executable section, no file bytes at the address,
57
+ ``max_instructions``/``stack_size`` out of range, an unknown register name, or
58
+ an ``initial_memory`` block that breaks the documented limits).
59
+
60
+ ABI defaults
61
+ ------------
62
+ Registers are seeded only with what the ABI says a function may assume at
63
+ entry: the instruction pointer (``rip``/``eip``) at ``address`` and the stack
64
+ pointer/base (``rsp``/``rbp``, or ``esp``/``ebp``) near the top of a stack
65
+ region mapped at :data:`STACK_BASE_64` / :data:`STACK_BASE_32` (recorded in the
66
+ evidence ``extra``). Argument registers are *not* guessed -- pass
67
+ ``initial_registers``.
68
+
69
+ Architecture support
70
+ --------------------
71
+ x86 (32-bit) and x86-64 are supported; those are the modes whose behaviour was
72
+ verified against the installed unicorn. ARM/AArch64/MIPS binaries get
73
+ ``stopped_reason="unsupported"`` and a warning instead of a guess, because the
74
+ return-value, stack and register conventions here are x86-specific.
75
+
76
+ The string-decoding helpers (:func:`xor_bytes`, :func:`decrypt_string_loop`)
77
+ are pure functions over bytes: they reinterpret a key recovered from the
78
+ emulator (or from static analysis) and never execute anything.
79
+ """
80
+
81
+ from __future__ import annotations
82
+
83
+ import faulthandler as _faulthandler
84
+ from collections.abc import Iterable, Mapping, Sequence
85
+ from dataclasses import dataclass
86
+ from pathlib import Path
87
+ from typing import Any, Literal
88
+
89
+ import capstone
90
+
91
+ from codevanta.core.loader import BinaryInfo, SectionInfo
92
+ from codevanta.core.report import AnalysisError, read_code
93
+ from codevanta.evidence.confidence import Confidence
94
+ from codevanta.evidence.models import Evidence
95
+
96
+ __all__ = [
97
+ "DEFAULT_MAX_INSTRUCTIONS",
98
+ "DEFAULT_STACK_SIZE",
99
+ "EMULATOR_AVAILABLE",
100
+ "MAX_INITIAL_BLOCKS",
101
+ "MAX_INITIAL_BLOCK_BYTES",
102
+ "MAX_MAX_INSTRUCTIONS",
103
+ "MAX_MEMORY_WRITES",
104
+ "MAX_SECTION_BYTES",
105
+ "MAX_STACK_SIZE",
106
+ "MAX_TOTAL_MAPPED",
107
+ "STACK_BASE_32",
108
+ "STACK_BASE_64",
109
+ "EmulationResult",
110
+ "StopReason",
111
+ "DECRYPT_OPS",
112
+ "decrypt_string_loop",
113
+ "emulate_function",
114
+ "emulator_status",
115
+ "resolve_indirect_target",
116
+ "xor_bytes",
117
+ ]
118
+
119
+
120
+ # ---- limits ---------------------------------------------------------------
121
+
122
+ #: Instruction budget used when the caller does not pass one.
123
+ DEFAULT_MAX_INSTRUCTIONS = 2000
124
+
125
+ #: Hard ceiling on ``max_instructions`` (bounded work, whatever the caller asks).
126
+ MAX_MAX_INSTRUCTIONS = 100_000
127
+
128
+ #: Stack region size in bytes used when the caller does not pass one.
129
+ DEFAULT_STACK_SIZE = 0x10000
130
+
131
+ #: Hard ceiling on ``stack_size`` (16 MiB of emulator memory).
132
+ MAX_STACK_SIZE = 0x100_0000
133
+
134
+ #: A single ``initial_memory`` block may not be larger than 1 MiB.
135
+ MAX_INITIAL_BLOCK_BYTES = 0x100000
136
+
137
+ #: At most this many ``initial_memory`` blocks are accepted.
138
+ MAX_INITIAL_BLOCKS = 32
139
+
140
+ #: At most this many memory writes are recorded in
141
+ #: :attr:`EmulationResult.memory_writes`; the rest are only counted and
142
+ #: reported through a warning.
143
+ MAX_MEMORY_WRITES = 256
144
+
145
+ #: A section larger than this is not mapped into the emulator (16 MiB). The
146
+ #: code and data a function-level emulation needs are far smaller; an outlier
147
+ #: of this size is a file-layout artefact, not something to copy per call.
148
+ MAX_SECTION_BYTES = 0x100_0000
149
+
150
+ #: Total emulator memory this module will ever map (64 MiB: image + stack +
151
+ #: ``initial_memory``).
152
+ MAX_TOTAL_MAPPED = 0x400_0000
153
+
154
+ #: Emulator page size; unicorn requires page-aligned addresses *and* sizes.
155
+ PAGE_SIZE = 0x1000
156
+
157
+ #: Where the stack region is mapped for 64-bit code (page aligned, chosen far
158
+ #: away from any image VA so a mapping collision is practically impossible).
159
+ STACK_BASE_64 = 0x7FFF_0000_0000
160
+
161
+ #: Where the stack region is mapped for 32-bit code (below the 4 GiB limit and
162
+ #: above the conventional 0x0804_8000 image base).
163
+ STACK_BASE_32 = 0x7E00_0000
164
+
165
+ #: Room left between the initial stack pointer and the top of the stack region
166
+ #: so a prologue can push without immediately leaving the mapping.
167
+ _STACK_HEADROOM = 0x100
168
+
169
+ #: Widest x86 instruction, in bytes.
170
+ _MAX_INSTRUCTION_BYTES = 15
171
+
172
+ #: Byte order used for every register/memory value read back out of the
173
+ #: emulator; x86 has no big-endian mode.
174
+ _ENDIANNESS_LITTLE = "little"
175
+
176
+ # ---- evidence -------------------------------------------------------------
177
+
178
+ _EVIDENCE_SOURCE = "emulate.unicorn"
179
+ _EVIDENCE_METHOD = "bounded_cpu_emulation"
180
+
181
+ # ---- stop reasons ---------------------------------------------------------
182
+
183
+ StopReason = Literal["return", "budget", "unmapped", "unsupported", "error"]
184
+
185
+ _STOP_RETURN: StopReason = "return"
186
+ _STOP_BUDGET: StopReason = "budget"
187
+ _STOP_UNMAPPED: StopReason = "unmapped"
188
+ _STOP_UNSUPPORTED: StopReason = "unsupported"
189
+ _STOP_ERROR: StopReason = "error"
190
+ #: Internal only: :func:`resolve_indirect_target` stopped at an indirect
191
+ #: branch. Never surfaces through :class:`EmulationResult`.
192
+ _STOP_INDIRECT = "indirect"
193
+
194
+ #: The reasons :class:`EmulationResult` may report (the public ``StopReason`` set).
195
+ _PUBLIC_REASONS = frozenset(
196
+ {_STOP_RETURN, _STOP_BUDGET, _STOP_UNMAPPED, _STOP_UNSUPPORTED, _STOP_ERROR}
197
+ )
198
+
199
+ #: Instruction mnemonics that leave the current 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
+ #: General-purpose registers snapshotted after a run, per bitness.
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
+
219
+ # ---- optional unicorn -----------------------------------------------------
220
+
221
+
222
+ def _probe_unicorn() -> tuple[Any, Any, str]:
223
+ """Import unicorn lazily and report why it is unavailable.
224
+
225
+ Returns:
226
+ ``(unicorn_module, x86_const_module, error)``. The modules are ``None``
227
+ when the import failed, and ``error`` then explains why.
228
+ """
229
+ try:
230
+ import unicorn as uni
231
+ import unicorn.x86_const as x86_const
232
+ except Exception as exc: # pragma: no cover - depends on the environment
233
+ return None, None, f"unicorn is not importable: {exc}"
234
+ return uni, x86_const, ""
235
+
236
+
237
+ _unicorn, _x86_const, _UNICORN_ERROR = _probe_unicorn()
238
+
239
+ #: True when a unicorn build is importable; emulation is refused otherwise.
240
+ EMULATOR_AVAILABLE = _unicorn is not None
241
+
242
+
243
+ def emulator_status() -> str:
244
+ """Return why emulation is unavailable, or ``""`` when it is available.
245
+
246
+ Callers should check this (or :data:`EMULATOR_AVAILABLE`) before offering
247
+ emulation in a UI. The text is one human-readable sentence naming the
248
+ missing dependency, and it is empty when unicorn imported cleanly.
249
+ """
250
+ return _UNICORN_ERROR
251
+
252
+
253
+ # ---- result ---------------------------------------------------------------
254
+
255
+
256
+ @dataclass(frozen=True)
257
+ class EmulationResult:
258
+ """Everything one bounded emulation run produced.
259
+
260
+ Attributes:
261
+ address: Virtual address the run started at.
262
+ stopped_reason: Why emulation ended (see the module docstring).
263
+ instructions: Instructions that *completed*. The instruction that ended
264
+ the run (the ``ret`` that left the region, or the one stopped by the
265
+ budget) is not counted, so a ``budget`` stop reports exactly
266
+ ``max_instructions``.
267
+ return_value: Value of the return register (``rax``/``eax``) when
268
+ ``stopped_reason`` is ``"return"``, otherwise ``None`` -- a value
269
+ left in ``rax`` by an interrupted run is not a return value.
270
+ registers: Register snapshot taken after the run (names as used by
271
+ unicorn's x86 constants: ``rax``, ``rip``, ...).
272
+ memory_writes: ``(address, bytes)`` for each recorded store, in the order
273
+ the stores happened. Bounded by :data:`MAX_MEMORY_WRITES`; stores to
274
+ the stack are included.
275
+ indirect_targets: Targets of the indirect jumps/calls that were seen, in
276
+ order. A target is only recorded when the operand could be
277
+ evaluated; unresolvable ones produce a warning instead.
278
+ warnings: Human-readable notes explaining every degradation: the budget,
279
+ the faulting address, unresolved indirect branches, dropped writes,
280
+ skipped sections.
281
+ evidence: Provenance for the run, including the mapped regions and the
282
+ instruction budget in ``extra``.
283
+ """
284
+
285
+ address: int
286
+ stopped_reason: StopReason
287
+ instructions: int
288
+ return_value: int | None
289
+ registers: Mapping[str, int]
290
+ memory_writes: tuple[tuple[int, bytes], ...]
291
+ indirect_targets: tuple[int, ...]
292
+ warnings: tuple[str, ...]
293
+ evidence: Evidence
294
+
295
+ def to_dict(self) -> dict[str, Any]:
296
+ """Serialize to a JSON-safe dict (bytes become hex, ints become hex strings)."""
297
+ return {
298
+ "address": f"0x{self.address:x}",
299
+ "stopped_reason": self.stopped_reason,
300
+ "instructions": self.instructions,
301
+ "return_value": (
302
+ f"0x{self.return_value:x}" if self.return_value is not None else None
303
+ ),
304
+ "registers": {
305
+ name: f"0x{value:x}" for name, value in sorted(self.registers.items())
306
+ },
307
+ "memory_writes": [
308
+ {"address": f"0x{address:x}", "size": len(data), "hex": data.hex()}
309
+ for address, data in self.memory_writes
310
+ ],
311
+ "indirect_targets": [f"0x{target:x}" for target in self.indirect_targets],
312
+ "warnings": list(self.warnings),
313
+ "evidence": self.evidence.to_dict(),
314
+ }
315
+
316
+
317
+ # ---- small helpers --------------------------------------------------------
318
+
319
+
320
+ def _align_up(value: int) -> int:
321
+ """Round ``value`` up to the next :data:`PAGE_SIZE` boundary."""
322
+ if value <= 0:
323
+ return 0
324
+ return (value + PAGE_SIZE - 1) & ~(PAGE_SIZE - 1)
325
+
326
+
327
+ def _byteorder(info: BinaryInfo) -> Literal["little", "big"]:
328
+ """Return the byte order used to read values back, validating the binary."""
329
+ endianness = (info.endianness or _ENDIANNESS_LITTLE).strip().lower()
330
+ return "big" if endianness == "big" else "little"
331
+
332
+
333
+ def _reg_const(name: str) -> int | None:
334
+ """Map a unicorn register name (``"rax"``, ``"eip"``) to its constant."""
335
+ if _x86_const is None:
336
+ return None
337
+ value = getattr(_x86_const, f"UC_X86_REG_{name.upper()}", None)
338
+ return int(value) if isinstance(value, int) else None
339
+
340
+
341
+ def _machine_bits(info: BinaryInfo) -> int | None:
342
+ """Return 32 or 64 when this module can emulate ``info``, else ``None``.
343
+
344
+ Only x86 modes are accepted: the return-value, stack and register handling
345
+ in this module is x86-specific, so an ARM/MIPS binary gets
346
+ ``stopped_reason="unsupported"`` rather than an emulation of unknown
347
+ fidelity.
348
+ """
349
+ if not EMULATOR_AVAILABLE:
350
+ return None
351
+ name = (info.architecture or "").strip().lower()
352
+ if name in _X86_64_NAMES:
353
+ bits = 64
354
+ elif name in _X86_32_NAMES:
355
+ bits = 32
356
+ else:
357
+ return None
358
+ if int(info.bitness) in (32, 64):
359
+ bits = int(info.bitness)
360
+ if _byteorder(info) == "big": # x86 is little-endian only
361
+ return None
362
+ return bits
363
+
364
+
365
+ def _merge_spans(spans: Iterable[tuple[int, int]]) -> list[tuple[int, int]]:
366
+ """Merge overlapping/adjacent page spans so each page is mapped once."""
367
+ merged: list[tuple[int, int]] = []
368
+ for start, end in sorted(spans):
369
+ if merged and start <= merged[-1][1]:
370
+ previous_start, previous_end = merged[-1]
371
+ merged[-1] = (previous_start, max(previous_end, end))
372
+ else:
373
+ merged.append((start, end))
374
+ return merged
375
+
376
+
377
+ # ---- instruction decoding -------------------------------------------------
378
+
379
+
380
+ class _Decoder:
381
+ """Single-instruction decoder used to recognise ``ret`` and indirect branches.
382
+
383
+ Unicorn tells us *where* control is, not *what* the instruction is, so the
384
+ bytes are also decoded with Capstone (detail enabled) inside the code hook.
385
+ Decoding only ever informs the stop decision; it never decides what executes.
386
+ """
387
+
388
+ def __init__(self, mode64: bool) -> None:
389
+ mode = capstone.CS_MODE_64 if mode64 else capstone.CS_MODE_32
390
+ self._cs = capstone.Cs(capstone.CS_ARCH_X86, mode)
391
+ self._cs.detail = True
392
+
393
+ def decode(self, code: bytes, address: int) -> Any:
394
+ """Decode one instruction from ``code``; ``None`` when it cannot be decoded."""
395
+ if not code:
396
+ return None
397
+ for insn in self._cs.disasm(code, address, 1):
398
+ return insn
399
+ return None
400
+
401
+
402
+ # ---- input validation -----------------------------------------------------
403
+
404
+
405
+ def _validated_address(address: int) -> int:
406
+ """Validate a start address (non-negative int) and return it."""
407
+ if isinstance(address, bool) or not isinstance(address, int):
408
+ raise AnalysisError(f"address must be an int, not {type(address).__name__}")
409
+ if address <= 0:
410
+ raise AnalysisError(f"address must be positive, got 0x{address:x}")
411
+ return address
412
+
413
+
414
+ def _validated_budget(max_instructions: int) -> int:
415
+ """Validate the instruction budget against the documented limits."""
416
+ if isinstance(max_instructions, bool) or not isinstance(max_instructions, int):
417
+ raise AnalysisError("max_instructions must be an int")
418
+ if max_instructions < 1:
419
+ raise AnalysisError("max_instructions must be >= 1")
420
+ if max_instructions > MAX_MAX_INSTRUCTIONS:
421
+ raise AnalysisError(
422
+ f"max_instructions too large ({max_instructions}); limit is {MAX_MAX_INSTRUCTIONS}"
423
+ )
424
+ return max_instructions
425
+
426
+
427
+ def _validated_stack_size(stack_size: int) -> int:
428
+ """Validate the stack size against the documented limits."""
429
+ if isinstance(stack_size, bool) or not isinstance(stack_size, int):
430
+ raise AnalysisError("stack_size must be an int")
431
+ if stack_size < PAGE_SIZE:
432
+ raise AnalysisError(f"stack_size must be >= {PAGE_SIZE} byte(s)")
433
+ if stack_size > MAX_STACK_SIZE:
434
+ raise AnalysisError(f"stack_size too large ({stack_size}); limit is {MAX_STACK_SIZE}")
435
+ return stack_size
436
+
437
+
438
+ def _validated_registers(
439
+ initial_registers: Mapping[str, int] | None,
440
+ bits: int,
441
+ ) -> dict[str, int]:
442
+ """Normalize and validate caller-supplied register seeds.
443
+
444
+ Raises:
445
+ AnalysisError: On an unknown register name, a non-int value, or a value
446
+ that does not fit the machine word.
447
+ """
448
+ seeds: dict[str, int] = {}
449
+ if not initial_registers:
450
+ return seeds
451
+ limit = 1 << bits
452
+ for name, value in initial_registers.items():
453
+ key = str(name).strip().lower()
454
+ if _reg_const(key) is None:
455
+ raise AnalysisError(f"unknown {bits}-bit register name {name!r}")
456
+ if isinstance(value, bool) or not isinstance(value, int):
457
+ raise AnalysisError(f"register {key!r} must be seeded with an int")
458
+ if value < 0 or value >= limit:
459
+ raise AnalysisError(f"value for register {key!r} does not fit in {bits} bits")
460
+ seeds[key] = int(value)
461
+ return seeds
462
+
463
+
464
+ def _validated_memory(initial_memory: Mapping[int, bytes] | None) -> dict[int, bytes]:
465
+ """Normalize and validate caller-supplied memory blocks.
466
+
467
+ Raises:
468
+ AnalysisError: On too many blocks, an oversized block, a negative
469
+ address, or a non-bytes payload.
470
+ """
471
+ blocks: dict[int, bytes] = {}
472
+ if not initial_memory:
473
+ return blocks
474
+ if len(initial_memory) > MAX_INITIAL_BLOCKS:
475
+ raise AnalysisError(
476
+ f"initial_memory has {len(initial_memory)} blocks; limit is {MAX_INITIAL_BLOCKS}"
477
+ )
478
+ for address, data in initial_memory.items():
479
+ if isinstance(address, bool) or not isinstance(address, int) or address < 0:
480
+ raise AnalysisError(f"initial_memory address {address!r} is not a non-negative int")
481
+ if not isinstance(data, (bytes, bytearray)):
482
+ raise AnalysisError(
483
+ f"initial_memory block at 0x{address:x} must be bytes"
484
+ )
485
+ payload = bytes(data)
486
+ if len(payload) > MAX_INITIAL_BLOCK_BYTES:
487
+ raise AnalysisError(
488
+ f"initial_memory block at 0x{address:x} is {len(payload)} byte(s); "
489
+ f"limit is {MAX_INITIAL_BLOCK_BYTES}"
490
+ )
491
+ blocks[address] = payload
492
+ return blocks
493
+
494
+
495
+
496
+ # ---- the emulation session ------------------------------------------------
497
+
498
+
499
+ class _Runner:
500
+ """One bounded emulation session on a private unicorn CPU.
501
+
502
+ The runner owns the emulator, the mapped regions and the per-instruction
503
+ state machine that decides when to stop. It never raises anything other
504
+ than :class:`AnalysisError`; every emulator exception is classified into a
505
+ :data:`StopReason` and a warning.
506
+ """
507
+
508
+ def __init__(
509
+ self,
510
+ path: str | Path,
511
+ info: BinaryInfo,
512
+ address: int,
513
+ *,
514
+ bits: int,
515
+ max_instructions: int,
516
+ stack_size: int,
517
+ stop_at_indirect: bool,
518
+ ) -> None:
519
+ self.path = Path(path)
520
+ self.info = info
521
+ self.address = address
522
+ self.bits = bits
523
+ self.max_instructions = max_instructions
524
+ self.stack_size = stack_size
525
+ self.stop_at_indirect = stop_at_indirect
526
+ self.mode64 = bits == 64
527
+ self.word_size = bits // 8
528
+ self.byteorder = _byteorder(info)
529
+ self.uc: Any = None
530
+ self.decoder = _Decoder(self.mode64)
531
+ self.attempted = 0
532
+ self.completed = 0
533
+ self.reason: str | None = None
534
+ self.warnings: list[str] = []
535
+ self.writes: list[tuple[int, bytes]] = []
536
+ self.dropped_writes = 0
537
+ self.indirect: list[int] = []
538
+ self.pending_indirect: int | None = None
539
+ self.fault: tuple[str, int, int] | None = None
540
+ self.mapped_pages: set[int] = set()
541
+ self.regions: list[tuple[str, int, int]] = []
542
+ self.mapped_bytes = 0
543
+ self.stack_base = 0
544
+ self.stack_top = 0
545
+ self._wide_write_warned = False
546
+ section = info.section_for_va(address)
547
+ if section is not None:
548
+ self.region = (section.virtual_address, section.virtual_address + section.size)
549
+ else: # pragma: no cover - the public API rejects this first
550
+ self.region = (address, address + 1)
551
+
552
+ # ---- setup ------------------------------------------------------------
553
+
554
+ def setup(
555
+ self,
556
+ initial_registers: Mapping[str, int],
557
+ initial_memory: Mapping[int, bytes],
558
+ ) -> None:
559
+ """Create the CPU, map the image and the stack, then seed state."""
560
+ uni = _unicorn
561
+ arch = uni.UC_ARCH_X86
562
+ mode = uni.UC_MODE_64 if self.mode64 else uni.UC_MODE_32
563
+ self.uc = uni.Uc(arch, mode)
564
+ self._map_image()
565
+ self._map_stack()
566
+ self._map_initial_memory(initial_memory)
567
+ self._seed_registers(initial_registers)
568
+ self._install_hooks()
569
+
570
+ def _map_image(self) -> None:
571
+ """Map every section of the image (page aligned, bounded), then copy its bytes."""
572
+ spans: list[tuple[int, int]] = []
573
+ mapped: list[SectionInfo] = []
574
+ for section in self.info.sections:
575
+ span = self._section_span(section)
576
+ if span is not None:
577
+ spans.append(span)
578
+ mapped.append(section)
579
+ for start, end in _merge_spans(spans):
580
+ self._map_pages(start, end, "image")
581
+ for section in mapped:
582
+ data = self._section_bytes(section)
583
+ if not data:
584
+ continue
585
+ try:
586
+ self.uc.mem_write(section.virtual_address, data)
587
+ except Exception as exc: # pragma: no cover - mapped just above
588
+ self.warnings.append(
589
+ f"section {section.name!r} could not be written at "
590
+ f"0x{section.virtual_address:x}: {exc}"
591
+ )
592
+
593
+ def _section_span(self, section: SectionInfo) -> tuple[int, int] | None:
594
+ """Page span to map for ``section``, or ``None`` when it is skipped."""
595
+ if section.size <= 0 or section.virtual_address <= 0:
596
+ return None # not allocated (e.g. .symtab, .shstrtab) or empty
597
+ if section.size > MAX_SECTION_BYTES:
598
+ self.warnings.append(
599
+ f"section {section.name!r} is {section.size} byte(s); sections larger than "
600
+ f"{MAX_SECTION_BYTES} byte(s) are not mapped into the emulator"
601
+ )
602
+ return None
603
+ start = section.virtual_address & ~(PAGE_SIZE - 1)
604
+ end = _align_up(section.virtual_address + section.size)
605
+ if self.mapped_bytes + (end - start) > MAX_TOTAL_MAPPED:
606
+ self.warnings.append(
607
+ f"section {section.name!r} is not mapped: the "
608
+ f"{MAX_TOTAL_MAPPED} byte(s) emulator mapping budget is spent"
609
+ )
610
+ return None
611
+ self.mapped_bytes += end - start
612
+ return start, end
613
+
614
+ def _section_bytes(self, section: SectionInfo) -> bytes:
615
+ """File bytes backing ``section`` (empty for bss-like sections)."""
616
+ want = min(section.size, MAX_SECTION_BYTES)
617
+ if want <= 0:
618
+ return b""
619
+ try:
620
+ return read_code(self.path, self.info, section.virtual_address, want)
621
+ except (AnalysisError, OSError):
622
+ return b""
623
+
624
+ def _map_stack(self) -> None:
625
+ """Map the stack region and remember where it lives."""
626
+ base = STACK_BASE_64 if self.mode64 else STACK_BASE_32
627
+ start = base & ~(PAGE_SIZE - 1)
628
+ end = _align_up(base + self.stack_size)
629
+ span = end - start
630
+ if self.mapped_bytes + span > MAX_TOTAL_MAPPED: # pragma: no cover - defensive
631
+ raise AnalysisError("stack does not fit in the emulator mapping budget")
632
+ self.mapped_bytes += span
633
+ self.stack_base, self.stack_top = start, end
634
+ self._map_pages(start, end, "stack")
635
+
636
+ def _map_pages(self, start: int, end: int, kind: str) -> None:
637
+ """Map the page range ``[start, end)``, skipping pages already mapped."""
638
+ run_start: int | None = None
639
+ page = start
640
+ while page < end:
641
+ if page in self.mapped_pages:
642
+ if run_start is not None:
643
+ self._commit(run_start, page, kind)
644
+ run_start = None
645
+ else:
646
+ self.mapped_pages.add(page)
647
+ if run_start is None:
648
+ run_start = page
649
+ page += PAGE_SIZE
650
+ if run_start is not None:
651
+ self._commit(run_start, end, kind)
652
+
653
+ def _commit(self, start: int, end: int, kind: str) -> None:
654
+ """Ask unicorn for one contiguous page run and record it."""
655
+ self.uc.mem_map(start, end - start)
656
+ self.regions.append((kind, start, end))
657
+
658
+ def _map_initial_memory(self, initial_memory: Mapping[int, bytes]) -> None:
659
+ """Map (as needed) and write every caller-supplied memory block."""
660
+ for address, data in initial_memory.items():
661
+ if not data:
662
+ continue
663
+ start = address & ~(PAGE_SIZE - 1)
664
+ end = _align_up(address + len(data))
665
+ span = end - start
666
+ if self.mapped_bytes + span > MAX_TOTAL_MAPPED:
667
+ raise AnalysisError(
668
+ f"initial_memory block at 0x{address:x} does not fit in the "
669
+ f"{MAX_TOTAL_MAPPED} byte(s) emulator mapping budget"
670
+ )
671
+ self.mapped_bytes += span
672
+ self._map_pages(start, end, "initial_memory")
673
+ try:
674
+ self.uc.mem_write(address, data)
675
+ except Exception as exc: # pragma: no cover - defensive
676
+ raise AnalysisError(
677
+ f"initial_memory block at 0x{address:x} could not be written: {exc}"
678
+ ) from exc
679
+
680
+ def _seed_registers(self, initial_registers: Mapping[str, int]) -> None:
681
+ """Seed the ABI defaults (rip/rsp/rbp) plus the caller's registers."""
682
+ sp = self.stack_top - _STACK_HEADROOM
683
+ seeds: dict[str, int] = (
684
+ {"rip": self.address, "rsp": sp, "rbp": sp}
685
+ if self.mode64
686
+ else {"eip": self.address, "esp": sp, "ebp": sp}
687
+ )
688
+ seeds.update(initial_registers)
689
+ for name, value in seeds.items():
690
+ constant = _reg_const(name)
691
+ if constant is None: # pragma: no cover - validated by the public API
692
+ continue
693
+ self.uc.reg_write(constant, value)
694
+
695
+
696
+ # ---- hooks ------------------------------------------------------------
697
+
698
+ def _install_hooks(self) -> None:
699
+ """Register the instruction, write and unmapped-access hooks."""
700
+ uni = _unicorn
701
+ self.uc.hook_add(uni.UC_HOOK_CODE, self._on_code)
702
+ self.uc.hook_add(uni.UC_HOOK_MEM_WRITE, self._on_write)
703
+ for hook_name in (
704
+ "UC_HOOK_MEM_READ_UNMAPPED",
705
+ "UC_HOOK_MEM_WRITE_UNMAPPED",
706
+ "UC_HOOK_MEM_FETCH_UNMAPPED",
707
+ ):
708
+ hook = getattr(uni, hook_name, None)
709
+ if hook is not None:
710
+ self.uc.hook_add(hook, self._on_unmapped)
711
+
712
+ def _on_code(self, uc: Any, address: int, size: int, user_data: Any) -> None:
713
+ """Count instructions and decide whether to stop *before* each one runs."""
714
+ self.attempted += 1
715
+ self.completed = self.attempted - 1
716
+ if self.attempted > self.max_instructions:
717
+ self.reason = _STOP_BUDGET
718
+ uc.emu_stop()
719
+ return
720
+ insn = self.decoder.decode(self._read_code_bytes(address), address)
721
+ if insn is None:
722
+ return # nothing to classify: unicorn stops us if it cannot run it
723
+ if insn.mnemonic in _RET_MNEMONICS or insn.group(capstone.CS_GRP_RET):
724
+ if self._ret_leaves_region(insn, address):
725
+ self.reason = _STOP_RETURN
726
+ uc.emu_stop()
727
+ return
728
+ if not (insn.group(capstone.CS_GRP_JUMP) or insn.group(capstone.CS_GRP_CALL)):
729
+ return
730
+ if not self._is_indirect(insn):
731
+ return
732
+ target = self._indirect_target(insn)
733
+ if target is None:
734
+ self.warnings.append(
735
+ f"indirect branch at 0x{address:x} ({insn.mnemonic} {insn.op_str}) "
736
+ "could not be resolved; its target is unknown"
737
+ )
738
+ return
739
+ if target not in self.indirect:
740
+ self.indirect.append(target)
741
+ if self.stop_at_indirect:
742
+ self.pending_indirect = target
743
+ self.reason = _STOP_INDIRECT
744
+ uc.emu_stop()
745
+
746
+ def _on_write(
747
+ self,
748
+ uc: Any,
749
+ access: int,
750
+ address: int,
751
+ size: int,
752
+ value: int,
753
+ user_data: Any,
754
+ ) -> None:
755
+ """Record (bounded) the bytes of every store the code performs."""
756
+ if len(self.writes) >= MAX_MEMORY_WRITES:
757
+ self.dropped_writes += 1
758
+ return
759
+ if size <= 0:
760
+ return
761
+ if size > 8:
762
+ if not self._wide_write_warned:
763
+ self._wide_write_warned = True
764
+ self.warnings.append(
765
+ f"store of {size} byte(s) at 0x{address:x} is recorded as its low 8 "
766
+ "byte(s); wide stores are truncated in memory_writes"
767
+ )
768
+ payload = (int(value) & 0xFFFF_FFFF_FFFF_FFFF).to_bytes(8, self.byteorder)
769
+ else:
770
+ mask = (1 << (8 * size)) - 1
771
+ payload = (int(value) & mask).to_bytes(size, self.byteorder)
772
+ self.writes.append((address, payload))
773
+
774
+ def _on_unmapped(
775
+ self,
776
+ uc: Any,
777
+ access: int,
778
+ address: int,
779
+ size: int,
780
+ value: int,
781
+ user_data: Any,
782
+ ) -> bool:
783
+ """Remember the exact faulting address; never map it to paper a bug over."""
784
+ uni = _unicorn
785
+ kinds = {
786
+ getattr(uni, "UC_MEM_READ_UNMAPPED", -1): "read",
787
+ getattr(uni, "UC_MEM_WRITE_UNMAPPED", -2): "write",
788
+ getattr(uni, "UC_MEM_FETCH_UNMAPPED", -3): "fetch",
789
+ }
790
+ self.fault = (kinds.get(access, "memory"), address, size)
791
+ return False
792
+
793
+
794
+ # ---- instruction helpers ---------------------------------------------
795
+
796
+ def _read_code_bytes(self, address: int) -> bytes:
797
+ """Read up to one instruction's bytes, tolerating a page edge."""
798
+ for want in (_MAX_INSTRUCTION_BYTES, 8, 4, 1):
799
+ try:
800
+ return bytes(self.uc.mem_read(address, want))
801
+ except Exception:
802
+ continue
803
+ return b""
804
+
805
+ def _read_register(self, name: str) -> int | None:
806
+ """Read one register, or ``None`` when this mode has no such register."""
807
+ constant = _reg_const(name)
808
+ if constant is None:
809
+ return None
810
+ try:
811
+ return int(self.uc.reg_read(constant))
812
+ except Exception: # pragma: no cover - defensive
813
+ return None
814
+
815
+ def _read_pointer(self, address: int) -> int | None:
816
+ """Read one machine word from emulator memory."""
817
+ try:
818
+ raw = bytes(self.uc.mem_read(address, self.word_size))
819
+ except Exception:
820
+ return None
821
+ if len(raw) != self.word_size: # pragma: no cover - unicorn is exact
822
+ return None
823
+ return int.from_bytes(raw, self.byteorder)
824
+
825
+ @staticmethod
826
+ def _is_indirect(insn: Any) -> bool:
827
+ """True when a branch target is a register or memory operand."""
828
+ operands = insn.operands
829
+ if not operands:
830
+ return False
831
+ return operands[0].type in (capstone.x86.X86_OP_REG, capstone.x86.X86_OP_MEM)
832
+
833
+ def _indirect_target(self, insn: Any) -> int | None:
834
+ """Evaluate the target of a register/memory indirect branch."""
835
+ operands = insn.operands
836
+ if not operands:
837
+ return None
838
+ operand = operands[0]
839
+ if operand.type == capstone.x86.X86_OP_REG:
840
+ return self._read_register(insn.reg_name(operand.reg).lower())
841
+ if operand.type != capstone.x86.X86_OP_MEM:
842
+ return None
843
+ address = self._memory_address(insn, operand.mem)
844
+ if address is None:
845
+ return None
846
+ return self._read_pointer(address)
847
+
848
+ def _memory_address(self, insn: Any, mem: Any) -> int | None:
849
+ """Compute the effective address of ``[base + index*scale + disp]``."""
850
+ if mem.index != 0 and mem.scale not in (1, 2, 4, 8):
851
+ return None
852
+ address = int(mem.disp)
853
+ if mem.base != 0:
854
+ name = insn.reg_name(mem.base).lower()
855
+ if name in ("rip", "eip"): # x86: relative to the *next* instruction
856
+ address += insn.address + insn.size
857
+ else:
858
+ base = self._read_register(name)
859
+ if base is None:
860
+ return None
861
+ address += base
862
+ if mem.index != 0:
863
+ index = self._read_register(insn.reg_name(mem.index).lower())
864
+ if index is None:
865
+ return None
866
+ address += index * int(mem.scale or 1)
867
+ if address < 0 or address >= (1 << self.bits):
868
+ return None
869
+ return address
870
+
871
+ def _ret_leaves_region(self, insn: Any, address: int) -> bool:
872
+ """True when the ``ret`` at ``address`` returns outside the current section."""
873
+ sp_name = "rsp" if self.mode64 else "esp"
874
+ sp = self._read_register(sp_name)
875
+ if sp is None: # pragma: no cover - the register always exists here
876
+ return False
877
+ target = self._read_pointer(sp)
878
+ low, high = self.region
879
+ if target is None:
880
+ self.warnings.append(
881
+ f"return at 0x{address:x} ({insn.mnemonic} {insn.op_str}) could not be "
882
+ f"resolved: the return address at {sp_name}=0x{sp:x} is not readable"
883
+ )
884
+ return False
885
+ if low <= target < high:
886
+ return False # ordinary intra-region return: keep emulating
887
+ return True
888
+
889
+ # ---- run and report ---------------------------------------------------
890
+
891
+ def run(self) -> None:
892
+ """Emulate until a stop reason is recorded; never re-raises ``UcError``."""
893
+ try:
894
+ # ``until=0`` means "run until something stops us"; the instruction
895
+ # budget is enforced by the code hook, not by unicorn's count, so the
896
+ # reported instruction count and the stop reason can never disagree.
897
+ #
898
+ # Unicorn resolves guest faults (``UC_ERR_READ_UNMAPPED`` and friends)
899
+ # with its own signal/SEH handler. CPython's faulthandler prints a
900
+ # "Windows fatal exception: access violation" line before Unicorn's
901
+ # handler runs, which looks like a crash in the test log even though the
902
+ # emulation is fine. Suppress it for the duration of the run and restore
903
+ # whatever state the caller had.
904
+ was_enabled = _faulthandler.is_enabled()
905
+ if was_enabled:
906
+ _faulthandler.disable()
907
+ try:
908
+ self.uc.emu_start(self.address, 0, 0, 0)
909
+ finally:
910
+ if was_enabled:
911
+ _faulthandler.enable()
912
+ except _unicorn.UcError as exc:
913
+ self._classify_error(exc)
914
+ if self.reason is None: # pragma: no cover - defensive
915
+ self.reason = _STOP_ERROR
916
+ self.warnings.append(
917
+ "emulation stopped without reaching a return, the budget, or a fault"
918
+ )
919
+ if self.reason == _STOP_BUDGET:
920
+ self.warnings.append(
921
+ f"instruction budget exhausted after {self.max_instructions} "
922
+ "instruction(s); the emulation result is truncated"
923
+ )
924
+ if self.dropped_writes:
925
+ self.warnings.append(
926
+ f"{self.dropped_writes} memory write(s) beyond the first "
927
+ f"{MAX_MEMORY_WRITES} were not recorded"
928
+ )
929
+
930
+ def _classify_error(self, exc: Any) -> None:
931
+ """Turn one unicorn exception into a stop reason plus an exact warning."""
932
+ uni = _unicorn
933
+ errno = getattr(exc, "errno", None)
934
+ pc = self._read_register("rip" if self.mode64 else "eip")
935
+ at = f" at 0x{pc:x}" if pc is not None else ""
936
+ unmapped = {
937
+ getattr(uni, "UC_ERR_READ_UNMAPPED", -6),
938
+ getattr(uni, "UC_ERR_WRITE_UNMAPPED", -7),
939
+ getattr(uni, "UC_ERR_FETCH_UNMAPPED", -8),
940
+ }
941
+ protection = {
942
+ getattr(uni, "UC_ERR_READ_PROT", -9),
943
+ getattr(uni, "UC_ERR_WRITE_PROT", -10),
944
+ getattr(uni, "UC_ERR_FETCH_PROT", -11),
945
+ getattr(uni, "UC_ERR_READ_UNALIGNED", -12),
946
+ getattr(uni, "UC_ERR_WRITE_UNALIGNED", -13),
947
+ getattr(uni, "UC_ERR_FETCH_UNALIGNED", -14),
948
+ }
949
+ if errno in unmapped:
950
+ self.reason = _STOP_UNMAPPED
951
+ self.warnings.append(
952
+ f"invalid {self._fault_text()}{at}; emulation stopped because the "
953
+ "address is not mapped"
954
+ )
955
+ elif errno in protection:
956
+ self.reason = _STOP_UNMAPPED
957
+ self.warnings.append(
958
+ f"memory protection violation ({self._fault_text()}){at}: {exc}"
959
+ )
960
+ elif errno == getattr(uni, "UC_ERR_INSN_INVALID", -15):
961
+ self.reason = _STOP_UNSUPPORTED
962
+ self.warnings.append(
963
+ f"unicorn could not decode the instruction{at}: {exc}; the emulation "
964
+ "result stops there"
965
+ )
966
+ else:
967
+ self.reason = _STOP_ERROR
968
+ self.warnings.append(f"emulation failed{at}: {exc}")
969
+
970
+ def _fault_text(self) -> str:
971
+ """Describe the recorded unmapped fault (exact address and width)."""
972
+ if self.fault is None:
973
+ return "memory access"
974
+ kind, address, size = self.fault
975
+ return f"{kind} access to unmapped address 0x{address:x} ({size} byte(s))"
976
+
977
+ def _read_registers(self) -> dict[str, int]:
978
+ """Snapshot the general-purpose registers that this mode exposes."""
979
+ names = _REGISTERS_64 if self.mode64 else _REGISTERS_32
980
+ snapshot: dict[str, int] = {}
981
+ for name in names:
982
+ value = self._read_register(name)
983
+ if value is not None:
984
+ snapshot[name] = value
985
+ return snapshot
986
+
987
+ def result(self) -> EmulationResult:
988
+ """Build the immutable result for this run."""
989
+ reason: str = self.reason or _STOP_ERROR
990
+ stopped_reason = reason if reason in _PUBLIC_REASONS else _STOP_ERROR
991
+ registers = self._read_registers()
992
+ return_value = None
993
+ if stopped_reason == _STOP_RETURN:
994
+ return_value = registers.get("rax" if self.mode64 else "eax")
995
+ return EmulationResult(
996
+ address=self.address,
997
+ stopped_reason=stopped_reason,
998
+ instructions=self.completed,
999
+ return_value=return_value,
1000
+ registers=registers,
1001
+ memory_writes=tuple(self.writes),
1002
+ indirect_targets=tuple(self.indirect),
1003
+ warnings=tuple(self.warnings),
1004
+ evidence=self._evidence(stopped_reason, registers),
1005
+ )
1006
+
1007
+ def _evidence(self, reason: str, registers: Mapping[str, int]) -> Evidence:
1008
+ """Describe how this result was produced, including every mapped region."""
1009
+ return Evidence(
1010
+ source=_EVIDENCE_SOURCE,
1011
+ method=_EVIDENCE_METHOD,
1012
+ confidence=Confidence.MEDIUM,
1013
+ address=self.address,
1014
+ detail=(
1015
+ f"{self.completed} instruction(s) emulated on a private "
1016
+ f"{self.bits}-bit unicorn CPU; stopped_reason={reason}"
1017
+ ),
1018
+ extra={
1019
+ "stopped_reason": reason,
1020
+ "instructions": self.completed,
1021
+ "max_instructions": self.max_instructions,
1022
+ "instruction_pointer": (
1023
+ f"0x{registers['rip']:x}" if "rip" in registers else (
1024
+ f"0x{registers['eip']:x}" if "eip" in registers else None
1025
+ )
1026
+ ),
1027
+ "stack_base": f"0x{self.stack_base:x}",
1028
+ "stack_size": self.stack_top - self.stack_base,
1029
+ "architecture": self.info.architecture,
1030
+ "bitness": self.bits,
1031
+ "regions": [
1032
+ {"kind": kind, "start": f"0x{start:x}", "end": f"0x{end:x}"}
1033
+ for kind, start, end in self.regions
1034
+ ],
1035
+ "recorded_memory_writes": len(self.writes),
1036
+ "dropped_memory_writes": self.dropped_writes,
1037
+ "indirect_targets": len(self.indirect),
1038
+ },
1039
+ )
1040
+
1041
+ def close(self) -> None:
1042
+ """Drop the emulator so unicorn can free its native handle.
1043
+
1044
+ ``Uc`` registers a ``weakref.finalize`` that closes the native engine
1045
+ handle when the instance is collected, so dropping the last reference is
1046
+ all this has to do; calling ``Uc.release_handle`` here would risk closing
1047
+ the same handle twice, because the finalizer still refers to it.
1048
+ """
1049
+ self.uc = None
1050
+
1051
+
1052
+ # ---- public API -----------------------------------------------------------
1053
+
1054
+
1055
+ def _start_runner(
1056
+ path: str | Path,
1057
+ info: BinaryInfo,
1058
+ address: int,
1059
+ initial_registers: Mapping[str, int] | None,
1060
+ initial_memory: Mapping[int, bytes] | None,
1061
+ max_instructions: int,
1062
+ stack_size: int,
1063
+ *,
1064
+ stop_at_indirect: bool,
1065
+ ) -> _Runner | None:
1066
+ """Validate the request and return a ready-to-run session.
1067
+
1068
+ Returns:
1069
+ A configured :class:`_Runner`, or ``None`` when this binary or this
1070
+ machine cannot be emulated (reported as ``"unsupported"`` by callers).
1071
+
1072
+ Raises:
1073
+ AnalysisError: When the request itself is invalid (bad address, missing
1074
+ file bytes, out-of-range budget/stack size, bad register or memory
1075
+ seed).
1076
+ """
1077
+ start = _validated_address(address)
1078
+ budget = _validated_budget(max_instructions)
1079
+ stack = _validated_stack_size(stack_size)
1080
+ bits = _machine_bits(info)
1081
+ if bits is None or not EMULATOR_AVAILABLE:
1082
+ return None
1083
+ seeds = _validated_registers(initial_registers, bits)
1084
+ memory = _validated_memory(initial_memory)
1085
+ section = info.section_for_va(start)
1086
+ if section is None:
1087
+ raise AnalysisError(f"address 0x{start:x} is not mapped by any section")
1088
+ if not section.is_executable:
1089
+ raise AnalysisError(
1090
+ f"address 0x{start:x} is inside section {section.name!r}, which is not executable"
1091
+ )
1092
+ if info.va_to_offset(start) is None:
1093
+ raise AnalysisError(f"address 0x{start:x} has no file bytes to execute")
1094
+ runner = _Runner(
1095
+ path,
1096
+ info,
1097
+ start,
1098
+ bits=bits,
1099
+ max_instructions=budget,
1100
+ stack_size=stack,
1101
+ stop_at_indirect=stop_at_indirect,
1102
+ )
1103
+ try:
1104
+ runner.setup(seeds, memory)
1105
+ except _unicorn.UcError as exc:
1106
+ runner.warnings.append(f"the emulator could not be prepared: {exc}")
1107
+ runner.reason = _STOP_ERROR
1108
+ return runner
1109
+
1110
+
1111
+ def _unsupported_result(
1112
+ address: int,
1113
+ info: BinaryInfo,
1114
+ max_instructions: int,
1115
+ detail: str,
1116
+ ) -> EmulationResult:
1117
+ """Build the ``"unsupported"`` result used when no emulation is possible."""
1118
+ if EMULATOR_AVAILABLE:
1119
+ support = "emulation supports 32-bit and 64-bit x86 only"
1120
+ else:
1121
+ support = emulator_status() or "the emulator is unavailable"
1122
+ warning = f"{detail}; {support}, so no code was run"
1123
+ return EmulationResult(
1124
+ address=address,
1125
+ stopped_reason=_STOP_UNSUPPORTED,
1126
+ instructions=0,
1127
+ return_value=None,
1128
+ registers={},
1129
+ memory_writes=(),
1130
+ indirect_targets=(),
1131
+ warnings=(warning,),
1132
+ evidence=Evidence(
1133
+ source=_EVIDENCE_SOURCE,
1134
+ method=_EVIDENCE_METHOD,
1135
+ confidence=Confidence.UNKNOWN,
1136
+ address=address,
1137
+ detail=detail,
1138
+ extra={
1139
+ "stopped_reason": _STOP_UNSUPPORTED,
1140
+ "instructions": 0,
1141
+ "max_instructions": max_instructions,
1142
+ "architecture": info.architecture,
1143
+ "bitness": info.bitness,
1144
+ "emulator_status": emulator_status(),
1145
+ },
1146
+ ),
1147
+ )
1148
+
1149
+
1150
+ def emulate_function(
1151
+ path: str | Path,
1152
+ info: BinaryInfo,
1153
+ address: int,
1154
+ initial_registers: Mapping[str, int] | None = None,
1155
+ initial_memory: Mapping[int, bytes] | None = None,
1156
+ max_instructions: int = DEFAULT_MAX_INSTRUCTIONS,
1157
+ stack_size: int = DEFAULT_STACK_SIZE,
1158
+ ) -> EmulationResult:
1159
+ """Emulate the function at ``address`` for the given initial state.
1160
+
1161
+ The image sections are mapped at their virtual addresses, a stack region is
1162
+ mapped at :data:`STACK_BASE_64`/:data:`STACK_BASE_32`, and registers start at
1163
+ the ABI defaults (``rip`` at ``address``, ``rsp``/``rbp`` near the top of the
1164
+ stack) plus ``initial_registers``. Emulation then runs with a private unicorn
1165
+ CPU until one of the :data:`StopReason` conditions is met.
1166
+
1167
+ Args:
1168
+ path: Binary the metadata came from (only its bytes are read).
1169
+ info: Loaded :class:`~codevanta.core.loader.BinaryInfo`.
1170
+ address: Virtual address to start at; must be inside an executable
1171
+ section that has file bytes.
1172
+ initial_registers: Extra register seeds (``{"rdi": 2}``). Names use
1173
+ unicorn's x86 spelling for the binary's bitness; unknown names or
1174
+ values that do not fit the machine word raise
1175
+ :class:`~codevanta.core.report.AnalysisError`.
1176
+ initial_memory: ``{address: bytes}`` blocks to place in emulator memory
1177
+ before the run (at most :data:`MAX_INITIAL_BLOCKS` blocks of at most
1178
+ :data:`MAX_INITIAL_BLOCK_BYTES` bytes each). Blocks that overlap the
1179
+ image are written over it.
1180
+ max_instructions: Instruction budget, at most
1181
+ :data:`MAX_MAX_INSTRUCTIONS`.
1182
+ stack_size: Size of the mapped stack region, at most
1183
+ :data:`MAX_STACK_SIZE`; rounded up to a page.
1184
+
1185
+ Returns:
1186
+ An :class:`EmulationResult`. Limitations are never raised: they are
1187
+ reported through ``stopped_reason`` and ``warnings``.
1188
+
1189
+ Raises:
1190
+ AnalysisError: Only for an invalid request (see :func:`_start_runner`).
1191
+ """
1192
+ start = _validated_address(address)
1193
+ budget = _validated_budget(max_instructions)
1194
+ runner = _start_runner(
1195
+ path,
1196
+ info,
1197
+ start,
1198
+ initial_registers,
1199
+ initial_memory,
1200
+ budget,
1201
+ stack_size,
1202
+ stop_at_indirect=False,
1203
+ )
1204
+ if runner is None:
1205
+ if not EMULATOR_AVAILABLE:
1206
+ detail = "the emulator is unavailable"
1207
+ else:
1208
+ detail = f"cannot emulate {info.architecture or 'unknown'} ({info.bitness}-bit)"
1209
+ return _unsupported_result(start, info, budget, detail)
1210
+ try:
1211
+ runner.run()
1212
+ return runner.result()
1213
+ finally:
1214
+ runner.close()
1215
+
1216
+
1217
+ def resolve_indirect_target(
1218
+ path: str | Path,
1219
+ info: BinaryInfo,
1220
+ address: int,
1221
+ initial_registers: Mapping[str, int] | None = None,
1222
+ max_instructions: int = DEFAULT_MAX_INSTRUCTIONS,
1223
+ *,
1224
+ initial_memory: Mapping[int, bytes] | None = None,
1225
+ ) -> int | None:
1226
+ """Emulate until the first indirect jump/call and return its target.
1227
+
1228
+ This is the static-analysis bridge: a ``jmp rax`` or ``call qword ptr
1229
+ [rip+0x10]`` cannot be resolved by decoding alone, but seeding the registers
1230
+ it depends on and running up to it can answer it in a few instructions.
1231
+
1232
+ Args:
1233
+ path: Binary the metadata came from.
1234
+ info: Loaded :class:`~codevanta.core.loader.BinaryInfo`.
1235
+ address: Virtual address to start at.
1236
+ initial_registers: Register seeds (see :func:`emulate_function`).
1237
+ max_instructions: Instruction budget, at most
1238
+ :data:`MAX_MAX_INSTRUCTIONS`.
1239
+ initial_memory: Memory blocks to place before the run.
1240
+
1241
+ Returns:
1242
+ The target of the first indirect branch reached, or ``None`` when no
1243
+ indirect branch was reached before the run stopped (budget, fault,
1244
+ return), when the operand could not be evaluated, or when the binary is
1245
+ not emulatable. ``None`` therefore means "not resolved", never "no
1246
+ target".
1247
+
1248
+ Raises:
1249
+ AnalysisError: Only for an invalid request.
1250
+ """
1251
+ start = _validated_address(address)
1252
+ budget = _validated_budget(max_instructions)
1253
+ runner = _start_runner(
1254
+ path,
1255
+ info,
1256
+ start,
1257
+ initial_registers,
1258
+ initial_memory,
1259
+ budget,
1260
+ DEFAULT_STACK_SIZE,
1261
+ stop_at_indirect=True,
1262
+ )
1263
+ if runner is None:
1264
+ return None
1265
+ if runner.reason == _STOP_ERROR:
1266
+ runner.close()
1267
+ return None
1268
+ try:
1269
+ runner.run()
1270
+ return runner.pending_indirect
1271
+ finally:
1272
+ runner.close()
1273
+
1274
+
1275
+ # ---- string-decoding primitives -------------------------------------------
1276
+
1277
+ #: Operations understood by :func:`decrypt_string_loop`, named after what the
1278
+ #: *encoder* did to each byte.
1279
+ DECRYPT_OPS = ("xor", "add", "sub")
1280
+
1281
+
1282
+ def xor_bytes(data: bytes, key: int) -> bytes:
1283
+ """XOR every byte of ``data`` with the low 8 bits of ``key``.
1284
+
1285
+ The primitive behind the classic ``xor byte ptr [reg], imm8`` loop. It is a
1286
+ pure function over bytes -- nothing is emulated or executed -- so it can
1287
+ document a decoded blob once the key has been recovered by
1288
+ :func:`emulate_function` (read the key from a register or ``memory_writes``).
1289
+
1290
+ Args:
1291
+ data: Ciphertext bytes.
1292
+ key: XOR key; only the low 8 bits take part in the operation.
1293
+
1294
+ Returns:
1295
+ ``data`` with each byte XORed against ``key``. Applying it twice with the
1296
+ same key returns the original bytes.
1297
+ """
1298
+ mask = int(key) & 0xFF
1299
+ return bytes(byte ^ mask for byte in bytes(data))
1300
+
1301
+
1302
+ def decrypt_string_loop(
1303
+ data: bytes,
1304
+ key: int,
1305
+ *,
1306
+ key_step: int = 0,
1307
+ op: str = "xor",
1308
+ terminators: Sequence[int] = (0,),
1309
+ ) -> bytes:
1310
+ """Decode a string blob the way an inline decode loop would, without executing it.
1311
+
1312
+ Models the loops compilers and packers emit over a byte buffer: each byte is
1313
+ transformed with the key (optionally advanced per byte) and decoding stops at
1314
+ the first terminator, C-string style.
1315
+
1316
+ Args:
1317
+ data: Ciphertext bytes, already read by the caller (``read_code`` or an
1318
+ ``initial_memory`` block).
1319
+ key: Initial key. Only the low 8 bits of each per-byte key are used.
1320
+ key_step: Amount added to the key after every byte (``1`` for the
1321
+ "increment the key each iteration" variant, ``0`` for a fixed key).
1322
+ op: The operation the *encoder* applied: ``"xor"`` (``c = p ^ k``),
1323
+ ``"add"`` (``c = p + k``) or ``"sub"`` (``c = p - k``). Decoding
1324
+ inverts it.
1325
+ terminators: Byte values that end the string; the terminator itself is not
1326
+ part of the result. Pass ``()`` to decode the whole blob.
1327
+
1328
+ Returns:
1329
+ The decoded bytes, up to (not including) the first terminator.
1330
+
1331
+ Raises:
1332
+ AnalysisError: When ``op`` is not one of :data:`DECRYPT_OPS`.
1333
+ """
1334
+ if op not in DECRYPT_OPS:
1335
+ raise AnalysisError(f"op must be one of {DECRYPT_OPS}, not {op!r}")
1336
+ stops = frozenset(int(value) & 0xFF for value in terminators)
1337
+ decoded = bytearray()
1338
+ for index, byte in enumerate(bytes(data)):
1339
+ current = (int(key) + index * int(key_step)) & 0xFF
1340
+ if op == "xor":
1341
+ value = byte ^ current
1342
+ elif op == "add":
1343
+ value = (byte - current) & 0xFF
1344
+ else:
1345
+ value = (byte + current) & 0xFF
1346
+ if value in stops:
1347
+ break
1348
+ decoded.append(value)
1349
+ return bytes(decoded)