@necessarysdk/react-native 0.0.0-stage → 0.1.6
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.
- package/CHANGELOG.md +40 -0
- package/LICENSE +201 -0
- package/NOTICE +2 -0
- package/NecessaryReactNative.podspec +23 -0
- package/README.md +429 -2
- package/android/build.gradle +83 -0
- package/android/src/main/AndroidManifest.xml +41 -0
- package/android/src/main/cpp/CMakeLists.txt +7 -0
- package/android/src/main/cpp/code_integrity.cpp +484 -0
- package/android/src/main/java/com/necessarysdk/reactnative/Causes.kt +49 -0
- package/android/src/main/java/com/necessarysdk/reactnative/InstallationKeyManager.kt +388 -0
- package/android/src/main/java/com/necessarysdk/reactnative/IntegrityProbeManager.kt +1157 -0
- package/android/src/main/java/com/necessarysdk/reactnative/KeyPresence.kt +62 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryModules.kt +94 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryNative.kt +338 -0
- package/android/src/main/java/com/necessarysdk/reactnative/NecessaryPackage.kt +38 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ProbeReads.kt +333 -0
- package/android/src/main/java/com/necessarysdk/reactnative/ScreenGuard.kt +56 -0
- package/android/src/main/java/com/necessarysdk/reactnative/SecureStore.kt +127 -0
- package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyEntry.kt +57 -0
- package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyManager.kt +767 -0
- package/contract/report-1/android-8-ios-5/RULES.md +149 -0
- package/contract/report-1/android-8-ios-5/report.schema.json +2753 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-complete.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-incomplete.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-invalid.json +205 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-location-one-fix.json +228 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-location-two-fixes.json +228 -0
- package/contract/report-1/android-8-ios-5/samples/android-8-refused.json +208 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-complete.json +106 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-incomplete.json +105 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-invalid.json +107 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-partial.json +110 -0
- package/contract/report-1/android-8-ios-5/samples/ios-5-refused.json +109 -0
- package/doc/integration_guide.md +771 -0
- package/doc/measurements.md +774 -0
- package/ios/InstallationKeyManager.swift +694 -0
- package/ios/IntegrityProbeManager.swift +959 -0
- package/ios/NecessaryModules.h +34 -0
- package/ios/NecessaryModules.mm +208 -0
- package/ios/NecessaryNative.swift +327 -0
- package/ios/PrivacyInfo.xcprivacy +14 -0
- package/ios/ProbeReads.swift +370 -0
- package/lib/client.d.ts +105 -0
- package/lib/client.js +176 -0
- package/lib/collector.d.ts +18 -0
- package/lib/collector.js +49 -0
- package/lib/deviceTrust.d.ts +253 -0
- package/lib/deviceTrust.js +699 -0
- package/lib/errors.d.ts +43 -0
- package/lib/errors.js +56 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +34 -0
- package/lib/installationKey.d.ts +62 -0
- package/lib/installationKey.js +185 -0
- package/lib/installationStore.d.ts +49 -0
- package/lib/installationStore.js +127 -0
- package/lib/native/NativeNecessaryInstallationKey.d.ts +10 -0
- package/lib/native/NativeNecessaryInstallationKey.js +4 -0
- package/lib/native/NativeNecessaryIntegrity.d.ts +8 -0
- package/lib/native/NativeNecessaryIntegrity.js +4 -0
- package/lib/native/NativeNecessarySecureScreen.d.ts +7 -0
- package/lib/native/NativeNecessarySecureScreen.js +4 -0
- package/lib/native/NativeNecessaryStepUpKey.d.ts +9 -0
- package/lib/native/NativeNecessaryStepUpKey.js +4 -0
- package/lib/native/NativeNecessaryStorage.d.ts +14 -0
- package/lib/native/NativeNecessaryStorage.js +4 -0
- package/lib/protocol.d.ts +24 -0
- package/lib/protocol.js +50 -0
- package/lib/reinstallHint.d.ts +23 -0
- package/lib/reinstallHint.js +65 -0
- package/lib/report.d.ts +62 -0
- package/lib/report.js +134 -0
- package/lib/requestProof.d.ts +116 -0
- package/lib/requestProof.js +220 -0
- package/lib/secureScreen.d.ts +15 -0
- package/lib/secureScreen.js +32 -0
- package/lib/stepUpKey.d.ts +105 -0
- package/lib/stepUpKey.js +187 -0
- package/lib/transport.d.ts +39 -0
- package/lib/transport.js +117 -0
- package/lib/util/answers.d.ts +6 -0
- package/lib/util/answers.js +16 -0
- package/lib/util/bytes.d.ts +18 -0
- package/lib/util/bytes.js +207 -0
- package/lib/util/native.d.ts +20 -0
- package/lib/util/native.js +49 -0
- package/lib/util/release.d.ts +2 -0
- package/lib/util/release.js +7 -0
- package/lib/util/target.d.ts +18 -0
- package/lib/util/target.js +128 -0
- package/package.json +81 -4
- package/src/client.ts +221 -0
- package/src/collector.ts +64 -0
- package/src/deviceTrust.ts +851 -0
- package/src/errors.ts +71 -0
- package/src/index.ts +19 -0
- package/src/installationKey.ts +223 -0
- package/src/installationStore.ts +143 -0
- package/src/native/NativeNecessaryInstallationKey.ts +12 -0
- package/src/native/NativeNecessaryIntegrity.ts +15 -0
- package/src/native/NativeNecessarySecureScreen.ts +9 -0
- package/src/native/NativeNecessaryStepUpKey.ts +11 -0
- package/src/native/NativeNecessaryStorage.ts +16 -0
- package/src/protocol.ts +52 -0
- package/src/reinstallHint.ts +69 -0
- package/src/report.ts +168 -0
- package/src/requestProof.ts +295 -0
- package/src/secureScreen.ts +29 -0
- package/src/stepUpKey.ts +238 -0
- package/src/transport.ts +145 -0
- package/src/util/answers.ts +15 -0
- package/src/util/bytes.ts +194 -0
- package/src/util/native.ts +56 -0
- package/src/util/release.ts +6 -0
- package/src/util/target.ts +132 -0
- package/vectors/client_server.json +264 -0
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CFNetwork
|
|
3
|
+
import CoreLocation
|
|
4
|
+
import CryptoKit
|
|
5
|
+
import MachO
|
|
6
|
+
import Security
|
|
7
|
+
import UIKit
|
|
8
|
+
|
|
9
|
+
/// Local integrity measurement collector for iOS.
|
|
10
|
+
///
|
|
11
|
+
/// IMPORTANT: these are risk signals, not remote attestation. On a jailbroken
|
|
12
|
+
/// device an attacker may be able to lie about local state. The server scores
|
|
13
|
+
/// these raw measurements together with server-observed behaviour and never
|
|
14
|
+
/// trusts a client-provided score - the collector deliberately computes no
|
|
15
|
+
/// score of its own, exactly like the Android host.
|
|
16
|
+
///
|
|
17
|
+
/// The probe names and every field name below are a hard contract with the
|
|
18
|
+
/// published measurement contract and every backend reading it. Renaming a
|
|
19
|
+
/// field silently disables the rule that reads it.
|
|
20
|
+
final class IntegrityProbeManager {
|
|
21
|
+
/// Per-platform numbering. 1 was the baseline probe set; 2 adds
|
|
22
|
+
/// `code_integrity`, the dyld analogue of Android's native probe. 3
|
|
23
|
+
/// volunteers `device` and `network` on every scan and answers the
|
|
24
|
+
/// optional measurements the server asks for. 4 adds screen capture,
|
|
25
|
+
/// location and mock locations, and the OS build. 5 reports the app's code
|
|
26
|
+
/// coverage image by image - compared, unreadable or skipped, each one not
|
|
27
|
+
/// compared named with why - and whether the main executable was compared;
|
|
28
|
+
/// and one location read answers both location measurements, which carry
|
|
29
|
+
/// its fix_time. 6 sends the memory page size with `device`, and asks
|
|
30
|
+
/// screen capture of the screen showing the app: with no window scene in
|
|
31
|
+
/// the foreground, recording is not measured and `recording_status` says
|
|
32
|
+
/// `unavailable`, as on Android.
|
|
33
|
+
/// What the reads mean - the coverage tally, the text span's checks, the
|
|
34
|
+
/// one location read - is in ProbeReads, which needs no Apple-only
|
|
35
|
+
/// framework and so can be tested off the phone, on any machine.
|
|
36
|
+
private static let collectorVersion = 8
|
|
37
|
+
/// Sent with every scan whether or not the server asked, like
|
|
38
|
+
/// code_integrity below, so no older client breaks.
|
|
39
|
+
private static let volunteeredProbes = ["device", "network"]
|
|
40
|
+
/// Collected only when the server asks for one AND the app enabled it.
|
|
41
|
+
private static let optionalMeasurements: Set<String> = [
|
|
42
|
+
"local_addresses", "accessibility", "notification_access", "other_access",
|
|
43
|
+
"screen_capture", "location", "mock_location",
|
|
44
|
+
]
|
|
45
|
+
private static let maxTextLength = 8192
|
|
46
|
+
|
|
47
|
+
/// Loaded-image names that indicate instrumentation or a hooking runtime.
|
|
48
|
+
/// This is the iOS counterpart of the /proc/self/maps token scan - and it
|
|
49
|
+
/// carries the same known weakness: it matches on NAME, so a renamed
|
|
50
|
+
/// library evades it; `code_integrity` is the structural answer.
|
|
51
|
+
private static let suspiciousImageTokens = [
|
|
52
|
+
"frida", "gadget", "objection", "cynject", "cycript",
|
|
53
|
+
"substrate", "mobilesubstrate", "substitute", "libhooker",
|
|
54
|
+
"ellekit", "cydia", "shadow", "flex", "libsparkapplist",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
/// Classic jailbreak filesystem artifacts, including rootless (/var/jb)
|
|
58
|
+
/// layouts used by modern jailbreaks such as palera1n and Dopamine.
|
|
59
|
+
private static let jailbreakPaths = [
|
|
60
|
+
"/Applications/Cydia.app",
|
|
61
|
+
"/Applications/Sileo.app",
|
|
62
|
+
"/Applications/Zebra.app",
|
|
63
|
+
"/Library/MobileSubstrate/MobileSubstrate.dylib",
|
|
64
|
+
"/Library/MobileSubstrate/DynamicLibraries",
|
|
65
|
+
"/usr/sbin/sshd",
|
|
66
|
+
"/usr/bin/ssh",
|
|
67
|
+
"/usr/libexec/ssh-keysign",
|
|
68
|
+
"/bin/bash",
|
|
69
|
+
"/bin/sh",
|
|
70
|
+
"/etc/apt",
|
|
71
|
+
"/private/var/lib/apt",
|
|
72
|
+
"/private/var/lib/cydia",
|
|
73
|
+
"/private/var/stash",
|
|
74
|
+
"/usr/libexec/ellekit",
|
|
75
|
+
"/var/jb",
|
|
76
|
+
"/var/jb/usr/lib/TweakInject",
|
|
77
|
+
"/.bootstrapped_electra",
|
|
78
|
+
"/taurine",
|
|
79
|
+
"/.installed_unc0ver",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
// MARK: - Entry point
|
|
83
|
+
|
|
84
|
+
func collect(
|
|
85
|
+
requiredProbes: [String],
|
|
86
|
+
challengeNonce: String,
|
|
87
|
+
optionalProbes: [String] = [],
|
|
88
|
+
enabledMeasurements: Set<String> = []
|
|
89
|
+
) -> [String: Any] {
|
|
90
|
+
var probes: [String: Any] = [:]
|
|
91
|
+
// One location read per collection, made only if an enabled location
|
|
92
|
+
// measurement asks: both answer from it.
|
|
93
|
+
let location = ProbeReads.LocationCapture { self.lastLocation() }
|
|
94
|
+
|
|
95
|
+
for probe in NSOrderedSet(array: requiredProbes).array as? [String] ?? requiredProbes {
|
|
96
|
+
probes[probe] = runProbe(probe, enabledMeasurements: enabledMeasurements, location: location)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// code_integrity is reported whether or not the server asked for it.
|
|
100
|
+
//
|
|
101
|
+
// The server tolerates probes beyond required_probes but rejects a
|
|
102
|
+
// report that omits a requested one, so a collector cannot be given a
|
|
103
|
+
// new mandatory probe without breaking every client that predates it -
|
|
104
|
+
// here that would mean an older iOS collector, which has the same eight
|
|
105
|
+
// probes this one had. Volunteering the measurement instead lets the
|
|
106
|
+
// server score it when present and ignore it when absent, so the two
|
|
107
|
+
// clients can adopt it independently. Promote it to the mandatory list
|
|
108
|
+
// once both report it.
|
|
109
|
+
if probes["code_integrity"] == nil {
|
|
110
|
+
probes["code_integrity"] = runProbe("code_integrity", location: location)
|
|
111
|
+
}
|
|
112
|
+
for probe in IntegrityProbeManager.volunteeredProbes where probes[probe] == nil {
|
|
113
|
+
probes[probe] = runProbe(probe, enabledMeasurements: enabledMeasurements, location: location)
|
|
114
|
+
}
|
|
115
|
+
for probe in optionalProbes
|
|
116
|
+
where probes[probe] == nil && IntegrityProbeManager.optionalMeasurements.contains(probe) {
|
|
117
|
+
probes[probe] = runProbe(probe, enabledMeasurements: enabledMeasurements, location: location)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return [
|
|
121
|
+
"collector_version": IntegrityProbeManager.collectorVersion,
|
|
122
|
+
"platform": "ios",
|
|
123
|
+
"challenge_nonce_echo": challengeNonce,
|
|
124
|
+
"os_version": UIDeviceOSVersion(),
|
|
125
|
+
"probes": probes,
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private func runProbe(
|
|
130
|
+
_ name: String, enabledMeasurements: Set<String> = [], location: ProbeReads.LocationCapture
|
|
131
|
+
) -> [String: Any] {
|
|
132
|
+
switch name {
|
|
133
|
+
case "app_identity": return probeAppIdentity()
|
|
134
|
+
case "code_signing": return probeCodeSigning()
|
|
135
|
+
case "debugger": return probeDebugger()
|
|
136
|
+
case "jailbreak_files": return probeJailbreakFiles()
|
|
137
|
+
case "sandbox": return probeSandbox()
|
|
138
|
+
case "dyld_images": return probeDyldImages()
|
|
139
|
+
case "environment": return probeEnvironment()
|
|
140
|
+
case "simulator": return probeSimulator()
|
|
141
|
+
case "code_integrity": return probeCodeIntegrity()
|
|
142
|
+
case "device": return probeDevice()
|
|
143
|
+
case "network": return probeNetwork()
|
|
144
|
+
case "local_addresses":
|
|
145
|
+
// Asked for by the server, declined by this app.
|
|
146
|
+
return enabledMeasurements.contains(name) ? probeLocalAddresses() : ["status": "not_enabled"]
|
|
147
|
+
case "accessibility", "notification_access", "other_access":
|
|
148
|
+
// iOS shows an app no other app: nothing to list.
|
|
149
|
+
return enabledMeasurements.contains(name)
|
|
150
|
+
? ["status": "unsupported", "reason": "iOS shows an app no other app."]
|
|
151
|
+
: ["status": "not_enabled"]
|
|
152
|
+
case "screen_capture":
|
|
153
|
+
return enabledMeasurements.contains(name) ? probeScreenCapture() : ["status": "not_enabled"]
|
|
154
|
+
case "location":
|
|
155
|
+
return enabledMeasurements.contains(name)
|
|
156
|
+
? ProbeReads.location(location.value) : ["status": "not_enabled"]
|
|
157
|
+
case "mock_location":
|
|
158
|
+
return enabledMeasurements.contains(name)
|
|
159
|
+
? ProbeReads.mockLocation(location.value) : ["status": "not_enabled"]
|
|
160
|
+
default:
|
|
161
|
+
return [
|
|
162
|
+
"status": "unsupported",
|
|
163
|
+
"reason": "Unknown probe requested by server",
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// MARK: - Probes
|
|
169
|
+
|
|
170
|
+
/// The OS version and build, the hardware model, and the memory page size.
|
|
171
|
+
/// iOS has no separate security patch level: its fixes ship as OS
|
|
172
|
+
/// versions, and the build tells a Rapid Security Response apart. The page
|
|
173
|
+
/// size (collector 6) is 16 KiB on Apple's 64-bit phones; a backend that
|
|
174
|
+
/// keeps a record of a release's runtime keeps one per page size.
|
|
175
|
+
private func probeDevice() -> [String: Any] {
|
|
176
|
+
return [
|
|
177
|
+
"status": "ok",
|
|
178
|
+
"os_version": UIDeviceOSVersion(),
|
|
179
|
+
"os_build": osBuild(),
|
|
180
|
+
"model_identifier": deviceModelIdentifier(),
|
|
181
|
+
"page_size": Int(getpagesize()),
|
|
182
|
+
]
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// Runs on the main thread: UIKit and Core Location state is read there.
|
|
186
|
+
/// The collector runs on a worker queue, so this never waits on itself.
|
|
187
|
+
private func onMain<T>(_ read: () -> T) -> T {
|
|
188
|
+
return Thread.isMainThread ? read() : DispatchQueue.main.sync(execute: read)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// Whether the screen showing the app is being recorded, mirrored or sent
|
|
192
|
+
/// to AirPlay (`UIScreen.isCaptured`, asked of the screen of each window
|
|
193
|
+
/// scene in the foreground), and how many other screens are attached.
|
|
194
|
+
/// With no window scene in the foreground - the app in the background, or
|
|
195
|
+
/// before its first window - there is no screen of the app's to ask:
|
|
196
|
+
/// ProbeReads.screenCapture leaves recording out and says why
|
|
197
|
+
/// (collector 6).
|
|
198
|
+
private func probeScreenCapture() -> [String: Any] {
|
|
199
|
+
return onMain {
|
|
200
|
+
let shown = UIApplication.shared.connectedScenes
|
|
201
|
+
.compactMap { $0 as? UIWindowScene }
|
|
202
|
+
.filter {
|
|
203
|
+
$0.activationState == .foregroundActive
|
|
204
|
+
|| $0.activationState == .foregroundInactive
|
|
205
|
+
}
|
|
206
|
+
return ProbeReads.screenCapture(
|
|
207
|
+
captured: shown.isEmpty ? nil : shown.contains { $0.screen.isCaptured },
|
|
208
|
+
otherScreens: UIScreen.screens.count - 1)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private func locationPermission(_ manager: CLLocationManager) -> String {
|
|
213
|
+
let status: CLAuthorizationStatus
|
|
214
|
+
if #available(iOS 14.0, *) {
|
|
215
|
+
status = manager.authorizationStatus
|
|
216
|
+
} else {
|
|
217
|
+
status = CLLocationManager.authorizationStatus()
|
|
218
|
+
}
|
|
219
|
+
guard status == .authorizedWhenInUse || status == .authorizedAlways else { return "none" }
|
|
220
|
+
if #available(iOS 14.0, *), manager.accuracyAuthorization == .reducedAccuracy {
|
|
221
|
+
return "approximate"
|
|
222
|
+
}
|
|
223
|
+
return "precise"
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// The collection's one location read: the last location the phone already holds - never a fresh
|
|
227
|
+
/// fix, never a prompt: only the app asks for the permission.
|
|
228
|
+
private func lastLocation() -> ProbeReads.LocationRead {
|
|
229
|
+
return onMain {
|
|
230
|
+
let manager = CLLocationManager()
|
|
231
|
+
let permission = locationPermission(manager)
|
|
232
|
+
let held: CLLocation? = permission == "none" ? nil : manager.location
|
|
233
|
+
return ProbeReads.read(permission: permission, fix: held.map { self.fixOf($0) }, now: Date())
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/// iOS 15 and later say whether a location was simulated by software or
|
|
238
|
+
/// produced by an accessory; before that the fix cannot tell.
|
|
239
|
+
private func fixOf(_ location: CLLocation) -> ProbeReads.Fix {
|
|
240
|
+
var simulated: Bool?
|
|
241
|
+
var accessory: Bool?
|
|
242
|
+
if #available(iOS 15.0, *), let source = location.sourceInformation {
|
|
243
|
+
simulated = source.isSimulatedBySoftware
|
|
244
|
+
accessory = source.isProducedByAccessory
|
|
245
|
+
}
|
|
246
|
+
return ProbeReads.Fix(
|
|
247
|
+
latitude: location.coordinate.latitude,
|
|
248
|
+
longitude: location.coordinate.longitude,
|
|
249
|
+
horizontalAccuracy: location.horizontalAccuracy,
|
|
250
|
+
timestamp: location.timestamp,
|
|
251
|
+
simulatedBySoftware: simulated,
|
|
252
|
+
producedByAccessory: accessory
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/// Whether a VPN or an HTTP proxy is in use. iOS gives other apps no VPN
|
|
257
|
+
/// API, so a VPN is inferred from the interfaces the system proxy settings
|
|
258
|
+
/// are scoped to - a heuristic, so their names are sent with it. Names
|
|
259
|
+
/// only, never an address.
|
|
260
|
+
private func probeNetwork() -> [String: Any] {
|
|
261
|
+
guard let unmanaged = CFNetworkCopySystemProxySettings() else {
|
|
262
|
+
return ["status": "error", "error": "The system proxy settings could not be read."]
|
|
263
|
+
}
|
|
264
|
+
let settings = unmanaged.takeRetainedValue() as NSDictionary
|
|
265
|
+
var scoped: [String] = []
|
|
266
|
+
if let scopes = settings["__SCOPED__"] as? NSDictionary {
|
|
267
|
+
scoped = scopes.allKeys.compactMap { $0 as? String }.sorted()
|
|
268
|
+
}
|
|
269
|
+
let tunnels = ["utun", "ipsec", "ppp", "tun", "tap"]
|
|
270
|
+
let vpn = scoped.contains { name in tunnels.contains { name.hasPrefix($0) } }
|
|
271
|
+
let proxy = ["HTTPEnable", "HTTPSEnable", "ProxyAutoConfigEnable"].contains { key in
|
|
272
|
+
(settings[key] as? NSNumber)?.intValue == 1
|
|
273
|
+
}
|
|
274
|
+
return [
|
|
275
|
+
"status": "ok",
|
|
276
|
+
"vpn_active": vpn,
|
|
277
|
+
"http_proxy_configured": proxy,
|
|
278
|
+
"scoped_interfaces": Array(scoped.prefix(32)),
|
|
279
|
+
]
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/// The phone's own IP addresses, per interface - an optional measurement.
|
|
283
|
+
/// Link-local addresses are left out: they can carry the
|
|
284
|
+
/// hardware address the platform deliberately hides.
|
|
285
|
+
private func probeLocalAddresses() -> [String: Any] {
|
|
286
|
+
var head: UnsafeMutablePointer<ifaddrs>?
|
|
287
|
+
guard getifaddrs(&head) == 0, let first = head else {
|
|
288
|
+
return ["status": "error", "error": "The network interfaces could not be listed."]
|
|
289
|
+
}
|
|
290
|
+
defer { freeifaddrs(head) }
|
|
291
|
+
var addresses: [[String: Any]] = []
|
|
292
|
+
// More than a list holds is said, never cut off unsaid (collector 8).
|
|
293
|
+
var truncated = false
|
|
294
|
+
var cursor: UnsafeMutablePointer<ifaddrs>? = first
|
|
295
|
+
while let entry = cursor {
|
|
296
|
+
cursor = entry.pointee.ifa_next
|
|
297
|
+
let flags = entry.pointee.ifa_flags
|
|
298
|
+
guard (flags & UInt32(IFF_UP)) != 0, (flags & UInt32(IFF_LOOPBACK)) == 0,
|
|
299
|
+
let address = entry.pointee.ifa_addr else { continue }
|
|
300
|
+
let family = Int32(address.pointee.sa_family)
|
|
301
|
+
guard family == AF_INET || family == AF_INET6 else { continue }
|
|
302
|
+
var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
|
303
|
+
guard getnameinfo(address, socklen_t(address.pointee.sa_len), &host,
|
|
304
|
+
socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0 else { continue }
|
|
305
|
+
let numeric = host.withUnsafeBufferPointer { String(cString: $0.baseAddress!) }
|
|
306
|
+
let text = numeric.split(separator: "%").first.map(String.init) ?? ""
|
|
307
|
+
if text.isEmpty || text.lowercased().hasPrefix("fe80") || text.hasPrefix("169.254") {
|
|
308
|
+
continue
|
|
309
|
+
}
|
|
310
|
+
if addresses.count >= 32 {
|
|
311
|
+
truncated = true
|
|
312
|
+
break
|
|
313
|
+
}
|
|
314
|
+
addresses.append([
|
|
315
|
+
"interface": String(cString: entry.pointee.ifa_name),
|
|
316
|
+
"address": text,
|
|
317
|
+
])
|
|
318
|
+
}
|
|
319
|
+
return ["status": "ok", "addresses": addresses, "truncated": truncated]
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private func probeAppIdentity() -> [String: Any] {
|
|
323
|
+
let bundle = Bundle.main
|
|
324
|
+
var result: [String: Any] = [
|
|
325
|
+
"status": "ok",
|
|
326
|
+
"bundle_id": bundle.bundleIdentifier ?? "",
|
|
327
|
+
"version_name": bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "",
|
|
328
|
+
"version_code": bundle.infoDictionary?["CFBundleVersion"] as? String ?? "",
|
|
329
|
+
]
|
|
330
|
+
// Hash the main executable. The server compares this against a
|
|
331
|
+
// record of the release when it holds one.
|
|
332
|
+
if let url = bundle.executableURL,
|
|
333
|
+
let data = try? Data(contentsOf: url, options: .mappedIfSafe) {
|
|
334
|
+
result["executable_sha256"] = SHA256.hash(data: data)
|
|
335
|
+
.map { String(format: "%02x", $0) }.joined()
|
|
336
|
+
result["executable_bytes"] = data.count
|
|
337
|
+
} else {
|
|
338
|
+
result["executable_sha256"] = ""
|
|
339
|
+
}
|
|
340
|
+
return result
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/// Reports this app's own code-signing identity by parsing the Mach-O
|
|
344
|
+
/// signature embedded in its executable on disk.
|
|
345
|
+
///
|
|
346
|
+
/// The obvious implementation - SecTaskCreateFromSelf plus
|
|
347
|
+
/// SecTaskCopyValueForEntitlement - does not compile for iOS. Those live in
|
|
348
|
+
/// Security/SecTask.h, which Apple ships as public API on macOS only; on
|
|
349
|
+
/// iOS the symbols are private and the build fails with "Cannot find
|
|
350
|
+
/// 'SecTaskCreateFromSelf' in scope".
|
|
351
|
+
///
|
|
352
|
+
/// Reading our own binary is the better measurement regardless. It reports
|
|
353
|
+
/// what is actually embedded in the file rather than what the kernel was
|
|
354
|
+
/// told at launch, and it degrades honestly: a TrollStore-installed
|
|
355
|
+
/// unsigned build carries no LC_CODE_SIGNATURE at all, which is reported as
|
|
356
|
+
/// signed=false instead of being mistaken for a clean signed app. That is
|
|
357
|
+
/// the same class of trap as an Android integrity bucket reporting
|
|
358
|
+
/// compared_bytes=0 and reading as clean.
|
|
359
|
+
///
|
|
360
|
+
/// signing_identifier / team_identifier / get_task_allow are fixed by the
|
|
361
|
+
/// measurement contract. The remaining fields are additive.
|
|
362
|
+
private func probeCodeSigning() -> [String: Any] {
|
|
363
|
+
var result: [String: Any] = [
|
|
364
|
+
"status": "ok",
|
|
365
|
+
"signing_identifier": "",
|
|
366
|
+
"team_identifier": "",
|
|
367
|
+
"get_task_allow": false,
|
|
368
|
+
"signed": false,
|
|
369
|
+
]
|
|
370
|
+
|
|
371
|
+
guard let url = Bundle.main.executableURL,
|
|
372
|
+
let data = try? Data(contentsOf: url, options: .mappedIfSafe) else {
|
|
373
|
+
result["status"] = "error"
|
|
374
|
+
result["error"] = "The main executable could not be read."
|
|
375
|
+
return result
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
guard let slice = machOSliceOffset(data) else {
|
|
379
|
+
result["status"] = "error"
|
|
380
|
+
result["error"] = "The main executable is not a recognised 64-bit Mach-O image."
|
|
381
|
+
return result
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
guard let signature = codeSignatureRange(data, sliceOffset: slice) else {
|
|
385
|
+
// Unsigned - legitimate for the jailbroken-device build. The server
|
|
386
|
+
// only compares these fields when a baseline is configured.
|
|
387
|
+
result["signature_absent"] = true
|
|
388
|
+
return result
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
result["signed"] = true
|
|
392
|
+
parseEmbeddedSignature(
|
|
393
|
+
data, offset: signature.offset, size: signature.size, into: &result
|
|
394
|
+
)
|
|
395
|
+
return result
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// MARK: - Code integrity
|
|
399
|
+
|
|
400
|
+
/// Compares each loaded image's `__TEXT,__text` in memory against the same
|
|
401
|
+
/// bytes on disk. This is the iOS analogue of Android's native
|
|
402
|
+
/// `code_integrity`, and its field names, the same on both platforms, are
|
|
403
|
+
/// fixed by the measurement contract.
|
|
404
|
+
///
|
|
405
|
+
/// **Only the app bucket can be measured on iOS, and that is a platform
|
|
406
|
+
/// fact rather than an omission.** Android compares libc and libart
|
|
407
|
+
/// against `/apex/.../libc.so`, real files with real bytes. iOS system
|
|
408
|
+
/// libraries do not exist as individual files: dyld combines them into the
|
|
409
|
+
/// shared cache, so there is nothing to `open()` for UIKit or libobjc.
|
|
410
|
+
/// Those images are therefore counted as *unreadable*, never as clean.
|
|
411
|
+
///
|
|
412
|
+
/// That distinction is the whole point. An implementation that skipped
|
|
413
|
+
/// them silently would report `ext_compared_bytes: 0, ext_diff_bytes: 0`,
|
|
414
|
+
/// which scores exactly like a pristine device while having measured
|
|
415
|
+
/// nothing. `checked` stays true because the app bucket really was
|
|
416
|
+
/// compared; `system_images_unreadable` and `system_bucket_reason` say why
|
|
417
|
+
/// the rest is empty.
|
|
418
|
+
///
|
|
419
|
+
/// The app bucket says how much of itself it measured (collector 5): every
|
|
420
|
+
/// bundle image dyld has loaded
|
|
421
|
+
/// is expected, and each is compared, unreadable (its file could not be
|
|
422
|
+
/// read in full) or skipped (no usable `__text`, a `__TEXT` over the
|
|
423
|
+
/// budget, no live address), with the `__text` bytes of each, and each
|
|
424
|
+
/// one not compared named with why. One readable framework no longer makes
|
|
425
|
+
/// the bucket look complete; `app_complete` says whether it is, and
|
|
426
|
+
/// `main_executable_compared` whether the main executable was among them.
|
|
427
|
+
private func probeCodeIntegrity() -> [String: Any] {
|
|
428
|
+
var result: [String: Any] = [
|
|
429
|
+
"status": "ok",
|
|
430
|
+
"checked": false,
|
|
431
|
+
// Core and ext are the system buckets. On iOS they are structurally
|
|
432
|
+
// unmeasurable; reported as zero-compared with a reason, so the
|
|
433
|
+
// server can tell "nothing to compare" from "compared and clean".
|
|
434
|
+
"core_compared_bytes": 0,
|
|
435
|
+
"core_diff_bytes": 0,
|
|
436
|
+
"diff_bytes": 0,
|
|
437
|
+
"ext_compared_bytes": 0,
|
|
438
|
+
"ext_diff_bytes": 0,
|
|
439
|
+
"ext_libs_diff": 0,
|
|
440
|
+
"app_compared_bytes": 0,
|
|
441
|
+
"app_diff_bytes": 0,
|
|
442
|
+
"app_libs_diff": 0,
|
|
443
|
+
"diffed_libs": "",
|
|
444
|
+
"system_images_unreadable": 0,
|
|
445
|
+
"system_bucket_reason": "dyld_shared_cache_has_no_backing_files",
|
|
446
|
+
]
|
|
447
|
+
|
|
448
|
+
let bundlePath = Bundle.main.bundlePath
|
|
449
|
+
let mainName = Bundle.main.executableURL?.lastPathComponent
|
|
450
|
+
var images: [ProbeReads.AppImage] = []
|
|
451
|
+
var systemUnreadable = 0
|
|
452
|
+
|
|
453
|
+
for index in 0..<_dyld_image_count() {
|
|
454
|
+
guard let namePointer = _dyld_get_image_name(index),
|
|
455
|
+
let header = _dyld_get_image_header(index) else {
|
|
456
|
+
// Counted, never dropped: it may be the app's (collector 8).
|
|
457
|
+
images.append(ProbeReads.unlistedImage(index: Int(index)))
|
|
458
|
+
continue
|
|
459
|
+
}
|
|
460
|
+
let path = String(cString: namePointer)
|
|
461
|
+
|
|
462
|
+
// Anything outside our own bundle is in the shared cache. Counted,
|
|
463
|
+
// not compared, and never treated as clean.
|
|
464
|
+
guard path.hasPrefix(bundlePath) else {
|
|
465
|
+
systemUnreadable += 1
|
|
466
|
+
continue
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Every bundle image is expected; one not compared is unreadable or
|
|
470
|
+
// skipped, never dropped, and named by its path in the bundle.
|
|
471
|
+
let label = ProbeReads.bundleLabel(imagePath: path, bundlePath: bundlePath)
|
|
472
|
+
images.append(ProbeReads.AppImage(
|
|
473
|
+
path: label,
|
|
474
|
+
isMainExecutable: label == mainName,
|
|
475
|
+
outcome: compareImage(path: path, header: header,
|
|
476
|
+
slide: _dyld_get_image_vmaddr_slide(index))
|
|
477
|
+
))
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// checked means "the app bucket was genuinely compared". If not one
|
|
481
|
+
// bundle image could be read, nothing was measured and saying
|
|
482
|
+
// otherwise would be the exact lie this probe exists to avoid.
|
|
483
|
+
result.merge(ProbeReads.appCoverage(images, systemImagesUnreadable: systemUnreadable)) { _, figure in
|
|
484
|
+
figure
|
|
485
|
+
}
|
|
486
|
+
return result
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/// One bundle image's __TEXT in memory against its file. The reads are
|
|
490
|
+
/// here; what they mean is ProbeReads'.
|
|
491
|
+
private func compareImage(
|
|
492
|
+
path: String, header: UnsafePointer<mach_header>, slide: Int
|
|
493
|
+
) -> ProbeReads.ImageOutcome {
|
|
494
|
+
let span: ProbeReads.TextSpan
|
|
495
|
+
let read = textSpan(of: header)
|
|
496
|
+
switch read.span {
|
|
497
|
+
case .success(let found):
|
|
498
|
+
span = found
|
|
499
|
+
case .failure(let problem):
|
|
500
|
+
return .skipped(textBytes: problem.textSize, why: problem.reason)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Where the image is in its file (collector 8): the file itself, or its
|
|
504
|
+
// arm64 slice in a universal file -- frameworks nothing thinned keep
|
|
505
|
+
// that form, and every offset of the image counts from the slice.
|
|
506
|
+
let slice: Int
|
|
507
|
+
switch readFileRange(path: path, offset: 0, length: 4096) {
|
|
508
|
+
case .success(let head):
|
|
509
|
+
guard let found = ProbeReads.arm64Slice([UInt8](head)) else {
|
|
510
|
+
return .unreadable(textBytes: span.textSize,
|
|
511
|
+
why: "neither a 64-bit Mach-O file nor a universal one with an arm64 image")
|
|
512
|
+
}
|
|
513
|
+
slice = found
|
|
514
|
+
case .failure(let problem):
|
|
515
|
+
return .unreadable(textBytes: span.textSize, why: problem.reason)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// The file must be the build that is running (collector 8): an app
|
|
519
|
+
// updated while it ran has the old code in memory and the new file on
|
|
520
|
+
// disk. Its header and load commands are read from the file and its
|
|
521
|
+
// LC_UUID compared with the loaded one's.
|
|
522
|
+
let commandsSize = Int(UnsafeRawPointer(header)
|
|
523
|
+
.assumingMemoryBound(to: mach_header_64.self).pointee.sizeofcmds)
|
|
524
|
+
if let loaded = read.uuid,
|
|
525
|
+
case .success(let fileHeader) = readFileRange(
|
|
526
|
+
path: path, offset: slice, length: MemoryLayout<mach_header_64>.size + commandsSize),
|
|
527
|
+
let problem = ProbeReads.buildProblem(
|
|
528
|
+
loaded: loaded, onDisk: ProbeReads.machOUUID([UInt8](fileHeader)), textSize: span.textSize) {
|
|
529
|
+
return .skipped(textBytes: problem.textSize, why: problem.reason)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
let onDisk: Data
|
|
533
|
+
switch readFileRange(path: path, offset: slice + span.segmentFileOffset, length: span.segmentSize) {
|
|
534
|
+
case .success(let data):
|
|
535
|
+
onDisk = data
|
|
536
|
+
case .failure(let problem):
|
|
537
|
+
// A bundle image we cannot read is not clean - and it is the app's
|
|
538
|
+
// own, not one of the system's (collector 4 counted it there).
|
|
539
|
+
return .unreadable(textBytes: span.textSize, why: problem.reason)
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
guard let liveBase = UnsafeRawPointer(
|
|
543
|
+
bitPattern: UInt(span.segmentVMAddress) &+ UInt(bitPattern: slide)
|
|
544
|
+
) else {
|
|
545
|
+
return .skipped(textBytes: span.textSize, why: "no live address for __TEXT")
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// One pass over the whole __TEXT segment, attributing each differing
|
|
549
|
+
// byte to __text or to the rest. __text is what gets scored; the
|
|
550
|
+
// remainder - the Mach-O header, __stubs, __cstring, __unwind_info -
|
|
551
|
+
// is reported separately because it has no clean baseline yet. If it
|
|
552
|
+
// measures zero on hardware, the header's unused mach_header_64
|
|
553
|
+
// .reserved field becomes a safe flip target, the direct analogue of
|
|
554
|
+
// the ELF EI_PAD bytes used to close the app bucket on Android.
|
|
555
|
+
// Until then it must not influence a verdict.
|
|
556
|
+
var textDiffering = 0
|
|
557
|
+
var segmentDiffering = 0
|
|
558
|
+
onDisk.withUnsafeBytes { (diskBytes: UnsafeRawBufferPointer) in
|
|
559
|
+
let liveBytes = liveBase.assumingMemoryBound(to: UInt8.self)
|
|
560
|
+
let textStart = span.textOffsetInSegment
|
|
561
|
+
let textEnd = textStart + span.textSize
|
|
562
|
+
for offset in 0..<onDisk.count where liveBytes[offset] != diskBytes[offset] {
|
|
563
|
+
segmentDiffering += 1
|
|
564
|
+
if offset >= textStart && offset < textEnd {
|
|
565
|
+
textDiffering += 1
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return .compared(textBytes: span.textSize, textDiffering: textDiffering,
|
|
570
|
+
segmentBytes: onDisk.count, segmentDiffering: segmentDiffering)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/// Finds the __TEXT segment and its __text section in an image's load
|
|
574
|
+
/// commands, and its encryption; ProbeReads decides whether they can be
|
|
575
|
+
/// compared. Every command is read: LC_ENCRYPTION_INFO_64 follows the
|
|
576
|
+
/// segments, so stopping at __TEXT would never see it.
|
|
577
|
+
/// Where the image's __text lies, and its LC_UUID as loaded (for the build
|
|
578
|
+
/// check, collector 8).
|
|
579
|
+
private func textSpan(of header: UnsafePointer<mach_header>)
|
|
580
|
+
-> (span: Result<ProbeReads.TextSpan, ProbeReads.CoverageProblem>, uuid: [UInt8]?) {
|
|
581
|
+
let raw = UnsafeRawPointer(header)
|
|
582
|
+
let header64 = raw.assumingMemoryBound(to: mach_header_64.self)
|
|
583
|
+
guard header64.pointee.magic == 0xfeed_facf else {
|
|
584
|
+
return (.failure(ProbeReads.CoverageProblem(reason: "not a 64-bit Mach-O image", textSize: 0)), nil)
|
|
585
|
+
}
|
|
586
|
+
var cursor = raw.advanced(by: MemoryLayout<mach_header_64>.size)
|
|
587
|
+
var found: Result<ProbeReads.TextSpan, ProbeReads.CoverageProblem>?
|
|
588
|
+
var encryption: (offset: UInt32, size: UInt32, id: UInt32)?
|
|
589
|
+
var uuid: [UInt8]?
|
|
590
|
+
|
|
591
|
+
for _ in 0..<header64.pointee.ncmds {
|
|
592
|
+
let command = cursor.assumingMemoryBound(to: load_command.self)
|
|
593
|
+
if command.pointee.cmd == UInt32(LC_SEGMENT_64), found == nil {
|
|
594
|
+
let segment = cursor.assumingMemoryBound(to: segment_command_64.self)
|
|
595
|
+
if name(of: segment.pointee.segname) == "__TEXT" {
|
|
596
|
+
var text: (offset: UInt64, size: UInt64)?
|
|
597
|
+
var section = cursor.advanced(by: MemoryLayout<segment_command_64>.size)
|
|
598
|
+
for _ in 0..<segment.pointee.nsects {
|
|
599
|
+
let entry = section.assumingMemoryBound(to: section_64.self)
|
|
600
|
+
if name(of: entry.pointee.sectname) == "__text" {
|
|
601
|
+
text = (offset: UInt64(entry.pointee.offset), size: entry.pointee.size)
|
|
602
|
+
break
|
|
603
|
+
}
|
|
604
|
+
section = section.advanced(by: MemoryLayout<section_64>.size)
|
|
605
|
+
}
|
|
606
|
+
found = ProbeReads.textSpan(
|
|
607
|
+
segmentFileOffset: segment.pointee.fileoff,
|
|
608
|
+
segmentFileSize: segment.pointee.filesize,
|
|
609
|
+
segmentVMAddress: segment.pointee.vmaddr,
|
|
610
|
+
section: text
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
} else if command.pointee.cmd == UInt32(LC_ENCRYPTION_INFO_64) {
|
|
614
|
+
let info = cursor.assumingMemoryBound(to: encryption_info_command_64.self)
|
|
615
|
+
encryption = (offset: info.pointee.cryptoff, size: info.pointee.cryptsize,
|
|
616
|
+
id: info.pointee.cryptid)
|
|
617
|
+
} else if command.pointee.cmd == UInt32(LC_UUID) {
|
|
618
|
+
let command = cursor.assumingMemoryBound(to: uuid_command.self)
|
|
619
|
+
uuid = withUnsafeBytes(of: command.pointee.uuid) { Array($0) }
|
|
620
|
+
}
|
|
621
|
+
cursor = cursor.advanced(by: Int(command.pointee.cmdsize))
|
|
622
|
+
}
|
|
623
|
+
guard let result = found else {
|
|
624
|
+
return (.failure(ProbeReads.CoverageProblem(reason: "no __TEXT segment", textSize: 0)), uuid)
|
|
625
|
+
}
|
|
626
|
+
if case .success(let span) = result, let crypt = encryption,
|
|
627
|
+
let problem = ProbeReads.encryptionProblem(
|
|
628
|
+
cryptOffset: crypt.offset, cryptSize: crypt.size, cryptID: crypt.id, span: span) {
|
|
629
|
+
return (.failure(problem), uuid)
|
|
630
|
+
}
|
|
631
|
+
return (result, uuid)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/// Mach-O stores segment and section names as a fixed 16-byte field that is
|
|
635
|
+
/// NOT necessarily NUL-terminated, so it cannot be read with String(cString:).
|
|
636
|
+
private func name(of field: Any) -> String {
|
|
637
|
+
var bytes: [UInt8] = []
|
|
638
|
+
withUnsafeBytes(of: field) { raw in
|
|
639
|
+
for byte in raw {
|
|
640
|
+
if byte == 0 { break }
|
|
641
|
+
bytes.append(byte)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return String(decoding: bytes, as: UTF8.self)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/// Exactly `length` bytes of the file from `offset`, or why not: a file
|
|
648
|
+
/// that cannot be opened, a seek or read that fails, or a short read.
|
|
649
|
+
private func readFileRange(
|
|
650
|
+
path: String, offset: Int, length: Int
|
|
651
|
+
) -> Result<Data, ProbeReads.CoverageProblem> {
|
|
652
|
+
guard let handle = FileHandle(forReadingAtPath: path) else {
|
|
653
|
+
return .failure(ProbeReads.CoverageProblem(
|
|
654
|
+
reason: "unreadable: the file could not be opened", textSize: 0))
|
|
655
|
+
}
|
|
656
|
+
defer { try? handle.close() }
|
|
657
|
+
do {
|
|
658
|
+
try handle.seek(toOffset: UInt64(offset))
|
|
659
|
+
let data = try handle.read(upToCount: length) ?? Data()
|
|
660
|
+
if let problem = ProbeReads.readProblem(got: data.count, wanted: length) {
|
|
661
|
+
return .failure(ProbeReads.CoverageProblem(reason: problem, textSize: 0))
|
|
662
|
+
}
|
|
663
|
+
return .success(data)
|
|
664
|
+
} catch {
|
|
665
|
+
return .failure(ProbeReads.CoverageProblem(
|
|
666
|
+
reason: "unreadable: \(String(String(describing: error).prefix(120)))", textSize: 0))
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// MARK: - Mach-O and code-signature parsing
|
|
671
|
+
|
|
672
|
+
// Integers are assembled byte by byte rather than loaded through a bound
|
|
673
|
+
// pointer: the offsets below are not guaranteed to be naturally aligned,
|
|
674
|
+
// and loadUnaligned(fromByteOffset:as:) is unavailable at our iOS 15.0
|
|
675
|
+
// deployment target.
|
|
676
|
+
|
|
677
|
+
private func u32le(_ d: Data, _ o: Int) -> UInt32? {
|
|
678
|
+
guard o >= 0, o + 4 <= d.count else { return nil }
|
|
679
|
+
return UInt32(d[o]) | (UInt32(d[o + 1]) << 8)
|
|
680
|
+
| (UInt32(d[o + 2]) << 16) | (UInt32(d[o + 3]) << 24)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
private func u32be(_ d: Data, _ o: Int) -> UInt32? {
|
|
684
|
+
guard o >= 0, o + 4 <= d.count else { return nil }
|
|
685
|
+
return (UInt32(d[o]) << 24) | (UInt32(d[o + 1]) << 16)
|
|
686
|
+
| (UInt32(d[o + 2]) << 8) | UInt32(d[o + 3])
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/// File offset of the 64-bit Mach-O image to inspect, resolving a fat
|
|
690
|
+
/// binary to its arm64 slice. Installed device binaries are thin; fat is
|
|
691
|
+
/// handled so the same code works on a locally built bundle.
|
|
692
|
+
///
|
|
693
|
+
/// Only MH_MAGIC_64 is accepted. A byte-swapped image would need every
|
|
694
|
+
/// subsequent field swapped too, and silently misparsing one is worse than
|
|
695
|
+
/// reporting that the image was not recognised.
|
|
696
|
+
private func machOSliceOffset(_ d: Data) -> Int? {
|
|
697
|
+
if let magic = u32le(d, 0), magic == 0xfeed_facf { return 0 }
|
|
698
|
+
|
|
699
|
+
guard let fat = u32be(d, 0), fat == 0xcafe_babe,
|
|
700
|
+
let count = u32be(d, 4), count < 64 else { return nil }
|
|
701
|
+
let cpuTypeArm64: UInt32 = 0x0100_000c
|
|
702
|
+
for index in 0..<Int(count) {
|
|
703
|
+
let base = 8 + index * 20
|
|
704
|
+
guard let cpu = u32be(d, base), let offset = u32be(d, base + 8) else {
|
|
705
|
+
return nil
|
|
706
|
+
}
|
|
707
|
+
if cpu == cpuTypeArm64,
|
|
708
|
+
let magic = u32le(d, Int(offset)), magic == 0xfeed_facf {
|
|
709
|
+
return Int(offset)
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return nil
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/// Walks the load commands for LC_CODE_SIGNATURE and returns the signature
|
|
716
|
+
/// blob's range. Its dataoff is relative to the start of the slice, so the
|
|
717
|
+
/// slice offset is added back for a fat image.
|
|
718
|
+
private func codeSignatureRange(
|
|
719
|
+
_ d: Data, sliceOffset: Int
|
|
720
|
+
) -> (offset: Int, size: Int)? {
|
|
721
|
+
guard let commandCount = u32le(d, sliceOffset + 16) else { return nil }
|
|
722
|
+
var cursor = sliceOffset + 32 // sizeof(struct mach_header_64)
|
|
723
|
+
|
|
724
|
+
for _ in 0..<Int(commandCount) {
|
|
725
|
+
guard let command = u32le(d, cursor),
|
|
726
|
+
let commandSize = u32le(d, cursor + 4),
|
|
727
|
+
commandSize >= 8 else { return nil }
|
|
728
|
+
|
|
729
|
+
if command == 0x1d { // LC_CODE_SIGNATURE
|
|
730
|
+
guard let dataOffset = u32le(d, cursor + 8),
|
|
731
|
+
let dataSize = u32le(d, cursor + 12) else { return nil }
|
|
732
|
+
let absolute = sliceOffset + Int(dataOffset)
|
|
733
|
+
guard absolute >= 0, absolute + Int(dataSize) <= d.count else {
|
|
734
|
+
return nil
|
|
735
|
+
}
|
|
736
|
+
return (absolute, Int(dataSize))
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
cursor += Int(commandSize)
|
|
740
|
+
if cursor >= d.count { return nil }
|
|
741
|
+
}
|
|
742
|
+
return nil
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/// Parses the embedded signature SuperBlob. The CodeDirectory carries the
|
|
746
|
+
/// signing identifier; the entitlements slot carries an XML plist holding
|
|
747
|
+
/// the team identifier and get-task-allow. Every field in these structures
|
|
748
|
+
/// is big-endian irrespective of the host byte order.
|
|
749
|
+
private func parseEmbeddedSignature(
|
|
750
|
+
_ d: Data, offset: Int, size: Int, into result: inout [String: Any]
|
|
751
|
+
) {
|
|
752
|
+
guard let magic = u32be(d, offset), magic == 0xfade_0cc0,
|
|
753
|
+
let blobCount = u32be(d, offset + 8), blobCount < 128 else {
|
|
754
|
+
result["signature_parse_error"] = "Not an embedded signature SuperBlob."
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
for index in 0..<Int(blobCount) {
|
|
759
|
+
let entry = offset + 12 + index * 8
|
|
760
|
+
guard let slot = u32be(d, entry),
|
|
761
|
+
let relative = u32be(d, entry + 4) else { return }
|
|
762
|
+
let blob = offset + Int(relative)
|
|
763
|
+
guard blob + 8 <= offset + size else { continue }
|
|
764
|
+
|
|
765
|
+
switch slot {
|
|
766
|
+
case 0: // CSSLOT_CODEDIRECTORY
|
|
767
|
+
guard let cdMagic = u32be(d, blob), cdMagic == 0xfade_0c02,
|
|
768
|
+
let flags = u32be(d, blob + 12),
|
|
769
|
+
let identOffset = u32be(d, blob + 20) else { continue }
|
|
770
|
+
result["code_directory_flags"] = Int(flags)
|
|
771
|
+
|
|
772
|
+
let start = blob + Int(identOffset)
|
|
773
|
+
var end = start
|
|
774
|
+
while end < d.count, d[end] != 0 { end += 1 }
|
|
775
|
+
if end > start,
|
|
776
|
+
let text = String(data: d.subdata(in: start..<end), encoding: .utf8) {
|
|
777
|
+
result["signing_identifier"] = text
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
case 5: // CSSLOT_ENTITLEMENTS
|
|
781
|
+
guard let entitlementMagic = u32be(d, blob),
|
|
782
|
+
entitlementMagic == 0xfade_7171,
|
|
783
|
+
let length = u32be(d, blob + 4), length > 8 else { continue }
|
|
784
|
+
let start = blob + 8
|
|
785
|
+
let stop = blob + Int(length)
|
|
786
|
+
guard stop <= d.count, start < stop else { continue }
|
|
787
|
+
|
|
788
|
+
let object = try? PropertyListSerialization.propertyList(
|
|
789
|
+
from: d.subdata(in: start..<stop), options: [], format: nil
|
|
790
|
+
)
|
|
791
|
+
if let parsed = object as? [String: Any] {
|
|
792
|
+
result["team_identifier"] =
|
|
793
|
+
parsed["com.apple.developer.team-identifier"] as? String ?? ""
|
|
794
|
+
result["get_task_allow"] =
|
|
795
|
+
(parsed["get-task-allow"] as? Bool) ?? false
|
|
796
|
+
result["entitlement_count"] = parsed.count
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
default:
|
|
800
|
+
continue
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/// P_TRACED via sysctl is the standard, non-private debugger check.
|
|
806
|
+
private func probeDebugger() -> [String: Any] {
|
|
807
|
+
var info = kinfo_proc()
|
|
808
|
+
var size = MemoryLayout<kinfo_proc>.stride
|
|
809
|
+
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
|
|
810
|
+
|
|
811
|
+
let status = sysctl(&mib, u_int(mib.count), &info, &size, nil, 0)
|
|
812
|
+
if status != 0 {
|
|
813
|
+
return [
|
|
814
|
+
"status": "error",
|
|
815
|
+
"error": "sysctl failed with errno \(errno)",
|
|
816
|
+
]
|
|
817
|
+
}
|
|
818
|
+
return [
|
|
819
|
+
"status": "ok",
|
|
820
|
+
"traced": (info.kp_proc.p_flag & P_TRACED) != 0,
|
|
821
|
+
]
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private func probeJailbreakFiles() -> [String: Any] {
|
|
825
|
+
// The Simulator's filesystem is the MAC's filesystem, and macOS really
|
|
826
|
+
// does ship /bin/bash, /bin/sh, /usr/bin/ssh and /usr/sbin/sshd - four
|
|
827
|
+
// entries in the list below. Running this check there produces a
|
|
828
|
+
// confident false positive on a perfectly clean machine, so it is
|
|
829
|
+
// skipped with an explicit reason rather than silently returning empty.
|
|
830
|
+
#if targetEnvironment(simulator)
|
|
831
|
+
return [
|
|
832
|
+
"status": "unsupported",
|
|
833
|
+
"reason": "simulator_filesystem_is_the_host",
|
|
834
|
+
"found_paths": [String](),
|
|
835
|
+
]
|
|
836
|
+
#else
|
|
837
|
+
var found: [String] = []
|
|
838
|
+
let manager = FileManager.default
|
|
839
|
+
for path in IntegrityProbeManager.jailbreakPaths {
|
|
840
|
+
// On a clean device the sandbox makes most of these unreadable,
|
|
841
|
+
// which is indistinguishable from absent - and that is fine. A
|
|
842
|
+
// false negative here is safe; the dyld and environment probes
|
|
843
|
+
// catch in-process instrumentation regardless.
|
|
844
|
+
if manager.fileExists(atPath: path) {
|
|
845
|
+
found.append(path)
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return [
|
|
849
|
+
"status": "ok",
|
|
850
|
+
"found_paths": found,
|
|
851
|
+
"checked_count": IntegrityProbeManager.jailbreakPaths.count,
|
|
852
|
+
]
|
|
853
|
+
#endif
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/// Attempt a write outside the app container. On a sandboxed device this
|
|
857
|
+
/// must fail; success means the sandbox is not being enforced, which the
|
|
858
|
+
/// server treats as a hard block.
|
|
859
|
+
private func probeSandbox() -> [String: Any] {
|
|
860
|
+
let path = "/private/devicetrust_sandbox_probe.txt"
|
|
861
|
+
var succeeded = false
|
|
862
|
+
do {
|
|
863
|
+
try "probe".write(toFile: path, atomically: true, encoding: .utf8)
|
|
864
|
+
succeeded = true
|
|
865
|
+
// Leave nothing behind on a device where this unexpectedly works.
|
|
866
|
+
try? FileManager.default.removeItem(atPath: path)
|
|
867
|
+
} catch {
|
|
868
|
+
succeeded = false
|
|
869
|
+
}
|
|
870
|
+
return [
|
|
871
|
+
"status": "ok",
|
|
872
|
+
"write_outside_sandbox_succeeded": succeeded,
|
|
873
|
+
"probe_path": path,
|
|
874
|
+
]
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/// The iOS analogue of scanning /proc/self/maps: walk the images actually
|
|
878
|
+
/// loaded into this process.
|
|
879
|
+
private func probeDyldImages() -> [String: Any] {
|
|
880
|
+
var tokens = Set<String>()
|
|
881
|
+
var suspiciousCount = 0
|
|
882
|
+
var unnamed = 0
|
|
883
|
+
let imageCount = _dyld_image_count()
|
|
884
|
+
let bundlePath = Bundle.main.bundlePath
|
|
885
|
+
let mainName = Bundle.main.executableURL?.lastPathComponent
|
|
886
|
+
|
|
887
|
+
for index in 0..<imageCount {
|
|
888
|
+
guard let raw = _dyld_get_image_name(index) else {
|
|
889
|
+
unnamed += 1
|
|
890
|
+
continue
|
|
891
|
+
}
|
|
892
|
+
let named = ProbeReads.imageTokens(
|
|
893
|
+
path: String(cString: raw), bundlePath: bundlePath, mainExecutable: mainName,
|
|
894
|
+
tokens: IntegrityProbeManager.suspiciousImageTokens)
|
|
895
|
+
tokens.formUnion(named)
|
|
896
|
+
if !named.isEmpty { suspiciousCount += 1 }
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
var result: [String: Any] = [
|
|
900
|
+
"status": "ok",
|
|
901
|
+
"suspicious_tokens": Array(tokens).sorted(),
|
|
902
|
+
"suspicious_image_count": suspiciousCount,
|
|
903
|
+
"image_count": Int(imageCount),
|
|
904
|
+
]
|
|
905
|
+
// A name dyld did not give could be the one listed (collector 8).
|
|
906
|
+
let unread = ProbeReads.unnamedImages(unnamed, of: Int(imageCount))
|
|
907
|
+
if !unread.isEmpty { result["unread"] = unread }
|
|
908
|
+
return result
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
private func probeEnvironment() -> [String: Any] {
|
|
912
|
+
let inserted = ProcessInfo.processInfo
|
|
913
|
+
.environment["DYLD_INSERT_LIBRARIES"] ?? ""
|
|
914
|
+
return [
|
|
915
|
+
"status": "ok",
|
|
916
|
+
"dyld_insert_libraries": String(inserted.prefix(IntegrityProbeManager.maxTextLength)),
|
|
917
|
+
]
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
private func probeSimulator() -> [String: Any] {
|
|
921
|
+
#if targetEnvironment(simulator)
|
|
922
|
+
let isSimulator = true
|
|
923
|
+
#else
|
|
924
|
+
let isSimulator = ProcessInfo.processInfo
|
|
925
|
+
.environment["SIMULATOR_DEVICE_NAME"] != nil
|
|
926
|
+
#endif
|
|
927
|
+
return [
|
|
928
|
+
"status": "ok",
|
|
929
|
+
"is_simulator": isSimulator,
|
|
930
|
+
"model": deviceModelIdentifier(),
|
|
931
|
+
]
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// MARK: - Helpers
|
|
935
|
+
|
|
936
|
+
private func deviceModelIdentifier() -> String {
|
|
937
|
+
var systemInfo = utsname()
|
|
938
|
+
uname(&systemInfo)
|
|
939
|
+
let mirror = Mirror(reflecting: systemInfo.machine)
|
|
940
|
+
return mirror.children.reduce(into: "") { identifier, element in
|
|
941
|
+
guard let value = element.value as? Int8, value != 0 else { return }
|
|
942
|
+
identifier += String(UnicodeScalar(UInt8(bitPattern: value)))
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/// The OS build, for example "21F90" (kern.osversion).
|
|
947
|
+
private func osBuild() -> String {
|
|
948
|
+
var size = 0
|
|
949
|
+
guard sysctlbyname("kern.osversion", nil, &size, nil, 0) == 0, size > 0 else { return "" }
|
|
950
|
+
var buffer = [CChar](repeating: 0, count: size)
|
|
951
|
+
guard sysctlbyname("kern.osversion", &buffer, &size, nil, 0) == 0 else { return "" }
|
|
952
|
+
return String(cString: buffer)
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
private func UIDeviceOSVersion() -> String {
|
|
956
|
+
let version = ProcessInfo.processInfo.operatingSystemVersion
|
|
957
|
+
return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
|
|
958
|
+
}
|
|
959
|
+
}
|