Flutter 앱 보안 강화(1): freeRASP 적용기와 보완 작업

Heina·2025년 9월 23일
0

intro...

열심히는 아니고 적당히 모바일 웹앱 개발중이었다
그런데 우리가 진행하는 앱이 공공기관과 협의중이였고, 웹은 CSAP인증이 완료되었고, GS인증도 완료 되었다 (축하축하)
그런데 갑자기 모바일 앱에도 보안이 적용이 되었냐고 묻는게 아니겠는가? 근데 우리는 모바일웹앱이기 때문에 상관없지 않을까 라고 방심하고 있었다....

그러나 모바일 앱 개발시 에 필연적으로 따라오는 보안문제들을 간과하고 있었다
예를들어 금융, 기업용 앱 에서는 루팅·탈옥 탐지, 앱 위변조 방지, 스크린샷/화면 녹화 차단 같은 기능이 필수적인데 우리는 전혀 생각지도 못했다 ^^ 되는줄?

따라서 이번 글에서 실제로 적용해본freeRASP 패키지 소개와 적용 과정, 그리고 freeRASP만으로는 해결할 수 없었던 부분을 네이티브로 직접 보완한 경험을 정리하려고 한다.

freeRASP 패키지란?

freeRASP는 Flutter 앱 보안을 위해 제공되는 라이브러리이다.
앱 실행 중 다양한 위협 요소를 탐지하고, 개발자가 지정한 방식으로 대응할 수 있다고 한다.

https://pub.dev/packages/freerasp

주요 기능

  • 루팅/탈옥 탐지 (Android/iOS)
  • 앱 무결성 검증 (변조 여부 확인)
  • 디버깅 탐지
  • 앱 클로닝 감지
  • 에뮬레이터 실행 탐지
  • 화면 캡쳐/녹화 탐지 이벤트 제공 (단, 방지 기능은 직접 구현 필요)
    - Android/iOS 공통 API 이벤트 제공 → Flutter + WebView 연계 가능

그리고 서치중 좋은 글을 발견했는데, 이를 확인하고 freeRASP의 기능을 비교했다
Flutter(플러터) 보안 – 보안 리스크를 방지하기 위한 10가지 팁

📌  freeRASP 기능 제공 여부 및 대체 방안

이정도는 참고만 하시길바라며 freeRASP는 어떻게 사용되는가?

freeRASP 적용하기

패키지 설치

 $ flutter pub add freerasp
dependencies:
  freerasp: ^7.2.1

초기화

main.dart에서 앱이 시작될 때 초기화한다.

다만 우리는 빌드환경을 flavor로 분리하였기 때문에,
로컬이나 개발환경으로 빌드시: 앱 종료 or 차단을 무시하고 테스트
운영환경으로 빌드시: 앱 종료

iOS 심사시 안내 없이 앱 종료시킬 경우 승인이 안된다는 블로그를 참고하여 다이얼로그를 띄운 후 액션을 분기처리 하였다.

Future<void> configApp() async {
  WidgetsBinding widgetsBinding = WidgetsFlutterBinding.ensureInitialized();

  // 스플래쉬 화면
  FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);

  // 빌드 환경 분리
  // appFlavor : local | dev | prod
  await dotenv.load(fileName: '.env');
  FlavorConfig(appFlavor);

  Get.put(WebviewMainController());

  // SecurityService 초기화
  // 다이얼로그가 뜨면 _securityDialogCompleter가 완료될 때까지 진행 멈춤
  final Completer<void> securityDialogCompleter = Completer();
  SecurityService.dialogCompleteCallback = () {
    if (!securityDialogCompleter.isCompleted) {
      securityDialogCompleter.complete();
    }
  };
  
  // SecurityService 별도로 분리하여 관리
  await SecurityService.initializeTalsec(
      enableDialog: true,
      isProd: appFlavor == FlavorEnv.prod.name
  );

  // 탐지 다이얼로그가 안 뜨더라도 진행되도록 보장
  if (!securityDialogCompleter.isCompleted) {
    securityDialogCompleter.complete();
  }

  // 탐지 다이얼로그가 끝날 때까지 대기
  await securityDialogCompleter.future;
  
  ...이후 생략
  

여기서 핵심은 SecurityService.initializeTalsec() 내부에서 freeRASP 초기화를 하고, 위협 탐지 시 빌드 타입에 따라 무시 or 앱 종료로 분기한다는 점

