모바일 앱 보안 — 인증서 피닝, 루트 감지, 데이터 암호화 실전

모바일 보안

모바일 보안인증서 피닝루트 감지데이터 암호화iOSAndroid

이 글은 누구를 위한 것인가

  • 금융, 의료, 커머스 등 민감한 데이터를 다루는 모바일 앱을 만드는 팀
  • 보안 감사나 컴플라이언스 요구사항을 충족해야 하는 개발자
  • "MITM 공격을 어떻게 막나요?"라는 질문에 답해야 하는 엔지니어

모바일 앱의 주요 보안 위협

위협설명
중간자 공격 (MITM)네트워크 트래픽 감청/변조
리버스 엔지니어링앱 바이너리 분석으로 비즈니스 로직 추출
루팅/탈옥 환경시스템 보호 우회, 런타임 조작
민감 데이터 노출로컬 저장 데이터 탈취
클립보드 데이터 탈취민감 정보 복사 후 다른 앱에서 접근

1. 인증서 피닝 (Certificate Pinning)

SSL/TLS는 인증서가 신뢰할 수 있는 CA(인증 기관)에서 발급된 것인지만 검증한다. 공격자가 루트 CA를 추가하면 자신의 인증서로 MITM 공격이 가능하다.

인증서 피닝은 앱이 신뢰하는 특정 인증서나 공개키를 하드코딩해 다른 인증서를 거부한다.

Flutter에서 구현

import 'dart:io';
import 'package:flutter/services.dart';

Future<HttpClient> createSecureClient() async {
  // 번들된 인증서 로드
  final certBytes = await rootBundle.load('assets/certs/server.pem');
  final cert = X509Certificate.fromPem(
    String.fromCharCodes(certBytes.buffer.asUint8List())
  );

  final context = SecurityContext(withTrustedRoots: false);
  context.setTrustedCertificatesBytes(certBytes.buffer.asUint8List());

  final client = HttpClient(context: context)
    ..badCertificateCallback = (X509Certificate cert, String host, int port) {
      // 피닝된 인증서와 일치하지 않으면 거부
      return false;
    };

  return client;
}

dio 패키지와 함께 사용

import 'package:dio/dio.dart';
import 'package:dio/io.dart';

Dio createSecureDio() {
  final dio = Dio();

  (dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
    final client = HttpClient()
      ..badCertificateCallback = (cert, host, port) {
        // 공개키 해시 비교 (더 강력한 방법)
        final expectedPublicKeyHash = 'sha256/AAAAAAAAAAAAAAAAAAA=';
        final actualHash = base64Encode(
          sha256.convert(cert.der).bytes
        );
        return actualHash == expectedPublicKeyHash;
      };
    return client;
  };

  return dio;
}

주의: 인증서 교체 계획

인증서가 만료되거나 교체될 때 피닝된 앱은 강제 업데이트 전까지 통신 불가 상태가 된다. 반드시:

  • 백업 인증서 해시를 함께 피닝 (2개 이상)
  • 인증서 만료 6개월 전 앱 업데이트 배포
  • 긴급 교체를 위한 원격 설정(Remote Config) 탈출구 마련

2. 루팅/탈옥 감지

루팅(Android)·탈옥(iOS) 환경에서는 시스템 파일 접근, 런타임 조작, 앱 바이너리 변조가 가능하다.

Android 루팅 감지

object RootDetector {
    fun isRooted(): Boolean {
        return checkSuBinary()
                || checkRootApps()
                || checkBuildTags()
                || checkTestKeys()
    }

    private fun checkSuBinary(): Boolean {
        val paths = arrayOf(
            "/system/bin/su", "/system/xbin/su",
            "/sbin/su", "/data/local/su",
            "/data/local/bin/su"
        )
        return paths.any { File(it).exists() }
    }

    private fun checkRootApps(): Boolean {
        val rootApps = arrayOf(
            "com.topjohnwu.magisk",    // Magisk
            "eu.chainfire.supersu",    // SuperSU
            "com.noshufou.android.su"  // SuperUser
        )
        return rootApps.any { packageName ->
            try {
                packageManager.getPackageInfo(packageName, 0)
                true
            } catch (e: PackageManager.NameNotFoundException) {
                false
            }
        }
    }

    private fun checkBuildTags(): Boolean {
        val buildTags = Build.TAGS
        return buildTags != null && buildTags.contains("test-keys")
    }

    private fun checkTestKeys(): Boolean {
        val buildType = Build.TYPE
        return buildType == "user" && "test-keys" in (Build.TAGS ?: "")
    }
}

