flutter_rasp_guard 0.1.0 copy "flutter_rasp_guard: ^0.1.0" to clipboard
flutter_rasp_guard: ^0.1.0 copied to clipboard

Runtime Application Self-Protection (RASP) for Flutter. Native root, jailbreak, instrumentation, hooking, debugger and integrity detection, with a security gate that decides in native code whether the [...]

Flutter RASP #

Runtime Application Self-Protection for Flutter, with native Android and iOS security enforcement.

License: MIT CI Platform

One import. Nothing platform-specific reaches your application code.

import 'package:flutter_rasp_guard/flutter_rasp_guard.dart';
Verdict passed Verdict failed
[The example app running with the engine active] [The gate blocking the application on an unsupported device]
The application renders normally. The application widget tree is never built.

Both captured from the bundled example on an Android emulator — which is why the block reason is "device is not supported". Screen protection is disabled in these two builds only; with it on, FLAG_SECURE blanks the screenshot, which is the point of it.


Overview #

RASP — Runtime Application Self-Protection — means the application defends itself while it runs, rather than relying on the environment to be trustworthy. A mobile app has no trusted execution context: the device may be rooted, the runtime may be hooked, the binary may have been repackaged, and a debugger may be attached. Flutter RASP detects those conditions from native code and lets you decide what the app does about them.

Why native enforcement. Dart is the wrong place to make a security decision. In a release build the Dart code is an AOT snapshot inside your APK or IPA, and an attacker who can patch the snapshot can make any Dart-side check return true. The detection, the policy, and the final verdict therefore all live in a C++ core reached over JNI and a Swift bridge. Dart receives a result it cannot influence — it asks, it does not decide.

What you integrate with. A single Dart package. RaspGate is a widget: wrap your app in it and the application widget tree is built only when the native verdict passes. There is no platform-specific code in your project.

Supported: Android (minSdk 23) and iOS (15.0+).

No security guarantee. Client-side integrity verification is inherently circular — the code doing the verifying is part of the application being verified. This SDK raises the cost of an attack. It does not make one impossible, and it is not a substitute for server-side validation. The security model is explicit about the limits.


Contents #


What it detects #

Category Android iOS
Instrumentation Frida (images, exported symbols, threads, control ports), injected libraries, executable anonymous memory same, plus tweak dylibs (Substrate, libhooker, ElleKit)
Hooking ART/JNI runtime tampering, libc & libssl prologue rewriting, Xposed/LSPosed Objective-C IMP redirection, symbol-table rebinding, libSystem prologue rewriting
Root / jailbreak Magisk, KernelSU, Zygisk, LSPosed, TrickyStore, Hide My Applist, su, and a concealment tier that survives Shamiko classic and rootless (/var/jb) jailbreaks, sandbox escape, successful fork(), DYLD_INSERT_LIBRARIES
Debugging TracerPid, load-time tracer latch P_TRACED via sysctl, load-time tracer latch
App integrity package id, signing certificate, Dart AOT snapshot bundle id, Team ID, code-signing flags, decrypted-binary (cryptid), Dart AOT snapshot
Emulator native /proc and property probes that survive Build field hooking Simulator
Observation FLAG_SECURE enforcement and strip detection screen recording, mirroring, screenshot notification
Network SPKI pinning verified in native memory same

The full catalogue with false-positive notes is in doc/ThreatReference.md.


The security gate #

The gate is the product. Detection on its own only produces a log line; the gate is what stops a compromised device from reaching your application.

MaterialApp(
  home: RaspGate(
    child: HomeScreen(),   // built ONLY if the native verdict passes
  ),
)

RaspGate blocks by not building its child. This is deliberate and it is not the same as covering the app with an overlay: on a blocked device HomeScreen is never constructed, so its initState never runs, no controller is created, no token is read, and no request is sent. There is no widget tree underneath to reach.

The verdict itself is computed in native code and Dart only renders it:

