@necessarysdk/react-native 0.0.0-stage → 0.1.5

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.
Files changed (114) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +2 -0
  4. package/NecessaryReactNative.podspec +23 -0
  5. package/README.md +429 -2
  6. package/android/build.gradle +83 -0
  7. package/android/src/main/AndroidManifest.xml +41 -0
  8. package/android/src/main/cpp/CMakeLists.txt +7 -0
  9. package/android/src/main/cpp/code_integrity.cpp +484 -0
  10. package/android/src/main/java/com/necessarysdk/reactnative/Causes.kt +49 -0
  11. package/android/src/main/java/com/necessarysdk/reactnative/InstallationKeyManager.kt +388 -0
  12. package/android/src/main/java/com/necessarysdk/reactnative/IntegrityProbeManager.kt +1152 -0
  13. package/android/src/main/java/com/necessarysdk/reactnative/KeyPresence.kt +62 -0
  14. package/android/src/main/java/com/necessarysdk/reactnative/NecessaryModules.kt +94 -0
  15. package/android/src/main/java/com/necessarysdk/reactnative/NecessaryNative.kt +323 -0
  16. package/android/src/main/java/com/necessarysdk/reactnative/NecessaryPackage.kt +38 -0
  17. package/android/src/main/java/com/necessarysdk/reactnative/ProbeReads.kt +314 -0
  18. package/android/src/main/java/com/necessarysdk/reactnative/ScreenGuard.kt +56 -0
  19. package/android/src/main/java/com/necessarysdk/reactnative/SecureStore.kt +127 -0
  20. package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyEntry.kt +57 -0
  21. package/android/src/main/java/com/necessarysdk/reactnative/StepUpKeyManager.kt +762 -0
  22. package/contract/report-1/android-8-ios-5/RULES.md +149 -0
  23. package/contract/report-1/android-8-ios-5/report.schema.json +2753 -0
  24. package/contract/report-1/android-8-ios-5/samples/android-8-complete.json +205 -0
  25. package/contract/report-1/android-8-ios-5/samples/android-8-incomplete.json +205 -0
  26. package/contract/report-1/android-8-ios-5/samples/android-8-invalid.json +205 -0
  27. package/contract/report-1/android-8-ios-5/samples/android-8-location-one-fix.json +228 -0
  28. package/contract/report-1/android-8-ios-5/samples/android-8-location-two-fixes.json +228 -0
  29. package/contract/report-1/android-8-ios-5/samples/android-8-refused.json +208 -0
  30. package/contract/report-1/android-8-ios-5/samples/ios-5-complete.json +106 -0
  31. package/contract/report-1/android-8-ios-5/samples/ios-5-incomplete.json +105 -0
  32. package/contract/report-1/android-8-ios-5/samples/ios-5-invalid.json +107 -0
  33. package/contract/report-1/android-8-ios-5/samples/ios-5-partial.json +110 -0
  34. package/contract/report-1/android-8-ios-5/samples/ios-5-refused.json +109 -0
  35. package/doc/integration_guide.md +771 -0
  36. package/doc/measurements.md +774 -0
  37. package/ios/InstallationKeyManager.swift +694 -0
  38. package/ios/IntegrityProbeManager.swift +960 -0
  39. package/ios/NecessaryModules.h +34 -0
  40. package/ios/NecessaryModules.mm +208 -0
  41. package/ios/NecessaryNative.swift +327 -0
  42. package/ios/PrivacyInfo.xcprivacy +14 -0
  43. package/ios/ProbeReads.swift +353 -0
  44. package/lib/client.d.ts +105 -0
  45. package/lib/client.js +176 -0
  46. package/lib/collector.d.ts +18 -0
  47. package/lib/collector.js +49 -0
  48. package/lib/deviceTrust.d.ts +253 -0
  49. package/lib/deviceTrust.js +690 -0
  50. package/lib/errors.d.ts +43 -0
  51. package/lib/errors.js +56 -0
  52. package/lib/index.d.ts +18 -0
  53. package/lib/index.js +34 -0
  54. package/lib/installationKey.d.ts +62 -0
  55. package/lib/installationKey.js +185 -0
  56. package/lib/installationStore.d.ts +49 -0
  57. package/lib/installationStore.js +127 -0
  58. package/lib/native/NativeNecessaryInstallationKey.d.ts +10 -0
  59. package/lib/native/NativeNecessaryInstallationKey.js +4 -0
  60. package/lib/native/NativeNecessaryIntegrity.d.ts +8 -0
  61. package/lib/native/NativeNecessaryIntegrity.js +4 -0
  62. package/lib/native/NativeNecessarySecureScreen.d.ts +7 -0
  63. package/lib/native/NativeNecessarySecureScreen.js +4 -0
  64. package/lib/native/NativeNecessaryStepUpKey.d.ts +9 -0
  65. package/lib/native/NativeNecessaryStepUpKey.js +4 -0
  66. package/lib/native/NativeNecessaryStorage.d.ts +14 -0
  67. package/lib/native/NativeNecessaryStorage.js +4 -0
  68. package/lib/protocol.d.ts +24 -0
  69. package/lib/protocol.js +50 -0
  70. package/lib/reinstallHint.d.ts +23 -0
  71. package/lib/reinstallHint.js +65 -0
  72. package/lib/report.d.ts +62 -0
  73. package/lib/report.js +140 -0
  74. package/lib/requestProof.d.ts +116 -0
  75. package/lib/requestProof.js +220 -0
  76. package/lib/secureScreen.d.ts +15 -0
  77. package/lib/secureScreen.js +32 -0
  78. package/lib/stepUpKey.d.ts +105 -0
  79. package/lib/stepUpKey.js +187 -0
  80. package/lib/transport.d.ts +39 -0
  81. package/lib/transport.js +117 -0
  82. package/lib/util/bytes.d.ts +18 -0
  83. package/lib/util/bytes.js +207 -0
  84. package/lib/util/native.d.ts +20 -0
  85. package/lib/util/native.js +49 -0
  86. package/lib/util/release.d.ts +2 -0
  87. package/lib/util/release.js +7 -0
  88. package/lib/util/target.d.ts +18 -0
  89. package/lib/util/target.js +128 -0
  90. package/package.json +81 -4
  91. package/src/client.ts +221 -0
  92. package/src/collector.ts +64 -0
  93. package/src/deviceTrust.ts +842 -0
  94. package/src/errors.ts +71 -0
  95. package/src/index.ts +19 -0
  96. package/src/installationKey.ts +223 -0
  97. package/src/installationStore.ts +143 -0
  98. package/src/native/NativeNecessaryInstallationKey.ts +12 -0
  99. package/src/native/NativeNecessaryIntegrity.ts +15 -0
  100. package/src/native/NativeNecessarySecureScreen.ts +9 -0
  101. package/src/native/NativeNecessaryStepUpKey.ts +11 -0
  102. package/src/native/NativeNecessaryStorage.ts +16 -0
  103. package/src/protocol.ts +52 -0
  104. package/src/reinstallHint.ts +69 -0
  105. package/src/report.ts +174 -0
  106. package/src/requestProof.ts +295 -0
  107. package/src/secureScreen.ts +29 -0
  108. package/src/stepUpKey.ts +238 -0
  109. package/src/transport.ts +145 -0
  110. package/src/util/bytes.ts +194 -0
  111. package/src/util/native.ts +56 -0
  112. package/src/util/release.ts +6 -0
  113. package/src/util/target.ts +132 -0
  114. package/vectors/client_server.json +264 -0
