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,646 @@
|
|
|
1
|
+
"""Parse what a dynamic session produced into a bounded, attributable report.
|
|
2
|
+
|
|
3
|
+
:mod:`codevanta.analysis.dynamic` writes the script; this module reads what the
|
|
4
|
+
script printed. It never runs anything, so it is the half of dynamic analysis
|
|
5
|
+
that can be tested deterministically and replayed from a saved log.
|
|
6
|
+
|
|
7
|
+
Two providers, two formats
|
|
8
|
+
--------------------------
|
|
9
|
+
``gdb``
|
|
10
|
+
:func:`parse_gdb_trace` reads the ``HIT <address> <reg>=<value> ...`` lines the
|
|
11
|
+
generated script prints, the ``BACKTRACE`` marker that follows each one, and
|
|
12
|
+
the ``#N 0x... in ...`` frames ``bt`` emits. Everything else gdb says
|
|
13
|
+
(breakpoint notices, ``Inferior 1 exited``, ``DONE n hits``) is noise and is
|
|
14
|
+
skipped. A ``LIMIT`` line means the session stopped at its own hit ceiling, so
|
|
15
|
+
the report is marked ``truncated``.
|
|
16
|
+
``frida``
|
|
17
|
+
:func:`parse_frida_trace` reads the JSON-lines messages the generated hook
|
|
18
|
+
sends (``{"hit": N, "address": "0x...", "tid": ..., "args": [...]}``). A
|
|
19
|
+
malformed line is skipped with a warning instead of aborting the parse;
|
|
20
|
+
``{"event": "limit"}`` marks the report truncated and ``{"event": "error"}``
|
|
21
|
+
becomes a warning.
|
|
22
|
+
|
|
23
|
+
Mapping hits onto functions
|
|
24
|
+
---------------------------
|
|
25
|
+
When a function list (usually :func:`codevanta.core.functions.discover_functions`)
|
|
26
|
+
is supplied, every hit is attributed to the candidate that encloses its address:
|
|
27
|
+
the greatest candidate start ``<= address`` that either contains the address
|
|
28
|
+
within its known ``size`` or, when the size is unknown, still starts before the
|
|
29
|
+
next candidate. Addresses outside every candidate stay unattributed rather than
|
|
30
|
+
being guessed onto the nearest symbol. ``function_coverage`` counts hits per
|
|
31
|
+
attributed function, so a caller can see where a session actually went.
|
|
32
|
+
|
|
33
|
+
Bounds
|
|
34
|
+
------
|
|
35
|
+
A parse never reads more than :data:`MAX_TRACE_LINES` lines, keeps at most
|
|
36
|
+
:data:`MAX_TRACE_HITS` hits and at most :data:`MAX_BACKTRACE_FRAMES` frames per
|
|
37
|
+
hit; a cut is reported through ``truncated`` plus a warning, never silently.
|
|
38
|
+
Warnings are capped too, so a corrupt log cannot turn into unbounded memory. The
|
|
39
|
+
only exception raised is :class:`~codevanta.core.report.AnalysisError`, and only
|
|
40
|
+
for a caller mistake (an out-of-range ``max_lines``/``max_hits``).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import json
|
|
46
|
+
import re
|
|
47
|
+
from bisect import bisect_right
|
|
48
|
+
from collections.abc import Mapping, Sequence
|
|
49
|
+
from dataclasses import dataclass, field
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
from codevanta.analysis.dynamic import MAX_FRIDA_ARGS
|
|
53
|
+
from codevanta.core.functions import FunctionCandidate
|
|
54
|
+
from codevanta.core.loader import BinaryInfo, SectionInfo
|
|
55
|
+
from codevanta.core.report import AnalysisError
|
|
56
|
+
from codevanta.evidence.confidence import Confidence
|
|
57
|
+
from codevanta.evidence.models import Evidence
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"MAX_BACKTRACE_FRAMES",
|
|
61
|
+
"MAX_TRACE_HITS",
|
|
62
|
+
"MAX_TRACE_LINES",
|
|
63
|
+
"MAX_WARNINGS",
|
|
64
|
+
"TraceHit",
|
|
65
|
+
"TraceReport",
|
|
66
|
+
"parse_frida_trace",
|
|
67
|
+
"parse_gdb_trace",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
#: Hard ceiling on the lines one parse will read.
|
|
71
|
+
MAX_TRACE_LINES = 100_000
|
|
72
|
+
|
|
73
|
+
#: Hard ceiling on the hits one parse will keep.
|
|
74
|
+
MAX_TRACE_HITS = 100_000
|
|
75
|
+
|
|
76
|
+
#: Frames kept per hit, whatever the session printed.
|
|
77
|
+
MAX_BACKTRACE_FRAMES = 64
|
|
78
|
+
|
|
79
|
+
#: Warnings kept per report before the rest are summarised away.
|
|
80
|
+
MAX_WARNINGS = 50
|
|
81
|
+
|
|
82
|
+
#: Longest input snippet echoed back in a warning.
|
|
83
|
+
_SNIPPET = 120
|
|
84
|
+
|
|
85
|
+
#: ``HIT 0x401000 pc=0x401000 sp=0x7ffd...`` -- the line the gdb script prints.
|
|
86
|
+
_HIT_LINE = re.compile(
|
|
87
|
+
r"^HIT\s+(?P<address>0x[0-9a-fA-F]+)"
|
|
88
|
+
r"(?P<registers>(?:\s+[A-Za-z][A-Za-z0-9_]*=(?:0x[0-9a-fA-F]+|\d+))*)\s*$"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
#: ``name=value`` inside a ``HIT`` line's register snapshot.
|
|
92
|
+
_REGISTER = re.compile(r"([A-Za-z][A-Za-z0-9_]*)=(0x[0-9a-fA-F]+|\d+)")
|
|
93
|
+
|
|
94
|
+
#: A line that starts like a hit but does not parse as one.
|
|
95
|
+
_HIT_PREFIX = re.compile(r"^HIT\b")
|
|
96
|
+
|
|
97
|
+
#: The marker the gdb script prints before its ``bt`` frames.
|
|
98
|
+
_BACKTRACE_MARKER = re.compile(r"^BACKTRACE\b")
|
|
99
|
+
|
|
100
|
+
#: ``#3 0x0000000000401000 in ?? ()`` -- one gdb backtrace frame.
|
|
101
|
+
_FRAME_LINE = re.compile(r"^#\d+\s+0x(?P<address>[0-9a-fA-F]+)")
|
|
102
|
+
|
|
103
|
+
#: The line the gdb script prints when it stops at its hit ceiling.
|
|
104
|
+
_LIMIT_MARKER = re.compile(r"^LIMIT\b")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class TraceHit:
|
|
109
|
+
"""One recorded visit to one address.
|
|
110
|
+
|
|
111
|
+
Attributes:
|
|
112
|
+
index: Position of the hit in the session, as the provider numbered it
|
|
113
|
+
(gdb hits are numbered in parse order; Frida reports its own counter
|
|
114
|
+
and the parser falls back to parse order when that counter is absent).
|
|
115
|
+
kind: ``"breakpoint"`` for a gdb hit, ``"message"`` for a Frida message.
|
|
116
|
+
address: The address the session reported: a file virtual address for gdb
|
|
117
|
+
(its breakpoints are absolute) and a runtime address for Frida.
|
|
118
|
+
function: Start address of the function candidate that encloses
|
|
119
|
+
``address``, or ``None`` when no candidate does.
|
|
120
|
+
function_name: Name of that candidate, when the candidate has one.
|
|
121
|
+
registers: Register snapshot. gdb supplies the registers its script
|
|
122
|
+
printed; Frida supplies ``tid`` plus ``arg0``..``arg3`` from the
|
|
123
|
+
message, because its messages carry no register dump.
|
|
124
|
+
backtrace: Frame addresses in call order, innermost first; empty for
|
|
125
|
+
Frida, whose messages carry no stack.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
index: int
|
|
129
|
+
kind: str
|
|
130
|
+
address: int
|
|
131
|
+
function: int | None = None
|
|
132
|
+
function_name: str | None = None
|
|
133
|
+
registers: Mapping[str, int] = field(default_factory=dict)
|
|
134
|
+
backtrace: tuple[int, ...] = ()
|
|
135
|
+
|
|
136
|
+
def to_dict(self) -> dict[str, Any]:
|
|
137
|
+
"""Serialize the hit for the JSON report (hex addresses, int registers)."""
|
|
138
|
+
return {
|
|
139
|
+
"index": self.index,
|
|
140
|
+
"kind": self.kind,
|
|
141
|
+
"address": f"0x{self.address:x}",
|
|
142
|
+
"function": f"0x{self.function:x}" if self.function is not None else None,
|
|
143
|
+
"function_name": self.function_name,
|
|
144
|
+
"registers": dict(self.registers),
|
|
145
|
+
"backtrace": [f"0x{address:x}" for address in self.backtrace],
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class TraceReport:
|
|
151
|
+
"""Everything one session produced, honestly bounded.
|
|
152
|
+
|
|
153
|
+
Attributes:
|
|
154
|
+
provider: ``"gdb"`` or ``"frida"``.
|
|
155
|
+
hits: The hits that were kept, in session order.
|
|
156
|
+
total_hits: Hits the parser saw. It exceeds ``len(hits)`` only when a
|
|
157
|
+
``max_hits`` bound stopped the parser from keeping the tail.
|
|
158
|
+
truncated: ``True`` when the input was cut -- by ``max_lines``, by
|
|
159
|
+
``max_hits``, or by the session's own ``LIMIT``/``limit`` marker.
|
|
160
|
+
unique_addresses: Distinct hit addresses, ascending.
|
|
161
|
+
function_coverage: Hits per attributed function start address. Functions
|
|
162
|
+
with no hits are absent, and unattributed hits are counted nowhere.
|
|
163
|
+
warnings: Everything that went wrong: malformed lines, foreign messages,
|
|
164
|
+
limits that cut the session.
|
|
165
|
+
evidence: How the report was produced, including the budgets that were in
|
|
166
|
+
effect and, when ``info`` was supplied, the binary it belongs to.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
provider: str
|
|
170
|
+
hits: tuple[TraceHit, ...]
|
|
171
|
+
total_hits: int
|
|
172
|
+
truncated: bool
|
|
173
|
+
unique_addresses: tuple[int, ...]
|
|
174
|
+
function_coverage: Mapping[int, int]
|
|
175
|
+
warnings: tuple[str, ...]
|
|
176
|
+
evidence: Evidence
|
|
177
|
+
|
|
178
|
+
def to_dict(self) -> dict[str, Any]:
|
|
179
|
+
"""Serialize the report for the JSON report (JSON-safe values only)."""
|
|
180
|
+
return {
|
|
181
|
+
"provider": self.provider,
|
|
182
|
+
"hits": [hit.to_dict() for hit in self.hits],
|
|
183
|
+
"total_hits": self.total_hits,
|
|
184
|
+
"truncated": self.truncated,
|
|
185
|
+
"unique_addresses": [f"0x{address:x}" for address in self.unique_addresses],
|
|
186
|
+
"function_coverage": {
|
|
187
|
+
f"0x{address:x}": count for address, count in self.function_coverage.items()
|
|
188
|
+
},
|
|
189
|
+
"warnings": list(self.warnings),
|
|
190
|
+
"evidence": self.evidence.to_dict(),
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# --------------------------------------------------------------------------- #
|
|
195
|
+
# Shared helpers
|
|
196
|
+
# --------------------------------------------------------------------------- #
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class _FunctionIndex:
|
|
200
|
+
"""Bisect-based lookup from an address to the candidate that encloses it.
|
|
201
|
+
|
|
202
|
+
A candidate's extent comes from its symbol size when the loader knows it, and
|
|
203
|
+
otherwise from the executable section that contains it: a function cannot run
|
|
204
|
+
past the end of its own section. When neither is available the extent is
|
|
205
|
+
unknown, and an address above the start is reported as unattributed instead of
|
|
206
|
+
being guessed onto the function.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def __init__(
|
|
210
|
+
self,
|
|
211
|
+
functions: Sequence[FunctionCandidate],
|
|
212
|
+
info: BinaryInfo | None = None,
|
|
213
|
+
) -> None:
|
|
214
|
+
self._starts: list[int] = []
|
|
215
|
+
self._ends: list[int | None] = []
|
|
216
|
+
self._names: list[str | None] = []
|
|
217
|
+
sections = (
|
|
218
|
+
[section for section in getattr(info, "sections", ()) if section.is_executable]
|
|
219
|
+
if info is not None
|
|
220
|
+
else []
|
|
221
|
+
)
|
|
222
|
+
for candidate in sorted(functions, key=lambda item: item.address):
|
|
223
|
+
if candidate.address <= 0:
|
|
224
|
+
continue
|
|
225
|
+
if self._starts and self._starts[-1] == candidate.address:
|
|
226
|
+
continue
|
|
227
|
+
self._starts.append(candidate.address)
|
|
228
|
+
self._ends.append(_candidate_end(candidate, sections))
|
|
229
|
+
self._names.append(candidate.name)
|
|
230
|
+
|
|
231
|
+
def locate(self, address: int) -> tuple[int, str | None] | None:
|
|
232
|
+
"""Return ``(start, name)`` for the enclosing candidate, or ``None``."""
|
|
233
|
+
position = bisect_right(self._starts, address) - 1
|
|
234
|
+
if position < 0:
|
|
235
|
+
return None
|
|
236
|
+
start = self._starts[position]
|
|
237
|
+
end = self._ends[position]
|
|
238
|
+
if end is not None:
|
|
239
|
+
inside = address < end
|
|
240
|
+
elif position + 1 < len(self._starts):
|
|
241
|
+
# No size and no section: the next candidate's start is the boundary.
|
|
242
|
+
inside = address < self._starts[position + 1]
|
|
243
|
+
else:
|
|
244
|
+
inside = False
|
|
245
|
+
if not inside:
|
|
246
|
+
return None
|
|
247
|
+
return start, self._names[position]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _candidate_end(candidate: FunctionCandidate, sections: Sequence[SectionInfo]) -> int | None:
|
|
251
|
+
"""Return the last address a candidate can occupy, or ``None`` when unknown."""
|
|
252
|
+
size = getattr(candidate, "size", None)
|
|
253
|
+
if isinstance(size, int) and size > 0:
|
|
254
|
+
return candidate.address + size
|
|
255
|
+
for section in sections:
|
|
256
|
+
start = section.virtual_address
|
|
257
|
+
end = start + section.size
|
|
258
|
+
if start <= candidate.address < end:
|
|
259
|
+
return end
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _snippet(line: str) -> str:
|
|
264
|
+
"""Return a quoted, length-bounded excerpt of ``line`` for a warning."""
|
|
265
|
+
text = line if len(line) <= _SNIPPET else line[: _SNIPPET - 3] + "..."
|
|
266
|
+
return repr(text)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _warn(warnings: list[str], message: str) -> None:
|
|
270
|
+
"""Append ``message`` to ``warnings``, capping the list at :data:`MAX_WARNINGS`."""
|
|
271
|
+
if len(warnings) < MAX_WARNINGS:
|
|
272
|
+
warnings.append(message)
|
|
273
|
+
elif len(warnings) == MAX_WARNINGS:
|
|
274
|
+
warnings.append(f"further warnings suppressed after {MAX_WARNINGS}")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _as_int(value: Any) -> int | None:
|
|
278
|
+
"""Return ``value`` as an int, accepting ``int`` and ``"0x1f"``-style text."""
|
|
279
|
+
if isinstance(value, bool):
|
|
280
|
+
return None
|
|
281
|
+
if isinstance(value, int):
|
|
282
|
+
return value
|
|
283
|
+
if isinstance(value, str):
|
|
284
|
+
text = value.strip()
|
|
285
|
+
if not text:
|
|
286
|
+
return None
|
|
287
|
+
try:
|
|
288
|
+
return int(text, 0)
|
|
289
|
+
except ValueError:
|
|
290
|
+
return None
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _require_max_lines(max_lines: int) -> int:
|
|
295
|
+
"""Return ``max_lines`` after checking it is a sane positive bound."""
|
|
296
|
+
if isinstance(max_lines, bool) or not isinstance(max_lines, int):
|
|
297
|
+
raise AnalysisError(
|
|
298
|
+
f"max_lines must be an int, got {type(max_lines).__name__}: {max_lines!r}"
|
|
299
|
+
)
|
|
300
|
+
if max_lines < 1:
|
|
301
|
+
raise AnalysisError(f"max_lines must be >= 1, got {max_lines}")
|
|
302
|
+
if max_lines > MAX_TRACE_LINES:
|
|
303
|
+
raise AnalysisError(f"max_lines must be <= {MAX_TRACE_LINES}, got {max_lines}")
|
|
304
|
+
return max_lines
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _require_max_hits(max_hits: int | None) -> int | None:
|
|
308
|
+
"""Return ``max_hits`` after checking it is ``None`` or a sane positive bound."""
|
|
309
|
+
if max_hits is None:
|
|
310
|
+
return None
|
|
311
|
+
if isinstance(max_hits, bool) or not isinstance(max_hits, int):
|
|
312
|
+
raise AnalysisError(
|
|
313
|
+
f"max_hits must be an int or None, got {type(max_hits).__name__}: {max_hits!r}"
|
|
314
|
+
)
|
|
315
|
+
if max_hits < 1:
|
|
316
|
+
raise AnalysisError(f"max_hits must be >= 1, got {max_hits}")
|
|
317
|
+
if max_hits > MAX_TRACE_HITS:
|
|
318
|
+
raise AnalysisError(f"max_hits must be <= {MAX_TRACE_HITS}, got {max_hits}")
|
|
319
|
+
return max_hits
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _make_hit(draft: dict[str, Any], functions: _FunctionIndex, kind: str) -> TraceHit:
|
|
323
|
+
"""Freeze one parsed draft into a :class:`TraceHit`, attributing its address."""
|
|
324
|
+
address: int = draft["address"]
|
|
325
|
+
located = functions.locate(address)
|
|
326
|
+
start, name = located if located is not None else (None, None)
|
|
327
|
+
return TraceHit(
|
|
328
|
+
index=int(draft["index"]),
|
|
329
|
+
kind=kind,
|
|
330
|
+
address=address,
|
|
331
|
+
function=start,
|
|
332
|
+
function_name=name,
|
|
333
|
+
registers=dict(draft["registers"]),
|
|
334
|
+
backtrace=tuple(draft["backtrace"]),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _build_report(
|
|
339
|
+
provider: str,
|
|
340
|
+
hits: list[TraceHit],
|
|
341
|
+
*,
|
|
342
|
+
total_hits: int,
|
|
343
|
+
truncated: bool,
|
|
344
|
+
warnings: list[str],
|
|
345
|
+
info: BinaryInfo | None,
|
|
346
|
+
lines_scanned: int,
|
|
347
|
+
max_lines: int,
|
|
348
|
+
) -> TraceReport:
|
|
349
|
+
"""Assemble the report, counting coverage and building the evidence record."""
|
|
350
|
+
coverage: dict[int, int] = {}
|
|
351
|
+
for hit in hits:
|
|
352
|
+
if hit.function is None:
|
|
353
|
+
continue
|
|
354
|
+
coverage[hit.function] = coverage.get(hit.function, 0) + 1
|
|
355
|
+
extra: dict[str, Any] = {
|
|
356
|
+
"provider": provider,
|
|
357
|
+
"lines_scanned": lines_scanned,
|
|
358
|
+
"line_limit": max_lines,
|
|
359
|
+
"truncated": truncated,
|
|
360
|
+
"hits": total_hits,
|
|
361
|
+
"kept_hits": len(hits),
|
|
362
|
+
"warning_count": len(warnings),
|
|
363
|
+
}
|
|
364
|
+
if info is not None:
|
|
365
|
+
extra["format"] = info.format
|
|
366
|
+
extra["architecture"] = info.architecture
|
|
367
|
+
extra["image_base"] = f"0x{info.image_base:x}"
|
|
368
|
+
evidence = Evidence(
|
|
369
|
+
source=f"{provider}.trace",
|
|
370
|
+
method=f"parse_{provider}_trace",
|
|
371
|
+
confidence=Confidence.HIGH if warnings else Confidence.CERTAIN,
|
|
372
|
+
detail=f"parsed {total_hits} hit(s) from {lines_scanned} line(s)",
|
|
373
|
+
extra=extra,
|
|
374
|
+
)
|
|
375
|
+
return TraceReport(
|
|
376
|
+
provider=provider,
|
|
377
|
+
hits=tuple(hits),
|
|
378
|
+
total_hits=total_hits,
|
|
379
|
+
truncated=truncated,
|
|
380
|
+
unique_addresses=tuple(sorted({hit.address for hit in hits})),
|
|
381
|
+
function_coverage=coverage,
|
|
382
|
+
warnings=tuple(warnings),
|
|
383
|
+
evidence=evidence,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# --------------------------------------------------------------------------- #
|
|
388
|
+
# gdb traces
|
|
389
|
+
# --------------------------------------------------------------------------- #
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def parse_gdb_trace(
|
|
393
|
+
text: str,
|
|
394
|
+
*,
|
|
395
|
+
info: BinaryInfo | None = None,
|
|
396
|
+
functions: Sequence[FunctionCandidate] = (),
|
|
397
|
+
max_lines: int = MAX_TRACE_LINES,
|
|
398
|
+
max_hits: int | None = None,
|
|
399
|
+
) -> TraceReport:
|
|
400
|
+
"""Parse the output of a generated gdb session script.
|
|
401
|
+
|
|
402
|
+
One ``HIT 0x... name=0x...`` line becomes one :class:`TraceHit` of kind
|
|
403
|
+
``"breakpoint"``; the frames printed after the following ``BACKTRACE`` marker
|
|
404
|
+
become its ``backtrace``. Everything else is ignored, except a ``LIMIT`` line,
|
|
405
|
+
which marks the report ``truncated`` because the session stopped at its own
|
|
406
|
+
ceiling. A line that starts with ``HIT`` but does not parse produces a warning
|
|
407
|
+
and never raises.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
text: The captured gdb output.
|
|
411
|
+
info: Binary metadata for context. It never changes which hits are
|
|
412
|
+
reported; it is recorded in the evidence so the report can be tied to
|
|
413
|
+
the file the session ran against.
|
|
414
|
+
functions: Candidates used to attribute each hit to a function.
|
|
415
|
+
max_lines: Line budget, ``1 <= max_lines <=`` :data:`MAX_TRACE_LINES`.
|
|
416
|
+
max_hits: Hit budget, or ``None``. Hits beyond it are counted in
|
|
417
|
+
``total_hits`` but not kept, and the report is ``truncated``.
|
|
418
|
+
|
|
419
|
+
Raises:
|
|
420
|
+
AnalysisError: ``max_lines`` or ``max_hits`` is out of range.
|
|
421
|
+
"""
|
|
422
|
+
line_limit = _require_max_lines(max_lines)
|
|
423
|
+
hit_limit = _require_max_hits(max_hits)
|
|
424
|
+
index = _FunctionIndex(functions, info)
|
|
425
|
+
|
|
426
|
+
lines = text.splitlines()
|
|
427
|
+
warnings: list[str] = []
|
|
428
|
+
truncated = False
|
|
429
|
+
if len(lines) > line_limit:
|
|
430
|
+
lines = lines[:line_limit]
|
|
431
|
+
truncated = True
|
|
432
|
+
_warn(warnings, f"input truncated to the first {line_limit} line(s)")
|
|
433
|
+
|
|
434
|
+
hits: list[TraceHit] = []
|
|
435
|
+
total_hits = 0
|
|
436
|
+
draft: dict[str, Any] | None = None
|
|
437
|
+
in_backtrace = False
|
|
438
|
+
hit_limit_reported = False
|
|
439
|
+
|
|
440
|
+
for number, line in enumerate(lines, start=1):
|
|
441
|
+
stripped = line.strip()
|
|
442
|
+
if not stripped:
|
|
443
|
+
continue
|
|
444
|
+
match = _HIT_LINE.match(stripped)
|
|
445
|
+
if match is not None:
|
|
446
|
+
if draft is not None:
|
|
447
|
+
hits.append(_make_hit(draft, index, "breakpoint"))
|
|
448
|
+
draft = None
|
|
449
|
+
total_hits += 1
|
|
450
|
+
if hit_limit is not None and len(hits) >= hit_limit:
|
|
451
|
+
if not hit_limit_reported:
|
|
452
|
+
truncated = True
|
|
453
|
+
hit_limit_reported = True
|
|
454
|
+
_warn(
|
|
455
|
+
warnings,
|
|
456
|
+
f"hit limit {hit_limit} reached; further hits are counted but "
|
|
457
|
+
"not kept",
|
|
458
|
+
)
|
|
459
|
+
continue
|
|
460
|
+
draft = {
|
|
461
|
+
"index": total_hits,
|
|
462
|
+
"address": int(match.group("address"), 16),
|
|
463
|
+
"registers": {
|
|
464
|
+
name: int(value, 0)
|
|
465
|
+
for name, value in _REGISTER.findall(match.group("registers"))
|
|
466
|
+
},
|
|
467
|
+
"backtrace": [],
|
|
468
|
+
}
|
|
469
|
+
in_backtrace = False
|
|
470
|
+
continue
|
|
471
|
+
if _HIT_PREFIX.match(stripped):
|
|
472
|
+
_warn(warnings, f"line {number}: unparseable HIT line {_snippet(stripped)}")
|
|
473
|
+
continue
|
|
474
|
+
if _LIMIT_MARKER.match(stripped):
|
|
475
|
+
truncated = True
|
|
476
|
+
_warn(
|
|
477
|
+
warnings,
|
|
478
|
+
f"line {number}: the session stopped at its hit limit ({_snippet(stripped)})",
|
|
479
|
+
)
|
|
480
|
+
continue
|
|
481
|
+
if _BACKTRACE_MARKER.match(stripped):
|
|
482
|
+
in_backtrace = True
|
|
483
|
+
continue
|
|
484
|
+
if in_backtrace:
|
|
485
|
+
frame = _FRAME_LINE.match(stripped)
|
|
486
|
+
if frame is not None:
|
|
487
|
+
if draft is not None and len(draft["backtrace"]) < MAX_BACKTRACE_FRAMES:
|
|
488
|
+
draft["backtrace"].append(int(frame.group("address"), 16))
|
|
489
|
+
continue
|
|
490
|
+
# Anything else is gdb noise: breakpoint notices, exit messages, "DONE".
|
|
491
|
+
|
|
492
|
+
if draft is not None:
|
|
493
|
+
hits.append(_make_hit(draft, index, "breakpoint"))
|
|
494
|
+
|
|
495
|
+
return _build_report(
|
|
496
|
+
"gdb",
|
|
497
|
+
hits,
|
|
498
|
+
total_hits=total_hits,
|
|
499
|
+
truncated=truncated,
|
|
500
|
+
warnings=warnings,
|
|
501
|
+
info=info,
|
|
502
|
+
lines_scanned=len(lines),
|
|
503
|
+
max_lines=line_limit,
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# --------------------------------------------------------------------------- #
|
|
508
|
+
# frida traces
|
|
509
|
+
# --------------------------------------------------------------------------- #
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def parse_frida_trace(
|
|
513
|
+
text: str,
|
|
514
|
+
*,
|
|
515
|
+
info: BinaryInfo | None = None,
|
|
516
|
+
functions: Sequence[FunctionCandidate] = (),
|
|
517
|
+
max_lines: int = MAX_TRACE_LINES,
|
|
518
|
+
max_hits: int | None = None,
|
|
519
|
+
) -> TraceReport:
|
|
520
|
+
"""Parse the JSON-lines messages a generated Frida script sends.
|
|
521
|
+
|
|
522
|
+
One ``{"hit": N, "address": "0x...", "tid": ..., "args": [...]}`` message
|
|
523
|
+
becomes one :class:`TraceHit` of kind ``"message"``, with ``tid`` and
|
|
524
|
+
``arg0``..``arg3`` in ``registers`` (Frida messages carry no register dump and
|
|
525
|
+
no stack). ``{"event": "limit"}`` marks the report ``truncated``;
|
|
526
|
+
``{"event": "error"}`` and any other unrecognised message become warnings. A
|
|
527
|
+
malformed line is skipped with a warning instead of aborting the parse.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
text: The captured frida output, one JSON message per line.
|
|
531
|
+
info: Binary metadata for context; recorded in the evidence only.
|
|
532
|
+
functions: Candidates used to attribute each hit to a function.
|
|
533
|
+
max_lines: Line budget, ``1 <= max_lines <=`` :data:`MAX_TRACE_LINES`.
|
|
534
|
+
max_hits: Hit budget, or ``None``. Hits beyond it are counted in
|
|
535
|
+
``total_hits`` but not kept, and the report is ``truncated``.
|
|
536
|
+
|
|
537
|
+
Raises:
|
|
538
|
+
AnalysisError: ``max_lines`` or ``max_hits`` is out of range.
|
|
539
|
+
"""
|
|
540
|
+
line_limit = _require_max_lines(max_lines)
|
|
541
|
+
hit_limit = _require_max_hits(max_hits)
|
|
542
|
+
index = _FunctionIndex(functions, info)
|
|
543
|
+
|
|
544
|
+
lines = text.splitlines()
|
|
545
|
+
warnings: list[str] = []
|
|
546
|
+
truncated = False
|
|
547
|
+
if len(lines) > line_limit:
|
|
548
|
+
lines = lines[:line_limit]
|
|
549
|
+
truncated = True
|
|
550
|
+
_warn(warnings, f"input truncated to the first {line_limit} line(s)")
|
|
551
|
+
|
|
552
|
+
hits: list[TraceHit] = []
|
|
553
|
+
total_hits = 0
|
|
554
|
+
hit_limit_reported = False
|
|
555
|
+
|
|
556
|
+
for number, line in enumerate(lines, start=1):
|
|
557
|
+
stripped = line.strip()
|
|
558
|
+
if not stripped:
|
|
559
|
+
continue
|
|
560
|
+
try:
|
|
561
|
+
payload = json.loads(stripped)
|
|
562
|
+
except ValueError:
|
|
563
|
+
_warn(warnings, f"line {number}: malformed JSON message {_snippet(stripped)}")
|
|
564
|
+
continue
|
|
565
|
+
if not isinstance(payload, dict):
|
|
566
|
+
_warn(
|
|
567
|
+
warnings,
|
|
568
|
+
f"line {number}: message is not a JSON object {_snippet(stripped)}",
|
|
569
|
+
)
|
|
570
|
+
continue
|
|
571
|
+
if "hit" not in payload:
|
|
572
|
+
event = payload.get("event")
|
|
573
|
+
if event == "limit":
|
|
574
|
+
truncated = True
|
|
575
|
+
_warn(warnings, f"line {number}: the session detached its hooks at the hit limit")
|
|
576
|
+
elif event == "error":
|
|
577
|
+
message = payload.get("message") or "no message"
|
|
578
|
+
_warn(warnings, f"line {number}: provider error: {message}")
|
|
579
|
+
else:
|
|
580
|
+
_warn(
|
|
581
|
+
warnings,
|
|
582
|
+
f"line {number}: message without a hit or a known event "
|
|
583
|
+
f"{_snippet(stripped)}",
|
|
584
|
+
)
|
|
585
|
+
continue
|
|
586
|
+
address = _as_int(payload.get("address"))
|
|
587
|
+
if address is None:
|
|
588
|
+
_warn(
|
|
589
|
+
warnings,
|
|
590
|
+
f"line {number}: hit without a usable address {_snippet(stripped)}",
|
|
591
|
+
)
|
|
592
|
+
continue
|
|
593
|
+
total_hits += 1
|
|
594
|
+
if hit_limit is not None and len(hits) >= hit_limit:
|
|
595
|
+
if not hit_limit_reported:
|
|
596
|
+
truncated = True
|
|
597
|
+
hit_limit_reported = True
|
|
598
|
+
_warn(
|
|
599
|
+
warnings,
|
|
600
|
+
f"hit limit {hit_limit} reached; further hits are counted but not kept",
|
|
601
|
+
)
|
|
602
|
+
continue
|
|
603
|
+
|
|
604
|
+
registers: dict[str, int] = {}
|
|
605
|
+
tid = _as_int(payload.get("tid"))
|
|
606
|
+
if tid is not None:
|
|
607
|
+
registers["tid"] = tid
|
|
608
|
+
arguments = payload.get("args")
|
|
609
|
+
if isinstance(arguments, list):
|
|
610
|
+
for position, value in enumerate(arguments[:MAX_FRIDA_ARGS]):
|
|
611
|
+
parsed = _as_int(value)
|
|
612
|
+
if parsed is None:
|
|
613
|
+
_warn(
|
|
614
|
+
warnings,
|
|
615
|
+
f"line {number}: argument {position} is not a number "
|
|
616
|
+
f"({value!r}); skipped",
|
|
617
|
+
)
|
|
618
|
+
continue
|
|
619
|
+
registers[f"arg{position}"] = parsed
|
|
620
|
+
elif arguments is not None:
|
|
621
|
+
_warn(warnings, f"line {number}: 'args' is not a list; ignored")
|
|
622
|
+
|
|
623
|
+
reported = _as_int(payload.get("hit"))
|
|
624
|
+
hits.append(
|
|
625
|
+
_make_hit(
|
|
626
|
+
{
|
|
627
|
+
"index": reported if reported is not None else total_hits,
|
|
628
|
+
"address": address,
|
|
629
|
+
"registers": registers,
|
|
630
|
+
"backtrace": [],
|
|
631
|
+
},
|
|
632
|
+
index,
|
|
633
|
+
"message",
|
|
634
|
+
)
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
return _build_report(
|
|
638
|
+
"frida",
|
|
639
|
+
hits,
|
|
640
|
+
total_hits=total_hits,
|
|
641
|
+
truncated=truncated,
|
|
642
|
+
warnings=warnings,
|
|
643
|
+
info=info,
|
|
644
|
+
lines_scanned=len(lines),
|
|
645
|
+
max_lines=line_limit,
|
|
646
|
+
)
|