ThreatCallback 활용: freeRASP의 핵심

freeRASP는 위협이 탐지되면 ThreatCallback을 통해 이벤트를 전달하며, 여기서 앱의 흐름을 제어할 수 있다.

아래는 ThreatCallback의 각 기능을 표로 정리했다.

해당 표를 참고하여 원하는 기능을 추가하면 될 것 같다

import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get/get.dart';
import 'package:freerasp/freerasp.dart';

class SecurityService {
  static void Function()? dialogCompleteCallback;

  SecurityService._();
  static bool _dialogShown = false;

  static String _hexToBase64(String hex) {
    final bytes = Uint8List.fromList(
      [for (int i = 0; i < hex.length; i += 2) int.parse(hex.substring(i, i + 2), radix: 16)],
    );
    return base64.encode(bytes);
  }

  static Future<void> initializeTalsec({required bool enableDialog, required bool isProd}) async {
    final packageName = dotenv.get('ANDROID_PACKAGE_NAME');
    final sha256Hex = dotenv.get('ANDROID_SHA256');
    final iOSTeamId = dotenv.get('IOS_TEAM_ID');
    final watcherMail = dotenv.get('WATCHER_MAIL');

    final androidConfig = AndroidConfig(
      packageName: packageName,
      signingCertHashes: [_hexToBase64(sha256Hex)],
      supportedStores: [packageName],
      // malwareConfig: MalwareConfig(
      //   blacklistedPackageNames: ['com.aheaditec.freeraspExample'],
      //   suspiciousPermissions: [
      //     ['android.permission.CAMERA'],
      //     ['android.permission.READ_SMS', 'android.permission.READ_CONTACTS'],
      //   ],
      // ),
    );

    final iosConfig = IOSConfig(
      bundleIds: [packageName],
      teamId: iOSTeamId,
    );

    final config= TalsecConfig(
      androidConfig: androidConfig,
      iosConfig: iosConfig,
      watcherMail: watcherMail,
      isProd: true,
    );

    await Talsec.instance.start(config);

    final threatCallback = ThreatCallback(
      onHooks: () => _onEvent('후킹 탐지', '앱 무결성이 손상되었습니다.', enableDialog, isProd),
      onDebug: () => _onEvent('디버깅 탐지', '디버깅 도구가 감지되었습니다.', enableDialog, isProd),
      onSimulator: () => _onEvent('에뮬레이터 탐지', '에뮬레이터 환경이 감지되었습니다.', enableDialog, isProd),
      onAppIntegrity: () => _onEvent('앱 변조 탐지', '앱이 변조되었습니다.', enableDialog, isProd),
      onDeviceBinding: () => _onEvent('디바이스 바인딩 위반', '디바이스 바인딩 위반이 감지되었습니다.', enableDialog, isProd),
      onUnofficialStore: () => _onEvent('신뢰되지 않은 설치처', '신뢰되지 않은 설치 소스가 감지되었습니다.', enableDialog, isProd),
      onScreenshot: () => _onScreenshotDetected(enableDialog, isProd),
      onScreenRecording: () => _onScreenRecordingDetected(enableDialog, isProd),
    );

    Talsec.instance.attachListener(threatCallback);
  }

  static Future<void> _onEvent(String title, String message, bool enableDialog, bool isProd) async {
    if (kDebugMode) debugPrint('[Talsec] $title : $message');

    if (!enableDialog) return;
    // Prod 모드에서는 무조건 우회 버튼 없음, Dev/Local 모드에서는 항상 우회 버튼 노출 (릴리즈 빌드 포함)
    final allowBypass = !isProd;


    WidgetsBinding.instance.addPostFrameCallback((_)  {
      if (_dialogShown) {
        return;
      }
      _dialogShown = true;

      Get.dialog(
        PopScope(
          canPop: false,
          child: AlertDialog(
            title: Text(title),
            content: Text(message),
            actions: [
              TextButton(
                onPressed: () {
                  Platform.isAndroid ? SystemNavigator.pop() : exit(0);
                  dialogCompleteCallback?.call(); // 외부 Completer 완료
                },
                child: const Text('앱 종료'),
              ),
              if (allowBypass)
                TextButton(
                  onPressed: () {
                    if (Get.isDialogOpen ?? false) Get.back();
                    dialogCompleteCallback?.call(); // 외부 Completer 완료
                  },
                  child: const Text('무시(테스트용)'),
                ),
            ],
          ),
        ),
        barrierDismissible: false,
      );
    });
  }

