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,1214 @@
|
|
|
1
|
+
"""Heuristic struct-field recovery from memory operands (phase 2h).
|
|
2
|
+
|
|
3
|
+
:func:`suggest_struct` disassembles a *bounded* window of a function and reads
|
|
4
|
+
the memory operands of every decoded instruction:
|
|
5
|
+
|
|
6
|
+
1. Only three addressing shapes are understood: ``[reg]``, ``[reg + 0xNN]`` and
|
|
7
|
+
``[reg - 0xNN]`` (a decimal displacement -- ``[rbp - 8]`` -- is accepted
|
|
8
|
+
exactly like a hexadecimal one). Anything with an index register, a scale or
|
|
9
|
+
a segment prefix (``[rdi + rcx*4]``, ``fs:[rax]``) is skipped rather than
|
|
10
|
+
guessed.
|
|
11
|
+
2. The field **width** comes from the Intel size keyword on the operand
|
|
12
|
+
(``byte``/``word``/``dword``/``qword`` = 1/2/4/8), falling back to the
|
|
13
|
+
pointer width of the binary when the operand carries no keyword.
|
|
14
|
+
3. The access **direction** is a write when the memory operand is the *first*
|
|
15
|
+
operand, and a read otherwise. Instructions that only dereference a memory
|
|
16
|
+
operand -- ``cmp``, ``cmpsb``/``cmpsw``/``cmpsd``/``cmpsq``, ``test``,
|
|
17
|
+
``call``, ``jmp`` and ``push`` -- are always reads, even though the memory
|
|
18
|
+
operand comes first.
|
|
19
|
+
4. The signedness of an access comes from the **mnemonic**: sign-extending
|
|
20
|
+
operations (``movsx``, ``movsxd``, ``sar``, ``idiv``, ``cqo``, ``cdq``,
|
|
21
|
+
``cwd``, ``cbw``) mark the operand as signed, zero-extending or unsigned
|
|
22
|
+
operations (``movzx``, ``shr``, ``div``, ``mul``) mark it as unsigned, and
|
|
23
|
+
every other mnemonic (``mov``, ``add``, ``cmp``, ...) carries no hint at all.
|
|
24
|
+
The hint is aggregated per offset like the width: only-signed accesses give
|
|
25
|
+
``signed=True``, only-unsigned accesses give ``signed=False``, no hint gives
|
|
26
|
+
``None``, and a contradiction gives ``None`` plus a
|
|
27
|
+
:class:`SignednessConflictWarning` naming the offset.
|
|
28
|
+
|
|
29
|
+
The accesses are then aggregated **per offset**: the dominant width of an offset
|
|
30
|
+
is the one seen most often (ties go to the wider access, which is the safer
|
|
31
|
+
assumption), and the read/write counts are totals for that offset over all
|
|
32
|
+
widths. Fields are returned sorted by ascending offset, so the result is
|
|
33
|
+
deterministic for a given input.
|
|
34
|
+
|
|
35
|
+
Two cheap layout hints are derived from those fields:
|
|
36
|
+
|
|
37
|
+
* ``alignment`` is the greatest common divisor of the **absolute** offsets,
|
|
38
|
+
ignoring offset 0 (which is a multiple of everything). It is ``None`` when
|
|
39
|
+
fewer than two distinct absolute non-zero offsets were seen, because a single
|
|
40
|
+
offset carries no information about a layout. Natural alignment therefore shows up as
|
|
41
|
+
a power-of-two gcd; odd offsets collapse it to 1.
|
|
42
|
+
* ``array_groups`` are **maximal runs** of at least
|
|
43
|
+
:data:`_MIN_ARRAY_ELEMENTS` consecutive fields (in ascending offset order)
|
|
44
|
+
that share one width *and* one stride between neighbours. Each run becomes one
|
|
45
|
+
:class:`ArrayGroup` starting at its first field. A run is never extended past a
|
|
46
|
+
width change or a stride change, and never overlaps the next group.
|
|
47
|
+
|
|
48
|
+
Both hints are computed from the fields that are actually reported, i.e. after
|
|
49
|
+
the ``max_fields`` cap, so a group never refers to a field that was dropped.
|
|
50
|
+
|
|
51
|
+
:func:`suggest_structs` merges several windows -- one per address of the same
|
|
52
|
+
binary -- into a single :class:`StructSuggestion`. Each address is decoded
|
|
53
|
+
exactly once with the same budget, and the per-window fields are folded
|
|
54
|
+
together:
|
|
55
|
+
|
|
56
|
+
* There is one merged field per offset. Its read/write counters are the sums
|
|
57
|
+
over the windows that saw the offset, and its ``first_access`` is the earliest
|
|
58
|
+
access any window recorded.
|
|
59
|
+
* Equal per-window widths are kept as they are. When the windows disagree the
|
|
60
|
+
widest width wins and a :class:`WidthConflictWarning` names the offset and the
|
|
61
|
+
widths: a window that only ever saw a ``dword`` is not evidence *against* the
|
|
62
|
+
``qword`` another window saw.
|
|
63
|
+
* Signedness is folded with the rule used inside one window -- consistent hints
|
|
64
|
+
survive, no hint stays unknown, and a contradiction becomes ``None`` plus one
|
|
65
|
+
:class:`SignednessConflictWarning`. A contradiction *inside* a window has
|
|
66
|
+
already warned during that window's own aggregation; the merge does not warn
|
|
67
|
+
about it a second time, because it only sees the resolved per-window hints.
|
|
68
|
+
* ``alignment``, ``array_groups`` and ``truncated`` are recomputed from the
|
|
69
|
+
merged fields, so a run that only becomes visible across windows is still
|
|
70
|
+
found.
|
|
71
|
+
|
|
72
|
+
The merged work is bounded too: at most ``max_windows`` addresses are accepted
|
|
73
|
+
(:data:`MAX_MERGED_WINDOWS`), duplicate addresses are rejected instead of being
|
|
74
|
+
merged twice, and every window costs at most one decode of ``max_instructions``
|
|
75
|
+
instructions. The merged field set stays capped at :data:`DEFAULT_MAX_FIELDS`
|
|
76
|
+
distinct offsets, which is lossless: each window is capped there as well and the
|
|
77
|
+
cap always keeps the *lowest* offsets, so an offset a window dropped cannot
|
|
78
|
+
belong to the lowest merged offsets either.
|
|
79
|
+
|
|
80
|
+
Everything here is a *heuristic*: a struct field recovered from operand shapes
|
|
81
|
+
is a hypothesis about how a function treats memory around one base register. It
|
|
82
|
+
is not a verified type, and it says nothing about a field's purpose or lifetime.
|
|
83
|
+
The ``signed`` hint is only a reading of the mnemonics that happened to be in the
|
|
84
|
+
window, so ``None`` there means "no signedness evidence", never "safe", and a
|
|
85
|
+
sign-extending *read* does not prove the field is a signed integer. Every
|
|
86
|
+
evidence record says so and sits at
|
|
87
|
+
:attr:`~codevanta.evidence.confidence.Confidence.MEDIUM`, never higher.
|
|
88
|
+
|
|
89
|
+
Work is bounded three ways: the caller's ``max_instructions`` budget (default
|
|
90
|
+
:data:`DEFAULT_MAX_INSTRUCTIONS`), the ``max_fields`` cap (default
|
|
91
|
+
:data:`DEFAULT_MAX_FIELDS`; extras are dropped and reported through
|
|
92
|
+
:attr:`StructSuggestion.truncated`), and the file-backed bytes of the containing
|
|
93
|
+
section -- at most ``max_instructions * 16`` bytes are read. The analysed file
|
|
94
|
+
is only ever read from disk, never mapped or executed.
|
|
95
|
+
|
|
96
|
+
Known, deliberate limits: ``lea`` and ``nop`` are skipped because their bracket
|
|
97
|
+
operands are not memory accesses; read-modify-write instructions (``add ...,
|
|
98
|
+
[rdi+8]``, ``cmpxchg``) count as writes only; operand syntax from other
|
|
99
|
+
architectures (ARM/MIPS ``[x0, #0x10]``) does not match at all, so a
|
|
100
|
+
non-Intel-shaped binary simply yields no fields instead of wrong ones.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
from __future__ import annotations
|
|
104
|
+
|
|
105
|
+
import re
|
|
106
|
+
import warnings
|
|
107
|
+
from collections.abc import Collection, Iterable, Sequence
|
|
108
|
+
from dataclasses import dataclass
|
|
109
|
+
from math import gcd
|
|
110
|
+
from pathlib import Path
|
|
111
|
+
from typing import Any
|
|
112
|
+
|
|
113
|
+
from codevanta.core.disassembler import Disassembler, DisassemblyError
|
|
114
|
+
from codevanta.core.loader import BinaryInfo
|
|
115
|
+
from codevanta.core.report import MAX_DISASSEMBLY_COUNT, AnalysisError, read_code
|
|
116
|
+
from codevanta.evidence.confidence import Confidence
|
|
117
|
+
from codevanta.evidence.models import Evidence
|
|
118
|
+
from codevanta.ir.instruction import IRInstruction
|
|
119
|
+
|
|
120
|
+
__all__ = [
|
|
121
|
+
"ArrayGroup",
|
|
122
|
+
"DEFAULT_MAX_FIELDS",
|
|
123
|
+
"DEFAULT_MAX_INSTRUCTIONS",
|
|
124
|
+
"MAX_MAX_FIELDS",
|
|
125
|
+
"MAX_MAX_INSTRUCTIONS",
|
|
126
|
+
"MAX_MERGED_WINDOWS",
|
|
127
|
+
"MIN_MAX_FIELDS",
|
|
128
|
+
"MIN_MAX_INSTRUCTIONS",
|
|
129
|
+
"MIN_MERGED_WINDOWS",
|
|
130
|
+
"READ",
|
|
131
|
+
"SignednessConflictWarning",
|
|
132
|
+
"STACK_REGISTERS",
|
|
133
|
+
"WRITE",
|
|
134
|
+
"FieldAccess",
|
|
135
|
+
"StructSuggestion",
|
|
136
|
+
"SuggestedField",
|
|
137
|
+
"WidthConflictWarning",
|
|
138
|
+
"WindowOverlapWarning",
|
|
139
|
+
"parse_memory_operands",
|
|
140
|
+
"suggest_struct",
|
|
141
|
+
"suggest_structs",
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
#: Default instruction budget for one suggestion.
|
|
145
|
+
DEFAULT_MAX_INSTRUCTIONS = 2_000
|
|
146
|
+
|
|
147
|
+
#: Smallest accepted ``max_instructions``; smaller requests are rejected.
|
|
148
|
+
MIN_MAX_INSTRUCTIONS = 1
|
|
149
|
+
|
|
150
|
+
#: Largest accepted ``max_instructions``; larger requests are rejected.
|
|
151
|
+
MAX_MAX_INSTRUCTIONS = MAX_DISASSEMBLY_COUNT
|
|
152
|
+
|
|
153
|
+
#: Default ceiling on distinct field offsets kept in one suggestion.
|
|
154
|
+
DEFAULT_MAX_FIELDS = 64
|
|
155
|
+
|
|
156
|
+
#: Smallest accepted ``max_fields``; smaller requests are rejected.
|
|
157
|
+
MIN_MAX_FIELDS = 1
|
|
158
|
+
|
|
159
|
+
#: Largest accepted ``max_fields``; larger requests are rejected.
|
|
160
|
+
MAX_MAX_FIELDS = 512
|
|
161
|
+
|
|
162
|
+
#: Default *and* largest number of windows :func:`suggest_structs` merges. Each
|
|
163
|
+
#: window costs one bounded decode, so this is the knob that keeps a merge cheap.
|
|
164
|
+
MAX_MERGED_WINDOWS = 16
|
|
165
|
+
|
|
166
|
+
#: Smallest accepted ``max_windows``; smaller requests are rejected.
|
|
167
|
+
MIN_MERGED_WINDOWS = 1
|
|
168
|
+
|
|
169
|
+
#: Direction label for a memory operand that is only read.
|
|
170
|
+
READ = "read"
|
|
171
|
+
|
|
172
|
+
#: Direction label for a memory operand that is written (possibly read-modify-write).
|
|
173
|
+
WRITE = "write"
|
|
174
|
+
|
|
175
|
+
#: Longest possible encoding of one instruction on a supported architecture.
|
|
176
|
+
#: Used to size the read window for the instruction budget.
|
|
177
|
+
_MAX_INSTRUCTION_BYTES = 16
|
|
178
|
+
|
|
179
|
+
#: Intel size keyword -> width in bytes.
|
|
180
|
+
_SIZE_KEYWORD_WIDTHS: dict[str, int] = {
|
|
181
|
+
"byte": 1,
|
|
182
|
+
"word": 2,
|
|
183
|
+
"dword": 4,
|
|
184
|
+
"qword": 8,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#: Pointer width in bytes, keyed by bitness. Unknown bitness falls back to 8.
|
|
188
|
+
_POINTER_WIDTH_BY_BITNESS: dict[int, int] = {16: 2, 32: 4, 64: 8}
|
|
189
|
+
|
|
190
|
+
#: Registers treated as frame pointers. Operands based on them describe a stack
|
|
191
|
+
#: slot or a spilled local, not a struct field, so they are skipped unless the
|
|
192
|
+
#: caller passes ``include_stack=True``.
|
|
193
|
+
STACK_REGISTERS = frozenset({"sp", "esp", "rsp", "bp", "ebp", "rbp"})
|
|
194
|
+
|
|
195
|
+
#: Instruction-pointer registers. ``[rip + disp]`` addresses a global, not a
|
|
196
|
+
#: field of the pointer argument, so these bases are always skipped.
|
|
197
|
+
INSTRUCTION_POINTER_REGISTERS = frozenset({"ip", "eip", "rip"})
|
|
198
|
+
|
|
199
|
+
#: Mnemonics whose memory operands are only compared, never assigned.
|
|
200
|
+
_COMPARE_MNEMONICS = frozenset({"cmp", "cmps", "cmpsb", "cmpsw", "cmpsd", "cmpsq", "test"})
|
|
201
|
+
|
|
202
|
+
#: Mnemonics that carry a bracket expression without touching memory.
|
|
203
|
+
_NON_ACCESSING_MNEMONICS = frozenset({"lea", "nop"})
|
|
204
|
+
|
|
205
|
+
#: Mnemonics whose result is sign-extended from the memory operand, or which do
|
|
206
|
+
#: signed arithmetic on it. Any access they make is treated as ``signed=True``.
|
|
207
|
+
#:
|
|
208
|
+
#: The implicit-operand sign-extension mnemonics (``cbw``, ``cwd``, ``cdq``,
|
|
209
|
+
#: ``cqo``) are absent on purpose: they never carry a memory operand, so they can
|
|
210
|
+
#: neither label a field nor appear in an access record.
|
|
211
|
+
_SIGNED_MNEMONICS = frozenset({"movsx", "movsxd", "sar", "idiv", "imul"})
|
|
212
|
+
|
|
213
|
+
#: Mnemonics whose result is zero-extended from the memory operand, or which do
|
|
214
|
+
#: unsigned arithmetic on it. Any access they make is treated as ``signed=False``.
|
|
215
|
+
_UNSIGNED_MNEMONICS = frozenset({"movzx", "shr", "div", "mul"})
|
|
216
|
+
|
|
217
|
+
#: A memory operand with an optional size keyword, a single base register and an
|
|
218
|
+
#: optional signed displacement: ``qword ptr [rdi + 0x18]``, ``[rax]``,
|
|
219
|
+
#: ``dword ptr [rbp - 8]``.
|
|
220
|
+
#:
|
|
221
|
+
#: An index register (``[rdi + rcx*4]``) or a segment prefix never matches, so
|
|
222
|
+
#: those operands are skipped instead of being mis-attributed to a field.
|
|
223
|
+
_MEMORY_OPERAND = re.compile(
|
|
224
|
+
r"^(?:(?P<size>byte|word|dword|qword)\s+ptr\s+)?"
|
|
225
|
+
r"\[\s*(?P<base>[A-Za-z][A-Za-z0-9]*)"
|
|
226
|
+
r"(?:\s*(?P<sign>[+-])\s*(?P<displacement>0x[0-9a-fA-F]+|[0-9]+))?"
|
|
227
|
+
r"\s*\]$"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
_FIELD_EVIDENCE_SOURCE = "type_recovery.memory_operand"
|
|
231
|
+
_FIELD_EVIDENCE_METHOD = "operand_scan"
|
|
232
|
+
_SUGGESTION_EVIDENCE_SOURCE = "type_recovery.suggestion"
|
|
233
|
+
_SUGGESTION_EVIDENCE_METHOD = "operand_aggregation"
|
|
234
|
+
_ARRAY_EVIDENCE_SOURCE = "type_recovery.array_run"
|
|
235
|
+
_ARRAY_EVIDENCE_METHOD = "stride_run_detection"
|
|
236
|
+
_MERGED_FIELD_EVIDENCE_SOURCE = "type_recovery.merged_field"
|
|
237
|
+
_MERGED_FIELD_EVIDENCE_METHOD = "window_merge"
|
|
238
|
+
_MERGED_SUGGESTION_EVIDENCE_SOURCE = "type_recovery.merged_suggestion"
|
|
239
|
+
_MERGED_SUGGESTION_EVIDENCE_METHOD = "window_merge"
|
|
240
|
+
|
|
241
|
+
#: Smallest number of equally spaced, equally wide fields that is reported as an
|
|
242
|
+
#: :class:`ArrayGroup`. Two fields are only a coincidence; three is the minimum
|
|
243
|
+
#: at which a repeated stride starts to carry information.
|
|
244
|
+
_MIN_ARRAY_ELEMENTS = 3
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class SignednessConflictWarning(UserWarning):
|
|
248
|
+
"""Emitted when one field offset is accessed as both signed and unsigned.
|
|
249
|
+
|
|
250
|
+
The two sources of evidence contradict each other, so the offset is reported
|
|
251
|
+
with ``signed=None`` -- exactly like an offset with no evidence at all --
|
|
252
|
+
and this warning says which offset lost its hint. It is a
|
|
253
|
+
:class:`UserWarning`, so callers that do not care about signedness can
|
|
254
|
+
ignore it; callers that do can filter on it.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class WindowOverlapWarning(UserWarning):
|
|
259
|
+
"""Emitted when two merged windows decode the same instruction bytes.
|
|
260
|
+
|
|
261
|
+
A partial overlap is not rejected the way a duplicate address is -- looking
|
|
262
|
+
further into the same function is a legitimate second window -- but the shared
|
|
263
|
+
instructions are then counted by both windows, so the per-offset counters are
|
|
264
|
+
per-window sums and can exceed the number of distinct accesses.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class WidthConflictWarning(UserWarning):
|
|
269
|
+
"""Emitted when merged windows disagree about one field's dominant width.
|
|
270
|
+
|
|
271
|
+
A wider access is never contradicted by a narrower one -- a window that only
|
|
272
|
+
saw ``dword ptr [rdi + 0x10]`` does not prove the field is 4 bytes wide -- so
|
|
273
|
+
the merged field keeps the **widest** per-window width while this warning
|
|
274
|
+
records the offset together with every width that was seen. It is a
|
|
275
|
+
:class:`UserWarning`, so it stays silent unless somebody listens.
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@dataclass(frozen=True)
|
|
280
|
+
class FieldAccess:
|
|
281
|
+
"""One observed access to a field-shaped memory operand.
|
|
282
|
+
|
|
283
|
+
Attributes:
|
|
284
|
+
offset: Displacement relative to ``base_register``; negative for
|
|
285
|
+
``[reg - N]``.
|
|
286
|
+
width: Access width in bytes (1, 2, 4, 8, or the pointer width).
|
|
287
|
+
direction: :data:`READ` or :data:`WRITE`, decided from the operand
|
|
288
|
+
position and the mnemonic.
|
|
289
|
+
base_register: Lowercase base register of the operand, e.g. ``"rdi"``.
|
|
290
|
+
address: Virtual address of the instruction that performed the access.
|
|
291
|
+
instruction: Instruction text, e.g. ``"mov rax, qword ptr [rdi + 0x10]"``.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
offset: int
|
|
295
|
+
width: int
|
|
296
|
+
direction: str
|
|
297
|
+
base_register: str
|
|
298
|
+
address: int
|
|
299
|
+
instruction: str
|
|
300
|
+
|
|
301
|
+
def to_dict(self) -> dict[str, Any]:
|
|
302
|
+
return {
|
|
303
|
+
"offset": f"0x{self.offset:x}",
|
|
304
|
+
"width": self.width,
|
|
305
|
+
"direction": self.direction,
|
|
306
|
+
"base_register": self.base_register,
|
|
307
|
+
"address": f"0x{self.address:x}",
|
|
308
|
+
"instruction": self.instruction,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass(frozen=True)
|
|
313
|
+
class SuggestedField:
|
|
314
|
+
"""A struct field suggested by aggregating accesses at one offset.
|
|
315
|
+
|
|
316
|
+
Attributes:
|
|
317
|
+
offset: Displacement of the field inside the struct.
|
|
318
|
+
width: Dominant access width in bytes.
|
|
319
|
+
read_count: Accesses classified as reads at this offset (all widths).
|
|
320
|
+
write_count: Accesses classified as writes at this offset (all widths).
|
|
321
|
+
first_access: Virtual address of the first instruction that touched the
|
|
322
|
+
offset, or ``None`` when no access was recorded.
|
|
323
|
+
evidence: Justification, always at ``Confidence.MEDIUM``.
|
|
324
|
+
signed: ``True`` when every signedness hint recorded at this offset came
|
|
325
|
+
from a sign-extending mnemonic, ``False`` when every hint came from a
|
|
326
|
+
zero-extending/unsigned one, and ``None`` when there was no hint or
|
|
327
|
+
the hints contradicted each other (in which case
|
|
328
|
+
:class:`SignednessConflictWarning` was raised). Omitted from
|
|
329
|
+
:meth:`to_dict` when ``None``.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
offset: int
|
|
333
|
+
width: int
|
|
334
|
+
read_count: int
|
|
335
|
+
write_count: int
|
|
336
|
+
first_access: int | None
|
|
337
|
+
evidence: Evidence
|
|
338
|
+
signed: bool | None = None
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def access_count(self) -> int:
|
|
342
|
+
"""Total number of accesses (reads plus writes) at this offset."""
|
|
343
|
+
return self.read_count + self.write_count
|
|
344
|
+
|
|
345
|
+
def to_dict(self) -> dict[str, Any]:
|
|
346
|
+
first = self.first_access
|
|
347
|
+
payload: dict[str, Any] = {
|
|
348
|
+
"offset": f"0x{self.offset:x}",
|
|
349
|
+
"width": self.width,
|
|
350
|
+
"read_count": self.read_count,
|
|
351
|
+
"write_count": self.write_count,
|
|
352
|
+
"access_count": self.access_count,
|
|
353
|
+
"first_access": f"0x{first:x}" if first is not None else None,
|
|
354
|
+
"evidence": self.evidence.to_dict(),
|
|
355
|
+
}
|
|
356
|
+
# An unknown signedness is an absent key, not a ``null``: it keeps the
|
|
357
|
+
# JSON of an unclassified field byte-identical to the pre-signedness one.
|
|
358
|
+
if self.signed is not None:
|
|
359
|
+
payload["signed"] = self.signed
|
|
360
|
+
return payload
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@dataclass(frozen=True)
|
|
364
|
+
class ArrayGroup:
|
|
365
|
+
"""A run of equally spaced, equally wide fields that looks like an array.
|
|
366
|
+
|
|
367
|
+
The run is *maximal*: it cannot be extended left or right by another field
|
|
368
|
+
with the same width and stride, and it never overlaps another group.
|
|
369
|
+
|
|
370
|
+
Attributes:
|
|
371
|
+
offset: Offset of the first field of the run.
|
|
372
|
+
stride: Distance in bytes between consecutive fields; always positive.
|
|
373
|
+
count: Number of fields in the run (at least
|
|
374
|
+
:data:`_MIN_ARRAY_ELEMENTS`).
|
|
375
|
+
width: Shared dominant width of the run's fields, in bytes.
|
|
376
|
+
evidence: Justification, always at ``Confidence.MEDIUM`` and always
|
|
377
|
+
carrying a heuristic caveat.
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
offset: int
|
|
381
|
+
stride: int
|
|
382
|
+
count: int
|
|
383
|
+
width: int
|
|
384
|
+
evidence: Evidence
|
|
385
|
+
|
|
386
|
+
@property
|
|
387
|
+
def span(self) -> int:
|
|
388
|
+
"""Total byte span covered by the run, i.e. ``stride * (count - 1) + width``."""
|
|
389
|
+
return self.stride * (self.count - 1) + self.width
|
|
390
|
+
|
|
391
|
+
def to_dict(self) -> dict[str, Any]:
|
|
392
|
+
return {
|
|
393
|
+
"offset": f"0x{self.offset:x}",
|
|
394
|
+
"stride": self.stride,
|
|
395
|
+
"count": self.count,
|
|
396
|
+
"width": self.width,
|
|
397
|
+
"span": self.span,
|
|
398
|
+
"evidence": self.evidence.to_dict(),
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@dataclass(frozen=True)
|
|
403
|
+
class StructSuggestion:
|
|
404
|
+
"""The fields suggested for the struct a function receives in a register.
|
|
405
|
+
|
|
406
|
+
Attributes:
|
|
407
|
+
address: Virtual address the sweep started at.
|
|
408
|
+
fields: Suggested fields sorted by ascending offset (at most
|
|
409
|
+
``max_fields`` entries).
|
|
410
|
+
instruction_count: Number of decoded instructions the sweep considered.
|
|
411
|
+
memory_access_count: Number of memory accesses the sweep recorded after
|
|
412
|
+
the stack and instruction-pointer filters.
|
|
413
|
+
max_fields: Field cap that was applied.
|
|
414
|
+
truncated: True when distinct offsets were dropped by ``max_fields``.
|
|
415
|
+
Instruction-budget exhaustion is not reported here.
|
|
416
|
+
include_stack: Whether stack-based operands were kept.
|
|
417
|
+
evidence: Justification for the suggestion as a whole.
|
|
418
|
+
alignment: GCD of the absolute non-zero field offsets, or ``None`` when
|
|
419
|
+
fewer than two distinct non-zero offsets were found.
|
|
420
|
+
array_groups: Maximal runs of at least :data:`_MIN_ARRAY_ELEMENTS`
|
|
421
|
+
equally wide, equally spaced fields, in ascending offset order.
|
|
422
|
+
"""
|
|
423
|
+
|
|
424
|
+
address: int
|
|
425
|
+
fields: tuple[SuggestedField, ...]
|
|
426
|
+
instruction_count: int
|
|
427
|
+
max_fields: int
|
|
428
|
+
truncated: bool
|
|
429
|
+
include_stack: bool
|
|
430
|
+
evidence: Evidence
|
|
431
|
+
alignment: int | None = None
|
|
432
|
+
array_groups: tuple[ArrayGroup, ...] = ()
|
|
433
|
+
memory_access_count: int = 0
|
|
434
|
+
|
|
435
|
+
@property
|
|
436
|
+
def field_count(self) -> int:
|
|
437
|
+
"""Number of suggested fields."""
|
|
438
|
+
return len(self.fields)
|
|
439
|
+
|
|
440
|
+
def to_dict(self) -> dict[str, Any]:
|
|
441
|
+
return {
|
|
442
|
+
"address": f"0x{self.address:x}",
|
|
443
|
+
"instruction_count": self.instruction_count,
|
|
444
|
+
"field_count": self.field_count,
|
|
445
|
+
"max_fields": self.max_fields,
|
|
446
|
+
"truncated": self.truncated,
|
|
447
|
+
"include_stack": self.include_stack,
|
|
448
|
+
"fields": [field.to_dict() for field in self.fields],
|
|
449
|
+
"alignment": self.alignment,
|
|
450
|
+
"array_groups": [group.to_dict() for group in self.array_groups],
|
|
451
|
+
"evidence": self.evidence.to_dict(),
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _split_operands(operands: str) -> list[str]:
|
|
456
|
+
"""Split an operand list on top-level commas, keeping bracket groups intact."""
|
|
457
|
+
parts: list[str] = []
|
|
458
|
+
current: list[str] = []
|
|
459
|
+
depth = 0
|
|
460
|
+
for char in operands:
|
|
461
|
+
if char == "[":
|
|
462
|
+
depth += 1
|
|
463
|
+
elif char == "]":
|
|
464
|
+
depth = max(0, depth - 1)
|
|
465
|
+
if char == "," and depth == 0:
|
|
466
|
+
parts.append("".join(current).strip())
|
|
467
|
+
current = []
|
|
468
|
+
continue
|
|
469
|
+
current.append(char)
|
|
470
|
+
tail = "".join(current).strip()
|
|
471
|
+
if tail:
|
|
472
|
+
parts.append(tail)
|
|
473
|
+
return parts
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _pointer_width(bitness: int) -> int:
|
|
477
|
+
"""Return the pointer width in bytes for ``bitness`` (8 when unknown)."""
|
|
478
|
+
return _POINTER_WIDTH_BY_BITNESS.get(bitness, 8)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _parse_displacement(sign: str | None, displacement: str | None) -> int:
|
|
482
|
+
"""Turn a matched ``+``/``-`` displacement into a signed offset."""
|
|
483
|
+
if sign is None or displacement is None:
|
|
484
|
+
return 0
|
|
485
|
+
base = 16 if displacement.lower().startswith("0x") else 10
|
|
486
|
+
value = int(displacement, base)
|
|
487
|
+
return -value if sign == "-" else value
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
#: Mnemonics whose memory operands are read even when they appear first.
|
|
491
|
+
_READ_ONLY_MNEMONICS = frozenset({"call", "jmp", "push"})
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _direction(mnemonic: str, operand_index: int) -> str:
|
|
495
|
+
"""Classify an access: writes are first operands, compares/calls are reads."""
|
|
496
|
+
if mnemonic in _COMPARE_MNEMONICS or mnemonic in _READ_ONLY_MNEMONICS:
|
|
497
|
+
return READ
|
|
498
|
+
return WRITE if operand_index == 0 else READ
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _signedness(mnemonic: str) -> bool | None:
|
|
502
|
+
"""Signedness hint for one mnemonic: ``True``, ``False``, or ``None``.
|
|
503
|
+
|
|
504
|
+
The hint is read off the mnemonic alone, because that is all the decoder
|
|
505
|
+
tells us: a sign-extending operation (``movsx``, ``movsxd``, ``sar``,
|
|
506
|
+
``idiv``, ``cqo``, ``cdq``, ``cwd``, ``cbw``) is evidence for a signed
|
|
507
|
+
field, a zero-extending or unsigned one (``movzx``, ``shr``, ``div``,
|
|
508
|
+
``mul``) is evidence for an unsigned field, and everything else is no
|
|
509
|
+
evidence either way.
|
|
510
|
+
"""
|
|
511
|
+
name = mnemonic.strip().lower()
|
|
512
|
+
if name in _SIGNED_MNEMONICS:
|
|
513
|
+
return True
|
|
514
|
+
if name in _UNSIGNED_MNEMONICS:
|
|
515
|
+
return False
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _signedness_text(signed: bool | None) -> str:
|
|
520
|
+
"""Render a signedness hint for evidence prose."""
|
|
521
|
+
if signed is None:
|
|
522
|
+
return "unknown"
|
|
523
|
+
return "signed" if signed else "unsigned"
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _width(match: re.Match[str], pointer_width: int) -> int:
|
|
527
|
+
"""Width from the operand's size keyword, else the pointer width."""
|
|
528
|
+
size_keyword = match.group("size")
|
|
529
|
+
if size_keyword:
|
|
530
|
+
width = _SIZE_KEYWORD_WIDTHS.get(size_keyword.lower())
|
|
531
|
+
if width is not None:
|
|
532
|
+
return width
|
|
533
|
+
return max(1, pointer_width)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def parse_memory_operands(
|
|
537
|
+
instruction: IRInstruction,
|
|
538
|
+
*,
|
|
539
|
+
include_stack: bool = False,
|
|
540
|
+
pointer_width: int = 8,
|
|
541
|
+
) -> tuple[FieldAccess, ...]:
|
|
542
|
+
"""Extract field-shaped memory accesses from a single instruction.
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
instruction: Decoded instruction to inspect.
|
|
546
|
+
include_stack: When true, operands based on :data:`STACK_REGISTERS` are
|
|
547
|
+
kept; by default they are dropped because they usually describe
|
|
548
|
+
stack slots rather than struct fields.
|
|
549
|
+
pointer_width: Width in bytes used when the operand carries no size
|
|
550
|
+
keyword, i.e. ``[rdi + 0x10]`` without ``qword ptr``.
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
One :class:`FieldAccess` per memory operand, in operand order. Empty for
|
|
554
|
+
instructions without a supported memory operand, for stack-based
|
|
555
|
+
operands when ``include_stack`` is false, and for mnemonics that carry a
|
|
556
|
+
bracket expression without touching memory (``lea``, ``nop``).
|
|
557
|
+
"""
|
|
558
|
+
mnemonic = instruction.mnemonic.strip().lower()
|
|
559
|
+
if not mnemonic or mnemonic in _NON_ACCESSING_MNEMONICS or mnemonic.startswith("prefetch"):
|
|
560
|
+
return ()
|
|
561
|
+
accesses: list[FieldAccess] = []
|
|
562
|
+
for index, operand in enumerate(_split_operands(instruction.operands)):
|
|
563
|
+
match = _MEMORY_OPERAND.match(operand)
|
|
564
|
+
if match is None:
|
|
565
|
+
continue
|
|
566
|
+
base = match.group("base").lower()
|
|
567
|
+
if base in INSTRUCTION_POINTER_REGISTERS:
|
|
568
|
+
continue
|
|
569
|
+
if base in STACK_REGISTERS and not include_stack:
|
|
570
|
+
continue
|
|
571
|
+
accesses.append(
|
|
572
|
+
FieldAccess(
|
|
573
|
+
offset=_parse_displacement(match.group("sign"), match.group("displacement")),
|
|
574
|
+
width=_width(match, pointer_width),
|
|
575
|
+
direction=_direction(mnemonic, index),
|
|
576
|
+
base_register=base,
|
|
577
|
+
address=instruction.address,
|
|
578
|
+
instruction=instruction.text,
|
|
579
|
+
)
|
|
580
|
+
)
|
|
581
|
+
return tuple(accesses)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _resolve_signedness(
|
|
585
|
+
offset: int, observed: Collection[bool], *, stacklevel: int = 4
|
|
586
|
+
) -> bool | None:
|
|
587
|
+
"""Collapse the signedness hints of one offset, warning on a contradiction.
|
|
588
|
+
|
|
589
|
+
One kind of hint means the offset is taken to be signed (or unsigned), no
|
|
590
|
+
hint means it stays unknown, and a mix is reported as unknown *and*
|
|
591
|
+
announced through :class:`SignednessConflictWarning` -- a contradictory
|
|
592
|
+
offset is not the same thing as an unclassified one, even though both end up
|
|
593
|
+
as ``None``. ``stacklevel`` only moves the warning's reported location.
|
|
594
|
+
"""
|
|
595
|
+
if len(observed) == 1:
|
|
596
|
+
return next(iter(observed))
|
|
597
|
+
if len(observed) > 1:
|
|
598
|
+
warnings.warn(
|
|
599
|
+
f"offset 0x{offset:x} is accessed both as signed and as unsigned; "
|
|
600
|
+
"reporting its signedness as unknown",
|
|
601
|
+
SignednessConflictWarning,
|
|
602
|
+
stacklevel=stacklevel,
|
|
603
|
+
)
|
|
604
|
+
return None
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _aggregate(accesses: Iterable[tuple[FieldAccess, bool | None]]) -> list[SuggestedField]:
|
|
608
|
+
"""Reduce ``(access, signedness hint)`` pairs to one field per offset.
|
|
609
|
+
|
|
610
|
+
Fields come back sorted by ascending offset, so the result is deterministic.
|
|
611
|
+
Widths are collapsed per offset (dominant width wins, ties go to the wider
|
|
612
|
+
access), read/write counters are totals over all widths, and signedness is
|
|
613
|
+
resolved by :func:`_resolve_signedness`.
|
|
614
|
+
"""
|
|
615
|
+
per_offset: dict[int, dict[int, list[int]]] = {}
|
|
616
|
+
hints: dict[int, set[bool]] = {}
|
|
617
|
+
first_access: dict[int, int] = {}
|
|
618
|
+
for access, signed in accesses:
|
|
619
|
+
counts = per_offset.setdefault(access.offset, {}).setdefault(access.width, [0, 0])
|
|
620
|
+
if access.direction == WRITE:
|
|
621
|
+
counts[1] += 1
|
|
622
|
+
else:
|
|
623
|
+
counts[0] += 1
|
|
624
|
+
if signed is not None:
|
|
625
|
+
hints.setdefault(access.offset, set()).add(signed)
|
|
626
|
+
first_access.setdefault(access.offset, access.address)
|
|
627
|
+
fields: list[SuggestedField] = []
|
|
628
|
+
for offset in sorted(per_offset):
|
|
629
|
+
widths = per_offset[offset]
|
|
630
|
+
# Dominant width: most frequent, ties resolved to the wider access.
|
|
631
|
+
dominant = max(widths.items(), key=lambda item: (item[1][0] + item[1][1], item[0]))[0]
|
|
632
|
+
reads = sum(count[0] for count in widths.values())
|
|
633
|
+
writes = sum(count[1] for count in widths.values())
|
|
634
|
+
first = first_access.get(offset)
|
|
635
|
+
signed = _resolve_signedness(offset, hints.get(offset, ()))
|
|
636
|
+
fields.append(
|
|
637
|
+
SuggestedField(
|
|
638
|
+
offset=offset,
|
|
639
|
+
width=dominant,
|
|
640
|
+
read_count=reads,
|
|
641
|
+
write_count=writes,
|
|
642
|
+
first_access=first,
|
|
643
|
+
evidence=Evidence(
|
|
644
|
+
source=_FIELD_EVIDENCE_SOURCE,
|
|
645
|
+
method=_FIELD_EVIDENCE_METHOD,
|
|
646
|
+
confidence=Confidence.MEDIUM,
|
|
647
|
+
address=first,
|
|
648
|
+
detail=(
|
|
649
|
+
f"offset 0x{offset:x}: {dominant}-byte field, "
|
|
650
|
+
f"{reads} read(s), {writes} write(s), "
|
|
651
|
+
f"signedness {_signedness_text(signed)}; "
|
|
652
|
+
"field is heuristic, not a verified type"
|
|
653
|
+
),
|
|
654
|
+
extra={
|
|
655
|
+
"offset": offset,
|
|
656
|
+
"width": dominant,
|
|
657
|
+
"read_count": reads,
|
|
658
|
+
"write_count": writes,
|
|
659
|
+
"signed": signed,
|
|
660
|
+
"signed_conflict": len(hints.get(offset, ())) > 1,
|
|
661
|
+
},
|
|
662
|
+
),
|
|
663
|
+
signed=signed,
|
|
664
|
+
)
|
|
665
|
+
)
|
|
666
|
+
return fields
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _alignment(fields: Sequence[SuggestedField]) -> int | None:
|
|
670
|
+
"""GCD of the absolute non-zero field offsets, or ``None`` when too few.
|
|
671
|
+
|
|
672
|
+
Offset 0 is ignored because it is a multiple of every candidate alignment and
|
|
673
|
+
would therefore contribute nothing. Fewer than two distinct non-zero offsets
|
|
674
|
+
cannot distinguish an alignment from the offset itself, so the answer is
|
|
675
|
+
``None`` instead of a guess.
|
|
676
|
+
"""
|
|
677
|
+
magnitudes = sorted({abs(field.offset) for field in fields if field.offset != 0})
|
|
678
|
+
if len(magnitudes) < 2:
|
|
679
|
+
return None
|
|
680
|
+
result = magnitudes[0]
|
|
681
|
+
for magnitude in magnitudes[1:]:
|
|
682
|
+
result = gcd(result, magnitude)
|
|
683
|
+
return result
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _array_group_evidence(
|
|
687
|
+
first: SuggestedField, stride: int, count: int, width: int
|
|
688
|
+
) -> Evidence:
|
|
689
|
+
"""Build the MEDIUM-confidence evidence record for one array run."""
|
|
690
|
+
return Evidence(
|
|
691
|
+
source=_ARRAY_EVIDENCE_SOURCE,
|
|
692
|
+
method=_ARRAY_EVIDENCE_METHOD,
|
|
693
|
+
confidence=Confidence.MEDIUM,
|
|
694
|
+
address=first.first_access,
|
|
695
|
+
detail=(
|
|
696
|
+
f"array candidate at offset 0x{first.offset:x}: {count} fields of "
|
|
697
|
+
f"{width} byte(s) spaced {stride} byte(s) apart; run heuristic only, "
|
|
698
|
+
"it cannot tell an array from repeated struct members and is not a "
|
|
699
|
+
"verified type"
|
|
700
|
+
),
|
|
701
|
+
extra={
|
|
702
|
+
"offset": first.offset,
|
|
703
|
+
"stride": stride,
|
|
704
|
+
"count": count,
|
|
705
|
+
"width": width,
|
|
706
|
+
},
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _array_groups(fields: Sequence[SuggestedField]) -> tuple[ArrayGroup, ...]:
|
|
711
|
+
"""Group maximal runs of equally wide, equally spaced fields.
|
|
712
|
+
|
|
713
|
+
Fields must already be sorted by ascending offset, which
|
|
714
|
+
:func:`_aggregate` guarantees. A run needs at least
|
|
715
|
+
:data:`_MIN_ARRAY_ELEMENTS` fields with one shared width and one shared
|
|
716
|
+
stride between neighbours; the scan is linear, and every field belongs to at
|
|
717
|
+
most one group, so the result is deterministic.
|
|
718
|
+
"""
|
|
719
|
+
groups: list[ArrayGroup] = []
|
|
720
|
+
total = len(fields)
|
|
721
|
+
start = 0
|
|
722
|
+
while start < total:
|
|
723
|
+
width = fields[start].width
|
|
724
|
+
end = start
|
|
725
|
+
stride: int | None = None
|
|
726
|
+
while end + 1 < total:
|
|
727
|
+
following = fields[end + 1]
|
|
728
|
+
if following.width != width:
|
|
729
|
+
break
|
|
730
|
+
step = following.offset - fields[end].offset
|
|
731
|
+
if stride is None:
|
|
732
|
+
stride = step
|
|
733
|
+
elif step != stride:
|
|
734
|
+
break
|
|
735
|
+
end += 1
|
|
736
|
+
count = end - start + 1
|
|
737
|
+
if stride is not None and count >= _MIN_ARRAY_ELEMENTS:
|
|
738
|
+
first = fields[start]
|
|
739
|
+
groups.append(
|
|
740
|
+
ArrayGroup(
|
|
741
|
+
offset=first.offset,
|
|
742
|
+
stride=stride,
|
|
743
|
+
count=count,
|
|
744
|
+
width=width,
|
|
745
|
+
evidence=_array_group_evidence(first, stride, count, width),
|
|
746
|
+
)
|
|
747
|
+
)
|
|
748
|
+
start = end + 1
|
|
749
|
+
else:
|
|
750
|
+
# No run here: retry from the next field, a shorter run may start there.
|
|
751
|
+
start += 1
|
|
752
|
+
return tuple(groups)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _merged_width(offset: int, widths: Sequence[int]) -> int:
|
|
756
|
+
"""Widest of the per-window widths, warning when the windows disagree.
|
|
757
|
+
|
|
758
|
+
A wide access is stronger evidence than a narrow one -- seeing a ``qword``
|
|
759
|
+
proves at least eight bytes, seeing a ``dword`` elsewhere proves nothing
|
|
760
|
+
against it -- so the widest width is kept. The disagreement is still
|
|
761
|
+
reported, because it says how much of the field the code was seen touching.
|
|
762
|
+
"""
|
|
763
|
+
widest = max(widths)
|
|
764
|
+
distinct = sorted(set(widths))
|
|
765
|
+
if len(distinct) > 1:
|
|
766
|
+
rendered = ", ".join(f"{width}-byte" for width in distinct)
|
|
767
|
+
warnings.warn(
|
|
768
|
+
f"offset 0x{offset:x} is accessed with different widths ({rendered}); "
|
|
769
|
+
f"keeping the widest, {widest} byte(s)",
|
|
770
|
+
WidthConflictWarning,
|
|
771
|
+
stacklevel=5,
|
|
772
|
+
)
|
|
773
|
+
return widest
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _merge_field(offset: int, parts: Sequence[SuggestedField]) -> SuggestedField:
|
|
777
|
+
"""Fold the per-window fields of one offset into a single merged field.
|
|
778
|
+
|
|
779
|
+
``parts`` holds one field per window that saw ``offset``, in ascending
|
|
780
|
+
window-address order. Counters are summed, the widest width wins (see
|
|
781
|
+
:func:`_merged_width`), the earliest access is kept, and signedness is folded
|
|
782
|
+
with the same rule as inside one window (:func:`_resolve_signedness`).
|
|
783
|
+
"""
|
|
784
|
+
width = _merged_width(offset, [part.width for part in parts])
|
|
785
|
+
reads = sum(part.read_count for part in parts)
|
|
786
|
+
writes = sum(part.write_count for part in parts)
|
|
787
|
+
hints = {part.signed for part in parts if part.signed is not None}
|
|
788
|
+
window_conflict = any(
|
|
789
|
+
bool(part.evidence.extra.get("signed_conflict")) for part in parts
|
|
790
|
+
)
|
|
791
|
+
if window_conflict:
|
|
792
|
+
# A window whose own accesses contradicted each other must not lose that
|
|
793
|
+
# contradiction to a window that only ever hinted one way.
|
|
794
|
+
if hints:
|
|
795
|
+
warnings.warn(
|
|
796
|
+
f"offset 0x{offset:x} was accessed as signed and unsigned inside one "
|
|
797
|
+
"window, so no merged signedness hint is kept",
|
|
798
|
+
SignednessConflictWarning,
|
|
799
|
+
stacklevel=5,
|
|
800
|
+
)
|
|
801
|
+
signed = None
|
|
802
|
+
else:
|
|
803
|
+
signed = _resolve_signedness(offset, hints, stacklevel=5)
|
|
804
|
+
first = min(
|
|
805
|
+
(part.first_access for part in parts if part.first_access is not None),
|
|
806
|
+
default=None,
|
|
807
|
+
)
|
|
808
|
+
widths = sorted({part.width for part in parts})
|
|
809
|
+
rendered = ", ".join(str(width_seen) for width_seen in widths)
|
|
810
|
+
return SuggestedField(
|
|
811
|
+
offset=offset,
|
|
812
|
+
width=width,
|
|
813
|
+
read_count=reads,
|
|
814
|
+
write_count=writes,
|
|
815
|
+
first_access=first,
|
|
816
|
+
evidence=Evidence(
|
|
817
|
+
source=_MERGED_FIELD_EVIDENCE_SOURCE,
|
|
818
|
+
method=_MERGED_FIELD_EVIDENCE_METHOD,
|
|
819
|
+
confidence=Confidence.MEDIUM,
|
|
820
|
+
address=first,
|
|
821
|
+
detail=(
|
|
822
|
+
f"offset 0x{offset:x}: {width}-byte field merged from "
|
|
823
|
+
f"{len(parts)} window(s) reporting width(s) {rendered}, "
|
|
824
|
+
f"{reads} read(s), {writes} write(s), "
|
|
825
|
+
f"signedness {_signedness_text(signed)}; "
|
|
826
|
+
"field is heuristic, not a verified type"
|
|
827
|
+
),
|
|
828
|
+
extra={
|
|
829
|
+
"offset": offset,
|
|
830
|
+
"width": width,
|
|
831
|
+
"widths": widths,
|
|
832
|
+
"read_count": reads,
|
|
833
|
+
"write_count": writes,
|
|
834
|
+
"signed": signed,
|
|
835
|
+
"signed_conflict": window_conflict or len(hints) > 1,
|
|
836
|
+
"window_count": len(parts),
|
|
837
|
+
},
|
|
838
|
+
),
|
|
839
|
+
signed=signed,
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _merge_window_fields(
|
|
844
|
+
windows: Sequence[tuple[int, StructSuggestion]],
|
|
845
|
+
) -> tuple[tuple[SuggestedField, ...], bool]:
|
|
846
|
+
"""Fold every window's fields into one field per offset.
|
|
847
|
+
|
|
848
|
+
Windows must already be in ascending address order. Offsets are visited in
|
|
849
|
+
ascending order, so the warnings raised by :func:`_merge_field` -- and the
|
|
850
|
+
merged fields themselves -- are identical for the same set of addresses
|
|
851
|
+
regardless of the order the caller passed them in.
|
|
852
|
+
|
|
853
|
+
Returns:
|
|
854
|
+
Tuple of the merged fields (sorted by ascending offset, capped at
|
|
855
|
+
:data:`DEFAULT_MAX_FIELDS`) and whether offsets were dropped by the cap.
|
|
856
|
+
"""
|
|
857
|
+
per_offset: dict[int, list[SuggestedField]] = {}
|
|
858
|
+
for _address, suggestion in windows:
|
|
859
|
+
for field in suggestion.fields:
|
|
860
|
+
per_offset.setdefault(field.offset, []).append(field)
|
|
861
|
+
merged = [_merge_field(offset, per_offset[offset]) for offset in sorted(per_offset)]
|
|
862
|
+
return tuple(merged[:DEFAULT_MAX_FIELDS]), len(merged) > DEFAULT_MAX_FIELDS
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _validate_window_addresses(addresses: object, limit: int) -> tuple[int, ...]:
|
|
866
|
+
"""Validate merged window addresses and return them in ascending order.
|
|
867
|
+
|
|
868
|
+
Every address is checked for type and sign, duplicates are rejected (a second
|
|
869
|
+
decode of the same address would only double-count its evidence), and the
|
|
870
|
+
number of windows is capped at ``limit`` so the total decode work stays
|
|
871
|
+
bounded by ``limit * max_instructions`` instructions. Sorting makes the merge
|
|
872
|
+
-- including the warnings it raises -- independent of the caller's order.
|
|
873
|
+
"""
|
|
874
|
+
if isinstance(addresses, (str, bytes, bytearray)) or not isinstance(
|
|
875
|
+
addresses, Collection
|
|
876
|
+
):
|
|
877
|
+
raise AnalysisError(f"addresses must be a collection of integers, got {addresses!r}")
|
|
878
|
+
targets: list[int] = []
|
|
879
|
+
seen: set[int] = set()
|
|
880
|
+
for address in addresses:
|
|
881
|
+
if isinstance(address, bool) or not isinstance(address, int):
|
|
882
|
+
raise AnalysisError(f"address must be an integer, got {address!r}")
|
|
883
|
+
if address < 0:
|
|
884
|
+
raise AnalysisError(f"address must be non-negative, got {address}")
|
|
885
|
+
if address in seen:
|
|
886
|
+
raise AnalysisError(f"window address 0x{address:x} appears more than once")
|
|
887
|
+
seen.add(address)
|
|
888
|
+
targets.append(address)
|
|
889
|
+
if not targets:
|
|
890
|
+
raise AnalysisError("addresses must contain at least one window address")
|
|
891
|
+
if len(targets) > limit:
|
|
892
|
+
raise AnalysisError(f"too many window addresses (limit {limit}), got {len(targets)}")
|
|
893
|
+
return tuple(sorted(targets))
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _require_int(name: str, value: object, minimum: int, maximum: int) -> int:
|
|
897
|
+
"""Validate an integer option and return it as ``int``."""
|
|
898
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
899
|
+
raise AnalysisError(f"{name} must be an integer, got {value!r}")
|
|
900
|
+
if value < minimum:
|
|
901
|
+
raise AnalysisError(f"{name} must be >= {minimum}, got {value}")
|
|
902
|
+
if value > maximum:
|
|
903
|
+
raise AnalysisError(f"{name} too large (limit {maximum}), got {value}")
|
|
904
|
+
return value
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def suggest_struct(
|
|
908
|
+
path: str | Path,
|
|
909
|
+
info: BinaryInfo,
|
|
910
|
+
start: int,
|
|
911
|
+
max_instructions: int = DEFAULT_MAX_INSTRUCTIONS,
|
|
912
|
+
max_fields: int = DEFAULT_MAX_FIELDS,
|
|
913
|
+
include_stack: bool = False,
|
|
914
|
+
) -> StructSuggestion:
|
|
915
|
+
"""Suggest the struct fields a function accesses through memory operands.
|
|
916
|
+
|
|
917
|
+
Args:
|
|
918
|
+
path: Binary file the bytes are read from.
|
|
919
|
+
info: Loaded metadata used for address mapping, section bounds, the
|
|
920
|
+
decoder and the pointer width.
|
|
921
|
+
start: Virtual address of the first instruction; must be inside an
|
|
922
|
+
executable section.
|
|
923
|
+
max_instructions: Instruction budget
|
|
924
|
+
(``MIN_MAX_INSTRUCTIONS`` .. ``MAX_MAX_INSTRUCTIONS``).
|
|
925
|
+
max_fields: Ceiling on distinct field offsets kept
|
|
926
|
+
(``MIN_MAX_FIELDS`` .. ``MAX_MAX_FIELDS``). Lower offsets win, the
|
|
927
|
+
rest are dropped and reported through ``truncated``.
|
|
928
|
+
include_stack: When true, ``[rsp]``/``[rbp]``-relative operands are kept
|
|
929
|
+
instead of being treated as stack slots.
|
|
930
|
+
|
|
931
|
+
Returns:
|
|
932
|
+
The :class:`StructSuggestion` for the region, with fields sorted by
|
|
933
|
+
ascending offset, each carrying its aggregated ``signed`` hint, plus the
|
|
934
|
+
offset-gcd ``alignment`` and the ``array_groups`` runs derived from those
|
|
935
|
+
reported fields.
|
|
936
|
+
|
|
937
|
+
Raises:
|
|
938
|
+
AnalysisError: When ``start``, ``max_instructions`` or ``max_fields`` is
|
|
939
|
+
invalid, when ``start`` is not inside an executable section, when
|
|
940
|
+
the address has no file bytes, when the architecture cannot be
|
|
941
|
+
disassembled, or when no instruction could be decoded.
|
|
942
|
+
|
|
943
|
+
Warns:
|
|
944
|
+
SignednessConflictWarning: Once per offset that is accessed both as
|
|
945
|
+
signed and as unsigned. Warnings are resolved during aggregation,
|
|
946
|
+
i.e. before the ``max_fields`` cap, so they are bounded by the
|
|
947
|
+
number of distinct offsets in the window rather than by the cap. The
|
|
948
|
+
warning is informational: the suggestion itself is still returned,
|
|
949
|
+
with ``signed=None`` at the contradictory offsets.
|
|
950
|
+
|
|
951
|
+
Note:
|
|
952
|
+
Two calls with the same arguments always produce the same suggestion:
|
|
953
|
+
the sweep is linear, aggregation is per offset, and fields, widths,
|
|
954
|
+
counters and warnings are all ordered deterministically. ``alignment``
|
|
955
|
+
and ``array_groups`` are derived from the reported fields only, so
|
|
956
|
+
capping with ``max_fields`` cannot leave a group pointing at a dropped
|
|
957
|
+
field.
|
|
958
|
+
"""
|
|
959
|
+
if isinstance(start, bool) or not isinstance(start, int):
|
|
960
|
+
raise AnalysisError(f"start must be an integer, got {start!r}")
|
|
961
|
+
if start < 0:
|
|
962
|
+
raise AnalysisError(f"start must be non-negative, got {start}")
|
|
963
|
+
instruction_budget = _require_int(
|
|
964
|
+
"max_instructions", max_instructions, MIN_MAX_INSTRUCTIONS, MAX_MAX_INSTRUCTIONS
|
|
965
|
+
)
|
|
966
|
+
field_cap = _require_int("max_fields", max_fields, MIN_MAX_FIELDS, MAX_MAX_FIELDS)
|
|
967
|
+
if not isinstance(include_stack, bool):
|
|
968
|
+
raise AnalysisError(f"include_stack must be a boolean, got {include_stack!r}")
|
|
969
|
+
|
|
970
|
+
section = info.section_for_va(start)
|
|
971
|
+
if section is None or not section.is_executable:
|
|
972
|
+
raise AnalysisError(f"address 0x{start:x} is not inside an executable section")
|
|
973
|
+
|
|
974
|
+
code = read_code(path, info, start, instruction_budget * _MAX_INSTRUCTION_BYTES)
|
|
975
|
+
try:
|
|
976
|
+
disassembler = Disassembler(info.architecture, info.bitness, info.endianness)
|
|
977
|
+
result = disassembler.disassemble(
|
|
978
|
+
code, base_address=start, max_instructions=instruction_budget
|
|
979
|
+
)
|
|
980
|
+
except DisassemblyError as exc:
|
|
981
|
+
raise AnalysisError(f"cannot disassemble 0x{start:x}: {exc}") from exc
|
|
982
|
+
|
|
983
|
+
instructions = list(result.instructions)
|
|
984
|
+
if not instructions:
|
|
985
|
+
raise AnalysisError(f"no instruction decoded at 0x{start:x}")
|
|
986
|
+
|
|
987
|
+
pointer_width = _pointer_width(info.bitness)
|
|
988
|
+
accesses = [
|
|
989
|
+
(access, _signedness(instruction.mnemonic))
|
|
990
|
+
for instruction in instructions
|
|
991
|
+
for access in parse_memory_operands(
|
|
992
|
+
instruction, include_stack=include_stack, pointer_width=pointer_width
|
|
993
|
+
)
|
|
994
|
+
]
|
|
995
|
+
all_fields = _aggregate(accesses)
|
|
996
|
+
fields = tuple(all_fields[:field_cap])
|
|
997
|
+
truncated = len(all_fields) > field_cap
|
|
998
|
+
alignment = _alignment(fields)
|
|
999
|
+
array_groups = _array_groups(fields)
|
|
1000
|
+
|
|
1001
|
+
evidence = Evidence(
|
|
1002
|
+
source=_SUGGESTION_EVIDENCE_SOURCE,
|
|
1003
|
+
method=_SUGGESTION_EVIDENCE_METHOD,
|
|
1004
|
+
confidence=Confidence.MEDIUM,
|
|
1005
|
+
address=start,
|
|
1006
|
+
detail=(
|
|
1007
|
+
f"{len(fields)} suggested field(s) from {len(accesses)} memory access(es) "
|
|
1008
|
+
f"over {len(instructions)} instruction(s) at 0x{start:x}"
|
|
1009
|
+
+ (f", alignment {alignment}" if alignment is not None else "")
|
|
1010
|
+
+ f", {len(array_groups)} array candidate(s)"
|
|
1011
|
+
+ (" (field cap reached)" if truncated else "")
|
|
1012
|
+
+ "; fields are heuristic from operand shapes, not verified types"
|
|
1013
|
+
),
|
|
1014
|
+
extra={
|
|
1015
|
+
"max_instructions": instruction_budget,
|
|
1016
|
+
"max_fields": field_cap,
|
|
1017
|
+
"instruction_count": len(instructions),
|
|
1018
|
+
"memory_access_count": len(accesses),
|
|
1019
|
+
"field_count": len(fields),
|
|
1020
|
+
"truncated": truncated,
|
|
1021
|
+
"include_stack": include_stack,
|
|
1022
|
+
"alignment": alignment,
|
|
1023
|
+
"array_group_count": len(array_groups),
|
|
1024
|
+
"decoded_bytes": result.consumed_bytes,
|
|
1025
|
+
},
|
|
1026
|
+
)
|
|
1027
|
+
return StructSuggestion(
|
|
1028
|
+
address=start,
|
|
1029
|
+
fields=fields,
|
|
1030
|
+
instruction_count=len(instructions),
|
|
1031
|
+
max_fields=field_cap,
|
|
1032
|
+
truncated=truncated,
|
|
1033
|
+
include_stack=include_stack,
|
|
1034
|
+
evidence=evidence,
|
|
1035
|
+
alignment=alignment,
|
|
1036
|
+
array_groups=array_groups,
|
|
1037
|
+
memory_access_count=len(accesses),
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _warn_on_overlapping_windows(
|
|
1042
|
+
windows: Sequence[tuple[int, StructSuggestion]],
|
|
1043
|
+
) -> None:
|
|
1044
|
+
"""Warn once for every window that starts inside its predecessor's decode.
|
|
1045
|
+
|
|
1046
|
+
Duplicate addresses are rejected outright, because a second decode of the same
|
|
1047
|
+
address only double counts. A partial overlap is a legitimate way to look
|
|
1048
|
+
further into one function, so it is announced instead of refused: the shared
|
|
1049
|
+
instructions are counted by both windows.
|
|
1050
|
+
"""
|
|
1051
|
+
previous_start: int | None = None
|
|
1052
|
+
previous_end: int | None = None
|
|
1053
|
+
for start, suggestion in windows:
|
|
1054
|
+
if previous_end is not None and start < previous_end:
|
|
1055
|
+
warnings.warn(
|
|
1056
|
+
f"window 0x{start:x} overlaps window 0x{previous_start:x}: the shared "
|
|
1057
|
+
"instructions are counted by both windows, so per-offset counters are "
|
|
1058
|
+
"per-window sums and may exceed the number of distinct accesses",
|
|
1059
|
+
WindowOverlapWarning,
|
|
1060
|
+
stacklevel=5,
|
|
1061
|
+
)
|
|
1062
|
+
decoded = int(suggestion.evidence.extra.get("decoded_bytes") or 0)
|
|
1063
|
+
previous_start = start
|
|
1064
|
+
previous_end = start + decoded if decoded else None
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def suggest_structs(
|
|
1068
|
+
path: str | Path,
|
|
1069
|
+
info: BinaryInfo,
|
|
1070
|
+
addresses: Collection[int],
|
|
1071
|
+
max_instructions: int = DEFAULT_MAX_INSTRUCTIONS,
|
|
1072
|
+
include_stack: bool = False,
|
|
1073
|
+
max_windows: int = MAX_MERGED_WINDOWS,
|
|
1074
|
+
) -> StructSuggestion:
|
|
1075
|
+
"""Merge the struct evidence of several windows into one suggestion.
|
|
1076
|
+
|
|
1077
|
+
Every address is inspected with exactly one :func:`suggest_struct` call --
|
|
1078
|
+
same budget, same stack policy -- and the per-window fields are then folded
|
|
1079
|
+
into one field per offset: counters are summed, the widest per-window width
|
|
1080
|
+
wins, the earliest access is kept, and signedness is combined with the
|
|
1081
|
+
single-window rule. ``alignment``, ``array_groups`` and ``truncated`` are
|
|
1082
|
+
recomputed from the merged fields, so a layout that only becomes visible
|
|
1083
|
+
across windows is still reported.
|
|
1084
|
+
|
|
1085
|
+
Args:
|
|
1086
|
+
path: Binary file the bytes are read from.
|
|
1087
|
+
info: Loaded metadata used for address mapping, section bounds, the
|
|
1088
|
+
decoder and the pointer width.
|
|
1089
|
+
addresses: Window start addresses inside the same binary. Each must be
|
|
1090
|
+
inside an executable section; duplicates are rejected instead of
|
|
1091
|
+
being counted twice.
|
|
1092
|
+
max_instructions: Per-window instruction budget
|
|
1093
|
+
(``MIN_MAX_INSTRUCTIONS`` .. ``MAX_MAX_INSTRUCTIONS``). Every window
|
|
1094
|
+
is decoded once with this budget, never more.
|
|
1095
|
+
include_stack: When true, ``[rsp]``/``[rbp]``-relative operands are kept.
|
|
1096
|
+
max_windows: Ceiling on the number of merged windows
|
|
1097
|
+
(``MIN_MERGED_WINDOWS`` .. :data:`MAX_MERGED_WINDOWS`).
|
|
1098
|
+
|
|
1099
|
+
Returns:
|
|
1100
|
+
A :class:`StructSuggestion` whose ``address`` is the **lowest** merged
|
|
1101
|
+
address, whose ``instruction_count`` is the total over all windows, and
|
|
1102
|
+
whose fields are sorted by ascending offset and capped at
|
|
1103
|
+
:data:`DEFAULT_MAX_FIELDS`. ``max_fields`` reports that cap. The evidence
|
|
1104
|
+
lists the window count and every merged address in ``extra``.
|
|
1105
|
+
|
|
1106
|
+
Raises:
|
|
1107
|
+
AnalysisError: When ``addresses`` is not a non-empty collection of
|
|
1108
|
+
non-negative integers, when an address appears twice, when more than
|
|
1109
|
+
``max_windows`` addresses are given, when ``max_instructions`` or
|
|
1110
|
+
``max_windows`` is out of range, when ``include_stack`` is not a
|
|
1111
|
+
bool, or when any single window fails the :func:`suggest_struct`
|
|
1112
|
+
checks (not executable, no bytes, undecodable).
|
|
1113
|
+
|
|
1114
|
+
Warns:
|
|
1115
|
+
WidthConflictWarning: Once per offset whose per-window dominant widths
|
|
1116
|
+
differ; the merged field keeps the widest one.
|
|
1117
|
+
SignednessConflictWarning: Once per offset whose surviving per-window
|
|
1118
|
+
hints disagree, including an offset that contradicted itself inside one
|
|
1119
|
+
window while another window hinted only one way.
|
|
1120
|
+
WindowOverlapWarning: Once per window that starts inside its predecessor's
|
|
1121
|
+
decoded range; the shared instructions are then counted by both
|
|
1122
|
+
windows.
|
|
1123
|
+
|
|
1124
|
+
Note:
|
|
1125
|
+
Work is bounded four ways: at most ``max_windows`` windows
|
|
1126
|
+
(:data:`MAX_MERGED_WINDOWS`), at most one decode of ``max_instructions``
|
|
1127
|
+
instructions per window, at most :data:`DEFAULT_MAX_FIELDS` reported
|
|
1128
|
+
offsets per window, and therefore at most ``max_windows *
|
|
1129
|
+
max_instructions`` decoded instructions in total. Addresses are sorted
|
|
1130
|
+
before the merge, so the result -- including the order of warnings -- is
|
|
1131
|
+
identical for any permutation of the same address set. A window that hit
|
|
1132
|
+
its own field cap cannot contribute the offsets it dropped, so the merged
|
|
1133
|
+
``truncated`` is the OR of the merge cap and every window cap, and
|
|
1134
|
+
``window_truncated`` names the windows that hit theirs. The result is still
|
|
1135
|
+
a heuristic, never a verified type.
|
|
1136
|
+
"""
|
|
1137
|
+
instruction_budget = _require_int(
|
|
1138
|
+
"max_instructions", max_instructions, MIN_MAX_INSTRUCTIONS, MAX_MAX_INSTRUCTIONS
|
|
1139
|
+
)
|
|
1140
|
+
window_budget = _require_int(
|
|
1141
|
+
"max_windows", max_windows, MIN_MERGED_WINDOWS, MAX_MERGED_WINDOWS
|
|
1142
|
+
)
|
|
1143
|
+
if not isinstance(include_stack, bool):
|
|
1144
|
+
raise AnalysisError(f"include_stack must be a boolean, got {include_stack!r}")
|
|
1145
|
+
targets = _validate_window_addresses(addresses, window_budget)
|
|
1146
|
+
|
|
1147
|
+
windows = [
|
|
1148
|
+
(
|
|
1149
|
+
start,
|
|
1150
|
+
suggest_struct(
|
|
1151
|
+
path,
|
|
1152
|
+
info,
|
|
1153
|
+
start,
|
|
1154
|
+
max_instructions=instruction_budget,
|
|
1155
|
+
max_fields=DEFAULT_MAX_FIELDS,
|
|
1156
|
+
include_stack=include_stack,
|
|
1157
|
+
),
|
|
1158
|
+
)
|
|
1159
|
+
for start in targets
|
|
1160
|
+
]
|
|
1161
|
+
fields, merged_truncated = _merge_window_fields(windows)
|
|
1162
|
+
alignment = _alignment(fields)
|
|
1163
|
+
array_groups = _array_groups(fields)
|
|
1164
|
+
_warn_on_overlapping_windows(windows)
|
|
1165
|
+
instruction_count = sum(suggestion.instruction_count for _start, suggestion in windows)
|
|
1166
|
+
access_count = sum(suggestion.memory_access_count for _start, suggestion in windows)
|
|
1167
|
+
capped_windows = [start for start, suggestion in windows if suggestion.truncated]
|
|
1168
|
+
truncated = merged_truncated or bool(capped_windows)
|
|
1169
|
+
primary = targets[0]
|
|
1170
|
+
|
|
1171
|
+
evidence = Evidence(
|
|
1172
|
+
source=_MERGED_SUGGESTION_EVIDENCE_SOURCE,
|
|
1173
|
+
method=_MERGED_SUGGESTION_EVIDENCE_METHOD,
|
|
1174
|
+
confidence=Confidence.MEDIUM,
|
|
1175
|
+
address=primary,
|
|
1176
|
+
detail=(
|
|
1177
|
+
f"{len(fields)} merged field(s) from {len(windows)} window(s) "
|
|
1178
|
+
f"({', '.join(f'0x{start:x}' for start in targets)}) over "
|
|
1179
|
+
f"{instruction_count} instruction(s)"
|
|
1180
|
+
+ (f", alignment {alignment}" if alignment is not None else "")
|
|
1181
|
+
+ f", {len(array_groups)} array candidate(s)"
|
|
1182
|
+
+ (" (field cap reached)" if truncated else "")
|
|
1183
|
+
+ "; one field per offset with the widest width winning, fields are "
|
|
1184
|
+
"heuristic from operand shapes, not verified types"
|
|
1185
|
+
),
|
|
1186
|
+
extra={
|
|
1187
|
+
"window_count": len(windows),
|
|
1188
|
+
"windows": [f"0x{start:x}" for start in targets],
|
|
1189
|
+
"max_windows": window_budget,
|
|
1190
|
+
"max_instructions": instruction_budget,
|
|
1191
|
+
"max_fields": DEFAULT_MAX_FIELDS,
|
|
1192
|
+
"instruction_count": instruction_count,
|
|
1193
|
+
"memory_access_count": access_count,
|
|
1194
|
+
"field_count": len(fields),
|
|
1195
|
+
"truncated": truncated,
|
|
1196
|
+
"window_truncated": [f"0x{start:x}" for start in capped_windows],
|
|
1197
|
+
"include_stack": include_stack,
|
|
1198
|
+
"alignment": alignment,
|
|
1199
|
+
"array_group_count": len(array_groups),
|
|
1200
|
+
},
|
|
1201
|
+
)
|
|
1202
|
+
return StructSuggestion(
|
|
1203
|
+
address=primary,
|
|
1204
|
+
fields=fields,
|
|
1205
|
+
instruction_count=instruction_count,
|
|
1206
|
+
max_fields=DEFAULT_MAX_FIELDS,
|
|
1207
|
+
truncated=truncated,
|
|
1208
|
+
include_stack=include_stack,
|
|
1209
|
+
evidence=evidence,
|
|
1210
|
+
alignment=alignment,
|
|
1211
|
+
array_groups=array_groups,
|
|
1212
|
+
)
|
|
1213
|
+
|
|
1214
|
+
|