Decides The C++ core. Runs every enabled detector synchronously before answering.
Renders Dart. Receives passed, a coarse category, and a count.
Fails closed No answer, no native core, an unrecognised category, or a gate that has not evaluated yet all render as blocked.

Users see a category, never a detector name. RaspVerdictCategory carries ten user-safe messages — "USB debugging is enabled", "Debugger detected", "Application integrity verification failed" — chosen so that the block screen tells a legitimate user what to fix without handing an attacker a checklist of which probe fired.

RaspGate(
  blockedBuilder: (context, verdict) => MyBlockScreen(
    message: verdict.message,        // user-safe
    // verdict.threat is the precise code — send it to telemetry, not to the UI
  ),
  child: const HomeScreen(),
)

Native termination remains available as a secondary fallback for the most severe findings, configured through the response table. The gate is the primary mechanism because a process that is killed cannot explain itself, and a user whose app closes on launch has no idea why.


Live re-evaluation #

A condition the user can switch off must be re-checked, or the block is a dead end: the user fixes the problem, returns, and is still locked out.

Every return to the foreground triggers a fresh native evaluation. Changing a device setting requires leaving the app, so coming back is exactly the right moment to look again.

app running ──▶ user leaves ──▶ changes a setting ──▶ returns
                                                        │
                       Activity resume / didBecomeActive │
                                                        ▼
                              native re-reads the device state
                                                        │
                                                    new verdict
                                                    ┌────┴────┐
                                                  PASS      BLOCKED

No force-stop, no clearing from recents, no reinstall, no reboot — and the process ID does not change, because the process was never restarted.

Not everything is allowed to clear. The core keeps two independent sets of verdict state:

Cleared on every evaluation Example
Recoverable Yes — re-read from the live device state USB debugging, developer mode, screen recording, overlays, VPN/proxy
Sticky No — latched for the life of the process Root, Frida, hooking, injected code, a debugger, integrity or signature failure

The distinction is the security of the whole mechanism. If a compromise could clear, an attacker would have a trivial retry loop: get blocked, detach the tool, resume the app, pass. So evidence of a compromise never clears while the process lives, while an environmental toggle is recomputed from a genuine fresh read — the ADB detector re-reads Settings.Global, it does not simply forget what it saw before.

RaspGate mirrors the native verdict in both directions and imposes no latch of its own; what may clear is decided in native, where an attacker cannot reach it by patching a widget.


The one thing to understand first #

By the time your Dart code runs, the SDK is already protecting the app.

The native core starts from a ContentProvider on Android and a library constructor on iOS — both before main(), before the Flutter engine exists, and before any application code. By the time you call Rasp.initialize() it has already taken its integrity snapshots, started its watchdog beacons, and possibly already detected something.

Rasp.initialize() does not start protection. It joins a session already in progress: it hands the engine your policy and attaches Dart as a listener so buffered detections can be replayed to you.

This matters practically. The natural assumption is the opposite, and acting on it leads people to put initialize behind a splash screen, a login, or a remote-config fetch — which delays policy commit past the install deadline and trips the SDK's own anti-repackaging check against their own app.

Call Rasp.initialize() in main(), before runApp().


Quick start #

1. Add the dependency #

dependencies:
  flutter_rasp_guard: ^0.1.0

2. Initialise before runApp #

import 'package:flutter/material.dart';
import 'package:flutter_rasp_guard/flutter_rasp_guard.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Ship monitor mode first. See "Choosing a profile" below.
  final result = await Rasp.initialize(
    const RaspConfiguration.monitorOnly(),
  );

  if (!result.nativeAvailable) {
    // Treat as a security event, not a degraded mode — see below.
    debugPrint('RASP: native core unavailable');
  }

  // If the SDK terminated the previous run, this is the only place you will
  // ever learn why.
  final kill = await Rasp.consumeKillReport();
  if (kill != null) {
    analytics.log('rasp_kill', {'threat': kill.type.key});
  }

  Rasp.threats.listen((event) {
    analytics.log('rasp_threat', {
      'type': event.type.key,
      'severity': event.severity.name,
      'response': event.response.name,
      'count': event.occurrences,
    });
  });

  runApp(const MyApp());
}

