attackmap-analyzer-swift 0.2.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.
- attackmap_analyzer_swift/__init__.py +4 -0
- attackmap_analyzer_swift/analyzer.py +296 -0
- attackmap_analyzer_swift/apple_surface.py +765 -0
- attackmap_analyzer_swift/objc.py +545 -0
- attackmap_analyzer_swift/routes.py +1195 -0
- attackmap_analyzer_swift/sbom.py +58 -0
- attackmap_analyzer_swift/swiftsrc.py +239 -0
- attackmap_analyzer_swift-0.2.0.dist-info/METADATA +175 -0
- attackmap_analyzer_swift-0.2.0.dist-info/RECORD +13 -0
- attackmap_analyzer_swift-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_swift-0.2.0.dist-info/entry_points.txt +3 -0
- attackmap_analyzer_swift-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_swift-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"""Objective-C / Objective-C++ analyzer (AttackMap#190), the package's second
|
|
2
|
+
analyzer entry point (``objc``). It shares the iOS/macOS surface model with the
|
|
3
|
+
Swift analyzer (``apple_surface.py``): the same project-file checks
|
|
4
|
+
(Info.plist, entitlements, xcconfig, Cartfile.resolved) and the same issue
|
|
5
|
+
kinds, applied to ``.m`` / ``.mm`` sources:
|
|
6
|
+
|
|
7
|
+
- entry points: URL-scheme handlers (``application:openURL:options:``,
|
|
8
|
+
``application:handleOpenURL:``, ``scene:openURLContexts:``,
|
|
9
|
+
``handleGetURLEvent:withReplyEvent:``), universal-link handlers
|
|
10
|
+
(``application:continueUserActivity:...``) and ``NSXPCListener`` services;
|
|
11
|
+
- ``evaluateJavaScript:`` / ``stringByEvaluatingJavaScriptFromString:`` with a
|
|
12
|
+
``stringWithFormat:`` whose ``%@`` sits inside a JS string literal and whose
|
|
13
|
+
argument isn't visibly encoded;
|
|
14
|
+
- secret-named values or keys written to ``NSUserDefaults`` or the general
|
|
15
|
+
pasteboard;
|
|
16
|
+
- an XPC ``listener:shouldAcceptNewConnection:`` that returns ``YES`` without
|
|
17
|
+
checking the peer;
|
|
18
|
+
- ``NSClassFromString`` / ``NSSelectorFromString`` / ``dlopen`` on a string
|
|
19
|
+
built at the call site;
|
|
20
|
+
- native weaknesses shared with the C/C++ plugins (``CodeWeakness``): a
|
|
21
|
+
non-literal, non-localized format string with no arguments
|
|
22
|
+
(``[NSString stringWithFormat:msg]``, ``NSLog(msg)``) and a shell command
|
|
23
|
+
built at runtime (``/bin/sh -c`` with a non-literal command, ``system()`` /
|
|
24
|
+
``popen()`` on a non-literal).
|
|
25
|
+
|
|
26
|
+
``NSFileManager`` / ``NSURL`` file sinks aren't reported: without a taint
|
|
27
|
+
pass for Objective-C they'd be guesses (precision over recall).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import functools
|
|
33
|
+
import re
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
from attackmap.sdk import iter_repo_files, line_of, read_source, rel
|
|
37
|
+
|
|
38
|
+
from . import apple_surface
|
|
39
|
+
from .analyzer import SKIP_DIRS, _is_test_path
|
|
40
|
+
from .apple_surface import Handler, is_secret_name, js_injection_exprs, js_injection_severity
|
|
41
|
+
from attackmap.sdk import (
|
|
42
|
+
AnalyzerMetadata,
|
|
43
|
+
AppSurfaceIssue,
|
|
44
|
+
CodeWeakness,
|
|
45
|
+
EntrypointHint,
|
|
46
|
+
ScanResult,
|
|
47
|
+
)
|
|
48
|
+
from .swiftsrc import Source, clip
|
|
49
|
+
|
|
50
|
+
OBJC_SUFFIXES = {".m", ".mm"}
|
|
51
|
+
_EVIDENCE_MAX = 200
|
|
52
|
+
# An Objective-C file, not MATLAB/Octave/Mercury `.m`.
|
|
53
|
+
_OBJC_MARKER = re.compile(r"@(?:implementation|interface|protocol|end)\b|^[ \t]*#import\b", re.MULTILINE)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------- C-family masking (strings, chars, comments) and brackets ----------
|
|
57
|
+
|
|
58
|
+
_TOKEN = re.compile(r'//|/\*|@?"|\'|[()\[\]{}]')
|
|
59
|
+
_STRING_BODY = re.compile(r'(?:[^"\\\n]|\\.)*"?')
|
|
60
|
+
_CHAR_BODY = re.compile(r"(?:[^'\\\n]|\\.){0,10}'?")
|
|
61
|
+
_OPEN_TO_CLOSE = {"(": ")", "[": "]", "{": "}"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@functools.lru_cache(maxsize=8)
|
|
65
|
+
def parse(text: str) -> Source:
|
|
66
|
+
src = Source(text)
|
|
67
|
+
stack: list[tuple[str, int]] = []
|
|
68
|
+
pos = 0
|
|
69
|
+
n = len(text)
|
|
70
|
+
while pos < n:
|
|
71
|
+
m = _TOKEN.search(text, pos)
|
|
72
|
+
if m is None:
|
|
73
|
+
break
|
|
74
|
+
tok, start = m.group(0), m.start()
|
|
75
|
+
if tok == "//":
|
|
76
|
+
end = text.find("\n", start)
|
|
77
|
+
end = n if end < 0 else end
|
|
78
|
+
src.masked.append((start, end))
|
|
79
|
+
elif tok == "/*":
|
|
80
|
+
end = text.find("*/", start + 2)
|
|
81
|
+
end = n if end < 0 else end + 2
|
|
82
|
+
src.masked.append((start, end))
|
|
83
|
+
elif tok.endswith('"'):
|
|
84
|
+
end = _STRING_BODY.match(text, m.end()).end()
|
|
85
|
+
src.masked.append((start, end))
|
|
86
|
+
src.strings.append((start, end))
|
|
87
|
+
elif tok == "'":
|
|
88
|
+
end = _CHAR_BODY.match(text, m.end()).end()
|
|
89
|
+
src.masked.append((start, end))
|
|
90
|
+
elif tok in _OPEN_TO_CLOSE:
|
|
91
|
+
stack.append((tok, start))
|
|
92
|
+
end = m.end()
|
|
93
|
+
else:
|
|
94
|
+
for depth in range(len(stack) - 1, max(-1, len(stack) - 8), -1):
|
|
95
|
+
if _OPEN_TO_CLOSE[stack[depth][0]] == tok:
|
|
96
|
+
open_at = stack[depth][1]
|
|
97
|
+
del stack[depth:]
|
|
98
|
+
src.pairs[open_at] = start
|
|
99
|
+
src.rpairs[start] = open_at
|
|
100
|
+
break
|
|
101
|
+
end = m.end()
|
|
102
|
+
pos = max(end, start + 1)
|
|
103
|
+
src._starts = [s for s, _ in src.masked]
|
|
104
|
+
return src
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _enclosing_bracket(src: Source, offset: int) -> tuple[int, int] | None:
|
|
108
|
+
"""The innermost `[...]` message send containing ``offset``."""
|
|
109
|
+
text = src.text
|
|
110
|
+
i = offset
|
|
111
|
+
lo = max(0, offset - 2000)
|
|
112
|
+
while i > lo:
|
|
113
|
+
i -= 1
|
|
114
|
+
ch = text[i]
|
|
115
|
+
if ch in ")]}":
|
|
116
|
+
open_ = src.open_of(i)
|
|
117
|
+
if open_ >= 0:
|
|
118
|
+
i = open_
|
|
119
|
+
continue
|
|
120
|
+
if ch == "[" and not src.in_masked(i):
|
|
121
|
+
close = src.close_of(i)
|
|
122
|
+
if close > offset:
|
|
123
|
+
return (i, close)
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------- Patterns ----------
|
|
128
|
+
|
|
129
|
+
_URL_HANDLER_DEF = re.compile(
|
|
130
|
+
r"^[ \t]*-[ \t]*\([ \t]*(?:BOOL|void)[ \t]*\)[ \t]*(?:application[ \t]*:[^{;]{0,300}?\b(?:openURL|handleOpenURL)[ \t]*:"
|
|
131
|
+
r"|scene[ \t]*:[^{;]{0,200}?\bopenURLContexts[ \t]*:|handleGetURLEvent[ \t]*:)",
|
|
132
|
+
re.MULTILINE,
|
|
133
|
+
)
|
|
134
|
+
_UNIVERSAL_LINK_DEF = re.compile(
|
|
135
|
+
r"^[ \t]*-[ \t]*\([ \t]*BOOL[ \t]*\)[ \t]*application[ \t]*:[^{;]{0,300}?\bcontinueUserActivity[ \t]*:",
|
|
136
|
+
re.MULTILINE,
|
|
137
|
+
)
|
|
138
|
+
_XPC_MACH_LISTENER = re.compile(r"\bNSXPCListener[ \t]+alloc[ \t]*\][ \t]*initWithMachServiceName[ \t]*:")
|
|
139
|
+
_XPC_LOCAL_LISTENER = re.compile(r"\[[ \t]*NSXPCListener[ \t]+(serviceListener|anonymousListener)[ \t]*\]")
|
|
140
|
+
_XPC_ACCEPT = re.compile(
|
|
141
|
+
r"^[ \t]*-[ \t]*\([ \t]*BOOL[ \t]*\)[ \t]*listener[ \t]*:[^{;]{0,200}?\bshouldAcceptNewConnection[ \t]*:"
|
|
142
|
+
r"[ \t]*\([^)\n]{0,80}\)[ \t]*(\w{1,60})[^{;]{0,200}\{",
|
|
143
|
+
re.MULTILINE,
|
|
144
|
+
)
|
|
145
|
+
_RETURN_YES = re.compile(r"\breturn[ \t]+(?:YES|true)\b")
|
|
146
|
+
|
|
147
|
+
_EVAL_JS = re.compile(r"\b(?:evaluateJavaScript|stringByEvaluatingJavaScriptFromString)[ \t]*:[ \t]*\[[ \t]*NSString[ \t]+stringWithFormat[ \t]*:[ \t]*@\"")
|
|
148
|
+
_FORMAT_SPEC = re.compile(r"%(?:%|[-+ #0]{0,4}\d{0,4}(?:\.\d{1,4})?(?:hh|h|ll|l|q|z|t|j|L)?([@dDiuUxXoOfFeEgGcCsSpaA]))")
|
|
149
|
+
|
|
150
|
+
_DEFAULTS_VAR = re.compile(r"\bNSUserDefaults[ \t]*\*[ \t]*(\w{1,60})[ \t]*=")
|
|
151
|
+
_PASTEBOARD_VAR = re.compile(r"\b(?:UIPasteboard|NSPasteboard)[ \t]*\*[ \t]*(\w{1,60})[ \t]*=")
|
|
152
|
+
_SET_FOR_KEY = re.compile(
|
|
153
|
+
r"\[[ \t]*(\[[^\[\]\n]{1,120}\]|[\w.]{1,120})[ \t]+set(?:Object|Value)[ \t]*:[ \t]*"
|
|
154
|
+
r"(\[[^\[\]\n]{1,160}\]|[^\s\[\]]{1,160})[ \t]+forKey[ \t]*:[ \t]*(@\"[^\"\n]{0,120}\"|[\w.]{1,120})[ \t]*\]"
|
|
155
|
+
)
|
|
156
|
+
_PASTEBOARD_SET = re.compile(
|
|
157
|
+
r"(\[[ \t]*(?:UIPasteboard|NSPasteboard)[ \t]+generalPasteboard[ \t]*\]|(?:UIPasteboard|NSPasteboard)\.generalPasteboard|\b\w{1,60})"
|
|
158
|
+
r"(?:[ \t]*\.[ \t]*string[ \t]*=[ \t]*([^;\n]{1,160});|[ \t]+setString[ \t]*:[ \t]*([^\s\]]{1,160}))"
|
|
159
|
+
)
|
|
160
|
+
_DYNAMIC = re.compile(r"\b(NSClassFromString|NSSelectorFromString|dlopen)[ \t]*\(")
|
|
161
|
+
_BUILT_STRING = re.compile(r"stringWithFormat|initWithFormat|stringByAppending|\+[ \t]*@?\"")
|
|
162
|
+
|
|
163
|
+
_FORMAT_CALL = re.compile(
|
|
164
|
+
r"\b(?:stringWithFormat|initWithFormat|appendFormat|stringByAppendingFormat|localizedStringWithFormat)[ \t]*:"
|
|
165
|
+
)
|
|
166
|
+
_NSLOG = re.compile(r"\bNSLog[ \t]*\(")
|
|
167
|
+
_LOCALIZED = re.compile(r"(?i)locali[sz]ed|\bNSLocalizedString|WMFLocalized")
|
|
168
|
+
# `initWithFormat:fmt arguments:va` / `locale:` forward a format the caller built.
|
|
169
|
+
_FORWARDED_FORMAT = re.compile(r"\b(?:arguments|locale)[ \t]*:")
|
|
170
|
+
|
|
171
|
+
_SHELL_PATH = re.compile(r'@"/(?:usr/)?bin/(?:ba|z|k|da)?sh"')
|
|
172
|
+
_SHELL_ARGS = re.compile(r'@\[[ \t]*@"-c"[ \t]*,[ \t]*([^\]\n]{1,200})\]')
|
|
173
|
+
_SYSTEM_CALL = re.compile(r"(?<![\w.])(system|popen)[ \t]*\(")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _first_arg(text: str, open_paren: int, src: Source) -> str | None:
|
|
177
|
+
close = src.close_of(open_paren)
|
|
178
|
+
if close < 0:
|
|
179
|
+
return None
|
|
180
|
+
inner = text[open_paren + 1:close]
|
|
181
|
+
depth = 0
|
|
182
|
+
for i, ch in enumerate(inner):
|
|
183
|
+
if ch in "([{":
|
|
184
|
+
depth += 1
|
|
185
|
+
elif ch in ")]}":
|
|
186
|
+
depth -= 1
|
|
187
|
+
elif ch == "," and depth == 0 and not src.in_masked(open_paren + 1 + i):
|
|
188
|
+
return inner[:i]
|
|
189
|
+
return inner
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _is_literal(expr: str) -> bool:
|
|
193
|
+
return expr.strip().startswith(('@"', '"'))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
_SELECTOR_TAIL = re.compile(r"@selector[ \t]*\([ \t]*$")
|
|
197
|
+
_NULL_ARGS = frozenset({"NULL", "nil", "0", "nullptr", "(char *)NULL", "(const char *)NULL"})
|
|
198
|
+
_NUMBER = re.compile(r"@?\(?-?\d+(?:\.\d+)?\)?[a-zA-Z]{0,3}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _is_constant(expr: str) -> bool:
|
|
202
|
+
"""`@"dark"`, `"x"`, `@42`, `3`: nothing a caller controls."""
|
|
203
|
+
expr = expr.strip()
|
|
204
|
+
return _is_literal(expr) or _NUMBER.fullmatch(expr) is not None or expr in ("YES", "NO", "nil", "NULL")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _objc_bound_sanitized(expr: str, before: str) -> bool:
|
|
208
|
+
"""True when the local ``expr`` was last assigned an encoded value:
|
|
209
|
+
`NSString *term = [raw wmf_stringByEscapingForJavaScript];`."""
|
|
210
|
+
name = expr.strip()
|
|
211
|
+
if not name.isidentifier():
|
|
212
|
+
return False
|
|
213
|
+
at = len(before)
|
|
214
|
+
for _ in range(64):
|
|
215
|
+
at = before.rfind(name, 0, at)
|
|
216
|
+
if at < 0:
|
|
217
|
+
return False
|
|
218
|
+
end = at + len(name)
|
|
219
|
+
prev = before[at - 1] if at > 0 else " "
|
|
220
|
+
if prev.isalnum() or prev == "_" or (end < len(before) and (before[end].isalnum() or before[end] == "_")):
|
|
221
|
+
continue
|
|
222
|
+
k = end
|
|
223
|
+
while k < len(before) and before[k] in " \t":
|
|
224
|
+
k += 1
|
|
225
|
+
if k < len(before) and before[k] == "=" and before[k + 1:k + 2] != "=":
|
|
226
|
+
line_end = before.find("\n", k)
|
|
227
|
+
return bool(apple_surface._SANITIZED.search(before[k:line_end if line_end >= 0 else len(before)]))
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _split_message_args(body: str) -> list[str]:
|
|
232
|
+
"""Top-level comma-separated parts of ``body`` (outside brackets/strings)."""
|
|
233
|
+
parts: list[str] = []
|
|
234
|
+
depth = 0
|
|
235
|
+
start = 0
|
|
236
|
+
in_str = False
|
|
237
|
+
i = 0
|
|
238
|
+
while i < len(body):
|
|
239
|
+
ch = body[i]
|
|
240
|
+
if in_str:
|
|
241
|
+
if ch == "\\":
|
|
242
|
+
i += 2
|
|
243
|
+
continue
|
|
244
|
+
if ch == '"':
|
|
245
|
+
in_str = False
|
|
246
|
+
elif ch == '"':
|
|
247
|
+
in_str = True
|
|
248
|
+
elif ch in "([{":
|
|
249
|
+
depth += 1
|
|
250
|
+
elif ch in ")]}":
|
|
251
|
+
depth -= 1
|
|
252
|
+
elif ch == "," and depth == 0:
|
|
253
|
+
parts.append(body[start:i])
|
|
254
|
+
start = i + 1
|
|
255
|
+
i += 1
|
|
256
|
+
parts.append(body[start:])
|
|
257
|
+
return parts
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _objc_string_value(text: str, at: int) -> tuple[str, int] | None:
|
|
261
|
+
"""(contents, end offset) of the `@"..."` literal starting at ``at``
|
|
262
|
+
(adjacent literals `@"a" @"b"` are concatenated)."""
|
|
263
|
+
pieces: list[str] = []
|
|
264
|
+
pos = at
|
|
265
|
+
n = len(text)
|
|
266
|
+
for _ in range(32):
|
|
267
|
+
while pos < n and text[pos] in " \t\r\n":
|
|
268
|
+
pos += 1
|
|
269
|
+
if text.startswith('@"', pos):
|
|
270
|
+
pos += 2
|
|
271
|
+
elif text.startswith('"', pos) and pieces:
|
|
272
|
+
pos += 1
|
|
273
|
+
else:
|
|
274
|
+
break
|
|
275
|
+
m = _STRING_BODY.match(text, pos)
|
|
276
|
+
end = m.end()
|
|
277
|
+
pieces.append(text[pos:end - 1] if text[end - 1:end] == '"' else text[pos:end])
|
|
278
|
+
pos = end
|
|
279
|
+
return ("".join(pieces), pos) if pieces else None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _quoted_format_args(fmt: str, args: list[str]) -> list[str]:
|
|
283
|
+
"""Arguments of the `%@` specifiers that sit inside a JS string literal."""
|
|
284
|
+
out: list[str] = []
|
|
285
|
+
quote: str | None = None
|
|
286
|
+
index = 0
|
|
287
|
+
i = 0
|
|
288
|
+
n = len(fmt)
|
|
289
|
+
while i < n:
|
|
290
|
+
ch = fmt[i]
|
|
291
|
+
if ch == "\\" and i + 1 < n:
|
|
292
|
+
if fmt[i + 1] == '"':
|
|
293
|
+
quote = None if quote == '"' else (quote or '"')
|
|
294
|
+
i += 2
|
|
295
|
+
continue
|
|
296
|
+
if ch == "%":
|
|
297
|
+
m = _FORMAT_SPEC.match(fmt, i)
|
|
298
|
+
if m is None:
|
|
299
|
+
i += 1
|
|
300
|
+
continue
|
|
301
|
+
conv = m.group(1)
|
|
302
|
+
if conv is not None:
|
|
303
|
+
if conv == "@" and quote is not None and index < len(args):
|
|
304
|
+
out.append(args[index])
|
|
305
|
+
index += 1
|
|
306
|
+
i = m.end()
|
|
307
|
+
continue
|
|
308
|
+
if ch in "'`":
|
|
309
|
+
quote = None if quote == ch else (quote or ch)
|
|
310
|
+
i += 1
|
|
311
|
+
return out
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _value_name(expr: str) -> str | None:
|
|
315
|
+
"""The identifier a value expression ends in: `self.token`, `[user password]`."""
|
|
316
|
+
expr = expr.strip().rstrip(";")
|
|
317
|
+
if expr.startswith("[") and expr.endswith("]"):
|
|
318
|
+
words = expr[1:-1].split()
|
|
319
|
+
return words[-1] if len(words) == 2 and words[-1].isidentifier() else None
|
|
320
|
+
tail = expr.rsplit(".", 1)[-1].strip()
|
|
321
|
+
return tail if tail.isidentifier() else None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ---------- Per-file analysis ----------
|
|
325
|
+
|
|
326
|
+
def analyze_objc_source(rel_path: str, text: str, result: ScanResult, handlers: list[Handler]) -> None:
|
|
327
|
+
src = parse(text)
|
|
328
|
+
|
|
329
|
+
def code(offset: int) -> bool:
|
|
330
|
+
return not src.in_masked(offset)
|
|
331
|
+
|
|
332
|
+
def snippet(offset: int) -> str:
|
|
333
|
+
start = text.rfind("\n", 0, offset) + 1
|
|
334
|
+
end = text.find("\n", offset)
|
|
335
|
+
return clip(text[start:end if end >= 0 else len(text)], _EVIDENCE_MAX)
|
|
336
|
+
|
|
337
|
+
def issue(kind: str, offset: int, severity: str, evidence: str | None = None) -> None:
|
|
338
|
+
result.app_surface_issues.append(AppSurfaceIssue(
|
|
339
|
+
kind=kind, file=rel_path, line=line_of(text, offset), severity=severity, # type: ignore[arg-type]
|
|
340
|
+
evidence_text=evidence or snippet(offset),
|
|
341
|
+
))
|
|
342
|
+
|
|
343
|
+
def weakness(kind: str, offset: int, evidence: str | None = None) -> None:
|
|
344
|
+
result.code_weaknesses.append(CodeWeakness(
|
|
345
|
+
kind=kind, file=rel_path, line=line_of(text, offset), severity="medium", # type: ignore[arg-type]
|
|
346
|
+
confidence="medium", evidence_text=evidence or snippet(offset),
|
|
347
|
+
))
|
|
348
|
+
|
|
349
|
+
# Entry points.
|
|
350
|
+
for m in _URL_HANDLER_DEF.finditer(text):
|
|
351
|
+
if code(m.start()):
|
|
352
|
+
line = line_of(text, m.start())
|
|
353
|
+
handlers.append(Handler("url_scheme_handler", rel_path, line))
|
|
354
|
+
result.entrypoint_hints.append(EntrypointHint(
|
|
355
|
+
hint="url_scheme_handler (opens URLs from other apps)", file=rel_path, line=line,
|
|
356
|
+
evidence_text=snippet(m.start()), confidence=0.9,
|
|
357
|
+
))
|
|
358
|
+
for m in _UNIVERSAL_LINK_DEF.finditer(text):
|
|
359
|
+
if code(m.start()):
|
|
360
|
+
result.entrypoint_hints.append(EntrypointHint(
|
|
361
|
+
hint="universal_link_handler (continueUserActivity)", file=rel_path,
|
|
362
|
+
line=line_of(text, m.start()), evidence_text=snippet(m.start()), confidence=0.8,
|
|
363
|
+
))
|
|
364
|
+
for m in _XPC_MACH_LISTENER.finditer(text):
|
|
365
|
+
if code(m.start()):
|
|
366
|
+
result.entrypoint_hints.append(EntrypointHint(
|
|
367
|
+
hint=apple_surface.XPC_MACH_HINT, file=rel_path, line=line_of(text, m.start()),
|
|
368
|
+
evidence_text=snippet(m.start()), confidence=0.9,
|
|
369
|
+
))
|
|
370
|
+
for m in _XPC_LOCAL_LISTENER.finditer(text):
|
|
371
|
+
if code(m.start()):
|
|
372
|
+
kind = "bundled XPC service" if m.group(1) == "serviceListener" else "anonymous listener"
|
|
373
|
+
result.entrypoint_hints.append(EntrypointHint(
|
|
374
|
+
hint=f"xpc_service: {kind} ([NSXPCListener {m.group(1)}])", file=rel_path,
|
|
375
|
+
line=line_of(text, m.start()), evidence_text=snippet(m.start()), confidence=0.9,
|
|
376
|
+
))
|
|
377
|
+
for m in _XPC_ACCEPT.finditer(text):
|
|
378
|
+
brace = m.end() - 1
|
|
379
|
+
close = src.close_of(brace)
|
|
380
|
+
if close < 0 or not code(brace):
|
|
381
|
+
continue
|
|
382
|
+
body = text[brace:close]
|
|
383
|
+
if _RETURN_YES.search(body) and not apple_surface.xpc_peer_checked(body, m.group(1)):
|
|
384
|
+
issue("apple_xpc_unvalidated_peer", m.start(), "high",
|
|
385
|
+
"listener:shouldAcceptNewConnection: returns YES without checking the peer's audit token or code signature")
|
|
386
|
+
|
|
387
|
+
# WKWebView / UIWebView script from stringWithFormat.
|
|
388
|
+
for m in _EVAL_JS.finditer(text):
|
|
389
|
+
if not code(m.start()):
|
|
390
|
+
continue
|
|
391
|
+
literal = _objc_string_value(text, m.end() - 2)
|
|
392
|
+
if literal is None:
|
|
393
|
+
continue
|
|
394
|
+
fmt, after = literal
|
|
395
|
+
bracket = _enclosing_bracket(src, after)
|
|
396
|
+
if bracket is None:
|
|
397
|
+
continue
|
|
398
|
+
rest = text[after:bracket[1]]
|
|
399
|
+
args = [a.strip() for a in _split_message_args(rest)[1:]] if rest.lstrip().startswith(",") else []
|
|
400
|
+
before = text[max(0, m.start() - 4000):m.start()]
|
|
401
|
+
exprs = [
|
|
402
|
+
e for e in js_injection_exprs(_quoted_format_args(fmt, args), before)
|
|
403
|
+
if not _is_constant(e) and not _objc_bound_sanitized(e, before)
|
|
404
|
+
]
|
|
405
|
+
if exprs:
|
|
406
|
+
issue("apple_webview_js_injection", m.start(), js_injection_severity(exprs),
|
|
407
|
+
clip(f"{snippet(m.start())} [interpolated: {', '.join(exprs[:3])}]", _EVIDENCE_MAX))
|
|
408
|
+
|
|
409
|
+
# NSUserDefaults: a secret-named value or key.
|
|
410
|
+
defaults_vars = {v.group(1) for v in _DEFAULTS_VAR.finditer(text)}
|
|
411
|
+
for m in _SET_FOR_KEY.finditer(text):
|
|
412
|
+
if not code(m.start()):
|
|
413
|
+
continue
|
|
414
|
+
receiver, value, key = m.group(1), m.group(2), m.group(3)
|
|
415
|
+
if "efaults" not in receiver and receiver not in defaults_vars:
|
|
416
|
+
continue
|
|
417
|
+
key_literal = key[2:-1] if key.startswith('@"') else None
|
|
418
|
+
name = _value_name(value)
|
|
419
|
+
if (key_literal and is_secret_name(key_literal)) or (name and is_secret_name(name)):
|
|
420
|
+
issue("apple_insecure_secret_storage", m.start(), "medium")
|
|
421
|
+
|
|
422
|
+
# Pasteboard: a secret-named value.
|
|
423
|
+
pasteboard_vars = {v.group(1) for v in _PASTEBOARD_VAR.finditer(text)}
|
|
424
|
+
for m in _PASTEBOARD_SET.finditer(text):
|
|
425
|
+
if not code(m.start()):
|
|
426
|
+
continue
|
|
427
|
+
target = m.group(1)
|
|
428
|
+
if "Pasteboard" not in target and target not in pasteboard_vars:
|
|
429
|
+
continue
|
|
430
|
+
name = _value_name(m.group(2) or m.group(3) or "")
|
|
431
|
+
if name and is_secret_name(name):
|
|
432
|
+
issue("apple_pasteboard_secret", m.start(), "low")
|
|
433
|
+
|
|
434
|
+
# Dynamic dispatch on a string built at the call site.
|
|
435
|
+
for m in _DYNAMIC.finditer(text):
|
|
436
|
+
if not code(m.start()):
|
|
437
|
+
continue
|
|
438
|
+
arg = _first_arg(text, m.end() - 1, src)
|
|
439
|
+
if arg is not None and _BUILT_STRING.search(arg):
|
|
440
|
+
issue("apple_dynamic_dispatch", m.start(), "medium")
|
|
441
|
+
|
|
442
|
+
# Format strings: a non-literal, non-localized format with nothing to format.
|
|
443
|
+
for m in _FORMAT_CALL.finditer(text):
|
|
444
|
+
if not code(m.start()):
|
|
445
|
+
continue
|
|
446
|
+
bracket = _enclosing_bracket(src, m.start())
|
|
447
|
+
if bracket is None:
|
|
448
|
+
continue
|
|
449
|
+
rest = text[m.end():bracket[1]]
|
|
450
|
+
parts = _split_message_args(rest)
|
|
451
|
+
fmt = parts[0].strip()
|
|
452
|
+
if _SELECTOR_TAIL.search(text, max(0, m.start() - 40), m.start()):
|
|
453
|
+
continue # `@selector(stringWithFormat:)` names the method, it doesn't call it
|
|
454
|
+
if (
|
|
455
|
+
len(parts) == 1 and fmt and not _is_literal(fmt) and not _LOCALIZED.search(fmt)
|
|
456
|
+
and not _FORWARDED_FORMAT.search(rest)
|
|
457
|
+
):
|
|
458
|
+
weakness("format_string", m.start())
|
|
459
|
+
for m in _NSLOG.finditer(text):
|
|
460
|
+
if not code(m.start()):
|
|
461
|
+
continue
|
|
462
|
+
close = src.close_of(m.end() - 1)
|
|
463
|
+
if close < 0:
|
|
464
|
+
continue
|
|
465
|
+
parts = _split_message_args(text[m.end():close])
|
|
466
|
+
fmt = parts[0].strip()
|
|
467
|
+
if len(parts) == 1 and fmt and not _is_literal(fmt) and not _LOCALIZED.search(fmt):
|
|
468
|
+
weakness("format_string", m.start())
|
|
469
|
+
|
|
470
|
+
# Shell commands built at runtime.
|
|
471
|
+
for m in _SHELL_ARGS.finditer(text):
|
|
472
|
+
if not code(m.start()):
|
|
473
|
+
continue
|
|
474
|
+
command = m.group(1).split(",", 1)[0].strip()
|
|
475
|
+
window = text[max(0, m.start() - 1500):m.end() + 300]
|
|
476
|
+
if command and not _is_literal(command) and _SHELL_PATH.search(window):
|
|
477
|
+
weakness("native_command_exec", m.start())
|
|
478
|
+
for m in _SYSTEM_CALL.finditer(text):
|
|
479
|
+
if not code(m.start()):
|
|
480
|
+
continue
|
|
481
|
+
arg = _first_arg(text, m.end() - 1, src)
|
|
482
|
+
if arg is not None and arg.strip() and not _is_literal(arg) and arg.strip() not in _NULL_ARGS:
|
|
483
|
+
weakness("native_command_exec", m.start()) # `system(NULL)` only asks for a shell
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def looks_like_objc(text: str) -> bool:
|
|
487
|
+
return _OBJC_MARKER.search(text) is not None
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class ObjCAnalyzer:
|
|
491
|
+
metadata = AnalyzerMetadata(
|
|
492
|
+
name="objc",
|
|
493
|
+
display_name="Objective-C Analyzer",
|
|
494
|
+
version="0.2.0",
|
|
495
|
+
description="Objective-C / Objective-C++ analyzer: the iOS/macOS app attack surface (ATS, URL schemes, entitlements, WKWebView, NSUserDefaults, XPC), format strings, shell commands and dynamic dispatch.",
|
|
496
|
+
scope="Objective-C and Objective-C++ (.m/.mm) iOS/macOS apps and frameworks, with their Info.plist, entitlements, xcconfig and Carthage files.",
|
|
497
|
+
targets=["ios", "macos", "cocoa"],
|
|
498
|
+
languages=["objective-c", "objective-c++"],
|
|
499
|
+
priority=21, # after `swift`, which wins a duplicate project-file signal
|
|
500
|
+
experimental=True,
|
|
501
|
+
enabled_by_default=False, # opt-in via `-m objc` while experimental
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
@property
|
|
505
|
+
def name(self) -> str:
|
|
506
|
+
return self.metadata.name
|
|
507
|
+
|
|
508
|
+
def detect(self, repo_path: str | Path) -> bool:
|
|
509
|
+
root = Path(repo_path).resolve()
|
|
510
|
+
if not root.exists() or not root.is_dir():
|
|
511
|
+
return False
|
|
512
|
+
checked = 0
|
|
513
|
+
for path in iter_repo_files(root, suffixes=OBJC_SUFFIXES, skip_dirs=SKIP_DIRS):
|
|
514
|
+
text = read_source(path)
|
|
515
|
+
if text is not None and looks_like_objc(text):
|
|
516
|
+
return True
|
|
517
|
+
checked += 1
|
|
518
|
+
if checked >= 64:
|
|
519
|
+
break
|
|
520
|
+
return False
|
|
521
|
+
|
|
522
|
+
def analyze(self, repo_path: str | Path) -> ScanResult:
|
|
523
|
+
root = Path(repo_path).resolve()
|
|
524
|
+
result = ScanResult(root=str(root))
|
|
525
|
+
if not root.exists() or not root.is_dir():
|
|
526
|
+
return result
|
|
527
|
+
apple_surface.analyze_project_files(root, result, SKIP_DIRS, is_test=_is_test_path)
|
|
528
|
+
handlers: list[Handler] = []
|
|
529
|
+
for path in iter_repo_files(root, suffixes=OBJC_SUFFIXES, skip_dirs=SKIP_DIRS):
|
|
530
|
+
rel_path = rel(path, root)
|
|
531
|
+
if _is_test_path(rel_path):
|
|
532
|
+
continue
|
|
533
|
+
text = read_source(path)
|
|
534
|
+
if text is None or not looks_like_objc(text):
|
|
535
|
+
continue
|
|
536
|
+
result.files_scanned += 1
|
|
537
|
+
analyze_objc_source(rel_path, text, result, handlers)
|
|
538
|
+
apple_surface.report_url_schemes(result, handlers)
|
|
539
|
+
apple_surface.finalize_xpc(result)
|
|
540
|
+
if result.files_scanned:
|
|
541
|
+
result.languages.append("objective-c")
|
|
542
|
+
return result
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
__all__ = ["ObjCAnalyzer", "analyze_objc_source", "looks_like_objc", "parse"]
|