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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""AltStore Classic source.json app-entry export.
|
|
3
|
+
|
|
4
|
+
A distribution metadata layer, deliberately kept separate from the signing
|
|
5
|
+
engine (see the architecture doc's AltStore-source-is-optional note) -- it
|
|
6
|
+
only describes an already-patched-and-signed .ipa, it never produces one.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ipa_forge.bundle.ipa import extract_ipa, load_bundle
|
|
17
|
+
from ipa_forge.manifest import sha256_of
|
|
18
|
+
from ipa_forge.validators.ipa_validator import validate_ipa_structure
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_app_entry(ipa_path: Path, download_url: str) -> dict[str, Any]:
|
|
22
|
+
with tempfile.TemporaryDirectory(prefix="ipa_forge_export_") as tmp:
|
|
23
|
+
validate_ipa_structure(ipa_path)
|
|
24
|
+
app_path = extract_ipa(ipa_path, Path(tmp))
|
|
25
|
+
bundle = load_bundle(app_path)
|
|
26
|
+
name = bundle.info_plist.get("CFBundleName", bundle.main_executable_name)
|
|
27
|
+
bundle_id = bundle.bundle_id
|
|
28
|
+
version = bundle.version
|
|
29
|
+
build = bundle.build
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
"name": name,
|
|
33
|
+
"bundleIdentifier": bundle_id,
|
|
34
|
+
"version": version,
|
|
35
|
+
"buildVersion": build,
|
|
36
|
+
"downloadURL": download_url,
|
|
37
|
+
"size": ipa_path.stat().st_size,
|
|
38
|
+
"sha256": sha256_of(ipa_path),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def write_source_json(entry: dict[str, Any], path: Path, source_name: str = "ipa-forge patched apps") -> None:
|
|
43
|
+
source = {"name": source_name, "apps": [entry]}
|
|
44
|
+
path.write_text(json.dumps(source, indent=2))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""General-purpose static reverse engineering of an IPA -- class-dump,
|
|
3
|
+
strings, symbols, security posture, and version diffing -- built on the same
|
|
4
|
+
Mach-O/ObjC analysis engine `ipa_forge.hooks` uses for hook verification
|
|
5
|
+
(`ipa_forge.machO.objc`). See `forge analysis --help`.
|
|
6
|
+
|
|
7
|
+
Deliberately out of scope, by design, not oversight (see ROADMAP.md):
|
|
8
|
+
|
|
9
|
+
- **FairPlay/App Store DRM decryption.** Every command here assumes an
|
|
10
|
+
already-decrypted `.ipa`, exactly like the rest of ipa-forge; decryption
|
|
11
|
+
is DRM-circumvention tooling, a different risk category from static
|
|
12
|
+
analysis of a binary you already have rights to inspect.
|
|
13
|
+
- **Instruction-level disassembly/decompilation** (capstone/Ghidra-grade).
|
|
14
|
+
A large additional dependency and maintenance surface; tracked as a
|
|
15
|
+
future phase in ROADMAP.md rather than bundled by default.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Render a `MachOAnalysis` as `.h`-style class-dump text: `@interface`,
|
|
3
|
+
`@protocol`, and `@interface (Category)` blocks with method signatures,
|
|
4
|
+
ivars, and properties -- the general-purpose reverse-engineering view built
|
|
5
|
+
on the same class table `ipa_forge.hooks` uses for hook verification.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ipa_forge.analysis.type_encoding import decode_method_signature, decode_type, read_one_type
|
|
11
|
+
from ipa_forge.machO.objc import MachOAnalysis, MachOCategory, MachOClass, MachOProtocol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _method_lines(methods: dict[str, str], prefix: str) -> list[str]:
|
|
15
|
+
return [f"{prefix} {decode_method_signature(sel, enc)};" for sel, enc in sorted(methods.items())]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _decl(type_str: str, name: str) -> str:
|
|
19
|
+
"""`Type name;` for a value type, `Type *name;` for a pointer type --
|
|
20
|
+
decode_type() already renders the trailing `*`, so avoid a stray space
|
|
21
|
+
between it and the identifier."""
|
|
22
|
+
sep = "" if type_str.endswith("*") else " "
|
|
23
|
+
return f"{type_str}{sep}{name}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def render_class(cls: MachOClass) -> str:
|
|
27
|
+
conforms = f" <{', '.join(sorted(cls.protocols))}>" if cls.protocols else ""
|
|
28
|
+
header = f"@interface {cls.name} : {cls.super_name or 'NSObject'}{conforms}"
|
|
29
|
+
lines = [header]
|
|
30
|
+
|
|
31
|
+
if cls.ivars:
|
|
32
|
+
lines.append("{")
|
|
33
|
+
for iv in cls.ivars:
|
|
34
|
+
lines.append(f" {_decl(decode_type(iv.type_encoding), iv.name)};")
|
|
35
|
+
lines.append("}")
|
|
36
|
+
|
|
37
|
+
for prop in cls.properties:
|
|
38
|
+
# attribute string: "T<type-encoding>,<flag>,<flag>,..." (e.g.
|
|
39
|
+
# 'T@"NSString",C,N,V_label') -- a naive split(",") would break on a
|
|
40
|
+
# comma inside a generic-collection class name, so tokenize the type
|
|
41
|
+
# encoding itself rather than string-splitting the whole attribute.
|
|
42
|
+
type_enc = prop.attributes[1:] if prop.attributes.startswith("T") else prop.attributes
|
|
43
|
+
tok, _ = read_one_type(type_enc, 0) if type_enc else ("", 0)
|
|
44
|
+
decoded = decode_type(tok) if tok else "id"
|
|
45
|
+
lines.append(f"@property {_decl(decoded, prop.name)}; // {prop.attributes}")
|
|
46
|
+
|
|
47
|
+
if cls.properties and (cls.inst or cls.cls):
|
|
48
|
+
lines.append("")
|
|
49
|
+
|
|
50
|
+
lines.extend(_method_lines(cls.cls, "+"))
|
|
51
|
+
lines.extend(_method_lines(cls.inst, "-"))
|
|
52
|
+
|
|
53
|
+
lines.append("")
|
|
54
|
+
lines.append("@end")
|
|
55
|
+
return "\n".join(lines)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def render_protocol(proto: MachOProtocol) -> str:
|
|
59
|
+
conforms = f" <{', '.join(sorted(proto.protocols))}>" if proto.protocols else ""
|
|
60
|
+
lines = [f"@protocol {proto.name}{conforms}"]
|
|
61
|
+
lines.extend(_method_lines(proto.cls, "+"))
|
|
62
|
+
lines.extend(_method_lines(proto.inst, "-"))
|
|
63
|
+
if proto.opt_cls or proto.opt_inst:
|
|
64
|
+
lines.append("@optional")
|
|
65
|
+
lines.extend(_method_lines(proto.opt_cls, "+"))
|
|
66
|
+
lines.extend(_method_lines(proto.opt_inst, "-"))
|
|
67
|
+
lines.append("@end")
|
|
68
|
+
return "\n".join(lines)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def render_category(cat: MachOCategory) -> str:
|
|
72
|
+
lines = [f"@interface {cat.class_name} ({cat.name})"]
|
|
73
|
+
lines.extend(_method_lines(cat.cls, "+"))
|
|
74
|
+
lines.extend(_method_lines(cat.inst, "-"))
|
|
75
|
+
lines.append("@end")
|
|
76
|
+
return "\n".join(lines)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def render_analysis(
|
|
80
|
+
analysis: MachOAnalysis,
|
|
81
|
+
*,
|
|
82
|
+
class_filter: str | None = None,
|
|
83
|
+
search: str | None = None,
|
|
84
|
+
) -> str:
|
|
85
|
+
"""Render every class/protocol/category in `analysis` as class-dump text,
|
|
86
|
+
optionally restricted to one class name or a substring/regex search over
|
|
87
|
+
class names (matches `forge hooks extract`'s --class/--search)."""
|
|
88
|
+
import re as _re
|
|
89
|
+
|
|
90
|
+
classes = sorted(analysis.classes.values(), key=lambda c: c.name)
|
|
91
|
+
if class_filter:
|
|
92
|
+
classes = [c for c in classes if c.name == class_filter]
|
|
93
|
+
elif search:
|
|
94
|
+
pat = _re.compile(search)
|
|
95
|
+
classes = [c for c in classes if pat.search(c.name)]
|
|
96
|
+
|
|
97
|
+
blocks: list[str] = []
|
|
98
|
+
if not class_filter and not search:
|
|
99
|
+
blocks.extend(render_protocol(p) for p in sorted(analysis.protocols.values(), key=lambda p: p.name))
|
|
100
|
+
blocks.extend(render_class(c) for c in classes)
|
|
101
|
+
if not class_filter and not search:
|
|
102
|
+
blocks.extend(render_category(cat) for cat in sorted(analysis.categories, key=lambda c: (c.class_name, c.name)))
|
|
103
|
+
return "\n\n".join(blocks)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Version-to-version diffing for porting: what changed between two builds
|
|
3
|
+
of the same app -- classes/protocols added or removed, per-class method
|
|
4
|
+
churn, and Info.plist key changes.
|
|
5
|
+
|
|
6
|
+
Distinct from `forge hooks diff`, which only re-checks a patch definition's
|
|
7
|
+
*declared* hook targets and is a pass/fail gate (did a required hook
|
|
8
|
+
regress?). This is the broader, informational survey: what changed at all,
|
|
9
|
+
independent of any particular patch set. Entitlements are deliberately not
|
|
10
|
+
diffed here -- reading them requires shelling out to `codesign`/`security`,
|
|
11
|
+
and `signing/backend.py` is the only module allowed to do that (see
|
|
12
|
+
architecture.md's hard constraint); a real entitlements diff belongs there,
|
|
13
|
+
not in this read-only static-analysis package.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ipa_forge.machO.objc import MachOAnalysis
|
|
22
|
+
|
|
23
|
+
_MISSING = object()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class AnalysisDiff:
|
|
28
|
+
added_classes: set[str] = field(default_factory=set)
|
|
29
|
+
removed_classes: set[str] = field(default_factory=set)
|
|
30
|
+
added_protocols: set[str] = field(default_factory=set)
|
|
31
|
+
removed_protocols: set[str] = field(default_factory=set)
|
|
32
|
+
# class name -> (added selectors, removed selectors); instance + class
|
|
33
|
+
# methods combined, only for classes present in both builds.
|
|
34
|
+
changed_class_methods: dict[str, tuple[set[str], set[str]]] = field(default_factory=dict)
|
|
35
|
+
changed_plist_keys: dict[str, tuple[Any, Any]] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def has_changes(self) -> bool:
|
|
39
|
+
return bool(
|
|
40
|
+
self.added_classes
|
|
41
|
+
or self.removed_classes
|
|
42
|
+
or self.added_protocols
|
|
43
|
+
or self.removed_protocols
|
|
44
|
+
or self.changed_class_methods
|
|
45
|
+
or self.changed_plist_keys
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def diff_classes(
|
|
50
|
+
old: MachOAnalysis, new: MachOAnalysis
|
|
51
|
+
) -> tuple[set[str], set[str], dict[str, tuple[set[str], set[str]]]]:
|
|
52
|
+
old_names = set(old.classes)
|
|
53
|
+
new_names = set(new.classes)
|
|
54
|
+
added = new_names - old_names
|
|
55
|
+
removed = old_names - new_names
|
|
56
|
+
|
|
57
|
+
changed: dict[str, tuple[set[str], set[str]]] = {}
|
|
58
|
+
for name in old_names & new_names:
|
|
59
|
+
old_methods = set(old.classes[name].inst) | set(old.classes[name].cls)
|
|
60
|
+
new_methods = set(new.classes[name].inst) | set(new.classes[name].cls)
|
|
61
|
+
added_m = new_methods - old_methods
|
|
62
|
+
removed_m = old_methods - new_methods
|
|
63
|
+
if added_m or removed_m:
|
|
64
|
+
changed[name] = (added_m, removed_m)
|
|
65
|
+
return added, removed, changed
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def diff_plists(old_plist: dict[str, Any], new_plist: dict[str, Any]) -> dict[str, tuple[Any, Any]]:
|
|
69
|
+
"""Key -> (old value, new value); a key present on only one side reports
|
|
70
|
+
`None` for the side it's missing from."""
|
|
71
|
+
changed: dict[str, tuple[Any, Any]] = {}
|
|
72
|
+
for key in set(old_plist) | set(new_plist):
|
|
73
|
+
old_val = old_plist.get(key, _MISSING)
|
|
74
|
+
new_val = new_plist.get(key, _MISSING)
|
|
75
|
+
if old_val != new_val:
|
|
76
|
+
changed[key] = (
|
|
77
|
+
None if old_val is _MISSING else old_val,
|
|
78
|
+
None if new_val is _MISSING else new_val,
|
|
79
|
+
)
|
|
80
|
+
return changed
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def diff_analyses(
|
|
84
|
+
old: MachOAnalysis,
|
|
85
|
+
new: MachOAnalysis,
|
|
86
|
+
old_plist: dict[str, Any],
|
|
87
|
+
new_plist: dict[str, Any],
|
|
88
|
+
) -> AnalysisDiff:
|
|
89
|
+
added_classes, removed_classes, changed_methods = diff_classes(old, new)
|
|
90
|
+
return AnalysisDiff(
|
|
91
|
+
added_classes=added_classes,
|
|
92
|
+
removed_classes=removed_classes,
|
|
93
|
+
added_protocols=set(new.protocols) - set(old.protocols),
|
|
94
|
+
removed_protocols=set(old.protocols) - set(new.protocols),
|
|
95
|
+
changed_class_methods=changed_methods,
|
|
96
|
+
changed_plist_keys=diff_plists(old_plist, new_plist),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def render_diff(diff: AnalysisDiff) -> str:
|
|
101
|
+
if not diff.has_changes:
|
|
102
|
+
return "no differences found"
|
|
103
|
+
|
|
104
|
+
lines: list[str] = []
|
|
105
|
+
if diff.added_classes:
|
|
106
|
+
lines.append(f"+ {len(diff.added_classes)} new class(es):")
|
|
107
|
+
lines.extend(f" {c}" for c in sorted(diff.added_classes))
|
|
108
|
+
if diff.removed_classes:
|
|
109
|
+
lines.append(f"- {len(diff.removed_classes)} removed class(es):")
|
|
110
|
+
lines.extend(f" {c}" for c in sorted(diff.removed_classes))
|
|
111
|
+
if diff.added_protocols:
|
|
112
|
+
lines.append(f"+ {len(diff.added_protocols)} new protocol(s): {', '.join(sorted(diff.added_protocols))}")
|
|
113
|
+
if diff.removed_protocols:
|
|
114
|
+
removed_names = ", ".join(sorted(diff.removed_protocols))
|
|
115
|
+
lines.append(f"- {len(diff.removed_protocols)} removed protocol(s): {removed_names}")
|
|
116
|
+
if diff.changed_class_methods:
|
|
117
|
+
lines.append(f"~ {len(diff.changed_class_methods)} class(es) with method changes:")
|
|
118
|
+
for cls, (added_m, removed_m) in sorted(diff.changed_class_methods.items()):
|
|
119
|
+
if added_m:
|
|
120
|
+
lines.append(f" {cls}: + {', '.join(sorted(added_m))}")
|
|
121
|
+
if removed_m:
|
|
122
|
+
lines.append(f" {cls}: - {', '.join(sorted(removed_m))}")
|
|
123
|
+
if diff.changed_plist_keys:
|
|
124
|
+
lines.append(f"~ {len(diff.changed_plist_keys)} Info.plist key(s) changed:")
|
|
125
|
+
for key, (old_v, new_v) in sorted(diff.changed_plist_keys.items()):
|
|
126
|
+
lines.append(f" {key}: {old_v!r} -> {new_v!r}")
|
|
127
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Read-only Mach-O security/build posture: PIE, encryption-flag detection
|
|
3
|
+
(never decryption -- see the module docstring boundary in
|
|
4
|
+
`ipa_forge.analysis`), stack protector, an ARC heuristic, min-OS, and
|
|
5
|
+
platform. The same kind of static check `otool -l`/jtool2's `--sig` print,
|
|
6
|
+
as structured data instead of text to grep."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import lief
|
|
14
|
+
|
|
15
|
+
from ipa_forge.machO.arch import arch_name, load_macho
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class SecurityPosture:
|
|
20
|
+
binary: str
|
|
21
|
+
arch: str
|
|
22
|
+
pie: bool
|
|
23
|
+
# True when LC_ENCRYPTION_INFO(_64)'s cryptid != 0 -- an App Store binary
|
|
24
|
+
# pulled without prior decryption. Detection only; this tool never
|
|
25
|
+
# decrypts it (see ROADMAP.md for why that is out of scope).
|
|
26
|
+
encrypted: bool
|
|
27
|
+
stack_protector: bool # ___stack_chk_fail imported
|
|
28
|
+
arc_heuristic: bool # _objc_storeStrong/_objc_release imported (best-effort, not definitive)
|
|
29
|
+
min_os: str | None
|
|
30
|
+
platform: str | None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def analyze_security(path: Path, arch: str | None = None) -> SecurityPosture:
|
|
34
|
+
binary = load_macho(path, arch)
|
|
35
|
+
imported = {str(s.name) for s in binary.symbols if s.category == lief.MachO.Symbol.CATEGORY.UNDEFINED}
|
|
36
|
+
|
|
37
|
+
enc = binary.encryption_info
|
|
38
|
+
encrypted = enc is not None and enc.crypt_id != 0
|
|
39
|
+
|
|
40
|
+
min_os: str | None = None
|
|
41
|
+
platform: str | None = None
|
|
42
|
+
bv = binary.build_version
|
|
43
|
+
vm = binary.version_min
|
|
44
|
+
if bv is not None:
|
|
45
|
+
min_os = ".".join(str(n) for n in bv.minos)
|
|
46
|
+
platform = str(bv.platform).rsplit(".", 1)[-1]
|
|
47
|
+
elif vm is not None:
|
|
48
|
+
min_os = ".".join(str(n) for n in vm.version)
|
|
49
|
+
|
|
50
|
+
return SecurityPosture(
|
|
51
|
+
binary=path.name,
|
|
52
|
+
arch=arch_name(binary),
|
|
53
|
+
pie=lief.MachO.Header.FLAGS.PIE in binary.header.flags_list,
|
|
54
|
+
encrypted=encrypted,
|
|
55
|
+
stack_protector="___stack_chk_fail" in imported,
|
|
56
|
+
arc_heuristic=bool({"_objc_storeStrong", "_objc_release"} & imported),
|
|
57
|
+
min_os=min_os,
|
|
58
|
+
platform=platform,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def render_security_posture(posture: SecurityPosture) -> str:
|
|
63
|
+
"""A short human-readable summary for the CLI."""
|
|
64
|
+
os_bit = f", min {posture.platform} {posture.min_os}" if posture.min_os else ""
|
|
65
|
+
lines = [
|
|
66
|
+
f"{posture.binary} ({posture.arch}){os_bit}",
|
|
67
|
+
f" PIE: {'yes' if posture.pie else 'NO'}",
|
|
68
|
+
f" Encrypted: {'yes (App Store, undecrypted)' if posture.encrypted else 'no'}",
|
|
69
|
+
f" Stack protector: {'yes' if posture.stack_protector else 'no'}",
|
|
70
|
+
f" ARC (heuristic): {'likely' if posture.arc_heuristic else 'unclear / MRC'}",
|
|
71
|
+
]
|
|
72
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Printable-string extraction from Mach-O binaries -- `strings <binary>`,
|
|
3
|
+
but IPA-aware: runs over every executable in the bundle (main + frameworks +
|
|
4
|
+
dylibs + extensions) in one pass, tagging each string with the binary it
|
|
5
|
+
came from."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from ipa_forge.bundle.models import AppBundle
|
|
13
|
+
from ipa_forge.machO.detect import bundle_executable_paths
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ExtractedString:
|
|
18
|
+
value: str
|
|
19
|
+
binary: str # file name of the executable it was found in
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def extract_strings(data: bytes, min_len: int = 4) -> list[str]:
|
|
23
|
+
"""Printable-ASCII runs at least `min_len` bytes long, terminated by a
|
|
24
|
+
non-printable/NUL byte -- the same definition classic `strings` uses.
|
|
25
|
+
Wide (UTF-16) string literals are not decoded; documented limitation."""
|
|
26
|
+
pattern = re.compile(rb"[\x20-\x7e]{%d,}" % max(min_len, 1))
|
|
27
|
+
return [m.group().decode("ascii") for m in pattern.finditer(data)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def strings_in_bundle(bundle: AppBundle, min_len: int = 4) -> list[ExtractedString]:
|
|
31
|
+
"""Extract strings from every executable in the bundle, main binary
|
|
32
|
+
first, in inventory order thereafter."""
|
|
33
|
+
out: list[ExtractedString] = []
|
|
34
|
+
for path in bundle_executable_paths(bundle):
|
|
35
|
+
try:
|
|
36
|
+
data = path.read_bytes()
|
|
37
|
+
except OSError:
|
|
38
|
+
continue
|
|
39
|
+
out.extend(ExtractedString(s, path.name) for s in extract_strings(data, min_len))
|
|
40
|
+
return out
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Linked libraries + imported/exported symbols for a Mach-O executable --
|
|
3
|
+
`otool -L` + `nm`, backed by LIEF's structured symbol table instead of
|
|
4
|
+
shelling out and parsing text."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import lief
|
|
12
|
+
|
|
13
|
+
from ipa_forge.machO.arch import arch_name, load_macho
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class BinarySymbols:
|
|
18
|
+
binary: str
|
|
19
|
+
arch: str
|
|
20
|
+
linked_libraries: list[str] = field(default_factory=list)
|
|
21
|
+
imported_symbols: list[str] = field(default_factory=list)
|
|
22
|
+
exported_symbols: list[str] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def analyze_symbols(path: Path, arch: str | None = None) -> BinarySymbols:
|
|
26
|
+
"""Fat binaries require an explicit `arch` -- same rule `forge patch`'s
|
|
27
|
+
binary operations follow (see `machO/arch.py::load_macho`); a thin
|
|
28
|
+
binary needs none."""
|
|
29
|
+
binary = load_macho(path, arch)
|
|
30
|
+
# CATEGORY.UNDEFINED: referenced here, defined in another image (an
|
|
31
|
+
# import). CATEGORY.EXTERNAL: defined in this image and globally
|
|
32
|
+
# visible (an export). CATEGORY.LOCAL: internal-only, not exported.
|
|
33
|
+
imported = sorted({str(s.name) for s in binary.symbols if s.category == lief.MachO.Symbol.CATEGORY.UNDEFINED})
|
|
34
|
+
exported = sorted({str(s.name) for s in binary.symbols if s.category == lief.MachO.Symbol.CATEGORY.EXTERNAL})
|
|
35
|
+
libraries = sorted({str(lib.name) for lib in binary.libraries})
|
|
36
|
+
return BinarySymbols(
|
|
37
|
+
binary=path.name,
|
|
38
|
+
arch=arch_name(binary),
|
|
39
|
+
linked_libraries=libraries,
|
|
40
|
+
imported_symbols=imported,
|
|
41
|
+
exported_symbols=exported,
|
|
42
|
+
)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Best-effort Objective-C type-encoding decoder.
|
|
3
|
+
|
|
4
|
+
Turns runtime type-encoding strings (as found on ivars, properties, and
|
|
5
|
+
method ``types`` fields -- see ``@encode`` in the Objective-C runtime) into
|
|
6
|
+
readable pseudo-header text for `class-dump`-style output. This is
|
|
7
|
+
deliberately not a full encoder/decoder round-trip: structs/unions render as
|
|
8
|
+
just their tag name (no field expansion) and bitfields fall back to the raw
|
|
9
|
+
encoding. Good enough to read a class-dump header; not good enough to
|
|
10
|
+
regenerate a compilable one.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
_MODIFIERS = "rnNoORV" # const, in, inout, out, bycopy, byref, oneway
|
|
18
|
+
|
|
19
|
+
_SIMPLE = {
|
|
20
|
+
"c": "char",
|
|
21
|
+
"i": "int",
|
|
22
|
+
"s": "short",
|
|
23
|
+
"l": "long",
|
|
24
|
+
"q": "long long",
|
|
25
|
+
"C": "unsigned char",
|
|
26
|
+
"I": "unsigned int",
|
|
27
|
+
"S": "unsigned short",
|
|
28
|
+
"L": "unsigned long",
|
|
29
|
+
"Q": "unsigned long long",
|
|
30
|
+
"f": "float",
|
|
31
|
+
"d": "double",
|
|
32
|
+
"B": "BOOL",
|
|
33
|
+
"v": "void",
|
|
34
|
+
"*": "char *",
|
|
35
|
+
"#": "Class",
|
|
36
|
+
":": "SEL",
|
|
37
|
+
"?": "void /* unknown type (block/fn ptr) */",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_ARRAY_RE = re.compile(r"^\[(\d+)(.*)\]$")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_one_type(encoding: str, i: int) -> tuple[str, int]:
|
|
44
|
+
"""Read a single type-encoding token starting at `i`. Returns the token
|
|
45
|
+
text and the index just past it -- callers use this to walk a method's
|
|
46
|
+
full ``types`` string one argument at a time."""
|
|
47
|
+
start = i
|
|
48
|
+
while i < len(encoding) and encoding[i] in _MODIFIERS:
|
|
49
|
+
i += 1
|
|
50
|
+
if i >= len(encoding):
|
|
51
|
+
return encoding[start:i], i
|
|
52
|
+
c = encoding[i]
|
|
53
|
+
if c == "@":
|
|
54
|
+
i += 1
|
|
55
|
+
if i < len(encoding) and encoding[i] == '"':
|
|
56
|
+
end = encoding.find('"', i + 1)
|
|
57
|
+
i = (end + 1) if end != -1 else len(encoding)
|
|
58
|
+
return encoding[start:i], i
|
|
59
|
+
if c in "{(":
|
|
60
|
+
close = "}" if c == "{" else ")"
|
|
61
|
+
open_c = c
|
|
62
|
+
depth = 1
|
|
63
|
+
i += 1
|
|
64
|
+
while i < len(encoding) and depth:
|
|
65
|
+
if encoding[i] == open_c:
|
|
66
|
+
depth += 1
|
|
67
|
+
elif encoding[i] == close:
|
|
68
|
+
depth -= 1
|
|
69
|
+
i += 1
|
|
70
|
+
return encoding[start:i], i
|
|
71
|
+
if c == "[":
|
|
72
|
+
depth = 1
|
|
73
|
+
i += 1
|
|
74
|
+
while i < len(encoding) and depth:
|
|
75
|
+
if encoding[i] == "[":
|
|
76
|
+
depth += 1
|
|
77
|
+
elif encoding[i] == "]":
|
|
78
|
+
depth -= 1
|
|
79
|
+
i += 1
|
|
80
|
+
return encoding[start:i], i
|
|
81
|
+
if c == "^":
|
|
82
|
+
i += 1
|
|
83
|
+
_, i = read_one_type(encoding, i)
|
|
84
|
+
return encoding[start:i], i
|
|
85
|
+
return encoding[start : i + 1], i + 1
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def decode_type(encoding: str) -> str:
|
|
89
|
+
"""Render one type-encoding token as a readable C/Objective-C type."""
|
|
90
|
+
encoding = encoding.lstrip(_MODIFIERS)
|
|
91
|
+
if not encoding:
|
|
92
|
+
return "?"
|
|
93
|
+
c = encoding[0]
|
|
94
|
+
if c in _SIMPLE:
|
|
95
|
+
return _SIMPLE[c]
|
|
96
|
+
if c == "@":
|
|
97
|
+
if encoding.startswith('@"'):
|
|
98
|
+
end = encoding.find('"', 2)
|
|
99
|
+
cls = encoding[2:end] if end != -1 else ""
|
|
100
|
+
if cls.startswith("<") and cls.endswith(">"):
|
|
101
|
+
return f"id<{cls[1:-1]}>"
|
|
102
|
+
return f"{cls} *" if cls else "id"
|
|
103
|
+
return "id"
|
|
104
|
+
if c == "^":
|
|
105
|
+
inner = decode_type(encoding[1:])
|
|
106
|
+
return inner if inner.endswith("*") else f"{inner} *"
|
|
107
|
+
if c == "{":
|
|
108
|
+
name = encoding[1:].split("=", 1)[0].split("}", 1)[0]
|
|
109
|
+
return f"struct {name}" if name else "struct"
|
|
110
|
+
if c == "(":
|
|
111
|
+
name = encoding[1:].split("=", 1)[0].split(")", 1)[0]
|
|
112
|
+
return f"union {name}" if name else "union"
|
|
113
|
+
if c == "[":
|
|
114
|
+
m = _ARRAY_RE.match(encoding)
|
|
115
|
+
if m:
|
|
116
|
+
count, inner = m.groups()
|
|
117
|
+
return f"{decode_type(inner)}[{count}]"
|
|
118
|
+
return encoding # fallback: bitfields and anything else unrecognized
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def decode_method_signature(selector: str, encoding: str) -> str:
|
|
122
|
+
"""Render `- (returnType)part1:(argType1)arg1 part2:(argType2)arg2` from
|
|
123
|
+
a selector and its method ``types`` encoding. Falls back to an untyped
|
|
124
|
+
signature (every arg as `id`) when the encoding is missing/unparseable --
|
|
125
|
+
e.g. a hook target the walker found via a string cross-check rather than
|
|
126
|
+
a fully decoded method list."""
|
|
127
|
+
parts = selector.split(":")
|
|
128
|
+
is_multi_arg = selector.endswith(":")
|
|
129
|
+
arg_labels = [p for p in parts if p] if is_multi_arg else []
|
|
130
|
+
|
|
131
|
+
if not encoding:
|
|
132
|
+
if not is_multi_arg:
|
|
133
|
+
return f"(id){selector}"
|
|
134
|
+
return " ".join(f"{label}:(id)arg{i + 1}" for i, label in enumerate(arg_labels))
|
|
135
|
+
|
|
136
|
+
i = 0
|
|
137
|
+
ret_tok, i = read_one_type(encoding, i)
|
|
138
|
+
ret = decode_type(ret_tok)
|
|
139
|
+
j = i
|
|
140
|
+
while j < len(encoding) and (encoding[j].isdigit() or encoding[j] == "-"):
|
|
141
|
+
j += 1
|
|
142
|
+
i = j
|
|
143
|
+
|
|
144
|
+
arg_types: list[str] = []
|
|
145
|
+
while i < len(encoding):
|
|
146
|
+
tok, i = read_one_type(encoding, i)
|
|
147
|
+
j = i
|
|
148
|
+
while j < len(encoding) and (encoding[j].isdigit() or encoding[j] == "-"):
|
|
149
|
+
j += 1
|
|
150
|
+
i = j
|
|
151
|
+
arg_types.append(tok)
|
|
152
|
+
|
|
153
|
+
real_args = arg_types[2:] # skip self (@) and _cmd (:)
|
|
154
|
+
if not is_multi_arg:
|
|
155
|
+
return f"({ret}){selector}"
|
|
156
|
+
if len(real_args) != len(arg_labels):
|
|
157
|
+
# types string didn't decode cleanly (e.g. truncated data) -- keep the
|
|
158
|
+
# return type but fall back to untyped args rather than mismatching
|
|
159
|
+
return f"({ret})" + " ".join(f"{label}:(id)arg{i + 1}" for i, label in enumerate(arg_labels))
|
|
160
|
+
pieces = [
|
|
161
|
+
f"{label}:({decode_type(tok)})arg{i + 1}"
|
|
162
|
+
for i, (label, tok) in enumerate(zip(arg_labels, real_args, strict=True))
|
|
163
|
+
]
|
|
164
|
+
return f"({ret})" + " ".join(pieces)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|