3. React to a compromise #

MaterialApp(
  home: RaspGuard(
    blockedBuilder: (context) => const MyBrandedBlockScreen(),
    child: const HomeScreen(),
  ),
)

That is a complete integration. Everything below is refinement.


Android setup #

Nothing is required. The plugin merges its own ContentProvider into your manifest and builds the native core through Gradle's CMake integration.

Two things you should do before shipping a release build:

Pin your signing certificate #

Without a pin, signatureMismatch can never fire and repackaging detection is inert. Add to android/app/src/main/AndroidManifest.xml:

<application>
    <meta-data
        android:name="dev.rasp.expectedSignatureSha256"
        android:value="AA:BB:CC:...,DD:EE:FF:..." />
</application>

Get the hash with:

keytool -list -v -keystore your-release.keystore -alias your-alias | grep SHA256

Play App Signing: your upload key and your distribution key are different. Pin both, or your Play-signed build will fail its own check. The distribution certificate is in Play Console → Setup → App signing.

Set the policy floor #

The floor is the minimum response the SDK will apply, regardless of what runtime configuration asks for. It lives in the signed manifest so a patched Dart snapshot cannot weaken it.

<meta-data android:name="dev.rasp.minimumResponse" android:value="report" />

Values: ignore, report (default), block, kill.

minSdk is 23. Four ABIs are built by default.


iOS setup #

Set the deployment target — required #

This is the one step you cannot skip. RASP requires iOS 15, and a Flutter project generated by flutter create declares no platform at all, so CocoaPods assumes 13.0 and dependency resolution fails.

In ios/Podfile, uncomment the platform line and set it to 15.0:

platform :ios, '15.0'

Then:

cd ios && pod install

Without it you get an error that does not name the real cause:

[!] CocoaPods could not find compatible versions for pod "flutter_rasp_guard":
    Specs satisfying the `flutter_rasp_guard` dependency were found, but they
    required a higher minimum deployment target.

Also raise iOS Deployment Target to 15.0 in Xcode (Runner target → Build Settings) so the app target and the pod agree.

Everything else is automatic #

The pod compiles the native core and installs a library constructor. Before shipping:

Pin your Team ID #

Add an RASP dictionary to ios/Runner/Info.plist:

<key>RASP</key>
<dict>
    <key>MinimumResponse</key>
    <string>report</string>
    <key>ExpectedTeamId</key>
    <string>ABCDE12345</string>
</dict>

Decide what "kill" means on iOS #

It defaults to block, not terminate:

const RaspConfiguration.production().copyWith(
  ios: const RaspIosOptions(fatalResponse: IosFatalResponse.block),
)

Apple treats programmatic termination as a crash, repeated crash-on-launch attracts review attention, and a user whose banking app silently vanishes writes a one-star review rather than a support ticket. Every incumbent product in this category blocks on iOS and backs it with server-side attestation. Override to IosFatalResponse.kill only if a compliance requirement demands it.

Deployment target is iOS 15 — set it in your Podfile, see above.


Reacting to threats #

Three surfaces, in increasing order of commitment:

// 1. A stream — for telemetry.
Rasp.threats.listen(analytics.record);

// 2. State transitions — for UI.
Rasp.states.listen((state) {
  if (state.isCompromised) showBlockScreen();
});

// 3. A widget — for the common case.
RaspGuard(child: MyApp())

RaspState.compromised is terminal. A device that was compromised once cannot be proven clean afterwards, so the engine never returns to active.

Do not do this #

// WRONG: initialize after a network call.
await remoteConfig.fetch();
await Rasp.initialize(config);   // install deadline may already have passed
// WRONG: treating an unavailable native core as acceptable.
final result = await Rasp.initialize(config);
// ...and then ignoring result.nativeAvailable

