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.
Files changed (65) hide show
  1. codevanta/__init__.py +1 -0
  2. codevanta/analysis/__init__.py +1 -0
  3. codevanta/analysis/binary_diff.py +1168 -0
  4. codevanta/analysis/callgraph.py +542 -0
  5. codevanta/analysis/cfg.py +1201 -0
  6. codevanta/analysis/dataflow.py +762 -0
  7. codevanta/analysis/debugger.py +289 -0
  8. codevanta/analysis/debuginfo.py +304 -0
  9. codevanta/analysis/decompile.py +1094 -0
  10. codevanta/analysis/decompilers/__init__.py +250 -0
  11. codevanta/analysis/decompilers/angr_backend.py +251 -0
  12. codevanta/analysis/decompilers/base.py +278 -0
  13. codevanta/analysis/decompilers/ghidra_backend.py +316 -0
  14. codevanta/analysis/dynamic.py +756 -0
  15. codevanta/analysis/emulate.py +1349 -0
  16. codevanta/analysis/evidence_bundle.py +736 -0
  17. codevanta/analysis/function_hash.py +562 -0
  18. codevanta/analysis/graph_export.py +441 -0
  19. codevanta/analysis/html_view.py +695 -0
  20. codevanta/analysis/jump_tables.py +546 -0
  21. codevanta/analysis/solver.py +715 -0
  22. codevanta/analysis/symbolic.py +1809 -0
  23. codevanta/analysis/trace.py +646 -0
  24. codevanta/analysis/type_propagation.py +1055 -0
  25. codevanta/analysis/type_recovery.py +1214 -0
  26. codevanta/analysis/vtables.py +572 -0
  27. codevanta/analysis/xrefs.py +434 -0
  28. codevanta/api/__init__.py +1 -0
  29. codevanta/cli/__init__.py +1 -0
  30. codevanta/cli/main.py +3038 -0
  31. codevanta/core/__init__.py +31 -0
  32. codevanta/core/disassembler.py +121 -0
  33. codevanta/core/entropy.py +237 -0
  34. codevanta/core/functions.py +176 -0
  35. codevanta/core/loader.py +1351 -0
  36. codevanta/core/report.py +306 -0
  37. codevanta/core/strings.py +109 -0
  38. codevanta/evidence/__init__.py +7 -0
  39. codevanta/evidence/confidence.py +36 -0
  40. codevanta/evidence/models.py +43 -0
  41. codevanta/evidence/provenance.py +37 -0
  42. codevanta/gui/__init__.py +46 -0
  43. codevanta/gui/layout.py +1384 -0
  44. codevanta/gui/server.py +1305 -0
  45. codevanta/ir/__init__.py +6 -0
  46. codevanta/ir/adapters/__init__.py +31 -0
  47. codevanta/ir/base.py +31 -0
  48. codevanta/ir/instruction.py +42 -0
  49. codevanta/mcp_server/__init__.py +5 -0
  50. codevanta/mcp_server/server.py +710 -0
  51. codevanta/mcp_server/tools.py +1364 -0
  52. codevanta/plugins/__init__.py +748 -0
  53. codevanta/setup/__init__.py +28 -0
  54. codevanta/setup/apply.py +22 -0
  55. codevanta/setup/clients.py +285 -0
  56. codevanta/setup/plan.py +79 -0
  57. codevanta/setup/verify.py +43 -0
  58. codevanta/storage/__init__.py +1 -0
  59. codevanta/storage/session.py +514 -0
  60. codevanta-0.1.0.dist-info/METADATA +688 -0
  61. codevanta-0.1.0.dist-info/RECORD +65 -0
  62. codevanta-0.1.0.dist-info/WHEEL +5 -0
  63. codevanta-0.1.0.dist-info/entry_points.txt +2 -0
  64. codevanta-0.1.0.dist-info/licenses/LICENSE +21 -0
  65. codevanta-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1351 @@