@@ -0,0 +1,1152 @@
1
+ package com.necessarysdk.reactnative
2
+
3
+ import android.Manifest
4
+ import android.accessibilityservice.AccessibilityServiceInfo
5
+ import android.annotation.SuppressLint
6
+ import android.app.Activity
7
+ import android.app.admin.DevicePolicyManager
8
+ import android.content.ComponentName
9
+ import android.content.Context
10
+ import android.content.pm.ApplicationInfo
11
+ import android.content.pm.PackageManager
12
+ import android.hardware.display.DisplayManager
13
+ import android.location.Location
14
+ import android.location.LocationManager
15
+ import android.net.ConnectivityManager
16
+ import android.net.NetworkCapabilities
17
+ import android.os.Build
18
+ import android.os.Debug
19
+ import android.os.SystemClock
20
+ import android.provider.Settings
21
+ import android.view.Display
22
+ import android.view.WindowManager
23
+ import android.view.accessibility.AccessibilityManager
24
+ import java.io.BufferedReader
25
+ import java.io.File
26
+ import java.io.FileInputStream
27
+ import java.io.InputStreamReader
28
+ import java.net.ConnectException
29
+ import java.net.InetSocketAddress
30
+ import java.net.NetworkInterface
31
+ import java.net.Socket
32
+ import java.security.MessageDigest
33
+ import java.util.Collections
34
+ import java.util.Locale
35
+ import java.util.concurrent.Executor
36
+ import java.util.concurrent.TimeUnit
37
+ import java.util.function.Consumer
38
+ import org.json.JSONObject
39
+
40
+ /**
41
+ * Local integrity measurement collector.
42
+ *
43
+ * IMPORTANT: these measurements are risk signals, not remote attestation. On a
44
+ * fully compromised OS an attacker may be able to lie about local state. The
45
+ * server must therefore score these signals together with server-observed
46
+ * device/account behavior and must never trust a client-provided score.
47
+ */
48
+ class IntegrityProbeManager(
49
+ private val context: Context,
50
+ // The visible activity, for the one measurement that needs a window:
51
+ // whether it is being recorded (screen_capture, Android 15 and later).
52
+ private val activity: () -> Activity? = { null },
53
+ ) {
54
+ companion object {
55
+ // 1 = name-based hook detection only (runtime_maps token scan).
56
+ // 2 = adds the structural probes: instrumentation_threads,
57
+ // exec_mappings, and the native code_integrity buckets. Every
58
+ // report carries this, so a stored report says which probe set
59
+ // produced it.
60
+ // 4 = volunteers device and network on every scan, and answers the
61
+ // optional measurements the server asks for.
62
+ // 5 = adds the optional measurements of what can read or drive the
63
+ // phone, screen capture, location and mock locations.
64
+ // 6 = sizes of writable-executable and unlabelled executable memory,
65
+ // and the process ABI, for runtime baselines published by the
66
+ // release pipeline.
67
+ // 7 = the page size, and the current signers apart from the lineage.
68
+ // 8 = a failed read is never a clean negative: the probe answers an
69
+ // error, or names the parts it could not read in "unread"
70
+ // (ProbeReads.kt). One location read answers both location
71
+ // measurements, which carry its fix_time.
72
+ private const val COLLECTOR_VERSION = 10
73
+ // Sent with every scan whether or not the server asked: the server
74
+ // tolerates probes beyond its plan but refuses a report missing one it
75
+ // asked for, so a probe new to this collector stays out of the plan and
76
+ // every older client keeps working.
77
+ private val volunteeredProbes = listOf("device", "network")
78
+ // The optional measurements this collector knows. Each is collected
79
+ // only when the server asks for it AND the app enabled it.
80
+ private val optionalMeasurements = setOf(
81
+ "local_addresses", "accessibility", "notification_access", "other_access",
82
+ "screen_capture", "location", "mock_location"
83
+ )
84
+ // Apps listed per capability, at most.
85
+ private const val MAX_APPS = 32
86
+ // Remote-control apps scammers have their victims install. Android 11
87
+ // and later show an app only the packages it names in advance, so the
88
+ // same names are in the manifest's <queries>.
89
+ private val remoteAccessPackages = listOf(
90
+ "com.anydesk.anydeskandroid",
91
+ "com.teamviewer.quicksupport.market",
92
+ "com.carriez.flutter_hbb",
93
+ "com.sand.airdroid"
94
+ )
95
+ private const val MAX_TEXT = 8192
96
+ // Every shell command is bounded: one hung
97
+ // child used to block its probe, and the whole scan, indefinitely.
98
+ private const val COMMAND_TIMEOUT_MS = 3000L
99
+ private val suspiciousRuntimeTokens = listOf(
100
+ "frida", "gadget", "objection", "xposed", "lsposed",
101
+ "substrate", "cydia", "zygisk", "riru", "magisk",
102
+ "kernelsu", "apatch"
103
+ )
104
+ private val rootPaths = listOf(
105
+ "/system/bin/su",
106
+ "/system/xbin/su",
107
+ "/sbin/su",
108
+ "/su/bin/su",
109
+ "/data/local/su",
110
+ "/data/local/bin/su",
111
+ "/data/local/xbin/su",
112
+ "/system/app/Superuser.apk",
113
+ "/system/app/SuperSU.apk",
114
+ "/sbin/magisk",
115
+ "/data/adb/magisk",
116
+ "/data/adb/modules",
117
+ "/data/adb/ksu",
118
+ "/data/adb/ap",
119
+ "/metadata/adb/magisk"
120
+ )
121
+ }
122
+
123
+ @Volatile private var cachedApkSha256: String? = null
124
+ // The split inventory, once every split was hashed: the installed APKs do
125
+ // not change under a running process (an update restarts it).
126
+ @Volatile private var cachedSplits: List<Map<String, String>>? = null
127
+
128
+ // Native code-integrity component. Loaded best-effort: if it is absent the
129
+ // probe degrades to reporting checked=false rather than crashing.
130
+ private external fun nativeCodeIntegrity(appApks: Array<String>, appDirs: Array<String>): String?
131
+
132
+ private val nativeAvailable: Boolean = try {
133
+ System.loadLibrary("codeintegrity")
134
+ true
135
+ } catch (_: Throwable) {
136
+ false
137
+ }
138
+
139
+ fun collect(
140
+ requiredProbes: List<String>,
141
+ challengeNonce: String,
142
+ optionalProbes: List<String> = emptyList(),
143
+ enabledMeasurements: Set<String> = emptySet(),
144
+ ): Map<String, Any> {
145
+ // One location read per collection, made only if an enabled location
146
+ // measurement asks: both answer from it.
147
+ val location = ProbeReads.LocationCapture { readLocation() }
148
+ val probes = linkedMapOf<String, Any>()
149
+ for (probe in requiredProbes.distinct()) {
150
+ probes[probe] = runProbe(probe, enabledMeasurements, location)
151
+ }
152
+ for (probe in volunteeredProbes) {
153
+ if (!probes.containsKey(probe)) probes[probe] = runProbe(probe, enabledMeasurements, location)
154
+ }
155
+ for (probe in optionalProbes.distinct()) {
156
+ if (!probes.containsKey(probe) && probe in optionalMeasurements) {
157
+ probes[probe] = runProbe(probe, enabledMeasurements, location)
158
+ }
159
+ }
160
+
161
+ return linkedMapOf(
162
+ "collector_version" to COLLECTOR_VERSION,
163
+ "platform" to "android",
164
+ "challenge_nonce_echo" to challengeNonce,
165
+ "sdk_int" to Build.VERSION.SDK_INT,
166
+ "probes" to probes
167
+ )
168
+ }
169
+
170
+ private fun runProbe(
171
+ probe: String,
172
+ enabledMeasurements: Set<String>,
173
+ location: ProbeReads.LocationCapture,
174
+ ): Map<String, Any> =
175
+ try {
176
+ when (probe) {
177
+ "app_identity" -> probeAppIdentity()
178
+ "debug_state" -> probeDebugState()
179
+ "root_files" -> probeRootFiles()
180
+ "root_shell" -> probeRootShell()
181
+ "system_properties" -> probeSystemProperties()
182
+ "selinux" -> probeSelinux()
183
+ "mounts" -> probeMounts()
184
+ "runtime_maps" -> probeRuntimeMaps()
185
+ "tracer" -> probeTracer()
186
+ "frida_ports" -> probeFridaPorts()
187
+ "emulator" -> probeEmulator()
188
+ "developer_settings" -> probeDeveloperSettings()
189
+ "instrumentation_threads" -> probeInstrumentationThreads()
190
+ "exec_mappings" -> probeExecMappings()
191
+ "code_integrity" -> probeCodeIntegrity()
192
+ "device" -> probeDevice()
193
+ "network" -> probeNetwork()
194
+ "local_addresses" ->
195
+ if (probe in enabledMeasurements) probeLocalAddresses() else notEnabled()
196
+ "accessibility" ->
197
+ if (probe in enabledMeasurements) probeAccessibility() else notEnabled()
198
+ "notification_access" ->
199
+ if (probe in enabledMeasurements) probeNotificationAccess() else notEnabled()
200
+ "other_access" ->
201
+ if (probe in enabledMeasurements) probeOtherAccess() else notEnabled()
202
+ "screen_capture" ->
203
+ if (probe in enabledMeasurements) probeScreenCapture() else notEnabled()
204
+ "location" ->
205
+ if (probe in enabledMeasurements) ProbeReads.location(location.value) else notEnabled()
206
+ "mock_location" ->
207
+ if (probe in enabledMeasurements) ProbeReads.mockLocation(location.value) else notEnabled()
208
+ else -> resultUnsupported("Unknown probe requested by server")
209
+ }
210
+ } catch (error: Throwable) {
211
+ linkedMapOf(
212
+ "status" to "error",
213
+ "error_type" to error.javaClass.simpleName,
214
+ // Its words, then every cause beneath them: the kind alone
215
+ // (error_type) says little of an Android 9 keystore failure.
216
+ "error" to Causes.withCauses(error.message ?: "probe failed", error.cause).take(240)
217
+ )
218
+ }
219
+
220
+ private fun ok(vararg pairs: Pair<String, Any?>): Map<String, Any> {
221
+ val result = linkedMapOf<String, Any>("status" to "ok")
222
+ for ((key, value) in pairs) {
223
+ if (value != null) result[key] = value
224
+ }
225
+ return result
226
+ }
227
+
228
+ // A probe that could not make its measurement says so, rather than
229
+ // reporting "ok" with nothing in it: unknown is not clean.
230
+ private fun unavailable(reason: String): Map<String, Any> = linkedMapOf(
231
+ "status" to "error",
232
+ "error" to reason
233
+ )
234
+
235
+ private fun resultUnsupported(reason: String): Map<String, Any> = linkedMapOf(
236
+ "status" to "unsupported",
237
+ "reason" to reason
238
+ )
239
+
240
+ // An optional measurement the server asked for but this app's developers
241
+ // did not enable: the business asked, the app declined.
242
+ private fun notEnabled(): Map<String, Any> = linkedMapOf("status" to "not_enabled")
243
+
244
+ /** The OS and the hardware model, and how current the security patches are. */
245
+ private fun probeDevice(): Map<String, Any> = ok(
246
+ "release" to (Build.VERSION.RELEASE ?: ""),
247
+ "sdk_int" to Build.VERSION.SDK_INT,
248
+ "security_patch" to
249
+ (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) Build.VERSION.SECURITY_PATCH else ""),
250
+ "manufacturer" to (Build.MANUFACTURER ?: ""),
251
+ "brand" to (Build.BRAND ?: ""),
252
+ "model" to (Build.MODEL ?: ""),
253
+ "device" to (Build.DEVICE ?: ""),
254
+ "build_id" to (Build.ID ?: ""),
255
+ "fingerprint" to (Build.FINGERPRINT ?: "").take(256),
256
+ // The ABI this process runs as: a 32-bit app on a 64-bit phone maps
257
+ // different code, so a runtime baseline is per ABI.
258
+ "abi" to processAbi(),
259
+ // The memory page size: 4 KiB, or 16 KiB on phones built for it from
260
+ // Android 15 - a runtime baseline is per page size too (collector 7).
261
+ "page_size" to android.system.Os.sysconf(android.system.OsConstants._SC_PAGESIZE).toInt()
262
+ )
263
+
264
+ private fun processAbi(): String {
265
+ val abis = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
266
+ if (android.os.Process.is64Bit()) Build.SUPPORTED_64_BIT_ABIS else Build.SUPPORTED_32_BIT_ABIS
267
+ } else {
268
+ Build.SUPPORTED_ABIS
269
+ }
270
+ return abis.firstOrNull() ?: ""
271
+ }
272
+
273
+ /**
274
+ * Whether a VPN is up and an HTTP proxy is set, as the system's own
275
+ * connectivity service says (ACCESS_NETWORK_STATE, granted at install). A
276
+ * VPN is a network of its own, so every network is asked, not only the
277
+ * default one. Interface names only - never an address.
278
+ */
279
+ private fun probeNetwork(): Map<String, Any> {
280
+ val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
281
+ ?: return unavailable("ConnectivityManager is not available")
282
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
283
+ return unavailable("needs Android 6 or newer")
284
+ }
285
+ val transports = linkedSetOf<String>()
286
+ manager.activeNetwork?.let { network ->
287
+ manager.getNetworkCapabilities(network)?.let { caps ->
288
+ if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) transports.add("wifi")
289
+ if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) transports.add("cellular")
290
+ if (caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) transports.add("ethernet")
291
+ if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) transports.add("vpn")
292
+ }
293
+ }
294
+ @Suppress("DEPRECATION")
295
+ val vpn = manager.allNetworks.any { network ->
296
+ manager.getNetworkCapabilities(network)
297
+ ?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
298
+ }
299
+ val proxy = manager.defaultProxy != null ||
300
+ !System.getProperty("http.proxyHost").isNullOrBlank()
301
+ // Names are context; the two answers above stand without them. A
302
+ // listing that failed, whole or for one interface, is in unread: an
303
+ // empty or short list would read as "no such interface".
304
+ val interfaces = mutableListOf<String>()
305
+ var interfacesUnread: String? = null
306
+ try {
307
+ NetworkInterface.getNetworkInterfaces()?.let { all ->
308
+ for (item in Collections.list(all)) {
309
+ try {
310
+ if (item.isUp && !item.isLoopback) interfaces.add(item.name.take(32))
311
+ } catch (error: Throwable) {
312
+ if (interfacesUnread == null) {
313
+ interfacesUnread = ProbeReads.why(error)
314
+ }
315
+ }
316
+ }
317
+ }
318
+ } catch (error: Throwable) {
319
+ interfacesUnread = ProbeReads.why(error)
320
+ }
321
+ val result = ok(
322
+ "vpn_active" to vpn,
323
+ "http_proxy_configured" to proxy,
324
+ "transports" to transports.toList(),
325
+ "interfaces" to interfaces.distinct().take(32)
326
+ ).toMutableMap()
327
+ interfacesUnread?.let { result["unread"] = mapOf("interfaces" to it) }
328
+ return result
329
+ }
330
+
331
+ /**
332
+ * The phone's own IP addresses, per interface - an optional measurement.
333
+ * Link-local addresses are left out: they can carry the
334
+ * hardware address, which the platforms deliberately hide.
335
+ */
336
+ private fun probeLocalAddresses(): Map<String, Any> {
337
+ val addresses = mutableListOf<Map<String, Any>>()
338
+ // More than a list holds is said, never cut off unsaid.
339
+ var truncated = false
340
+ val all = NetworkInterface.getNetworkInterfaces()
341
+ ?: return unavailable("no network interface could be listed")
342
+ for (item in Collections.list(all)) {
343
+ if (!item.isUp || item.isLoopback) continue
344
+ for (address in Collections.list(item.inetAddresses)) {
345
+ if (address.isLoopbackAddress || address.isLinkLocalAddress) continue
346
+ if (addresses.size >= 32) {
347
+ truncated = true
348
+ break
349
+ }
350
+ addresses.add(
351
+ linkedMapOf(
352
+ "interface" to item.name.take(32),
353
+ "address" to (address.hostAddress ?: "").substringBefore('%').take(64)
354
+ )
355
+ )
356
+ }
357
+ }
358
+ return ok("addresses" to addresses, "truncated" to truncated)
359
+ }
360
+
361
+ /**
362
+ * One app holding a capability: its package and component
363
+ * class, whether it is part of the system, and which package installed it
364
+ * ("" for none - adb, or an installer that names nobody). system and
365
+ * installer are left out where Android hides the app from this one; the
366
+ * server then calls its source unknown, which is never evidence. The
367
+ * server, not the phone, decides what counts as an app store.
368
+ */
369
+ private fun appEntry(
370
+ packageName: String,
371
+ component: String? = null,
372
+ knownInfo: ApplicationInfo? = null,
373
+ ): Map<String, Any> {
374
+ val entry = linkedMapOf<String, Any>("package" to packageName.take(128))
375
+ if (component != null) entry["service"] = component.take(200)
376
+ val pm = context.packageManager
377
+ val info = knownInfo ?: try {
378
+ pm.getApplicationInfo(packageName, 0)
379
+ } catch (_: Throwable) {
380
+ null
381
+ }
382
+ val installer: String? = try {
383
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
384
+ pm.getInstallSourceInfo(packageName).installingPackageName ?: ""
385
+ } else {
386
+ @Suppress("DEPRECATION")
387
+ pm.getInstallerPackageName(packageName) ?: ""
388
+ }
389
+ } catch (_: Throwable) {
390
+ null
391
+ }
392
+ if (info != null) {
393
+ entry["system"] = (info.flags and ApplicationInfo.FLAG_SYSTEM) != 0
394
+ if (installer != null) entry["installer"] = installer.take(128)
395
+ }
396
+ return entry
397
+ }
398
+
399
+ /**
400
+ * Every Accessibility service that is on, and the keyboard in use - the
401
+ * two ways banking trojans read what is typed. The
402
+ * Accessibility manager's list is compared with the system setting it is
403
+ * built from: a service the setting names that the manager does not report
404
+ * is hiding, as an Android bug allowed until the December 2022 patch.
405
+ */
406
+ private fun probeAccessibility(): Map<String, Any> {
407
+ val manager = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
408
+ ?: return unavailable("AccessibilityManager is not available")
409
+ val services = mutableListOf<Map<String, Any>>()
410
+ val reported = mutableSetOf<String>()
411
+ var undescribed = 0
412
+ for (info in manager.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)) {
413
+ val service = info.resolveInfo?.serviceInfo
414
+ if (service == null) {
415
+ // The manager reports it, but not its app: counted as unread,
416
+ // and never read as a service hiding from the manager.
417
+ info.id?.let { ComponentName.unflattenFromString(it.trim())?.flattenToString() }
418
+ ?.let { reported.add(it) }
419
+ undescribed++
420
+ continue
421
+ }
422
+ if (!reported.add(ComponentName(service.packageName, service.name).flattenToString())) continue
423
+ if (services.size < MAX_APPS) {
424
+ services.add(appEntry(service.packageName, service.name, service.applicationInfo))
425
+ }
426
+ }
427
+ val hidden = Settings.Secure.getString(context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES)
428
+ ?.split(':')
429
+ ?.mapNotNull { ComponentName.unflattenFromString(it.trim())?.flattenToString() }
430
+ ?.filter { it !in reported }
431
+ ?.distinct()
432
+ ?: emptyList()
433
+ val result = linkedMapOf<String, Any>(
434
+ "status" to "ok",
435
+ "services" to services,
436
+ "settings_only" to hidden.take(MAX_APPS).map { it.take(200) },
437
+ // More than this collector sends: the server says so, never guesses.
438
+ "truncated" to (reported.size > MAX_APPS || hidden.size > MAX_APPS)
439
+ )
440
+ Settings.Secure.getString(context.contentResolver, Settings.Secure.DEFAULT_INPUT_METHOD)
441
+ ?.let { ComponentName.unflattenFromString(it.trim()) }
442
+ ?.let { result["keyboard"] = appEntry(it.packageName, it.className) }
443
+ if (undescribed > 0) {
444
+ result["unread"] = mapOf("services" to "$undescribed enabled services the system did not describe")
445
+ }
446
+ return result
447
+ }
448
+
449
+ /** Every app allowed to read the phone's notifications - one-time codes among them. */
450
+ private fun probeNotificationAccess(): Map<String, Any> {
451
+ // Not a public constant; AndroidX's NotificationManagerCompat reads the
452
+ // same setting. Unreadable, it throws, and the probe answers error.
453
+ val all = Settings.Secure.getString(context.contentResolver, "enabled_notification_listeners")
454
+ ?.split(':')
455
+ ?.mapNotNull { ComponentName.unflattenFromString(it.trim()) }
456
+ ?.distinctBy { it.flattenToString() }
457
+ ?: emptyList()
458
+ return ok(
459
+ "listeners" to all.take(MAX_APPS).map { appEntry(it.packageName, it.className) },
460
+ "truncated" to (all.size > MAX_APPS)
461
+ )
462
+ }
463
+
464
+ /** The active device administrators, and the known remote-control apps installed. */
465
+ private fun probeOtherAccess(): Map<String, Any> {
466
+ val policy = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as? DevicePolicyManager
467
+ ?: return unavailable("DevicePolicyManager is not available")
468
+ val active = policy.activeAdmins ?: emptyList()
469
+ val admins = active.take(MAX_APPS).map { appEntry(it.packageName, it.className) }
470
+ val pm = context.packageManager
471
+ val remote = remoteAccessPackages.filter { name ->
472
+ try {
473
+ pm.getPackageInfo(name, 0)
474
+ true
475
+ } catch (_: PackageManager.NameNotFoundException) {
476
+ false
477
+ }
478
+ }.map { appEntry(it) }
479
+ return ok(
480
+ "device_admins" to admins,
481
+ "remote_access_apps" to remote,
482
+ "truncated" to (active.size > MAX_APPS)
483
+ )
484
+ }
485
+
486
+ /**
487
+ * Whether this app's window is being recorded (Android 15 and later, with
488
+ * DETECT_SCREEN_RECORDING, a normal permission the app declares), and how
489
+ * many displays besides the phone's own are on and public: casting,
490
+ * mirroring, an external screen - which this cannot tell apart. A display
491
+ * that is off (a foldable's other screen) or private to another app is
492
+ * not counted. When recording cannot be measured it is left out -
493
+ * unknown, not "no" - and recording_status says why (collector 7):
494
+ * unsupported below Android 15, no_permission without
495
+ * DETECT_SCREEN_RECORDING, unavailable with no window to ask.
496
+ */
497
+ private fun probeScreenCapture(): Map<String, Any> {
498
+ val result = linkedMapOf<String, Any>("status" to "ok")
499
+ val window = activity()?.windowManager
500
+ val granted = context.checkSelfPermission("android.permission.DETECT_SCREEN_RECORDING") ==
501
+ PackageManager.PERMISSION_GRANTED
502
+ if (Build.VERSION.SDK_INT >= 35 && window != null && granted) {
503
+ val callback = Consumer<Int> { }
504
+ try {
505
+ val state = window.addScreenRecordingCallback(Executor { it.run() }, callback)
506
+ result["recording"] = state == WindowManager.SCREEN_RECORDING_STATE_VISIBLE
507
+ } finally {
508
+ window.removeScreenRecordingCallback(callback)
509
+ }
510
+ } else {
511
+ result["recording_status"] = when {
512
+ Build.VERSION.SDK_INT < 35 -> "unsupported"
513
+ !granted -> "no_permission"
514
+ else -> "unavailable"
515
+ }
516
+ }
517
+ val displays = (context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)?.displays
518
+ ?: return unavailable("DisplayManager is not available")
519
+ result["external_displays"] = displays.count {
520
+ it.displayId != Display.DEFAULT_DISPLAY && it.state == Display.STATE_ON &&
521
+ (it.flags and Display.FLAG_PRIVATE) == 0
522
+ }
523
+ return result
524
+ }
525
+
526
+ private fun locationPermission(): String = when {
527
+ context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
528
+ PackageManager.PERMISSION_GRANTED -> "precise"
529
+ context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) ==
530
+ PackageManager.PERMISSION_GRANTED -> "approximate"
531
+ else -> "none"
532
+ }
533
+
534
+ /**
535
+ * The collection's one location read: the permission, then the newest fix any enabled provider
536
+ * already holds - never a fresh fix (no GPS power, no waiting) and never
537
+ * a prompt; only the app asks for the permission, on its own screen.
538
+ */
539
+ @SuppressLint("MissingPermission")
540
+ private fun readLocation(): ProbeReads.FixRead {
541
+ val permission = locationPermission()
542
+ if (permission == "none") return ProbeReads.FixRead(permission)
543
+ val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager
544
+ ?: return ProbeReads.FixRead(permission, failure = "the location service is unavailable")
545
+ val now = SystemClock.elapsedRealtimeNanos()
546
+ val providers = linkedMapOf<String, Result<ProbeReads.Fix?>>()
547
+ for (provider in manager.getProviders(true)) {
548
+ providers[provider] = runCatching { manager.getLastKnownLocation(provider)?.let { fixOf(it, now) } }
549
+ }
550
+ return ProbeReads.newestFix(permission, providers)
551
+ }
552
+
553
+ private fun fixOf(fix: Location, now: Long) = ProbeReads.Fix(
554
+ latitude = fix.latitude,
555
+ longitude = fix.longitude,
556
+ accuracyM = if (fix.hasAccuracy()) fix.accuracy.toDouble() else null,
557
+ ageSeconds = maxOf(0L, (now - fix.elapsedRealtimeNanos) / 1_000_000_000L),
558
+ elapsedNanos = fix.elapsedRealtimeNanos,
559
+ provider = fix.provider ?: "",
560
+ timeMillis = fix.time,
561
+ mock = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
562
+ fix.isMock
563
+ } else {
564
+ @Suppress("DEPRECATION")
565
+ fix.isFromMockProvider
566
+ },
567
+ )
568
+
569
+ @Suppress("DEPRECATION")
570
+ private fun probeAppIdentity(): Map<String, Any> {
571
+ val pm = context.packageManager
572
+ val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
573
+ PackageManager.GET_SIGNING_CERTIFICATES
574
+ } else {
575
+ PackageManager.GET_SIGNATURES
576
+ }
577
+ val packageInfo = pm.getPackageInfo(context.packageName, flags)
578
+ val appInfo = packageInfo.applicationInfo ?: context.applicationInfo
579
+ val certDigests = mutableListOf<String>()
580
+ // Apart from cert_sha256 (collector 7): the
581
+ // certificate(s) signing the APK now, and - after a key rotation - the
582
+ // signing lineage, oldest first. The server takes a rotation as no
583
+ // signer change only where the release process approved it.
584
+ val currentSigners = mutableListOf<String>()
585
+ val lineage = mutableListOf<String>()
586
+
587
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
588
+ val signingInfo = packageInfo.signingInfo
589
+ val signatures = signingInfo?.let {
590
+ if (it.hasMultipleSigners()) it.apkContentsSigners else it.signingCertificateHistory
591
+ }.orEmpty()
592
+
593
+ signatures.forEach { signature ->
594
+ certDigests.add(sha256Hex(signature.toByteArray()))
595
+ }
596
+ signingInfo?.apkContentsSigners.orEmpty().forEach { signature ->
597
+ currentSigners.add(sha256Hex(signature.toByteArray()))
598
+ }
599
+ if (signingInfo != null && !signingInfo.hasMultipleSigners()) {
600
+ signingInfo.signingCertificateHistory.orEmpty().forEach { signature ->
601
+ lineage.add(sha256Hex(signature.toByteArray()))
602
+ }
603
+ }
604
+ } else {
605
+ packageInfo.signatures?.forEach { signature ->
606
+ certDigests.add(sha256Hex(signature.toByteArray()))
607
+ }
608
+ currentSigners.addAll(certDigests)
609
+ }
610
+ certDigests.sort()
611
+ currentSigners.sort()
612
+
613
+ // A lookup that threw is unread; null from a lookup that worked means
614
+ // no installer named one (adb, or an installer that names nobody).
615
+ var installerUnread: String? = null
616
+ val installer = try {
617
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
618
+ pm.getInstallSourceInfo(context.packageName).installingPackageName
619
+ } else {
620
+ @Suppress("DEPRECATION")
621
+ pm.getInstallerPackageName(context.packageName)
622
+ }
623
+ } catch (error: Throwable) {
624
+ installerUnread = ProbeReads.why(error)
625
+ null
626
+ }
627
+
628
+ // The base APK alone (ApplicationInfo.sourceDir): apk_sha256 never
629
+ // covers the splits, which are listed apart.
630
+ val apkHash = cachedApkSha256 ?: sha256File(File(appInfo.sourceDir)).also {
631
+ cachedApkSha256 = it
632
+ }
633
+ var splitsUnread = emptyMap<String, String>()
634
+ val splits = cachedSplits ?: ProbeReads.splitApks(
635
+ appInfo.splitSourceDirs.orEmpty().associate { path ->
636
+ File(path).name to runCatching { sha256File(File(path)) }
637
+ }
638
+ ).let { (listed, unread) ->
639
+ splitsUnread = unread
640
+ if (unread.isEmpty()) cachedSplits = listed
641
+ listed
642
+ }
643
+ val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
644
+ packageInfo.longVersionCode
645
+ } else {
646
+ @Suppress("DEPRECATION")
647
+ packageInfo.versionCode.toLong()
648
+ }
649
+
650
+ return ok(
651
+ "package_name" to context.packageName,
652
+ "version_name" to (packageInfo.versionName ?: ""),
653
+ "version_code" to versionCode,
654
+ "debuggable" to ((appInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0),
655
+ "allow_backup" to ((appInfo.flags and ApplicationInfo.FLAG_ALLOW_BACKUP) != 0),
656
+ "cert_sha256" to certDigests,
657
+ "current_signers" to currentSigners.distinct().take(8),
658
+ "signing_lineage" to lineage.take(8),
659
+ "apk_sha256" to apkHash,
660
+ "split_apks" to splits,
661
+ "installer_package" to installer,
662
+ "source_dir" to appInfo.sourceDir
663
+ ).let { result ->
664
+ val unread = linkedMapOf<String, String>()
665
+ installerUnread?.let { unread["installer_package"] = it }
666
+ unread.putAll(splitsUnread)
667
+ if (unread.isEmpty()) result else result + ("unread" to unread)
668
+ }
669
+ }
670
+
671
+ private fun probeDebugState(): Map<String, Any> = ok(
672
+ "debugger_connected" to Debug.isDebuggerConnected(),
673
+ "waiting_for_debugger" to Debug.waitingForDebugger()
674
+ )
675
+
676
+ private fun probeRootFiles(): Map<String, Any> = ProbeReads.rootFiles(
677
+ rootPaths.associateWith { path -> runCatching { File(path).exists() } },
678
+ Build.TAGS ?: ""
679
+ )
680
+
681
+ // A lookup that could not start, or never finished, is not "no su": the
682
+ // probe fails, and the report says it measured nothing.
683
+ private fun probeRootShell(): Map<String, Any> = ProbeReads.rootShell(
684
+ runCommand(listOf("/system/bin/sh", "-c", "command -v su || which su || true"))
685
+ )
686
+
687
+ private fun probeSystemProperties(): Map<String, Any> {
688
+ val names = listOf(
689
+ "ro.secure",
690
+ "ro.debuggable",
691
+ "ro.build.type",
692
+ "ro.build.tags",
693
+ "ro.boot.verifiedbootstate",
694
+ "ro.boot.flash.locked",
695
+ "ro.boot.vbmeta.device_state",
696
+ "ro.boot.veritymode"
697
+ )
698
+ return ProbeReads.systemProperties(
699
+ names.associateWith { name -> runCommand(listOf("/system/bin/getprop", name)) }
700
+ )
701
+ }
702
+
703
+ private fun probeSelinux(): Map<String, Any> {
704
+ // Collect SELinux state using two independent local views. Some OEMs do
705
+ // not expose /system/bin/getenforce consistently to an application
706
+ // process even though SELinux itself is enforcing. /sys/fs/selinux/enforce
707
+ // is the authoritative kernel boolean when readable: 1=enforcing,
708
+ // 0=permissive. Never turn command errors into a security verdict here;
709
+ // the server normalizes and scores the evidence.
710
+ // A timed-out getenforce is unknown, like an unreadable one: unknown
711
+ // SELinux state is telemetry, never evidence (server scores it +0).
712
+ val mode = runCommand(listOf("/system/bin/sh", "-c", "getenforce 2>/dev/null || true"))
713
+ .let { if (it.problem == null) it.output else "" }
714
+ .trim().lineSequence().firstOrNull().orEmpty().take(64)
715
+
716
+ val enforceValue = try {
717
+ val file = File("/sys/fs/selinux/enforce")
718
+ if (file.canRead()) file.readText().trim().take(8) else ""
719
+ } catch (_: Throwable) {
720
+ ""
721
+ }
722
+
723
+ val normalizedMode = when {
724
+ mode.equals("Enforcing", ignoreCase = true) -> "enforcing"
725
+ mode.equals("Permissive", ignoreCase = true) -> "permissive"
726
+ mode.equals("Disabled", ignoreCase = true) -> "disabled"
727
+ enforceValue == "1" -> "enforcing"
728
+ enforceValue == "0" -> "permissive"
729
+ else -> "unknown"
730
+ }
731
+
732
+ return ok(
733
+ "mode" to normalizedMode,
734
+ "getenforce" to mode,
735
+ "enforce_value" to enforceValue
736
+ )
737
+ }
738
+
739
+ // An unreadable /proc/mounts is no measurement, never "no writable mount".
740
+ private fun probeMounts(): Map<String, Any> = ProbeReads.mounts(readLines(File("/proc/mounts")))
741
+
742
+ private fun probeRuntimeMaps(): Map<String, Any> {
743
+ val found = linkedSetOf<String>()
744
+ var suspiciousLines = 0
745
+ var mapsLines = 0
746
+ val file = File("/proc/self/maps")
747
+ if (!file.canRead()) return unavailable("/proc/self/maps is not readable")
748
+ run {
749
+ file.forEachLine { original ->
750
+ mapsLines++
751
+ val line = original.lowercase(Locale.US)
752
+ var lineMatched = false
753
+ for (token in suspiciousRuntimeTokens) {
754
+ if (line.contains(token)) {
755
+ found.add(token)
756
+ lineMatched = true
757
+ }
758
+ }
759
+ if (lineMatched) suspiciousLines++
760
+ }
761
+ }
762
+ if (mapsLines == 0) return unavailable("/proc/self/maps returned no lines")
763
+ return ok(
764
+ "suspicious_tokens" to found.toList(),
765
+ "suspicious_line_count" to suspiciousLines,
766
+ "maps_lines" to mapsLines
767
+ )
768
+ }
769
+
770
+ // An unreadable status file, or a TracerPid missing or not a number, is no
771
+ // measurement - never a tracer pid of 0.
772
+ private fun probeTracer(): Map<String, Any> = ProbeReads.tracer(readLines(File("/proc/self/status")))
773
+
774
+ // A refused connection is a measured "closed"; any other failure means the
775
+ // port could not be tested, and says so.
776
+ private fun probeFridaPorts(): Map<String, Any> = ProbeReads.fridaPorts(
777
+ listOf(27042, 27043).associateWith { port ->
778
+ try {
779
+ Socket().use { socket ->
780
+ socket.connect(InetSocketAddress("127.0.0.1", port), 80)
781
+ }
782
+ ProbeReads.PortResult.Open
783
+ } catch (_: ConnectException) {
784
+ ProbeReads.PortResult.Refused
785
+ } catch (error: Throwable) {
786
+ ProbeReads.PortResult.Failed(ProbeReads.why(error))
787
+ }
788
+ }
789
+ )
790
+
791
+ private fun probeEmulator(): Map<String, Any> {
792
+ val reasons = mutableListOf<String>()
793
+ fun contains(value: String?, token: String) =
794
+ value?.lowercase(Locale.US)?.contains(token) == true
795
+
796
+ if (Build.FINGERPRINT.startsWith("generic") || contains(Build.FINGERPRINT, "emulator")) reasons.add("fingerprint")
797
+ if (contains(Build.MODEL, "google_sdk") || contains(Build.MODEL, "emulator") || contains(Build.MODEL, "android sdk built for")) reasons.add("model")
798
+ if (contains(Build.MANUFACTURER, "genymotion")) reasons.add("manufacturer")
799
+ if (contains(Build.PRODUCT, "sdk") || contains(Build.PRODUCT, "emulator") || contains(Build.PRODUCT, "simulator")) reasons.add("product")
800
+ if (contains(Build.HARDWARE, "goldfish") || contains(Build.HARDWARE, "ranchu")) reasons.add("hardware")
801
+ if (contains(Build.BOARD, "goldfish")) reasons.add("board")
802
+
803
+ return ok(
804
+ "suspected" to reasons.isNotEmpty(),
805
+ "reasons" to reasons,
806
+ "model" to Build.MODEL,
807
+ "manufacturer" to Build.MANUFACTURER,
808
+ "product" to Build.PRODUCT,
809
+ "hardware" to Build.HARDWARE
810
+ )
811
+ }
812
+
813
+ // A setting that could not be read is unread, never "off".
814
+ private fun probeDeveloperSettings(): Map<String, Any> {
815
+ val resolver = context.contentResolver
816
+ return ProbeReads.developerSettings(
817
+ runCatching { Settings.Global.getInt(resolver, Settings.Global.ADB_ENABLED, 0) != 0 },
818
+ runCatching { Settings.Global.getInt(resolver, Settings.Global.DEVELOPMENT_SETTINGS_ENABLED, 0) != 0 }
819
+ )
820
+ }
821
+
822
+ /**
823
+ * Instrumentation-runtime thread names.
824
+ *
825
+ * Structural, not name-of-file based: Frida's Gum runtime and its GLib
826
+ * dependency spawn threads with names compiled into the framework
827
+ * (gum-js-loop, pool-frida, gmain, gdbus). Renaming the injected .so - which
828
+ * defeats the /proc/self/maps pathname scan - does not rename these threads,
829
+ * so an idle, renamed Frida Gadget is still visible here. Reads
830
+ * /proc/self/task/<tid>/comm, which an app can always read for its own
831
+ * threads regardless of SELinux.
832
+ */
833
+ private fun probeInstrumentationThreads(): Map<String, Any> {
834
+ // Unmistakably Frida/Gum: these are compiled into the framework, so a
835
+ // renamed injected .so keeps them. gum-js-loop is the Gum JS event loop.
836
+ val fridaThreads = listOf("gum-js-loop", "gum-js", "pool-frida")
837
+ // GLib runtime threads: Frida pulls GLib in, and Android apps almost
838
+ // never link GLib themselves, but reported separately so the server can
839
+ // weight them as corroborating rather than conclusive.
840
+ val glibThreads = listOf("gmain", "gdbus", "pool-spawner")
841
+ // A running process always has threads; none enumerated means the
842
+ // probe saw nothing, not that nothing is there. A thread that ended
843
+ // between listing and reading is gone; one whose name could not be
844
+ // read is unread - a renamed instrumentation thread could be that one.
845
+ val names = mutableListOf<String>()
846
+ var unreadable = 0
847
+ for (task in File("/proc/self/task").listFiles().orEmpty()) {
848
+ val name = try {
849
+ File(task, "comm").readText().trim()
850
+ } catch (_: Throwable) {
851
+ if (task.exists()) unreadable++
852
+ continue
853
+ }
854
+ names.add(name)
855
+ }
856
+ return ProbeReads.threads(names, unreadable, fridaThreads, glibThreads, suspiciousRuntimeTokens)
857
+ }
858
+
859
+ /**
860
+ * Structurally suspicious executable memory.
861
+ *
862
+ * Behaviour-based: inline-hooking and injection frameworks allocate
863
+ * executable trampoline/agent memory. Two shapes are rare in a clean,
864
+ * W^X-compliant app and are reported here: mappings that are simultaneously
865
+ * writable and executable, and executable mappings backed by a file marked
866
+ * "(deleted)". Anonymous executable regions carrying a recognized
867
+ * "[anon:...]" label (for example the ART JIT code cache) are counted
868
+ * separately as telemetry and are NOT treated as suspicious, to avoid
869
+ * flagging the legitimate runtime.
870
+ */
871
+ private fun probeExecMappings(): Map<String, Any> {
872
+ var wx = 0
873
+ var deletedExec = 0
874
+ var deletedExecJit = 0
875
+ var deletedExecOwnCode = 0
876
+ // The app's own compiled code: Android recompiles an app in the
877
+ // background after an install or update and replaces these files
878
+ // under the running process (seen about a minute after an
879
+ // install). Writing here takes installd, system or root --
880
+ // an attacker who can plant code here already owns the device -- so
881
+ // they are counted apart and the server treats them as advisory.
882
+ // Nothing outside oat/ is exempt, not even base.apk.
883
+ val ownCodeDir = File(context.applicationInfo.sourceDir).parentFile?.path?.let { "$it/oat/" }
884
+ var anonExecLabeled = 0
885
+ var anonExecUnlabeled = 0
886
+ var anonExecUnlabeledBytes = 0L
887
+ // The size of every writable-executable mapping, so the server can
888
+ // hold them to the build's published envelope.
889
+ val wxSizes = java.util.TreeMap<Long, Int>(Collections.reverseOrder<Long>())
890
+ val samples = mutableListOf<String>()
891
+ var mapsLines = 0
892
+ val file = File("/proc/self/maps")
893
+ if (!file.canRead()) return unavailable("/proc/self/maps is not readable")
894
+ run {
895
+ file.forEachLine { line ->
896
+ mapsLines++
897
+ val parts = line.trim().split(Regex("\\s+"), limit = 6)
898
+ if (parts.size < 5) return@forEachLine
899
+ val perms = parts[1]
900
+ if (perms.length < 4) return@forEachLine
901
+ val writable = perms[1] == 'w'
902
+ val executable = perms[2] == 'x'
903
+ if (!executable) return@forEachLine
904
+ val path = if (parts.size >= 6) parts[5] else ""
905
+ val size = mappingSize(parts[0])
906
+ if (writable) {
907
+ wx++
908
+ if (size > 0) wxSizes[size] = (wxSizes[size] ?: 0) + 1
909
+ if (samples.size < 8) samples.add(line.trim().take(160))
910
+ }
911
+ if (path.contains("(deleted)")) {
912
+ // The ART JIT code cache is a memfd/ashmem region that
913
+ // always shows as executable and "(deleted)" on a clean
914
+ // device. Exclude it, or every device is a false positive.
915
+ val lower = path.lowercase(Locale.US)
916
+ val isJit = lower.contains("jit-cache") ||
917
+ lower.contains("dalvik-jit-code-cache") ||
918
+ lower.contains("dalvik-") ||
919
+ lower.contains("/art") ||
920
+ lower.contains("jit-zygote")
921
+ val mapped = path.removeSuffix("(deleted)").trim()
922
+ val isOwnCode = ownCodeDir != null && mapped.startsWith(ownCodeDir) &&
923
+ (mapped.endsWith(".odex") || mapped.endsWith(".vdex") ||
924
+ mapped.endsWith(".art") || mapped.endsWith(".oat"))
925
+ if (isJit) {
926
+ deletedExecJit++
927
+ } else if (isOwnCode) {
928
+ deletedExecOwnCode++
929
+ if (samples.size < 8) samples.add(line.trim().take(160))
930
+ } else {
931
+ deletedExec++
932
+ if (samples.size < 8) samples.add(line.trim().take(160))
933
+ }
934
+ }
935
+ if (path.isEmpty()) {
936
+ anonExecUnlabeled++
937
+ anonExecUnlabeledBytes += maxOf(0L, size)
938
+ } else if (path.startsWith("[anon:")) {
939
+ anonExecLabeled++
940
+ }
941
+ }
942
+ }
943
+ if (mapsLines == 0) return unavailable("/proc/self/maps returned no lines")
944
+ val wxBytes = wxSizes.entries.sumOf { it.key * it.value }
945
+ return ok(
946
+ "wx_mappings" to wx,
947
+ // Every size class, largest first, as "<bytes>:<count>": a list cut
948
+ // short earns no baseline allowance.
949
+ "wx_bytes" to wxBytes,
950
+ "wx_size_classes" to wxSizes.entries.joinToString(",") { "${it.key}:${it.value}" },
951
+ "wx_smallest_bytes" to (wxSizes.keys.lastOrNull() ?: 0L),
952
+ "wx_largest_bytes" to (wxSizes.keys.firstOrNull() ?: 0L),
953
+ "anon_exec_unlabeled_bytes" to anonExecUnlabeledBytes,
954
+ "maps_lines" to mapsLines,
955
+ "deleted_exec_mappings" to deletedExec,
956
+ "deleted_exec_jit" to deletedExecJit,
957
+ "deleted_exec_own_code" to deletedExecOwnCode,
958
+ "anon_exec_labeled" to anonExecLabeled,
959
+ "anon_exec_unlabeled" to anonExecUnlabeled,
960
+ "samples" to samples
961
+ )
962
+ }
963
+
964
+ /** The size of one /proc/self/maps range, "start-end" in hex; 0 if unreadable. */
965
+ private fun mappingSize(range: String): Long {
966
+ val dash = range.indexOf('-')
967
+ if (dash <= 0) return 0L
968
+ val start = range.substring(0, dash).toLongOrNull(16) ?: return 0L
969
+ val end = range.substring(dash + 1).toLongOrNull(16) ?: return 0L
970
+ return if (end > start) end - start else 0L
971
+ }
972
+
973
+ /**
974
+ * Code integrity of libc: in-memory .text versus the same bytes on disk.
975
+ *
976
+ * The purest structural check. An inline hook overwrites the prologue of a
977
+ * hooked function with a trampoline, so the executable segment loaded in
978
+ * memory diverges from the file on disk. This is name-independent and
979
+ * behaviour-based - it catches any inline-hooking library, whatever it is
980
+ * called. The executable segment is not modified at runtime by the dynamic
981
+ * linker (relocations land in the GOT/data segments, not in .text), so on a
982
+ * clean device the two are byte-identical. Reads a bounded window through
983
+ * /proc/self/mem, which an app may read for its own address space.
984
+ */
985
+ private fun probeCodeIntegrity(): Map<String, Any> {
986
+ // Native reads our own mapped r-x pages by pointer, so unlike the
987
+ // /proc/self/mem path it is not blocked by SELinux. It compares three
988
+ // buckets of libraries in memory against disk: core (libc/libart),
989
+ // ext (other system hook targets incl. TLS), and app (the app's own
990
+ // native code). Any inline hook overwrites a prologue and shows up as
991
+ // a byte difference, whatever the hooking framework is called.
992
+ if (!nativeAvailable) {
993
+ return ok("checked" to false, "reason" to "native_unavailable")
994
+ }
995
+ // The app's own: the APKs it was installed from (its region, by exact
996
+ // path) and the directories its files live in (what is outside the
997
+ // region but the app's, collector 10).
998
+ val info = context.applicationInfo
999
+ val apks = (listOf(info.sourceDir) + info.splitSourceDirs.orEmpty().toList())
1000
+ .filterNotNull().distinct().toTypedArray()
1001
+ val dirs = listOfNotNull(
1002
+ info.sourceDir?.let { File(it).parent }?.let { "$it/" },
1003
+ info.dataDir?.let { "$it/" },
1004
+ "/data/data/${context.packageName}/",
1005
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) info.deviceProtectedDataDir?.let { "$it/" } else null,
1006
+ ).distinct().toTypedArray()
1007
+ val started = System.nanoTime()
1008
+ val json = try {
1009
+ nativeCodeIntegrity(apks, dirs)
1010
+ } catch (error: Throwable) {
1011
+ return ok("checked" to false, "reason" to ("native_error:" + error.javaClass.simpleName))
1012
+ }
1013
+ val elapsedMs = (System.nanoTime() - started) / 1_000_000
1014
+ if (json.isNullOrEmpty()) {
1015
+ return ok("checked" to false, "reason" to "native_no_result")
1016
+ }
1017
+ return try {
1018
+ val o = JSONObject(json)
1019
+ val checked = o.optBoolean("checked", false)
1020
+ val result = linkedMapOf<String, Any>(
1021
+ "status" to "ok",
1022
+ "checked" to checked,
1023
+ "diff_bytes" to o.optLong("diff_bytes", 0),
1024
+ "core_compared_bytes" to o.optLong("core_compared_bytes", 0),
1025
+ "core_diff_bytes" to o.optLong("core_diff_bytes", 0),
1026
+ "ext_compared_bytes" to o.optLong("ext_compared_bytes", 0),
1027
+ "ext_diff_bytes" to o.optLong("ext_diff_bytes", 0),
1028
+ "ext_libs_diff" to o.optInt("ext_libs_diff", 0),
1029
+ "app_compared_bytes" to o.optLong("app_compared_bytes", 0),
1030
+ "app_diff_bytes" to o.optLong("app_diff_bytes", 0),
1031
+ "app_libs_diff" to o.optInt("app_libs_diff", 0),
1032
+ // Visibility into execute-only mappings: a bucket that skipped
1033
+ // everything reads as clean unless these are reported.
1034
+ "xom_regions_unlocked" to o.optInt("xom_regions_unlocked", 0),
1035
+ "xom_regions_unreadable" to o.optInt("xom_regions_unreadable", 0),
1036
+ "diffed_libs" to o.optString("diffed_libs", ""),
1037
+ // Wall time of the native comparison: what a scan costs.
1038
+ "elapsed_ms" to elapsedMs
1039
+ )
1040
+ // Coverage per bucket: expected = compared + skipped +
1041
+ // unreadable, so partial measurement is visible, never read as
1042
+ // clean. Copied only when the native side reported them.
1043
+ for (bucket in listOf("core", "ext", "app")) {
1044
+ for (field in listOf("expected_bytes", "skipped_bytes", "unreadable_bytes")) {
1045
+ val key = "${bucket}_$field"
1046
+ if (o.has(key)) result[key] = o.optLong(key, 0)
1047
+ }
1048
+ val complete = "${bucket}_complete"
1049
+ if (o.has(complete)) result[complete] = o.optBoolean(complete, false)
1050
+ }
1051
+ if (o.has("protect_restore_failures")) {
1052
+ result["protect_restore_failures"] = o.optInt("protect_restore_failures", 0)
1053
+ }
1054
+ // The app's own executable files mapped outside the app region
1055
+ // (collector 10): counted, and named, at most 32.
1056
+ if (o.has("app_outside_count")) {
1057
+ result["app_outside_count"] = o.optInt("app_outside_count", 0)
1058
+ val files = o.optJSONArray("app_outside_files")
1059
+ result["app_outside_files"] = (0 until (files?.length() ?: 0)).map { files!!.optString(it) }
1060
+ }
1061
+ // Why nothing was compared: the memory map unreadable or empty, or
1062
+ // no target mapped in it -- never a bucket read as complete.
1063
+ val reason = o.optString("reason", "")
1064
+ if (!checked && reason.isNotEmpty()) result["reason"] = reason
1065
+ result
1066
+ } catch (error: Throwable) {
1067
+ ok("checked" to false, "reason" to ("parse_error:" + error.javaClass.simpleName))
1068
+ }
1069
+ }
1070
+
1071
+ /**
1072
+ * Runs one command with a deadline, and says how it went: started or not,
1073
+ * finished within COMMAND_TIMEOUT_MS or not (the child is then killed),
1074
+ * its exit code, and its output (at most MAX_TEXT) when it was read to the
1075
+ * end. The caller decides what an unanswered command means for its probe;
1076
+ * it is never an empty answer. The output is read on a separate thread
1077
+ * because a blocking read is exactly what used to hang.
1078
+ */
1079
+ private fun runCommand(command: List<String>): ProbeReads.CommandResult {
1080
+ val process = try {
1081
+ ProcessBuilder(command).redirectErrorStream(true).start()
1082
+ } catch (error: Throwable) {
1083
+ // Never "" -- a command that did not start answered nothing.
1084
+ return ProbeReads.CommandResult(false, false, null, "", ProbeReads.why(error))
1085
+ }
1086
+ val output = StringBuilder()
1087
+ var readError: String? = null
1088
+ val reader = Thread {
1089
+ try {
1090
+ BufferedReader(InputStreamReader(process.inputStream)).use { input ->
1091
+ var line: String?
1092
+ while (input.readLine().also { line = it } != null) {
1093
+ synchronized(output) {
1094
+ if (output.length < MAX_TEXT) output.append(line).append('\n')
1095
+ }
1096
+ }
1097
+ }
1098
+ } catch (error: Throwable) {
1099
+ readError = ProbeReads.why(error)
1100
+ }
1101
+ }
1102
+ reader.isDaemon = true
1103
+ reader.start()
1104
+ return try {
1105
+ if (!process.waitFor(COMMAND_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
1106
+ process.destroyForcibly()
1107
+ ProbeReads.CommandResult(true, false, null, "")
1108
+ } else {
1109
+ reader.join(500)
1110
+ val failed = readError
1111
+ if (failed != null) {
1112
+ // Its output could not be read to the end: not an answer.
1113
+ ProbeReads.CommandResult(true, false, process.exitValue(), "", "output: $failed")
1114
+ } else {
1115
+ ProbeReads.CommandResult(
1116
+ true, true, process.exitValue(),
1117
+ synchronized(output) { output.toString().take(MAX_TEXT) }
1118
+ )
1119
+ }
1120
+ }
1121
+ } catch (_: InterruptedException) {
1122
+ process.destroyForcibly()
1123
+ Thread.currentThread().interrupt()
1124
+ ProbeReads.CommandResult(true, false, null, "")
1125
+ }
1126
+ }
1127
+
1128
+ /** A file's lines, or null when it could not be read: never an empty "nothing found". */
1129
+ private fun readLines(file: File): List<String>? = try {
1130
+ if (file.canRead()) file.readLines() else null
1131
+ } catch (_: Throwable) {
1132
+ null
1133
+ }
1134
+
1135
+ private fun sha256Hex(bytes: ByteArray): String =
1136
+ MessageDigest.getInstance("SHA-256")
1137
+ .digest(bytes)
1138
+ .joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
1139
+
1140
+ private fun sha256File(file: File): String {
1141
+ val digest = MessageDigest.getInstance("SHA-256")
1142
+ FileInputStream(file).use { input ->
1143
+ val buffer = ByteArray(1024 * 1024)
1144
+ while (true) {
1145
+ val read = input.read(buffer)
1146
+ if (read <= 0) break
1147
+ digest.update(buffer, 0, read)
1148
+ }
1149
+ }
1150
+ return digest.digest().joinToString("") { byte -> "%02x".format(byte.toInt() and 0xff) }
1151
+ }
1152
+ }