Every unhookable defence lives in the native core. The single highest-value attack on any JNI-backed RASP is not on a detector — it is one line against the library loader:

Runtime.nativeLoad.implementation = (path, loader) =>
    path.indexOf('rasp') !== -1 ? null : this.nativeLoad(path, loader);

That reduces the SDK to a hookable Dart shell. nativeAvailable == false is how you find out it happened.


Choosing a profile #

Profile Mode Terminates? Use for
RaspConfiguration.monitorOnly() monitor never your first release
RaspConfiguration.production() enforce never — blocks most apps
RaspConfiguration.strict() enforce high-confidence tier only regulated / high-value
RaspConfiguration.development() monitor never CI, QA, internal builds

Ship monitorOnly first. Watch your telemetry for a release cycle, see what your real install base actually produces, then escalate. Enabling enforcement without that data is how a security SDK ends up in someone's incident review — the failure mode is not "attackers get through", it is "10,000 legitimate users on a Samsung device cannot open the app".

Full reference: doc/Configuration.md.


Verifying it works #

On a clean device the threat list stays empty, which is correct but unconvincing. To see the SDK react:

Test Expected
flutter run (debug build) device.developer_mode
Android Studio ▶ Debug, or jdb -attach debug.attached
lldb/gdb attach debug.tracer, debug.at_load
Run on an emulator / Simulator android.emulator_native / ios.simulator
frida -U -f your.app.id frida.runtime, usually within one beacon tick
Rooted device with Magisk android.root_artifact, android.concealment
Jailbroken device ios.jailbreak
Re-sign the APK with a different key integrity.signature
Patch libapp.so by one byte integrity.app_snapshot

The last one is the Flutter-specific case worth testing yourself, because it is the attack a naively ported Android RASP misses entirely. Full matrix and troubleshooting: doc/Troubleshooting.md.


Architecture #

┌──────────────────────────────────────────────────────────────┐
│  Your Flutter app     import 'package:flutter_rasp_guard/...'│
├──────────────────────────────────────────────────────────────┤
│  Dart API               Rasp · RaspConfiguration · streams   │
├──────────────────────────────────────────────────────────────┤
│  Platform channels      control plane + event stream         │
├───────────────────────────┬──────────────────────────────────┤
│  Android engine (Kotlin)  │  iOS engine (Swift)              │
│  lifecycle · environment  │  lifecycle · environment         │
│  marshalling · screen     │  marshalling · screen guard      │
│  guard · JNI facade       │  C interop facade                │
├───────────────────────────┴──────────────────────────────────┤
│  NATIVE SECURITY CORE (C++17)          ← the product         │
│  policy engine · watchdog beacons · dispatcher · ring buffer │
│  kill ladder · detector registry · SPKI pin table            │
│  ┌────────────────┬─────────────────┬──────────────────────┐ │
│  │ port layer     │ platform/android│ platform/ios         │ │
│  │ (free fns, no  │ /proc · ELF ·   │ Mach VM · Mach-O ·   │ │
│  │  vtables)      │ raw syscalls    │ dyld · public APIs   │ │
│  └────────────────┴─────────────────┴──────────────────────┘ │
└──────────────────────────────────────────────────────────────┘

The security decisions — what counts as a threat, whether to enforce, whether to terminate — all live in the C++ core. The Kotlin and Swift layers are transports. Dart is treated as untrusted: a repackaged Flutter app has a patched AOT snapshot, so runtime configuration may only escalate policy above a build-time floor, never soften it.

Full detail: doc/Architecture.md · doc/SecurityModel.md.


What this does not defend against #

