hotflow 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.
- hotflow/__init__.py +10 -0
- hotflow/analyzer.py +86 -0
- hotflow/binary.py +294 -0
- hotflow/cfg.py +255 -0
- hotflow/cli.py +478 -0
- hotflow/disassembly.py +200 -0
- hotflow/frequency.py +96 -0
- hotflow/loops.py +124 -0
- hotflow/metrics.py +305 -0
- hotflow/output/__init__.py +10 -0
- hotflow/output/disassembly.py +61 -0
- hotflow/output/dot.py +114 -0
- hotflow/output/relations.py +321 -0
- hotflow/output/text.py +120 -0
- hotflow/probability.py +214 -0
- hotflow-0.1.0.dist-info/METADATA +23 -0
- hotflow-0.1.0.dist-info/RECORD +20 -0
- hotflow-0.1.0.dist-info/WHEEL +4 -0
- hotflow-0.1.0.dist-info/entry_points.txt +2 -0
- hotflow-0.1.0.dist-info/licenses/LICENSE +674 -0
hotflow/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Jarkko Sakkinen <jarkko.sakkinen@iki.fi>
|
|
3
|
+
|
|
4
|
+
"""Probabilistic control-flow analysis for ELF binaries."""
|
|
5
|
+
|
|
6
|
+
from hotflow.analyzer import analyze_binary
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["analyze_binary", "__version__"]
|
hotflow/analyzer.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Jarkko Sakkinen <jarkko.sakkinen@iki.fi>
|
|
3
|
+
|
|
4
|
+
"""End-to-end analysis orchestration for binary functions."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import fnmatch
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from hotflow.binary import ElfBinary, LoadedFunction
|
|
13
|
+
from hotflow.cfg import build_cfg
|
|
14
|
+
from hotflow.disassembly import disassemble
|
|
15
|
+
from hotflow.frequency import solve_frequencies
|
|
16
|
+
from hotflow.loops import analyze_loops
|
|
17
|
+
from hotflow.metrics import FunctionAnalysis, analyze_metrics
|
|
18
|
+
from hotflow.probability import assign_probabilities
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _defined_function_names(elf: ElfBinary) -> list[str]:
|
|
22
|
+
return [
|
|
23
|
+
sym.name
|
|
24
|
+
for sym in elf.list_symbols(function_only=True)
|
|
25
|
+
if sym.section_name is not None
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _select_function_names(names: list[str], patterns: list[str]) -> list[str]:
|
|
30
|
+
"""Select exact names or shell globs in pattern order without duplicates."""
|
|
31
|
+
selected: list[str] = []
|
|
32
|
+
seen: set[str] = set()
|
|
33
|
+
for pattern in patterns:
|
|
34
|
+
for name in names:
|
|
35
|
+
if name not in seen and (
|
|
36
|
+
name == pattern or fnmatch.fnmatchcase(name, pattern)
|
|
37
|
+
):
|
|
38
|
+
selected.append(name)
|
|
39
|
+
seen.add(name)
|
|
40
|
+
return selected
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _analyze_loaded(loaded: LoadedFunction, model_name: str) -> FunctionAnalysis:
|
|
44
|
+
instructions = disassemble(
|
|
45
|
+
loaded.code_bytes,
|
|
46
|
+
base_address=loaded.address,
|
|
47
|
+
mode_64=loaded.is_64bit,
|
|
48
|
+
relocations=loaded.relocations,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
cfg = build_cfg(
|
|
52
|
+
loaded.address,
|
|
53
|
+
loaded.size,
|
|
54
|
+
instructions,
|
|
55
|
+
)
|
|
56
|
+
loops = analyze_loops(cfg)
|
|
57
|
+
assign_probabilities(cfg, loops, model_name)
|
|
58
|
+
solve_frequencies(cfg)
|
|
59
|
+
|
|
60
|
+
return analyze_metrics(
|
|
61
|
+
symbol=loaded.symbol.name,
|
|
62
|
+
address=loaded.address,
|
|
63
|
+
size=loaded.size,
|
|
64
|
+
architecture=loaded.architecture,
|
|
65
|
+
section_name=loaded.section_name,
|
|
66
|
+
model_name=model_name,
|
|
67
|
+
cfg=cfg,
|
|
68
|
+
loops=loops,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def analyze_binary(
|
|
73
|
+
binary_path: str | Path,
|
|
74
|
+
patterns: list[str] | None = None,
|
|
75
|
+
model_name: str = "heuristic",
|
|
76
|
+
) -> Iterator[FunctionAnalysis]:
|
|
77
|
+
"""Analyze exact symbol names or shell globs, opening the ELF once."""
|
|
78
|
+
with ElfBinary(binary_path) as elf:
|
|
79
|
+
available = _defined_function_names(elf)
|
|
80
|
+
names = (
|
|
81
|
+
available
|
|
82
|
+
if patterns is None
|
|
83
|
+
else _select_function_names(available, patterns)
|
|
84
|
+
)
|
|
85
|
+
for name in names:
|
|
86
|
+
yield _analyze_loaded(elf.load_function(name), model_name)
|
hotflow/binary.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Jarkko Sakkinen <jarkko.sakkinen@iki.fi>
|
|
3
|
+
|
|
4
|
+
"""ELF binary loading, symbol extraction, and section mapping."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from bisect import bisect_right
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from elftools.elf.elffile import ELFFile
|
|
13
|
+
from elftools.elf.relocation import RelocationSection
|
|
14
|
+
from elftools.elf.sections import SymbolTableSection
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BinaryError(Exception):
|
|
18
|
+
"""Base exception for binary loading and symbol lookup errors."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SymbolNotFoundError(BinaryError):
|
|
22
|
+
"""Raised when a requested symbol is not found in the binary."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class SymbolInfo:
|
|
27
|
+
"""An extracted ELF symbol."""
|
|
28
|
+
|
|
29
|
+
name: str
|
|
30
|
+
address: int
|
|
31
|
+
size: int
|
|
32
|
+
section_name: str | None
|
|
33
|
+
section_index: int | None
|
|
34
|
+
binding: str
|
|
35
|
+
is_function: bool
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class RelocationInfo:
|
|
40
|
+
"""Relocation entry in a relocatable ELF file."""
|
|
41
|
+
|
|
42
|
+
offset: int
|
|
43
|
+
symbol_name: str | None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class LoadedFunction:
|
|
48
|
+
"""A function loaded from an ELF binary ready for disassembly."""
|
|
49
|
+
|
|
50
|
+
symbol: SymbolInfo
|
|
51
|
+
address: int
|
|
52
|
+
size: int
|
|
53
|
+
code_bytes: bytes
|
|
54
|
+
architecture: str
|
|
55
|
+
is_64bit: bool
|
|
56
|
+
section_name: str | None = None
|
|
57
|
+
relocations: dict[int, RelocationInfo] = field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ElfBinary:
|
|
61
|
+
"""Parser and extractor for ELF binaries."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, file_path: str | Path) -> None:
|
|
64
|
+
self._path = Path(file_path)
|
|
65
|
+
self._file = self._path.open("rb")
|
|
66
|
+
try:
|
|
67
|
+
self.elf = ELFFile(self._file)
|
|
68
|
+
except Exception as error:
|
|
69
|
+
self._file.close()
|
|
70
|
+
raise BinaryError(f"Failed to parse ELF file: {error}") from error
|
|
71
|
+
|
|
72
|
+
self.arch = self.elf.get_machine_arch()
|
|
73
|
+
self.is_64bit = self.elf.elfclass == 64
|
|
74
|
+
|
|
75
|
+
self._symbol_tables: list[SymbolTableSection] | None = None
|
|
76
|
+
self._symbols: list[SymbolInfo] | None = None
|
|
77
|
+
self._symbols_by_name: dict[str, list[SymbolInfo]] | None = None
|
|
78
|
+
self._section_symbol_addrs: dict[int, list[int]] | None = None
|
|
79
|
+
self._relocations_by_section: dict[int, dict[int, RelocationInfo]] = {}
|
|
80
|
+
|
|
81
|
+
def __enter__(self) -> ElfBinary:
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
85
|
+
self.close()
|
|
86
|
+
|
|
87
|
+
def close(self) -> None:
|
|
88
|
+
self._file.close()
|
|
89
|
+
|
|
90
|
+
def _get_symbol_tables(self) -> list[SymbolTableSection]:
|
|
91
|
+
if self._symbol_tables is None:
|
|
92
|
+
tables = []
|
|
93
|
+
symtab = self.elf.get_section_by_name(".symtab")
|
|
94
|
+
if isinstance(symtab, SymbolTableSection):
|
|
95
|
+
tables.append(symtab)
|
|
96
|
+
dynsym = self.elf.get_section_by_name(".dynsym")
|
|
97
|
+
if isinstance(dynsym, SymbolTableSection):
|
|
98
|
+
tables.append(dynsym)
|
|
99
|
+
self._symbol_tables = tables
|
|
100
|
+
return self._symbol_tables
|
|
101
|
+
|
|
102
|
+
def _ensure_symbol_index(self) -> None:
|
|
103
|
+
if self._symbols is not None:
|
|
104
|
+
return
|
|
105
|
+
symbols: list[SymbolInfo] = []
|
|
106
|
+
by_name: dict[str, list[SymbolInfo]] = {}
|
|
107
|
+
section_sym_addrs: dict[int, list[int]] = {}
|
|
108
|
+
seen: set[tuple[str, int, int | None]] = set()
|
|
109
|
+
num_sections = self.elf.num_sections()
|
|
110
|
+
section_names: dict[int, str] = {}
|
|
111
|
+
|
|
112
|
+
for table in self._get_symbol_tables():
|
|
113
|
+
for sym in table.iter_symbols():
|
|
114
|
+
name = sym.name
|
|
115
|
+
if not name:
|
|
116
|
+
continue
|
|
117
|
+
st_value = sym["st_value"]
|
|
118
|
+
sec_idx = sym["st_shndx"]
|
|
119
|
+
sec_idx_int = sec_idx if isinstance(sec_idx, int) and 0 <= sec_idx < num_sections else None
|
|
120
|
+
key = (name, st_value, sec_idx_int)
|
|
121
|
+
if key in seen:
|
|
122
|
+
continue
|
|
123
|
+
seen.add(key)
|
|
124
|
+
|
|
125
|
+
if sec_idx_int is not None:
|
|
126
|
+
if sec_idx_int not in section_names:
|
|
127
|
+
sec = self.elf.get_section(sec_idx_int)
|
|
128
|
+
section_names[sec_idx_int] = sec.name if sec else ""
|
|
129
|
+
sec_name = section_names[sec_idx_int] or None
|
|
130
|
+
section_sym_addrs.setdefault(sec_idx_int, []).append(st_value)
|
|
131
|
+
else:
|
|
132
|
+
sec_name = None
|
|
133
|
+
|
|
134
|
+
st_type = sym["st_info"]["type"]
|
|
135
|
+
is_func = st_type in ("STT_FUNC", "STT_GNU_IFUNC")
|
|
136
|
+
sym_info = SymbolInfo(
|
|
137
|
+
name=name,
|
|
138
|
+
address=st_value,
|
|
139
|
+
size=sym["st_size"],
|
|
140
|
+
section_name=sec_name,
|
|
141
|
+
section_index=sec_idx_int,
|
|
142
|
+
binding=sym["st_info"]["bind"],
|
|
143
|
+
is_function=is_func,
|
|
144
|
+
)
|
|
145
|
+
symbols.append(sym_info)
|
|
146
|
+
by_name.setdefault(name, []).append(sym_info)
|
|
147
|
+
|
|
148
|
+
for sec_idx_k in section_sym_addrs:
|
|
149
|
+
section_sym_addrs[sec_idx_k].sort()
|
|
150
|
+
|
|
151
|
+
self._symbols = symbols
|
|
152
|
+
self._symbols_by_name = by_name
|
|
153
|
+
self._section_symbol_addrs = section_sym_addrs
|
|
154
|
+
|
|
155
|
+
def list_symbols(self, function_only: bool = False) -> list[SymbolInfo]:
|
|
156
|
+
"""List all symbols or function symbols in the ELF."""
|
|
157
|
+
self._ensure_symbol_index()
|
|
158
|
+
seen = set()
|
|
159
|
+
result = []
|
|
160
|
+
for sym in self._symbols or []:
|
|
161
|
+
if function_only and not sym.is_function:
|
|
162
|
+
continue
|
|
163
|
+
if sym.name in seen:
|
|
164
|
+
continue
|
|
165
|
+
seen.add(sym.name)
|
|
166
|
+
result.append(sym)
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
def find_symbol(self, symbol_name: str) -> SymbolInfo:
|
|
170
|
+
"""Find a symbol by exact name."""
|
|
171
|
+
self._ensure_symbol_index()
|
|
172
|
+
candidates = (self._symbols_by_name or {}).get(symbol_name)
|
|
173
|
+
if not candidates:
|
|
174
|
+
raise SymbolNotFoundError(
|
|
175
|
+
f"Symbol '{symbol_name}' not found in binary '{self._path}'"
|
|
176
|
+
)
|
|
177
|
+
func_candidates = [c for c in candidates if c.is_function]
|
|
178
|
+
if func_candidates:
|
|
179
|
+
return max(
|
|
180
|
+
func_candidates,
|
|
181
|
+
key=lambda s: (
|
|
182
|
+
s.section_name is not None,
|
|
183
|
+
s.size > 0,
|
|
184
|
+
s.binding == "STB_GLOBAL",
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
return candidates[0]
|
|
188
|
+
|
|
189
|
+
def _infer_symbol_size(self, symbol: SymbolInfo, section) -> int:
|
|
190
|
+
"""Infer size of a symbol when st_size is 0."""
|
|
191
|
+
sec_size = section["sh_size"]
|
|
192
|
+
sec_addr = section["sh_addr"]
|
|
193
|
+
sym_addr = symbol.address
|
|
194
|
+
|
|
195
|
+
min_next_addr = None
|
|
196
|
+
if symbol.section_index is not None and self._section_symbol_addrs:
|
|
197
|
+
addrs = self._section_symbol_addrs.get(symbol.section_index)
|
|
198
|
+
if addrs:
|
|
199
|
+
idx = bisect_right(addrs, sym_addr)
|
|
200
|
+
if idx < len(addrs):
|
|
201
|
+
min_next_addr = addrs[idx]
|
|
202
|
+
|
|
203
|
+
is_relocatable = self.elf["e_type"] == "ET_REL"
|
|
204
|
+
if is_relocatable:
|
|
205
|
+
offset = sym_addr
|
|
206
|
+
if min_next_addr is not None and min_next_addr <= sec_size:
|
|
207
|
+
return min_next_addr - offset
|
|
208
|
+
return sec_size - offset
|
|
209
|
+
else:
|
|
210
|
+
if min_next_addr is not None and min_next_addr <= (sec_addr + sec_size):
|
|
211
|
+
return min_next_addr - sym_addr
|
|
212
|
+
return (sec_addr + sec_size) - sym_addr
|
|
213
|
+
|
|
214
|
+
def _get_relocations_for_section(self, section_index: int) -> dict[int, RelocationInfo]:
|
|
215
|
+
"""Extract relocations targeting a specific section."""
|
|
216
|
+
if section_index in self._relocations_by_section:
|
|
217
|
+
return self._relocations_by_section[section_index]
|
|
218
|
+
|
|
219
|
+
relocs: dict[int, RelocationInfo] = {}
|
|
220
|
+
for section in self.elf.iter_sections():
|
|
221
|
+
if isinstance(section, RelocationSection) and section["sh_info"] == section_index:
|
|
222
|
+
symtab = self.elf.get_section(section["sh_link"])
|
|
223
|
+
for rel in section.iter_relocations():
|
|
224
|
+
sym_name = None
|
|
225
|
+
if symtab and rel["r_info_sym"] < symtab.num_symbols():
|
|
226
|
+
target_sym = symtab.get_symbol(rel["r_info_sym"])
|
|
227
|
+
sym_name = target_sym.name
|
|
228
|
+
|
|
229
|
+
relocs[rel["r_offset"]] = RelocationInfo(
|
|
230
|
+
offset=rel["r_offset"],
|
|
231
|
+
symbol_name=sym_name,
|
|
232
|
+
)
|
|
233
|
+
self._relocations_by_section[section_index] = relocs
|
|
234
|
+
return relocs
|
|
235
|
+
|
|
236
|
+
def load_function(self, symbol_name: str) -> LoadedFunction:
|
|
237
|
+
"""Extract function bytes and metadata for analysis."""
|
|
238
|
+
symbol = self.find_symbol(symbol_name)
|
|
239
|
+
|
|
240
|
+
if symbol.section_index is None:
|
|
241
|
+
raise BinaryError(f"Symbol '{symbol_name}' has no defined section")
|
|
242
|
+
|
|
243
|
+
section = self.elf.get_section(symbol.section_index)
|
|
244
|
+
if section is None:
|
|
245
|
+
raise BinaryError(
|
|
246
|
+
f"Section index {symbol.section_index} for symbol '{symbol_name}' not found"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
is_relocatable = self.elf["e_type"] == "ET_REL"
|
|
250
|
+
sec_data = section.data()
|
|
251
|
+
sec_addr = section["sh_addr"]
|
|
252
|
+
|
|
253
|
+
if is_relocatable:
|
|
254
|
+
func_offset = symbol.address
|
|
255
|
+
actual_addr = symbol.address
|
|
256
|
+
else:
|
|
257
|
+
func_offset = symbol.address - sec_addr
|
|
258
|
+
actual_addr = symbol.address
|
|
259
|
+
|
|
260
|
+
if func_offset < 0 or func_offset >= len(sec_data):
|
|
261
|
+
raise BinaryError(
|
|
262
|
+
f"Symbol '{symbol_name}' offset {func_offset} is outside section '{section.name}' (size {len(sec_data)})"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
func_size = symbol.size
|
|
266
|
+
if func_size == 0:
|
|
267
|
+
func_size = self._infer_symbol_size(symbol, section)
|
|
268
|
+
|
|
269
|
+
if func_size <= 0:
|
|
270
|
+
raise BinaryError(f"Unable to determine size for symbol '{symbol_name}'")
|
|
271
|
+
|
|
272
|
+
code_bytes = sec_data[func_offset : func_offset + func_size]
|
|
273
|
+
relocations = self._get_relocations_for_section(symbol.section_index)
|
|
274
|
+
|
|
275
|
+
# Adjust relocations relative to function address/offset
|
|
276
|
+
func_relocs: dict[int, RelocationInfo] = {}
|
|
277
|
+
for r_offset, r_info in relocations.items():
|
|
278
|
+
if is_relocatable:
|
|
279
|
+
if func_offset <= r_offset < func_offset + func_size:
|
|
280
|
+
func_relocs[r_offset] = r_info
|
|
281
|
+
else:
|
|
282
|
+
if actual_addr <= r_offset < actual_addr + func_size:
|
|
283
|
+
func_relocs[r_offset] = r_info
|
|
284
|
+
|
|
285
|
+
return LoadedFunction(
|
|
286
|
+
symbol=symbol,
|
|
287
|
+
address=actual_addr,
|
|
288
|
+
size=len(code_bytes),
|
|
289
|
+
code_bytes=code_bytes,
|
|
290
|
+
architecture=self.arch,
|
|
291
|
+
is_64bit=self.is_64bit,
|
|
292
|
+
section_name=section.name,
|
|
293
|
+
relocations=func_relocs,
|
|
294
|
+
)
|
hotflow/cfg.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Jarkko Sakkinen <jarkko.sakkinen@iki.fi>
|
|
3
|
+
|
|
4
|
+
"""Control-Flow Graph (CFG) representation and construction."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
import networkx as nx
|
|
11
|
+
|
|
12
|
+
from hotflow.disassembly import Instruction, InstructionCategory
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EdgeKind(str, Enum):
|
|
16
|
+
"""Types of control-flow edges."""
|
|
17
|
+
|
|
18
|
+
FALLTHROUGH = "fallthrough"
|
|
19
|
+
CONDITIONAL_TAKEN = "conditional_taken"
|
|
20
|
+
UNCONDITIONAL = "unconditional"
|
|
21
|
+
BACKEDGE = "backedge"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Edge:
|
|
26
|
+
"""An edge between basic blocks in the CFG."""
|
|
27
|
+
|
|
28
|
+
src: int
|
|
29
|
+
dst: int
|
|
30
|
+
kind: EdgeKind
|
|
31
|
+
probability: float = 0.0
|
|
32
|
+
probability_source: str = ""
|
|
33
|
+
confidence: str = "medium"
|
|
34
|
+
reason: str = ""
|
|
35
|
+
frequency: float = 0.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class BasicBlock:
|
|
40
|
+
"""A basic block of linear machine instructions."""
|
|
41
|
+
|
|
42
|
+
address: int
|
|
43
|
+
size: int
|
|
44
|
+
instructions: list[Instruction] = field(default_factory=list)
|
|
45
|
+
flags: set[str] = field(default_factory=set)
|
|
46
|
+
frequency: float = 0.0
|
|
47
|
+
reach_probability: float = 0.0
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def last_instruction(self) -> Instruction | None:
|
|
51
|
+
return self.instructions[-1] if self.instructions else None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def instruction_count(self) -> int:
|
|
55
|
+
return len(self.instructions)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def branch_count(self) -> int:
|
|
59
|
+
return sum(1 for insn in self.instructions if insn.is_branch)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def call_count(self) -> int:
|
|
63
|
+
return sum(1 for insn in self.instructions if insn.is_call)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def return_count(self) -> int:
|
|
67
|
+
return sum(1 for insn in self.instructions if insn.is_return)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_exit(self) -> bool:
|
|
71
|
+
return "exit" in self.flags
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def is_trap(self) -> bool:
|
|
75
|
+
return "trap" in self.flags or "noreturn" in self.flags
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CFG:
|
|
79
|
+
"""Control-Flow Graph for a function."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, entry_address: int) -> None:
|
|
82
|
+
self.entry_address = entry_address
|
|
83
|
+
self.graph: nx.DiGraph[int] = nx.DiGraph()
|
|
84
|
+
|
|
85
|
+
def add_block(self, block: BasicBlock) -> None:
|
|
86
|
+
self.graph.add_node(block.address, block=block)
|
|
87
|
+
|
|
88
|
+
def add_edge(self, edge: Edge) -> None:
|
|
89
|
+
self.graph.add_edge(edge.src, edge.dst, edge=edge)
|
|
90
|
+
|
|
91
|
+
def get_block(self, address: int) -> BasicBlock | None:
|
|
92
|
+
data = self.graph.nodes.get(address)
|
|
93
|
+
return data["block"] if data else None
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def blocks(self) -> dict[int, BasicBlock]:
|
|
97
|
+
return {addr: data["block"] for addr, data in self.graph.nodes(data=True)}
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def edges(self) -> list[Edge]:
|
|
101
|
+
return [data["edge"] for _, _, data in self.graph.edges(data=True)]
|
|
102
|
+
|
|
103
|
+
def get_outgoing_edges(self, block_address: int) -> list[Edge]:
|
|
104
|
+
return [
|
|
105
|
+
data["edge"]
|
|
106
|
+
for _, _, data in self.graph.out_edges(block_address, data=True)
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
def get_edge(self, src: int, dst: int) -> Edge | None:
|
|
110
|
+
data = self.graph.get_edge_data(src, dst)
|
|
111
|
+
return data["edge"] if data else None
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def exit_blocks(self) -> list[BasicBlock]:
|
|
115
|
+
return [
|
|
116
|
+
data["block"]
|
|
117
|
+
for _, data in self.graph.nodes(data=True)
|
|
118
|
+
if data["block"].is_exit or self.graph.out_degree(data["block"].address) == 0
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def reachable_blocks(self) -> set[int]:
|
|
123
|
+
if not self.graph.has_node(self.entry_address):
|
|
124
|
+
return set()
|
|
125
|
+
return {self.entry_address} | nx.descendants(self.graph, self.entry_address)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def cyclomatic_complexity(self) -> int:
|
|
129
|
+
"""Calculate McCabe cyclomatic complexity: E - V + 2P."""
|
|
130
|
+
v = self.graph.number_of_nodes()
|
|
131
|
+
e = self.graph.number_of_edges()
|
|
132
|
+
if v == 0:
|
|
133
|
+
return 0
|
|
134
|
+
return max(1, e - v + 2)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_cfg(
|
|
138
|
+
function_address: int,
|
|
139
|
+
function_size: int,
|
|
140
|
+
instructions: list[Instruction],
|
|
141
|
+
) -> CFG:
|
|
142
|
+
"""Build a control-flow graph from disassembled instructions."""
|
|
143
|
+
if not instructions:
|
|
144
|
+
return CFG(function_address)
|
|
145
|
+
|
|
146
|
+
function_end = function_address + function_size
|
|
147
|
+
entry_address = instructions[0].address
|
|
148
|
+
cfg = CFG(entry_address)
|
|
149
|
+
address_to_index = {
|
|
150
|
+
instruction.address: index
|
|
151
|
+
for index, instruction in enumerate(instructions)
|
|
152
|
+
}
|
|
153
|
+
leaders = {entry_address}
|
|
154
|
+
|
|
155
|
+
for index, instruction in enumerate(instructions):
|
|
156
|
+
if instruction.is_branch:
|
|
157
|
+
if (
|
|
158
|
+
instruction.target is not None
|
|
159
|
+
and function_address <= instruction.target < function_end
|
|
160
|
+
and instruction.target in address_to_index
|
|
161
|
+
):
|
|
162
|
+
leaders.add(instruction.target)
|
|
163
|
+
if index + 1 < len(instructions):
|
|
164
|
+
leaders.add(instructions[index + 1].address)
|
|
165
|
+
elif (instruction.is_return or instruction.is_trap) and index + 1 < len(instructions):
|
|
166
|
+
leaders.add(instructions[index + 1].address)
|
|
167
|
+
|
|
168
|
+
leader_indexes = sorted(address_to_index[address] for address in leaders)
|
|
169
|
+
for position, start in enumerate(leader_indexes):
|
|
170
|
+
end = (
|
|
171
|
+
leader_indexes[position + 1]
|
|
172
|
+
if position + 1 < len(leader_indexes)
|
|
173
|
+
else len(instructions)
|
|
174
|
+
)
|
|
175
|
+
block_instructions = instructions[start:end]
|
|
176
|
+
block = BasicBlock(
|
|
177
|
+
address=block_instructions[0].address,
|
|
178
|
+
size=sum(instruction.size for instruction in block_instructions),
|
|
179
|
+
instructions=block_instructions,
|
|
180
|
+
)
|
|
181
|
+
if block.address == entry_address:
|
|
182
|
+
block.flags.add("entry")
|
|
183
|
+
cfg.add_block(block)
|
|
184
|
+
|
|
185
|
+
block_addresses = sorted(cfg.blocks)
|
|
186
|
+
for index, address in enumerate(block_addresses):
|
|
187
|
+
block = cfg.blocks[address]
|
|
188
|
+
last_instruction = block.last_instruction
|
|
189
|
+
if last_instruction is None:
|
|
190
|
+
continue
|
|
191
|
+
next_address = (
|
|
192
|
+
block_addresses[index + 1]
|
|
193
|
+
if index + 1 < len(block_addresses)
|
|
194
|
+
else None
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if last_instruction.is_conditional:
|
|
198
|
+
if (
|
|
199
|
+
last_instruction.target is not None
|
|
200
|
+
and last_instruction.target in cfg.blocks
|
|
201
|
+
):
|
|
202
|
+
cfg.add_edge(
|
|
203
|
+
Edge(
|
|
204
|
+
src=block.address,
|
|
205
|
+
dst=last_instruction.target,
|
|
206
|
+
kind=EdgeKind.CONDITIONAL_TAKEN,
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
fallthrough = last_instruction.address + last_instruction.size
|
|
210
|
+
if fallthrough in cfg.blocks:
|
|
211
|
+
cfg.add_edge(
|
|
212
|
+
Edge(
|
|
213
|
+
src=block.address,
|
|
214
|
+
dst=fallthrough,
|
|
215
|
+
kind=EdgeKind.FALLTHROUGH,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
elif last_instruction.is_unconditional:
|
|
219
|
+
if (
|
|
220
|
+
last_instruction.target is not None
|
|
221
|
+
and last_instruction.target in cfg.blocks
|
|
222
|
+
):
|
|
223
|
+
cfg.add_edge(
|
|
224
|
+
Edge(
|
|
225
|
+
src=block.address,
|
|
226
|
+
dst=last_instruction.target,
|
|
227
|
+
kind=EdgeKind.UNCONDITIONAL,
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
else:
|
|
231
|
+
block.flags.add("exit")
|
|
232
|
+
elif last_instruction.is_return:
|
|
233
|
+
block.flags.add("exit")
|
|
234
|
+
elif last_instruction.is_trap:
|
|
235
|
+
block.flags.update(("trap", "exit"))
|
|
236
|
+
elif last_instruction.category == InstructionCategory.INDIRECT_BRANCH:
|
|
237
|
+
block.flags.update(("indirect", "exit"))
|
|
238
|
+
elif (
|
|
239
|
+
next_address is not None
|
|
240
|
+
and last_instruction.address + last_instruction.size == next_address
|
|
241
|
+
):
|
|
242
|
+
cfg.add_edge(
|
|
243
|
+
Edge(
|
|
244
|
+
src=block.address,
|
|
245
|
+
dst=next_address,
|
|
246
|
+
kind=EdgeKind.FALLTHROUGH,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
else:
|
|
250
|
+
block.flags.add("exit")
|
|
251
|
+
|
|
252
|
+
if not cfg.get_outgoing_edges(block.address):
|
|
253
|
+
block.flags.add("exit")
|
|
254
|
+
|
|
255
|
+
return cfg
|