diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d6a41e..905b0a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-05-16 + +### Added + +- Added `ReleaseEmulatorGuardActivity` for Android apps that need non-debuggable release APKs blocked before Flutter starts on emulators. +- Added optional Android manifest metadata for native release-emulator blocker title, subtitle, message, and button text. +- Added package consumer keep rules for the Android release-emulator guard and detector classes, plus R8 warning suppression for annotation-only Tink references from `androidx.security:security-crypto`. +- Documented Android launch-guard setup, Android install-vs-launch enforcement, and iOS simulator release tooling prevention. +- Added iOS screen connect/disconnect observers so capture state is refreshed when mirrored or external screens are attached or removed. +- Added broader iOS jailbreak and runtime-tampering indicators for Frida/Gadget artifacts, injected DYLD environment variables, suspicious runtime classes, and known instrumentation dylibs. + +### Changed + +- Android release-emulator launch blocking is package-owned; host apps only need a launcher manifest handoff. +- Android guarded-screen overlay hardening now reports `supportsOverlayHardening: false` after `setHideOverlayWindows(...)` is unavailable. +- Android security signal refresh is isolated from detector exceptions and executor shutdown races. +- Android root `su` probing now has a timeout and cleans up its spawned process. +- iOS live capture detection now checks all connected screens instead of only `UIScreen.main`. +- iOS secure-storage writes now update existing Keychain items in place before adding new items. + +### Fixed + +- Fixed Android plugin registration so `Window.setHideOverlayWindows(...)` `SecurityException`s do not prevent `flutter_defender` registration. +- Fixed Android screenshot callback registration/unregistration so platform callback failures do not crash plugin setup or teardown. +- Fixed stale UI-thread protection updates from applying after an activity detach/rebind. +- Fixed Android release guard target-activity failures to show a native configuration error instead of crashing. +- Fixed iOS secure-storage writes so a failed add cannot remove an existing value first. + +### Tests + +- Verified `flutter analyze`, `flutter test`, Android release APK build, iOS simulator debug build, and Android release emulator launch blocking. + ## [0.2.4] - 2026-05-12 ### Fixed diff --git a/README.md b/README.md index 607078a..cb6a6e3 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,63 @@ There is no route-observer setup. A guarded screen protects itself before the se ```yaml dependencies: - flutter_defender: ^0.2.4 + flutter_defender: ^0.3.0 ``` +### Android release emulator launch block + +`enableEmulatorDetectionRelease` blocks guarded Flutter screens in release +builds. If you need the stricter policy where a release APK is blocked before +Flutter starts, make the package guard activity your Android launcher and point +it at your real Flutter activity: + +```xml + + + + + + + + + + + +``` + +No Gradle change is required. Debug and profile builds remain runnable on +emulators; non-debuggable release-like builds are blocked at launch when an +emulator is detected. Android can still install a release APK on a compatible +emulator, so this is launch-time enforcement rather than install prevention. If +your manifest does not already define it, add +`xmlns:tools="http://schemas.android.com/tools"` to the root `` tag. +If `TARGET_ACTIVITY` is wrong, the native guard shows a configuration error and +logs the missing activity instead of crashing. + ## Quick Start Initialize once before `runApp`: @@ -193,10 +247,10 @@ await FlutterDefender.instance.init( | --- | --- | --- | | Secure screenshots / recents | Yes, via `FLAG_SECURE` | No direct equivalent | | Screenshot event | Android 14+ screenshot callback | Post-capture notification only | -| Live capture / mirroring detection | Limited | Yes, via `UIScreen.isCaptured` | +| Live capture / mirroring detection | Limited | Yes, across connected screens via `UIScreen.isCaptured` | | Conceal on focus loss (`inactive`) | Lifecycle-driven concealment | Yes, hides guarded content immediately | | Overlay protection | Mitigation-based hardening | Not supported | -| Emulator / simulator release block | Yes | Yes | +| Emulator / simulator release block | Guarded screens; optional native launch guard | Flutter/Xcode tooling blocks release simulator builds | | Root / jailbreak detection | Yes (best-effort indicators) | Yes (best-effort indicators) | | Proxy / VPN detection | Yes | Yes | | Basic RASP (debugger / hooking) | Yes | Yes | @@ -206,7 +260,7 @@ Important limitations: - **Android overlay defense is mitigation-based.** The plugin hardens guarded screens and reports obscured-touch violations; it does not claim perfect detection of every hostile overlay. - **iOS screenshot detection is after capture.** The system screenshot has already happened when the notification arrives. - **iOS uses privacy concealment, not hostile-overlay detection.** Guarded content is hidden when the app becomes inactive, such as during Control Center, Notification Center, Siri, calls, or app-switcher transitions. -- **Release-only emulator/simulator blocking** applies on guarded screens when `enableEmulatorDetectionRelease` is enabled. +- **Release-only emulator/simulator blocking** applies on guarded screens when `enableEmulatorDetectionRelease` is enabled. On Android, the optional package launcher guard blocks release-like emulator launches before Flutter starts. On iOS, `flutter build ios --simulator --release` is already rejected by Flutter/Xcode tooling. ## Background Timeout Behavior @@ -259,6 +313,8 @@ flutter run ```bash flutter analyze flutter test +cd example && flutter build apk --release +cd example && flutter build ios --simulator --debug --no-pub cd example && flutter test flutter pub publish --dry-run ``` @@ -270,7 +326,7 @@ This repository includes GitHub Actions for CI and publishing: - Pull requests run package and example analysis plus tests. - Pushes to `main` / `master` rerun those checks, verify that `pubspec.yaml` contains a version higher than the previous branch tip, and then create a - matching Git tag such as `v0.2.1`. + matching Git tag such as `v0.3.0`. - Pushing that tag triggers the publish workflow, which runs a final `flutter pub publish --dry-run` and then publishes to pub.dev. diff --git a/android/build.gradle b/android/build.gradle index d256549..0a4e2ad 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -45,6 +45,7 @@ android { defaultConfig { minSdk = 24 + consumerProguardFiles "consumer-rules.pro" } dependencies { diff --git a/android/consumer-rules.pro b/android/consumer-rules.pro new file mode 100644 index 0000000..061318c --- /dev/null +++ b/android/consumer-rules.pro @@ -0,0 +1,11 @@ +-keep class aleem.flutter.defender.ReleaseEmulatorGuardActivity { *; } +-keep class aleem.flutter.defender.EmulatorDetector { *; } + +# androidx.security:security-crypto pulls Tink, which references compile-time +# annotation packages that are not needed at runtime. +-dontwarn com.google.errorprone.annotations.CanIgnoreReturnValue +-dontwarn com.google.errorprone.annotations.CheckReturnValue +-dontwarn com.google.errorprone.annotations.Immutable +-dontwarn com.google.errorprone.annotations.RestrictedApi +-dontwarn javax.annotation.Nullable +-dontwarn javax.annotation.concurrent.GuardedBy diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 1965a68..bed61df 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,3 +1,8 @@ + + + diff --git a/android/src/main/kotlin/aleem/flutter/defender/AdvancedSecurityDetector.kt b/android/src/main/kotlin/aleem/flutter/defender/AdvancedSecurityDetector.kt index 2e40adb..12bd41d 100644 --- a/android/src/main/kotlin/aleem/flutter/defender/AdvancedSecurityDetector.kt +++ b/android/src/main/kotlin/aleem/flutter/defender/AdvancedSecurityDetector.kt @@ -7,6 +7,7 @@ import android.os.Build import android.os.Debug import java.io.File import java.net.NetworkInterface +import java.util.concurrent.TimeUnit internal class AdvancedSecurityDetector(private val context: Context) { fun collectSignals(): AdvancedSecuritySignals { @@ -60,12 +61,15 @@ internal class AdvancedSecurityDetector(private val context: Context) { } private fun canExecuteSu(): Boolean { + var process: Process? = null return try { - val process = Runtime.getRuntime().exec(arrayOf("/system/xbin/which", "su")) - val output = process.inputStream.bufferedReader().use { it.readText() } - output.isNotBlank() + process = Runtime.getRuntime().exec(arrayOf("/system/xbin/which", "su")) + val completed = process.waitFor(2, TimeUnit.SECONDS) + completed && process.inputStream.bufferedReader().use { it.readText() }.isNotBlank() } catch (_: Throwable) { false + } finally { + process?.destroy() } } diff --git a/android/src/main/kotlin/aleem/flutter/defender/EmulatorDetector.kt b/android/src/main/kotlin/aleem/flutter/defender/EmulatorDetector.kt index c98475b..8b5ac07 100644 --- a/android/src/main/kotlin/aleem/flutter/defender/EmulatorDetector.kt +++ b/android/src/main/kotlin/aleem/flutter/defender/EmulatorDetector.kt @@ -2,7 +2,7 @@ package aleem.flutter.defender import android.os.Build -internal object EmulatorDetector { +object EmulatorDetector { fun isEmulator(): Boolean { return Build.FINGERPRINT.startsWith("generic") || Build.FINGERPRINT.startsWith("unknown") || diff --git a/android/src/main/kotlin/aleem/flutter/defender/FlutterDefenderPlugin.kt b/android/src/main/kotlin/aleem/flutter/defender/FlutterDefenderPlugin.kt index 3cff661..8e95b68 100644 --- a/android/src/main/kotlin/aleem/flutter/defender/FlutterDefenderPlugin.kt +++ b/android/src/main/kotlin/aleem/flutter/defender/FlutterDefenderPlugin.kt @@ -2,6 +2,7 @@ package aleem.flutter.defender import android.app.Activity import android.os.Build +import android.util.Log import android.view.ViewTreeObserver import android.view.Window import android.view.WindowManager @@ -10,8 +11,13 @@ import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException class FlutterDefenderPlugin : FlutterPlugin, ActivityAware, DefenderHostApi { + private companion object { + const val TAG = "FlutterDefender" + } + private var activity: Activity? = null private var flutterApi: DefenderFlutterApi? = null private var snapshotStore: LifecycleSnapshotStore? = null @@ -23,6 +29,7 @@ class FlutterDefenderPlugin : FlutterPlugin, ActivityAware, DefenderHostApi { private var windowCallbackWrapper: OverlayAwareWindowCallback? = null private var secureActive = false private var overlayHardeningActive = false + private var hideOverlayWindowsAvailable = true @Volatile private var advancedSecuritySignalsCache = AdvancedSecuritySignals( rootedOrJailbroken = false, @@ -84,7 +91,8 @@ class FlutterDefenderPlugin : FlutterPlugin, ActivityAware, DefenderHostApi { isForeground = currentForegroundState(), isScreenCaptured = false, isEmulator = EmulatorDetector.isEmulator(), - supportsOverlayHardening = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + supportsOverlayHardening = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + hideOverlayWindowsAvailable ) } @@ -146,13 +154,25 @@ class FlutterDefenderPlugin : FlutterPlugin, ActivityAware, DefenderHostApi { private fun applyProtectionState() { val activeActivity = activity ?: return activeActivity.runOnUiThread { + if (activity !== activeActivity) { + return@runOnUiThread + } if (secureActive) { activeActivity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) } else { activeActivity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - activeActivity.window.setHideOverlayWindows(overlayHardeningActive) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && hideOverlayWindowsAvailable) { + try { + activeActivity.window.setHideOverlayWindows(overlayHardeningActive) + } catch (error: SecurityException) { + hideOverlayWindowsAvailable = false + Log.w( + TAG, + "System overlay hiding is unavailable; continuing with fallback overlay hardening.", + error + ) + } } activeActivity.window.decorView.setFilterTouchesRecursively(overlayHardeningActive) updateWindowCallback(activeActivity.window) @@ -193,15 +213,24 @@ class FlutterDefenderPlugin : FlutterPlugin, ActivityAware, DefenderHostApi { return } val callback = Activity.ScreenCaptureCallback { flutterApi?.onScreenshotDetected {} } - activeActivity.registerScreenCaptureCallback(activeActivity.mainExecutor, callback) - screenCaptureCallbackHandle = callback + try { + activeActivity.registerScreenCaptureCallback(activeActivity.mainExecutor, callback) + screenCaptureCallbackHandle = callback + } catch (error: RuntimeException) { + screenCaptureCallbackHandle = null + Log.w(TAG, "Screen-capture callback registration failed; continuing without callback.", error) + } } private fun unregisterScreenCaptureCallback() { val activeActivity = activity ?: return val callback = screenCaptureCallbackHandle as? Activity.ScreenCaptureCallback ?: return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - activeActivity.unregisterScreenCaptureCallback(callback) + try { + activeActivity.unregisterScreenCaptureCallback(callback) + } catch (error: RuntimeException) { + Log.w(TAG, "Screen-capture callback unregistration failed.", error) + } } screenCaptureCallbackHandle = null } @@ -242,8 +271,16 @@ class FlutterDefenderPlugin : FlutterPlugin, ActivityAware, DefenderHostApi { private fun scheduleSecurityRefresh() { val detector = advancedSecurityDetector ?: return val executor = detectorExecutor ?: return - executor.execute { - advancedSecuritySignalsCache = detector.collectSignals() + try { + executor.execute { + try { + advancedSecuritySignalsCache = detector.collectSignals() + } catch (error: Throwable) { + Log.w(TAG, "Advanced security signal refresh failed.", error) + } + } + } catch (error: RejectedExecutionException) { + Log.w(TAG, "Advanced security signal refresh was rejected.", error) } } } diff --git a/android/src/main/kotlin/aleem/flutter/defender/ReleaseEmulatorGuardActivity.kt b/android/src/main/kotlin/aleem/flutter/defender/ReleaseEmulatorGuardActivity.kt new file mode 100644 index 0000000..d697e60 --- /dev/null +++ b/android/src/main/kotlin/aleem/flutter/defender/ReleaseEmulatorGuardActivity.kt @@ -0,0 +1,282 @@ +package aleem.flutter.defender + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.Gravity +import android.widget.Button +import android.widget.LinearLayout +import android.widget.TextView + +class ReleaseEmulatorGuardActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (shouldBlockReleaseEmulator()) { + Log.w(TAG, "Blocking non-debuggable emulator launch.") + showReleaseEmulatorBlocker() + return + } + forwardToTargetActivity() + } + + private fun shouldBlockReleaseEmulator(): Boolean { + return isReleaseLikeBuild() && EmulatorDetector.isEmulator() + } + + private fun isReleaseLikeBuild(): Boolean { + return (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) == 0 + } + + private fun targetActivityName(): String { + val configured = activityMetadata().getString(META_TARGET_ACTIVITY) + if (!configured.isNullOrBlank()) { + return if (configured.startsWith(".")) { + "$packageName$configured" + } else { + configured + } + } + return "$packageName.MainActivity" + } + + private fun forwardToTargetActivity() { + val targetActivity = targetActivityName() + try { + Log.d(TAG, "Forwarding launcher to $targetActivity.") + startActivity(Intent().setClassName(packageName, targetActivity)) + finish() + overridePendingTransition(0, 0) + } catch (error: ActivityNotFoundException) { + Log.e(TAG, "Configured target activity was not found: $targetActivity", error) + showConfigurationError(targetActivity) + } + } + + @Suppress("DEPRECATION") + private fun activityMetadata(): Bundle { + return try { + val activityInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getActivityInfo( + componentName, + PackageManager.ComponentInfoFlags.of(PackageManager.GET_META_DATA.toLong()) + ) + } else { + packageManager.getActivityInfo(componentName, PackageManager.GET_META_DATA) + } + activityInfo.metaData ?: Bundle.EMPTY + } catch (error: PackageManager.NameNotFoundException) { + Log.w(TAG, "Release emulator guard metadata was not found.", error) + Bundle.EMPTY + } + } + + private fun showReleaseEmulatorBlocker() { + val metadata = activityMetadata() + val titleText = metadataText(metadata, META_BLOCK_TITLE, DEFAULT_BLOCK_TITLE) + val subtitleText = metadataText(metadata, META_BLOCK_SUBTITLE, DEFAULT_BLOCK_SUBTITLE) + val messageText = metadataText(metadata, META_BLOCK_MESSAGE, DEFAULT_BLOCK_MESSAGE) + val buttonText = metadataText(metadata, META_BLOCK_BUTTON, DEFAULT_BLOCK_BUTTON) + + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setBackgroundColor(Color.parseColor("#F4F7FB")) + setPadding(dp(24), dp(24), dp(24), dp(24)) + } + + val card = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + setPadding(dp(28), dp(32), dp(28), dp(28)) + elevation = dp(8).toFloat() + + background = GradientDrawable().apply { + cornerRadius = dp(24).toFloat() + setColor(Color.WHITE) + } + } + + val icon = TextView(this).apply { + text = "!" + textSize = 54f + setTextColor(Color.parseColor("#DC2626")) + setTypeface(null, Typeface.BOLD) + gravity = Gravity.CENTER + setPadding(0, 0, 0, dp(14)) + } + + val title = TextView(this).apply { + text = titleText + setTextColor(Color.parseColor("#0F172A")) + setTypeface(null, Typeface.BOLD) + gravity = Gravity.CENTER + textSize = 24f + } + + val subtitle = TextView(this).apply { + text = subtitleText + setTextColor(Color.parseColor("#64748B")) + gravity = Gravity.CENTER + textSize = 15f + setPadding(0, dp(8), 0, dp(22)) + } + + val message = TextView(this).apply { + text = messageText + + setTextColor(Color.parseColor("#334155")) + gravity = Gravity.CENTER + textSize = 17f + setLineSpacing(0f, 1.3f) + setPadding(0, 0, 0, dp(28)) + } + + val closeButton = Button(this).apply { + text = buttonText + isAllCaps = false + textSize = 16f + setTextColor(Color.WHITE) + + background = GradientDrawable().apply { + cornerRadius = dp(14).toFloat() + setColor(Color.parseColor("#2563EB")) + } + + setPadding(dp(20), dp(14), dp(20), dp(14)) + + setOnClickListener { + finishAndRemoveTask() + } + } + + card.addView(icon) + + card.addView( + title, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + ) + + card.addView( + subtitle, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + ) + + card.addView( + message, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + ) + + card.addView( + closeButton, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + ) + + root.addView( + card, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + ) + + setContentView(root) + } + + private fun showConfigurationError(targetActivity: String) { + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setBackgroundColor(Color.WHITE) + setPadding(dp(24), dp(24), dp(24), dp(24)) + } + val title = TextView(this).apply { + text = "Launch configuration error" + setTextColor(Color.parseColor("#991B1B")) + setTypeface(null, Typeface.BOLD) + gravity = Gravity.CENTER + textSize = 22f + } + val message = TextView(this).apply { + text = "flutter_defender could not open $targetActivity.\nCheck aleem.flutter.defender.TARGET_ACTIVITY in AndroidManifest.xml." + setTextColor(Color.parseColor("#334155")) + gravity = Gravity.CENTER + textSize = 16f + setLineSpacing(0f, 1.25f) + setPadding(0, dp(18), 0, dp(26)) + } + val closeButton = Button(this).apply { + text = "Close App" + isAllCaps = false + setOnClickListener { finishAndRemoveTask() } + } + root.addView( + title, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + ) + root.addView( + message, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + ) + root.addView( + closeButton, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + ) + setContentView(root) + } + + private fun metadataText(metadata: Bundle, key: String, fallback: String): String { + return metadata.getString(key)?.takeIf { it.isNotBlank() } ?: fallback + } + + private fun dp(value: Int): Int { + return (value * resources.displayMetrics.density).toInt() + } + + private companion object { + const val TAG = "FlutterDefenderGuard" + const val META_TARGET_ACTIVITY = "aleem.flutter.defender.TARGET_ACTIVITY" + const val META_BLOCK_TITLE = "aleem.flutter.defender.BLOCK_TITLE" + const val META_BLOCK_SUBTITLE = "aleem.flutter.defender.BLOCK_SUBTITLE" + const val META_BLOCK_MESSAGE = "aleem.flutter.defender.BLOCK_MESSAGE" + const val META_BLOCK_BUTTON = "aleem.flutter.defender.BLOCK_BUTTON" + const val DEFAULT_BLOCK_TITLE = "Unsupported Device" + const val DEFAULT_BLOCK_SUBTITLE = "Security protection is enabled" + const val DEFAULT_BLOCK_BUTTON = "Close App" + val DEFAULT_BLOCK_MESSAGE = """ + This release build cannot run on emulators. + Please use a physical Android device. + + لا يمكن تشغيل هذا الإصدار على المحاكي. + يرجى استخدام جهاز حقيقي. + """.trimIndent() + } +} diff --git a/example/README.md b/example/README.md index 72e4f8d..c925005 100644 --- a/example/README.md +++ b/example/README.md @@ -9,6 +9,7 @@ This example is a manual feature lab for the plugin. It demonstrates: - advanced-layer toggles for root/jailbreak, proxy/VPN, and RASP checks - secure storage helper demo flow - release emulator or simulator blocking on guarded routes +- optional Android native release-emulator launch blocking through `ReleaseEmulatorGuardActivity` ## Run @@ -59,4 +60,6 @@ RUN_IOS_INTEGRATION=0 ./tool/run_tests.sh 6. Sign in, open **Authenticated Area**, background for less than 20 seconds, then repeat for more than 20 seconds and confirm logout is requested. 7. Open **Custom Blocking Screen Demo** and verify the underlying route is not tappable while blocked. 8. Apply the advanced security profiles and validate expected blocking behavior on matching device/network conditions. -9. Build a **release** on an emulator or simulator and verify guarded routes are blocked there while debug remains usable. +9. On Android, configure `ReleaseEmulatorGuardActivity` as the launcher, build a release APK, install it on an emulator, and verify launch is blocked before Flutter screens load. +10. On Android, run debug/profile builds on an emulator and verify they remain usable. +11. On iOS, run `flutter build ios --simulator --release` and verify Flutter rejects the build with `Release mode is not supported for simulators.` diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf7..391a902 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 634db94..808cd23 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -20,7 +20,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_defender: 7e55a34fe841275f58a880fe36b7bffcfaf3f48c + flutter_defender: bf0b8324f4507a2eda3f83a7ad3c14b7bd1566aa integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index 6266644..c30b367 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -2,12 +2,15 @@ import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index 62b054b..a9260bd 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -12,6 +14,13 @@ $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 + CFBundleLocalizations + + en + es + fr + ar + CFBundleName flutter_defender_example CFBundlePackageType @@ -22,15 +31,31 @@ ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) - CFBundleLocalizations - - en - es - fr - ar - LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -48,9 +73,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/ios/Classes/FlutterDefenderPlugin.swift b/ios/Classes/FlutterDefenderPlugin.swift index 682210b..172d5b0 100644 --- a/ios/Classes/FlutterDefenderPlugin.swift +++ b/ios/Classes/FlutterDefenderPlugin.swift @@ -1,5 +1,6 @@ import Darwin import Flutter +import MachO import Security import UIKit @@ -37,12 +38,20 @@ private final class IosAdvancedSecurityDetector { #else let suspiciousPaths = [ "/Applications/Cydia.app", + "/Applications/Sileo.app", + "/Applications/Zebra.app", "/Library/MobileSubstrate/MobileSubstrate.dylib", + "/Library/PreferenceLoader/PreferenceLoader.dylib", "/bin/bash", "/usr/sbin/sshd", "/etc/apt", "/private/var/lib/apt/", "/private/var/stash", + "/private/var/jb", + "/var/jb", + "/usr/lib/libjailbreak.dylib", + "/usr/lib/libsubstitute.dylib", + "/usr/lib/substrate", ] if suspiciousPaths.contains(where: { FileManager.default.fileExists(atPath: $0) }) { return true @@ -82,7 +91,7 @@ private final class IosAdvancedSecurityDetector { return false } let http = (settings[kCFNetworkProxiesHTTPEnable as String] as? NSNumber)?.boolValue ?? false - let https = (settings[kCFNetworkProxiesHTTPSEnable as String] as? NSNumber)?.boolValue ?? false + let https = (settings["HTTPSEnable"] as? NSNumber)?.boolValue ?? false return http || https } @@ -113,7 +122,50 @@ private final class IosAdvancedSecurityDetector { } private func isHookingDetected() -> Bool { - let suspicious = ["frida", "substrate", "cycript", "xposed", "libhook"] + hasSuspiciousDyldEnvironment() || + hasSuspiciousRuntimeClass() || + hasSuspiciousLoadedImage() || + hasSuspiciousInstrumentationPath() + } + + private func hasSuspiciousDyldEnvironment() -> Bool { + let environment = ProcessInfo.processInfo.environment + let suspiciousKeys = [ + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "DYLD_FRAMEWORK_PATH", + ] + return suspiciousKeys.contains { key in + environment[key]?.isEmpty == false + } + } + + private func hasSuspiciousRuntimeClass() -> Bool { + let suspiciousClasses = [ + "FridaGadget", + "FridaScriptEngine", + "CydiaSubstrate", + "SubstrateLoader", + "SubstrateBootstrap", + "MSHookFunction", + "CaptainHook", + "CYListenServer", + ] + return suspiciousClasses.contains { NSClassFromString($0) != nil } + } + + private func hasSuspiciousLoadedImage() -> Bool { + let suspicious = [ + "frida", + "gadget", + "substrate", + "substitute", + "libhooker", + "cycript", + "sslkill", + "flex", + "libcolorpicker", + ] for index in 0..<_dyld_image_count() { guard let rawName = _dyld_get_image_name(index) else { continue @@ -125,6 +177,20 @@ private final class IosAdvancedSecurityDetector { } return false } + + private func hasSuspiciousInstrumentationPath() -> Bool { + let suspiciousPaths = [ + "/usr/sbin/frida-server", + "/usr/bin/frida-server", + "/usr/lib/frida/frida-agent.dylib", + "/usr/lib/frida/frida-gadget.dylib", + "/Library/MobileSubstrate/DynamicLibraries/FridaGadget.dylib", + "/Library/MobileSubstrate/DynamicLibraries/SSLKillSwitch2.dylib", + "/Library/MobileSubstrate/DynamicLibraries/FLEX.dylib", + "/Library/MobileSubstrate/DynamicLibraries/RevealServer.dylib", + ] + return suspiciousPaths.contains { FileManager.default.fileExists(atPath: $0) } + } } private final class IosSecureStorageHelper { @@ -132,16 +198,22 @@ private final class IosSecureStorageHelper { func write(key: String, value: String) throws { guard let data = value.data(using: .utf8) else { - throw FlutterError(code: "storage_encoding_error", message: "Failed to encode secure value.", details: nil) + throw PigeonError(code: "storage_encoding_error", message: "Failed to encode secure value.", details: nil) } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: key, ] - let deleteStatus = SecItemDelete(query as CFDictionary) - if deleteStatus != errSecSuccess && deleteStatus != errSecItemNotFound { - throw FlutterError(code: "storage_delete_error", message: "Failed to delete existing keychain item.", details: deleteStatus) + let update: [String: Any] = [ + kSecValueData as String: data, + ] + let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary) + if updateStatus == errSecSuccess { + return + } + if updateStatus != errSecItemNotFound { + throw PigeonError(code: "storage_write_error", message: "Failed to update keychain item.", details: Int(updateStatus)) } let add: [String: Any] = [ @@ -153,7 +225,7 @@ private final class IosSecureStorageHelper { ] let addStatus = SecItemAdd(add as CFDictionary, nil) if addStatus != errSecSuccess { - throw FlutterError(code: "storage_write_error", message: "Failed to write keychain item.", details: addStatus) + throw PigeonError(code: "storage_write_error", message: "Failed to write keychain item.", details: Int(addStatus)) } } @@ -173,7 +245,7 @@ private final class IosSecureStorageHelper { guard status == errSecSuccess, let data = item as? Data else { - throw FlutterError(code: "storage_read_error", message: "Failed to read keychain item.", details: status) + throw PigeonError(code: "storage_read_error", message: "Failed to read keychain item.", details: Int(status)) } return String(data: data, encoding: .utf8) } @@ -186,7 +258,7 @@ private final class IosSecureStorageHelper { ] let status = SecItemDelete(query as CFDictionary) if status != errSecSuccess && status != errSecItemNotFound { - throw FlutterError(code: "storage_delete_error", message: "Failed to delete keychain item.", details: status) + throw PigeonError(code: "storage_delete_error", message: "Failed to delete keychain item.", details: Int(status)) } } @@ -197,7 +269,7 @@ private final class IosSecureStorageHelper { ] let status = SecItemDelete(query as CFDictionary) if status != errSecSuccess && status != errSecItemNotFound { - throw FlutterError(code: "storage_clear_error", message: "Failed to clear keychain items.", details: status) + throw PigeonError(code: "storage_clear_error", message: "Failed to clear keychain items.", details: Int(status)) } } } @@ -226,6 +298,8 @@ public final class FlutterDefenderPlugin: NSObject, FlutterPlugin, DefenderHostA private var screenshotObserver: NSObjectProtocol? private var captureObserver: NSObjectProtocol? + private var screenConnectObserver: NSObjectProtocol? + private var screenDisconnectObserver: NSObjectProtocol? private var didBecomeActiveObserver: NSObjectProtocol? private var willResignActiveObserver: NSObjectProtocol? @@ -238,7 +312,7 @@ public final class FlutterDefenderPlugin: NSObject, FlutterPlugin, DefenderHostA }, screenCaptureProvider: @escaping FlutterDefenderScreenCaptureProvider = { if #available(iOS 11.0, *) { - return UIScreen.main.isCaptured + return UIScreen.screens.contains { $0.isCaptured } } return false }, @@ -275,6 +349,12 @@ public final class FlutterDefenderPlugin: NSObject, FlutterPlugin, DefenderHostA if let captureObserver { notificationCenter.removeObserver(captureObserver) } + if let screenConnectObserver { + notificationCenter.removeObserver(screenConnectObserver) + } + if let screenDisconnectObserver { + notificationCenter.removeObserver(screenDisconnectObserver) + } if let didBecomeActiveObserver { notificationCenter.removeObserver(didBecomeActiveObserver) } @@ -364,8 +444,21 @@ public final class FlutterDefenderPlugin: NSObject, FlutterPlugin, DefenderHostA object: nil, queue: .main ) { [weak self] _ in - guard let self else { return } - self.flutterApi.onScreenCaptureChanged(active: self.screenCaptureProvider()) { _ in } + self?.emitScreenCaptureState() + } + screenConnectObserver = notificationCenter.addObserver( + forName: UIScreen.didConnectNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.emitScreenCaptureState() + } + screenDisconnectObserver = notificationCenter.addObserver( + forName: UIScreen.didDisconnectNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.emitScreenCaptureState() } } @@ -387,6 +480,10 @@ public final class FlutterDefenderPlugin: NSObject, FlutterPlugin, DefenderHostA } } + private func emitScreenCaptureState() { + flutterApi.onScreenCaptureChanged(active: screenCaptureProvider()) { _ in } + } + private func scheduleSecurityRefresh() { detectorQueue.async { [weak self] in guard let self else { return } diff --git a/pubspec.yaml b/pubspec.yaml index 0eba8a0..0efd422 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter_defender -description: "A comprehensive Flutter security plugin that protects against overlay attacks, task hijacking, screenshot capture, background session abuse, an emulator execution." -version: 0.2.4 -homepage: https://github.com/aleemElmozogi/flutter_defender +description: "A comprehensive Flutter security plugin that protects against overlay attacks, task hijacking, screenshot capture, background session abuse, and emulator execution." +version: 0.3.0 +homepage: https://pub.dev/packages/flutter_defender repository: https://github.com/aleemElmozogi/flutter_defender license: Apache-2.0