Stated plainly, because a vendor who overclaims gets found out:

  • An attacker with a rooted device, physical access and unlimited time eventually wins. Client-side integrity is circular — the code verifying the app is the code being modified. Local detection raises cost; it does not create a boundary.
  • iOS cannot enumerate processes or installed applications. The sandbox denies it. Several signals Android has do not exist on iOS, and the capability matrix in doc/SecurityModel.md says so rather than implying parity.
  • dart:io TLS is not covered by platform pinning. Dart's HttpClient uses BoringSSL compiled into libflutter, not the OS TLS stack. Pinning that covers only URLSession and OkHttp leaves every package:http request unpinned. This is the most likely real-world bypass of any Flutter security SDK; the mitigations are documented rather than glossed over.
  • This is not a substitute for server-side attestation. Play Integrity and App Attest are what make a verdict something your backend can trust. This SDK is the fast local path and the tamper-evidence layer.

Repository layout #

flutter_rasp_guard/
├── lib/                         Dart public API
│   ├── flutter_rasp_guard.dart  the single import
│   └── src/                     models · platform channel · widgets
├── android/                     Kotlin engine + Gradle/CMake wiring
│   └── src/main/kotlin/dev/rasp/security/
├── ios/                         Swift engine + podspec
│   ├── Classes/                 Swift bridge
│   └── native/                  THE SECURITY CORE (C++17)
│       ├── include/rasp/        public C ABI
│       ├── core/                policy · gate · events · watchdog · kill · pinning
│       ├── detectors/           portable detectors
│       ├── platform/{android,ios,host}/
│       └── tests/               host-run unit tests, no device needed
├── example/                     reference integration
├── test/                        Dart tests
├── tool/                        token generator
└── doc/

Why the shared C++ core lives under ios/native/. CocoaPods cannot reference sources outside the podspec's directory — it silently drops them — and pub publish ships only files inside the package directory. Putting the core anywhere else means it is missing from the published package, from the pod, or from both. Android reaches it through ../ios/native/CMakeLists.txt, which is the one direction that works for all three build systems at once.

The core builds standalone with plain CMake and no Flutter, Gradle or CocoaPods. That is deliberate: the policy engine is testable on a laptop in milliseconds, and the same core can back a Kotlin Multiplatform or pure-native distribution without a Flutter dependency.


Building and testing #

flutter analyze --fatal-infos && flutter test
cmake -S ios/native -B build -DRASP_HOST_BUILD=ON -DRASP_BUILD_TESTS=ON && cmake --build build && ./build/rasp_tests
cd example && flutter run
python3 tool/encode_tokens.py

The last command regenerates the obfuscated detection tokens; run it after editing the token list, so that strings on a release binary does not print the detector inventory.


Documentation #

Document What it covers
Architecture.md Layers, lifecycle, data flow, design decisions
Integration.md Step-by-step for both platforms
Configuration.md Every option, build-time and runtime
ThreatReference.md Every threat, cause, false-positive profile
SecurityModel.md Trust model, capability matrix, honest limits
MigrationGuide.md Moving from an existing Android RASP
Troubleshooting.md False positives, build errors, test matrix
FAQ.md Common questions

Contributing #

Issues and pull requests are welcome. Before opening a PR:

flutter analyze --fatal-infos && flutter test
cmake -S ios/native -B build -DRASP_HOST_BUILD=ON -DRASP_BUILD_TESTS=ON && cmake --build build && ./build/rasp_tests

A change to detection or policy behaviour needs a test that fails without it. Changes to the C ABI must bump RASP_ABI_VERSION — the Dart, Kotlin, Swift and C tiers have to agree.

Found a bypass? Please do not open a public issue. See SECURITY.md for private reporting.


Author #

Ahmed Omara@AhmedOmara14


Licence #

MIT, with a no-security-guarantee notice. See doc/SecurityModel.md for what this SDK does and does not claim to do.

1
likes
130
points
39
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Runtime Application Self-Protection (RASP) for Flutter. Native root, jailbreak, instrumentation, hooking, debugger and integrity detection, with a security gate that decides in native code whether the application may open.

Topics

#security #rasp #root-detection #jailbreak-detection #anti-tampering

License

unknown (license)

Dependencies

flutter

More

Packages that depend on flutter_rasp_guard

Packages that implement flutter_rasp_guard