이 글은 누구를 위한 것인가
- 금융, 의료, 커머스 등 민감한 데이터를 다루는 모바일 앱을 만드는 팀
- 보안 감사나 컴플라이언스 요구사항을 충족해야 하는 개발자
- "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 저장이 먼저다. 이 두 가지로 가장 흔한 공격 대부분을 방어할 수 있다. 인증서 피닝과 루팅 감지는 그 다음 레이어다.