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/objc.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Mach-O Objective-C runtime analysis: classes, protocols, categories,
|
|
3
|
+
ivars, properties, method lists, and selectors.
|
|
4
|
+
|
|
5
|
+
Parses the ``__objc_classlist``/``__objc_protolist``/``__objc_catlist``/
|
|
6
|
+
``__objc_classname``/``__objc_selrefs`` sections of an arm64 Mach-O
|
|
7
|
+
(chained-fixup aware). This is the shared ground truth for two consumers:
|
|
8
|
+
``ipa_forge.hooks`` (does this hook target exist and attach?) and
|
|
9
|
+
``ipa_forge.analysis`` (class-dump/strings/diff — general reverse
|
|
10
|
+
engineering of an IPA).
|
|
11
|
+
|
|
12
|
+
Fat binaries are thinned to the arm64 slice first (lipo); section file
|
|
13
|
+
offsets are then correct for direct data reads.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import struct
|
|
19
|
+
import subprocess
|
|
20
|
+
import tempfile
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from ipa_forge.bundle.models import AppBundle
|
|
25
|
+
from ipa_forge.machO.detect import bundle_executable_paths
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class MachOIvar:
|
|
30
|
+
name: str
|
|
31
|
+
type_encoding: str # raw Objective-C type-encoding string, e.g. '@"NSString"', 'i', 'q'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class MachOProperty:
|
|
36
|
+
name: str
|
|
37
|
+
attributes: str # raw property attribute string, e.g. 'T@"NSString",C,N'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class MachOProtocol:
|
|
42
|
+
name: str
|
|
43
|
+
protocols: set[str] = field(default_factory=set) # inherited protocols
|
|
44
|
+
inst: dict[str, str] = field(default_factory=dict) # required instance methods -> type encoding
|
|
45
|
+
cls: dict[str, str] = field(default_factory=dict) # required class methods -> type encoding
|
|
46
|
+
opt_inst: dict[str, str] = field(default_factory=dict) # @optional instance methods -> type encoding
|
|
47
|
+
opt_cls: dict[str, str] = field(default_factory=dict) # @optional class methods -> type encoding
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class MachOCategory:
|
|
52
|
+
name: str
|
|
53
|
+
class_name: str # class being extended; "«external»" for a system-class
|
|
54
|
+
# category (Foundation/UIKit); "" only if the pointer itself is absent
|
|
55
|
+
inst: dict[str, str] = field(default_factory=dict) # instance methods -> type encoding
|
|
56
|
+
cls: dict[str, str] = field(default_factory=dict) # class (metaclass) methods -> type encoding
|
|
57
|
+
protocols: set[str] = field(default_factory=set)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class MachOClass:
|
|
62
|
+
name: str
|
|
63
|
+
super_name: str | None = None # None = root; "«external»" = defined in another image
|
|
64
|
+
inst: dict[str, str] = field(default_factory=dict) # instance methods -> type encoding
|
|
65
|
+
cls: dict[str, str] = field(default_factory=dict) # class (metaclass) methods -> type encoding
|
|
66
|
+
protocols: set[str] = field(default_factory=set)
|
|
67
|
+
ivars: list[MachOIvar] = field(default_factory=list)
|
|
68
|
+
properties: list[MachOProperty] = field(default_factory=list)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class MachOAnalysis:
|
|
73
|
+
classes: dict[str, MachOClass]
|
|
74
|
+
classnames: set[str] # every class-name string defined in the image
|
|
75
|
+
selectors: set[str] # every selector the image references (selrefs + method lists)
|
|
76
|
+
main_executable: Path | None
|
|
77
|
+
methnames: set[str] = field(default_factory=set) # every selector DECLARED as a method name (__objc_methname)
|
|
78
|
+
protocols: dict[str, MachOProtocol] = field(default_factory=dict) # __objc_protolist, by name
|
|
79
|
+
categories: list[MachOCategory] = field(default_factory=list) # __objc_catlist
|
|
80
|
+
# Raw bytes of every analyzed binary, kept so the verifier can cross-check
|
|
81
|
+
# a missing class/selector against the actual string table (a class name or
|
|
82
|
+
# selector present as a cstring but absent from the parsed method lists is
|
|
83
|
+
# a parser gap, not a rename — see verify.py). Short-lived per-command
|
|
84
|
+
# data; held in memory only for the duration of the analysis.
|
|
85
|
+
raw_data: list[bytes] = field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _run(*args: str) -> str:
|
|
89
|
+
return subprocess.run(args, capture_output=True, text=True, check=True).stdout
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def analyze_macho(path: Path) -> MachOAnalysis:
|
|
93
|
+
"""Analyze a Mach-O executable (thin or fat; fat gets thinned to arm64)."""
|
|
94
|
+
with tempfile.TemporaryDirectory(prefix="ipa_forge_hooks_") as tmp:
|
|
95
|
+
info = _run("lipo", "-info", str(path))
|
|
96
|
+
bin_path = path
|
|
97
|
+
if "are:" in info and "arm64" in info:
|
|
98
|
+
bin_path = Path(tmp) / f"{path.name}.arm64"
|
|
99
|
+
_run("lipo", "-thin", "arm64", str(path), "-output", str(bin_path))
|
|
100
|
+
return _analyze_thin(bin_path, original=path)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def analyze_bundle(bundle: AppBundle) -> MachOAnalysis:
|
|
104
|
+
"""Analyze every executable in an AppBundle (main binary + embedded
|
|
105
|
+
frameworks/dylibs) and merge the results. Hook targets routinely live in
|
|
106
|
+
frameworks (Spotify's SPTDataLoaderService is in SpotifyShared.framework),
|
|
107
|
+
so verification must cover all of them, not just the main executable."""
|
|
108
|
+
paths = bundle_executable_paths(bundle, kinds={"framework"})
|
|
109
|
+
|
|
110
|
+
merged: MachOAnalysis | None = None
|
|
111
|
+
for binary_path in paths:
|
|
112
|
+
try:
|
|
113
|
+
analysis = analyze_macho(binary_path)
|
|
114
|
+
except (ValueError, OSError, subprocess.CalledProcessError):
|
|
115
|
+
continue # not a Mach-O or unreadable; skip
|
|
116
|
+
if merged is None:
|
|
117
|
+
merged = analysis
|
|
118
|
+
else:
|
|
119
|
+
merged.classes.update(analysis.classes)
|
|
120
|
+
merged.classnames |= analysis.classnames
|
|
121
|
+
merged.selectors |= analysis.selectors
|
|
122
|
+
merged.methnames |= analysis.methnames
|
|
123
|
+
merged.protocols.update(analysis.protocols)
|
|
124
|
+
merged.categories.extend(analysis.categories)
|
|
125
|
+
merged.raw_data.extend(analysis.raw_data)
|
|
126
|
+
if merged is None:
|
|
127
|
+
raise ValueError("no analyzable Mach-O found in the app bundle")
|
|
128
|
+
return merged
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _analyze_thin(bin_path: Path, original: Path) -> MachOAnalysis:
|
|
132
|
+
otool = _run("otool", "-l", str(bin_path))
|
|
133
|
+
|
|
134
|
+
sections: dict[str, tuple[int, int, int]] = {}
|
|
135
|
+
cur: str | None = None
|
|
136
|
+
addr = size = off = 0
|
|
137
|
+
seg_bases: list[int] = []
|
|
138
|
+
for line in otool.splitlines():
|
|
139
|
+
s = line.strip()
|
|
140
|
+
if s.startswith("vmaddr 0x") and "segment" not in s:
|
|
141
|
+
seg_bases.append(int(s.split()[1], 16))
|
|
142
|
+
if s.startswith("sectname "):
|
|
143
|
+
cur = s.split()[1]
|
|
144
|
+
elif s.startswith("addr 0x") and cur:
|
|
145
|
+
addr = int(s.split()[1], 16)
|
|
146
|
+
elif s.startswith("size 0x") and cur:
|
|
147
|
+
size = int(s.split()[1], 16)
|
|
148
|
+
elif s.startswith("offset ") and cur:
|
|
149
|
+
off = int(s.split()[1], 10)
|
|
150
|
+
sections[cur] = (addr, size, off)
|
|
151
|
+
cur = None
|
|
152
|
+
if not seg_bases:
|
|
153
|
+
raise ValueError("no __TEXT segment found (not a Mach-O?)")
|
|
154
|
+
ib = min(b for b in seg_bases if b)
|
|
155
|
+
|
|
156
|
+
data = bin_path.read_bytes()
|
|
157
|
+
|
|
158
|
+
def vm2off(a: int) -> int | None:
|
|
159
|
+
for _n, (va, sz, fo) in sections.items():
|
|
160
|
+
if va <= a < va + sz:
|
|
161
|
+
return fo + (a - va)
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def rd64(a: int) -> int | None:
|
|
165
|
+
o = vm2off(a)
|
|
166
|
+
if o is None or o + 8 > len(data):
|
|
167
|
+
return None
|
|
168
|
+
return struct.unpack_from("<Q", data, o)[0]
|
|
169
|
+
|
|
170
|
+
def cstr(a: int) -> str | None:
|
|
171
|
+
o = vm2off(a)
|
|
172
|
+
if o is None:
|
|
173
|
+
return None
|
|
174
|
+
e = data.find(b"\x00", o)
|
|
175
|
+
if e <= o or e - o > 4000:
|
|
176
|
+
return None
|
|
177
|
+
try:
|
|
178
|
+
return data[o:e].decode("utf-8")
|
|
179
|
+
except (UnicodeDecodeError, ValueError):
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
def chained(raw: int | None) -> tuple[str, int | None]:
|
|
183
|
+
if raw is None:
|
|
184
|
+
return "none", None
|
|
185
|
+
if raw >> 32 == 0:
|
|
186
|
+
return "plain", raw
|
|
187
|
+
if raw & (1 << 63):
|
|
188
|
+
return "bind", None
|
|
189
|
+
return "rebase", raw & 0xFFFFFFFFF
|
|
190
|
+
|
|
191
|
+
def resolve_ptr(raw: int | None) -> int | None:
|
|
192
|
+
"""Resolve a (possibly chained) pointer to a vm address.
|
|
193
|
+
|
|
194
|
+
Three addressing modes occur in the wild:
|
|
195
|
+
- PIE executables: chained rebase targets (and plain small pointers)
|
|
196
|
+
are offsets from the image base (ib) -- add ib.
|
|
197
|
+
- Mergeable/zero-based dylibs: targets are already absolute vms.
|
|
198
|
+
- Plain absolute pointers: already complete.
|
|
199
|
+
Try absolute first (cheap section lookup), then offset-from-base.
|
|
200
|
+
"""
|
|
201
|
+
kind, val = chained(raw)
|
|
202
|
+
if kind == "plain":
|
|
203
|
+
val = raw
|
|
204
|
+
if val is None:
|
|
205
|
+
return None
|
|
206
|
+
if vm2off(val) is not None:
|
|
207
|
+
return val
|
|
208
|
+
return val + ib
|
|
209
|
+
|
|
210
|
+
classnames: set[str] = set()
|
|
211
|
+
if "__objc_classname" in sections:
|
|
212
|
+
_a, sz, o = sections["__objc_classname"]
|
|
213
|
+
classnames.update(s.decode("utf-8", "replace") for s in data[o : o + sz].split(b"\x00") if s)
|
|
214
|
+
|
|
215
|
+
# ---- pass 1: class table (name, superclass, ro ptr, metaclass ptr) ----
|
|
216
|
+
ro_by_name: dict[str, int] = {}
|
|
217
|
+
meta_by_name: dict[str, int] = {}
|
|
218
|
+
|
|
219
|
+
def class_ptr(raw: int) -> int | None:
|
|
220
|
+
return resolve_ptr(raw)
|
|
221
|
+
|
|
222
|
+
if "__objc_classlist" in sections:
|
|
223
|
+
_a, sz, o = sections["__objc_classlist"]
|
|
224
|
+
for i in range(sz // 8):
|
|
225
|
+
raw = struct.unpack_from("<Q", data, o + i * 8)[0]
|
|
226
|
+
p = class_ptr(raw)
|
|
227
|
+
if p is None:
|
|
228
|
+
continue
|
|
229
|
+
dp = resolve_ptr(rd64(p + 32))
|
|
230
|
+
if not dp:
|
|
231
|
+
continue
|
|
232
|
+
np = resolve_ptr(rd64(dp + 24))
|
|
233
|
+
if not np:
|
|
234
|
+
continue
|
|
235
|
+
name = cstr(np)
|
|
236
|
+
if not name:
|
|
237
|
+
continue
|
|
238
|
+
ro_by_name[name] = dp
|
|
239
|
+
ip_ = resolve_ptr(rd64(p)) # isa -> metaclass
|
|
240
|
+
if ip_:
|
|
241
|
+
meta_by_name[name] = ip_
|
|
242
|
+
|
|
243
|
+
classes: dict[str, MachOClass] = {n: MachOClass(name=n) for n in ro_by_name}
|
|
244
|
+
|
|
245
|
+
if "__objc_classlist" in sections:
|
|
246
|
+
_a, sz, o = sections["__objc_classlist"]
|
|
247
|
+
for i in range(sz // 8):
|
|
248
|
+
raw = struct.unpack_from("<Q", data, o + i * 8)[0]
|
|
249
|
+
p = class_ptr(raw)
|
|
250
|
+
if p is None:
|
|
251
|
+
continue
|
|
252
|
+
dp = resolve_ptr(rd64(p + 32))
|
|
253
|
+
if not dp:
|
|
254
|
+
continue
|
|
255
|
+
np = resolve_ptr(rd64(dp + 24))
|
|
256
|
+
if not np:
|
|
257
|
+
continue
|
|
258
|
+
name = cstr(np)
|
|
259
|
+
if not name or name not in classes:
|
|
260
|
+
continue
|
|
261
|
+
sp = resolve_ptr(rd64(p + 8))
|
|
262
|
+
super_name: str | None = None
|
|
263
|
+
if sp:
|
|
264
|
+
sdp = resolve_ptr(rd64(sp + 32))
|
|
265
|
+
if sdp:
|
|
266
|
+
snp = resolve_ptr(rd64(sdp + 24))
|
|
267
|
+
if snp:
|
|
268
|
+
super_name = cstr(snp)
|
|
269
|
+
if super_name is None:
|
|
270
|
+
# Superclass pointer exists but the name could not be
|
|
271
|
+
# resolved (external bind, chained-fixup variant we do not
|
|
272
|
+
# decode) — the class still inherits from SOMETHING, so
|
|
273
|
+
# mark it external rather than pretending it is a root.
|
|
274
|
+
super_name = "«external»"
|
|
275
|
+
else:
|
|
276
|
+
# a NULL/absent superclass pointer means it binds to an external
|
|
277
|
+
# superclass (defined in another image)
|
|
278
|
+
super_name = "«external»"
|
|
279
|
+
classes[name].super_name = super_name
|
|
280
|
+
|
|
281
|
+
# ---- pass 2: method lists ----
|
|
282
|
+
# Returns {selector: type_encoding} rather than a bare set of selectors --
|
|
283
|
+
# `sel in methods` still works unchanged (dict key containment), but the
|
|
284
|
+
# type encoding lets class-dump reconstruct full method signatures
|
|
285
|
+
# instead of bare selector names.
|
|
286
|
+
def parse_methods(list_raw: int | None) -> dict[str, str]:
|
|
287
|
+
if not list_raw:
|
|
288
|
+
return {}
|
|
289
|
+
lp = resolve_ptr(list_raw)
|
|
290
|
+
if not lp:
|
|
291
|
+
return {}
|
|
292
|
+
eo = vm2off(lp)
|
|
293
|
+
if eo is None or eo + 8 > len(data):
|
|
294
|
+
return {}
|
|
295
|
+
eaf = struct.unpack_from("<I", data, eo)[0]
|
|
296
|
+
entsize = eaf & 0xFFFF or eaf & 0x3FFFFF
|
|
297
|
+
rel = bool(eaf & 0x80000000)
|
|
298
|
+
count = struct.unpack_from("<I", data, eo + 4)[0]
|
|
299
|
+
if count > 5_000_000:
|
|
300
|
+
return {}
|
|
301
|
+
methods: dict[str, str] = {}
|
|
302
|
+
for i in range(count):
|
|
303
|
+
e = lp + 8 + i * entsize
|
|
304
|
+
eo_i = vm2off(e)
|
|
305
|
+
if eo_i is None or eo_i + 12 > len(data):
|
|
306
|
+
break
|
|
307
|
+
if rel and entsize == 12:
|
|
308
|
+
# name/types are self-relative: a signed 32-bit offset from
|
|
309
|
+
# the FIELD's own address. `name` additionally indirects
|
|
310
|
+
# through a selector-reference slot (uniquing); `types`
|
|
311
|
+
# points straight at the encoding cstring (no uniquing table
|
|
312
|
+
# exists for them), no second resolve_ptr needed.
|
|
313
|
+
name_off = struct.unpack_from("<i", data, eo_i)[0]
|
|
314
|
+
sel_ptr = resolve_ptr(rd64(e + name_off))
|
|
315
|
+
sel = cstr(sel_ptr) if sel_ptr else None
|
|
316
|
+
types_off = struct.unpack_from("<i", data, eo_i + 4)[0]
|
|
317
|
+
types = cstr(e + 4 + types_off)
|
|
318
|
+
elif not rel:
|
|
319
|
+
sel_ptr = resolve_ptr(rd64(e))
|
|
320
|
+
sel = cstr(sel_ptr) if sel_ptr else None
|
|
321
|
+
types_ptr = resolve_ptr(rd64(e + 8))
|
|
322
|
+
types = cstr(types_ptr) if types_ptr else None
|
|
323
|
+
else:
|
|
324
|
+
continue
|
|
325
|
+
if sel:
|
|
326
|
+
methods[sel] = types or ""
|
|
327
|
+
return methods
|
|
328
|
+
|
|
329
|
+
# ---- pass 2b: protocol/ivar/property lists shared by classes, protocols,
|
|
330
|
+
# and categories (protocol_list_t / ivar_list_t / property_list_t) ----
|
|
331
|
+
def parse_ptr_list(list_raw: int | None) -> list[int]:
|
|
332
|
+
"""protocol_list_t: a size_t count followed by `count` pointers."""
|
|
333
|
+
if not list_raw:
|
|
334
|
+
return []
|
|
335
|
+
lp = resolve_ptr(list_raw)
|
|
336
|
+
if not lp:
|
|
337
|
+
return []
|
|
338
|
+
co = vm2off(lp)
|
|
339
|
+
if co is None or co + 8 > len(data):
|
|
340
|
+
return []
|
|
341
|
+
count = struct.unpack_from("<Q", data, co)[0]
|
|
342
|
+
if count > 100_000:
|
|
343
|
+
return []
|
|
344
|
+
ptrs: list[int] = []
|
|
345
|
+
for i in range(count):
|
|
346
|
+
raw = rd64(lp + 8 + i * 8)
|
|
347
|
+
p = resolve_ptr(raw) if raw else None
|
|
348
|
+
if p:
|
|
349
|
+
ptrs.append(p)
|
|
350
|
+
return ptrs
|
|
351
|
+
|
|
352
|
+
def protocol_name(proto_ptr: int) -> str | None:
|
|
353
|
+
np = resolve_ptr(rd64(proto_ptr + 8)) # protocol_t.name
|
|
354
|
+
return cstr(np) if np else None
|
|
355
|
+
|
|
356
|
+
def parse_protocol_names(list_raw: int | None) -> set[str]:
|
|
357
|
+
return {n for p in parse_ptr_list(list_raw) if (n := protocol_name(p))}
|
|
358
|
+
|
|
359
|
+
def parse_ivars(list_raw: int | None) -> list[MachOIvar]:
|
|
360
|
+
if not list_raw:
|
|
361
|
+
return []
|
|
362
|
+
lp = resolve_ptr(list_raw)
|
|
363
|
+
if not lp:
|
|
364
|
+
return []
|
|
365
|
+
ho = vm2off(lp)
|
|
366
|
+
if ho is None or ho + 8 > len(data):
|
|
367
|
+
return []
|
|
368
|
+
entsize = struct.unpack_from("<I", data, ho)[0]
|
|
369
|
+
count = struct.unpack_from("<I", data, ho + 4)[0]
|
|
370
|
+
if entsize == 0 or count > 100_000:
|
|
371
|
+
return []
|
|
372
|
+
ivars: list[MachOIvar] = []
|
|
373
|
+
for i in range(count):
|
|
374
|
+
eo = vm2off(lp + 8 + i * entsize)
|
|
375
|
+
if eo is None or eo + 24 > len(data):
|
|
376
|
+
break
|
|
377
|
+
name_ptr = resolve_ptr(rd64(lp + 8 + i * entsize + 8)) # ivar_t.name
|
|
378
|
+
type_ptr = resolve_ptr(rd64(lp + 8 + i * entsize + 16)) # ivar_t.type
|
|
379
|
+
name = cstr(name_ptr) if name_ptr else None
|
|
380
|
+
if name:
|
|
381
|
+
ivars.append(MachOIvar(name, (cstr(type_ptr) if type_ptr else None) or ""))
|
|
382
|
+
return ivars
|
|
383
|
+
|
|
384
|
+
def parse_properties(list_raw: int | None) -> list[MachOProperty]:
|
|
385
|
+
if not list_raw:
|
|
386
|
+
return []
|
|
387
|
+
lp = resolve_ptr(list_raw)
|
|
388
|
+
if not lp:
|
|
389
|
+
return []
|
|
390
|
+
ho = vm2off(lp)
|
|
391
|
+
if ho is None or ho + 8 > len(data):
|
|
392
|
+
return []
|
|
393
|
+
entsize = struct.unpack_from("<I", data, ho)[0]
|
|
394
|
+
count = struct.unpack_from("<I", data, ho + 4)[0]
|
|
395
|
+
if entsize == 0 or count > 100_000:
|
|
396
|
+
return []
|
|
397
|
+
props: list[MachOProperty] = []
|
|
398
|
+
for i in range(count):
|
|
399
|
+
eo = vm2off(lp + 8 + i * entsize)
|
|
400
|
+
if eo is None or eo + 16 > len(data):
|
|
401
|
+
break
|
|
402
|
+
name_ptr = resolve_ptr(rd64(lp + 8 + i * entsize)) # property_t.name
|
|
403
|
+
attr_ptr = resolve_ptr(rd64(lp + 8 + i * entsize + 8)) # property_t.attributes
|
|
404
|
+
name = cstr(name_ptr) if name_ptr else None
|
|
405
|
+
if name:
|
|
406
|
+
props.append(MachOProperty(name, (cstr(attr_ptr) if attr_ptr else None) or ""))
|
|
407
|
+
return props
|
|
408
|
+
|
|
409
|
+
def class_name_from_ptr(raw: int | None) -> str | None:
|
|
410
|
+
"""Resolve a class_t* (isa/superclass/cache/vtable/data) to its name,
|
|
411
|
+
for category_t.cls -- the same chain pass 1 walks for the classlist.
|
|
412
|
+
Returns "«external»" (not None) when the pointer is a bind to a class
|
|
413
|
+
defined in another image -- routine for a category on a system class
|
|
414
|
+
(NSObject, UIView, ...) and distinct from a genuinely absent pointer."""
|
|
415
|
+
if not raw:
|
|
416
|
+
return None
|
|
417
|
+
p = resolve_ptr(raw)
|
|
418
|
+
if not p:
|
|
419
|
+
return "«external»"
|
|
420
|
+
dp = resolve_ptr(rd64(p + 32))
|
|
421
|
+
if not dp:
|
|
422
|
+
return "«external»"
|
|
423
|
+
np = resolve_ptr(rd64(dp + 24))
|
|
424
|
+
name = cstr(np) if np else None
|
|
425
|
+
return name or "«external»"
|
|
426
|
+
|
|
427
|
+
for name, ro in ro_by_name.items():
|
|
428
|
+
cls = classes[name]
|
|
429
|
+
cls.inst = parse_methods(rd64(ro + 32)) # ro.baseMethods
|
|
430
|
+
cls.protocols = parse_protocol_names(rd64(ro + 40)) # ro.baseProtocols
|
|
431
|
+
cls.ivars = parse_ivars(rd64(ro + 48)) # ro.ivars
|
|
432
|
+
cls.properties = parse_properties(rd64(ro + 64)) # ro.baseProperties
|
|
433
|
+
meta = meta_by_name.get(name)
|
|
434
|
+
if meta:
|
|
435
|
+
mdp = resolve_ptr(rd64(meta + 32))
|
|
436
|
+
if mdp:
|
|
437
|
+
cls.cls = parse_methods(rd64(mdp + 32)) # metaclass ro.baseMethods
|
|
438
|
+
|
|
439
|
+
# ---- pass 3: selrefs (every selector the image references) ----
|
|
440
|
+
selectors: set[str] = set()
|
|
441
|
+
if "__objc_selrefs" in sections:
|
|
442
|
+
_a, sz, o = sections["__objc_selrefs"]
|
|
443
|
+
for i in range(sz // 8):
|
|
444
|
+
raw = struct.unpack_from("<Q", data, o + i * 8)[0]
|
|
445
|
+
sp = resolve_ptr(raw)
|
|
446
|
+
if sp:
|
|
447
|
+
sel_str = cstr(sp)
|
|
448
|
+
if sel_str:
|
|
449
|
+
selectors.add(sel_str)
|
|
450
|
+
for cls in classes.values():
|
|
451
|
+
selectors |= set(cls.inst) | set(cls.cls)
|
|
452
|
+
|
|
453
|
+
# ---- pass 4: methname (every selector DECLARED as a method name) ----
|
|
454
|
+
# Distinct from selrefs: a selector can be *referenced* (NSSelectorFromString,
|
|
455
|
+
# performSelector, optional protocol sends) without being declared as a
|
|
456
|
+
# method anywhere. A hook on such a selector cannot attach (no IMP exists to
|
|
457
|
+
# swizzle) — the verifier uses methnames to distinguish that dead case from
|
|
458
|
+
# a parser gap on a real method.
|
|
459
|
+
methnames: set[str] = set()
|
|
460
|
+
if "__objc_methname" in sections:
|
|
461
|
+
_a, sz, o = sections["__objc_methname"]
|
|
462
|
+
methnames.update(s.decode("utf-8", "replace") for s in data[o : o + sz].split(b"\x00") if s)
|
|
463
|
+
|
|
464
|
+
# ---- pass 5: protocol table (__objc_protolist) ----
|
|
465
|
+
# protocol_t: isa@0, name@8, protocols(protocol_list_t*)@16,
|
|
466
|
+
# instanceMethods@24, classMethods@32, optionalInstanceMethods@40,
|
|
467
|
+
# optionalClassMethods@48.
|
|
468
|
+
protocols: dict[str, MachOProtocol] = {}
|
|
469
|
+
if "__objc_protolist" in sections:
|
|
470
|
+
_a, sz, o = sections["__objc_protolist"]
|
|
471
|
+
for i in range(sz // 8):
|
|
472
|
+
raw = struct.unpack_from("<Q", data, o + i * 8)[0]
|
|
473
|
+
pp = resolve_ptr(raw)
|
|
474
|
+
if not pp:
|
|
475
|
+
continue
|
|
476
|
+
name = protocol_name(pp)
|
|
477
|
+
if not name:
|
|
478
|
+
continue
|
|
479
|
+
protocols[name] = MachOProtocol(
|
|
480
|
+
name=name,
|
|
481
|
+
protocols=parse_protocol_names(rd64(pp + 16)),
|
|
482
|
+
inst=parse_methods(rd64(pp + 24)),
|
|
483
|
+
cls=parse_methods(rd64(pp + 32)),
|
|
484
|
+
opt_inst=parse_methods(rd64(pp + 40)),
|
|
485
|
+
opt_cls=parse_methods(rd64(pp + 48)),
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
# ---- pass 6: categories (__objc_catlist) ----
|
|
489
|
+
# category_t: name@0, cls(class_t*)@8, instanceMethods@16, classMethods@24,
|
|
490
|
+
# protocols(protocol_list_t*)@32, instanceProperties@40.
|
|
491
|
+
categories: list[MachOCategory] = []
|
|
492
|
+
if "__objc_catlist" in sections:
|
|
493
|
+
_a, sz, o = sections["__objc_catlist"]
|
|
494
|
+
for i in range(sz // 8):
|
|
495
|
+
raw = struct.unpack_from("<Q", data, o + i * 8)[0]
|
|
496
|
+
cp = resolve_ptr(raw)
|
|
497
|
+
if not cp:
|
|
498
|
+
continue
|
|
499
|
+
name_ptr = resolve_ptr(rd64(cp))
|
|
500
|
+
cat_name = cstr(name_ptr) if name_ptr else None
|
|
501
|
+
if not cat_name:
|
|
502
|
+
continue
|
|
503
|
+
categories.append(
|
|
504
|
+
MachOCategory(
|
|
505
|
+
name=cat_name,
|
|
506
|
+
class_name=class_name_from_ptr(rd64(cp + 8)) or "",
|
|
507
|
+
inst=parse_methods(rd64(cp + 16)),
|
|
508
|
+
cls=parse_methods(rd64(cp + 24)),
|
|
509
|
+
protocols=parse_protocol_names(rd64(cp + 32)),
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
return MachOAnalysis(
|
|
514
|
+
classes=classes,
|
|
515
|
+
classnames=classnames,
|
|
516
|
+
selectors=selectors,
|
|
517
|
+
methnames=methnames,
|
|
518
|
+
protocols=protocols,
|
|
519
|
+
categories=categories,
|
|
520
|
+
main_executable=original,
|
|
521
|
+
raw_data=[data],
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def contains_string(analysis: MachOAnalysis, s: str) -> bool:
|
|
526
|
+
"""True when ``s`` appears as a standalone cstring (\0-delimited) in any
|
|
527
|
+
analyzed image — i.e. the string is *present* in the binary even if the
|
|
528
|
+
class-table/method-list walk missed it. The \0-preceded, \0-terminated
|
|
529
|
+
pattern matches how string tables lay consecutive cstrings out, so a match
|
|
530
|
+
means the string exists on its own rather than inside a longer name."""
|
|
531
|
+
if not s:
|
|
532
|
+
return False
|
|
533
|
+
target = b"\x00" + s.encode("utf-8") + b"\x00"
|
|
534
|
+
return any(target in data for data in analysis.raw_data)
|
ipa_forge/manifest.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Structured manifest emitted after patch application, before signing --
|
|
3
|
+
makes debugging a failed install possible independent of signing outcome.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ipa_forge.patch.base import PatchResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def sha256_of(path: Path) -> str:
|
|
18
|
+
h = hashlib.sha256()
|
|
19
|
+
try:
|
|
20
|
+
with open(path, "rb") as f:
|
|
21
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
22
|
+
h.update(chunk)
|
|
23
|
+
except OSError as e:
|
|
24
|
+
raise OSError(f"cannot hash '{path}': {e}") from e
|
|
25
|
+
return h.hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ProfileManifestEntry:
|
|
30
|
+
uuid: str
|
|
31
|
+
team_id: str
|
|
32
|
+
expiration: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Manifest:
|
|
37
|
+
input_sha256: str
|
|
38
|
+
bundle_id: str
|
|
39
|
+
version: str
|
|
40
|
+
build: str
|
|
41
|
+
patches_applied: list[dict[str, Any]] = field(default_factory=list)
|
|
42
|
+
files_added: list[str] = field(default_factory=list)
|
|
43
|
+
files_modified: list[str] = field(default_factory=list)
|
|
44
|
+
files_removed: list[str] = field(default_factory=list)
|
|
45
|
+
macho_modified: list[str] = field(default_factory=list)
|
|
46
|
+
hook_report: list[dict[str, Any]] = field(default_factory=list)
|
|
47
|
+
profile: ProfileManifestEntry | None = None
|
|
48
|
+
output_sha256: str | None = None
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_patch_results(
|
|
52
|
+
cls,
|
|
53
|
+
input_ipa: Path,
|
|
54
|
+
bundle_id: str,
|
|
55
|
+
version: str,
|
|
56
|
+
build: str,
|
|
57
|
+
results: list[PatchResult],
|
|
58
|
+
) -> Manifest:
|
|
59
|
+
manifest = cls(input_sha256=sha256_of(input_ipa), bundle_id=bundle_id, version=version, build=build)
|
|
60
|
+
for result in results:
|
|
61
|
+
manifest.patches_applied.append({"id": result.op_id, "status": result.status, "message": result.message})
|
|
62
|
+
touched = [str(p) for p in result.files_touched]
|
|
63
|
+
if result.category == "added":
|
|
64
|
+
manifest.files_added.extend(touched)
|
|
65
|
+
elif result.category == "removed":
|
|
66
|
+
manifest.files_removed.extend(touched)
|
|
67
|
+
elif result.category == "modified":
|
|
68
|
+
manifest.files_modified.extend(touched)
|
|
69
|
+
if result.macho_modified:
|
|
70
|
+
manifest.macho_modified.extend(touched)
|
|
71
|
+
return manifest
|
|
72
|
+
|
|
73
|
+
def to_json(self) -> str:
|
|
74
|
+
return json.dumps(asdict(self), indent=2, default=str)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
ipa_forge/patch/base.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Shared patch-operation contract. The core engine only knows this interface --
|
|
3
|
+
never a specific app, bundle id, or byte pattern."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal, Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
from ipa_forge.bundle.models import AppBundle
|
|
12
|
+
|
|
13
|
+
PatchStatus = Literal["applied", "skipped", "failed", "dry_run_ok"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PatchContext:
|
|
18
|
+
bundle: AppBundle
|
|
19
|
+
patch_source_dir: Path
|
|
20
|
+
"""Directory that a patch definition's `source:`/`dylib:` fields resolve relative to."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
FileCategory = Literal["added", "modified", "removed"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class PatchResult:
|
|
28
|
+
op_id: str
|
|
29
|
+
status: PatchStatus
|
|
30
|
+
message: str = ""
|
|
31
|
+
files_touched: list[Path] = field(default_factory=list)
|
|
32
|
+
macho_modified: bool = False
|
|
33
|
+
category: FileCategory | None = None
|
|
34
|
+
"""How files_touched changed, for the manifest's added/modified/removed breakdown."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class PatchOperation(Protocol):
|
|
39
|
+
op_id: str
|
|
40
|
+
|
|
41
|
+
def dry_run(self, ctx: PatchContext) -> PatchResult:
|
|
42
|
+
"""Validate the operation can be applied without mutating anything."""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
def apply(self, ctx: PatchContext) -> PatchResult:
|
|
46
|
+
"""Perform the mutation. Callers must have already run dry_run and
|
|
47
|
+
confirmed dry_run_ok across all operations before calling this."""
|
|
48
|
+
...
|