1
+ """Binary loading and metadata extraction backed by LIEF.
2
+
3
+ The loader normalizes PE, ELF, and Mach-O binaries into :class:`BinaryInfo`.
4
+ All addresses exposed by ``BinaryInfo`` are absolute virtual addresses (VA);
5
+ PE RVAs are rebased with the image base so downstream modules can treat every
6
+ format uniformly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import struct
13
+ from dataclasses import dataclass, field, replace
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import lief
18
+
19
+ from codevanta.evidence.confidence import Confidence
20
+ from codevanta.evidence.models import Evidence
21
+
22
+ try: # keep expected parser warnings (e.g. section-only ELF) out of stderr
23
+ lief.logging.set_level(lief.logging.LEVEL.ERROR)
24
+ except Exception: # pragma: no cover - LIEF build without logging controls
25
+ pass
26
+
27
+
28
+
29
+ class BinaryLoadError(Exception):
30
+ """Raised when a file cannot be loaded as a supported binary."""
31
+
32
+
33
+ # ---- fat (universal) Mach-O container --------------------------------------
34
+
35
+ # A universal binary wraps several thin Mach-O images behind one of these
36
+ # magics. The 8-byte header (magic + nfat_arch) is always followed by one
37
+ # ``fat_arch`` entry per slice; the "_64" variants use 64-bit offset/size
38
+ # fields and the "CIGAM" variants store the fields in little-endian order.
39
+ FAT_MAGIC = 0xCAFEBABE
40
+ FAT_CIGAM = 0xBEBAFECA
41
+ FAT_MAGIC_64 = 0xCAFEBABF
42
+ FAT_CIGAM_64 = 0xBFBAFECA
43
+
44
+ # magic -> (magic name, 64-bit table entries, struct byte order)
45
+ _FAT_MAGICS: dict[int, tuple[str, bool, str]] = {
46
+ FAT_MAGIC: ("FAT_MAGIC", False, ">"),
47
+ FAT_CIGAM: ("FAT_CIGAM", False, "<"),
48
+ FAT_MAGIC_64: ("FAT_MAGIC_64", True, ">"),
49
+ FAT_CIGAM_64: ("FAT_CIGAM_64", True, "<"),
50
+ }
51
+
52
+ _FAT_HEADER_SIZE = 8
53
+ _FAT_ARCH_SIZE = 20 # fat_arch: cputype, cpusubtype, offset, size, align
54
+ _FAT_ARCH_64_SIZE = 32 # fat_arch_64: 64-bit offset/size plus a reserved field
55
+
56
+ # Universal binaries in the wild carry at most a handful of slices (2-4 is
57
+ # typical). ``nfat_arch`` is attacker-controlled, so the table is hard-capped:
58
+ # without this bound a crafted 512 MiB file could declare hundreds of thousands
59
+ # of entries and burn seconds of parsing time plus proportional memory.
60
+ MAX_FAT_SLICES = 32
61
+
62
+ # ``fat_arch.align`` is an exponent (``1 << align``), never larger than 2**20
63
+ # in real toolchain output. Anything above that would let a file demand absurd
64
+ # slice alignment, so it is rejected rather than fed into ``1 << align``.
65
+ MAX_FAT_ALIGN = 20
66
+
67
+ # CPU_ARCH_ABI64 marks the 64-bit dialect of a CPU family (x86_64, arm64, ...).
68
+ _CPU_ARCH_ABI64 = 0x01000000
69
+ _CPU_TYPE_NAMES = {
70
+ 0x00000007: "x86",
71
+ 0x01000007: "x86_64",
72
+ 0x0000000C: "arm",
73
+ 0x0100000C: "aarch64",
74
+ 0x0200000C: "aarch64_32",
75
+ 0x00000012: "ppc",
76
+ 0x01000012: "ppc64",
77
+ }
78
+
79
+
80
+ def _cpu_type_name(cputype: int) -> str:
81
+ """Stable, human-readable name for a Mach-O ``cputype``."""
82
+ return _CPU_TYPE_NAMES.get(cputype, f"cputype_{cputype:#x}")
83
+
84
+
85
+ # LIEF ``CPU_TYPE`` member name -> numeric cputype, used only when the enum
86
+ # cannot be cast to ``int`` (older/limited LIEF builds).
87
+ _CPU_TYPE_ALIASES = {
88
+ "I386": 0x00000007,
89
+ "X86": 0x00000007,
90
+ "X86_64": 0x01000007,
91
+ "AMD64": 0x01000007,
92
+ "ARM": 0x0000000C,
93
+ "AARCH64": 0x0100000C,
94
+ "ARM64": 0x0100000C,
95
+ "ARM64E": 0x0100000C,
96
+ "ARM64_32": 0x0200000C,
97
+ "POWERPC": 0x00000012,
98
+ "POWERPC64": 0x01000012,
99
+ }
100
+
101
+
102
+ # Selection rule tokens, kept stable for reports and tests.
103
+ RULE_PREFER_64BIT = "prefer_64bit"
104
+ RULE_FIRST_BY_OFFSET = "first_by_offset"
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class FatArch:
109
+ """One ``fat_arch`` entry of a fat Mach-O architecture table."""
110
+
111
+ index: int
112
+ cputype: int
113
+ cpusubtype: int
114
+ offset: int
115
+ size: int
116
+ align: int
117
+
118
+ @property
119
+ def is_64bit(self) -> bool:
120
+ return bool(self.cputype & _CPU_ARCH_ABI64)
121
+
122
+ @property
123
+ def arch_name(self) -> str:
124
+ return _cpu_type_name(self.cputype)
125
+
126
+ def to_dict(self) -> dict[str, Any]:
127
+ return {
128
+ "index": self.index,
129
+ "arch": self.arch_name,
130
+ "cputype": f"0x{self.cputype:x}",
131
+ "cpusubtype": self.cpusubtype,
132
+ "offset": f"0x{self.offset:x}",
133
+ "size": self.size,
134
+ "align": self.align,
135
+ "is_64bit": self.is_64bit,
136
+ }
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class FatContainer:
141
+ """A parsed fat (universal) Mach-O container plus the selected slice."""
142
+
143
+ magic: int
144
+ wide_entries: bool
145
+ arches: tuple[FatArch, ...]
146
+ slice: FatArch
147
+ rule: str
148
+ reason: str
149
+
150
+ @property
151
+ def magic_name(self) -> str:
152
+ return _FAT_MAGICS[self.magic][0]
153
+
154
+ @property
155
+ def slice_count(self) -> int:
156
+ return len(self.arches)
157
+
158
+ def evidence_extra(self) -> dict[str, Any]:
159
+ """Structured container metadata for :attr:`BinaryInfo.evidence`."""
160
+ return {
161
+ "fat": True,
162
+ "fat_magic": f"0x{self.magic:08x}",
163
+ "fat_magic_name": self.magic_name,
164
+ "fat_arch_64": self.wide_entries,
165
+ "slice_index": self.slice.index,
166
+ "slice_count": self.slice_count,
167
+ "slice_arch": self.slice.arch_name,
168
+ "slice_cputype": f"0x{self.slice.cputype:x}",
169
+ "slice_offset": self.slice.offset,
170
+ "slice_size": self.slice.size,
171
+ "selection_rule": self.rule,
172
+ "selection_reason": self.reason,
173
+ "available_arches": [arch.arch_name for arch in self.arches],
174
+ # The table is already bounded by MAX_FAT_SLICES, but the evidence
175
+ # list is capped explicitly so reports stay bounded by construction
176
+ # even if that limit is ever raised.
177
+ "available_slices": [
178
+ arch.to_dict() for arch in self.arches[:MAX_FAT_SLICES]
179
+ ],
180
+ }
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class SectionInfo:
185
+ """A section with a known file mapping."""
186
+
187
+ name: str
188
+ virtual_address: int
189
+ size: int
190
+ offset: int
191
+ raw_size: int = 0
192
+ entropy: float | None = None
193
+ is_executable: bool = False
194
+ is_writable: bool = False
195
+ evidence: Evidence | None = None
196
+
197
+ def to_dict(self) -> dict[str, Any]:
198
+ data: dict[str, Any] = {
199
+ "name": self.name,
200
+ "virtual_address": f"0x{self.virtual_address:x}",
201
+ "size": self.size,
202
+ "raw_size": self.raw_size,
203
+ "offset": f"0x{self.offset:x}",
204
+ "entropy": self.entropy,
205
+ "executable": self.is_executable,
206
+ "writable": self.is_writable,
207
+ }
208
+ if self.evidence is not None:
209
+ data["evidence"] = self.evidence.to_dict()
210
+ return data
211
+
212
+
213
+ @dataclass(frozen=True)
214
+ class ImportInfo:
215
+ """An imported symbol."""
216
+
217
+ name: str
218
+ library: str | None = None
219
+ address: int | None = None
220
+ evidence: Evidence | None = None
221
+
222
+ def to_dict(self) -> dict[str, Any]:
223
+ data: dict[str, Any] = {"name": self.name}
224
+ if self.library:
225
+ data["library"] = self.library
226
+ if self.address is not None:
227
+ data["address"] = f"0x{self.address:x}"
228
+ if self.evidence is not None:
229
+ data["evidence"] = self.evidence.to_dict()
230
+ return data
231
+
232
+
233
+ @dataclass(frozen=True)
234
+ class ExportInfo:
235
+ """An exported symbol, with forwarding information when present."""
236
+
237
+ name: str
238
+ address: int | None
239
+ ordinal: int | None = None
240
+ forwarded_to: str | None = None
241
+ evidence: Evidence | None = None
242
+
243
+ def to_dict(self) -> dict[str, Any]:
244
+ data: dict[str, Any] = {"name": self.name}
245
+ if self.address is not None:
246
+ data["address"] = f"0x{self.address:x}"
247
+ if self.ordinal is not None:
248
+ data["ordinal"] = self.ordinal
249
+ if self.forwarded_to:
250
+ data["forwarded_to"] = self.forwarded_to
251
+ if self.evidence is not None:
252
+ data["evidence"] = self.evidence.to_dict()
253
+ return data
254
+
255
+
256
+ @dataclass(frozen=True)
257
+ class SymbolInfo:
258
+ """A symbol from a symbol table (ELF symtab/dynsym, PE COFF)."""
259
+
260
+ name: str
261
+ address: int
262
+ size: int = 0
263
+ kind: str = "unknown"
264
+ is_function: bool = False
265
+ evidence: Evidence | None = None
266
+
267
+ def to_dict(self) -> dict[str, Any]:
268
+ data: dict[str, Any] = {
269
+ "name": self.name,
270
+ "address": f"0x{self.address:x}",
271
+ "size": self.size,
272
+ "kind": self.kind,
273
+ "is_function": self.is_function,
274
+ }
275
+ if self.evidence is not None:
276
+ data["evidence"] = self.evidence.to_dict()
277
+ return data
278
+
279
+
280
+ @dataclass
281
+ class BinaryInfo:
282
+ """Normalized metadata for a loaded binary. All addresses are VA."""
283
+
284
+ path: Path
285
+ format: str
286
+ architecture: str
287
+ bitness: int
288
+ endianness: str
289
+ entry_point: int | None
290
+ image_base: int
291
+ sha256: str
292
+ file_size: int
293
+ sections: list[SectionInfo] = field(default_factory=list)
294
+ imports: list[ImportInfo] = field(default_factory=list)
295
+ exports: list[ExportInfo] = field(default_factory=list)
296
+ symbols: list[SymbolInfo] = field(default_factory=list)
297
+ is_stripped: bool = False
298
+ warnings: list[str] = field(default_factory=list)
299
+ # Container-level provenance (currently the fat Mach-O slice selection).
300
+ evidence: Evidence | None = None
301
+
302
+ def to_dict(self) -> dict[str, Any]:
303
+ data: dict[str, Any] = {
304
+ "path": str(self.path),
305
+ "format": self.format,
306
+ "architecture": self.architecture,
307
+ "bitness": self.bitness,
308
+ "endianness": self.endianness,
309
+ "entry_point": f"0x{self.entry_point:x}" if self.entry_point is not None else None,
310
+ "image_base": f"0x{self.image_base:x}",
311
+ "sha256": self.sha256,
312
+ "file_size": self.file_size,
313
+ "is_stripped": self.is_stripped,
314
+ "sections": [s.to_dict() for s in self.sections],
315
+ "imports": [i.to_dict() for i in self.imports],
316
+ "exports": [e.to_dict() for e in self.exports],
317
+ "symbols": [s.to_dict() for s in self.symbols],
318
+ "warnings": list(self.warnings),
319
+ }
320
+ if self.evidence is not None:
321
+ data["evidence"] = self.evidence.to_dict()
322
+ return data
323
+
324
+ def section_for_va(self, va: int) -> SectionInfo | None:
325
+ """Return the section that contains ``va``, if any."""
326
+ for section in self.sections:
327
+ if section.size <= 0:
328
+ continue
329
+ if section.virtual_address <= va < section.virtual_address + section.size:
330
+ return section
331
+ return None
332
+
333
+ def _mapped_size(self, section: SectionInfo) -> int:
334
+ if section.raw_size > 0:
335
+ return section.raw_size
336
+ if self.format == "ELF":
337
+ return section.size
338
+ return 0
339
+
340
+ def va_to_offset(self, va: int) -> int | None:
341
+ """Map a virtual address to a file offset using section metadata."""
342
+ section = self.section_for_va(va)
343
+ if section is None:
344
+ return None
345
+ delta = va - section.virtual_address
346
+ mapped = self._mapped_size(section)
347
+ if mapped <= 0 or delta < 0 or delta >= mapped:
348
+ return None
349
+ return section.offset + delta
350
+
351
+ def offset_to_va(self, offset: int) -> int | None:
352
+ """Map a file offset to a virtual address using section metadata."""
353
+ for section in self.sections:
354
+ mapped = self._mapped_size(section)
355
+ if mapped <= 0:
356
+ continue
357
+ if section.offset <= offset < section.offset + mapped:
358
+ return section.virtual_address + (offset - section.offset)
359
+ return None
360
+
361
+
362
+ class BinaryLoader:
363
+ """Loads PE, ELF, Mach-O, and DEX binaries and extracts structural metadata."""
364
+
365
+ MAX_FILE_SIZE = 512 * 1024 * 1024 # 512 MiB safety guard
366
+
367
+ def load(self, path: str | Path) -> BinaryInfo:
368
+ """Load ``path`` and return normalized :class:`BinaryInfo`."""
369
+ file_path = Path(path)
370
+ self._validate(file_path)
371
+ raw = file_path.read_bytes()
372
+ sha256 = hashlib.sha256(raw).hexdigest()
373
+
374
+ # DEX is a bytecode container, not an executable image: LIEF only
375
+ # reaches it through the dedicated DEX parser, and the generic
376
+ # ``lief.parse`` answers None for it. The magic cannot collide with
377
+ # any fat/thin image magic, so checking it first is safe.
378
+ if raw[:4] == b"dex\n":
379
+ info = self._load_dex(file_path, raw)
380
+ info.sha256 = sha256
381
+ info.file_size = len(raw)
382
+ return info
383
+
384
+ # A fat (universal) container must be unwrapped before any of the thin
385
+ # parsers run: LIEF would only ever see the container magic.
386
+ container = self._fat_container(raw, file_path)
387
+ fat_offset = 0
388
+ if container is not None:
389
+ chosen = container.slice
390
+ payload = raw[chosen.offset : chosen.offset + chosen.size]
391
+ fat_offset = chosen.offset
392
+ try:
393
+ binary = lief.parse(payload)
394
+ except Exception as exc: # LIEF raises several native error types
395
+ raise BinaryLoadError(
396
+ f"Fat Mach-O {file_path}: slice {chosen.index} "
397
+ f"({chosen.arch_name}) could not be parsed: {exc}"
398
+ ) from exc
399
+ if binary is None:
400
+ raise BinaryLoadError(
401
+ f"Fat Mach-O {file_path}: slice {chosen.index} "
402
+ f"({chosen.arch_name}) at offset 0x{chosen.offset:x} "
403
+ f"is not a valid thin Mach-O image"
404
+ )
405
+ else:
406
+ try:
407
+ binary = lief.parse(str(file_path))
408
+ except Exception as exc: # LIEF raises several native error types
409
+ raise BinaryLoadError(f"LIEF could not parse {file_path}: {exc}") from exc
410
+ if binary is None:
411
+ raise BinaryLoadError(f"Unsupported or unreadable binary: {file_path}")
412
+
413
+ fmt = self._format_name(binary)
414
+ if container is not None:
415
+ # Everything inside a fat container must be a thin Mach-O image:
416
+ # an ELF or PE payload would be parsed with slice-relative offsets
417
+ # and silently reported as that foreign format.
418
+ if fmt != "MachO":
419
+ raise BinaryLoadError(
420
+ f"Fat Mach-O {file_path}: fat slice is not a Mach-O image: {fmt}"
421
+ )
422
+ # With the payload parsed, the slice header is the authority on what
423
+ # was actually loaded; the container table is only a claim.
424
+ container = self._verify_fat_slice(container, binary, file_path)
425
+ if fmt == "PE":
426
+ info = self._load_pe(binary, file_path)
427
+ elif fmt == "ELF":
428
+ info = self._load_elf(binary, file_path)
429
+ elif fmt == "MachO":
430
+ info = self._load_macho(
431
+ binary, file_path, fat_offset=fat_offset, container=container
432
+ )
433
+ else:
434
+ raise BinaryLoadError(f"Unsupported format {fmt!r}: {file_path}")
435
+
436
+ info.sha256 = sha256
437
+ info.file_size = len(raw)
438
+ return info
439
+
440
+ @staticmethod
441
+ def _validate(path: Path) -> None:
442
+ if not path.exists():
443
+ raise BinaryLoadError(f"File not found: {path}")
444
+ if not path.is_file():
445
+ raise BinaryLoadError(f"Not a regular file: {path}")
446
+ size = path.stat().st_size
447
+ if size == 0:
448
+ raise BinaryLoadError(f"Empty file: {path}")
449
+ if size > BinaryLoader.MAX_FILE_SIZE:
450
+ raise BinaryLoadError(
451
+ f"File too large ({size} bytes); limit is {BinaryLoader.MAX_FILE_SIZE} bytes"
452
+ )
453
+
454
+ @staticmethod
455
+ def _format_name(binary: Any) -> str:
456
+ text = str(getattr(binary, "format", "")).upper()
457
+ if "ELF" in text:
458
+ return "ELF"
459
+ if "PE" in text:
460
+ return "PE"
461
+ if "MACHO" in text or "MACH_O" in text:
462
+ return "MachO"
463
+ type_name = type(binary).__name__.upper()
464
+ if "ELF" in type_name:
465
+ return "ELF"
466
+ if "PE" in type_name:
467
+ return "PE"
468
+ return "Unknown"
469
+
470
+ # ---- fat (universal) Mach-O container ----------------------------------
471
+
472
+ @classmethod
473
+ def _fat_container(cls, raw: bytes, path: Path) -> FatContainer | None:
474
+ """Parse a fat Mach-O container header and pick one slice.
475
+
476
+ Returns ``None`` when ``raw`` does not start with a fat magic, so thin
477
+ Mach-O, ELF and PE inputs keep taking the unchanged path. Malformed
478
+ containers raise :class:`BinaryLoadError` naming the offending field.
479
+ """
480
+ if len(raw) < len(b"\xca\xfe\xba\xbe"):
481
+ return None
482
+ magic = int.from_bytes(raw[:4], "big")
483
+ if magic not in _FAT_MAGICS:
484
+ return None
485
+ magic_name, wide_entries, order = _FAT_MAGICS[magic]
486
+ entry_size = _FAT_ARCH_64_SIZE if wide_entries else _FAT_ARCH_SIZE
487
+
488
+ if len(raw) < _FAT_HEADER_SIZE:
489
+ raise BinaryLoadError(
490
+ f"Malformed fat Mach-O {path}: nfat_arch field is truncated "
491
+ f"({magic_name} header needs {_FAT_HEADER_SIZE} bytes, "
492
+ f"file has {len(raw)})"
493
+ )
494
+ slice_count = struct.unpack_from(f"{order}I", raw, 4)[0]
495
+ if slice_count == 0:
496
+ raise BinaryLoadError(
497
+ f"Malformed fat Mach-O {path}: nfat_arch is zero, a fat "
498
+ f"{magic_name} container must hold at least one slice"
499
+ )
500
+ if slice_count > MAX_FAT_SLICES:
501
+ raise BinaryLoadError(
502
+ f"Malformed fat Mach-O {path}: nfat_arch={slice_count} exceeds "
503
+ f"the supported maximum of {MAX_FAT_SLICES} slices"
504
+ )
505
+
506
+ table_end = _FAT_HEADER_SIZE + slice_count * entry_size
507
+ if table_end > len(raw):
508
+ raise BinaryLoadError(
509
+ f"Malformed fat Mach-O {path}: fat_arch table truncated "
510
+ f"(nfat_arch={slice_count} needs {table_end} bytes, "
511
+ f"file has {len(raw)})"
512
+ )
513
+
514
+ format_string = f"{order}IIQQII" if wide_entries else f"{order}IIIII"
515
+ arches: list[FatArch] = []
516
+ for index in range(slice_count):
517
+ base = _FAT_HEADER_SIZE + index * entry_size
518
+ if wide_entries:
519
+ # fat_arch_64 carries a trailing reserved u32.
520
+ cputype, cpusubtype, offset, size, align = struct.unpack_from(
521
+ format_string, raw, base
522
+ )[:5]
523
+ else:
524
+ cputype, cpusubtype, offset, size, align = struct.unpack_from(
525
+ format_string, raw, base
526
+ )
527
+ if size == 0:
528
+ raise BinaryLoadError(
529
+ f"Malformed fat Mach-O {path}: fat_arch[{index}].size is zero"
530
+ )
531
+ if align > MAX_FAT_ALIGN:
532
+ raise BinaryLoadError(
533
+ f"Malformed fat Mach-O {path}: fat_arch[{index}].align is "
534
+ f"{align}, above the supported maximum of {MAX_FAT_ALIGN}"
535
+ )
536
+ alignment = 1 << align
537
+ if offset % alignment != 0:
538
+ raise BinaryLoadError(
539
+ f"Malformed fat Mach-O {path}: fat_arch[{index}].offset "
540
+ f"0x{offset:x} is not aligned to {alignment} bytes "
541
+ f"(align={align})"
542
+ )
543
+ if offset < table_end:
544
+ raise BinaryLoadError(
545
+ f"Malformed fat Mach-O {path}: fat_arch[{index}].offset "
546
+ f"0x{offset:x} points inside the fat header/table, which "
547
+ f"ends at 0x{table_end:x}"
548
+ )
549
+ if offset + size > len(raw):
550
+ raise BinaryLoadError(
551
+ f"Malformed fat Mach-O {path}: fat_arch[{index}].offset/size "
552
+ f"outside the file (offset=0x{offset:x}, size=0x{size:x}, "
553
+ f"file size=0x{len(raw):x})"
554
+ )
555
+ arches.append(
556
+ FatArch(
557
+ index=index,
558
+ cputype=cputype,
559
+ cpusubtype=cpusubtype,
560
+ offset=offset,
561
+ size=size,
562
+ align=align,
563
+ )
564
+ )
565
+
566
+ chosen, rule, reason = cls._select_fat_slice(arches)
567
+ return FatContainer(
568
+ magic=magic,
569
+ wide_entries=wide_entries,
570
+ arches=tuple(arches),
571
+ slice=chosen,
572
+ rule=rule,
573
+ reason=reason,
574
+ )
575
+
576
+ @staticmethod
577
+ def _select_fat_slice(arches: list[FatArch]) -> tuple[FatArch, str, str]:
578
+ """Pick one slice deterministically: 64-bit first, then lowest offset."""
579
+ wide = [arch for arch in arches if arch.is_64bit]
580
+ candidates = wide or arches
581
+ chosen = min(candidates, key=lambda arch: (arch.offset, arch.index))
582
+ if wide:
583
+ return (
584
+ chosen,
585
+ RULE_PREFER_64BIT,
586
+ "64-bit slice preferred over 32-bit slices, lowest file offset wins",
587
+ )
588
+ return (
589
+ chosen,
590
+ RULE_FIRST_BY_OFFSET,
591
+ "no 64-bit slice present, first slice by file offset wins",
592
+ )
593
+
594
+ @classmethod
595
+ def _verify_fat_slice(
596
+ cls, container: FatContainer, binary: Any, path: Path
597
+ ) -> FatContainer:
598
+ """Reconcile the container entry with the parsed thin Mach-O header.
599
+
600
+ The table entry only claims an architecture; the slice header proves it.
601
+ A mismatch means the bytes are not what the container advertises, so it
602
+ is rejected. On success the container's slice is rebuilt from the real
603
+ header, keeping ``slice_cputype``/``slice_arch`` evidence honest.
604
+ """
605
+ header_cputype = cls._macho_header_cputype(binary)
606
+ if header_cputype is None: # LIEF build without a usable cpu_type
607
+ return container
608
+ chosen = container.slice
609
+ if header_cputype != chosen.cputype:
610
+ raise BinaryLoadError(
611
+ f"Fat Mach-O {path}: fat_arch[{chosen.index}].cputype "
612
+ f"{_cpu_type_name(chosen.cputype)} (0x{chosen.cputype:x}) does "
613
+ f"not match the parsed slice header cpu_type "
614
+ f"{_cpu_type_name(header_cputype)} (0x{header_cputype:x})"
615
+ )
616
+ return replace(container, slice=replace(chosen, cputype=header_cputype))
617
+
618
+ @staticmethod
619
+ def _macho_header_cputype(binary: Any) -> int | None:
620
+ """Numeric ``cputype`` of a parsed Mach-O header, or ``None``."""
621
+ header = getattr(binary, "header", None)
622
+ cpu_type = getattr(header, "cpu_type", None)
623
+ if cpu_type is None:
624
+ return None
625
+ try:
626
+ return int(cpu_type)
627
+ except (TypeError, ValueError):
628
+ pass
629
+ # LIEF builds that expose only the enum member name.
630
+ name = str(getattr(cpu_type, "name", cpu_type) or "").upper()
631
+ return _CPU_TYPE_ALIASES.get(name)
632
+
633
+ # ---- PE ---------------------------------------------------------------
634
+
635
+ def _load_pe(self, binary: Any, path: Path) -> BinaryInfo:
636
+ optional = getattr(binary, "optional_header", None)
637
+ image_base = self._as_int(getattr(optional, "imagebase", 0))
638
+ entry_rva = self._as_int(getattr(optional, "addressof_entrypoint", 0))
639
+ entry_va = image_base + entry_rva if entry_rva else None
640
+ header = getattr(binary, "header", None)
641
+ machine = str(self._get_attr(header, "machine", "machine_type") or "")
642
+ arch, bitness = self._pe_arch(machine)
643
+ sections = [self._pe_section(s, image_base) for s in self._safe(binary, "sections")]
644
+ imports = self._pe_imports(binary, image_base)
645
+ exports = self._pe_exports(binary, image_base)
646
+ symbols = self._pe_symbols(binary)
647
+ warnings: list[str] = []
648
+ if not exports and not symbols:
649
+ warnings.append(
650
+ "No exports or symbols found; function discovery is limited to the entry point."
651
+ )
652
+ return BinaryInfo(
653
+ path=path,
654
+ format="PE",
655
+ architecture=arch,
656
+ bitness=bitness,
657
+ endianness="little",
658
+ entry_point=entry_va,
659
+ image_base=image_base,
660
+ sha256="",
661
+ file_size=0,
662
+ sections=sections,
663
+ imports=imports,
664
+ exports=exports,
665
+ symbols=symbols,
666
+ is_stripped=not symbols and not exports,
667
+ warnings=warnings,
668
+ )
669
+
670
+ @staticmethod
671
+ def _pe_arch(machine: str) -> tuple[str, int]:
672
+ upper = machine.upper()
673
+ if "AMD64" in upper or "X86_64" in upper:
674
+ return "x86_64", 64
675
+ if "I386" in upper or "I686" in upper:
676
+ return "x86", 32
677
+ if "AARCH64" in upper or "ARM64" in upper:
678
+ return "aarch64", 64
679
+ if "ARM" in upper:
680
+ return "arm", 32
681
+ if "IA64" in upper:
682
+ return "ia64", 64
683
+ return "unknown", 0
684
+
685
+ @classmethod
686
+ def _pe_section(cls, section: Any, image_base: int) -> SectionInfo:
687
+ characteristics = cls._as_int(getattr(section, "characteristics", 0))
688
+ entropy = getattr(section, "entropy", None)
689
+ virtual_size = cls._as_int(getattr(section, "virtual_size", 0))
690
+ size = virtual_size or cls._as_int(getattr(section, "size", 0))
691
+ return SectionInfo(
692
+ name=str(getattr(section, "name", "") or ""),
693
+ virtual_address=image_base + cls._as_int(getattr(section, "virtual_address", 0)),
694
+ size=size,
695
+ offset=cls._as_int(getattr(section, "offset", 0)),
696
+ raw_size=cls._as_int(getattr(section, "sizeof_raw_data", 0)),
697
+ entropy=float(entropy) if entropy is not None else None,
698
+ is_executable=bool(characteristics & 0x20000000),
699
+ is_writable=bool(characteristics & 0x80000000),
700
+ evidence=Evidence(
701
+ source="pe.section_table",
702
+ method="section_header",
703
+ confidence=Confidence.CERTAIN,
704
+ address=image_base + cls._as_int(getattr(section, "virtual_address", 0)),
705
+ detail=f"PE section {getattr(section, 'name', '')!r}",
706
+ ),
707
+ )
708
+
709
+ @classmethod
710
+ def _pe_imports(cls, binary: Any, image_base: int) -> list[ImportInfo]:
711
+ imports: list[ImportInfo] = []
712
+ for lib in cls._safe(binary, "imports"):
713
+ library = str(getattr(lib, "name", "") or "") or None
714
+ for entry in getattr(lib, "entries", []) or []:
715
+ name = str(getattr(entry, "name", "") or "")
716
+ if not name and getattr(entry, "is_ordinal", False):
717
+ name = f"ordinal_{cls._as_int(getattr(entry, 'ordinal', 0))}"
718
+ if not name:
719
+ continue
720
+ iat = getattr(entry, "iat_address", None)
721
+ address = image_base + cls._as_int(iat) if iat else None
722
+ imports.append(
723
+ ImportInfo(
724
+ name=name,
725
+ library=library,
726
+ address=address,
727
+ evidence=Evidence(
728
+ source="pe.import_table",
729
+ method="import_directory",
730
+ confidence=Confidence.HIGH,
731
+ address=address,
732
+ detail=f"imported from {library or 'unknown module'}",
733
+ ),
734
+ )
735
+ )
736
+ return imports
737
+
738
+ @classmethod
739
+ def _pe_exports(cls, binary: Any, image_base: int) -> list[ExportInfo]:
740
+ exports: list[ExportInfo] = []
741
+ table = None
742
+ getter = getattr(binary, "get_export", None) or getattr(binary, "export", None)
743
+ if callable(getter):
744
+ try:
745
+ table = getter()
746
+ except Exception:
747
+ table = None
748
+ entries = list(getattr(table, "entries", []) or []) if table is not None else []
749
+ if entries:
750
+ for entry in entries:
751
+ name = str(getattr(entry, "name", "") or "")
752
+ ordinal = getattr(entry, "ordinal", None)
753
+ forwarded = bool(getattr(entry, "is_forwarded", False))
754
+ forward_to = cls._pe_forward_target(entry)
755
+ rva = getattr(entry, "function_rva", None)
756
+ if not rva:
757
+ rva = getattr(entry, "address", None)
758
+ address = image_base + cls._as_int(rva) if rva else None
759
+ confidence = Confidence.HIGH if address is not None else Confidence.MEDIUM
760
+ detail = "forwarded export" if forwarded else "exported function"
761
+ evidence = Evidence(
762
+ source="pe.export_table",
763
+ method="export_directory",
764
+ confidence=confidence,
765
+ address=address,
766
+ detail=detail,
767
+ )
768
+ exports.append(
769
+ ExportInfo(
770
+ name=name or f"ordinal_{cls._as_int(ordinal)}",
771
+ address=address,
772
+ ordinal=cls._as_int(ordinal) if ordinal is not None else None,
773
+ forwarded_to=forward_to,
774
+ evidence=evidence,
775
+ )
776
+ )
777
+ return exports
778
+
779
+ # Fallback for LIEF builds that only expose a name list.
780
+ for item in cls._safe(binary, "exported_functions"):
781
+ if isinstance(item, str):
782
+ name, raw_address = item, None
783
+ else:
784
+ name = str(getattr(item, "name", "") or "")
785
+ raw_address = getattr(item, "address", None)
786
+ if not name:
787
+ continue
788
+ address = image_base + cls._as_int(raw_address) if raw_address else None
789
+ evidence = Evidence(
790
+ source="pe.exported_functions",
791
+ method="export_list",
792
+ confidence=Confidence.HIGH if address is not None else Confidence.MEDIUM,
793
+ address=address,
794
+ detail="exported function",
795
+ )
796
+ exports.append(ExportInfo(name=name, address=address, evidence=evidence))
797
+ return exports
798
+
799
+ @staticmethod
800
+ def _pe_forward_target(entry: Any) -> str | None:
801
+ info = getattr(entry, "forward_information", None)
802
+ if info is None:
803
+ return None
804
+ library = getattr(info, "library", None)
805
+ function = getattr(info, "function", None)
806
+ if library and function:
807
+ return f"{library}.{function}"
808
+ return str(info) if info else None
809
+
810
+ @classmethod
811
+ def _pe_symbols(cls, binary: Any) -> list[SymbolInfo]:
812
+ symbols: list[SymbolInfo] = []
813
+ for symbol in cls._safe(binary, "symbols"):
814
+ name = str(getattr(symbol, "name", "") or "")
815
+ if not name:
816
+ continue
817
+ value = cls._as_int(getattr(symbol, "value", 0))
818
+ size = cls._as_int(getattr(symbol, "size", 0))
819
+ is_function = bool(getattr(symbol, "is_function", False))
820
+ evidence = Evidence(
821
+ source="pe.coff_symbols",
822
+ method="symbol_table",
823
+ confidence=Confidence.MEDIUM,
824
+ address=value or None,
825
+ detail="COFF symbol value may be section-relative",
826
+ )
827
+ symbols.append(
828
+ SymbolInfo(
829
+ name=name,
830
+ address=value,
831
+ size=size,
832
+ kind="function" if is_function else "data",
833
+ is_function=is_function,
834
+ evidence=evidence,
835
+ )
836
+ )
837
+ return symbols
838
+
839
+
840
+ # ---- ELF --------------------------------------------------------------
841
+
842
+ def _load_elf(self, binary: Any, path: Path) -> BinaryInfo:
843
+ header = getattr(binary, "header", None)
844
+ machine = str(self._get_attr(header, "machine_type", "machine") or "")
845
+ arch, bitness = self._elf_arch(machine)
846
+ class_text = str(self._get_attr(header, "identity_class", "") or "").upper()
847
+ if "ELF64" in class_text:
848
+ bitness = 64
849
+ elif "ELF32" in class_text:
850
+ bitness = 32
851
+ data_text = str(self._get_attr(header, "identity_data", "") or "").upper()
852
+ endianness = "big" if "MSB" in data_text else "little"
853
+ image_base = self._as_int(getattr(binary, "imagebase", 0))
854
+ if image_base < 0 or image_base >= (1 << 63):
855
+ image_base = 0
856
+ entry_point = self._as_int(getattr(binary, "entrypoint", 0)) or None
857
+ sections = [self._elf_section(s) for s in self._safe(binary, "sections")]
858
+ imports = self._elf_imports(binary)
859
+ exports = self._elf_exports(binary)
860
+ symbols = self._elf_symbols(binary)
861
+ warnings: list[str] = []
862
+ if not sections:
863
+ warnings.append("ELF has no section headers; section-based analysis is limited.")
864
+ if not symbols:
865
+ warnings.append("Binary is stripped: no symbol table found.")
866
+ return BinaryInfo(
867
+ path=path,
868
+ format="ELF",
869
+ architecture=arch,
870
+ bitness=bitness,
871
+ endianness=endianness,
872
+ entry_point=entry_point,
873
+ image_base=image_base,
874
+ sha256="",
875
+ file_size=0,
876
+ sections=sections,
877
+ imports=imports,
878
+ exports=exports,
879
+ symbols=symbols,
880
+ is_stripped=not symbols,
881
+ warnings=warnings,
882
+ )
883
+
884
+ @staticmethod
885
+ def _elf_arch(machine: str) -> tuple[str, int]:
886
+ upper = machine.upper()
887
+ if "X86_64" in upper or "AMD64" in upper:
888
+ return "x86_64", 64
889
+ if "I386" in upper or upper.endswith("386"):
890
+ return "x86", 32
891
+ if "AARCH64" in upper or "ARM64" in upper:
892
+ return "aarch64", 64
893
+ if "ARM" in upper:
894
+ return "arm", 32
895
+ if "MIPS" in upper:
896
+ return "mips", 0
897
+ return "unknown", 0
898
+
899
+ @classmethod
900
+ def _elf_section(cls, section: Any) -> SectionInfo:
901
+ flags = cls._as_int(getattr(section, "flags", 0))
902
+ entropy = getattr(section, "entropy", None)
903
+ return SectionInfo(
904
+ name=str(getattr(section, "name", "") or ""),
905
+ virtual_address=cls._as_int(getattr(section, "virtual_address", 0)),
906
+ size=cls._as_int(getattr(section, "size", 0)),
907
+ offset=cls._as_int(getattr(section, "offset", 0)),
908
+ raw_size=0,
909
+ entropy=float(entropy) if entropy is not None else None,
910
+ is_executable=bool(flags & 0x4), # SHF_EXECINSTR
911
+ is_writable=bool(flags & 0x1), # SHF_WRITE
912
+ evidence=Evidence(
913
+ source="elf.section_table",
914
+ method="section_header",
915
+ confidence=Confidence.CERTAIN,
916
+ address=cls._as_int(getattr(section, "virtual_address", 0)),
917
+ detail=f"ELF section {getattr(section, 'name', '')!r}",
918
+ ),
919
+ )
920
+
921
+
922
+ @classmethod
923
+ def _elf_imports(cls, binary: Any) -> list[ImportInfo]:
924
+ imports: list[ImportInfo] = []
925
+ for symbol in cls._safe(binary, "imported_symbols"):
926
+ name = str(getattr(symbol, "name", "") or "")
927
+ if not name:
928
+ continue
929
+ value = cls._as_int(getattr(symbol, "value", 0))
930
+ imports.append(
931
+ ImportInfo(
932
+ name=name,
933
+ address=value or None,
934
+ evidence=Evidence(
935
+ source="elf.dynsym",
936
+ method="imported_symbol",
937
+ confidence=Confidence.HIGH,
938
+ address=value or None,
939
+ detail="imported dynamic symbol",
940
+ ),
941
+ )
942
+ )
943
+ return imports
944
+
945
+ @classmethod
946
+ def _elf_exports(cls, binary: Any) -> list[ExportInfo]:
947
+ exports: list[ExportInfo] = []
948
+ for symbol in cls._safe(binary, "exported_symbols"):
949
+ name = str(getattr(symbol, "name", "") or "")
950
+ if not name:
951
+ continue
952
+ value = cls._as_int(getattr(symbol, "value", 0))
953
+ evidence = Evidence(
954
+ source="elf.dynsym",
955
+ method="exported_symbol",
956
+ confidence=Confidence.HIGH if value else Confidence.MEDIUM,
957
+ address=value or None,
958
+ detail="exported dynamic symbol",
959
+ )
960
+ exports.append(ExportInfo(name=name, address=value or None, evidence=evidence))
961
+ return exports
962
+
963
+ @classmethod
964
+ def _elf_symbols(cls, binary: Any) -> list[SymbolInfo]:
965
+ symbols: list[SymbolInfo] = []
966
+ seen: set[tuple[str, int]] = set()
967
+ for symbol in cls._safe(binary, "symbols"):
968
+ name = str(getattr(symbol, "name", "") or "")
969
+ if not name:
970
+ continue
971
+ value = cls._as_int(getattr(symbol, "value", 0))
972
+ key = (name, value)
973
+ if key in seen:
974
+ continue
975
+ seen.add(key)
976
+ size = cls._as_int(getattr(symbol, "size", 0))
977
+ type_text = str(getattr(symbol, "type", "") or "")
978
+ is_function = "FUNC" in type_text.upper()
979
+ evidence = Evidence(
980
+ source="elf.symtab",
981
+ method="symbol_table",
982
+ confidence=Confidence.HIGH,
983
+ address=value or None,
984
+ detail="ELF symbol table entry",
985
+ )
986
+ symbols.append(
987
+ SymbolInfo(
988
+ name=name,
989
+ address=value,
990
+ size=size,
991
+ kind=type_text or "unknown",
992
+ is_function=is_function,
993
+ evidence=evidence,
994
+ )
995
+ )
996
+ return symbols
997
+
998
+ # ---- Mach-O ------------------------------------------------------------
999
+
1000
+ def _load_macho(
1001
+ self,
1002
+ binary: Any,
1003
+ path: Path,
1004
+ *,
1005
+ fat_offset: int = 0,
1006
+ container: FatContainer | None = None,
1007
+ ) -> BinaryInfo:
1008
+ header = getattr(binary, "header", None)
1009
+ cpu = str(self._get_attr(header, "cpu_type", "") or "")
1010
+ arch, bitness = self._macho_arch(cpu)
1011
+ if bool(getattr(header, "is_64bit", False)):
1012
+ bitness = 64
1013
+ elif bool(getattr(header, "is_32bit", False)):
1014
+ bitness = 32
1015
+ magic_text = str(self._get_attr(header, "magic", "") or "").upper()
1016
+ endianness = "big" if "CIGAM" in magic_text else "little"
1017
+ image_base = self._as_int(getattr(binary, "imagebase", 0))
1018
+ entry_point = self._as_int(getattr(binary, "entrypoint", 0)) or None
1019
+ # Section offsets from LIEF are relative to the parsed slice; shift them
1020
+ # so they stay valid offsets into the outer fat container.
1021
+ sections = [
1022
+ self._macho_section(s, fat_offset) for s in self._safe(binary, "sections")
1023
+ ]
1024
+ imports = self._macho_imports(binary)
1025
+ exports = self._macho_exports(binary)
1026
+ symbols = self._macho_symbols(binary, sections)
1027
+ warnings: list[str] = []
1028
+ if not sections:
1029
+ warnings.append("Mach-O has no sections; section-based analysis is limited.")
1030
+ if not symbols and not exports:
1031
+ warnings.append(
1032
+ "No symbol table or exports found; function discovery is limited to the entry point."
1033
+ )
1034
+ evidence = None
1035
+ if container is not None:
1036
+ chosen = container.slice
1037
+ evidence = Evidence(
1038
+ source="macho.fat_header",
1039
+ method="fat_arch_table",
1040
+ confidence=Confidence.CERTAIN,
1041
+ detail=(
1042
+ f"selected slice {chosen.index} of {container.slice_count} "
1043
+ f"({chosen.arch_name}) from a {container.magic_name} container: "
1044
+ f"{container.reason}"
1045
+ ),
1046
+ extra=container.evidence_extra(),
1047
+ )
1048
+ return BinaryInfo(
1049
+ path=path,
1050
+ format="MachO",
1051
+ architecture=arch,
1052
+ bitness=bitness,
1053
+ endianness=endianness,
1054
+ entry_point=entry_point,
1055
+ image_base=image_base,
1056
+ sha256="",
1057
+ file_size=0,
1058
+ sections=sections,
1059
+ imports=imports,
1060
+ exports=exports,
1061
+ symbols=symbols,
1062
+ is_stripped=not symbols and not exports,
1063
+ warnings=warnings,
1064
+ evidence=evidence,
1065
+ )
1066
+
1067
+ @staticmethod
1068
+ def _macho_arch(cpu: str) -> tuple[str, int]:
1069
+ upper = cpu.upper()
1070
+ if "X86_64" in upper:
1071
+ return "x86_64", 64
1072
+ if "ARM64" in upper or "ARM64E" in upper:
1073
+ return "aarch64", 64
1074
+ if "I386" in upper:
1075
+ return "x86", 32
1076
+ if "ARM" in upper:
1077
+ return "arm", 32
1078
+ if "POWERPC64" in upper:
1079
+ return "ppc64", 64
1080
+ if "POWERPC" in upper:
1081
+ return "ppc", 32
1082
+ return "unknown", 0
1083
+
1084
+ @classmethod
1085
+ def _macho_section(cls, section: Any, fat_offset: int = 0) -> SectionInfo:
1086
+ flags = cls._as_int(getattr(section, "flags", 0))
1087
+ offset = cls._as_int(getattr(section, "offset", 0))
1088
+ size = cls._as_int(getattr(section, "size", 0))
1089
+ virtual_address = cls._as_int(getattr(section, "virtual_address", 0))
1090
+ # A slice-relative offset of 0 means "no file bytes" (S_ZEROFILL and
1091
+ # friends); those stay unmapped even inside a fat container.
1092
+ file_offset = offset + fat_offset if offset > 0 else offset
1093
+ segment = None
1094
+ if bool(getattr(section, "has_segment", False)):
1095
+ try:
1096
+ segment = section.segment
1097
+ except Exception:
1098
+ segment = None
1099
+ init_protection = cls._as_int(getattr(segment, "init_protection", 0))
1100
+ entropy = getattr(section, "entropy", None)
1101
+ name = cls._decode_name(cls._get_attr(section, "fullname", "name"))
1102
+ return SectionInfo(
1103
+ name=name,
1104
+ virtual_address=virtual_address,
1105
+ size=size,
1106
+ offset=file_offset,
1107
+ # Sections without file bytes (S_ZEROFILL and friends) have offset 0.
1108
+ raw_size=size if file_offset > 0 else 0,
1109
+ entropy=float(entropy) if entropy is not None else None,
1110
+ # S_ATTR_PURE_INSTRUCTIONS / S_ATTR_SOME_INSTRUCTIONS.
1111
+ is_executable=bool(flags & 0x80000000) or bool(flags & 0x400),
1112
+ # Write permission lives on the segment; vm_prot_t VM_PROT_WRITE == 2.
1113
+ is_writable=bool(init_protection & 0x2),
1114
+ evidence=Evidence(
1115
+ source="macho.section_table",
1116
+ method="section_header",
1117
+ confidence=Confidence.CERTAIN,
1118
+ address=virtual_address,
1119
+ detail=f"Mach-O section {name!r}",
1120
+ ),
1121
+ )
1122
+
1123
+ @classmethod
1124
+ def _macho_imports(cls, binary: Any) -> list[ImportInfo]:
1125
+ imports: list[ImportInfo] = []
1126
+ for symbol in cls._safe(binary, "imported_symbols"):
1127
+ name = cls._decode_name(getattr(symbol, "name", None))
1128
+ if not name:
1129
+ continue
1130
+ library_object = cls._get_attr(symbol, "library")
1131
+ library = cls._decode_name(cls._get_attr(library_object, "name")) if library_object is not None else ""
1132
+ value = cls._as_int(getattr(symbol, "value", 0))
1133
+ imports.append(
1134
+ ImportInfo(
1135
+ name=name,
1136
+ library=library or None,
1137
+ address=value or None,
1138
+ evidence=Evidence(
1139
+ source="macho.undefined_symbols",
1140
+ method="imported_symbol",
1141
+ confidence=Confidence.HIGH,
1142
+ address=value or None,
1143
+ detail="undefined symbol bound by dyld at load time",
1144
+ ),
1145
+ )
1146
+ )
1147
+ return imports
1148
+
1149
+ @classmethod
1150
+ def _macho_exports(cls, binary: Any) -> list[ExportInfo]:
1151
+ exports: list[ExportInfo] = []
1152
+ for symbol in cls._safe(binary, "exported_symbols"):
1153
+ name = cls._decode_name(getattr(symbol, "name", None))
1154
+ if not name:
1155
+ continue
1156
+ value = cls._as_int(getattr(symbol, "value", 0))
1157
+ exports.append(
1158
+ ExportInfo(
1159
+ name=name,
1160
+ address=value or None,
1161
+ evidence=Evidence(
1162
+ source="macho.exported_symbols",
1163
+ method="exported_symbol",
1164
+ confidence=Confidence.HIGH if value else Confidence.MEDIUM,
1165
+ address=value or None,
1166
+ detail="symbol with dyld export information",
1167
+ ),
1168
+ )
1169
+ )
1170
+ return exports
1171
+
1172
+ @classmethod
1173
+ def _macho_symbols(cls, binary: Any, sections: list[SectionInfo]) -> list[SymbolInfo]:
1174
+ # Mach-O nlist entries do not encode function vs data. Membership in an
1175
+ # executable section is the strongest structural evidence available, so
1176
+ # it drives is_function and is stated explicitly in the evidence detail.
1177
+ exec_ranges = [
1178
+ (s.virtual_address, s.virtual_address + s.size)
1179
+ for s in sections
1180
+ if s.is_executable and s.size > 0
1181
+ ]
1182
+ symbols: list[SymbolInfo] = []
1183
+ seen: set[tuple[str, int]] = set()
1184
+ for symbol in cls._safe(binary, "symbols"):
1185
+ name = cls._decode_name(getattr(symbol, "name", None))
1186
+ if not name:
1187
+ continue
1188
+ value = cls._as_int(getattr(symbol, "value", 0))
1189
+ key = (name, value)
1190
+ if key in seen:
1191
+ continue
1192
+ seen.add(key)
1193
+ size = cls._as_int(getattr(symbol, "size", 0))
1194
+ is_function = bool(value) and any(
1195
+ start <= value < end for start, end in exec_ranges
1196
+ )
1197
+ type_text = str(getattr(symbol, "type", "") or "")
1198
+ symbols.append(
1199
+ SymbolInfo(
1200
+ name=name,
1201
+ address=value,
1202
+ size=size,
1203
+ kind=type_text or "unknown",
1204
+ is_function=is_function,
1205
+ evidence=Evidence(
1206
+ source="macho.symbol_table",
1207
+ method="symtab_entry",
1208
+ confidence=Confidence.HIGH,
1209
+ address=value or None,
1210
+ detail=(
1211
+ "function inferred from executable-section membership"
1212
+ if is_function
1213
+ else "symbol kind is not encoded in the Mach-O symbol table"
1214
+ ),
1215
+ ),
1216
+ )
1217
+ )
1218
+ return symbols
1219
+
1220
+ # ---- DEX ---------------------------------------------------------------
1221
+
1222
+ @staticmethod
1223
+ def _load_dex(path: Path, raw: bytes) -> BinaryInfo:
1224
+ """Normalize a DEX bytecode container into :class:`BinaryInfo`.
1225
+
1226
+ DEX has no virtual address space and no sections: method addresses are
1227
+ **file offsets**, stated in a loader warning and in every method's
1228
+ evidence. Defined methods (with bytecode) become function symbols;
1229
+ referenced-but-undefined methods (external APIs like ``println``)
1230
+ become imports with the defining class as their ``library``.
1231
+ """
1232
+ try:
1233
+ dex = lief.DEX.parse(str(path))
1234
+ except Exception as exc:
1235
+ raise BinaryLoadError(f"LIEF could not parse DEX {path}: {exc}") from exc
1236
+ if dex is None:
1237
+ raise BinaryLoadError(f"Unsupported or unreadable DEX: {path}")
1238
+
1239
+ # The endianness lives in the header's endian_tag at offset 0x28
1240
+ # (0x12345678 = little-endian, byte-swapped = big-endian).
1241
+ endian_tag = int.from_bytes(raw[0x28:0x2C], "little")
1242
+ endianness = "big" if endian_tag == 0x78563412 else "little"
1243
+
1244
+ warnings: list[str] = [
1245
+ "DEX container: no virtual address space; symbol addresses are file offsets",
1246
+ "Dalvik bytecode disassembly is not supported yet",
1247
+ ]
1248
+
1249
+ symbols: list[SymbolInfo] = []
1250
+ imports: list[ImportInfo] = []
1251
+ for method in dex.methods:
1252
+ class_name = ""
1253
+ if method.has_class and method.cls is not None:
1254
+ class_name = str(method.cls.fullname)
1255
+ qualified = f"{class_name}->{method.name}" if class_name else str(method.name)
1256
+ code_offset = int(getattr(method, "code_offset", 0) or 0)
1257
+ if code_offset > 0:
1258
+ symbols.append(
1259
+ SymbolInfo(
1260
+ name=qualified,
1261
+ address=code_offset,
1262
+ kind="method",
1263
+ is_function=True,
1264
+ evidence=Evidence(
1265
+ source="dex.method_ids",
1266
+ method="class_data",
1267
+ confidence=Confidence.HIGH,
1268
+ address=code_offset,
1269
+ detail=(
1270
+ f"{qualified}: code at file offset 0x{code_offset:x} "
1271
+ "(DEX addresses are file offsets, not VA)"
1272
+ ),
1273
+ ),
1274
+ )
1275
+ )
1276
+ else:
1277
+ imports.append(
1278
+ ImportInfo(
1279
+ name=qualified,
1280
+ library=class_name or None,
1281
+ evidence=Evidence(
1282
+ source="dex.method_ids",
1283
+ method="method_reference",
1284
+ confidence=Confidence.HIGH,
1285
+ detail=(
1286
+ f"{qualified}: referenced but not defined in this container"
1287
+ ),
1288
+ ),
1289
+ )
1290
+ )
1291
+
1292
+ return BinaryInfo(
1293
+ path=path,
1294
+ format="DEX",
1295
+ architecture="dalvik",
1296
+ bitness=0,
1297
+ endianness=endianness,
1298
+ entry_point=None,
1299
+ image_base=0,
1300
+ sha256="",
1301
+ file_size=0,
1302
+ sections=[],
1303
+ imports=imports,
1304
+ exports=[],
1305
+ symbols=symbols,
1306
+ is_stripped=not symbols,
1307
+ warnings=warnings,
1308
+ )
1309
+
1310
+ # ---- helpers ----------------------------------------------------------
1311
+
1312
+ @staticmethod
1313
+ def _decode_name(value: Any) -> str:
1314
+ """Decode a LIEF name that may arrive as bytes or str; strip padding."""
1315
+ if isinstance(value, (bytes, bytearray)):
1316
+ text = bytes(value).decode("utf-8", "replace")
1317
+ else:
1318
+ text = str(value or "")
1319
+ return text.split("\x00", 1)[0].strip()
1320
+
1321
+ @staticmethod
1322
+ def _get_attr(obj: Any, *names: str, default: Any = None) -> Any:
1323
+ for name in names:
1324
+ if obj is not None and hasattr(obj, name):
1325
+ return getattr(obj, name)
1326
+ return default
1327
+
1328
+ @staticmethod
1329
+ def _as_int(value: Any) -> int:
1330
+ if value is None:
1331
+ return 0
1332
+ try:
1333
+ return int(value)
1334
+ except (TypeError, ValueError):
1335
+ return 0
1336
+
1337
+ @staticmethod
1338
+ def _safe(binary: Any, attribute: str) -> list[Any]:
1339
+ try:
1340
+ value = getattr(binary, attribute)
1341
+ except Exception:
1342
+ return []
1343
+ if value is None:
1344
+ return []
1345
+ try:
1346
+ return list(value)
1347
+ except TypeError:
1348
+ return []
1349
+
1350
+
1351
+