iOS 탈옥 감지

class JailbreakDetector {
    static func isJailbroken() -> Bool {
        return checkJailbreakFiles()
            || checkWritePermission()
            || checkDynamicLibraries()
    }

    private static func checkJailbreakFiles() -> Bool {
        let paths = [
            "/Applications/Cydia.app",
            "/Library/MobileSubstrate/MobileSubstrate.dylib",
            "/bin/bash",
            "/usr/sbin/sshd",
            "/etc/apt",
        ]
        return paths.contains { FileManager.default.fileExists(atPath: $0) }
    }

    private static func checkWritePermission() -> Bool {
        let testPath = "/private/JBTest_\(UUID().uuidString)"
        do {
            try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
            try FileManager.default.removeItem(atPath: testPath)
            return true  // 샌드박스 외부 쓰기 성공 = 탈옥
        } catch {
            return false
        }
    }
}

감지 후 대응 정책

루팅/탈옥 감지 후 무조건 앱 종료하는 것은 오탐으로 인한 정상 사용자 차단 위험이 있다. 비즈니스 컨텍스트에 맞게 정책을 결정한다.

앱 유형권장 대응
금융/결제앱 실행 차단, 안내 메시지
일반 커머스경고 표시 후 계속 허용
게임민감 기능(결제) 제한

3. 민감 데이터 안전한 저장

절대 하면 안 되는 것

  • SharedPreferences/UserDefaults에 토큰, 비밀번호 저장
  • 로컬 파일에 평문으로 민감 데이터 저장
  • 앱 번들에 API 키 하드코딩

안전한 저장: Keystore / Keychain

// flutter_secure_storage 패키지 사용
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

const storage = FlutterSecureStorage(
  aOptions: AndroidOptions(
    encryptedSharedPreferences: true,  // Android Keystore 사용
  ),
  iOptions: IOSOptions(
    accessibility: KeychainAccessibility.first_unlock,  // 기기 잠금 해제 후 접근
  ),
);

// 저장
await storage.write(key: 'auth_token', value: token);

// 읽기
final token = await storage.read(key: 'auth_token');

// 삭제
await storage.delete(key: 'auth_token');

Android Keystore는 키를 하드웨어 보안 모듈(TEE)에 저장해 루팅 환경에서도 키 추출을 어렵게 한다.

DB 암호화: SQLCipher

// drift + sqlcipher_flutter_libs
import 'package:drift/drift.dart';
import 'package:sqlcipher_flutter_libs/sqlcipher_flutter_libs.dart';

QueryExecutor openDatabase() {
  return LazyDatabase(() async {
    final key = await getEncryptionKey();  // Keystore에서 키 로드
    return NativeDatabase.createInBackground(
      File(dbPath),
      setup: (db) {
        db.execute("PRAGMA key = '$key'");  // DB 암호화 키 설정
        db.execute("PRAGMA cipher_compatibility = 4");
      },
    );
  });
}

4. 네트워크 보안 설정

Android Network Security Config

<!-- res/xml/network_security_config.xml -->
<network-security-config>
  <base-config cleartextTrafficPermitted="false">
    <!-- HTTP 통신 전면 차단 -->
    <trust-anchors>
      <certificates src="system" />
    </trust-anchors>
  </base-config>

  <domain-config>
    <domain includeSubdomains="true">api.yourapp.com</domain>
    <pin-set expiration="2027-04-06">
      <pin digest="SHA-256">base64+primary=</pin>
      <pin digest="SHA-256">base64+backup=</pin>  <!-- 백업 핀 필수 -->
    </pin-set>
  </domain-config>
</network-security-config>

iOS ATS (App Transport Security)

<!-- Info.plist -->
<key>NSAppTransportSecurity</key>
<dict>
  <key>NSAllowsArbitraryLoads</key>
  <false/>  <!-- HTTP 전면 차단 -->
  <key>NSExceptionDomains</key>
  <dict>
    <!-- 특정 도메인 예외 필요 시만 추가 -->
  </dict>
</dict>

맺으며

모바일 앱 보안은 "적을 얼마나 늦출 수 있는가"의 게임이다. 인증서 피닝도, 루팅 감지도 완벽하지 않다 — 공격자에게 시간과 비용을 더 들게 만드는 것이 목표다.

우선순위를 정한다면: HTTPS 강제 + 민감 데이터 Keystore 저장이 먼저다. 이 두 가지로 가장 흔한 공격 대부분을 방어할 수 있다. 인증서 피닝과 루팅 감지는 그 다음 레이어다.