codevanta 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codevanta/__init__.py +1 -0
- codevanta/analysis/__init__.py +1 -0
- codevanta/analysis/binary_diff.py +1168 -0
- codevanta/analysis/callgraph.py +542 -0
- codevanta/analysis/cfg.py +1201 -0
- codevanta/analysis/dataflow.py +762 -0
- codevanta/analysis/debugger.py +289 -0
- codevanta/analysis/debuginfo.py +304 -0
- codevanta/analysis/decompile.py +1094 -0
- codevanta/analysis/decompilers/__init__.py +250 -0
- codevanta/analysis/decompilers/angr_backend.py +251 -0
- codevanta/analysis/decompilers/base.py +278 -0
- codevanta/analysis/decompilers/ghidra_backend.py +316 -0
- codevanta/analysis/dynamic.py +756 -0
- codevanta/analysis/emulate.py +1349 -0
- codevanta/analysis/evidence_bundle.py +736 -0
- codevanta/analysis/function_hash.py +562 -0
- codevanta/analysis/graph_export.py +441 -0
- codevanta/analysis/html_view.py +695 -0
- codevanta/analysis/jump_tables.py +546 -0
- codevanta/analysis/solver.py +715 -0
- codevanta/analysis/symbolic.py +1809 -0
- codevanta/analysis/trace.py +646 -0
- codevanta/analysis/type_propagation.py +1055 -0
- codevanta/analysis/type_recovery.py +1214 -0
- codevanta/analysis/vtables.py +572 -0
- codevanta/analysis/xrefs.py +434 -0
- codevanta/api/__init__.py +1 -0
- codevanta/cli/__init__.py +1 -0
- codevanta/cli/main.py +3038 -0
- codevanta/core/__init__.py +31 -0
- codevanta/core/disassembler.py +121 -0
- codevanta/core/entropy.py +237 -0
- codevanta/core/functions.py +176 -0
- codevanta/core/loader.py +1351 -0
- codevanta/core/report.py +306 -0
- codevanta/core/strings.py +109 -0
- codevanta/evidence/__init__.py +7 -0
- codevanta/evidence/confidence.py +36 -0
- codevanta/evidence/models.py +43 -0
- codevanta/evidence/provenance.py +37 -0
- codevanta/gui/__init__.py +46 -0
- codevanta/gui/layout.py +1384 -0
- codevanta/gui/server.py +1305 -0
- codevanta/ir/__init__.py +6 -0
- codevanta/ir/adapters/__init__.py +31 -0
- codevanta/ir/base.py +31 -0
- codevanta/ir/instruction.py +42 -0
- codevanta/mcp_server/__init__.py +5 -0
- codevanta/mcp_server/server.py +710 -0
- codevanta/mcp_server/tools.py +1364 -0
- codevanta/plugins/__init__.py +748 -0
- codevanta/setup/__init__.py +28 -0
- codevanta/setup/apply.py +22 -0
- codevanta/setup/clients.py +285 -0
- codevanta/setup/plan.py +79 -0
- codevanta/setup/verify.py +43 -0
- codevanta/storage/__init__.py +1 -0
- codevanta/storage/session.py +514 -0
- codevanta-0.1.0.dist-info/METADATA +688 -0
- codevanta-0.1.0.dist-info/RECORD +65 -0
- codevanta-0.1.0.dist-info/WHEEL +5 -0
- codevanta-0.1.0.dist-info/entry_points.txt +2 -0
- codevanta-0.1.0.dist-info/licenses/LICENSE +21 -0
- codevanta-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
"""Content fingerprints for a single function region (phase 2g).
|
|
2
|
+
|
|
3
|
+
:func:`function_fingerprint` disassembles the function that starts at an address
|
|
4
|
+
with a bounded *linear sweep* and reduces the decoded instructions to one
|
|
5
|
+
SHA-256 digest over their **normalized shape**:
|
|
6
|
+
|
|
7
|
+
1. Every instruction is rendered as ``"<mnemonic> <operands>"``.
|
|
8
|
+
2. Numeric literals are masked: ``0x401030``, ``-0x10`` and ``42`` all become
|
|
9
|
+
``#``. Consecutive masks are collapsed and whitespace is normalized, so
|
|
10
|
+
``mov eax, [rbp - 0x10]`` and ``mov eax, [rbp - 0x8]`` render identically.
|
|
11
|
+
3. The masked instructions are joined with ``"\\n"`` and hashed with SHA-256.
|
|
12
|
+
|
|
13
|
+
That makes the digest a *shape* fingerprint: two functions with the same
|
|
14
|
+
mnemonic/operand structure compare equal even when their immediates, branch
|
|
15
|
+
targets or frame offsets differ. This is what makes the digest useful for a
|
|
16
|
+
recompiled or relocated build -- and exactly why equal digests are **not** byte
|
|
17
|
+
equality and different digests are **not** a semantic difference. Every
|
|
18
|
+
fingerprint carries that caveat in its evidence at ``Confidence.MEDIUM``.
|
|
19
|
+
|
|
20
|
+
Work is bounded twice: the caller's ``max_instructions`` budget (default
|
|
21
|
+
:data:`DEFAULT_MAX_INSTRUCTIONS`) and the file-backed bytes of the containing
|
|
22
|
+
section, so at most ``max_instructions * 16`` bytes are read.
|
|
23
|
+
:attr:`FunctionFingerprint.truncated` says whether the digest covers only a
|
|
24
|
+
prefix of the function.
|
|
25
|
+
|
|
26
|
+
:func:`normalized_tokens` exposes the token stream those steps build, and
|
|
27
|
+
:func:`function_similarity` compares two regions as **multisets** of normalized
|
|
28
|
+
instructions (``collections.Counter``): the score is
|
|
29
|
+
``sum(min(counts)) / sum(max(counts))``, i.e. the Jaccard overlap of the two
|
|
30
|
+
token multisets, where ``1.0`` means both sides share every token with the same
|
|
31
|
+
multiplicity and ``0.0`` means they share none. The score is therefore a
|
|
32
|
+
*shape* measure exactly like the digest -- see
|
|
33
|
+
:data:`~codevanta.analysis.function_hash.SimilarityResult` -- and never a
|
|
34
|
+
semantic comparison.
|
|
35
|
+
|
|
36
|
+
Nothing is mapped or executed: bytes are read with
|
|
37
|
+
:func:`~codevanta.core.report.read_code`, which only ever reads the file, and
|
|
38
|
+
the target architecture is decoded by
|
|
39
|
+
:class:`~codevanta.core.disassembler.Disassembler`.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import hashlib
|
|
45
|
+
import re
|
|
46
|
+
from collections import Counter
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
from codevanta.core.disassembler import Disassembler, DisassemblyError
|
|
52
|
+
from codevanta.core.loader import BinaryInfo
|
|
53
|
+
from codevanta.core.report import MAX_DISASSEMBLY_COUNT, AnalysisError, read_code
|
|
54
|
+
from codevanta.evidence.confidence import Confidence
|
|
55
|
+
from codevanta.evidence.models import Evidence
|
|
56
|
+
from codevanta.ir.instruction import IRInstruction
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"DEFAULT_MAX_INSTRUCTIONS",
|
|
60
|
+
"DIGEST_ALGORITHM",
|
|
61
|
+
"MAX_MAX_INSTRUCTIONS",
|
|
62
|
+
"MIN_MAX_INSTRUCTIONS",
|
|
63
|
+
"FunctionFingerprint",
|
|
64
|
+
"SimilarityResult",
|
|
65
|
+
"function_fingerprint",
|
|
66
|
+
"function_similarity",
|
|
67
|
+
"normalize_instruction",
|
|
68
|
+
"normalized_tokens",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
#: Default instruction budget for one fingerprint.
|
|
72
|
+
DEFAULT_MAX_INSTRUCTIONS = 2_000
|
|
73
|
+
|
|
74
|
+
#: Smallest accepted ``max_instructions``; smaller requests are rejected.
|
|
75
|
+
MIN_MAX_INSTRUCTIONS = 1
|
|
76
|
+
|
|
77
|
+
#: Largest accepted ``max_instructions``; larger requests are rejected.
|
|
78
|
+
MAX_MAX_INSTRUCTIONS = MAX_DISASSEMBLY_COUNT
|
|
79
|
+
|
|
80
|
+
#: Hash used for the digest, recorded in :attr:`FunctionFingerprint.algorithm`.
|
|
81
|
+
DIGEST_ALGORITHM = "sha256"
|
|
82
|
+
|
|
83
|
+
#: Longest possible encoding of one instruction on a supported architecture.
|
|
84
|
+
#: Used to size the read window for the instruction budget.
|
|
85
|
+
_MAX_INSTRUCTION_BYTES = 16
|
|
86
|
+
|
|
87
|
+
#: ``0x401030``, ``-0x10``, ``+0X1F`` -> one mask. Hex is masked before bare
|
|
88
|
+
#: numbers so the ``0x`` form never leaves a trailing digit behind.
|
|
89
|
+
_HEX_LITERAL = re.compile(r"[-+]?0[xX][0-9a-fA-F]+")
|
|
90
|
+
|
|
91
|
+
#: A bare decimal literal (``42``, ARM's ``#-1``). The lookarounds keep register
|
|
92
|
+
#: and operand names that merely contain digits -- ``x29``, ``dword``, ``q3`` --
|
|
93
|
+
#: intact.
|
|
94
|
+
_BARE_NUMBER = re.compile(r"(?<![0-9A-Za-z_])[-+]?[0-9]+(?![0-9A-Za-z_])")
|
|
95
|
+
|
|
96
|
+
#: Consecutive masks collapse, so ARM's ``#0x1`` (which normalizes to ``##``)
|
|
97
|
+
#: and x86's ``0x1`` (which normalizes to ``#``) agree.
|
|
98
|
+
_REPEATED_MASK = re.compile(r"#+")
|
|
99
|
+
|
|
100
|
+
_EVIDENCE_SOURCE = "function_hash.normalized"
|
|
101
|
+
_EVIDENCE_METHOD = "normalized_linear_disassembly"
|
|
102
|
+
_CAVEAT = (
|
|
103
|
+
"shape-based fingerprint: instructions are normalized and numeric literals are masked "
|
|
104
|
+
"to '#', so an equal digest is not byte equality and a different digest is not a "
|
|
105
|
+
"semantic difference; nothing was executed"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
_EVIDENCE_SIMILARITY_SOURCE = "function_hash.similarity"
|
|
109
|
+
_EVIDENCE_SIMILARITY_METHOD = "token_multiset"
|
|
110
|
+
_SIMILARITY_CAVEAT = (
|
|
111
|
+
"shape-not-semantics comparison: the score is the multiset overlap of normalized "
|
|
112
|
+
"instructions with numeric literals masked to '#', so a high score is structural "
|
|
113
|
+
"resemblance and not proven semantic equivalence, and a low score is not proof of "
|
|
114
|
+
"different behaviour; sweeps are bounded prefixes and nothing was executed"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
#: Decimals kept when a similarity score is serialized.
|
|
118
|
+
_SCORE_DECIMALS = 4
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class FunctionFingerprint:
|
|
123
|
+
"""A normalized-content fingerprint for one function region.
|
|
124
|
+
|
|
125
|
+
Attributes:
|
|
126
|
+
address: Virtual address the sweep started at (the requested address).
|
|
127
|
+
digest: Lower-case hexadecimal SHA-256 of the newline-joined, normalized
|
|
128
|
+
instruction stream.
|
|
129
|
+
instruction_count: Number of instructions that contributed to
|
|
130
|
+
``digest``.
|
|
131
|
+
truncated: ``True`` when the sweep stopped before the end of the
|
|
132
|
+
available bytes -- the instruction budget was reached, the section's
|
|
133
|
+
file-backed bytes ran out, or trailing bytes could not be decoded.
|
|
134
|
+
The digest then covers only a prefix of the function.
|
|
135
|
+
evidence: How the digest was produced, including the shape caveat and
|
|
136
|
+
the applied bounds.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
address: int
|
|
140
|
+
digest: str
|
|
141
|
+
instruction_count: int
|
|
142
|
+
truncated: bool
|
|
143
|
+
evidence: Evidence
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def algorithm(self) -> str:
|
|
147
|
+
"""Name of the hash used for :attr:`digest`."""
|
|
148
|
+
return DIGEST_ALGORITHM
|
|
149
|
+
|
|
150
|
+
def to_dict(self) -> dict[str, Any]:
|
|
151
|
+
"""Serialize the fingerprint for the JSON report."""
|
|
152
|
+
return {
|
|
153
|
+
"address": f"0x{self.address:x}",
|
|
154
|
+
"algorithm": self.algorithm,
|
|
155
|
+
"digest": self.digest,
|
|
156
|
+
"instruction_count": self.instruction_count,
|
|
157
|
+
"truncated": self.truncated,
|
|
158
|
+
"evidence": self.evidence.to_dict(),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass(frozen=True)
|
|
163
|
+
class SimilarityResult:
|
|
164
|
+
"""Shape similarity between the instruction streams of two regions.
|
|
165
|
+
|
|
166
|
+
The comparison is a **multiset** comparison of the normalized instruction
|
|
167
|
+
tokens of both regions, so both the identities and the multiplicities of the
|
|
168
|
+
instructions matter::
|
|
169
|
+
|
|
170
|
+
shared = sum((left_counts & right_counts).values()) # sum(min)
|
|
171
|
+
union = sum((left_counts | right_counts).values()) # sum(max)
|
|
172
|
+
score = shared / union
|
|
173
|
+
|
|
174
|
+
``score`` is therefore ``1.0`` for two regions that agree token for token and
|
|
175
|
+
``0.0`` for two regions that share no token at all. It is a *shape* measure:
|
|
176
|
+
immediates, frame offsets and branch targets are masked away before the
|
|
177
|
+
comparison, so equal scores are not byte equality and different scores are
|
|
178
|
+
not a semantic difference.
|
|
179
|
+
|
|
180
|
+
Attributes:
|
|
181
|
+
score: ``sum(min) / sum(max)`` of the two token multisets, in ``[0, 1]``.
|
|
182
|
+
shared: Number of token occurrences both sides share (``sum(min)``).
|
|
183
|
+
left_total: Number of token occurrences in the left region.
|
|
184
|
+
right_total: Number of token occurrences in the right region.
|
|
185
|
+
evidence: How the score was produced, including the shape caveat and the
|
|
186
|
+
applied bounds. Its ``address`` is the *left* region's start.
|
|
187
|
+
warnings: Human-readable notes about the measurement -- for example that
|
|
188
|
+
one side was truncated to the instruction budget, so the score
|
|
189
|
+
compares prefixes. Empty when neither side was cut short.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
score: float
|
|
193
|
+
shared: int
|
|
194
|
+
left_total: int
|
|
195
|
+
right_total: int
|
|
196
|
+
evidence: Evidence
|
|
197
|
+
warnings: tuple[str, ...] = ()
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def union(self) -> int:
|
|
201
|
+
"""Number of token occurrences in the union (``sum(max)``)."""
|
|
202
|
+
return self.left_total + self.right_total - self.shared
|
|
203
|
+
|
|
204
|
+
def to_dict(self) -> dict[str, Any]:
|
|
205
|
+
"""Serialize the similarity for the JSON report (``score`` to 4 decimals)."""
|
|
206
|
+
return {
|
|
207
|
+
"score": round(self.score, _SCORE_DECIMALS),
|
|
208
|
+
"shared": self.shared,
|
|
209
|
+
"left_total": self.left_total,
|
|
210
|
+
"right_total": self.right_total,
|
|
211
|
+
"union": self.union,
|
|
212
|
+
"warnings": list(self.warnings),
|
|
213
|
+
"evidence": self.evidence.to_dict(),
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def normalize_instruction(instruction: IRInstruction) -> str:
|
|
218
|
+
"""Render one instruction with every numeric literal masked to ``#``.
|
|
219
|
+
|
|
220
|
+
The mnemonic is lower-cased and kept verbatim (it is the instruction's
|
|
221
|
+
identity); only the operand text is masked, so ``mov eax, 0x2a`` and
|
|
222
|
+
``mov eax, 0x63`` both normalize to ``"mov eax, #"``. Whitespace inside the
|
|
223
|
+
operands is collapsed, which makes the result independent of the
|
|
224
|
+
disassembler's spacing.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
instruction: Decoded instruction to normalize.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
``"<mnemonic> <masked operands>"``, or just the mnemonic when the
|
|
231
|
+
instruction takes no operands.
|
|
232
|
+
"""
|
|
233
|
+
mnemonic = (instruction.mnemonic or "").strip().lower()
|
|
234
|
+
masked = _HEX_LITERAL.sub("#", instruction.operands or "")
|
|
235
|
+
masked = _BARE_NUMBER.sub("#", masked)
|
|
236
|
+
masked = _REPEATED_MASK.sub("#", masked)
|
|
237
|
+
masked = " ".join(masked.split())
|
|
238
|
+
return f"{mnemonic} {masked}".strip()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass(frozen=True)
|
|
242
|
+
class _Sweep:
|
|
243
|
+
"""Outcome of one bounded linear sweep (internal to this module).
|
|
244
|
+
|
|
245
|
+
Attributes:
|
|
246
|
+
address: Requested start address of the sweep.
|
|
247
|
+
tokens: Normalized instruction tokens in decoding order.
|
|
248
|
+
consumed_bytes: Bytes the decoder consumed.
|
|
249
|
+
buffer_bytes: Bytes read from the file for the sweep.
|
|
250
|
+
truncated: ``True`` when the sweep stopped before the end of the
|
|
251
|
+
available bytes -- the instruction budget was reached, the section's
|
|
252
|
+
file-backed bytes ran out, or trailing bytes could not be decoded.
|
|
253
|
+
max_instructions: Instruction budget that was applied.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
address: int
|
|
257
|
+
tokens: tuple[str, ...]
|
|
258
|
+
consumed_bytes: int
|
|
259
|
+
buffer_bytes: int
|
|
260
|
+
truncated: bool
|
|
261
|
+
max_instructions: int
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _validate_bounds(address: int, max_instructions: int) -> None:
|
|
265
|
+
"""Reject an invalid address or instruction budget.
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
AnalysisError: When ``address`` is not a non-negative integer or
|
|
269
|
+
``max_instructions`` is outside
|
|
270
|
+
``MIN_MAX_INSTRUCTIONS`` .. ``MAX_MAX_INSTRUCTIONS``.
|
|
271
|
+
"""
|
|
272
|
+
if isinstance(address, bool) or not isinstance(address, int):
|
|
273
|
+
raise AnalysisError(f"address must be an integer, got {address!r}")
|
|
274
|
+
if address < 0:
|
|
275
|
+
raise AnalysisError(f"address must be non-negative, got {address}")
|
|
276
|
+
if isinstance(max_instructions, bool) or not isinstance(max_instructions, int):
|
|
277
|
+
raise AnalysisError(f"max_instructions must be an integer, got {max_instructions!r}")
|
|
278
|
+
if max_instructions < MIN_MAX_INSTRUCTIONS:
|
|
279
|
+
raise AnalysisError("max_instructions must be >= 1")
|
|
280
|
+
if max_instructions > MAX_MAX_INSTRUCTIONS:
|
|
281
|
+
raise AnalysisError(f"max_instructions too large (limit {MAX_MAX_INSTRUCTIONS})")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _sweep(
|
|
285
|
+
path: str | Path,
|
|
286
|
+
info: BinaryInfo,
|
|
287
|
+
address: int,
|
|
288
|
+
max_instructions: int,
|
|
289
|
+
) -> _Sweep:
|
|
290
|
+
"""Disassemble and normalize one bounded linear sweep at ``address``.
|
|
291
|
+
|
|
292
|
+
This is the only place a region is decoded, so :func:`normalized_tokens`,
|
|
293
|
+
:func:`function_fingerprint` and :func:`function_similarity` can never
|
|
294
|
+
disagree about which instructions a region contains or about the bounds that
|
|
295
|
+
were applied. At most ``max_instructions * 16`` file bytes are read and the
|
|
296
|
+
bytes are never mapped or executed.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
path: File the bytes are read from (read-only).
|
|
300
|
+
info: Loaded metadata used for address mapping and for choosing the
|
|
301
|
+
decoder.
|
|
302
|
+
address: Virtual address of the first instruction; must be inside an
|
|
303
|
+
executable section.
|
|
304
|
+
max_instructions: Instruction budget
|
|
305
|
+
(``MIN_MAX_INSTRUCTIONS`` .. ``MAX_MAX_INSTRUCTIONS``).
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
The normalized tokens of the region together with the applied bounds.
|
|
309
|
+
|
|
310
|
+
Raises:
|
|
311
|
+
AnalysisError: When ``address`` or ``max_instructions`` is invalid, when
|
|
312
|
+
``address`` is not inside an executable section, when the address has
|
|
313
|
+
no file bytes, when the architecture cannot be disassembled, or when
|
|
314
|
+
no instruction could be decoded.
|
|
315
|
+
"""
|
|
316
|
+
_validate_bounds(address, max_instructions)
|
|
317
|
+
|
|
318
|
+
section = info.section_for_va(address)
|
|
319
|
+
if section is None or not section.is_executable:
|
|
320
|
+
raise AnalysisError(f"address 0x{address:x} is not inside an executable section")
|
|
321
|
+
|
|
322
|
+
code = read_code(path, info, address, max_instructions * _MAX_INSTRUCTION_BYTES)
|
|
323
|
+
try:
|
|
324
|
+
disassembler = Disassembler(info.architecture, info.bitness, info.endianness)
|
|
325
|
+
result = disassembler.disassemble(
|
|
326
|
+
code,
|
|
327
|
+
base_address=address,
|
|
328
|
+
max_instructions=max_instructions,
|
|
329
|
+
)
|
|
330
|
+
except DisassemblyError as exc:
|
|
331
|
+
raise AnalysisError(f"cannot disassemble 0x{address:x}: {exc}") from exc
|
|
332
|
+
|
|
333
|
+
instructions = tuple(result.instructions)
|
|
334
|
+
if not instructions:
|
|
335
|
+
raise AnalysisError(f"no instruction decoded at 0x{address:x}")
|
|
336
|
+
|
|
337
|
+
tokens = tuple(normalize_instruction(instruction) for instruction in instructions)
|
|
338
|
+
truncated = (
|
|
339
|
+
bool(result.truncated)
|
|
340
|
+
or len(tokens) >= max_instructions
|
|
341
|
+
or result.consumed_bytes < len(code)
|
|
342
|
+
)
|
|
343
|
+
return _Sweep(
|
|
344
|
+
address=address,
|
|
345
|
+
tokens=tokens,
|
|
346
|
+
consumed_bytes=result.consumed_bytes,
|
|
347
|
+
buffer_bytes=len(code),
|
|
348
|
+
truncated=truncated,
|
|
349
|
+
max_instructions=max_instructions,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def normalized_tokens(
|
|
354
|
+
path: str | Path,
|
|
355
|
+
info: BinaryInfo,
|
|
356
|
+
address: int,
|
|
357
|
+
max_instructions: int = DEFAULT_MAX_INSTRUCTIONS,
|
|
358
|
+
) -> tuple[str, ...]:
|
|
359
|
+
"""Return the normalized instruction tokens of the region at ``address``.
|
|
360
|
+
|
|
361
|
+
The tokens are exactly the strings :func:`function_fingerprint` hashes, so
|
|
362
|
+
``sha256("\\n".join(normalized_tokens(...)))`` reproduces the digest. They are
|
|
363
|
+
reusable on their own for multiset comparisons (see
|
|
364
|
+
:func:`function_similarity`) or for reporting which instructions two
|
|
365
|
+
regions have in common.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
path: File the bytes are read from (read-only; never executed).
|
|
369
|
+
info: Loaded metadata used for address mapping and for choosing the
|
|
370
|
+
decoder.
|
|
371
|
+
address: Virtual address of the first instruction; must be inside an
|
|
372
|
+
executable section.
|
|
373
|
+
max_instructions: Instruction budget
|
|
374
|
+
(``MIN_MAX_INSTRUCTIONS`` .. ``MAX_MAX_INSTRUCTIONS``).
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
The tokens in decoding order -- never empty, because a region without a
|
|
378
|
+
decodable instruction raises instead of returning ``()``.
|
|
379
|
+
|
|
380
|
+
Raises:
|
|
381
|
+
AnalysisError: When ``address`` or ``max_instructions`` is invalid, when
|
|
382
|
+
``address`` is not inside an executable section, when the address has
|
|
383
|
+
no file bytes, when the architecture cannot be disassembled, or when
|
|
384
|
+
no instruction could be decoded.
|
|
385
|
+
"""
|
|
386
|
+
return _sweep(path, info, address, max_instructions).tokens
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def function_fingerprint(
|
|
390
|
+
path: str | Path,
|
|
391
|
+
info: BinaryInfo,
|
|
392
|
+
address: int,
|
|
393
|
+
max_instructions: int = DEFAULT_MAX_INSTRUCTIONS,
|
|
394
|
+
) -> FunctionFingerprint:
|
|
395
|
+
"""Fingerprint the normalized instruction stream that starts at ``address``.
|
|
396
|
+
|
|
397
|
+
The sweep is a plain linear disassembly of the executable section's file
|
|
398
|
+
bytes starting at ``address``: it does not follow branches, so bytes that
|
|
399
|
+
belong to a neighbouring function are included when ``address`` is not a
|
|
400
|
+
real function entry, and a function longer than the budget is fingerprinted
|
|
401
|
+
from its prefix only (``truncated`` is then ``True``).
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
path: File the bytes are read from (read-only; never executed).
|
|
405
|
+
info: Loaded metadata used for address mapping and for choosing the
|
|
406
|
+
decoder.
|
|
407
|
+
address: Virtual address of the first instruction; must be inside an
|
|
408
|
+
executable section.
|
|
409
|
+
max_instructions: Instruction budget
|
|
410
|
+
(``MIN_MAX_INSTRUCTIONS`` .. ``MAX_MAX_INSTRUCTIONS``).
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
The :class:`FunctionFingerprint` for the region.
|
|
414
|
+
|
|
415
|
+
Raises:
|
|
416
|
+
AnalysisError: When ``address`` or ``max_instructions`` is invalid, when
|
|
417
|
+
``address`` is not inside an executable section, when the address
|
|
418
|
+
has no file bytes, when the architecture cannot be disassembled, or
|
|
419
|
+
when no instruction could be decoded.
|
|
420
|
+
|
|
421
|
+
Note:
|
|
422
|
+
An empty stream is an error rather than an empty digest: every
|
|
423
|
+
undecodable region would otherwise share one digest and appear to
|
|
424
|
+
"match" every other such region.
|
|
425
|
+
"""
|
|
426
|
+
sweep = _sweep(path, info, address, max_instructions)
|
|
427
|
+
digest = hashlib.sha256("\n".join(sweep.tokens).encode("utf-8")).hexdigest()
|
|
428
|
+
truncated = sweep.truncated
|
|
429
|
+
|
|
430
|
+
evidence = Evidence(
|
|
431
|
+
source=_EVIDENCE_SOURCE,
|
|
432
|
+
method=_EVIDENCE_METHOD,
|
|
433
|
+
confidence=Confidence.MEDIUM,
|
|
434
|
+
address=address,
|
|
435
|
+
detail=(
|
|
436
|
+
f"sha256 over {len(sweep.tokens)} normalized instruction(s)"
|
|
437
|
+
+ (" from a truncated sweep (prefix only)" if truncated else "")
|
|
438
|
+
+ f"; {_CAVEAT}"
|
|
439
|
+
),
|
|
440
|
+
extra={
|
|
441
|
+
"algorithm": DIGEST_ALGORITHM,
|
|
442
|
+
"max_instructions": max_instructions,
|
|
443
|
+
"instruction_count": len(sweep.tokens),
|
|
444
|
+
"consumed_bytes": sweep.consumed_bytes,
|
|
445
|
+
"truncated": truncated,
|
|
446
|
+
},
|
|
447
|
+
)
|
|
448
|
+
return FunctionFingerprint(
|
|
449
|
+
address=address,
|
|
450
|
+
digest=digest,
|
|
451
|
+
instruction_count=len(sweep.tokens),
|
|
452
|
+
truncated=truncated,
|
|
453
|
+
evidence=evidence,
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def function_similarity(
|
|
458
|
+
path_a: str | Path,
|
|
459
|
+
info_a: BinaryInfo,
|
|
460
|
+
address_a: int,
|
|
461
|
+
path_b: str | Path,
|
|
462
|
+
info_b: BinaryInfo,
|
|
463
|
+
address_b: int,
|
|
464
|
+
max_instructions: int = DEFAULT_MAX_INSTRUCTIONS,
|
|
465
|
+
) -> SimilarityResult:
|
|
466
|
+
"""Compare two function regions as multisets of normalized instructions.
|
|
467
|
+
|
|
468
|
+
Both regions are swept with the same bounded linear sweep as
|
|
469
|
+
:func:`normalized_tokens` and their tokens are counted with
|
|
470
|
+
:class:`collections.Counter`::
|
|
471
|
+
|
|
472
|
+
shared = sum((left & right).values()) # sum(min), shared occurrences
|
|
473
|
+
union = sum((left | right).values()) # sum(max), union size
|
|
474
|
+
score = shared / union
|
|
475
|
+
|
|
476
|
+
Multiplicity matters, so ``nop; nop; ret`` is only half similar to
|
|
477
|
+
``nop; ret``. Immediates, frame offsets and branch targets are masked before
|
|
478
|
+
counting, so a recompiled or relocated copy of a function usually scores
|
|
479
|
+
``1.0``. The score describes *shape*, not semantics -- see
|
|
480
|
+
:class:`SimilarityResult`.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
path_a: File of the left region (read-only; never executed).
|
|
484
|
+
info_a: Loaded metadata of ``path_a``.
|
|
485
|
+
address_a: Start address of the left region.
|
|
486
|
+
path_b: File of the right region (read-only; never executed).
|
|
487
|
+
info_b: Loaded metadata of ``path_b``.
|
|
488
|
+
address_b: Start address of the right region.
|
|
489
|
+
max_instructions: Per-side instruction budget
|
|
490
|
+
(``MIN_MAX_INSTRUCTIONS`` .. ``MAX_MAX_INSTRUCTIONS``).
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
The :class:`SimilarityResult`; ``1.0`` when both token multisets are
|
|
494
|
+
identical and ``0.0`` when they share no token.
|
|
495
|
+
|
|
496
|
+
Raises:
|
|
497
|
+
AnalysisError: When either side cannot be swept (invalid address or
|
|
498
|
+
budget, address outside an executable section, no file bytes,
|
|
499
|
+
undecodable architecture), or when either side yields no tokens.
|
|
500
|
+
|
|
501
|
+
Note:
|
|
502
|
+
Every side is bound by ``max_instructions``, so a full comparison
|
|
503
|
+
decodes at most ``2 * max_instructions`` instructions and reads at most
|
|
504
|
+
``2 * max_instructions * 16`` bytes. When a side was truncated, the
|
|
505
|
+
result carries a warning and the score compares prefixes only.
|
|
506
|
+
"""
|
|
507
|
+
left = _sweep(path_a, info_a, address_a, max_instructions)
|
|
508
|
+
right = _sweep(path_b, info_b, address_b, max_instructions)
|
|
509
|
+
if not left.tokens or not right.tokens:
|
|
510
|
+
# Defensive: _sweep already refuses an empty region, but a score against
|
|
511
|
+
# an empty multiset would be a meaningless 0.0 rather than an error.
|
|
512
|
+
side = "left" if not left.tokens else "right"
|
|
513
|
+
raise AnalysisError(f"{side} region produced no instruction tokens to compare")
|
|
514
|
+
|
|
515
|
+
left_counts = Counter(left.tokens)
|
|
516
|
+
right_counts = Counter(right.tokens)
|
|
517
|
+
shared = sum((left_counts & right_counts).values())
|
|
518
|
+
union = sum((left_counts | right_counts).values())
|
|
519
|
+
score = shared / union if union else 0.0
|
|
520
|
+
|
|
521
|
+
warnings: list[str] = []
|
|
522
|
+
if left.truncated:
|
|
523
|
+
warnings.append(
|
|
524
|
+
f"left region 0x{address_a:x} was truncated to {len(left.tokens)} "
|
|
525
|
+
"instruction(s); the score compares prefixes"
|
|
526
|
+
)
|
|
527
|
+
if right.truncated:
|
|
528
|
+
warnings.append(
|
|
529
|
+
f"right region 0x{address_b:x} was truncated to {len(right.tokens)} "
|
|
530
|
+
"instruction(s); the score compares prefixes"
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
evidence = Evidence(
|
|
534
|
+
source=_EVIDENCE_SIMILARITY_SOURCE,
|
|
535
|
+
method=_EVIDENCE_SIMILARITY_METHOD,
|
|
536
|
+
confidence=Confidence.MEDIUM,
|
|
537
|
+
address=address_a,
|
|
538
|
+
detail=(
|
|
539
|
+
f"{shared} shared of {union} token occurrence(s) in the union "
|
|
540
|
+
f"(left {len(left.tokens)}, right {len(right.tokens)}): "
|
|
541
|
+
f"score {score:.{_SCORE_DECIMALS}f}; {_SIMILARITY_CAVEAT}"
|
|
542
|
+
),
|
|
543
|
+
extra={
|
|
544
|
+
"left_address": f"0x{address_a:x}",
|
|
545
|
+
"right_address": f"0x{address_b:x}",
|
|
546
|
+
"shared": shared,
|
|
547
|
+
"left_total": len(left.tokens),
|
|
548
|
+
"right_total": len(right.tokens),
|
|
549
|
+
"union": union,
|
|
550
|
+
"max_instructions": max_instructions,
|
|
551
|
+
"left_truncated": left.truncated,
|
|
552
|
+
"right_truncated": right.truncated,
|
|
553
|
+
},
|
|
554
|
+
)
|
|
555
|
+
return SimilarityResult(
|
|
556
|
+
score=score,
|
|
557
|
+
shared=shared,
|
|
558
|
+
left_total=len(left.tokens),
|
|
559
|
+
right_total=len(right.tokens),
|
|
560
|
+
evidence=evidence,
|
|
561
|
+
warnings=tuple(warnings),
|
|
562
|
+
)
|