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/gui/index.html
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="color-scheme" content="light dark" />
|
|
7
|
+
<title>ipa-forge — Patch an IPA</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--accent: #0071e3;
|
|
11
|
+
--accent-hover: #0077ed;
|
|
12
|
+
--accent-press: #0068d6;
|
|
13
|
+
--bg: #f5f5f7;
|
|
14
|
+
--card: #ffffff;
|
|
15
|
+
--text: #1d1d1f;
|
|
16
|
+
--text-secondary: #6e6e73;
|
|
17
|
+
--border: rgba(0, 0, 0, 0.12);
|
|
18
|
+
--shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
|
|
19
|
+
--success: #34c759;
|
|
20
|
+
--danger: #ff3b30;
|
|
21
|
+
--success-bg: rgba(52, 199, 89, 0.12);
|
|
22
|
+
--danger-bg: rgba(255, 59, 48, 0.12);
|
|
23
|
+
--danger-border: rgba(255, 59, 48, 0.5);
|
|
24
|
+
}
|
|
25
|
+
@media (prefers-color-scheme: dark) {
|
|
26
|
+
:root {
|
|
27
|
+
--bg: #000000;
|
|
28
|
+
--card: #1c1c1e;
|
|
29
|
+
--text: #f5f5f7;
|
|
30
|
+
--text-secondary: #98989d;
|
|
31
|
+
--border: rgba(255, 255, 255, 0.16);
|
|
32
|
+
--shadow: 0 8px 30px rgba(0, 0, 0, 0.6);
|
|
33
|
+
--accent: #0a84ff;
|
|
34
|
+
--accent-hover: #3a9bff;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
* { box-sizing: border-box; }
|
|
38
|
+
body {
|
|
39
|
+
margin: 0;
|
|
40
|
+
font-family: -apple-system, "SF Pro Text", "Helvetica Neue", sans-serif;
|
|
41
|
+
background: var(--bg);
|
|
42
|
+
color: var(--text);
|
|
43
|
+
display: flex;
|
|
44
|
+
min-height: 100vh;
|
|
45
|
+
align-items: center;
|
|
46
|
+
justify-content: center;
|
|
47
|
+
padding: 24px;
|
|
48
|
+
}
|
|
49
|
+
.card {
|
|
50
|
+
background: var(--card);
|
|
51
|
+
border-radius: 18px;
|
|
52
|
+
box-shadow: var(--shadow);
|
|
53
|
+
padding: 32px;
|
|
54
|
+
width: 100%;
|
|
55
|
+
max-width: 460px;
|
|
56
|
+
}
|
|
57
|
+
h1 { font-size: 22px; margin: 0 0 4px; }
|
|
58
|
+
.subtitle { color: var(--text-secondary); font-size: 13px; margin: 0 0 20px; }
|
|
59
|
+
.subtitle a { color: var(--accent); }
|
|
60
|
+
.drop {
|
|
61
|
+
border: 2px dashed var(--border);
|
|
62
|
+
border-radius: 12px;
|
|
63
|
+
padding: 28px 16px;
|
|
64
|
+
text-align: center;
|
|
65
|
+
color: var(--text-secondary);
|
|
66
|
+
cursor: pointer;
|
|
67
|
+
transition: border-color 0.2s, color 0.2s;
|
|
68
|
+
font-size: 14px;
|
|
69
|
+
}
|
|
70
|
+
.drop.dragover, .drop:hover { border-color: var(--accent); color: var(--text); }
|
|
71
|
+
.drop .file-name { color: var(--text); font-weight: 600; word-break: break-all; }
|
|
72
|
+
.drop input { display: none; }
|
|
73
|
+
.status {
|
|
74
|
+
margin-top: 16px;
|
|
75
|
+
border-radius: 10px;
|
|
76
|
+
padding: 12px 14px;
|
|
77
|
+
font-size: 13px;
|
|
78
|
+
display: none;
|
|
79
|
+
}
|
|
80
|
+
.status.error { display: block; background: var(--danger-bg); border: 1px solid var(--danger-border); color: var(--danger); }
|
|
81
|
+
.status.info { display: block; background: var(--success-bg); border: 1px solid rgba(52,199,89,0.5); color: var(--success); }
|
|
82
|
+
.status.warn { display: block; background: rgba(255,149,0,0.12); border: 1px solid rgba(255,149,0,0.5); color: #ff9500; }
|
|
83
|
+
.status.warn b { color: var(--text); }
|
|
84
|
+
.detected { display: none; margin-top: 16px; font-size: 13px; }
|
|
85
|
+
.detected.show { display: block; }
|
|
86
|
+
.detected .row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid var(--border); }
|
|
87
|
+
.detected .row:last-child { border-bottom: none; }
|
|
88
|
+
.detected .k { color: var(--text-secondary); }
|
|
89
|
+
.detected .v { font-weight: 600; }
|
|
90
|
+
button {
|
|
91
|
+
margin-top: 18px;
|
|
92
|
+
width: 100%;
|
|
93
|
+
padding: 14px;
|
|
94
|
+
border: none;
|
|
95
|
+
border-radius: 12px;
|
|
96
|
+
background: var(--accent);
|
|
97
|
+
color: #fff;
|
|
98
|
+
font-size: 15px;
|
|
99
|
+
font-weight: 600;
|
|
100
|
+
cursor: pointer;
|
|
101
|
+
transition: background 0.2s;
|
|
102
|
+
}
|
|
103
|
+
button:hover { background: var(--accent-hover); }
|
|
104
|
+
button:active { background: var(--accent-press); }
|
|
105
|
+
button:disabled { opacity: 0.5; cursor: default; }
|
|
106
|
+
.footnote { margin-top: 14px; color: var(--text-secondary); font-size: 12px; text-align: center; }
|
|
107
|
+
.download { margin-top: 14px; text-align: center; }
|
|
108
|
+
.download a { color: var(--accent); font-weight: 600; text-decoration: none; }
|
|
109
|
+
</style>
|
|
110
|
+
</head>
|
|
111
|
+
<body>
|
|
112
|
+
<div class="card">
|
|
113
|
+
<h1>ipa-forge</h1>
|
|
114
|
+
<p class="subtitle">
|
|
115
|
+
Select an IPA to patch it with the matching patch set. Output is unsigned — ready for AltStore.
|
|
116
|
+
Just want to inspect an IPA? <a href="/analysis">Reverse engineering tools</a>.
|
|
117
|
+
</p>
|
|
118
|
+
|
|
119
|
+
<div class="drop" id="drop">
|
|
120
|
+
<span id="dropText">Click or drop your .ipa file here</span>
|
|
121
|
+
<input type="file" id="file" accept=".ipa" />
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<div class="status" id="status"></div>
|
|
125
|
+
|
|
126
|
+
<div class="detected" id="detected">
|
|
127
|
+
<div class="row"><span class="k">App</span><span class="v" id="dApp">–</span></div>
|
|
128
|
+
<div class="row"><span class="k">Version</span><span class="v" id="dVersion">–</span></div>
|
|
129
|
+
<div class="row"><span class="k">Patch set</span><span class="v" id="dPatchSet">–</span></div>
|
|
130
|
+
<div class="row"><span class="k">Targets</span><span class="v" id="dTarget">–</span></div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<button id="patchBtn" disabled>Patch</button>
|
|
134
|
+
|
|
135
|
+
<div class="download" id="download"></div>
|
|
136
|
+
<p class="footnote">version mismatch shows a warning but patching still runs — hook verification is the safety net.</p>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<script>
|
|
140
|
+
const fileInput = document.getElementById("file");
|
|
141
|
+
const drop = document.getElementById("drop");
|
|
142
|
+
const dropText = document.getElementById("dropText");
|
|
143
|
+
const status = document.getElementById("status");
|
|
144
|
+
const detected = document.getElementById("detected");
|
|
145
|
+
const patchBtn = document.getElementById("patchBtn");
|
|
146
|
+
const download = document.getElementById("download");
|
|
147
|
+
|
|
148
|
+
let currentFile = null;
|
|
149
|
+
let currentPatchSet = null;
|
|
150
|
+
|
|
151
|
+
function showStatus(kind, html) {
|
|
152
|
+
status.className = "status " + kind;
|
|
153
|
+
status.innerHTML = html;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function setDetected(app, version, patchSet, target) {
|
|
157
|
+
detected.classList.add("show");
|
|
158
|
+
document.getElementById("dApp").textContent = app || "–";
|
|
159
|
+
document.getElementById("dVersion").textContent = version || "–";
|
|
160
|
+
document.getElementById("dPatchSet").textContent = patchSet || "none found";
|
|
161
|
+
document.getElementById("dTarget").textContent = target || "–";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function analyze(file) {
|
|
165
|
+
showStatus("info", "Analyzing…");
|
|
166
|
+
download.innerHTML = "";
|
|
167
|
+
const fd = new FormData();
|
|
168
|
+
fd.append("ipa", file);
|
|
169
|
+
const res = await fetch("/detect", { method: "POST", body: fd });
|
|
170
|
+
const data = await res.json();
|
|
171
|
+
if (!res.ok) {
|
|
172
|
+
showStatus("error", data.error || "Could not read the IPA.");
|
|
173
|
+
patchBtn.disabled = true;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const ps = data.patch_sets || [];
|
|
177
|
+
currentPatchSet = ps[0] ? ps[0].name : null;
|
|
178
|
+
setDetected(data.bundle_id, data.version, currentPatchSet || "none", ps[0] ? ps[0].target_version : "–");
|
|
179
|
+
|
|
180
|
+
if (!ps.length) {
|
|
181
|
+
showStatus("error", "No patch set found for <b>" + (data.bundle_id || "this app") + "</b>.");
|
|
182
|
+
patchBtn.disabled = true;
|
|
183
|
+
} else if (data.mismatch) {
|
|
184
|
+
showStatus(
|
|
185
|
+
"warn",
|
|
186
|
+
"Heads up: this patch set targets <b>" + ps[0].target_version + "</b> but your IPA is <b>" +
|
|
187
|
+
data.version + "</b>. The hooks gate will still verify everything — patching is allowed."
|
|
188
|
+
);
|
|
189
|
+
patchBtn.disabled = false;
|
|
190
|
+
} else {
|
|
191
|
+
showStatus("info", "Ready — patch set <b>" + currentPatchSet + "</b> matches.");
|
|
192
|
+
patchBtn.disabled = false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
drop.addEventListener("click", () => fileInput.click());
|
|
197
|
+
drop.addEventListener("dragover", (e) => { e.preventDefault(); drop.classList.add("dragover"); });
|
|
198
|
+
drop.addEventListener("dragleave", () => drop.classList.remove("dragover"));
|
|
199
|
+
drop.addEventListener("drop", (e) => {
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
drop.classList.remove("dragover");
|
|
202
|
+
if (e.dataTransfer.files.length) {
|
|
203
|
+
currentFile = e.dataTransfer.files[0];
|
|
204
|
+
dropText.textContent = currentFile.name;
|
|
205
|
+
dropText.classList.add("file-name");
|
|
206
|
+
analyze(currentFile);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
fileInput.addEventListener("change", () => {
|
|
210
|
+
if (fileInput.files.length) {
|
|
211
|
+
currentFile = fileInput.files[0];
|
|
212
|
+
dropText.textContent = currentFile.name;
|
|
213
|
+
dropText.classList.add("file-name");
|
|
214
|
+
analyze(currentFile);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
patchBtn.addEventListener("click", async () => {
|
|
219
|
+
if (!currentFile || !currentPatchSet) return;
|
|
220
|
+
patchBtn.disabled = true;
|
|
221
|
+
showStatus("info", "Patching… (a 150 MB IPA takes a few minutes)");
|
|
222
|
+
download.innerHTML = "";
|
|
223
|
+
const fd = new FormData();
|
|
224
|
+
fd.append("ipa", currentFile);
|
|
225
|
+
fd.append("patch_set", currentPatchSet);
|
|
226
|
+
fd.append("no_sign", "true");
|
|
227
|
+
const res = await fetch("/patch", { method: "POST", body: fd });
|
|
228
|
+
const data = await res.json();
|
|
229
|
+
if (!res.ok) {
|
|
230
|
+
showStatus("error", data.error || "Patching failed.");
|
|
231
|
+
patchBtn.disabled = false;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const manifest = data.manifest || {};
|
|
235
|
+
const applied = (manifest.patches_applied || []).filter((p) => p.status === "applied").length;
|
|
236
|
+
showStatus("info", "Patched — " + applied + " operation(s) applied.");
|
|
237
|
+
if (data.download_url) {
|
|
238
|
+
download.innerHTML = '<a href="' + data.download_url + '">Download patched IPA</a>';
|
|
239
|
+
}
|
|
240
|
+
patchBtn.disabled = false;
|
|
241
|
+
});
|
|
242
|
+
</script>
|
|
243
|
+
</body>
|
|
244
|
+
</html>
|
|
File without changes
|
ipa_forge/hooks/scan.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Scan ObjC tweak sources for runtime hook calls.
|
|
3
|
+
|
|
4
|
+
Recognizes ``<prefix>HookInstance/HookClass/AddInstanceMethod`` calls with an
|
|
5
|
+
inline ``NSClassFromString(@"X")`` (or a file-scoped, unambiguous
|
|
6
|
+
``cls = NSClassFromString(@"X")`` assignment), plus class names passed
|
|
7
|
+
through a local resolver helper — any function whose body calls
|
|
8
|
+
``NSClassFromString`` and that is invoked as ``resolver("X")`` at the hook
|
|
9
|
+
call site (e.g. ``igHookInstance(igClass("YTSettings"), ...)``).
|
|
10
|
+
|
|
11
|
+
Helper/loop-based hooks whose class is *passed into another function* before
|
|
12
|
+
reaching the hook call (``hookParserNil(igClass("X"))`` → ``igHookInstance(cls,
|
|
13
|
+
...)`` inside ``hookParserNil``) are not traced across function boundaries —
|
|
14
|
+
declare those manually in the definition's ``hooks:`` block, exactly as the
|
|
15
|
+
docs say.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from ipa_forge.hooks.verify import HookDecl
|
|
24
|
+
|
|
25
|
+
_PATTERN = re.compile(
|
|
26
|
+
r"([A-Za-z_][A-Za-z0-9_]*)?(?:Hook(Instance|Class|ConfigBool)|AddInstanceMethod)"
|
|
27
|
+
r"\("
|
|
28
|
+
r"\s*(?:NSClassFromString\(@?\"([^\"]+)\"\)|\[\s*(\w+)\s*class\]|(\w+)|(\w+)\(@?\"([^\"]+)\"\))"
|
|
29
|
+
r"\s*,\s*(?:@selector\(([^)]+)\)|sel_registerName\(\"([^\"]+)\"\))"
|
|
30
|
+
)
|
|
31
|
+
_VAR_RE = re.compile(r"(\w+)\s*=\s*NSClassFromString\(@?\"([^\"]+)\"\)")
|
|
32
|
+
# A class-resolver helper: a function returning Class whose body calls
|
|
33
|
+
# NSClassFromString (simple single-expression bodies). Calls like
|
|
34
|
+
# ``resolver("X")`` resolve X to a class name at the hook call site.
|
|
35
|
+
_RESOLVER_RE = re.compile(r"(?:\bstatic\s+)?Class\s+(\w+)\s*\([^)]*\)\s*\{[^{}]*NSClassFromString")
|
|
36
|
+
_RESOLVER_CALL_RE = re.compile(r"(\w+)\s*\(\s*@?\"([^\"]+)\"\s*\)")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def scan_hook_sources(dylib_src: Path) -> list[HookDecl]:
|
|
40
|
+
"""Scan ``*.m`` files under ``dylib_src`` and return hook declarations."""
|
|
41
|
+
decls: list[HookDecl] = []
|
|
42
|
+
for f in sorted(dylib_src.glob("*.m")):
|
|
43
|
+
text = f.read_text(errors="replace")
|
|
44
|
+
# map NSClassFromString assignments to their variable (file-scoped),
|
|
45
|
+
# only when unambiguous (a var assigned two different classes is
|
|
46
|
+
# untrustworthy — skip it)
|
|
47
|
+
var_class: dict[str, str] = {}
|
|
48
|
+
var_classes: dict[str, set[str]] = {}
|
|
49
|
+
for m in _VAR_RE.finditer(text):
|
|
50
|
+
var_classes.setdefault(m.group(1), set()).add(m.group(2))
|
|
51
|
+
for var, classes in var_classes.items():
|
|
52
|
+
if len(classes) == 1:
|
|
53
|
+
var_class[var] = next(iter(classes))
|
|
54
|
+
|
|
55
|
+
# resolver helpers: functions whose body calls NSClassFromString
|
|
56
|
+
resolvers = {m.group(1) for m in _RESOLVER_RE.finditer(text)}
|
|
57
|
+
resolver_class: dict[str, str] = {}
|
|
58
|
+
if resolvers:
|
|
59
|
+
for m in _RESOLVER_CALL_RE.finditer(text):
|
|
60
|
+
if m.group(1) in resolvers:
|
|
61
|
+
resolver_class[m.group(1)] = m.group(2)
|
|
62
|
+
|
|
63
|
+
for m in _PATTERN.finditer(text):
|
|
64
|
+
_prefix, kind, cls, cls2, clsvar, clsfn, clsarg, sel, sel2 = m.groups()
|
|
65
|
+
is_add = kind is None # AddInstanceMethod branch
|
|
66
|
+
cls = cls or cls2 or var_class.get(clsvar or "") or (resolver_class.get(clsfn or "") if clsfn else "")
|
|
67
|
+
sel = sel or sel2
|
|
68
|
+
if not cls or not sel:
|
|
69
|
+
continue
|
|
70
|
+
hook_kind = "class" if kind == "Class" else "instance" # ConfigBool hooks the getter -> instance
|
|
71
|
+
decls.append(HookDecl(cls, sel, hook_kind, added=is_add)) # type: ignore[arg-type]
|
|
72
|
+
|
|
73
|
+
# de-duplicate, keep first-seen order
|
|
74
|
+
seen: set[tuple[str, str]] = set()
|
|
75
|
+
unique: list[HookDecl] = []
|
|
76
|
+
for d in decls:
|
|
77
|
+
if (d.class_name, d.selector) in seen:
|
|
78
|
+
continue
|
|
79
|
+
seen.add((d.class_name, d.selector))
|
|
80
|
+
unique.append(d)
|
|
81
|
+
return unique
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Hook verification: classify each declared hook against a MachOAnalysis.
|
|
3
|
+
|
|
4
|
+
Statuses:
|
|
5
|
+
|
|
6
|
+
- ``ok`` — class parsed and selector found on the class or an ancestor.
|
|
7
|
+
- ``ok-system`` — class is a system-framework class (not in the app's
|
|
8
|
+
classlist) and the selector is referenced somewhere in the app binary.
|
|
9
|
+
- ``added`` — declared ``added: true`` (the tweak adds the method); absence
|
|
10
|
+
is the expected state and counts as a pass.
|
|
11
|
+
- ``missing-class`` — class absent from the app entirely (renamed/removed).
|
|
12
|
+
- ``missing-selector`` — class present but the selector is nowhere in the
|
|
13
|
+
binary: the hook would attach but never fire.
|
|
14
|
+
- ``elsewhere`` — selector exists in the binary but not on this class or its
|
|
15
|
+
ancestors: the hook would silently no-op on this class.
|
|
16
|
+
|
|
17
|
+
``required`` hooks that resolve to a failure status fail the run; the rest
|
|
18
|
+
are reported as warnings so porting to a new version is guided, not blocked.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Literal
|
|
25
|
+
|
|
26
|
+
from ipa_forge.machO.objc import MachOAnalysis, contains_string
|
|
27
|
+
|
|
28
|
+
HookStatus = Literal[
|
|
29
|
+
"ok",
|
|
30
|
+
"ok-system",
|
|
31
|
+
"ok-inherited",
|
|
32
|
+
"added",
|
|
33
|
+
"missing-class",
|
|
34
|
+
"missing-selector",
|
|
35
|
+
"elsewhere",
|
|
36
|
+
"referenced-only",
|
|
37
|
+
"unverified",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
_SYSTEM_PREFIXES = ("NS", "UI", "CA", "AV", "MP", "CF", "CG", "SK", "_", "TUI")
|
|
41
|
+
|
|
42
|
+
# Selectors hooks commonly target that are defined on system superclasses
|
|
43
|
+
# (UIView/UIButton/UIViewController etc.) — not visible in the app's class
|
|
44
|
+
# method lists, but inherited at runtime, so the hook attaches fine.
|
|
45
|
+
_SYSTEM_SELECTORS = {
|
|
46
|
+
"init",
|
|
47
|
+
"dealloc",
|
|
48
|
+
"layoutSubviews",
|
|
49
|
+
"didMoveToWindow",
|
|
50
|
+
"viewDidAppear:",
|
|
51
|
+
"viewDidLoad",
|
|
52
|
+
"viewDidDisappear:",
|
|
53
|
+
"setHidden:",
|
|
54
|
+
"setFrame:",
|
|
55
|
+
"setBackgroundColor:",
|
|
56
|
+
"setEnabled:",
|
|
57
|
+
"sizeThatFits:",
|
|
58
|
+
"titleLabel",
|
|
59
|
+
"setTitle:forState:",
|
|
60
|
+
"addGestureRecognizer:",
|
|
61
|
+
"setDelegate:",
|
|
62
|
+
"setUserInteractionEnabled:",
|
|
63
|
+
"setAlpha:",
|
|
64
|
+
"setNeedsLayout",
|
|
65
|
+
"removeFromSuperview",
|
|
66
|
+
"setText:",
|
|
67
|
+
"setOn:",
|
|
68
|
+
"setAccessibilityIdentifier:",
|
|
69
|
+
"setValue:forKey:",
|
|
70
|
+
"didMoveToSuperview",
|
|
71
|
+
# UIResponder event-handling methods -- every UIView/UIViewController
|
|
72
|
+
# inherits a default implementation, so hooking one on an app class whose
|
|
73
|
+
# superclass chain ends external (UIViewController/UIView) attaches fine
|
|
74
|
+
# even though no app class overrides it. Found via a real false-positive
|
|
75
|
+
# `elsewhere` classification on IGRootViewController -[motionEnded:
|
|
76
|
+
# withEvent:] (a shake-gesture hook): the selector is only implemented on
|
|
77
|
+
# unrelated UIWindow/UIApplication subclasses in that binary, which the
|
|
78
|
+
# ancestry walk (rightly) doesn't associate with a UIViewController.
|
|
79
|
+
"motionEnded:withEvent:",
|
|
80
|
+
"touchesBegan:withEvent:",
|
|
81
|
+
"touchesMoved:withEvent:",
|
|
82
|
+
"touchesEnded:withEvent:",
|
|
83
|
+
"touchesCancelled:withEvent:",
|
|
84
|
+
"pressesBegan:withEvent:",
|
|
85
|
+
"pressesEnded:withEvent:",
|
|
86
|
+
"pressesCancelled:withEvent:",
|
|
87
|
+
"remoteControlReceivedWithEvent:",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class HookDecl:
|
|
93
|
+
class_name: str
|
|
94
|
+
selector: str
|
|
95
|
+
kind: Literal["instance", "class"] = "instance"
|
|
96
|
+
added: bool = False
|
|
97
|
+
required: bool = False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class HookResult:
|
|
102
|
+
class_name: str
|
|
103
|
+
selector: str
|
|
104
|
+
kind: Literal["instance", "class"]
|
|
105
|
+
status: HookStatus
|
|
106
|
+
detail: str = ""
|
|
107
|
+
required: bool = False
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def ok(self) -> bool:
|
|
111
|
+
return self.status in ("ok", "ok-system", "ok-inherited", "added")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_system_class(name: str) -> bool:
|
|
115
|
+
return name.startswith(_SYSTEM_PREFIXES) or name in (
|
|
116
|
+
"UITableViewCell",
|
|
117
|
+
"UIColor",
|
|
118
|
+
"UIFont",
|
|
119
|
+
"UIWindow",
|
|
120
|
+
"UIView",
|
|
121
|
+
"UIViewController",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _ancestry(analysis: MachOAnalysis, name: str) -> list[str]:
|
|
126
|
+
chain: list[str] = []
|
|
127
|
+
seen: set[str] = set()
|
|
128
|
+
cur: str | None = name
|
|
129
|
+
while cur and cur in analysis.classes and cur not in seen:
|
|
130
|
+
seen.add(cur)
|
|
131
|
+
chain.append(cur)
|
|
132
|
+
cur = analysis.classes[cur].super_name
|
|
133
|
+
if cur is None:
|
|
134
|
+
break
|
|
135
|
+
return chain
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def verify_hooks(analysis: MachOAnalysis, hooks: list[HookDecl]) -> list[HookResult]:
|
|
139
|
+
results: list[HookResult] = []
|
|
140
|
+
for hook in hooks:
|
|
141
|
+
cls = hook.class_name
|
|
142
|
+
sel = hook.selector
|
|
143
|
+
mc = analysis.classes.get(cls)
|
|
144
|
+
|
|
145
|
+
if hook.added:
|
|
146
|
+
# The tweak ADDS the method. If the binary references the selector
|
|
147
|
+
# (selrefs) but no class implements it, the app is calling into a
|
|
148
|
+
# hole that this add fills — exactly the intended state.
|
|
149
|
+
if sel in analysis.selectors:
|
|
150
|
+
results.append(
|
|
151
|
+
HookResult(
|
|
152
|
+
cls, sel, hook.kind, "ok", "app references this selector; the tweak provides it", hook.required
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
results.append(
|
|
157
|
+
HookResult(
|
|
158
|
+
cls, sel, hook.kind, "added", "method added by the tweak; absence expected", hook.required
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if mc is None:
|
|
164
|
+
if cls in analysis.classnames:
|
|
165
|
+
results.append(
|
|
166
|
+
HookResult(
|
|
167
|
+
cls,
|
|
168
|
+
sel,
|
|
169
|
+
hook.kind,
|
|
170
|
+
"unverified",
|
|
171
|
+
"class exists (classname section) but the walk missed it (likely attaches)",
|
|
172
|
+
hook.required,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
elif cls.startswith("_Tt"):
|
|
176
|
+
# Swift-mangled class absent from the parsed table. Not a
|
|
177
|
+
# system class — labelling it ok-system would hide drift. The
|
|
178
|
+
# hook may well attach (the walk misses Swift classes too), so
|
|
179
|
+
# report honestly instead.
|
|
180
|
+
results.append(
|
|
181
|
+
HookResult(
|
|
182
|
+
cls,
|
|
183
|
+
sel,
|
|
184
|
+
hook.kind,
|
|
185
|
+
"unverified",
|
|
186
|
+
"Swift class not in the parsed class table (mangled name; the walk missed it)",
|
|
187
|
+
hook.required,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
elif _is_system_class(cls):
|
|
191
|
+
if sel in analysis.selectors or sel in _SYSTEM_SELECTORS:
|
|
192
|
+
results.append(
|
|
193
|
+
HookResult(
|
|
194
|
+
cls,
|
|
195
|
+
sel,
|
|
196
|
+
hook.kind,
|
|
197
|
+
"ok-system",
|
|
198
|
+
"system class; selector referenced by the app or a known system API",
|
|
199
|
+
hook.required,
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
else:
|
|
203
|
+
results.append(
|
|
204
|
+
HookResult(
|
|
205
|
+
cls,
|
|
206
|
+
sel,
|
|
207
|
+
hook.kind,
|
|
208
|
+
"unverified",
|
|
209
|
+
"system class; selector not referenced by the app binary",
|
|
210
|
+
hook.required,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
else:
|
|
214
|
+
if contains_string(analysis, cls):
|
|
215
|
+
# The name exists as a standalone cstring in some image but
|
|
216
|
+
# the class-table walk missed it — a parser gap, not a
|
|
217
|
+
# rename. Soft-fail instead of calling it missing.
|
|
218
|
+
results.append(
|
|
219
|
+
HookResult(
|
|
220
|
+
cls,
|
|
221
|
+
sel,
|
|
222
|
+
hook.kind,
|
|
223
|
+
"unverified",
|
|
224
|
+
"class-name string present in a binary image but the class-table walk missed "
|
|
225
|
+
"it (likely attaches)",
|
|
226
|
+
hook.required,
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
results.append(
|
|
231
|
+
HookResult(cls, sel, hook.kind, "missing-class", "class not found in the binary", hook.required)
|
|
232
|
+
)
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
# class present — walk the ancestry for the selector
|
|
236
|
+
chain = _ancestry(analysis, cls)
|
|
237
|
+
pool = "cls" if hook.kind == "class" else "inst"
|
|
238
|
+
found = False
|
|
239
|
+
found_at: str | None = None
|
|
240
|
+
for ancestor in chain:
|
|
241
|
+
methods = getattr(analysis.classes[ancestor], pool)
|
|
242
|
+
if sel in methods:
|
|
243
|
+
found = True
|
|
244
|
+
found_at = ancestor
|
|
245
|
+
break
|
|
246
|
+
if found:
|
|
247
|
+
if found_at == cls:
|
|
248
|
+
results.append(HookResult(cls, sel, hook.kind, "ok", f"on {cls}", hook.required))
|
|
249
|
+
else:
|
|
250
|
+
results.append(
|
|
251
|
+
HookResult(cls, sel, hook.kind, "ok-inherited", f"inherited from {found_at}", hook.required)
|
|
252
|
+
)
|
|
253
|
+
continue
|
|
254
|
+
|
|
255
|
+
# Not in the parsed hierarchy. Two soft cases before we call it broken:
|
|
256
|
+
# (a) a system superclass method (UIView etc.) — attaches via inheritance;
|
|
257
|
+
# (b) the selector exists in the binary but the parser missed the method
|
|
258
|
+
# list entry (chained-fixup decode gaps) — likely on this class.
|
|
259
|
+
chain_classes = [analysis.classes[c] for c in chain]
|
|
260
|
+
ends_in_system = any(
|
|
261
|
+
(c.super_name == "«external»" or (c.super_name or "").startswith(("NS", "UI"))) for c in chain_classes
|
|
262
|
+
)
|
|
263
|
+
if sel in _SYSTEM_SELECTORS and ends_in_system:
|
|
264
|
+
results.append(
|
|
265
|
+
HookResult(
|
|
266
|
+
cls,
|
|
267
|
+
sel,
|
|
268
|
+
hook.kind,
|
|
269
|
+
"ok-inherited",
|
|
270
|
+
"system superclass method (attaches via inheritance)",
|
|
271
|
+
hook.required,
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
continue
|
|
275
|
+
if sel in analysis.selectors:
|
|
276
|
+
pool_of = "cls" if hook.kind == "class" else "inst"
|
|
277
|
+
parsed_anywhere = any(sel in getattr(c, pool_of) for c in analysis.classes.values())
|
|
278
|
+
if parsed_anywhere:
|
|
279
|
+
results.append(
|
|
280
|
+
HookResult(
|
|
281
|
+
cls,
|
|
282
|
+
sel,
|
|
283
|
+
hook.kind,
|
|
284
|
+
"elsewhere",
|
|
285
|
+
"selector exists in the binary but not on this class/ancestors (hook would no-op)",
|
|
286
|
+
hook.required,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
elif sel in analysis.methnames:
|
|
290
|
+
# Declared as a method name somewhere (a protocol, category or a
|
|
291
|
+
# method list the walker did not decode) — the hook may well
|
|
292
|
+
# attach; keep it soft.
|
|
293
|
+
results.append(
|
|
294
|
+
HookResult(
|
|
295
|
+
cls,
|
|
296
|
+
sel,
|
|
297
|
+
hook.kind,
|
|
298
|
+
"unverified",
|
|
299
|
+
"selector declared as a method somewhere (protocol/category/undecoded list) "
|
|
300
|
+
"but not in any parsed class method list (likely attaches)",
|
|
301
|
+
hook.required,
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
else:
|
|
305
|
+
# Referenced (NSSelectorFromString/performSelector/optional
|
|
306
|
+
# protocol sends) but declared as NO method anywhere in the
|
|
307
|
+
# binary — no IMP exists to swizzle, so class_getInstanceMethod
|
|
308
|
+
# returns NULL and the hook cannot attach.
|
|
309
|
+
results.append(
|
|
310
|
+
HookResult(
|
|
311
|
+
cls,
|
|
312
|
+
sel,
|
|
313
|
+
hook.kind,
|
|
314
|
+
"referenced-only",
|
|
315
|
+
"selector referenced by the binary but not declared as a method anywhere "
|
|
316
|
+
"(no IMP to swizzle; the hook cannot attach unless a system superclass provides it)",
|
|
317
|
+
hook.required,
|
|
318
|
+
)
|
|
319
|
+
)
|
|
320
|
+
else:
|
|
321
|
+
if contains_string(analysis, sel):
|
|
322
|
+
results.append(
|
|
323
|
+
HookResult(
|
|
324
|
+
cls,
|
|
325
|
+
sel,
|
|
326
|
+
hook.kind,
|
|
327
|
+
"unverified",
|
|
328
|
+
"selector string present in a binary image but not in any parsed method list "
|
|
329
|
+
"(parser may have missed it)",
|
|
330
|
+
hook.required,
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
results.append(
|
|
335
|
+
HookResult(
|
|
336
|
+
cls,
|
|
337
|
+
sel,
|
|
338
|
+
hook.kind,
|
|
339
|
+
"missing-selector",
|
|
340
|
+
"selector not found anywhere in the binary",
|
|
341
|
+
hook.required,
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
return results
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def failing(results: list[HookResult]) -> list[HookResult]:
|
|
348
|
+
return [r for r in results if not r.ok]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|