  static void _onScreenshotDetected(bool enableDialog, bool hideBypassButton) async{
    _onEvent('스크린샷 탐지', '화면이 캡처되었습니다.', enableDialog, hideBypassButton);
    if (Platform.isIOS) {

    }
    // await FlutterWindowManager.addFlags(FlutterWindowManager.FLAG_SECURE);
  }

  static void _onScreenRecordingDetected(bool enableDialog, bool hideBypassButton) {
    _onEvent('화면 녹화 탐지', '화면 녹화가 감지되었습니다.', enableDialog, hideBypassButton);
  }
}

iOS, Android Config에는 각각의 내용들이 필요하다
[iOS] 번들Ids, teamID 는 iOS 개발자 사이트에서 확인이 가능하며,
[Android] packageName, signingCertHashes 또한 구글 플레이콘솔에서 확인이 가능하다.

freeRASP의 문제점 및 freeRASP 대신 네이티브로 구현한 부분

코드를 보면 스크린샷 탐지 및 화면 녹화 탐지에 어떤 코드도 구현하지 않았다..
이것은 해당 패키지는 감지만 할 뿐 방지를 하지 않기 때문이었다
나는 이것을 고민하던 중 네이티브에 직접 구현하기로 했다.

iOS

  • UIScreen.main.isCaptured 감지
  • 캡쳐·녹화 시 화면 블러 처리 또는 특정 뷰 차단

Android

  • WindowManager.LayoutParams.FLAG_SECURE 플래그 설정으로 화면 캡쳐·녹화 방지

이렇게 네이티브 기능을 직접 적용하면서, freeRASP로 해결할 수 없는 부분을 보완했다.

왜 스크린샷 방지 코드를 여기 안 넣었나?

이번 글의 주제는 freeRASP 적용기에 집중하기 위해
iOS/Android 네이티브 단의 스크린샷 방지 코드 자체는 생략했다.

이유:

  • freeRASP와 관련된 핵심 흐름(ThreatCallback + 보안 초기화)을 명확히 보여주고 싶었기 때문
  • 캡쳐/녹화 방지 코드는 플랫폼마다 방식이 다르고, freeRASP와 직접적으로 연결되지 않음
  • 따라서 글에서는 “왜 freeRASP 대신 네이티브로 처리했는가”까지만 정리

(추후 블로그 시리즈로 별도 포스팅: “iOS/Android 네이티브에서 화면 캡쳐/녹화 방지 구현하기”로 다루는 것이 더 적절하다고 판단,, 내용이 핵 길어질것 같기 때문)

마무리

이번 글에서는 실제 모바일 앱에서 적용한 freeRASP 패키지를 중심으로 보안 적용 과정을 정리했다.

  • freeRASP는 Flutter 앱 보안을 위한 탐지 라이브러리로, 루팅·탈옥, 앱 변조, 디버깅, 클로닝, 에뮬레이터, 화면 캡처/녹화 이벤트를 감지할 수 있다.

  • ThreatCallback을 활용하면, 탐지 이벤트가 발생했을 때 앱의 흐름을 개발자가 제어할 수 있습니다.

    • 운영 환경에서는 다이얼로그를 띄운 뒤 앱을 종료하도록 처리할 수 있다.
    • 개발이나 테스트 환경에서는 탐지를 무시하고 앱을 계속 실행하도록 설정할 수 있고,
  • 다만 화면 캡처·녹화 방지는 freeRASP에서 직접 제공하지 않기 때문에, iOS와 Android에서는 네이티브로 직접 구현해야 한다.

특히, SecurityService를 별도로 분리한 이유는, 앱 빌드 환경(flavor)에 따라 로컬/개발/운영 환경을 구분하고, 탐지 이벤트를 한 곳에서 관리하기 편리하게 하기 위해서 였음

다음 글에서는 이번에 생략한 iOS/Android 네이티브 화면 캡처·녹화 방지 적용 방법과 실제 앱 적용 사례를 자세히 다룰 예정이다.....

힘내보자...

0개의 댓글