narvy-cli 1.0.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.
- narvy/__init__.py +3 -0
- narvy/android/__init__.py +0 -0
- narvy/android/source_analyzer.py +268 -0
- narvy/android/split_bundle.py +475 -0
- narvy/android_rule_context.py +196 -0
- narvy/apk_memory_preflight.py +248 -0
- narvy/auth.py +40 -0
- narvy/ci_templates/bitbucket.yml +25 -0
- narvy/ci_templates/github.yml +60 -0
- narvy/ci_templates/gitlab.yml +32 -0
- narvy/cloud/__init__.py +0 -0
- narvy/cloud/aws_scan.py +1188 -0
- narvy/comment_filter.py +134 -0
- narvy/crypto_taint_lite.py +82 -0
- narvy/decompiler.py +337 -0
- narvy/doctor.py +244 -0
- narvy/host/__init__.py +0 -0
- narvy/host/audit.py +157 -0
- narvy/host/host_knowledge.py +982 -0
- narvy/host/lynis_bootstrap.py +400 -0
- narvy/host/lynis_parser.py +358 -0
- narvy/host/narvy_checks.py +789 -0
- narvy/host/report.py +69 -0
- narvy/host/ssh_exec.py +219 -0
- narvy/ios/__init__.py +0 -0
- narvy/ios/binary_analyzer.py +1201 -0
- narvy/ios/plist_checks.py +249 -0
- narvy/ios/source_analyzer.py +301 -0
- narvy/ios/third_party_filter.py +242 -0
- narvy/ios/trust_all_context.py +66 -0
- narvy/main.py +1983 -0
- narvy/native_hardening.py +503 -0
- narvy/reporter.py +151 -0
- narvy/rule_engine.py +57 -0
- narvy/rules/android/config.yml +77 -0
- narvy/rules/android/crypto.yml +34 -0
- narvy/rules/android/secrets.yml +161 -0
- narvy/rules/android/storage.yml +24 -0
- narvy/rules/android/webview.yml +23 -0
- narvy/rules/android.yml +219 -0
- narvy/rules/ios/objc/crypto.yml +56 -0
- narvy/rules/ios/objc/network.yml +67 -0
- narvy/rules/ios/objc/secrets.yml +79 -0
- narvy/rules/ios/objc/storage.yml +45 -0
- narvy/rules/ios/objc/webview.yml +45 -0
- narvy/rules/ios_swift.yml +327 -0
- narvy/rules/web/go.yml +2837 -0
- narvy/rules/web/java.yml +1576 -0
- narvy/rules/web/javascript.yml +3683 -0
- narvy/rules/web/kotlin.yml +413 -0
- narvy/rules/web/local/csharp_narvy/config.yml +61 -0
- narvy/rules/web/local/csharp_narvy/crypto.yml +64 -0
- narvy/rules/web/local/csharp_narvy/deserialization.yml +59 -0
- narvy/rules/web/local/csharp_narvy/injection.yml +122 -0
- narvy/rules/web/local/csharp_narvy/xxe.yml +48 -0
- narvy/rules/web/local/java_narvy/auth_jwt.yml +134 -0
- narvy/rules/web/local/java_narvy/deserialization.yml +108 -0
- narvy/rules/web/local/java_narvy/mybatis.yml +39 -0
- narvy/rules/web/local/java_narvy/snakeyaml.yml +34 -0
- narvy/rules/web/local/java_narvy/spring_authz.yml +32 -0
- narvy/rules/web/local/java_narvy/spring_config.yml +67 -0
- narvy/rules/web/local/java_narvy/spring_hardening.yml +291 -0
- narvy/rules/web/local/java_narvy/sqli.yml +235 -0
- narvy/rules/web/local/java_narvy/xxe.yml +212 -0
- narvy/rules/web/php.yml +1644 -0
- narvy/rules/web/python.yml +3967 -0
- narvy/rules/web/ruby.yml +703 -0
- narvy/rules/web/rust.yml +258 -0
- narvy/rules/web/secrets.yml +1420 -0
- narvy/rules/web/secrets_supplement.yml +383 -0
- narvy/sca/__init__.py +1 -0
- narvy/sca/android_deps.py +349 -0
- narvy/sca/ios_deps.py +578 -0
- narvy/sca/osv_client.py +617 -0
- narvy/sca/web_deps.py +955 -0
- narvy/scope_config.py +195 -0
- narvy/semgrep_engine.py +219 -0
- narvy/stack_protector_evidence.py +96 -0
- narvy/third_party_filter.py +211 -0
- narvy/uploader.py +92 -0
- narvy/weak_prng_context.py +270 -0
- narvy/web/__init__.py +0 -0
- narvy/web/nuclei_binary.py +105 -0
- narvy/web/scan_blocklist.py +96 -0
- narvy/web/scanner.py +842 -0
- narvy/web/source_analyzer.py +714 -0
- narvy/web/ssrf_guard.py +374 -0
- narvy_cli-1.0.0.dist-info/METADATA +165 -0
- narvy_cli-1.0.0.dist-info/RECORD +93 -0
- narvy_cli-1.0.0.dist-info/WHEEL +5 -0
- narvy_cli-1.0.0.dist-info/entry_points.txt +2 -0
- narvy_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
- narvy_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1201 @@
|
|
|
1
|
+
"""iOS IPA binary-mode SAST: symbol, string and Mach-O metadata checks on the
|
|
2
|
+
main executable and on first-party embedded frameworks.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import struct
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import plistlib
|
|
12
|
+
import zipfile
|
|
13
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import lief
|
|
17
|
+
LIEF_AVAILABLE = True
|
|
18
|
+
except ImportError:
|
|
19
|
+
LIEF_AVAILABLE = False
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import icdump # noqa: F401
|
|
23
|
+
ICDUMP_AVAILABLE = True
|
|
24
|
+
except ImportError:
|
|
25
|
+
ICDUMP_AVAILABLE = False
|
|
26
|
+
|
|
27
|
+
from .third_party_filter import (
|
|
28
|
+
classify_ios_framework,
|
|
29
|
+
check_ios_override,
|
|
30
|
+
get_own_bundle_id,
|
|
31
|
+
TIER_FIRST_PARTY,
|
|
32
|
+
TIER_THIRD_PARTY,
|
|
33
|
+
TIER_UNCONFIRMED,
|
|
34
|
+
)
|
|
35
|
+
from ..scope_config import ScopeConfig
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def icdump_available() -> bool:
|
|
39
|
+
return ICDUMP_AVAILABLE
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# icdump.objc.parse() is a native extension that can SIGSEGV on some binaries,
|
|
43
|
+
# with no catchable Python exception, so it always runs out of process.
|
|
44
|
+
_ICDUMP_PROBE_SCRIPT = (
|
|
45
|
+
"import sys, json, icdump\n"
|
|
46
|
+
"md = icdump.objc.parse(sys.argv[1])\n"
|
|
47
|
+
"print(json.dumps({'classes': len(md.classes), "
|
|
48
|
+
"'methods': sum(len(c.methods) for c in md.classes)}))\n"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _icdump_class_count(binary_path: str, timeout: int = 60
|
|
53
|
+
) -> Tuple[bool, int, int, Optional[str]]:
|
|
54
|
+
"""Returns (ok, n_classes, n_methods, error)."""
|
|
55
|
+
import sys as _sys
|
|
56
|
+
try:
|
|
57
|
+
proc = subprocess.run(
|
|
58
|
+
[_sys.executable, "-c", _ICDUMP_PROBE_SCRIPT, binary_path],
|
|
59
|
+
capture_output=True, text=True, timeout=timeout,
|
|
60
|
+
)
|
|
61
|
+
except subprocess.TimeoutExpired:
|
|
62
|
+
return False, 0, 0, f"timed out after {timeout}s"
|
|
63
|
+
except Exception as e:
|
|
64
|
+
return False, 0, 0, str(e)
|
|
65
|
+
if proc.returncode < 0:
|
|
66
|
+
return False, 0, 0, f"crashed (signal {-proc.returncode})"
|
|
67
|
+
if proc.returncode != 0:
|
|
68
|
+
detail = (proc.stderr or proc.stdout or "").strip().splitlines()
|
|
69
|
+
return False, 0, 0, (detail[-1] if detail else f"exit code {proc.returncode}")
|
|
70
|
+
try:
|
|
71
|
+
import json as _json
|
|
72
|
+
data = _json.loads(proc.stdout.strip().splitlines()[-1])
|
|
73
|
+
return True, int(data["classes"]), int(data["methods"]), None
|
|
74
|
+
except Exception as e:
|
|
75
|
+
return False, 0, 0, f"could not parse subprocess output: {e}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_RULES: Dict[str, Dict[str, Any]] = {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _register(rule_id: str, name: str, severity: str, cwe: str, masvs: str,
|
|
82
|
+
description: str, recommendation: str) -> None:
|
|
83
|
+
_RULES[rule_id] = {
|
|
84
|
+
"id": rule_id,
|
|
85
|
+
"name": name,
|
|
86
|
+
"severity": severity,
|
|
87
|
+
"details": {
|
|
88
|
+
"cwe": cwe,
|
|
89
|
+
"masvs": masvs,
|
|
90
|
+
"description": description,
|
|
91
|
+
"recommendation": recommendation,
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _finding(rule_id: str, file_path: str, description: Optional[str] = None,
|
|
97
|
+
name: Optional[str] = None, severity: Optional[str] = None,
|
|
98
|
+
location_symbol: Optional[str] = None) -> Dict[str, Any]:
|
|
99
|
+
"""`line` is a placeholder: a release binary carries no line-number table."""
|
|
100
|
+
r = _RULES[rule_id]
|
|
101
|
+
details = dict(r["details"])
|
|
102
|
+
if description:
|
|
103
|
+
details["description"] = description
|
|
104
|
+
f: Dict[str, Any] = {
|
|
105
|
+
"rule_id": rule_id,
|
|
106
|
+
"file_path": file_path,
|
|
107
|
+
"name": name or r["name"],
|
|
108
|
+
"severity": severity or r["severity"],
|
|
109
|
+
"line": 1,
|
|
110
|
+
"engine": "binary",
|
|
111
|
+
"details": details,
|
|
112
|
+
}
|
|
113
|
+
if location_symbol:
|
|
114
|
+
f["location_symbol"] = location_symbol
|
|
115
|
+
return f
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def all_rule_defs() -> List[Dict[str, Any]]:
|
|
119
|
+
return list(_RULES.values())
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_register("IOS-BIN-CRYPTO-001", "Weak Cryptographic Algorithm", "HIGH", "CWE-327",
|
|
123
|
+
"MSTG-CRYPTO-4",
|
|
124
|
+
"Binary imports/uses a weak cryptographic algorithm (DES/3DES/RC2/RC4/ECB mode).",
|
|
125
|
+
"Use AES-256 in GCM or CBC (with a random IV) mode instead.")
|
|
126
|
+
_register("IOS-BIN-CRYPTO-002", "Weak Hash Algorithm", "HIGH", "CWE-328",
|
|
127
|
+
"MSTG-CRYPTO-4",
|
|
128
|
+
"Binary imports/uses a weak or broken hash algorithm (MD2/MD4/MD5/SHA1).",
|
|
129
|
+
"Replace with SHA-256 or stronger.")
|
|
130
|
+
_register("IOS-BIN-CRYPTO-003", "Non-Cryptographic RNG API Linked", "INFO", "CWE-338",
|
|
131
|
+
"MSTG-CRYPTO-6",
|
|
132
|
+
"Binary links a non-cryptographic RNG API (rand/srand/random) and also links "
|
|
133
|
+
"cryptographic APIs. INFORMATIONAL ONLY: this is symbol co-presence, not "
|
|
134
|
+
"evidence of insecure key/nonce generation - establishing that needs call-site "
|
|
135
|
+
"analysis this binary-mode scanner does not perform. Statically linked zlib/"
|
|
136
|
+
"ffmpeg/sqlite pull in rand() on their own.",
|
|
137
|
+
"No action required on this finding alone. If you do generate keys, tokens, IVs "
|
|
138
|
+
"or nonces, confirm those specific call sites use SecRandomCopyBytes (or "
|
|
139
|
+
"arc4random_buf), not rand()/random().")
|
|
140
|
+
|
|
141
|
+
_register("IOS-BIN-SEC-001", "Missing PIE (Position Independent Executable)", "HIGH",
|
|
142
|
+
"CWE-693", "MSTG-CODE-9",
|
|
143
|
+
"Binary is not compiled as a Position Independent Executable.",
|
|
144
|
+
"Enable PIE (default in Xcode; verify no -Wl,-no_pie / legacy build setting).")
|
|
145
|
+
_register("IOS-BIN-SEC-002", "Missing Stack Canary", "HIGH", "CWE-693", "MSTG-CODE-9",
|
|
146
|
+
"Native (C/Objective-C) code in this binary is compiled without stack-smashing "
|
|
147
|
+
"protection (___stack_chk_guard/___stack_chk_fail absent).",
|
|
148
|
+
"Build with -fstack-protector-all (Xcode default; verify custom build flags "
|
|
149
|
+
"haven't disabled it).")
|
|
150
|
+
_register("IOS-BIN-SEC-003", "Missing ARC (Automatic Reference Counting)", "MEDIUM",
|
|
151
|
+
"CWE-401", "MSTG-CODE-9",
|
|
152
|
+
"Objective-C code in this binary appears to be compiled without ARC "
|
|
153
|
+
"(objc_retain/objc_release symbols absent despite custom Objective-C classes).",
|
|
154
|
+
"Enable ARC (CLANG_ENABLE_OBJC_ARC = YES) for automatic memory management and "
|
|
155
|
+
"use-after-free prevention.")
|
|
156
|
+
_register("IOS-BIN-SEC-004", "Binary Missing Code Signature", "HIGH", "CWE-345",
|
|
157
|
+
"MSTG-CODE-9",
|
|
158
|
+
"No LC_CODE_SIGNATURE load command found - this binary is not code-signed.",
|
|
159
|
+
"Ensure the IPA is built and exported for distribution (ad-hoc/App Store) with "
|
|
160
|
+
"a valid signing identity; iOS refuses to run unsigned binaries on real devices.")
|
|
161
|
+
|
|
162
|
+
_register("IOS-BIN-SENSITIVE-001", "Hardcoded Credential in Binary", "HIGH", "CWE-798",
|
|
163
|
+
"MSTG-STORAGE-1",
|
|
164
|
+
"A binary string looks like a hardcoded password/API key/secret-key assignment.",
|
|
165
|
+
"Remove hardcoded credentials; use Keychain for anything secret at runtime, and "
|
|
166
|
+
"fetch API keys from a backend/config service instead of compiling them in.")
|
|
167
|
+
_register("IOS-BIN-SENSITIVE-002", "SQL Query Built with String Formatting", "HIGH",
|
|
168
|
+
"CWE-89", "MSTG-CODE-8",
|
|
169
|
+
"A compiled-in SQL statement contains a format-string placeholder "
|
|
170
|
+
"(SELECT/INSERT/UPDATE/DELETE ... %), consistent with a query assembled by "
|
|
171
|
+
"string interpolation rather than parameter binding.",
|
|
172
|
+
"Use parameterized queries / prepared statements (sqlite3_bind_*, NSPredicate, "
|
|
173
|
+
"or an ORM) instead of formatting SQL text.")
|
|
174
|
+
_register("IOS-BIN-SENSITIVE-003", "Sensitive Data Logged", "MEDIUM", "CWE-532",
|
|
175
|
+
"MSTG-STORAGE-3",
|
|
176
|
+
"A compiled-in NSLog format string references password/token/secret, "
|
|
177
|
+
"consistent with sensitive data being written to the device console log.",
|
|
178
|
+
"Remove sensitive values from log statements, or strip logging in release "
|
|
179
|
+
"builds.")
|
|
180
|
+
_register("IOS-BIN-SENSITIVE-004", "Insecure Deserialization API", "MEDIUM", "CWE-502",
|
|
181
|
+
"MSTG-PLATFORM-8",
|
|
182
|
+
"Binary references the legacy NSKeyedUnarchiver "
|
|
183
|
+
"unarchiveObjectWithData:/unarchiveObjectWithFile: selectors, which decode "
|
|
184
|
+
"without a class allowlist unless the caller has opted into secure coding.",
|
|
185
|
+
"Use unarchivedObjectOfClasses:fromData:error: (NSSecureCoding) instead of the "
|
|
186
|
+
"legacy unarchiveObjectWithData:/unarchiveObjectWithFile: APIs.")
|
|
187
|
+
|
|
188
|
+
_register("IOS-BIN-API-001", "Insecure Keychain Accessibility (Always)", "HIGH", "CWE-522",
|
|
189
|
+
"MSTG-STORAGE-1",
|
|
190
|
+
"Binary references kSecAttrAccessibleAlways, which keeps Keychain items "
|
|
191
|
+
"readable even when the device is locked AND lets them sync to iCloud Keychain "
|
|
192
|
+
"and unencrypted backups (they leave the device).",
|
|
193
|
+
"Use kSecAttrAccessibleWhenUnlockedThisDeviceOnly (or "
|
|
194
|
+
"kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly for background needs).")
|
|
195
|
+
_register("IOS-BIN-API-006", "Keychain Accessibility Always (Device-Bound)", "MEDIUM",
|
|
196
|
+
"CWE-522", "MSTG-STORAGE-1",
|
|
197
|
+
"Binary references kSecAttrAccessibleAlwaysThisDeviceOnly: the item is readable "
|
|
198
|
+
"even while the device is locked. It is device-bound (never synced to iCloud "
|
|
199
|
+
"Keychain, never restored to another device from a backup), so the risk is "
|
|
200
|
+
"materially lower than the plain kSecAttrAccessibleAlways it is often confused "
|
|
201
|
+
"with, but a locked/stolen device still exposes it.",
|
|
202
|
+
"Prefer kSecAttrAccessibleWhenUnlockedThisDeviceOnly, or "
|
|
203
|
+
"kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly if a background task genuinely "
|
|
204
|
+
"needs the item before first unlock.")
|
|
205
|
+
_register("IOS-BIN-API-002", "Deprecated Insecure WebView (UIWebView)", "HIGH", "CWE-79",
|
|
206
|
+
"MSTG-PLATFORM-2",
|
|
207
|
+
"Binary links against the deprecated, insecure UIWebView (Apple has rejected "
|
|
208
|
+
"App Store submissions using it since Dec 2020).",
|
|
209
|
+
"Migrate to WKWebView.")
|
|
210
|
+
_register("IOS-BIN-API-003", "No File Protection on Written Data", "HIGH", "CWE-311",
|
|
211
|
+
"MSTG-STORAGE-1",
|
|
212
|
+
"Binary references NSDataWritingFileProtectionNone, explicitly disabling "
|
|
213
|
+
"iOS Data Protection encryption for the written file.",
|
|
214
|
+
"Use NSDataWritingFileProtectionComplete (or "
|
|
215
|
+
"CompleteUnlessOpen/CompleteUntilFirstUserAuthentication as appropriate).")
|
|
216
|
+
_register("IOS-BIN-API-004", "Deprecated Networking API (NSURLConnection)", "MEDIUM",
|
|
217
|
+
"CWE-477", "MSTG-NETWORK-1",
|
|
218
|
+
"Binary references the deprecated NSURLConnection API (deprecated iOS 9+).",
|
|
219
|
+
"Migrate to URLSession.")
|
|
220
|
+
_register("IOS-BIN-API-005", "Deprecated Low-Level SSL/TLS API", "MEDIUM", "CWE-327",
|
|
221
|
+
"MSTG-NETWORK-2",
|
|
222
|
+
"Binary references the deprecated Secure Transport SSLCreateContext API.",
|
|
223
|
+
"Use URLSession/Network.framework, which negotiate current TLS versions and "
|
|
224
|
+
"handle certificate validation correctly by default.")
|
|
225
|
+
|
|
226
|
+
_register("IOS-BIN-RASP-001", "No Runtime Application Self-Protection Detected", "MEDIUM",
|
|
227
|
+
"CWE-693", "MSTG-RESILIENCE-1",
|
|
228
|
+
"No commercial RASP SDK and no anti-tampering signal (ptrace/jailbreak/"
|
|
229
|
+
"Frida-detection strings) found in this binary.",
|
|
230
|
+
"For high-risk apps (banking, health, payments) consider a RASP SDK "
|
|
231
|
+
"(commercial: Zimperium/Guardsquare/Digital.ai/Promon, or open-source "
|
|
232
|
+
"Talsec freeRASP) plus basic ptrace/jailbreak/Frida-detection.")
|
|
233
|
+
|
|
234
|
+
_register("IOS-BIN-URL-001", "Custom URL Scheme Registered", "MEDIUM", "CWE-284",
|
|
235
|
+
"MSTG-PLATFORM-3",
|
|
236
|
+
"App registers a custom (non-system) URL scheme. Any other app can register "
|
|
237
|
+
"the same scheme and potentially hijack deep links.",
|
|
238
|
+
"Prefer Universal Links (https://) for deep linking; if a custom scheme is "
|
|
239
|
+
"required, validate the calling app via options[.sourceApplication].")
|
|
240
|
+
_register("IOS-BIN-URL-002", "Custom URL Schemes Without Universal Links", "MEDIUM",
|
|
241
|
+
"CWE-284", "MSTG-PLATFORM-3",
|
|
242
|
+
"App declares custom URL scheme(s) but its entitlements carry no "
|
|
243
|
+
"applinks: associated domain, so it has no Universal Links fallback.",
|
|
244
|
+
"Implement Universal Links (com.apple.developer.associated-domains "
|
|
245
|
+
"entitlement + apple-app-site-association on your domain).")
|
|
246
|
+
_register("IOS-BIN-URL-003", "App Transport Security Disabled", "HIGH", "CWE-319",
|
|
247
|
+
"MSTG-NETWORK-2",
|
|
248
|
+
"NSAllowsArbitraryLoads is true in Info.plist - ATS is disabled app-wide, "
|
|
249
|
+
"permitting cleartext HTTP to any host.",
|
|
250
|
+
"Remove NSAllowsArbitraryLoads (or set it false); use per-domain exceptions "
|
|
251
|
+
"only where a real third-party requirement forces cleartext.")
|
|
252
|
+
_register("IOS-BIN-URL-004", "ATS Insecure HTTP Exception for Domain", "MEDIUM",
|
|
253
|
+
"CWE-319", "MSTG-NETWORK-2",
|
|
254
|
+
"NSExceptionAllowsInsecureHTTPLoads is true for a specific domain in "
|
|
255
|
+
"NSAppTransportSecurity.",
|
|
256
|
+
"Use HTTPS for this domain; remove the exception once the backend supports TLS.")
|
|
257
|
+
_register("IOS-BIN-URL-005", "ATS Insecure HTTP for All Subdomains", "HIGH", "CWE-319",
|
|
258
|
+
"MSTG-NETWORK-2",
|
|
259
|
+
"NSIncludesSubdomains + NSExceptionAllowsInsecureHTTPLoads together allow "
|
|
260
|
+
"cleartext HTTP to every subdomain of the exception domain.",
|
|
261
|
+
"Scope the exception to the exact subdomain that needs it; do not combine "
|
|
262
|
+
"NSIncludesSubdomains with an insecure-HTTP exception.")
|
|
263
|
+
_register("IOS-BIN-URL-006", "Querying Sensitive App URL Schemes", "MEDIUM", "CWE-200",
|
|
264
|
+
"MSTG-PLATFORM-3",
|
|
265
|
+
"LSApplicationQueriesSchemes includes schemes suggestive of banking/health/"
|
|
266
|
+
"finance/auth apps, which can be used to fingerprint installed apps.",
|
|
267
|
+
"Only query schemes that are essential for a real integration; avoid using "
|
|
268
|
+
"canOpenURL as an app-presence fingerprinting signal.")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
_STRINGS_BIN = shutil.which("strings")
|
|
272
|
+
|
|
273
|
+
# Pure-Python fallback for `strings -a`: runs of >=4 printable ASCII bytes.
|
|
274
|
+
_PRINTABLE_RUN_RE = re.compile(rb"[\x20-\x7e]{4,}")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _get_strings(binary_path: str) -> List[str]:
|
|
278
|
+
"""ASCII strings in the file. Never raises: returns [] on any failure."""
|
|
279
|
+
if _STRINGS_BIN:
|
|
280
|
+
try:
|
|
281
|
+
out = subprocess.check_output(
|
|
282
|
+
[_STRINGS_BIN, "-a", binary_path],
|
|
283
|
+
stderr=subprocess.DEVNULL, timeout=60,
|
|
284
|
+
)
|
|
285
|
+
return out.decode("utf-8", errors="ignore").split("\n")
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
try:
|
|
289
|
+
with open(binary_path, "rb") as f:
|
|
290
|
+
data = f.read()
|
|
291
|
+
return [m.decode("ascii") for m in _PRINTABLE_RUN_RE.findall(data)]
|
|
292
|
+
except OSError:
|
|
293
|
+
return []
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class ParsedMachO:
|
|
297
|
+
"""Keeps a Mach-O slice and the parent FatBinary it was yielded from alive
|
|
298
|
+
together: a slice whose FatBinary is collected dangles and segfaults."""
|
|
299
|
+
|
|
300
|
+
__slots__ = ("_fat", "binary")
|
|
301
|
+
|
|
302
|
+
def __init__(self, fat, binary):
|
|
303
|
+
self._fat = fat
|
|
304
|
+
self.binary = binary
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _pick_best_slice(binary_path: str) -> Optional[ParsedMachO]:
|
|
308
|
+
"""Return the best single-arch slice of a Mach-O, arm64 first, or None."""
|
|
309
|
+
if not LIEF_AVAILABLE:
|
|
310
|
+
return None
|
|
311
|
+
try:
|
|
312
|
+
fat = lief.MachO.parse(binary_path)
|
|
313
|
+
except Exception:
|
|
314
|
+
return None
|
|
315
|
+
if not fat or len(fat) == 0:
|
|
316
|
+
return None
|
|
317
|
+
best = None
|
|
318
|
+
best_rank = -1
|
|
319
|
+
for b in fat:
|
|
320
|
+
try:
|
|
321
|
+
cpu = str(b.header.cpu_type)
|
|
322
|
+
except Exception:
|
|
323
|
+
cpu = ""
|
|
324
|
+
rank = 3 if "ARM64" in cpu else 2 if "ARM" in cpu else 1 if "X86_64" in cpu else 0
|
|
325
|
+
if rank > best_rank:
|
|
326
|
+
best, best_rank = b, rank
|
|
327
|
+
if best is None:
|
|
328
|
+
return None
|
|
329
|
+
return ParsedMachO(fat, best)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _symbol_names(parsed: Optional["ParsedMachO"]) -> List[str]:
|
|
333
|
+
if parsed is None:
|
|
334
|
+
return []
|
|
335
|
+
try:
|
|
336
|
+
return [s.name for s in parsed.binary.symbols if getattr(s, "name", None)]
|
|
337
|
+
except Exception:
|
|
338
|
+
return []
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# Mach-O nlist n_type bit fields (<mach-o/nlist.h>), read off `Symbol.raw_type`:
|
|
342
|
+
# LIEF's `Symbol.type` enum masks N_TYPE without first stripping the N_STAB bits.
|
|
343
|
+
_N_STAB = 0xe0
|
|
344
|
+
_N_TYPE = 0x0e
|
|
345
|
+
_N_UNDF = 0x00
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _symbol_is_defined(sym) -> bool:
|
|
349
|
+
"""True if this Mach-O defines `sym`, rather than importing or debug-listing it."""
|
|
350
|
+
raw = getattr(sym, "raw_type", None)
|
|
351
|
+
if raw is not None:
|
|
352
|
+
try:
|
|
353
|
+
raw = int(raw)
|
|
354
|
+
if raw & _N_STAB:
|
|
355
|
+
return False
|
|
356
|
+
return (raw & _N_TYPE) != _N_UNDF
|
|
357
|
+
except (TypeError, ValueError):
|
|
358
|
+
pass
|
|
359
|
+
try:
|
|
360
|
+
return not (bool(getattr(sym, "is_external", False))
|
|
361
|
+
and int(getattr(sym, "value", 0)) == 0)
|
|
362
|
+
except Exception:
|
|
363
|
+
return True
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _defined_symbol_names(parsed: Optional["ParsedMachO"]) -> List[str]:
|
|
367
|
+
if parsed is None:
|
|
368
|
+
return []
|
|
369
|
+
try:
|
|
370
|
+
return [s.name for s in parsed.binary.symbols
|
|
371
|
+
if getattr(s, "name", None) and _symbol_is_defined(s)]
|
|
372
|
+
except Exception:
|
|
373
|
+
return []
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# A Swift class exposed to the ObjC runtime gets an _OBJC_CLASS_$__TtC... symbol
|
|
377
|
+
# emitted by the Swift compiler, which is not hand-written Objective-C.
|
|
378
|
+
_SWIFT_OBJC_THUNK_MARKER = "_TtC"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _has_custom_objc_classes(defined_symbol_names: List[str]) -> bool:
|
|
382
|
+
skip = ("NSObject", "NSString", "NSArray", "NSDictionary", "NSNumber",
|
|
383
|
+
"NSData", "NSError", "NSException", "NSValue", "NSURL", "NSDate",
|
|
384
|
+
"NSSet", "PodsDummy_", "Swift")
|
|
385
|
+
return any(
|
|
386
|
+
"_OBJC_CLASS_$_" in n
|
|
387
|
+
and _SWIFT_OBJC_THUNK_MARKER not in n
|
|
388
|
+
and not any(s in n for s in skip)
|
|
389
|
+
for n in defined_symbol_names
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _is_swift(symbol_names: List[str]) -> bool:
|
|
394
|
+
return any(n.startswith("_$s") or n.startswith("$s") for n in symbol_names)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _has_arc_symbols(symbol_names: List[str]) -> bool:
|
|
398
|
+
return any(n in symbol_names for n in
|
|
399
|
+
("_objc_release", "_objc_retain", "_objc_autoreleaseReturnValue"))
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _has_canary_symbols(symbol_names: List[str]) -> bool:
|
|
403
|
+
return any(n in symbol_names for n in ("___stack_chk_fail", "___stack_chk_guard"))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
_WEAK_CRYPTO_ALGOS = {
|
|
407
|
+
"kCCAlgorithmDES": ("DES", "HIGH"), "CCAlgorithmDES": ("DES", "HIGH"),
|
|
408
|
+
"kCCAlgorithm3DES": ("3DES", "MEDIUM"), "CCAlgorithm3DES": ("3DES", "MEDIUM"),
|
|
409
|
+
"kCCAlgorithmRC2": ("RC2", "HIGH"),
|
|
410
|
+
"kCCAlgorithmRC4": ("RC4", "HIGH"),
|
|
411
|
+
"kCCOptionECBMode": ("ECB Mode", "HIGH"), "kCCModeECB": ("ECB Mode", "HIGH"),
|
|
412
|
+
}
|
|
413
|
+
_WEAK_HASH_ALGOS = {
|
|
414
|
+
"CC_MD2": ("MD2", "HIGH"), "CC_MD4": ("MD4", "HIGH"), "CC_MD5": ("MD5", "HIGH"),
|
|
415
|
+
"kCCHmacAlgMD5": ("HMAC-MD5", "HIGH"),
|
|
416
|
+
"CC_SHA1": ("SHA1", "MEDIUM"), "kCCHmacAlgSHA1": ("HMAC-SHA1", "MEDIUM"),
|
|
417
|
+
}
|
|
418
|
+
_INSECURE_RANDOM_SYMBOLS = ("_srand", "_random", "_rand")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _check_crypto(corpus_blob: str, symbol_names: List[str], file_path: str,
|
|
422
|
+
findings: List[Dict[str, Any]]) -> None:
|
|
423
|
+
# Match against the joined symbol text, not exact token membership.
|
|
424
|
+
seen_algo = set()
|
|
425
|
+
for token, (algo_name, sev) in _WEAK_CRYPTO_ALGOS.items():
|
|
426
|
+
if token in corpus_blob and algo_name not in seen_algo:
|
|
427
|
+
seen_algo.add(algo_name)
|
|
428
|
+
findings.append(_finding(
|
|
429
|
+
"IOS-BIN-CRYPTO-001", file_path, severity=sev,
|
|
430
|
+
description=f"Binary references weak cryptographic algorithm: {algo_name} "
|
|
431
|
+
f"(symbol/constant: {token}).",
|
|
432
|
+
location_symbol=token))
|
|
433
|
+
seen_hash = set()
|
|
434
|
+
for token, (algo_name, sev) in _WEAK_HASH_ALGOS.items():
|
|
435
|
+
if token in corpus_blob and algo_name not in seen_hash:
|
|
436
|
+
seen_hash.add(algo_name)
|
|
437
|
+
findings.append(_finding(
|
|
438
|
+
"IOS-BIN-CRYPTO-002", file_path, severity=sev,
|
|
439
|
+
description=f"Binary references weak hash algorithm: {algo_name} "
|
|
440
|
+
f"(symbol/constant: {token}).",
|
|
441
|
+
location_symbol=token))
|
|
442
|
+
uses_crypto_api = any(t in corpus_blob for t in
|
|
443
|
+
("CCCrypt", "SecKeyEncrypt", "kCCAlgorithmAES", "CC_SHA256"))
|
|
444
|
+
if uses_crypto_api:
|
|
445
|
+
for sym in _INSECURE_RANDOM_SYMBOLS:
|
|
446
|
+
if sym in symbol_names:
|
|
447
|
+
has_csprng = any(t in corpus_blob for t in
|
|
448
|
+
("_arc4random", "arc4random_buf", "SecRandomCopyBytes"))
|
|
449
|
+
csprng_note = (
|
|
450
|
+
" This binary already links a CSPRNG (arc4random/SecRandomCopyBytes), "
|
|
451
|
+
"so the security-sensitive paths are most likely already correct."
|
|
452
|
+
if has_csprng else "")
|
|
453
|
+
findings.append(_finding(
|
|
454
|
+
"IOS-BIN-CRYPTO-003", file_path,
|
|
455
|
+
description=f"Binary links the non-cryptographic RNG API "
|
|
456
|
+
f"{sym.lstrip('_')}() and also links cryptographic APIs. "
|
|
457
|
+
f"Informational only - no evidence of insecure key/nonce "
|
|
458
|
+
f"generation: this is symbol co-presence with no call-site "
|
|
459
|
+
f"link, and statically linked zlib/ffmpeg/sqlite import "
|
|
460
|
+
f"rand() by themselves.{csprng_note}",
|
|
461
|
+
location_symbol=sym))
|
|
462
|
+
break
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _check_macho_security(parsed: "ParsedMachO", symbol_names: List[str], file_path: str,
|
|
466
|
+
is_main_binary: bool, findings: List[Dict[str, Any]]) -> None:
|
|
467
|
+
binary = parsed.binary
|
|
468
|
+
defined_names = _defined_symbol_names(parsed)
|
|
469
|
+
# PIE is only meaningful for the main executable: dylibs are always
|
|
470
|
+
# position-independent by construction.
|
|
471
|
+
if is_main_binary:
|
|
472
|
+
try:
|
|
473
|
+
pie = bool(binary.is_pie)
|
|
474
|
+
except Exception:
|
|
475
|
+
pie = True
|
|
476
|
+
if not pie:
|
|
477
|
+
findings.append(_finding("IOS-BIN-SEC-001", file_path,
|
|
478
|
+
location_symbol="Mach-O header (MH_PIE flag)"))
|
|
479
|
+
|
|
480
|
+
is_swift = _is_swift(symbol_names)
|
|
481
|
+
has_objc = _has_custom_objc_classes(defined_names)
|
|
482
|
+
is_swift_only = is_swift and not has_objc
|
|
483
|
+
|
|
484
|
+
# A fully stripped binary exposes no symbols at all, which is the absence of
|
|
485
|
+
# any signal, not evidence that a canary or ARC is missing.
|
|
486
|
+
has_any_symbols = bool(symbol_names)
|
|
487
|
+
# Stack canary and ARC only apply to native C/Obj-C code; Swift is memory-safe.
|
|
488
|
+
if has_any_symbols and not is_swift_only:
|
|
489
|
+
if not _has_canary_symbols(symbol_names):
|
|
490
|
+
# clang emits a canary only for functions owning a stack buffer, so a
|
|
491
|
+
# small framework can legitimately have none.
|
|
492
|
+
findings.append(_finding(
|
|
493
|
+
"IOS-BIN-SEC-002", file_path,
|
|
494
|
+
severity=None if is_main_binary else "INFO",
|
|
495
|
+
description=None if is_main_binary else (
|
|
496
|
+
"No ___stack_chk_guard/___stack_chk_fail reference in this embedded "
|
|
497
|
+
"framework. INFORMATIONAL ONLY: clang emits a stack canary only for "
|
|
498
|
+
"functions that own a stack buffer, so a small module can legitimately "
|
|
499
|
+
"have none while stack protection is enabled. This is not evidence "
|
|
500
|
+
"-fstack-protector was disabled - confirm against the module's build "
|
|
501
|
+
"settings before acting on it."),
|
|
502
|
+
location_symbol="symbol table (___stack_chk_fail/___stack_chk_guard absent)"))
|
|
503
|
+
if has_objc and not _has_arc_symbols(symbol_names):
|
|
504
|
+
findings.append(_finding(
|
|
505
|
+
"IOS-BIN-SEC-003", file_path,
|
|
506
|
+
location_symbol="symbol table (objc_retain/objc_release absent)"))
|
|
507
|
+
|
|
508
|
+
try:
|
|
509
|
+
signed = bool(binary.has_code_signature)
|
|
510
|
+
except Exception:
|
|
511
|
+
signed = True
|
|
512
|
+
if not signed:
|
|
513
|
+
findings.append(_finding(
|
|
514
|
+
"IOS-BIN-SEC-004", file_path,
|
|
515
|
+
location_symbol="code signature (LC_CODE_SIGNATURE load command absent)"))
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
_CRED_FP_MARKERS = (
|
|
519
|
+
"%@", "%s", "%d", "%ld", "%lld", "%lu", "%u", "%i", "%f", "{0}", "{1}",
|
|
520
|
+
"${", "$(", "<key>", "<string>", "placeholder",
|
|
521
|
+
"x-api-key", "authorization:", "content-type",
|
|
522
|
+
)
|
|
523
|
+
_CRED_FP_VALUES = {
|
|
524
|
+
"true", "false", "nil", "null", "none", "undefined", "value", "string",
|
|
525
|
+
"header", "token", "apikey", "secretkey", "password", "username",
|
|
526
|
+
"yes", "no", "example", "<value>",
|
|
527
|
+
}
|
|
528
|
+
_CRED_VALUE_RE = re.compile(
|
|
529
|
+
r"(?i)(?:api[_-]?key|secret[_-]?key|password|pwd|passwd)"
|
|
530
|
+
r"\s*[=:\"']\s*([A-Za-z0-9+/=._\-]{8,})"
|
|
531
|
+
)
|
|
532
|
+
# Display only: which keyword matched. Detection stays with _CRED_VALUE_RE.
|
|
533
|
+
_CRED_KEYWORD_RE = re.compile(r"(?i)(api[_-]?key|secret[_-]?key|password|pwd|passwd)")
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _looks_like_real_secret_assignment(line: str) -> Optional[str]:
|
|
537
|
+
"""Return the assigned value, or None: a bare keyword is not a secret."""
|
|
538
|
+
low = line.lower().strip()
|
|
539
|
+
if any(m in low for m in _CRED_FP_MARKERS):
|
|
540
|
+
return None
|
|
541
|
+
m = _CRED_VALUE_RE.search(line)
|
|
542
|
+
if not m:
|
|
543
|
+
return None
|
|
544
|
+
val = m.group(1)
|
|
545
|
+
if val.lower() in _CRED_FP_VALUES:
|
|
546
|
+
return None
|
|
547
|
+
# Reject Obj-C selector fragments ("keyword:value:moreKeyword:"), where the
|
|
548
|
+
# captured value is immediately followed by another colon.
|
|
549
|
+
if m.end() < len(line) and line[m.end()] == ":":
|
|
550
|
+
return None
|
|
551
|
+
if any(c.isdigit() for c in val) or len(val) >= 16:
|
|
552
|
+
return val
|
|
553
|
+
return None
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# `%` is also SQL's LIKE wildcard, so only a real conversion specifier outside a
|
|
557
|
+
# single-quoted literal counts as interpolation.
|
|
558
|
+
_SQL_FORMAT_SPEC = (
|
|
559
|
+
r"%(?:\d+\$)?[-+ #0]*\d*(?:\.\d+)?(?:hh|h|ll|l|z|j|t|L|q)?[@dioufFeEgGxXsScCp]"
|
|
560
|
+
)
|
|
561
|
+
_SQL_FORMAT_SPEC_RE = re.compile(_SQL_FORMAT_SPEC)
|
|
562
|
+
_SQL_SINGLE_QUOTED_LITERAL_RE = re.compile(r"'[^']*'")
|
|
563
|
+
|
|
564
|
+
_SQL_FORMAT_RE = re.compile(
|
|
565
|
+
r"(?i)(?:SELECT\s+.+\s+FROM\s+.+\s+WHERE\s+.+" + _SQL_FORMAT_SPEC + r"|"
|
|
566
|
+
r"INSERT\s+INTO\s+.+\s+VALUES\s*\(.+" + _SQL_FORMAT_SPEC + r"|"
|
|
567
|
+
r"UPDATE\s+.+\s+SET\s+.+\s+WHERE\s+.+" + _SQL_FORMAT_SPEC + r"|"
|
|
568
|
+
r"DELETE\s+FROM\s+.+\s+WHERE\s+.+" + _SQL_FORMAT_SPEC + r")"
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _blank_sql_like_wildcard_literals(line: str) -> str:
|
|
573
|
+
def _repl(m: "re.Match") -> str:
|
|
574
|
+
body = m.group(0)
|
|
575
|
+
if "%" not in body:
|
|
576
|
+
return body
|
|
577
|
+
# Whatever stays percent-shaped after stripping real specifiers is a
|
|
578
|
+
# LIKE wildcard, making the whole literal a pattern rather than a slot.
|
|
579
|
+
residue = _SQL_FORMAT_SPEC_RE.sub("", body)
|
|
580
|
+
return "''" if "%" in residue else body
|
|
581
|
+
return _SQL_SINGLE_QUOTED_LITERAL_RE.sub(_repl, line)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _sql_built_by_string_formatting(line: str) -> bool:
|
|
585
|
+
if "%" not in line:
|
|
586
|
+
return False
|
|
587
|
+
return bool(_SQL_FORMAT_RE.search(_blank_sql_like_wildcard_literals(line)))
|
|
588
|
+
_NSLOG_SENSITIVE_RE = re.compile(r"(?i)NSLog.{0,60}(?:password|token|secret)")
|
|
589
|
+
_DESERIALIZATION_RE = re.compile(r"unarchiveObjectWith(?:Data|File)")
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _check_sensitive_strings(strings_list: List[str], file_path: str,
|
|
593
|
+
findings: List[Dict[str, Any]]) -> None:
|
|
594
|
+
seen_cred = False
|
|
595
|
+
seen_sql = False
|
|
596
|
+
seen_log = False
|
|
597
|
+
seen_deser = False
|
|
598
|
+
for line in strings_list:
|
|
599
|
+
if not seen_cred and ("password" in line.lower() or "api" in line.lower()
|
|
600
|
+
or "secret" in line.lower()):
|
|
601
|
+
val = _looks_like_real_secret_assignment(line)
|
|
602
|
+
if val:
|
|
603
|
+
seen_cred = True
|
|
604
|
+
kw_match = _CRED_KEYWORD_RE.search(line)
|
|
605
|
+
kw = kw_match.group(1) if kw_match else "credential"
|
|
606
|
+
findings.append(_finding(
|
|
607
|
+
"IOS-BIN-SENSITIVE-001", file_path,
|
|
608
|
+
description=f"Hardcoded credential-like assignment found in binary "
|
|
609
|
+
f"strings: {line.strip()[:120]!r}.",
|
|
610
|
+
location_symbol=f"string constant (matched keyword: {kw})"))
|
|
611
|
+
if not seen_sql and _sql_built_by_string_formatting(line):
|
|
612
|
+
seen_sql = True
|
|
613
|
+
findings.append(_finding(
|
|
614
|
+
"IOS-BIN-SENSITIVE-002", file_path,
|
|
615
|
+
description=f"SQL statement with a format-string placeholder found in "
|
|
616
|
+
f"binary strings: {line.strip()[:150]!r}.",
|
|
617
|
+
location_symbol="string constant (SQL statement)"))
|
|
618
|
+
if not seen_log and _NSLOG_SENSITIVE_RE.search(line):
|
|
619
|
+
seen_log = True
|
|
620
|
+
findings.append(_finding(
|
|
621
|
+
"IOS-BIN-SENSITIVE-003", file_path,
|
|
622
|
+
description=f"Log format string references sensitive data: "
|
|
623
|
+
f"{line.strip()[:150]!r}.",
|
|
624
|
+
location_symbol="string constant (NSLog format string)"))
|
|
625
|
+
if not seen_deser and _DESERIALIZATION_RE.search(line):
|
|
626
|
+
seen_deser = True
|
|
627
|
+
deser_match = _DESERIALIZATION_RE.search(line)
|
|
628
|
+
findings.append(_finding(
|
|
629
|
+
"IOS-BIN-SENSITIVE-004", file_path,
|
|
630
|
+
location_symbol=f"selector: {deser_match.group(0)}"))
|
|
631
|
+
if seen_cred and seen_sql and seen_log and seen_deser:
|
|
632
|
+
break
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
# The bare name also shows up in unrelated allowlists. These linkage markers
|
|
636
|
+
# identify UIWebView usage via the binary's linkage/metadata sections.
|
|
637
|
+
_UIWEBVIEW_LINKAGE_MARKERS = (
|
|
638
|
+
"_OBJC_CLASS_$_UIWebView", "_OBJC_METACLASS_$_UIWebView",
|
|
639
|
+
'@"UIWebView"', "So9UIWebView", "UIWebViewDelegate",
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
_DANGEROUS_API_STRING_CHECKS = (
|
|
643
|
+
("NSDataWritingFileProtectionNone", "IOS-BIN-API-003"),
|
|
644
|
+
("NSURLConnection", "IOS-BIN-API-004"),
|
|
645
|
+
("SSLCreateContext", "IOS-BIN-API-005"),
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
# The lookahead stops the bare constant matching inside the materially different
|
|
649
|
+
# kSecAttrAccessibleAlwaysThisDeviceOnly.
|
|
650
|
+
_KEYCHAIN_ALWAYS_BARE_RE = re.compile(r"kSecAttrAccessibleAlways(?!ThisDeviceOnly)")
|
|
651
|
+
_KEYCHAIN_ALWAYS_DEVICE_ONLY_RE = re.compile(r"kSecAttrAccessibleAlwaysThisDeviceOnly")
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _check_keychain_accessibility(corpus_blob: str, file_path: str,
|
|
655
|
+
findings: List[Dict[str, Any]]) -> None:
|
|
656
|
+
if _KEYCHAIN_ALWAYS_BARE_RE.search(corpus_blob):
|
|
657
|
+
findings.append(_finding(
|
|
658
|
+
"IOS-BIN-API-001", file_path,
|
|
659
|
+
description="Binary references kSecAttrAccessibleAlways (the bare constant, "
|
|
660
|
+
"not ...ThisDeviceOnly): Keychain items stay readable while the "
|
|
661
|
+
"device is locked AND are eligible for iCloud Keychain sync and "
|
|
662
|
+
"unencrypted backup restore onto another device.",
|
|
663
|
+
location_symbol="kSecAttrAccessibleAlways"))
|
|
664
|
+
if _KEYCHAIN_ALWAYS_DEVICE_ONLY_RE.search(corpus_blob):
|
|
665
|
+
findings.append(_finding(
|
|
666
|
+
"IOS-BIN-API-006", file_path,
|
|
667
|
+
description="Binary references kSecAttrAccessibleAlwaysThisDeviceOnly: the "
|
|
668
|
+
"Keychain item is readable while the device is locked, but it is "
|
|
669
|
+
"device-bound (no iCloud Keychain sync, not restorable to another "
|
|
670
|
+
"device), so the exfiltration risk of the plain "
|
|
671
|
+
"kSecAttrAccessibleAlways does not apply here.",
|
|
672
|
+
location_symbol="kSecAttrAccessibleAlwaysThisDeviceOnly"))
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _check_dangerous_apis(corpus_blob: str, file_path: str,
|
|
676
|
+
findings: List[Dict[str, Any]]) -> None:
|
|
677
|
+
uiwebview_hit = next((m for m in _UIWEBVIEW_LINKAGE_MARKERS if m in corpus_blob), None)
|
|
678
|
+
if uiwebview_hit:
|
|
679
|
+
findings.append(_finding("IOS-BIN-API-002", file_path,
|
|
680
|
+
location_symbol=f"symbol/string: {uiwebview_hit}"))
|
|
681
|
+
for token, rule_id in _DANGEROUS_API_STRING_CHECKS:
|
|
682
|
+
if token in corpus_blob:
|
|
683
|
+
findings.append(_finding(rule_id, file_path, location_symbol=token))
|
|
684
|
+
_check_keychain_accessibility(corpus_blob, file_path, findings)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
_RASP_SIGNATURES = {
|
|
688
|
+
"Guardsquare iXGuard": ("ixguard", "iXGuard", "GuardsquareSDK", "dexguard", "DexGuard"),
|
|
689
|
+
"Zimperium zShield": ("zimperium", "Zimperium", "zShield", "zIPS", "ZDetection"),
|
|
690
|
+
"Digital.ai (Arxan)": ("arxan", "Arxan", "digital_ai", "TransformIT"),
|
|
691
|
+
"Promon SHIELD": ("promon", "Promon", "PromonShield"),
|
|
692
|
+
"Appdome": ("appdome", "Appdome", "APPDOME_"),
|
|
693
|
+
"Talsec freeRASP": ("talsec", "Talsec", "freeRASP", "FreeRasp", "TalsecSecurity"),
|
|
694
|
+
}
|
|
695
|
+
# Anti-tamper / anti-debug / jailbreak-detection indicator strings.
|
|
696
|
+
_ANTI_TAMPER_PATTERNS = (
|
|
697
|
+
"ptrace", "PT_DENY_ATTACH", "P_TRACED", "isDebugged", "debugger_attached",
|
|
698
|
+
"frida", "substrate", "cycript", "objection", "SSLKillSwitch", "trustkit",
|
|
699
|
+
"jailbroken", "jailbreak", "Cydia", "MobileSubstrate", "libhooker",
|
|
700
|
+
"/var/jb", "DYLD_INSERT",
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
# Unambiguous on their own, so one hit is enough where the generic list needs two.
|
|
704
|
+
_STRONG_ANTI_TAMPER_PATTERNS = (
|
|
705
|
+
"jailbroken", "jailbreak", "Cydia", "MobileSubstrate", "libhooker", "/var/jb",
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
# Matching is case-insensitive: the real symbols are CamelCase identifiers.
|
|
709
|
+
|
|
710
|
+
def _check_rasp(strings_list: List[str], symbol_names: List[str], file_path: str,
|
|
711
|
+
findings: List[Dict[str, Any]], notes: List[str],
|
|
712
|
+
encrypted: bool = False) -> None:
|
|
713
|
+
corpus = " ".join(strings_list) + " " + " ".join(symbol_names)
|
|
714
|
+
corpus_lower = corpus.lower()
|
|
715
|
+
detected_libs = []
|
|
716
|
+
for lib_name, sigs in _RASP_SIGNATURES.items():
|
|
717
|
+
if any(s in corpus for s in sigs):
|
|
718
|
+
detected_libs.append(lib_name)
|
|
719
|
+
anti_tamper_hits = [p for p in _ANTI_TAMPER_PATTERNS if p.lower() in corpus_lower]
|
|
720
|
+
strong_hits = [p for p in _STRONG_ANTI_TAMPER_PATTERNS if p.lower() in corpus_lower]
|
|
721
|
+
|
|
722
|
+
if detected_libs:
|
|
723
|
+
notes.append(f"RASP/anti-tampering SDK detected ({', '.join(detected_libs)}) - "
|
|
724
|
+
f"informational, no finding raised.")
|
|
725
|
+
elif strong_hits or len(anti_tamper_hits) >= 2:
|
|
726
|
+
notes.append(f"Custom anti-tampering signals present ({len(anti_tamper_hits)} "
|
|
727
|
+
f"patterns: {', '.join(anti_tamper_hits[:5])}) - informational, "
|
|
728
|
+
f"no finding raised.")
|
|
729
|
+
elif encrypted:
|
|
730
|
+
notes.append(
|
|
731
|
+
"RASP/anti-tampering: UNKNOWN, not assessed - this binary is FairPlay-encrypted "
|
|
732
|
+
"so its own strings are unreadable and this check cannot assess it. No finding "
|
|
733
|
+
"raised (an absence of evidence here is not evidence of absence).")
|
|
734
|
+
else:
|
|
735
|
+
findings.append(_finding(
|
|
736
|
+
"IOS-BIN-RASP-001", file_path,
|
|
737
|
+
location_symbol="binary-wide string/symbol scan (no signal found)"))
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
# com.apple.developer.associated-domains is an entitlements key, never an
|
|
741
|
+
# Info.plist key, so Universal Links can only be read out of the signature blob.
|
|
742
|
+
_CSMAGIC_EMBEDDED_ENTITLEMENTS = b"\xfa\xde\x71\x71"
|
|
743
|
+
_MAX_ENTITLEMENTS_BLOB = 1_000_000
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def extract_entitlements_from_binary(binary_path: str) -> Dict[str, Any]:
|
|
747
|
+
"""Read entitlements out of a Mach-O code signature. {} means unreadable."""
|
|
748
|
+
try:
|
|
749
|
+
with open(binary_path, "rb") as f:
|
|
750
|
+
data = f.read()
|
|
751
|
+
except OSError:
|
|
752
|
+
return {}
|
|
753
|
+
offset = 0
|
|
754
|
+
while True:
|
|
755
|
+
index = data.find(_CSMAGIC_EMBEDDED_ENTITLEMENTS, offset)
|
|
756
|
+
if index < 0:
|
|
757
|
+
return {}
|
|
758
|
+
offset = index + 4
|
|
759
|
+
try:
|
|
760
|
+
length = struct.unpack(">I", data[index + 4:index + 8])[0]
|
|
761
|
+
except struct.error:
|
|
762
|
+
continue
|
|
763
|
+
if not (8 < length < _MAX_ENTITLEMENTS_BLOB):
|
|
764
|
+
continue
|
|
765
|
+
blob = data[index + 8:index + length]
|
|
766
|
+
if b"<plist" not in blob:
|
|
767
|
+
continue
|
|
768
|
+
try:
|
|
769
|
+
parsed = plistlib.loads(blob)
|
|
770
|
+
except Exception:
|
|
771
|
+
continue
|
|
772
|
+
if isinstance(parsed, dict):
|
|
773
|
+
return parsed
|
|
774
|
+
return {}
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
# Apple back-deployment dylibs bundled directly under Frameworks/, matched by
|
|
778
|
+
# filename convention rather than by the ObjC/Swift class-prefix convention.
|
|
779
|
+
_APPLE_REDIST_DYLIB_PREFIXES = ("libswift", "libSystem", "libobjc", "libc++", "libz.",
|
|
780
|
+
"libsqlite3", "libicucore", "libnetwork", "libDER")
|
|
781
|
+
|
|
782
|
+
_SYSTEM_URL_SCHEMES = {"http", "https", "mailto", "tel", "sms", "facetime", "facetime-audio"}
|
|
783
|
+
_SENSITIVE_SCHEME_MARKERS = ("bank", "health", "medical", "finance", "password", "auth")
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _check_url_schemes(plist: Dict[str, Any], entitlements: Optional[Dict[str, Any]],
|
|
787
|
+
findings: List[Dict[str, Any]]) -> None:
|
|
788
|
+
file_path = "Info.plist"
|
|
789
|
+
url_types = plist.get("CFBundleURLTypes") or []
|
|
790
|
+
custom_schemes = []
|
|
791
|
+
for ut in url_types:
|
|
792
|
+
for scheme in ut.get("CFBundleURLSchemes") or []:
|
|
793
|
+
if isinstance(scheme, str) and scheme.lower() not in _SYSTEM_URL_SCHEMES:
|
|
794
|
+
custom_schemes.append(scheme)
|
|
795
|
+
if custom_schemes:
|
|
796
|
+
findings.append(_finding(
|
|
797
|
+
"IOS-BIN-URL-001", file_path,
|
|
798
|
+
description=f"Custom URL scheme(s) registered: {', '.join(custom_schemes)}.",
|
|
799
|
+
location_symbol="Info.plist key: CFBundleURLTypes/CFBundleURLSchemes"))
|
|
800
|
+
|
|
801
|
+
if custom_schemes and entitlements is not None:
|
|
802
|
+
domains = entitlements.get("com.apple.developer.associated-domains") or []
|
|
803
|
+
has_universal_links = any(
|
|
804
|
+
isinstance(d, str) and d.startswith("applinks:") for d in domains)
|
|
805
|
+
if not has_universal_links:
|
|
806
|
+
findings.append(_finding(
|
|
807
|
+
"IOS-BIN-URL-002", file_path,
|
|
808
|
+
location_symbol="entitlement: com.apple.developer.associated-domains (absent)"))
|
|
809
|
+
|
|
810
|
+
query_schemes = plist.get("LSApplicationQueriesSchemes") or []
|
|
811
|
+
sensitive = [s for s in query_schemes if isinstance(s, str)
|
|
812
|
+
and any(m in s.lower() for m in _SENSITIVE_SCHEME_MARKERS)]
|
|
813
|
+
if sensitive:
|
|
814
|
+
findings.append(_finding(
|
|
815
|
+
"IOS-BIN-URL-006", file_path,
|
|
816
|
+
description=f"LSApplicationQueriesSchemes includes schemes suggestive of "
|
|
817
|
+
f"sensitive apps: {', '.join(sensitive[:5])}.",
|
|
818
|
+
location_symbol="Info.plist key: LSApplicationQueriesSchemes"))
|
|
819
|
+
|
|
820
|
+
ats = plist.get("NSAppTransportSecurity") or {}
|
|
821
|
+
if ats.get("NSAllowsArbitraryLoads"):
|
|
822
|
+
findings.append(_finding(
|
|
823
|
+
"IOS-BIN-URL-003", file_path,
|
|
824
|
+
location_symbol="Info.plist key: NSAppTransportSecurity/NSAllowsArbitraryLoads"))
|
|
825
|
+
for domain, cfg in (ats.get("NSExceptionDomains") or {}).items():
|
|
826
|
+
if not isinstance(cfg, dict):
|
|
827
|
+
continue
|
|
828
|
+
insecure = bool(cfg.get("NSExceptionAllowsInsecureHTTPLoads"))
|
|
829
|
+
if insecure and cfg.get("NSIncludesSubdomains"):
|
|
830
|
+
findings.append(_finding(
|
|
831
|
+
"IOS-BIN-URL-005", file_path,
|
|
832
|
+
description=f"Insecure HTTP allowed for all subdomains of {domain!r} "
|
|
833
|
+
f"(NSIncludesSubdomains + NSExceptionAllowsInsecureHTTPLoads).",
|
|
834
|
+
location_symbol=f"Info.plist key: NSExceptionDomains.{domain}"))
|
|
835
|
+
elif insecure:
|
|
836
|
+
findings.append(_finding(
|
|
837
|
+
"IOS-BIN-URL-004", file_path,
|
|
838
|
+
description=f"Insecure HTTP allowed for domain {domain!r} "
|
|
839
|
+
f"(NSExceptionAllowsInsecureHTTPLoads).",
|
|
840
|
+
location_symbol=f"Info.plist key: NSExceptionDomains.{domain}"))
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _detect_cross_platform_framework(all_names: List[str], notes: List[str]) -> None:
|
|
844
|
+
lower_names = [n.lower() for n in all_names]
|
|
845
|
+
if any("hermes.framework" in n or "libhermes" in n or "react.framework" in n
|
|
846
|
+
or "main.jsbundle" in n for n in lower_names):
|
|
847
|
+
notes.append("React Native runtime detected (Hermes/React.framework/"
|
|
848
|
+
"main.jsbundle) - informational, not itself a vulnerability. "
|
|
849
|
+
"JS-bundle-level analysis is out of scope for binary-mode SAST.")
|
|
850
|
+
elif any("flutter.framework" in n or "app.framework" in n and "flutter" in n
|
|
851
|
+
for n in lower_names):
|
|
852
|
+
notes.append("Flutter runtime detected (Flutter.framework) - informational, not "
|
|
853
|
+
"itself a vulnerability. Dart AOT code is opaque to Mach-O-level "
|
|
854
|
+
"static analysis.")
|
|
855
|
+
elif any("cordova" in n or ("www" in n and n.endswith("/index.html")) for n in lower_names):
|
|
856
|
+
notes.append("Cordova/PhoneGap hybrid runtime detected - informational, not itself "
|
|
857
|
+
"a vulnerability. HTML/JS bundle content is out of scope for "
|
|
858
|
+
"binary-mode SAST.")
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _analyze_one_binary(binary_path: str, display_path: str, is_main_binary: bool
|
|
862
|
+
) -> List[Dict[str, Any]]:
|
|
863
|
+
findings: List[Dict[str, Any]] = []
|
|
864
|
+
parsed = _pick_best_slice(binary_path)
|
|
865
|
+
symbol_names = _symbol_names(parsed)
|
|
866
|
+
strings_list = _get_strings(binary_path)
|
|
867
|
+
corpus_blob = "\n".join(strings_list) + "\n" + "\n".join(symbol_names)
|
|
868
|
+
|
|
869
|
+
if parsed is not None:
|
|
870
|
+
_check_macho_security(parsed, symbol_names, display_path, is_main_binary, findings)
|
|
871
|
+
_check_crypto(corpus_blob, symbol_names, display_path, findings)
|
|
872
|
+
_check_sensitive_strings(strings_list, display_path, findings)
|
|
873
|
+
_check_dangerous_apis(corpus_blob, display_path, findings)
|
|
874
|
+
return findings
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _read_framework_bundle_id(zf: zipfile.ZipFile, framework_dir_member: str) -> Optional[str]:
|
|
878
|
+
plist_member = framework_dir_member.rstrip("/") + "/Info.plist"
|
|
879
|
+
try:
|
|
880
|
+
data = zf.read(plist_member)
|
|
881
|
+
except KeyError:
|
|
882
|
+
return None
|
|
883
|
+
try:
|
|
884
|
+
pl = plistlib.loads(data)
|
|
885
|
+
except Exception:
|
|
886
|
+
return None
|
|
887
|
+
return get_own_bundle_id(pl)
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
_UNCONFIRMED_ORIGIN_SUMMARY = (
|
|
891
|
+
"this framework's origin (first-party vs third-party) could not be "
|
|
892
|
+
"automatically confirmed - verify before treating this as the app "
|
|
893
|
+
"developer's own code"
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _tag_unconfirmed_origin(findings: List[Dict[str, Any]], reason: str) -> None:
|
|
898
|
+
"""Tag, never suppress, every finding from an unconfirmed-origin framework."""
|
|
899
|
+
for f in findings:
|
|
900
|
+
f["name"] = f"[Unconfirmed Origin] {f['name']}"
|
|
901
|
+
f["origin_uncertain"] = True
|
|
902
|
+
f["origin_note"] = f"{_UNCONFIRMED_ORIGIN_SUMMARY} ({reason})."
|
|
903
|
+
base_desc = f["details"].get("description", "")
|
|
904
|
+
f["details"]["description"] = (
|
|
905
|
+
f"{base_desc} NOTE: {_UNCONFIRMED_ORIGIN_SUMMARY} ({reason})."
|
|
906
|
+
).strip()
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _dedupe_findings(findings: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
910
|
+
seen = set()
|
|
911
|
+
out = []
|
|
912
|
+
for f in findings:
|
|
913
|
+
key = (f["rule_id"], f["file_path"], f["details"].get("description"))
|
|
914
|
+
if key in seen:
|
|
915
|
+
continue
|
|
916
|
+
seen.add(key)
|
|
917
|
+
out.append(f)
|
|
918
|
+
return out
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def analyze_ipa(ipa_path: str, override_config: Optional[ScopeConfig] = None) -> Dict[str, Any]:
|
|
922
|
+
"""Analyze a single .ipa file. override_config is consulted before the
|
|
923
|
+
automatic first-party/third-party classification of every framework."""
|
|
924
|
+
result: Dict[str, Any] = {
|
|
925
|
+
"ok": False,
|
|
926
|
+
"error": None,
|
|
927
|
+
"encrypted": False,
|
|
928
|
+
"encrypted_message": None,
|
|
929
|
+
"findings": [],
|
|
930
|
+
"rule_defs": all_rule_defs(),
|
|
931
|
+
"icdump_used": False,
|
|
932
|
+
"notes": [],
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if not LIEF_AVAILABLE:
|
|
936
|
+
result["error"] = (
|
|
937
|
+
"The `lief` package is required for iOS binary-mode scanning but is not "
|
|
938
|
+
"installed. Run `pip install lief` (or reinstall narvy, which "
|
|
939
|
+
"declares it as a dependency)."
|
|
940
|
+
)
|
|
941
|
+
return result
|
|
942
|
+
|
|
943
|
+
if not os.path.isfile(ipa_path):
|
|
944
|
+
result["error"] = f"File not found: {ipa_path}"
|
|
945
|
+
return result
|
|
946
|
+
|
|
947
|
+
try:
|
|
948
|
+
zf = zipfile.ZipFile(ipa_path, "r")
|
|
949
|
+
except zipfile.BadZipFile as e:
|
|
950
|
+
result["error"] = f"Could not open {ipa_path} as a zip archive: {e}. Not a valid IPA?"
|
|
951
|
+
return result
|
|
952
|
+
|
|
953
|
+
try:
|
|
954
|
+
with zf:
|
|
955
|
+
names = zf.namelist()
|
|
956
|
+
info_plist_candidates = [
|
|
957
|
+
n for n in names
|
|
958
|
+
if n.endswith(".app/Info.plist") and n.count("/") == 2
|
|
959
|
+
and n.split("/", 1)[0].lower() == "payload"
|
|
960
|
+
]
|
|
961
|
+
if not info_plist_candidates:
|
|
962
|
+
# Some re-zips do not keep a clean Payload/ root.
|
|
963
|
+
info_plist_candidates = sorted(
|
|
964
|
+
(n for n in names if n.endswith(".app/Info.plist")),
|
|
965
|
+
key=lambda n: n.count("/"))
|
|
966
|
+
if not info_plist_candidates:
|
|
967
|
+
result["error"] = (
|
|
968
|
+
f"No Payload/*.app/Info.plist found inside {ipa_path} - this doesn't "
|
|
969
|
+
f"look like a valid IPA (expected a Payload/ directory with exactly "
|
|
970
|
+
f"one .app bundle)."
|
|
971
|
+
)
|
|
972
|
+
return result
|
|
973
|
+
|
|
974
|
+
info_plist_member = info_plist_candidates[0]
|
|
975
|
+
app_dir_member = info_plist_member.rsplit("/Info.plist", 1)[0]
|
|
976
|
+
|
|
977
|
+
try:
|
|
978
|
+
plist = plistlib.loads(zf.read(info_plist_member))
|
|
979
|
+
except Exception as e:
|
|
980
|
+
result["error"] = f"Could not parse Info.plist inside {ipa_path}: {e}"
|
|
981
|
+
return result
|
|
982
|
+
|
|
983
|
+
app_bundle_id = get_own_bundle_id(plist)
|
|
984
|
+
|
|
985
|
+
exe_name = plist.get("CFBundleExecutable")
|
|
986
|
+
if not exe_name:
|
|
987
|
+
result["error"] = (
|
|
988
|
+
f"Info.plist inside {ipa_path} has no CFBundleExecutable key - cannot "
|
|
989
|
+
f"locate the main binary."
|
|
990
|
+
)
|
|
991
|
+
return result
|
|
992
|
+
|
|
993
|
+
main_binary_member = f"{app_dir_member}/{exe_name}"
|
|
994
|
+
if main_binary_member not in names:
|
|
995
|
+
result["error"] = (
|
|
996
|
+
f"Info.plist declares CFBundleExecutable={exe_name!r} but "
|
|
997
|
+
f"{main_binary_member} is not present in the archive."
|
|
998
|
+
)
|
|
999
|
+
return result
|
|
1000
|
+
|
|
1001
|
+
frameworks_prefix = app_dir_member + "/Frameworks/"
|
|
1002
|
+
framework_members = [n for n in names if n.startswith(frameworks_prefix)]
|
|
1003
|
+
|
|
1004
|
+
import tempfile
|
|
1005
|
+
with tempfile.TemporaryDirectory(prefix="narvy-ios-") as tmpdir:
|
|
1006
|
+
main_binary_path = _safe_extract_member(zf, main_binary_member, tmpdir)
|
|
1007
|
+
if main_binary_path is None:
|
|
1008
|
+
result["error"] = f"Failed to extract main binary {main_binary_member} " \
|
|
1009
|
+
f"from {ipa_path} (zip-bomb guard or I/O error)."
|
|
1010
|
+
return result
|
|
1011
|
+
|
|
1012
|
+
main_parsed = _pick_best_slice(main_binary_path)
|
|
1013
|
+
encrypted = False
|
|
1014
|
+
if main_parsed is not None:
|
|
1015
|
+
try:
|
|
1016
|
+
b = main_parsed.binary
|
|
1017
|
+
if b.has_encryption_info and b.encryption_info.crypt_id == 1:
|
|
1018
|
+
encrypted = True
|
|
1019
|
+
except Exception:
|
|
1020
|
+
pass
|
|
1021
|
+
result["encrypted"] = encrypted
|
|
1022
|
+
if encrypted:
|
|
1023
|
+
result["encrypted_message"] = (
|
|
1024
|
+
"This IPA is FairPlay-encrypted (Apple's App Store DRM, cryptid=1): "
|
|
1025
|
+
"the executable __TEXT code is ciphertext until the device that "
|
|
1026
|
+
"purchased/installed it decrypts it at launch time using a key Apple "
|
|
1027
|
+
"grants only to that device - no static analysis tool, Narvy "
|
|
1028
|
+
"included, can read the actual instruction stream of an encrypted "
|
|
1029
|
+
"App Store export. This is a physical limitation of the distribution "
|
|
1030
|
+
"format, not a gap in this scanner. What still works and was still "
|
|
1031
|
+
"run: Info.plist / entitlements / URL-scheme / ATS analysis (always "
|
|
1032
|
+
"cleartext), linked-framework and embedded-framework-name listing "
|
|
1033
|
+
"(load commands are never encrypted), and Objective-C class/method "
|
|
1034
|
+
"metadata recovery (this metadata typically lives outside the "
|
|
1035
|
+
"encrypted __TEXT range). What is unreliable: weak-"
|
|
1036
|
+
"crypto/hardcoded-secret/dangerous-API string matching against the "
|
|
1037
|
+
"app's OWN literal string constants, since those live inside the "
|
|
1038
|
+
"encrypted region and read as ciphertext noise - expect a near-empty "
|
|
1039
|
+
"result for those specific checks, not a false 'clean bill of "
|
|
1040
|
+
"health'. The ONLY way to read that encrypted code is a decrypted "
|
|
1041
|
+
"binary (a jailbroken-device dump). `--upload` does NOT decrypt "
|
|
1042
|
+
"anything: the same encrypted binary is analysed, not decrypted."
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
findings = _analyze_one_binary(main_binary_path, exe_name, is_main_binary=True)
|
|
1046
|
+
|
|
1047
|
+
entitlements = extract_entitlements_from_binary(main_binary_path)
|
|
1048
|
+
if not entitlements:
|
|
1049
|
+
entitlements = None # unknown, not "no entitlements"
|
|
1050
|
+
_check_url_schemes(plist, entitlements, findings)
|
|
1051
|
+
|
|
1052
|
+
arch = "Unknown"
|
|
1053
|
+
linked_frameworks: List[str] = []
|
|
1054
|
+
if main_parsed is not None:
|
|
1055
|
+
b = main_parsed.binary
|
|
1056
|
+
try:
|
|
1057
|
+
arch = str(b.header.cpu_type).replace("CPU_TYPE.", "")
|
|
1058
|
+
except Exception:
|
|
1059
|
+
pass
|
|
1060
|
+
try:
|
|
1061
|
+
linked_frameworks = sorted({
|
|
1062
|
+
re.search(r"/([^/]+)\.framework/", lib.name).group(1)
|
|
1063
|
+
for lib in b.libraries
|
|
1064
|
+
if lib.name and "/System/Library/Frameworks/" in lib.name
|
|
1065
|
+
and re.search(r"/([^/]+)\.framework/", lib.name)
|
|
1066
|
+
})
|
|
1067
|
+
except Exception:
|
|
1068
|
+
pass
|
|
1069
|
+
result["notes"].append(
|
|
1070
|
+
f"Main binary: {exe_name} (arch: {arch}, "
|
|
1071
|
+
f"{len(linked_frameworks)} system frameworks linked).")
|
|
1072
|
+
|
|
1073
|
+
icdump_used = False
|
|
1074
|
+
if ICDUMP_AVAILABLE:
|
|
1075
|
+
ok_icdump, n_classes, n_methods, icdump_err = _icdump_class_count(main_binary_path)
|
|
1076
|
+
if ok_icdump:
|
|
1077
|
+
result["notes"].append(
|
|
1078
|
+
f"icdump recovered {n_classes} Objective-C classes / "
|
|
1079
|
+
f"{n_methods} methods from the main binary.")
|
|
1080
|
+
icdump_used = True
|
|
1081
|
+
else:
|
|
1082
|
+
result["notes"].append(
|
|
1083
|
+
f"icdump failed to parse the main binary ({icdump_err}) - "
|
|
1084
|
+
f"continuing with LIEF symbol-table-only Obj-C detection for "
|
|
1085
|
+
f"this binary.")
|
|
1086
|
+
else:
|
|
1087
|
+
result["notes"].append(
|
|
1088
|
+
"icdump not installed - full Obj-C class/method/property "
|
|
1089
|
+
"introspection needs Linux/macOS/WSL "
|
|
1090
|
+
"(pip install narvy[ios-full]). Falling back to binary "
|
|
1091
|
+
"symbol-table scanning only."
|
|
1092
|
+
)
|
|
1093
|
+
result["icdump_used"] = icdump_used
|
|
1094
|
+
|
|
1095
|
+
# Apple's two packaging shapes for an embedded dependency: a bare
|
|
1096
|
+
# "X.dylib" file, or an "X.framework/" directory.
|
|
1097
|
+
fw_entries: Dict[str, Tuple[str, str]] = {}
|
|
1098
|
+
for m in framework_members:
|
|
1099
|
+
rel = m[len(frameworks_prefix):]
|
|
1100
|
+
top = rel.split("/", 1)[0]
|
|
1101
|
+
if not top or top in fw_entries:
|
|
1102
|
+
continue
|
|
1103
|
+
if top.endswith(".dylib") and "/" not in rel:
|
|
1104
|
+
fw_entries[top] = ("dylib", m)
|
|
1105
|
+
elif top.endswith(".framework"):
|
|
1106
|
+
fw_stripped = top[: -len(".framework")]
|
|
1107
|
+
fw_entries[top] = ("framework", f"{frameworks_prefix}{top}/{fw_stripped}")
|
|
1108
|
+
|
|
1109
|
+
analyzed_fw = 0
|
|
1110
|
+
skipped_fw = 0
|
|
1111
|
+
unconfirmed_fw = 0
|
|
1112
|
+
override_own_fw = 0
|
|
1113
|
+
override_vendor_fw = 0
|
|
1114
|
+
for fw_entry, (kind, fw_binary_member) in sorted(fw_entries.items()):
|
|
1115
|
+
fw_stripped = fw_entry[: -len(".framework")] if kind == "framework" else fw_entry[: -len(".dylib")]
|
|
1116
|
+
# A bare dylib has no Info.plist and so no bundle id, so the
|
|
1117
|
+
# filename convention has to be checked before classification.
|
|
1118
|
+
if kind == "dylib" and fw_stripped.startswith(_APPLE_REDIST_DYLIB_PREFIXES):
|
|
1119
|
+
skipped_fw += 1
|
|
1120
|
+
continue
|
|
1121
|
+
|
|
1122
|
+
fw_bundle_id = None
|
|
1123
|
+
if kind == "framework":
|
|
1124
|
+
fw_dir_member = f"{frameworks_prefix}{fw_entry}"
|
|
1125
|
+
fw_bundle_id = _read_framework_bundle_id(zf, fw_dir_member)
|
|
1126
|
+
|
|
1127
|
+
override_tier = check_ios_override(fw_stripped, fw_bundle_id, override_config)
|
|
1128
|
+
if override_tier == "own":
|
|
1129
|
+
override_own_fw += 1
|
|
1130
|
+
tier, tier_reason = TIER_FIRST_PARTY, (
|
|
1131
|
+
"forced first-party by .narvy-scope.yml 'own' override")
|
|
1132
|
+
elif override_tier == "vendor":
|
|
1133
|
+
override_vendor_fw += 1
|
|
1134
|
+
tier, tier_reason = TIER_THIRD_PARTY, (
|
|
1135
|
+
"forced third-party by .narvy-scope.yml 'vendor' override")
|
|
1136
|
+
else:
|
|
1137
|
+
tier, tier_reason = classify_ios_framework(fw_stripped, fw_bundle_id, app_bundle_id)
|
|
1138
|
+
|
|
1139
|
+
if tier == TIER_THIRD_PARTY:
|
|
1140
|
+
skipped_fw += 1
|
|
1141
|
+
continue
|
|
1142
|
+
if fw_binary_member not in names:
|
|
1143
|
+
continue
|
|
1144
|
+
fw_local_path = _safe_extract_member(zf, fw_binary_member, tmpdir)
|
|
1145
|
+
if fw_local_path is None:
|
|
1146
|
+
continue
|
|
1147
|
+
fw_display = f"Frameworks/{fw_entry}"
|
|
1148
|
+
fw_findings = _analyze_one_binary(fw_local_path, fw_display, is_main_binary=False)
|
|
1149
|
+
if tier == TIER_UNCONFIRMED:
|
|
1150
|
+
_tag_unconfirmed_origin(fw_findings, tier_reason)
|
|
1151
|
+
unconfirmed_fw += 1
|
|
1152
|
+
findings.extend(fw_findings)
|
|
1153
|
+
analyzed_fw += 1
|
|
1154
|
+
fw_names = list(fw_entries.keys())
|
|
1155
|
+
if fw_names:
|
|
1156
|
+
result["notes"].append(
|
|
1157
|
+
f"Embedded frameworks: {analyzed_fw} binaries analyzed for content "
|
|
1158
|
+
f"(crypto/sensitive-strings/dangerous-API) - {unconfirmed_fw} of "
|
|
1159
|
+
f"those flagged [Unconfirmed Origin] (bundle id neither matched the "
|
|
1160
|
+
f"app's own reverse-DNS root nor a known vendor, see per-finding "
|
|
1161
|
+
f"notes), {skipped_fw} confirmed third-party SDKs skipped "
|
|
1162
|
+
f"(bundle-id and/or name match), out of {len(fw_names)} total.")
|
|
1163
|
+
if override_own_fw or override_vendor_fw:
|
|
1164
|
+
result["notes"].append(
|
|
1165
|
+
f".narvy-scope.yml overrides ({override_config.source_path}): "
|
|
1166
|
+
f"{override_own_fw} framework(s) forced first-party, "
|
|
1167
|
+
f"{override_vendor_fw} framework(s) forced third-party-excluded.")
|
|
1168
|
+
|
|
1169
|
+
main_strings = _get_strings(main_binary_path)
|
|
1170
|
+
main_symbols = _symbol_names(main_parsed)
|
|
1171
|
+
_check_rasp(main_strings, main_symbols, exe_name, findings,
|
|
1172
|
+
result["notes"], encrypted=encrypted)
|
|
1173
|
+
|
|
1174
|
+
_detect_cross_platform_framework(names + fw_names, result["notes"])
|
|
1175
|
+
|
|
1176
|
+
result["findings"] = _dedupe_findings(findings)
|
|
1177
|
+
result["ok"] = True
|
|
1178
|
+
return result
|
|
1179
|
+
|
|
1180
|
+
except Exception as e: # noqa: BLE001
|
|
1181
|
+
result["error"] = f"Unexpected error analyzing {ipa_path}: {e}"
|
|
1182
|
+
result["ok"] = False
|
|
1183
|
+
return result
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def _safe_extract_member(zf: zipfile.ZipFile, member: str, dest_dir: str,
|
|
1187
|
+
max_bytes: int = 3 * 1024 * 1024 * 1024) -> Optional[str]:
|
|
1188
|
+
"""Stream one zip member to disk under a size cap. None means skip-this-one."""
|
|
1189
|
+
try:
|
|
1190
|
+
info = zf.getinfo(member)
|
|
1191
|
+
except KeyError:
|
|
1192
|
+
return None
|
|
1193
|
+
if info.file_size > max_bytes:
|
|
1194
|
+
return None
|
|
1195
|
+
dest_path = os.path.join(dest_dir, os.path.basename(member) + f"_{abs(hash(member))}")
|
|
1196
|
+
try:
|
|
1197
|
+
with zf.open(member) as src, open(dest_path, "wb") as dst:
|
|
1198
|
+
shutil.copyfileobj(src, dst, length=1 << 20)
|
|
1199
|
+
except Exception:
|
|
1200
|
+
return None
|
|
1201
|
+
return dest_path
|