【Flutter】freerasp × Riverpod でルート化/脱獄などの脅威を検知・ハンドリングする設計
はじめに
アプリのセキュリティー強化は大切です。
モバイルアプリにおいてはAndroid OS のルート化、iOSの脱獄(ジェイルブレーク)によって、
アプリのリバースエンジニアリングによる、機微情報の抜き取り、データの改竄、APIの悪用などの危険があります。
そういった悪意あるユーザーの脅威を検知、防ぐサービスとして有名なところではFirebase AppCheckがあります。
しかし、私としてはイマイチ安定に欠けると思っていたところで先日、Talsec社が提供する freerasp というパッケージを知りました。
この記事ではこの freerasp を riverpod と組み合わせて実装したサンプルを作ってみたので、ご紹介します。
記事の対象者
- Flutter でモバイルアプリを開発しており、ルート化 / 脱獄 への対策やセキュリティ強化に興味がある人
- Firebase App Check などを使ったことがあるが、別のアプローチや補完手段を探している人
- freerasp を知った / 使ってみたいが、どんなことができるのかと実装イメージを掴みたい Flutter エンジニア
- Riverpod を日常的に使っていて、セキュリティ関連の状態管理・通知をどう設計するか知りたい人
- 「検知ロジック(インフラ寄りの処理)」と「UI や画面遷移」をきちんと分離した設計例を見てみたい人
記事を執筆時点での筆者の環境
[✓] Flutter (Channel stable, 3.35.4, on macOS 26.0.1 25A362 darwin-arm64, locale ja-JP)
[!] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] VS Code (version 1.104.3)
[✓] Connected device (5 available)
[✓] Network resources
関連パッケージ
サンプルプロジェクト
- アプリを起動すると裏で freerasp による検査が開始
- 検査が終わり、正常であればUIが変わってログインボタンが現れる
- ログイン(仮)後にホーム画面のフローティングボタンを押して、検査状態を不正として流す
- 状態変更を検知してUIが反応、ログイン画面に戻される
ソースコード
freerasp でできること
概要
このパッケージは冒頭でも述べた ルート化/脱獄といったOSの改造のほかに、アプリに対して悪意ある操作である可能性のある操作を検知することができます。
詳しい内容は公式のドキュメントに譲りますが、そうした脅威を検知して 知らせてくれる機能 を提供してくれます。
この脅威がないかを検知するタイミングは2つあります。
- アプリ起動時(初期化を実行した時)に実施
- アプリ起動中に定期的に実施
検知した内容を通知する機能やダッシュボードで確認することもできるようです。
開発時の考慮事項
このパッケージの検知機能を有効化させて開発を行っていると、いわゆるリスタートがうまく動かなくなりました。
リスタートしたい場合は一旦停止してから再ビルドする必要があるようです。
※ ホットリロードは大丈夫でした。
実装にあたっての設計
freerasp を動作させるには、以下の3つを実装します。
- 検査完了時のコールバック設定
- 脅威検知時のコールバック設定
- 基本設定と検査の実行
コールバック内に DB 削除や画面遷移などを直接書くこともできますが、責務が肥大化するため、コールバックでは状態の通知だけを行う設計にしました。
また、検査完了のコールバックが用意されていることから、検査が終わるまではアプリを操作させないようにしたいと考えました。
これらを実現するため、検査状態を Stream で配信し、UI 側で購読する構成にしています。
検査の実行と通知を送る仕組みを実装する
チェック状態を表す DeviceSecurityStatus
まず、デバイスのチェック状態を表現する型を sealed class で定義します。
今回はこれを riverpod で扱うことも鑑みて相性のいい freezed で定義します。
/// デバイスセキュリティの状態
sealed class DeviceSecurityStatus with _$DeviceSecurityStatus {
/// チェック中
const factory DeviceSecurityStatus.checking() = DeviceSecurityStatusChecking;
/// チェック完了で安全
const factory DeviceSecurityStatus.safe() = DeviceSecurityStatusSafe;
/// 不正端末
const factory DeviceSecurityStatus.threat({
required String message,
}) = DeviceSecurityStatusThreat;
}
機能を提供するソースのインスタンス talsecProvider
次に機能を提供するパッケージのインスタンスを provider で定義しておきます。
パッケージ名は freerasp なのにクラスが社名の Talsec なのでちょっとややこしいです。
(keepAlive: true)
Talsec talsec(Ref ref) => Talsec.instance;
検査の実行・状態通知・監視機能を提供する DeviceSecurityRepository の全体像
表題の通りに次の大きく分けると3つを実装します。
-
init: 検知機能の初期設定、コールバック設定と検査の開始を行う初期化 -
_statusController: セキュリティーの状態を通知する -
watch: セキュリティーの状態を監視する
まずは全体をお見せすると、以下となります。
class DeviceSecurityRepository {
DeviceSecurityRepository(this.ref, {required this.enableThreatInDebug});
final Ref ref;
/// デバッグモードで脅威検知を有効にするかどうか
///
/// テストで差し込めるようにコンストラクタ引数にしている
final bool enableThreatInDebug;
/// セキュリティ状態のストリームコントローラー
final _statusController = _StatusStreamController();
/// Talsec インスタンス
Talsec get _talsec => ref.read(talsecProvider);
/// 設定
///
/// テストで差し込めるようにgetterにしている
TalsecConfig get config => _TalsecConfig.value;
/// セキュリティ状態を監視するストリーム
///
/// 購読開始時に現在の状態を即座に流し、以降は状態変化時に通知する
Stream<DeviceSecurityStatus> watch() => _statusController.watch();
/// リソースの解放
Future<void> dispose() async => _statusController.close();
/// 初期化: freeRASP を開始 + コールバックを設定
Future<void> init() async {
// 検査完了時のコールバック登録:安全としみなしてストリームを流す
_talsec.attachExecutionStateListener(_createExecutionStateCallback());
// 脅威検知用コールバックの登録:脅威検知時に内容によってストリームを流す
await _talsec.attachListener(_createThreatCallback());
// freeRASP 開始
//
// 原則は初回にのみ呼び出す。
// 以後はアプリが生きている限りバックグラウンドで動作し続ける。
await _talsec.start(config);
}
}
先ほど大きく分けて3つ述べましたが、そのうちの2つは init が役割を担っています。
まず、 init で行っている流れは、
- 検査の完了/脅威の検知 のコールバックを設定
このコールバックの中で状況に応じて_statusControllerで状態を配信する - 検査の基本設定をし、検査を実行
を行っています。
DeviceSecurityStatus を監視、更新する仕組みを整備する
init の中で登録しているコールバックの大きな役割は、DeviceSecurityStatus を更新することにあります。
そして、その変更を検知するメソッドとして提供しているのが watchです。
Stream<DeviceSecurityStatus> watch() => _statusController.watch();
その watch を実現するために、コールバックで状態を配信したり、配信するかどうかを判断したり、といった内容を一箇所で定義すべくリポジトリー内で DeviceSecurityStatus を管理する _StatusStreamController クラスを定義します。
final class _StatusStreamController {
/// 現在の値(初期値は checking)
DeviceSecurityStatus _value = const DeviceSecurityStatus.checking();
final _streamController = StreamController<DeviceSecurityStatus>.broadcast();
/// 安全な状態かどうか(脅威が検出されていない)
bool get isSafe => _value is! DeviceSecurityStatusThreat;
/// 値を更新してストリームに流す
void _add(DeviceSecurityStatus value) {
_value = value;
_streamController.add(value);
}
/// 安全状態を通知
void addSafe() => _add(const DeviceSecurityStatus.safe());
/// 脅威検出を通知
void addThreat(String message) =>
_add(DeviceSecurityStatus.threat(message: message));
/// 現在の値を流してからストリームを返す
Stream<DeviceSecurityStatus> watch() {
return Stream<DeviceSecurityStatus>.multi((sink) {
// 1. controller.stream を購読
final subscription = _streamController.stream.listen(
sink.add,
onError: sink.addError,
onDone: sink.close,
);
sink
// 2. 現在の値を即座に流す
..add(_value)
// 3. キャンセル時にクリーンアップ
..onCancel = subscription.cancel;
});
}
/// リソースの解放
Future<void> close() => _streamController.close();
}
クラス内ではまず、現状の状態を保持しておきます。
初期はチェック中としておきます。
DeviceSecurityStatus _value = const DeviceSecurityStatus.checking();
次に状態をストリームで流すコントローラを定義しています。
final _streamController = StreamController<DeviceSecurityStatus>.broadcast();
そして、コールバック内で 脅威の検知/完了の検知 をした場合はその状態を自身に保存した上で、配信します。
void _add(DeviceSecurityStatus value) {
_value = value;
_streamController.add(value);
}
watch では Stream.multi を使い、
- まず
_streamController.streamを購読して「これから流れてくる値」を受け取れるようにしてから - その直後に「現時点の値(キャッシュされている
_value)」を一発流し - 購読がキャンセルされたら、内部の購読もちゃんと解放する
という流れをひとまとめにしています。これにより、
「購読開始時点の最新状態」と「それ以降の変化」の両方を、漏れなく受け取れるストリームになります。
/// 現在の値を流してからストリームを返す
Stream<DeviceSecurityStatus> watch() {
return Stream<DeviceSecurityStatus>.multi((sink) {
// 1. controller.stream を購読
final subscription = _streamController.stream.listen(
sink.add,
onError: sink.addError,
onDone: sink.close,
);
sink
// 2. 現在の値を即座に流す
..add(_value)
// 3. キャンセル時にクリーンアップ
..onCancel = subscription.cancel;
});
}
検査完了のコールバック
検査完了のコールバックでは専用のクラス、RaspExecutionStateCallback にコールバック関数を渡します。
extension _CallbackExtension on DeviceSecurityRepository {
/// 実行状態のコールバックを生成
RaspExecutionStateCallback _createExecutionStateCallback() {
return RaspExecutionStateCallback(
onAllChecksDone: () {
// 初期スキャン完了時、脅威が検出されていなければ安全
if (_statusController.isSafe) {
_statusController.addSafe();
}
},
);
}
// ...
}
ここでは先ほど定義した _StatusStreamController の isSafe を使って現在の状態を確認します。
bool get isSafe => _value is! DeviceSecurityStatusThreat;
状態が脅威を検知していない == 安全
ということで DeviceSecurityStatus.safe() を流します。
これは当たり前ではありますが、コールバックの呼び出し順番が最初に脅威検知のコールバック、最後に完了コールバックとなるからです。
完了コールバック == 安全だったことを知らせるコールバック ➡️ ではない! ということに注意しましょう。
そして、状態を流す _statusController.addSafe(); の実装は内部で以下のように書いています。
void addSafe() => _add(const DeviceSecurityStatus.safe());
こうして定義した完了コールバックの関数を、init で登録しています。
// 検査完了時のコールバック登録:安全としみなしてストリームを流す
_talsec.attachExecutionStateListener(_createExecutionStateCallback());
検知できる脅威を危険度ごとに分類しつつ一元管理する
このパッケージで検知できる脅威は全部で18種類あります。
ただ、この全てが危険かどうかは内容を見ると微妙なものもあります。
ここは公式ドキュメントにも記載がありますが、アプリの要件によってその検知されたものが脅威であるかどうかは変わると思われます。
そこで検知できる脅威の危険度を3段階で分けることにします。
enum _ThreatLevel {
/// 危険:アプリをブロック
block,
/// 監視:アプリは継続するが、Crashlytics等で集計
monitor,
/// 無視:ログも送らない(網羅のために定義)
ignore,
}
検知できる脅威をenumでまとめつつ、以下のフィールドを設定していきます。
- メッセージ
- 危険度レベル
- デバックモードで無視するか
以下は一部を抜粋しています。
enum _ThreatType {
/// root/Jailbreakを検知
privilegedAccess(
'root化/Jailbreakが検出されました',
level: _ThreatLevel.block,
),
/// パスコード未設定を検知
passcode(
'端末にパスコードが設定されていません',
level: _ThreatLevel.monitor,
),
/// 安全でないWiFiを検知
unsecureWifi(
'安全でないWiFiが検出されました',
level: _ThreatLevel.ignore,
),
//...
;
const _ThreatType(
this.message, {
required this.level,
this.ignoreInDebugMode = false,
});
/// ログ出力用のメッセージ
final String message;
/// 脅威の危険度
final _ThreatLevel level;
/// デバッグモードでは無視するかどうか
final bool ignoreInDebugMode;
}
_ThreatType 全文
enum _ThreatType {
/// 動的フッキング(Frida等)を検知
hooks(
'動的フッキング(Frida等)が検出されました',
level: _ThreatLevel.block,
),
/// デバッガ接続を検知
debug(
'デバッガが検出されました',
level: _ThreatLevel.block,
ignoreInDebugMode: true,
),
/// パスコード未設定を検知
passcode(
'端末にパスコードが設定されていません',
level: _ThreatLevel.monitor,
),
/// アプリ再インストールを検知(iOS only)
deviceId(
'アプリが再インストールされました',
level: _ThreatLevel.ignore,
),
/// エミュレータ/シミュレータを検知
simulator(
'エミュレータ/シミュレータが検出されました',
level: _ThreatLevel.block,
ignoreInDebugMode: true,
),
/// アプリ整合性の違反を検知
appIntegrity(
'アプリの整合性に問題があります',
level: _ThreatLevel.block,
),
/// 難読化されていないことを検知
obfuscationIssues(
'アプリが難読化されていません',
level: _ThreatLevel.monitor,
ignoreInDebugMode: true,
),
/// デバイスバインディング違反を検知
deviceBinding(
'デバイスバインディングに違反しています',
level: _ThreatLevel.block,
),
/// 非公式ストアからのインストールを検知
unofficialStore(
'非公式ストアからインストールされました',
level: _ThreatLevel.block,
ignoreInDebugMode: true,
),
/// root/Jailbreakを検知
privilegedAccess(
'root化/Jailbreakが検出されました',
level: _ThreatLevel.block,
),
/// セキュアハードウェア未対応を検知
secureHardwareNotAvailable(
'セキュアハードウェアが利用できません',
level: _ThreatLevel.monitor,
),
/// システムVPNを検知
systemVpn(
'システムVPNが検出されました',
level: _ThreatLevel.ignore,
),
/// 開発者モードを検知
devMode(
'開発者モードが有効です',
level: _ThreatLevel.monitor,
ignoreInDebugMode: true,
),
/// ADBを検知(Android only)
adbEnabled(
'ADBが有効です',
level: _ThreatLevel.monitor,
ignoreInDebugMode: true,
),
/// マルウェアを検知(Android only)
malware(
'マルウェアが検出されました',
level: _ThreatLevel.block,
),
/// スクリーンショットを検知
screenshot(
'スクリーンショットが検出されました',
level: _ThreatLevel.ignore,
),
/// 画面録画を検知
screenRecording(
'画面録画が検出されました',
level: _ThreatLevel.ignore,
),
/// マルチインスタンスを検知
multiInstance(
'複数インスタンスが検出されました',
level: _ThreatLevel.block,
),
/// 安全でないWiFiを検知
unsecureWifi(
'安全でないWiFiが検出されました',
level: _ThreatLevel.ignore,
),
/// 時刻改ざんを検知
timeSpoofing(
'時刻の改ざんが検出されました',
level: _ThreatLevel.block,
),
/// 位置情報改ざんを検知
locationSpoofing(
'位置情報の改ざんが検出されました',
level: _ThreatLevel.block,
);
//...
}
脅威を検知した場合のコールバック
こちらは検査完了のコールバックとは違い、脅威の種類とその脅威のレベルによってハンドリングを変えます。
まずは各種のコールバックで _handleThreat に検知した脅威の種類を渡します。
extension _CallbackExtension on DeviceSecurityRepository {
// ...
/// 脅威検知用コールバックを生成
ThreatCallback _createThreatCallback() {
return ThreatCallback(
onHooks: () => _handleThreat(_ThreatType.hooks),
onDebug: () => _handleThreat(_ThreatType.debug),
onPasscode: () => _handleThreat(_ThreatType.passcode),
// 以下省略
// ...
);
}
/// 脅威検知時の共通処理
void _handleThreat(_ThreatType type) {
// _ThreatTypeによってハンドリングする
}
}
_handleThreat 内ではまず最初に開発中は無視するものを除外します。
開発中であり、かつ除外したいフラグを持つものは除外します。
デバックモードやエミュレータは検知されてしまうので、開発中は検知を無効にします。
void _handleThreat(_ThreatType type) {
// デバッグモードで無視する設定の場合
if (kDebugMode && type.ignoreInDebugMode) {
logger.d('${type.message}(デバッグモードでは無視)');
return;
}
// ...
}
その後は脅威のレベルによって処理を切り替えます。
switch (type.level) {
case _ThreatLevel.block:
// Crashlytics等に送信などを想定
logger.w('セキュリティ脅威: ${type.message}');
// まだ脅威が検出されていない場合のみストリームに流す
// 2種類目の脅威以降は通知しない
if (_statusController.isSafe) {
_statusController.addThreat(type.message);
}
case _ThreatLevel.monitor:
// Crashlytics等に送信(アプリは継続)
logger.i('セキュリティ監視: ${type.message}');
case _ThreatLevel.ignore:
// 何もしない(網羅のために定義)
break;
}
危険なものだった場合は Crashlytics などにレポートを送信。
先ほどと同じく _statusController.isSafe がまだ true だった場合は、今度は _statusController.addThreat(type.message); でストリームに脅威を流します。
addThreat の実装は以下となっています。
void addThreat(String message) =>
_add(DeviceSecurityStatus.threat(message: message));
アプリ側としては一度でも脅威が検知できたらよく、2種類目以降の検知は内々に処理(ログ送信など)していれば良いので、一度でも脅威を流していれば送信しないようにしています。
その他のレベルはログだけ送信、または何もしないといったハンドリングを行っています。
以上のように定義したコールバックの関数を使って、以下の init の中で登録しています。
// 脅威検知用コールバックの登録:脅威検知時に内容によってストリームを流す
await _talsec.attachListener(_createThreatCallback());
基本設定を登録しつつ、検査を実行する
init の最後には、基本設定を登録しつつ検査を実行する start を宣言します。
基本の実行宣言は最初だけでよく、アプリが生きている限り、
- 初回の検査
- アプリ実行中の定期的な検査
が行われるため、2回目の宣言は不要です。
await _talsec.start(config);
また、この設定値である config はこの init 関数内で書いてもいいのですが、見通しを良くするために以下のようにプライベートなクラスに定義しています。
abstract final class _TalsecConfig {
/// Talsec 設定値
static final value = TalsecConfig(
watcherMail: _watcherMail,
androidConfig: _androidConfig,
iosConfig: _iosConfig,
// 💡 不正を検知した際にアプリを強制終了する場合は有効化
// killOnBypass: true,
);
/// Talsec ポータル向けメール
static const _watcherMail = 'your_mail@example.com';
/// Android設定
static final _androidConfig = AndroidConfig(
packageName: 'com.base.sample.app.base_sample',
// デバッグ証明書のBase64-SHA256ハッシュ
signingCertHashes: [
'tgjD7tTWEyd0juKHzWS6/Hf00Sl0hdPHSJ69Mm+LSOc=',
],
supportedStores: [],
);
/// iOS設定
static final _iosConfig = IOSConfig(
// iOS 向けなら実際の Bundle ID
bundleIds: ['your.bundle.id'],
// iOS 向けならあなたの Team ID
teamId: 'YOUR_APPLE_TEAM_ID',
);
}
こちらをテストなどでも差し込めるように、リポジトリーのインスタンスのゲッターで定義しています。
TalsecConfig get config => _TalsecConfig.value;
通知を受け取る仕組み
先述した DeviceSecurityRepository で配信されるストリームを受信するproviderを定義します。
/// デバイスセキュリティ状態のProvider
(keepAlive: true)
Stream<DeviceSecurityStatus> deviceSecurityStatus(Ref ref) {
final repository = ref.read(deviceSecurityRepositoryProvider);
return repository.watch();
}
UI側でセキュリティー状態を受け取る
初期化を実行
アプリの初期化プロバイダーで先ほどの init を呼び出します。
(keepAlive: true)
Future<void> appStartup(Ref ref) async {
// DBの初期化など、他の初期化処理があればここに追加
// ...
// freeRASPの初期化
final deviceSecurityRepository = ref.read(deviceSecurityRepositoryProvider);
await deviceSecurityRepository.init();
}
この初期化を実行するのはmainの直下を想定しています。
class AppStartupConsumer extends ConsumerWidget {
const AppStartupConsumer({
required this.onLoaded,
required this.onLoading,
required this.onError,
super.key,
});
final Widget Function() onLoaded;
final Widget Function() onLoading;
final Widget Function(
Object,
StackTrace, {
required VoidCallback onRestart,
}) onError;
Widget build(BuildContext context, WidgetRef ref) {
final appStartupState = ref.watch(appStartupProvider);
return appStartupState.when(
skipLoadingOnRefresh: false,
data: (_) => onLoaded(),
loading: onLoading,
error: (e, s) =>
onError(e, s, onRestart: () => ref.invalidate(appStartupProvider)),
);
}
}
/// アプリのエントリーポイント
void main() {
runApp(
const ProviderScope(
child: AppStartupConsumer(
onLoaded: MainApp.new,
onLoading: MainAppLoading.new,
onError: MainAppError.new,
),
),
);
}
最初の画面であるログイン画面でwatchする
ここで deviceSecurityStatusProvider を watch します。
ここでは最初に DeviceSecurityStatus.checking()が流れてくる想定です。
class LoginScreen extends ConsumerWidget {
const LoginScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final asyncSecurityStatus = ref.watch(deviceSecurityStatusProvider);
return Scaffold(
appBar: AppBar(
title: const Text('ログイン'),
),
body: Center(
child: switch (asyncSecurityStatus) {
AsyncValue(value: final status?) =>
_SecurityStatusView(status: status),
AsyncError(:final error) => Column(
// ...
),
AsyncLoading() => const Column(
// ...
),
},
),
);
}
}
一度 deviceSecurityStatusProvider の状態が読み込みできれば、あとは検査が完了するか、脅威を検知した場合に状態が更新されます。
更新された状態によって、UIを出し分けています。
class _SecurityStatusView extends StatelessWidget {
const _SecurityStatusView({required this.status});
final DeviceSecurityStatus status;
Widget build(BuildContext context) {
return switch (status) {
DeviceSecurityStatusChecking() => const _CheckingView(),
DeviceSecurityStatusSafe() => const _SafeView(),
DeviceSecurityStatusThreat(:final message) =>
_ThreatView(message: message),
};
}
}
// 実際のUI実装は省略
アプリを使用中に脅威を検知した場合の処理
例えば、ログイン完了後にホーム画面に遷移しているとします。
そのままアプリを使用している最中に脅威を検知した場合は各種画面、またはその親となる根本の画面で deviceSecurityStatusProvider を listen しておけば
データを消して、ログアウトさせる
といった副作用を起こすことができます。
class HomeScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
// 不正が検知されたらログイン画面に戻す
ref.listen(
deviceSecurityStatusProvider,
(previous, next) async {
if (next.value case DeviceSecurityStatusThreat()) {
// データ削除のモック処理
await mockDeleteAllData();
// ログイン画面に遷移
if (!context.mounted) return;
context.go(LoginScreen.path);
}
},
);
// ...
}
}
終わりに
本記事では freerasp を使って、ルート化/脱獄などの脅威を検知し、Riverpod で状態管理する方法を紹介しました。
ポイントをまとめると:
- freerasp は18種類の脅威を検知でき、初回起動時と定期的なバックグラウンドチェックを提供
- sealed class で検査状態(checking / safe / threat)を型安全に表現
- Repository層 で検知ロジックを隠蔽し、Stream で UI に通知する設計
- 脅威の 危険度を3段階に分類 することで、ブロック・監視・無視を柔軟に制御
Firebase App Check とは異なるアプローチで、クライアント側で直接脅威を検知・ハンドリングできる点が freerasp の強みです。セキュリティ要件に応じて、併用や使い分けを検討してみてください。
この記事が誰かのお役に立てれば幸いです。
テスト
今回は詳しく触れておりませんが、テストコードも書いてみました。
気になる方は以下のGitHubでご覧いただければと思います。
Discussion