ipa-forge 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.
- ipa_forge/__init__.py +1 -0
- ipa_forge/altstore/__init__.py +1 -0
- ipa_forge/altstore/source.py +44 -0
- ipa_forge/analysis/__init__.py +18 -0
- ipa_forge/analysis/classdump.py +103 -0
- ipa_forge/analysis/diff.py +127 -0
- ipa_forge/analysis/security.py +72 -0
- ipa_forge/analysis/strings.py +40 -0
- ipa_forge/analysis/symbols.py +42 -0
- ipa_forge/analysis/type_encoding.py +164 -0
- ipa_forge/bundle/__init__.py +1 -0
- ipa_forge/bundle/inventory.py +96 -0
- ipa_forge/bundle/ipa.py +81 -0
- ipa_forge/bundle/models.py +58 -0
- ipa_forge/bundle/plist.py +18 -0
- ipa_forge/cli/__init__.py +1 -0
- ipa_forge/cli/analysis.py +225 -0
- ipa_forge/cli/common.py +41 -0
- ipa_forge/cli/hooks.py +357 -0
- ipa_forge/cli/main.py +170 -0
- ipa_forge/gui/__init__.py +1 -0
- ipa_forge/gui/analysis.html +229 -0
- ipa_forge/gui/app.py +304 -0
- ipa_forge/gui/index.html +244 -0
- ipa_forge/hooks/__init__.py +0 -0
- ipa_forge/hooks/scan.py +81 -0
- ipa_forge/hooks/verify.py +348 -0
- ipa_forge/machO/__init__.py +1 -0
- ipa_forge/machO/arch.py +94 -0
- ipa_forge/machO/detect.py +50 -0
- ipa_forge/machO/injector.py +99 -0
- ipa_forge/machO/objc.py +534 -0
- ipa_forge/manifest.py +74 -0
- ipa_forge/patch/__init__.py +1 -0
- ipa_forge/patch/base.py +48 -0
- ipa_forge/patch/binary.py +131 -0
- ipa_forge/patch/dylib.py +59 -0
- ipa_forge/patch/engine.py +48 -0
- ipa_forge/patch/loader.py +88 -0
- ipa_forge/patch/paths.py +19 -0
- ipa_forge/patch/plist.py +61 -0
- ipa_forge/patch/resolver.py +37 -0
- ipa_forge/patch/resource.py +135 -0
- ipa_forge/patch/schema.py +118 -0
- ipa_forge/patch/version.py +25 -0
- ipa_forge/patches.py +76 -0
- ipa_forge/pipeline.py +235 -0
- ipa_forge/signing/__init__.py +1 -0
- ipa_forge/signing/backend.py +111 -0
- ipa_forge/signing/pipeline.py +99 -0
- ipa_forge/signing/profile.py +131 -0
- ipa_forge/signing/provider.py +113 -0
- ipa_forge/signing/reconcile.py +27 -0
- ipa_forge/validators/__init__.py +1 -0
- ipa_forge/validators/archive_validator.py +25 -0
- ipa_forge/validators/bundle_validator.py +27 -0
- ipa_forge/validators/ipa_validator.py +39 -0
- ipa_forge-0.1.0.dist-info/METADATA +914 -0
- ipa_forge-0.1.0.dist-info/RECORD +62 -0
- ipa_forge-0.1.0.dist-info/WHEEL +4 -0
- ipa_forge-0.1.0.dist-info/entry_points.txt +2 -0
- ipa_forge-0.1.0.dist-info/licenses/LICENSE +674 -0
ipa_forge/machO/arch.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Read-only fat/universal Mach-O architecture selection, backed by LIEF."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import lief
|
|
9
|
+
|
|
10
|
+
# Enum members are hashable at runtime (mypy agrees); Pyright's reportUnhashable
|
|
11
|
+
# does not special-case enum members as dict keys (false positive).
|
|
12
|
+
_ARCH_NAMES: dict[lief.MachO.Header.CPU_TYPE, str] = {
|
|
13
|
+
lief.MachO.Header.CPU_TYPE.ARM64: "arm64", # pyright: ignore[reportUnhashable]
|
|
14
|
+
lief.MachO.Header.CPU_TYPE.ARM: "armv7", # pyright: ignore[reportUnhashable]
|
|
15
|
+
lief.MachO.Header.CPU_TYPE.X86_64: "x86_64", # pyright: ignore[reportUnhashable]
|
|
16
|
+
lief.MachO.Header.CPU_TYPE.X86: "i386", # pyright: ignore[reportUnhashable]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NotMachOError(Exception):
|
|
21
|
+
"""Raised when the target file cannot be parsed as Mach-O at all."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AmbiguousArchError(Exception):
|
|
25
|
+
"""Raised when a fat/universal binary is targeted without an explicit `arch:` selector."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ArchNotFoundError(Exception):
|
|
29
|
+
"""Raised when an explicit `arch:` selector does not match any slice in the binary."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def arch_name(binary: lief.MachO.Binary) -> str:
|
|
33
|
+
"""Canonical arch name (e.g. "arm64") for a parsed Mach-O slice."""
|
|
34
|
+
return _ARCH_NAMES.get(binary.header.cpu_type, str(binary.header.cpu_type))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def require_slice(fat: lief.MachO.FatBinary, index: int) -> lief.MachO.Binary:
|
|
38
|
+
"""LIEF's FatBinary.at() is typed as Optional; callers only use it after a
|
|
39
|
+
non-empty length check, so a None here means the file changed under us."""
|
|
40
|
+
binary = fat.at(index)
|
|
41
|
+
if binary is None:
|
|
42
|
+
raise NotMachOError(f"Mach-O slice {index} unexpectedly missing")
|
|
43
|
+
return binary
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_macho(path: Path, arch: str | None = None) -> lief.MachO.Binary:
|
|
47
|
+
"""Return the single Mach-O slice to operate on.
|
|
48
|
+
|
|
49
|
+
Thin binaries are returned directly. Fat/universal binaries require an
|
|
50
|
+
explicit `arch` selector -- silently picking "the first slice" would make
|
|
51
|
+
patches applied to the wrong architecture on a per-device basis.
|
|
52
|
+
"""
|
|
53
|
+
fat = lief.MachO.parse(str(path))
|
|
54
|
+
if fat is None or len(fat) == 0:
|
|
55
|
+
raise NotMachOError(f"{path} is not a valid Mach-O file")
|
|
56
|
+
|
|
57
|
+
if len(fat) == 1:
|
|
58
|
+
return require_slice(fat, 0)
|
|
59
|
+
|
|
60
|
+
available = [arch_name(require_slice(fat, i)) for i in range(len(fat))]
|
|
61
|
+
if arch is None:
|
|
62
|
+
raise AmbiguousArchError(
|
|
63
|
+
f"{path} is a universal binary with architectures {available}; "
|
|
64
|
+
"an explicit `arch:` selector is required in the patch definition"
|
|
65
|
+
)
|
|
66
|
+
for i in range(len(fat)):
|
|
67
|
+
binary = require_slice(fat, i)
|
|
68
|
+
if arch_name(binary) == arch:
|
|
69
|
+
return binary
|
|
70
|
+
raise ArchNotFoundError(f"Architecture '{arch}' not found in {path}; available: {available}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def available_archs(path: Path) -> list[str]:
|
|
74
|
+
fat = lief.MachO.parse(str(path))
|
|
75
|
+
if fat is None or len(fat) == 0:
|
|
76
|
+
raise NotMachOError(f"{path} is not a valid Mach-O file")
|
|
77
|
+
return [arch_name(require_slice(fat, i)) for i in range(len(fat))]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def slice_byte_range(path: Path, arch: str | None = None) -> tuple[int, int]:
|
|
81
|
+
"""Return the (start, end) byte offsets of the selected arch's slice within
|
|
82
|
+
the on-disk file. For thin (non-fat) binaries this is the whole file --
|
|
83
|
+
used to bound binary-pattern search/replace to the correct architecture
|
|
84
|
+
so a patch never silently lands in the wrong slice of a universal binary.
|
|
85
|
+
"""
|
|
86
|
+
fat = lief.MachO.parse(str(path))
|
|
87
|
+
if fat is None or len(fat) == 0:
|
|
88
|
+
raise NotMachOError(f"{path} is not a valid Mach-O file")
|
|
89
|
+
|
|
90
|
+
if len(fat) == 1:
|
|
91
|
+
return 0, path.stat().st_size
|
|
92
|
+
|
|
93
|
+
binary = load_macho(path, arch)
|
|
94
|
+
return binary.fat_offset, binary.fat_offset + binary.original_size
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Lightweight Mach-O detection by magic number, used by the bundle inventory walker."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import struct
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ipa_forge.bundle.models import AppBundle, MachOKind
|
|
10
|
+
|
|
11
|
+
_MACHO_MAGICS = {
|
|
12
|
+
0xFEEDFACE, # MH_MAGIC (32-bit)
|
|
13
|
+
0xFEEDFACF, # MH_MAGIC_64
|
|
14
|
+
0xCEFAEDFE, # MH_CIGAM (32-bit, byte-swapped)
|
|
15
|
+
0xCFFAEDFE, # MH_CIGAM_64
|
|
16
|
+
0xCAFEBABE, # FAT_MAGIC
|
|
17
|
+
0xBEBAFECA, # FAT_CIGAM
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_macho_file(path: Path) -> bool:
|
|
22
|
+
if not path.is_file() or path.is_symlink():
|
|
23
|
+
return False
|
|
24
|
+
try:
|
|
25
|
+
with open(path, "rb") as f:
|
|
26
|
+
header = f.read(4)
|
|
27
|
+
except OSError:
|
|
28
|
+
return False
|
|
29
|
+
if len(header) < 4:
|
|
30
|
+
return False
|
|
31
|
+
(magic,) = struct.unpack(">I", header)
|
|
32
|
+
return magic in _MACHO_MAGICS
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def bundle_executable_paths(bundle: AppBundle, kinds: set[MachOKind] | None = None) -> list[Path]:
|
|
36
|
+
"""Every on-disk executable in the bundle, main binary first: the main
|
|
37
|
+
executable plus each `MachOTarget` whose `kind` is in `kinds` (default:
|
|
38
|
+
every kind -- main, framework, dylib, appex, watch_app, other). Callers
|
|
39
|
+
doing a narrower analysis (e.g. hook verification, which only cares
|
|
40
|
+
about app code) pass an explicit, smaller `kinds` set."""
|
|
41
|
+
paths = [bundle.root / bundle.main_executable_name]
|
|
42
|
+
for target in bundle.executables:
|
|
43
|
+
# the main executable is always target.kind == "main" and already
|
|
44
|
+
# covered by the first line above -- skip it here or it'd be listed
|
|
45
|
+
# (and analyzed) twice.
|
|
46
|
+
if target.kind == "main":
|
|
47
|
+
continue
|
|
48
|
+
if kinds is None or target.kind in kinds:
|
|
49
|
+
paths.append(bundle.extraction_root / Path(target.bundle_relative))
|
|
50
|
+
return [p for p in paths if p.is_file()]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""LIEF-backed dylib load-command injection.
|
|
3
|
+
|
|
4
|
+
Modifies the target Mach-O's load commands only -- copying the dylib file
|
|
5
|
+
into the bundle (if it isn't already present) is a separate `resource_add`
|
|
6
|
+
patch operation, and re-signing (required because LIEF's rewrite invalidates
|
|
7
|
+
any existing code signature) happens later in the pipeline's signing stage.
|
|
8
|
+
Never silently continues past a load-command failure: every outcome maps to
|
|
9
|
+
one of the four explicit statuses below.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import enum
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import lief
|
|
19
|
+
|
|
20
|
+
from ipa_forge.machO.arch import (
|
|
21
|
+
AmbiguousArchError,
|
|
22
|
+
ArchNotFoundError,
|
|
23
|
+
NotMachOError,
|
|
24
|
+
arch_name,
|
|
25
|
+
require_slice,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class InjectionStatus(enum.Enum):
|
|
30
|
+
INJECTED = "INJECTED"
|
|
31
|
+
INJECTION_SKIPPED = "INJECTION_SKIPPED"
|
|
32
|
+
INJECTION_UNSUPPORTED = "INJECTION_UNSUPPORTED"
|
|
33
|
+
INJECTION_FAILED = "INJECTION_FAILED"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class InjectionResult:
|
|
38
|
+
status: InjectionStatus
|
|
39
|
+
message: str = ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_FACTORY_BY_LOAD_COMMAND = {
|
|
43
|
+
"LC_LOAD_DYLIB": lief.MachO.DylibCommand.load_dylib,
|
|
44
|
+
"LC_LOAD_WEAK_DYLIB": lief.MachO.DylibCommand.weak_lib,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _select_slice(fat: lief.MachO.FatBinary, arch: str | None) -> lief.MachO.Binary:
|
|
49
|
+
"""Like machO.arch.load_macho, but keeps the FatBinary container so the
|
|
50
|
+
caller can write it back whole -- writing an individually-selected slice
|
|
51
|
+
directly (`binary.write(path)`) discards every *other* slice in a
|
|
52
|
+
universal binary, silently corrupting it."""
|
|
53
|
+
if len(fat) == 1:
|
|
54
|
+
return require_slice(fat, 0)
|
|
55
|
+
|
|
56
|
+
available = [arch_name(require_slice(fat, i)) for i in range(len(fat))]
|
|
57
|
+
if arch is None:
|
|
58
|
+
raise AmbiguousArchError(
|
|
59
|
+
f"universal binary with architectures {available}; an explicit `arch:` selector is required"
|
|
60
|
+
)
|
|
61
|
+
for i in range(len(fat)):
|
|
62
|
+
binary = require_slice(fat, i)
|
|
63
|
+
if arch_name(binary) == arch:
|
|
64
|
+
return binary
|
|
65
|
+
raise ArchNotFoundError(f"architecture '{arch}' not found; available: {available}")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def inject_dylib(
|
|
69
|
+
target_path: Path,
|
|
70
|
+
dylib_install_name: str,
|
|
71
|
+
arch: str | None = None,
|
|
72
|
+
load_command: str = "LC_LOAD_DYLIB",
|
|
73
|
+
) -> InjectionResult:
|
|
74
|
+
if load_command not in _FACTORY_BY_LOAD_COMMAND:
|
|
75
|
+
return InjectionResult(InjectionStatus.INJECTION_FAILED, f"unknown load_command '{load_command}'")
|
|
76
|
+
|
|
77
|
+
fat = lief.MachO.parse(str(target_path))
|
|
78
|
+
if fat is None or len(fat) == 0:
|
|
79
|
+
return InjectionResult(InjectionStatus.INJECTION_UNSUPPORTED, f"{target_path} is not a valid Mach-O file")
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
binary = _select_slice(fat, arch)
|
|
83
|
+
except NotMachOError as e:
|
|
84
|
+
return InjectionResult(InjectionStatus.INJECTION_UNSUPPORTED, str(e))
|
|
85
|
+
except (AmbiguousArchError, ArchNotFoundError) as e:
|
|
86
|
+
return InjectionResult(InjectionStatus.INJECTION_FAILED, str(e))
|
|
87
|
+
|
|
88
|
+
if dylib_install_name in {lib.name for lib in binary.libraries}:
|
|
89
|
+
return InjectionResult(InjectionStatus.INJECTION_SKIPPED, f"{dylib_install_name} is already loaded")
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
command = _FACTORY_BY_LOAD_COMMAND[load_command](dylib_install_name)
|
|
93
|
+
if binary.add(command) is None:
|
|
94
|
+
return InjectionResult(InjectionStatus.INJECTION_FAILED, "LIEF failed to add the load command")
|
|
95
|
+
fat.write(str(target_path))
|
|
96
|
+
except Exception as e: # noqa: BLE001 -- LIEF raises broad/internal exception types on malformed input
|
|
97
|
+
return InjectionResult(InjectionStatus.INJECTION_FAILED, f"LIEF injection failed: {e}")
|
|
98
|
+
|
|
99
|
+
return InjectionResult(InjectionStatus.INJECTED, f"added {load_command} {dylib_install_name}")
|