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,1201 @@
|
|
|
1
|
+
"""Control-flow graph construction by linear sweep (phase 2).
|
|
2
|
+
|
|
3
|
+
This module is a *phase 2* analysis step. It decodes one contiguous region of
|
|
4
|
+
an executable section with :class:`~codevanta.core.disassembler.Disassembler`
|
|
5
|
+
and derives basic blocks from purely local rules:
|
|
6
|
+
|
|
7
|
+
1. **Linear sweep.** ``max_instructions`` instruction slots are read from the
|
|
8
|
+
file (bounded by the section's file-backed bytes) and decoded sequentially
|
|
9
|
+
from ``start``. The sweep stops at ``max_instructions`` or at the end of the
|
|
10
|
+
section; ``FunctionCFG.truncated`` says whether the sweep stopped early.
|
|
11
|
+
2. **Leaders.** ``start``; the instruction that follows any terminator; and the
|
|
12
|
+
direct target of a branch when that target is itself a decoded instruction
|
|
13
|
+
address.
|
|
14
|
+
3. **Blocks.** Consecutive instructions between two leaders form one
|
|
15
|
+
:class:`BasicBlock`; the block ``end`` is the address just past its last
|
|
16
|
+
instruction (exclusive).
|
|
17
|
+
4. **Successors.** ``ret``-like instructions and unconditional jumps have no
|
|
18
|
+
fallthrough; conditional jumps and ordinary instructions fall through to the
|
|
19
|
+
next instruction; unconditional and conditional jumps additionally gain
|
|
20
|
+
their direct branch target. Successors are deduplicated, sorted, and always
|
|
21
|
+
reference a decoded block start.
|
|
22
|
+
|
|
23
|
+
The sweep is deliberately conservative: block boundaries, terminators, and
|
|
24
|
+
successors are heuristic (mnemonic based), never proof of the true control
|
|
25
|
+
flow. Every block and the function carry :class:`Evidence` stating exactly how
|
|
26
|
+
they were produced, and the target binary is only ever read, never executed.
|
|
27
|
+
Indirect branches (``jmp rax``, ``jmp qword ptr [...]``, jump tables) are not
|
|
28
|
+
resolved in phase 2, so their blocks end with an unresolved terminator.
|
|
29
|
+
|
|
30
|
+
:func:`build_recursive_cfg` offers the same block model from a different
|
|
31
|
+
traversal: instead of sweeping a contiguous window it *follows* control flow
|
|
32
|
+
from ``start`` with a worklist of block starts and a visited set, so
|
|
33
|
+
unreachable bytes (padding, alignment tables, data embedded in ``.text``) are
|
|
34
|
+
never reported as code. ``build_cfg`` stays the flat-window baseline.
|
|
35
|
+
|
|
36
|
+
A traversal can decode one block *past* a leader it only discovers later (a
|
|
37
|
+
backward ``jmp`` into the middle of an already decoded run). A post-traversal
|
|
38
|
+
split pass therefore cuts every block at the leaders inside it, so the reported
|
|
39
|
+
ranges are disjoint and every instruction address is reported exactly once.
|
|
40
|
+
|
|
41
|
+
:func:`build_recursive_cfg` can *optionally* resolve indirect jumps through
|
|
42
|
+
pointer tables (``jmp qword ptr [rip + disp]``, ``jmp qword ptr [abs]``) with
|
|
43
|
+
:func:`codevanta.analysis.jump_tables.resolve_jump_table`. The behaviour is
|
|
44
|
+
opt-in (``resolve_indirect=False`` by default, so every pre-existing call and
|
|
45
|
+
its results are unchanged): when enabled, the memory operand is turned into an
|
|
46
|
+
absolute table address, the table is decoded, and the executable targets become
|
|
47
|
+
successors -- deduplicated, sorted and followed by the traversal like any other
|
|
48
|
+
leader. Resolution is bounded per traversal (``max_jump_tables`` tables,
|
|
49
|
+
``max_table_entries`` slots each). Register-indirect jumps (``jmp rax``,
|
|
50
|
+
``jmp qword ptr [rax]``), unresolvable expressions and tables beyond the budget
|
|
51
|
+
leave the block without those successors and are reported once through
|
|
52
|
+
``FunctionCFG.warnings``. Nothing is executed; a table is only read through the
|
|
53
|
+
containing section's file bytes.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
from collections import deque
|
|
59
|
+
from dataclasses import dataclass
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
from typing import Any
|
|
62
|
+
|
|
63
|
+
from codevanta.analysis.jump_tables import MAX_ENTRIES as _JUMP_TABLE_MAX_ENTRIES
|
|
64
|
+
from codevanta.analysis.jump_tables import resolve_jump_table
|
|
65
|
+
from codevanta.core.disassembler import Disassembler, DisassemblyError
|
|
66
|
+
from codevanta.core.functions import discover_functions
|
|
67
|
+
from codevanta.core.loader import BinaryInfo
|
|
68
|
+
from codevanta.core.report import MAX_DISASSEMBLY_COUNT, AnalysisError, read_code
|
|
69
|
+
from codevanta.evidence.confidence import Confidence
|
|
70
|
+
from codevanta.evidence.models import Evidence
|
|
71
|
+
from codevanta.ir.instruction import IRInstruction
|
|
72
|
+
|
|
73
|
+
__all__ = ["BasicBlock", "FunctionCFG", "build_cfg", "build_recursive_cfg"]
|
|
74
|
+
|
|
75
|
+
#: Widest instruction any supported architecture can decode (x86 allows 15).
|
|
76
|
+
_MAX_INSTRUCTION_BYTES = 16
|
|
77
|
+
|
|
78
|
+
#: Decode window for a single recursively traversed block. A block normally
|
|
79
|
+
#: ends at a terminator long before this, so the cap only bounds pathological
|
|
80
|
+
#: runs of unterminated instructions (such a run is simply split into more
|
|
81
|
+
#: blocks at the fallthrough, which yields the same successors).
|
|
82
|
+
_MAX_BLOCK_INSTRUCTIONS = 256
|
|
83
|
+
|
|
84
|
+
#: Mnemonics that unconditionally end a block and never fall through.
|
|
85
|
+
_RET_MNEMONICS = frozenset(
|
|
86
|
+
{"ret", "retn", "retf", "retq", "iret", "iretd", "iretq", "eret"}
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
#: Unconditional jump mnemonics per supported architecture (x86 / ARM / AArch64).
|
|
90
|
+
_UNCONDITIONAL_JUMPS = frozenset({"jmp", "ljmp", "b", "br", "bx"})
|
|
91
|
+
|
|
92
|
+
#: Conditional branch mnemonics that are not covered by a ``j``/``b.`` prefix.
|
|
93
|
+
_CONDITIONAL_JUMP_MNEMONICS = frozenset(
|
|
94
|
+
{
|
|
95
|
+
# AArch64 compare-and-branch / test-and-branch
|
|
96
|
+
"cbz",
|
|
97
|
+
"cbnz",
|
|
98
|
+
"tbz",
|
|
99
|
+
"tbnz",
|
|
100
|
+
# ARM (A32/T32) conditional branches, written without the "b." infix
|
|
101
|
+
"beq",
|
|
102
|
+
"bne",
|
|
103
|
+
"bhs",
|
|
104
|
+
"blo",
|
|
105
|
+
"bcs",
|
|
106
|
+
"bcc",
|
|
107
|
+
"bmi",
|
|
108
|
+
"bpl",
|
|
109
|
+
"bvs",
|
|
110
|
+
"bvc",
|
|
111
|
+
"bhi",
|
|
112
|
+
"bls",
|
|
113
|
+
"bge",
|
|
114
|
+
"blt",
|
|
115
|
+
"bgt",
|
|
116
|
+
"ble",
|
|
117
|
+
"bal",
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
#: Default cap on jump tables resolved during one recursive traversal.
|
|
123
|
+
_DEFAULT_MAX_JUMP_TABLES = 8
|
|
124
|
+
|
|
125
|
+
#: Default cap on pointer slots decoded from a single jump table.
|
|
126
|
+
_DEFAULT_MAX_TABLE_ENTRIES = 256
|
|
127
|
+
|
|
128
|
+
#: ``ptr`` size keyword that denotes a pointer-sized memory read, by width.
|
|
129
|
+
_POINTER_SIZE_KEYWORDS = {4: "dword", 8: "qword"}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class BasicBlock:
|
|
134
|
+
"""A single-entry run of instructions with its outgoing edges.
|
|
135
|
+
|
|
136
|
+
Attributes:
|
|
137
|
+
start: Virtual address of the first instruction in the block.
|
|
138
|
+
end: Virtual address just past the last instruction (exclusive).
|
|
139
|
+
instructions: Decoded instructions as ``IRInstruction.to_dict()`` data.
|
|
140
|
+
successors: Sorted, deduplicated successor block addresses.
|
|
141
|
+
terminator: Mnemonic of the block's final instruction when it ends the
|
|
142
|
+
block unconditionally or conditionally, else ``None``.
|
|
143
|
+
evidence: How this block was derived.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
start: int
|
|
147
|
+
end: int
|
|
148
|
+
instructions: list[dict[str, Any]]
|
|
149
|
+
successors: tuple[int, ...]
|
|
150
|
+
terminator: str | None
|
|
151
|
+
evidence: Evidence
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def instruction_count(self) -> int:
|
|
155
|
+
"""Number of instructions in the block."""
|
|
156
|
+
return len(self.instructions)
|
|
157
|
+
|
|
158
|
+
def to_dict(self) -> dict[str, Any]:
|
|
159
|
+
"""Serialize the block for the JSON report."""
|
|
160
|
+
return {
|
|
161
|
+
"start": f"0x{self.start:x}",
|
|
162
|
+
"end": f"0x{self.end:x}",
|
|
163
|
+
"instruction_count": self.instruction_count,
|
|
164
|
+
"terminator": self.terminator,
|
|
165
|
+
"successors": [f"0x{address:x}" for address in self.successors],
|
|
166
|
+
"instructions": list(self.instructions),
|
|
167
|
+
"evidence": self.evidence.to_dict(),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class FunctionCFG:
|
|
173
|
+
"""Control-flow graph for one function region.
|
|
174
|
+
|
|
175
|
+
Attributes:
|
|
176
|
+
function_address: Requested start address (also the entry block start).
|
|
177
|
+
function_name: Name from function discovery when a candidate starts at
|
|
178
|
+
``function_address``, else ``None``.
|
|
179
|
+
blocks: Blocks in ascending address order with disjoint ranges.
|
|
180
|
+
instruction_count: Total number of decoded instructions; for
|
|
181
|
+
:func:`build_recursive_cfg` this counts *unique* instruction
|
|
182
|
+
addresses across the disjoint blocks.
|
|
183
|
+
truncated: ``True`` when instruction decoding or block reporting was
|
|
184
|
+
cut short by a limit, so the graph may be incomplete.
|
|
185
|
+
evidence: How the graph as a whole was derived.
|
|
186
|
+
warnings: Human-readable caveats about the sweep.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
function_address: int
|
|
190
|
+
function_name: str | None
|
|
191
|
+
blocks: list[BasicBlock]
|
|
192
|
+
instruction_count: int
|
|
193
|
+
truncated: bool
|
|
194
|
+
evidence: Evidence
|
|
195
|
+
warnings: tuple[str, ...] = ()
|
|
196
|
+
|
|
197
|
+
def to_dict(self) -> dict[str, Any]:
|
|
198
|
+
"""Serialize the CFG for the JSON report."""
|
|
199
|
+
return {
|
|
200
|
+
"function_address": f"0x{self.function_address:x}",
|
|
201
|
+
"function_name": self.function_name,
|
|
202
|
+
"block_count": len(self.blocks),
|
|
203
|
+
"instruction_count": self.instruction_count,
|
|
204
|
+
"truncated": self.truncated,
|
|
205
|
+
"warnings": list(self.warnings),
|
|
206
|
+
"blocks": [block.to_dict() for block in self.blocks],
|
|
207
|
+
"evidence": self.evidence.to_dict(),
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def build_cfg(
|
|
212
|
+
path: str | Path,
|
|
213
|
+
info: BinaryInfo,
|
|
214
|
+
start: int,
|
|
215
|
+
max_instructions: int = 2000,
|
|
216
|
+
max_blocks: int = 500,
|
|
217
|
+
) -> FunctionCFG:
|
|
218
|
+
"""Build a CFG for the region starting at ``start`` by linear sweep.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
path: File the bytes are read from (read-only; never executed).
|
|
222
|
+
info: Loaded binary metadata used for address mapping and decoding.
|
|
223
|
+
start: Virtual address of the first instruction; must be inside an
|
|
224
|
+
executable section.
|
|
225
|
+
max_instructions: Instruction budget for the sweep (1 ..
|
|
226
|
+
``MAX_DISASSEMBLY_COUNT``).
|
|
227
|
+
max_blocks: Maximum number of blocks to report (>= 1). When the sweep
|
|
228
|
+
yields more blocks the graph is cut and marked ``truncated``.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
The reconstructed :class:`FunctionCFG`.
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
AnalysisError: When ``start`` is not executable code, when a limit is
|
|
235
|
+
out of range, when the start address has no file bytes, or when the
|
|
236
|
+
architecture cannot be disassembled.
|
|
237
|
+
"""
|
|
238
|
+
if max_instructions < 1:
|
|
239
|
+
raise AnalysisError("max_instructions must be >= 1")
|
|
240
|
+
if max_instructions > MAX_DISASSEMBLY_COUNT:
|
|
241
|
+
raise AnalysisError(f"max_instructions too large (limit {MAX_DISASSEMBLY_COUNT})")
|
|
242
|
+
if max_blocks < 1:
|
|
243
|
+
raise AnalysisError("max_blocks must be >= 1")
|
|
244
|
+
if max_blocks > MAX_DISASSEMBLY_COUNT:
|
|
245
|
+
raise AnalysisError(f"max_blocks too large (limit {MAX_DISASSEMBLY_COUNT})")
|
|
246
|
+
|
|
247
|
+
section = info.section_for_va(start)
|
|
248
|
+
if section is None or not section.is_executable:
|
|
249
|
+
raise AnalysisError(f"address 0x{start:x} is not inside an executable section")
|
|
250
|
+
|
|
251
|
+
code = read_code(path, info, start, max_instructions * _MAX_INSTRUCTION_BYTES)
|
|
252
|
+
try:
|
|
253
|
+
disassembler = Disassembler(info.architecture, info.bitness, info.endianness)
|
|
254
|
+
result = disassembler.disassemble(
|
|
255
|
+
code,
|
|
256
|
+
base_address=start,
|
|
257
|
+
max_instructions=max_instructions,
|
|
258
|
+
)
|
|
259
|
+
except DisassemblyError as exc:
|
|
260
|
+
raise AnalysisError(f"cannot disassemble 0x{start:x}: {exc}") from exc
|
|
261
|
+
|
|
262
|
+
instructions = list(result.instructions)
|
|
263
|
+
warnings: list[str] = []
|
|
264
|
+
truncated = bool(result.truncated)
|
|
265
|
+
if truncated:
|
|
266
|
+
warnings.append(
|
|
267
|
+
f"linear sweep stopped after {len(instructions)} instruction(s) "
|
|
268
|
+
f"(max_instructions={max_instructions}); the graph may be incomplete"
|
|
269
|
+
)
|
|
270
|
+
elif instructions and result.consumed_bytes < len(code):
|
|
271
|
+
warnings.append(
|
|
272
|
+
f"linear sweep stopped at 0x{start + result.consumed_bytes:x} before the end "
|
|
273
|
+
"of the section (undecodable bytes)"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
blocks, blocks_truncated, block_warnings = _split_blocks(instructions, max_blocks)
|
|
277
|
+
truncated = truncated or blocks_truncated
|
|
278
|
+
warnings.extend(block_warnings)
|
|
279
|
+
if not instructions:
|
|
280
|
+
warnings.append(f"no instructions decoded at 0x{start:x}")
|
|
281
|
+
|
|
282
|
+
evidence = Evidence(
|
|
283
|
+
source="cfg.linear_sweep",
|
|
284
|
+
method="linear_sweep",
|
|
285
|
+
confidence=Confidence.MEDIUM,
|
|
286
|
+
address=start,
|
|
287
|
+
detail=(
|
|
288
|
+
f"linear sweep decoded {len(instructions)} instruction(s) into {len(blocks)} "
|
|
289
|
+
"block(s); block boundaries are heuristic (leader/terminator rules, phase 2)"
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
return FunctionCFG(
|
|
293
|
+
function_address=start,
|
|
294
|
+
function_name=_function_name_for(info, start),
|
|
295
|
+
blocks=blocks,
|
|
296
|
+
instruction_count=len(instructions),
|
|
297
|
+
truncated=truncated,
|
|
298
|
+
evidence=evidence,
|
|
299
|
+
warnings=tuple(warnings),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def build_recursive_cfg(
|
|
304
|
+
path: str | Path,
|
|
305
|
+
info: BinaryInfo,
|
|
306
|
+
start: int,
|
|
307
|
+
max_instructions: int = 2000,
|
|
308
|
+
max_blocks: int = 500,
|
|
309
|
+
resolve_indirect: bool = False,
|
|
310
|
+
max_jump_tables: int = _DEFAULT_MAX_JUMP_TABLES,
|
|
311
|
+
max_table_entries: int = _DEFAULT_MAX_TABLE_ENTRIES,
|
|
312
|
+
) -> FunctionCFG:
|
|
313
|
+
"""Build a CFG by *following* control flow from ``start`` (recursive traversal).
|
|
314
|
+
|
|
315
|
+
Unlike :func:`build_cfg` this never sweeps a contiguous window: a worklist
|
|
316
|
+
of block starts plus a visited set drive the traversal, so only code
|
|
317
|
+
reachable from ``start`` becomes a block.
|
|
318
|
+
|
|
319
|
+
Traversal rules:
|
|
320
|
+
|
|
321
|
+
1. **Worklist.** ``start`` is the first leader. Each block is decoded from
|
|
322
|
+
its leader until a terminator mnemonic, the instruction budget, an
|
|
323
|
+
already-visited leader (the block ends just before it), or the end of an
|
|
324
|
+
executable section.
|
|
325
|
+
2. **Successors.** A conditional jump leads to its fallthrough *and* its
|
|
326
|
+
direct target, an unconditional jump to its direct target only (no
|
|
327
|
+
fallthrough), a return to nothing, and any other instruction to its
|
|
328
|
+
fallthrough. Only targets inside an executable, file-backed section are
|
|
329
|
+
kept.
|
|
330
|
+
3. **Calls stay flat.** ``call`` is an ordinary instruction: the return
|
|
331
|
+
address is the fallthrough and the callee is *not* followed.
|
|
332
|
+
4. **Bounded.** ``max_instructions`` caps decoded instructions and
|
|
333
|
+
``max_blocks`` caps reported blocks; the visited set plus the worklist
|
|
334
|
+
make cycles terminate. Hitting either limit marks the graph
|
|
335
|
+
``truncated``.
|
|
336
|
+
5. **Disjoint blocks.** Traversal hypotheses can overlap: a block decoded
|
|
337
|
+
before a backward ``jmp`` is discovered runs past that target. A post-
|
|
338
|
+
traversal split pass cuts every block at the leaders inside it (a split
|
|
339
|
+
leading segment falls through to the split address and has no
|
|
340
|
+
terminator, the trailing segment keeps the original terminator and
|
|
341
|
+
successors), then keeps the most complete block per start address. The
|
|
342
|
+
reported ranges therefore never overlap and ``instruction_count`` counts
|
|
343
|
+
unique instruction addresses.
|
|
344
|
+
6. **Indirect jumps (opt-in).** With ``resolve_indirect=True`` an indirect
|
|
345
|
+
``jmp`` through a pointer-sized memory operand (``jmp qword ptr [rip +
|
|
346
|
+
disp]`` or ``jmp qword ptr [abs]``) becomes a jump-table lookup: the
|
|
347
|
+
operand is turned into an absolute table address and
|
|
348
|
+
:func:`~codevanta.analysis.jump_tables.resolve_jump_table` decodes up to
|
|
349
|
+
``max_table_entries`` slots from it. Executable, file-backed targets
|
|
350
|
+
become that block's successors (deduplicated, sorted) and are followed
|
|
351
|
+
like any other leader. At most ``max_jump_tables`` tables are resolved
|
|
352
|
+
per traversal; register/indexed operands, unreadable tables and tables
|
|
353
|
+
past the budget leave the block without those successors, and every such
|
|
354
|
+
limitation is reported once (prefixed with the block start) in
|
|
355
|
+
:attr:`FunctionCFG.warnings`. Nothing is executed.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
path: File the bytes are read from (read-only; never executed).
|
|
359
|
+
info: Loaded binary metadata used for address mapping and decoding.
|
|
360
|
+
start: Virtual address of the first instruction; must be inside an
|
|
361
|
+
executable section.
|
|
362
|
+
max_instructions: Total instruction budget (1 ..
|
|
363
|
+
``MAX_DISASSEMBLY_COUNT``).
|
|
364
|
+
max_blocks: Maximum number of blocks to report (1 ..
|
|
365
|
+
``MAX_DISASSEMBLY_COUNT``).
|
|
366
|
+
resolve_indirect: When true, indirect jumps through a pointer table are
|
|
367
|
+
resolved (see rule 6). ``False`` (the default) keeps the historical
|
|
368
|
+
behaviour exactly: an indirect jump simply has no successor and is
|
|
369
|
+
counted as unresolved.
|
|
370
|
+
max_jump_tables: Maximum number of jump tables resolved during this
|
|
371
|
+
traversal (1 .. ``MAX_DISASSEMBLY_COUNT``); ignored unless
|
|
372
|
+
``resolve_indirect`` is true.
|
|
373
|
+
max_table_entries: Maximum pointer slots decoded per table (1 ..
|
|
374
|
+
``jump_tables.MAX_ENTRIES``); ignored unless ``resolve_indirect`` is
|
|
375
|
+
true.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
The reconstructed :class:`FunctionCFG` with blocks in ascending
|
|
379
|
+
address order.
|
|
380
|
+
|
|
381
|
+
Raises:
|
|
382
|
+
AnalysisError: When ``start`` is not executable code, when a limit is
|
|
383
|
+
out of range (including the jump-table limits), or when the
|
|
384
|
+
architecture cannot be disassembled.
|
|
385
|
+
"""
|
|
386
|
+
_validate_limits(max_instructions, max_blocks)
|
|
387
|
+
_validate_indirect_limits(max_jump_tables, max_table_entries)
|
|
388
|
+
|
|
389
|
+
section = info.section_for_va(start)
|
|
390
|
+
if section is None or not section.is_executable:
|
|
391
|
+
raise AnalysisError(f"address 0x{start:x} is not inside an executable section")
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
disassembler = Disassembler(info.architecture, info.bitness, info.endianness)
|
|
395
|
+
except DisassemblyError as exc:
|
|
396
|
+
raise AnalysisError(f"cannot disassemble 0x{start:x}: {exc}") from exc
|
|
397
|
+
|
|
398
|
+
# Leaders already queued are block starts: this set *is* the visited set.
|
|
399
|
+
leaders: set[int] = {start}
|
|
400
|
+
worklist: deque[int] = deque([start])
|
|
401
|
+
decoded: dict[int, _Segment] = {}
|
|
402
|
+
remaining = max_instructions
|
|
403
|
+
indirect_unresolved = 0
|
|
404
|
+
budget_exhausted = False
|
|
405
|
+
dropped: list[int] = []
|
|
406
|
+
warnings: list[str] = []
|
|
407
|
+
tables: _JumpTableBudget | None = (
|
|
408
|
+
_JumpTableBudget(max_tables=max_jump_tables, max_entries=max_table_entries)
|
|
409
|
+
if resolve_indirect
|
|
410
|
+
else None
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
while worklist and remaining > 0 and len(decoded) < max_blocks:
|
|
414
|
+
leader = worklist.popleft()
|
|
415
|
+
if leader in decoded: # pragma: no cover - a leader is queued once
|
|
416
|
+
continue
|
|
417
|
+
chunk, reason = _decode_block(path, info, disassembler, leader, leaders, remaining)
|
|
418
|
+
if not chunk:
|
|
419
|
+
warnings.append(f"no decodable instruction at block start 0x{leader:x}")
|
|
420
|
+
continue
|
|
421
|
+
if reason == "budget":
|
|
422
|
+
budget_exhausted = True
|
|
423
|
+
remaining -= len(chunk)
|
|
424
|
+
last = chunk[-1]
|
|
425
|
+
table_targets: tuple[int, ...] | None = None
|
|
426
|
+
if (
|
|
427
|
+
tables is not None
|
|
428
|
+
and _terminator_kind(last.mnemonic) == "jmp"
|
|
429
|
+
and _direct_target(last.operands) is None
|
|
430
|
+
):
|
|
431
|
+
table_targets = _jump_table_successors(
|
|
432
|
+
path, info, tables, leader, last, warnings
|
|
433
|
+
)
|
|
434
|
+
successors, unresolved = _recursive_successors(
|
|
435
|
+
info, last, dropped, table_targets
|
|
436
|
+
)
|
|
437
|
+
indirect_unresolved += int(unresolved)
|
|
438
|
+
decoded[leader] = _Segment(
|
|
439
|
+
instructions=tuple(chunk),
|
|
440
|
+
successors=successors,
|
|
441
|
+
terminator=_terminator_mnemonic(last.mnemonic),
|
|
442
|
+
)
|
|
443
|
+
for successor in successors: # sorted, so queue order stays deterministic
|
|
444
|
+
if successor not in leaders:
|
|
445
|
+
leaders.add(successor)
|
|
446
|
+
worklist.append(successor)
|
|
447
|
+
|
|
448
|
+
# Overlapping traversal hypotheses become disjoint blocks here.
|
|
449
|
+
segments = _split_at_leaders(list(decoded.values()))
|
|
450
|
+
block_limit_from_split = len(segments) > max_blocks
|
|
451
|
+
if block_limit_from_split:
|
|
452
|
+
segments = segments[:max_blocks]
|
|
453
|
+
blocks = [_to_block(segment) for segment in segments]
|
|
454
|
+
# Disjoint ranges mean each instruction address is reported exactly once.
|
|
455
|
+
instruction_count = len(
|
|
456
|
+
{instruction.address for segment in segments for instruction in segment.instructions}
|
|
457
|
+
)
|
|
458
|
+
if worklist and not remaining:
|
|
459
|
+
budget_exhausted = True
|
|
460
|
+
block_limit_reached = bool(worklist) and len(decoded) >= max_blocks
|
|
461
|
+
truncated = budget_exhausted or bool(worklist) or block_limit_from_split
|
|
462
|
+
|
|
463
|
+
if budget_exhausted:
|
|
464
|
+
warnings.append(
|
|
465
|
+
f"instruction limit reached (max_instructions={max_instructions}); "
|
|
466
|
+
"the graph may be incomplete"
|
|
467
|
+
)
|
|
468
|
+
if block_limit_reached:
|
|
469
|
+
warnings.append(
|
|
470
|
+
f"block limit reached (max_blocks={max_blocks}); {len(worklist)} reached "
|
|
471
|
+
"block(s) were not decoded and the graph may be incomplete"
|
|
472
|
+
)
|
|
473
|
+
if block_limit_from_split:
|
|
474
|
+
warnings.append(
|
|
475
|
+
f"block limit reached (max_blocks={max_blocks}); splitting blocks at "
|
|
476
|
+
"interior leaders produced more blocks and later ones were not reported"
|
|
477
|
+
)
|
|
478
|
+
if dropped:
|
|
479
|
+
warnings.append(
|
|
480
|
+
f"edge target(s) {_address_sample(dropped)} are outside any executable "
|
|
481
|
+
"section; those edges are omitted"
|
|
482
|
+
)
|
|
483
|
+
missing = sorted(
|
|
484
|
+
{address for block in blocks for address in block.successors}
|
|
485
|
+
- {block.start for block in blocks}
|
|
486
|
+
)
|
|
487
|
+
if missing and not truncated:
|
|
488
|
+
warnings.append(
|
|
489
|
+
f"edge(s) to {_address_sample(missing)} have no reported block; those edges are kept"
|
|
490
|
+
)
|
|
491
|
+
if indirect_unresolved:
|
|
492
|
+
warnings.append(
|
|
493
|
+
f"{indirect_unresolved} indirect branch target(s) were not resolved "
|
|
494
|
+
"(register/memory operands) and those edges are omitted"
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
# Only mention jump tables when the caller asked for them, so the evidence
|
|
498
|
+
# text of a default traversal stays byte-for-byte what it used to be.
|
|
499
|
+
table_detail = ""
|
|
500
|
+
if tables is not None:
|
|
501
|
+
table_detail = (
|
|
502
|
+
f"; {tables.resolved} jump table(s) resolved from indirect jumps "
|
|
503
|
+
f"({tables.entries} pointer slot(s) decoded, bounded by "
|
|
504
|
+
f"max_jump_tables={max_jump_tables} and max_table_entries={max_table_entries})"
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
evidence = Evidence(
|
|
508
|
+
source="cfg.recursive_traversal",
|
|
509
|
+
method="recursive_traversal",
|
|
510
|
+
confidence=Confidence.MEDIUM,
|
|
511
|
+
address=start,
|
|
512
|
+
detail=(
|
|
513
|
+
f"recursive traversal from 0x{start:x} followed control flow into {len(blocks)} "
|
|
514
|
+
f"block(s) / {instruction_count} unique instruction(s); bounded by "
|
|
515
|
+
f"max_instructions={max_instructions} and max_blocks={max_blocks}; "
|
|
516
|
+
f"{indirect_unresolved} indirect target(s) unresolved and call targets not "
|
|
517
|
+
"followed; blocks split at interior leaders so reported ranges are disjoint"
|
|
518
|
+
f"{table_detail}; boundaries are heuristic"
|
|
519
|
+
),
|
|
520
|
+
)
|
|
521
|
+
return FunctionCFG(
|
|
522
|
+
function_address=start,
|
|
523
|
+
function_name=_function_name_for(info, start),
|
|
524
|
+
blocks=blocks,
|
|
525
|
+
instruction_count=instruction_count,
|
|
526
|
+
truncated=truncated,
|
|
527
|
+
evidence=evidence,
|
|
528
|
+
warnings=tuple(warnings),
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _validate_limits(max_instructions: int, max_blocks: int) -> None:
|
|
533
|
+
"""Reject out-of-range traversal limits."""
|
|
534
|
+
if max_instructions < 1:
|
|
535
|
+
raise AnalysisError("max_instructions must be >= 1")
|
|
536
|
+
if max_instructions > MAX_DISASSEMBLY_COUNT:
|
|
537
|
+
raise AnalysisError(f"max_instructions too large (limit {MAX_DISASSEMBLY_COUNT})")
|
|
538
|
+
if max_blocks < 1:
|
|
539
|
+
raise AnalysisError("max_blocks must be >= 1")
|
|
540
|
+
if max_blocks > MAX_DISASSEMBLY_COUNT:
|
|
541
|
+
raise AnalysisError(f"max_blocks too large (limit {MAX_DISASSEMBLY_COUNT})")
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _validate_indirect_limits(max_jump_tables: int, max_table_entries: int) -> None:
|
|
545
|
+
"""Reject out-of-range jump-table limits.
|
|
546
|
+
|
|
547
|
+
Validated unconditionally (even for ``resolve_indirect=False``) so a caller
|
|
548
|
+
can pre-check its bounds without having to branch on the flag.
|
|
549
|
+
"""
|
|
550
|
+
if max_jump_tables < 1:
|
|
551
|
+
raise AnalysisError("max_jump_tables must be >= 1")
|
|
552
|
+
if max_jump_tables > MAX_DISASSEMBLY_COUNT:
|
|
553
|
+
raise AnalysisError(
|
|
554
|
+
f"max_jump_tables too large (limit {MAX_DISASSEMBLY_COUNT})"
|
|
555
|
+
)
|
|
556
|
+
if max_table_entries < 1:
|
|
557
|
+
raise AnalysisError("max_table_entries must be >= 1")
|
|
558
|
+
if max_table_entries > _JUMP_TABLE_MAX_ENTRIES:
|
|
559
|
+
raise AnalysisError(
|
|
560
|
+
f"max_table_entries too large (limit {_JUMP_TABLE_MAX_ENTRIES})"
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _decode_block(
|
|
565
|
+
path: str | Path,
|
|
566
|
+
info: BinaryInfo,
|
|
567
|
+
disassembler: Disassembler,
|
|
568
|
+
leader: int,
|
|
569
|
+
known_starts: set[int],
|
|
570
|
+
budget: int,
|
|
571
|
+
) -> tuple[list[IRInstruction], str]:
|
|
572
|
+
"""Decode the single basic block that begins at ``leader``.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
path: File the bytes are read from (read-only).
|
|
576
|
+
info: Loaded binary metadata.
|
|
577
|
+
disassembler: Decoder already bound to the binary's architecture.
|
|
578
|
+
leader: Block start; assumed to be executable and file-backed.
|
|
579
|
+
known_starts: Leaders already queued, i.e. addresses that must begin a
|
|
580
|
+
block. Decoding stops *before* re-entering one of them.
|
|
581
|
+
budget: Instructions still available in the overall budget.
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
``(instructions, reason)`` where ``reason`` is ``"terminator"``,
|
|
585
|
+
``"leader"``, ``"budget"``, ``"section"``, ``"window"`` (the per-block
|
|
586
|
+
decode window filled up, so the block continues at the fallthrough) or
|
|
587
|
+
``"empty"``.
|
|
588
|
+
"""
|
|
589
|
+
if budget <= 0:
|
|
590
|
+
return [], "budget"
|
|
591
|
+
window = min(budget, _MAX_BLOCK_INSTRUCTIONS)
|
|
592
|
+
try:
|
|
593
|
+
code = read_code(path, info, leader, window * _MAX_INSTRUCTION_BYTES)
|
|
594
|
+
except AnalysisError:
|
|
595
|
+
return [], "empty"
|
|
596
|
+
try:
|
|
597
|
+
result = disassembler.disassemble(code, base_address=leader, max_instructions=window)
|
|
598
|
+
except DisassemblyError as exc: # pragma: no cover - decoder is already validated
|
|
599
|
+
raise AnalysisError(f"cannot disassemble 0x{leader:x}: {exc}") from exc
|
|
600
|
+
|
|
601
|
+
chunk: list[IRInstruction] = []
|
|
602
|
+
for instruction in result.instructions:
|
|
603
|
+
if len(chunk) >= budget:
|
|
604
|
+
return chunk, "budget"
|
|
605
|
+
if chunk and instruction.address in known_starts:
|
|
606
|
+
return chunk, "leader"
|
|
607
|
+
chunk.append(instruction)
|
|
608
|
+
if _terminator_kind(instruction.mnemonic) is not None:
|
|
609
|
+
return chunk, "terminator"
|
|
610
|
+
if not _is_executable_address(info, instruction.address + instruction.size):
|
|
611
|
+
return chunk, "section"
|
|
612
|
+
return chunk, "window" if chunk else "empty"
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _recursive_successors(
|
|
616
|
+
info: BinaryInfo,
|
|
617
|
+
last: IRInstruction,
|
|
618
|
+
dropped: list[int],
|
|
619
|
+
indirect_targets: tuple[int, ...] | None = None,
|
|
620
|
+
) -> tuple[tuple[int, ...], bool]:
|
|
621
|
+
"""Return ``(successors, indirect_unresolved)`` for a block's final instruction.
|
|
622
|
+
|
|
623
|
+
Conditional jumps yield fallthrough *and* target, unconditional jumps only
|
|
624
|
+
their direct target (never a fallthrough), returns nothing, and ordinary
|
|
625
|
+
instructions (calls included) only the fallthrough. Targets outside an
|
|
626
|
+
executable, file-backed section are discarded and recorded in ``dropped``.
|
|
627
|
+
|
|
628
|
+
Args:
|
|
629
|
+
info: Loaded binary metadata used for the executable/file-backed check.
|
|
630
|
+
last: Final instruction of the block.
|
|
631
|
+
dropped: Collector for edge targets that are not executable code.
|
|
632
|
+
indirect_targets: Already-resolved targets of an indirect ``jmp`` (a jump
|
|
633
|
+
table), or ``None`` when indirect branches are not resolved. ``None``
|
|
634
|
+
keeps the historical behaviour: an indirect ``jmp``/``Jcc`` gets no
|
|
635
|
+
successor and is reported as unresolved. An empty tuple means "the
|
|
636
|
+
jump was resolved and really leads nowhere", which is *not* counted
|
|
637
|
+
as unresolved.
|
|
638
|
+
"""
|
|
639
|
+
kind = _terminator_kind(last.mnemonic)
|
|
640
|
+
candidates: list[int] = []
|
|
641
|
+
unresolved = False
|
|
642
|
+
|
|
643
|
+
if kind in (None, "cond"): # ordinary instructions and conditional jumps fall through
|
|
644
|
+
candidates.append(last.address + last.size)
|
|
645
|
+
if kind in ("jmp", "cond"): # direct branch targets only
|
|
646
|
+
target = _direct_target(last.operands)
|
|
647
|
+
if target is not None:
|
|
648
|
+
candidates.append(target)
|
|
649
|
+
elif indirect_targets is None:
|
|
650
|
+
unresolved = True
|
|
651
|
+
else:
|
|
652
|
+
candidates.extend(indirect_targets)
|
|
653
|
+
|
|
654
|
+
kept: set[int] = set()
|
|
655
|
+
for address in candidates:
|
|
656
|
+
if _is_executable_address(info, address):
|
|
657
|
+
kept.add(address)
|
|
658
|
+
else:
|
|
659
|
+
dropped.append(address)
|
|
660
|
+
return tuple(sorted(kept)), unresolved
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
@dataclass
|
|
664
|
+
class _JumpTableBudget:
|
|
665
|
+
"""Book-keeping for jump-table resolution during one traversal.
|
|
666
|
+
|
|
667
|
+
Attributes:
|
|
668
|
+
max_tables: Tables that may still be resolved (``max_jump_tables``).
|
|
669
|
+
max_entries: Slots decoded per table (``max_table_entries``).
|
|
670
|
+
resolved: Tables actually resolved so far.
|
|
671
|
+
entries: Pointer slots decoded across every resolved table.
|
|
672
|
+
limit_warned: ``True`` once the ``max_tables`` warning was emitted, so it
|
|
673
|
+
is reported exactly once per traversal.
|
|
674
|
+
"""
|
|
675
|
+
|
|
676
|
+
max_tables: int
|
|
677
|
+
max_entries: int
|
|
678
|
+
resolved: int = 0
|
|
679
|
+
entries: int = 0
|
|
680
|
+
limit_warned: bool = False
|
|
681
|
+
|
|
682
|
+
@property
|
|
683
|
+
def exhausted(self) -> bool:
|
|
684
|
+
"""``True`` once no further table may be resolved."""
|
|
685
|
+
return self.resolved >= self.max_tables
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _jump_table_successors(
|
|
689
|
+
path: str | Path,
|
|
690
|
+
info: BinaryInfo,
|
|
691
|
+
budget: _JumpTableBudget,
|
|
692
|
+
block_start: int,
|
|
693
|
+
last: IRInstruction,
|
|
694
|
+
warnings: list[str],
|
|
695
|
+
) -> tuple[int, ...] | None:
|
|
696
|
+
"""Resolve the pointer table behind one indirect ``jmp`` into block starts.
|
|
697
|
+
|
|
698
|
+
The dispatch operand is turned into an absolute table address, the table is
|
|
699
|
+
decoded with :func:`~codevanta.analysis.jump_tables.resolve_jump_table`
|
|
700
|
+
(bounded by ``budget.max_entries``) and the executable, file-backed targets
|
|
701
|
+
are returned deduplicated and sorted. Every table-level problem is appended
|
|
702
|
+
to ``warnings`` prefixed with ``block_start``, so the caveat can be traced
|
|
703
|
+
back to the block that caused it. The table module's own warnings are
|
|
704
|
+
surfaced verbatim, including the conventional zero terminator: "the scan
|
|
705
|
+
ended where a terminator was expected" is still a statement about how much
|
|
706
|
+
of the graph is guesswork, and dropping it would hide the reason a table
|
|
707
|
+
looked short.
|
|
708
|
+
|
|
709
|
+
Args:
|
|
710
|
+
path: Binary to read (read-only; never executed).
|
|
711
|
+
info: Loaded binary metadata.
|
|
712
|
+
budget: Per-traversal jump-table budget, updated in place.
|
|
713
|
+
block_start: Start address of the block, used as the warning prefix.
|
|
714
|
+
last: Final instruction of the block (an indirect ``jmp``).
|
|
715
|
+
warnings: Collector for human-readable caveats.
|
|
716
|
+
|
|
717
|
+
Returns:
|
|
718
|
+
The resolved successor addresses, an empty tuple when the table yielded
|
|
719
|
+
no usable target, or ``None`` when the jump could not be resolved at all
|
|
720
|
+
(register/indexed operand, unreadable table, or spent budget). ``None``
|
|
721
|
+
makes the caller count the block as an unresolved indirect branch.
|
|
722
|
+
"""
|
|
723
|
+
table_address = _indirect_table_address(info, last)
|
|
724
|
+
if table_address is None:
|
|
725
|
+
return None
|
|
726
|
+
if budget.exhausted:
|
|
727
|
+
if not budget.limit_warned:
|
|
728
|
+
budget.limit_warned = True
|
|
729
|
+
warnings.append(
|
|
730
|
+
f"block 0x{block_start:x}: jump-table limit reached "
|
|
731
|
+
f"(max_jump_tables={budget.max_tables}); further indirect jumps were "
|
|
732
|
+
"left unresolved"
|
|
733
|
+
)
|
|
734
|
+
return None
|
|
735
|
+
|
|
736
|
+
try:
|
|
737
|
+
result = resolve_jump_table(path, info, table_address, max_entries=budget.max_entries)
|
|
738
|
+
except AnalysisError as exc:
|
|
739
|
+
warnings.append(
|
|
740
|
+
f"block 0x{block_start:x}: jump table at 0x{table_address:x} could not be "
|
|
741
|
+
f"read ({exc}); that edge is omitted"
|
|
742
|
+
)
|
|
743
|
+
return None
|
|
744
|
+
|
|
745
|
+
budget.resolved += 1
|
|
746
|
+
budget.entries += len(result.entries)
|
|
747
|
+
if result.truncated:
|
|
748
|
+
warnings.append(
|
|
749
|
+
f"block 0x{block_start:x}: jump table at 0x{table_address:x} was truncated at "
|
|
750
|
+
f"max_table_entries={budget.max_entries}; some targets are missing"
|
|
751
|
+
)
|
|
752
|
+
for warning in result.warnings:
|
|
753
|
+
warnings.append(
|
|
754
|
+
f"block 0x{block_start:x}: jump table at 0x{table_address:x}: {warning}"
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
targets: list[int] = []
|
|
758
|
+
unusable: list[int] = []
|
|
759
|
+
for entry in result.entries:
|
|
760
|
+
if _is_executable_address(info, entry.target):
|
|
761
|
+
targets.append(entry.target)
|
|
762
|
+
else:
|
|
763
|
+
unusable.append(entry.target)
|
|
764
|
+
if unusable: # bounded: one warning, at most eight addresses named
|
|
765
|
+
warnings.append(
|
|
766
|
+
f"block 0x{block_start:x}: jump-table target(s) {_address_sample(unusable)} "
|
|
767
|
+
"are not executable file bytes; those edges are omitted"
|
|
768
|
+
)
|
|
769
|
+
return tuple(sorted(set(targets)))
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
@dataclass(frozen=True)
|
|
773
|
+
class _Segment:
|
|
774
|
+
"""One decoded instruction run with the edges the traversal gave it.
|
|
775
|
+
|
|
776
|
+
Internal to :func:`build_recursive_cfg`: the traversal records raw runs and
|
|
777
|
+
only turns them into :class:`BasicBlock` objects after the split pass, so the
|
|
778
|
+
split can discard instructions without touching serialized data.
|
|
779
|
+
"""
|
|
780
|
+
|
|
781
|
+
instructions: tuple[IRInstruction, ...]
|
|
782
|
+
successors: tuple[int, ...]
|
|
783
|
+
terminator: str | None
|
|
784
|
+
split: bool = False
|
|
785
|
+
|
|
786
|
+
@property
|
|
787
|
+
def start(self) -> int:
|
|
788
|
+
"""Virtual address of the first instruction in the run."""
|
|
789
|
+
return self.instructions[0].address
|
|
790
|
+
|
|
791
|
+
@property
|
|
792
|
+
def end(self) -> int:
|
|
793
|
+
"""Virtual address just past the last instruction (exclusive)."""
|
|
794
|
+
last = self.instructions[-1]
|
|
795
|
+
return last.address + last.size
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _split_at_leaders(decoded: list[_Segment]) -> list[_Segment]:
|
|
799
|
+
"""Turn traversed runs into blocks with disjoint address ranges.
|
|
800
|
+
|
|
801
|
+
Traversal is driven by a worklist, so a run decoded early can reach past a
|
|
802
|
+
leader that is only discovered later (a backward ``jmp`` into an already
|
|
803
|
+
decoded region); two reported blocks would then claim the same instruction
|
|
804
|
+
addresses. This pass removes that overlap:
|
|
805
|
+
|
|
806
|
+
1. **Leaders.** Every traversed block start is a leader.
|
|
807
|
+
2. **Split.** Each run is cut at every leader that appears strictly inside
|
|
808
|
+
it. The leading piece of such a cut falls through to the split address
|
|
809
|
+
(its only successor) and reports no terminator; the trailing piece keeps
|
|
810
|
+
the original terminator and successor set.
|
|
811
|
+
3. **Dedupe.** Pieces sharing a start address collapse to the most complete
|
|
812
|
+
one (most instructions, then farthest end, then most successors), which
|
|
813
|
+
is deterministic because the input is ordered by start first.
|
|
814
|
+
4. **Order and clip.** The result is sorted by start and clipped so ranges
|
|
815
|
+
never overlap.
|
|
816
|
+
|
|
817
|
+
Args:
|
|
818
|
+
decoded: Runs exactly as the traversal decoded them, in any order.
|
|
819
|
+
|
|
820
|
+
Returns:
|
|
821
|
+
Disjoint segments in ascending address order.
|
|
822
|
+
"""
|
|
823
|
+
ordered = sorted(decoded, key=lambda segment: segment.start)
|
|
824
|
+
leaders = {segment.start for segment in ordered}
|
|
825
|
+
candidates = [piece for segment in ordered for piece in _cut_segment(segment, leaders)]
|
|
826
|
+
return _clip_overlaps(_most_complete(candidates))
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _cut_segment(segment: _Segment, leaders: set[int]) -> list[_Segment]:
|
|
830
|
+
"""Cut one run at every leader that is strictly inside it."""
|
|
831
|
+
instructions = segment.instructions
|
|
832
|
+
cuts = [
|
|
833
|
+
index
|
|
834
|
+
for index, instruction in enumerate(instructions)
|
|
835
|
+
if index > 0 and instruction.address in leaders
|
|
836
|
+
]
|
|
837
|
+
if not cuts:
|
|
838
|
+
return [segment]
|
|
839
|
+
|
|
840
|
+
bounds = [0, *cuts, len(instructions)]
|
|
841
|
+
pieces: list[_Segment] = []
|
|
842
|
+
for position in range(len(bounds) - 1):
|
|
843
|
+
piece = instructions[bounds[position] : bounds[position + 1]]
|
|
844
|
+
trailing = position + 2 >= len(bounds)
|
|
845
|
+
pieces.append(
|
|
846
|
+
_Segment(
|
|
847
|
+
instructions=piece,
|
|
848
|
+
# Only the tail keeps the original edges; a cut piece simply
|
|
849
|
+
# falls through to the split address.
|
|
850
|
+
successors=segment.successors
|
|
851
|
+
if trailing
|
|
852
|
+
else (instructions[bounds[position + 1]].address,),
|
|
853
|
+
terminator=segment.terminator if trailing else None,
|
|
854
|
+
split=True,
|
|
855
|
+
)
|
|
856
|
+
)
|
|
857
|
+
return pieces
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _most_complete(segments: list[_Segment]) -> list[_Segment]:
|
|
861
|
+
"""Keep the most complete segment per start address, ordered by start."""
|
|
862
|
+
best: dict[int, _Segment] = {}
|
|
863
|
+
for segment in segments: # deterministic input order breaks exact ties
|
|
864
|
+
current = best.get(segment.start)
|
|
865
|
+
if current is None or _completeness(segment) > _completeness(current):
|
|
866
|
+
best[segment.start] = segment
|
|
867
|
+
return sorted(best.values(), key=lambda segment: segment.start)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _completeness(segment: _Segment) -> tuple[int, int, int]:
|
|
871
|
+
"""Ranking key used to choose between segments that share a start address."""
|
|
872
|
+
return (len(segment.instructions), segment.end, len(segment.successors))
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _clip_overlaps(segments: list[_Segment]) -> list[_Segment]:
|
|
876
|
+
"""Trim a run whose instructions still reach into the next kept block.
|
|
877
|
+
|
|
878
|
+
The split already guarantees disjointness whenever leaders land on
|
|
879
|
+
instruction boundaries of the containing run. Overlapping decodes of the
|
|
880
|
+
same bytes can disagree about boundaries, so any instruction that would
|
|
881
|
+
cross the next block start is dropped here and the run simply falls through
|
|
882
|
+
to it. A run is never emptied: an undecodable first instruction is kept as
|
|
883
|
+
is and reported through the traversal's warnings.
|
|
884
|
+
"""
|
|
885
|
+
clipped: list[_Segment] = []
|
|
886
|
+
for index, segment in enumerate(segments):
|
|
887
|
+
if index + 1 >= len(segments):
|
|
888
|
+
clipped.append(segment)
|
|
889
|
+
continue
|
|
890
|
+
next_start = segments[index + 1].start
|
|
891
|
+
kept = tuple(
|
|
892
|
+
instruction
|
|
893
|
+
for instruction in segment.instructions
|
|
894
|
+
if instruction.address + instruction.size <= next_start
|
|
895
|
+
)
|
|
896
|
+
if not kept or len(kept) == len(segment.instructions):
|
|
897
|
+
clipped.append(segment)
|
|
898
|
+
continue
|
|
899
|
+
clipped.append(
|
|
900
|
+
_Segment(
|
|
901
|
+
instructions=kept,
|
|
902
|
+
successors=(next_start,),
|
|
903
|
+
terminator=None,
|
|
904
|
+
split=True,
|
|
905
|
+
)
|
|
906
|
+
)
|
|
907
|
+
return clipped
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _to_block(segment: _Segment) -> BasicBlock:
|
|
911
|
+
"""Serialize one disjoint segment into a :class:`BasicBlock`."""
|
|
912
|
+
detail = f"{len(segment.instructions)} instructions reached by recursive traversal"
|
|
913
|
+
if segment.split:
|
|
914
|
+
detail += " and split at interior leaders"
|
|
915
|
+
return BasicBlock(
|
|
916
|
+
start=segment.start,
|
|
917
|
+
end=segment.end,
|
|
918
|
+
instructions=[instruction.to_dict() for instruction in segment.instructions],
|
|
919
|
+
successors=segment.successors,
|
|
920
|
+
terminator=segment.terminator,
|
|
921
|
+
evidence=Evidence(
|
|
922
|
+
source="cfg.recursive_traversal",
|
|
923
|
+
method="basic_block",
|
|
924
|
+
confidence=Confidence.MEDIUM,
|
|
925
|
+
address=segment.start,
|
|
926
|
+
detail=detail,
|
|
927
|
+
),
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _split_blocks(
|
|
932
|
+
instructions: list[IRInstruction],
|
|
933
|
+
max_blocks: int,
|
|
934
|
+
) -> tuple[list[BasicBlock], bool, list[str]]:
|
|
935
|
+
"""Split one linear instruction stream into blocks with successors."""
|
|
936
|
+
if not instructions:
|
|
937
|
+
return [], False, []
|
|
938
|
+
|
|
939
|
+
addresses = [instruction.address for instruction in instructions]
|
|
940
|
+
decoded = set(addresses)
|
|
941
|
+
|
|
942
|
+
leaders: set[int] = {addresses[0]}
|
|
943
|
+
for index, instruction in enumerate(instructions):
|
|
944
|
+
if _terminator_kind(instruction.mnemonic) is None:
|
|
945
|
+
continue
|
|
946
|
+
if index + 1 < len(instructions):
|
|
947
|
+
leaders.add(addresses[index + 1])
|
|
948
|
+
target = _direct_target(instruction.operands)
|
|
949
|
+
if target is not None and target in decoded:
|
|
950
|
+
leaders.add(target)
|
|
951
|
+
|
|
952
|
+
start_indexes = [index for index, address in enumerate(addresses) if address in leaders]
|
|
953
|
+
|
|
954
|
+
blocks: list[BasicBlock] = []
|
|
955
|
+
dropped: list[int] = []
|
|
956
|
+
for position, first in enumerate(start_indexes):
|
|
957
|
+
last = (
|
|
958
|
+
start_indexes[position + 1] - 1
|
|
959
|
+
if position + 1 < len(start_indexes)
|
|
960
|
+
else len(instructions) - 1
|
|
961
|
+
)
|
|
962
|
+
chunk = instructions[first : last + 1]
|
|
963
|
+
if not chunk: # pragma: no cover - leaders are instruction indexes
|
|
964
|
+
continue
|
|
965
|
+
blocks.append(
|
|
966
|
+
BasicBlock(
|
|
967
|
+
start=chunk[0].address,
|
|
968
|
+
end=chunk[-1].address + chunk[-1].size,
|
|
969
|
+
instructions=[instruction.to_dict() for instruction in chunk],
|
|
970
|
+
successors=_successors(chunk[-1], leaders, dropped),
|
|
971
|
+
terminator=_terminator_mnemonic(chunk[-1].mnemonic),
|
|
972
|
+
evidence=Evidence(
|
|
973
|
+
source="cfg.linear_sweep",
|
|
974
|
+
method="basic_block",
|
|
975
|
+
confidence=Confidence.MEDIUM,
|
|
976
|
+
address=chunk[0].address,
|
|
977
|
+
detail=f"{len(chunk)} instructions",
|
|
978
|
+
),
|
|
979
|
+
)
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
warnings: list[str] = []
|
|
983
|
+
if dropped: # bounded: only the first few unresolved edge targets are listed
|
|
984
|
+
unique = list(dict.fromkeys(dropped))
|
|
985
|
+
shown = ", ".join(f"0x{address:x}" for address in unique[:8])
|
|
986
|
+
extra = f" (+{len(unique) - 8} more)" if len(unique) > 8 else ""
|
|
987
|
+
warnings.append(
|
|
988
|
+
f"edge target(s) {shown}{extra} were not decoded; those edges are omitted"
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
truncated = False
|
|
992
|
+
if len(blocks) > max_blocks:
|
|
993
|
+
blocks = blocks[:max_blocks]
|
|
994
|
+
truncated = True
|
|
995
|
+
warnings.append(
|
|
996
|
+
f"block limit reached (max_blocks={max_blocks}); later blocks were not reported"
|
|
997
|
+
)
|
|
998
|
+
return blocks, truncated, warnings
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _successors(
|
|
1002
|
+
last: IRInstruction,
|
|
1003
|
+
block_starts: set[int],
|
|
1004
|
+
dropped: list[int],
|
|
1005
|
+
) -> tuple[int, ...]:
|
|
1006
|
+
"""Compute the sorted, deduplicated successor set of a final instruction."""
|
|
1007
|
+
kind = _terminator_kind(last.mnemonic)
|
|
1008
|
+
candidates: list[int] = []
|
|
1009
|
+
|
|
1010
|
+
if kind in (None, "cond"): # ordinary instructions and conditional jumps fall through
|
|
1011
|
+
candidates.append(last.address + last.size)
|
|
1012
|
+
if kind in ("jmp", "cond"): # direct branch targets
|
|
1013
|
+
target = _direct_target(last.operands)
|
|
1014
|
+
if target is not None:
|
|
1015
|
+
candidates.append(target)
|
|
1016
|
+
|
|
1017
|
+
successors: set[int] = set()
|
|
1018
|
+
for address in candidates:
|
|
1019
|
+
if address in block_starts:
|
|
1020
|
+
successors.add(address)
|
|
1021
|
+
else:
|
|
1022
|
+
dropped.append(address)
|
|
1023
|
+
return tuple(sorted(successors))
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _terminator_kind(mnemonic: str) -> str | None:
|
|
1027
|
+
"""Classify a mnemonic as ``"ret"``, ``"jmp"``, ``"cond"`` or ``None``."""
|
|
1028
|
+
name = (mnemonic or "").strip().lower()
|
|
1029
|
+
if not name:
|
|
1030
|
+
return None
|
|
1031
|
+
if name in _RET_MNEMONICS or name.startswith("ret"):
|
|
1032
|
+
return "ret"
|
|
1033
|
+
if name in _UNCONDITIONAL_JUMPS:
|
|
1034
|
+
return "jmp"
|
|
1035
|
+
if name in _CONDITIONAL_JUMP_MNEMONICS or name.startswith("b."):
|
|
1036
|
+
return "cond"
|
|
1037
|
+
if name.startswith("j"): # x86 Jcc family: je, jne, jle, jrcxz, ...
|
|
1038
|
+
return "cond"
|
|
1039
|
+
return None
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _terminator_mnemonic(mnemonic: str) -> str | None:
|
|
1043
|
+
"""Return the normalized terminator mnemonic, or ``None`` for a fallthrough end."""
|
|
1044
|
+
name = (mnemonic or "").strip().lower()
|
|
1045
|
+
if not name or _terminator_kind(name) is None:
|
|
1046
|
+
return None
|
|
1047
|
+
return name
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _direct_target(operands: str) -> int | None:
|
|
1051
|
+
"""Return the immediate branch target encoded in ``operands``, if any.
|
|
1052
|
+
|
|
1053
|
+
Only straight hexadecimal immediates are accepted (``jmp 0x401006``,
|
|
1054
|
+
``b.eq #0x401006``). Register and memory operands (``jmp rax``,
|
|
1055
|
+
``jmp qword ptr [rip + 0x20]``) are indirect and therefore unresolved.
|
|
1056
|
+
"""
|
|
1057
|
+
text = (operands or "").strip()
|
|
1058
|
+
if not text:
|
|
1059
|
+
return None
|
|
1060
|
+
lowered = text.lower()
|
|
1061
|
+
if any(marker in lowered for marker in ("[", "]", "ptr", ":")):
|
|
1062
|
+
return None
|
|
1063
|
+
if "," in text:
|
|
1064
|
+
return None
|
|
1065
|
+
token = text.lstrip("#").strip().lower()
|
|
1066
|
+
if not token.startswith("0x"):
|
|
1067
|
+
return None
|
|
1068
|
+
try:
|
|
1069
|
+
return int(token, 16)
|
|
1070
|
+
except ValueError:
|
|
1071
|
+
return None
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _pointer_width(info: BinaryInfo) -> int | None:
|
|
1075
|
+
"""Return the pointer width in bytes implied by ``info.bitness``."""
|
|
1076
|
+
bitness = int(getattr(info, "bitness", 0) or 0)
|
|
1077
|
+
if bitness == 64:
|
|
1078
|
+
return 8
|
|
1079
|
+
if bitness == 32:
|
|
1080
|
+
return 4
|
|
1081
|
+
return None
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def _split_terms(text: str) -> list[tuple[int, str]]:
|
|
1085
|
+
"""Split a memory-operand body into ``(sign, term)`` pairs.
|
|
1086
|
+
|
|
1087
|
+
``"rip + 0xffa"`` becomes ``[(1, "rip "), (1, " 0xffa")]`` and
|
|
1088
|
+
``"0x401000 - 0x10"`` becomes ``[(1, "0x401000 "), (-1, " 0x10")]``. Only
|
|
1089
|
+
``+``/``-`` separate terms; everything else stays in the term so the caller
|
|
1090
|
+
can accept or reject it.
|
|
1091
|
+
"""
|
|
1092
|
+
terms: list[tuple[int, str]] = []
|
|
1093
|
+
sign = 1
|
|
1094
|
+
current = ""
|
|
1095
|
+
for char in text:
|
|
1096
|
+
if char in "+-":
|
|
1097
|
+
terms.append((sign, current))
|
|
1098
|
+
sign = -1 if char == "-" else 1
|
|
1099
|
+
current = ""
|
|
1100
|
+
continue
|
|
1101
|
+
current += char
|
|
1102
|
+
terms.append((sign, current))
|
|
1103
|
+
return terms
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def _hex_operand(term: str) -> int | None:
|
|
1107
|
+
"""Return the value of a ``0x``-prefixed hexadecimal term, else ``None``."""
|
|
1108
|
+
token = term.strip()
|
|
1109
|
+
if not token.startswith("0x"):
|
|
1110
|
+
return None
|
|
1111
|
+
try:
|
|
1112
|
+
return int(token, 16)
|
|
1113
|
+
except ValueError:
|
|
1114
|
+
return None
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def _indirect_table_address(info: BinaryInfo, last: IRInstruction) -> int | None:
|
|
1118
|
+
"""Return the table address an indirect ``jmp`` reads through, if it is one.
|
|
1119
|
+
|
|
1120
|
+
Only a pointer-sized memory operand is accepted: ``jmp qword ptr [rip +
|
|
1121
|
+
disp]`` on a 64-bit binary (``dword ptr [abs]`` on a 32-bit one) or its
|
|
1122
|
+
absolute form ``jmp qword ptr [0x402000]``. A RIP-relative displacement is
|
|
1123
|
+
resolved against the address of the *next* instruction, exactly as the CPU
|
|
1124
|
+
would. Everything else -- ``jmp rax``, an indexed operand such as ``jmp
|
|
1125
|
+
qword ptr [rax*8 + 0x402000]``, a symbol expression, a narrower or wider
|
|
1126
|
+
read -- returns ``None``: the operand may still be a dispatcher, but this
|
|
1127
|
+
function cannot justify a table address for it.
|
|
1128
|
+
|
|
1129
|
+
Args:
|
|
1130
|
+
info: Loaded binary metadata; only ``bitness`` is used, to know which
|
|
1131
|
+
``ptr`` size keyword denotes a pointer read.
|
|
1132
|
+
last: Instruction whose operands are inspected.
|
|
1133
|
+
|
|
1134
|
+
Returns:
|
|
1135
|
+
The absolute table address, or ``None`` when the operand cannot be
|
|
1136
|
+
resolved to one.
|
|
1137
|
+
"""
|
|
1138
|
+
width = _pointer_width(info)
|
|
1139
|
+
if width is None:
|
|
1140
|
+
return None
|
|
1141
|
+
keyword = _POINTER_SIZE_KEYWORDS[width]
|
|
1142
|
+
operands = (last.operands or "").strip().lower()
|
|
1143
|
+
if "[" not in operands or "]" not in operands:
|
|
1144
|
+
return None
|
|
1145
|
+
head, _, rest = operands.partition("[")
|
|
1146
|
+
inside, _, tail = rest.partition("]")
|
|
1147
|
+
if tail.strip(): # trailing syntax we do not model
|
|
1148
|
+
return None
|
|
1149
|
+
if "ptr" not in head:
|
|
1150
|
+
return None
|
|
1151
|
+
if head.split("ptr")[0].strip() != keyword:
|
|
1152
|
+
return None
|
|
1153
|
+
|
|
1154
|
+
base: str | None = None
|
|
1155
|
+
displacement = 0
|
|
1156
|
+
for sign, term in _split_terms(inside):
|
|
1157
|
+
term = term.strip()
|
|
1158
|
+
if not term:
|
|
1159
|
+
continue
|
|
1160
|
+
if term == "rip":
|
|
1161
|
+
if base is not None:
|
|
1162
|
+
return None
|
|
1163
|
+
base = term
|
|
1164
|
+
continue
|
|
1165
|
+
value = _hex_operand(term)
|
|
1166
|
+
if value is None:
|
|
1167
|
+
return None # register, symbol, scaled index or non-hex literal
|
|
1168
|
+
displacement += sign * value
|
|
1169
|
+
|
|
1170
|
+
address = last.address + last.size + displacement if base == "rip" else displacement
|
|
1171
|
+
return address if address > 0 else None
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def _is_executable_address(info: BinaryInfo, address: int) -> bool:
|
|
1175
|
+
"""Return ``True`` when ``address`` is executable *and* backed by file bytes."""
|
|
1176
|
+
if address < 0:
|
|
1177
|
+
return False
|
|
1178
|
+
section = info.section_for_va(address)
|
|
1179
|
+
if section is None or not section.is_executable:
|
|
1180
|
+
return False
|
|
1181
|
+
return info.va_to_offset(address) is not None
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _address_sample(addresses: list[int]) -> str:
|
|
1185
|
+
"""Format at most eight addresses for a warning, noting how many were cut."""
|
|
1186
|
+
unique = list(dict.fromkeys(addresses))
|
|
1187
|
+
shown = ", ".join(f"0x{address:x}" for address in unique[:8])
|
|
1188
|
+
return f"{shown} (+{len(unique) - 8} more)" if len(unique) > 8 else shown
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def _function_name_for(info: BinaryInfo, start: int) -> str | None:
|
|
1192
|
+
"""Return the discovered function name for ``start``, if there is one."""
|
|
1193
|
+
try:
|
|
1194
|
+
candidates = discover_functions(info)
|
|
1195
|
+
except Exception: # pragma: no cover - discovery must never break CFG building
|
|
1196
|
+
return None
|
|
1197
|
+
for candidate in candidates:
|
|
1198
|
+
if candidate.address == start and candidate.name:
|
|
1199
|
+
return candidate.name
|
|
1200
|
+
return None
|
|
1201
|
+
|