flutter_rasp_guard 0.1.0
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.
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
- The security gate
- Live re-evaluation
- The one thing to understand first
- Quick start
- Android setup
- iOS setup
- Reacting to threats
- Choosing a profile
- Verifying it works
- Architecture
- What this does not defend against
- Repository layout
- Building and testing
- Documentation
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:ioTLS is not covered by platform pinning. Dart'sHttpClientuses BoringSSL compiled intolibflutter, not the OS TLS stack. Pinning that covers onlyURLSessionand OkHttp leaves everypackage:httprequest 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.