All posts

Flutter 난독화로 API 키를 숨길 수 있을까? 앱과 서버의 보안 경계

Flutter 난독화와 CI 환경변수의 한계를 짚고, 공개 키와 비밀키를 구분하는 구조를 다이어그램으로 설명한다.

앱의 API 키와 별도의 서버 금고를 보여주는 보안 일러스트

Flutter 앱을 릴리스할 때 코드를 난독화해도 앱에 넣은 API 비밀키가 안전해지는 것은 아니다. 앱은 사용자 기기에 배포되고, 공격자는 설치 파일과 실행 환경을 분석할 수 있다. 코드 저장소에서 키를 숨기는 일과 배포된 앱에서 키를 비밀로 유지하는 일은 서로 다른 문제다.

Flutter 공식 문서는 난독화가 함수와 클래스 등의 심볼 이름을 바꾸는 기능이며 리소스를 암호화하거나 역공학을 막지 않는다고 설명한다. 앱에 비밀을 저장하는 것은 좋지 않은 보안 관행이라고도 명시한다.

난독화를 적용했는데 키가 보이는 이유

다음 명령은 릴리스 빌드에 Dart 심볼 난독화를 적용하고, 오류 분석에 필요한 심볼 정보를 별도 경로에 저장한다.

flutter build apk --release --obfuscate --split-debug-info=./debug-info

이 옵션은 앱에 포함된 문자열을 비밀로 보관하는 기능이 아니다. 예를 들어 API 키를 문자열로 포함해 빌드하면 APK의 네이티브 라이브러리나 다른 패키지 내용에서 드러날 수 있다. 실제 표현과 추출 방법은 빌드와 사용 방식에 따라 다르다. 문자열 검색에서 찾지 못해도 비밀이 안전하다고 판단해서는 안 된다.

이번 글은 제공된 Flutter 개발 경험을 바탕으로 작성했다. 원래 Reddit 게시물과 개별 키 노출 및 조치 내역은 독립적으로 확인하지 못해, 아래 예시는 특정 앱에서 확인된 사실이 아닌 일반적인 보안 사례로 설명한다.

CI 환경변수와 배포 파일은 다른 경계다

GitHub Secrets나 CI 환경변수에 비밀을 넣으면 저장소에 값을 직접 커밋하지 않도록 도울 수 있다. 하지만 그 값을 --dart-define으로 앱 코드에 주입해 결과물에 포함한다면, 배포 후에는 앱 사용자에게도 제공되는 셈이다.

Flutter 빌드에 주입한 비밀이 배포 앱에 포함되는 경로와 백엔드에서 비밀을 관리하는 권장 구조

그림의 위쪽은 CI의 비밀이 빌드 결과물로 이동하는 경로다. 아래쪽은 앱이 사용자 인증 후 백엔드에 요청하고, 백엔드가 비밀을 관리해 외부 API를 호출하는 구조다. 백엔드는 단순한 중계 서버로 끝나지 않는다. 사용자 인증, 권한 검사, 요청 제한, 사용량 모니터링이 함께 필요하다.

모든 키가 같은 비밀은 아니다

앱에서 문자열이 발견됐다는 사실만으로 모두 같은 수준의 사고라고 볼 수는 없다. 해당 키가 무엇을 할 수 있는지, 어떤 데이터와 과금에 접근할 수 있는지 살펴야 한다.

  • Google Maps API 키: 모바일 앱에서 사용할 수 있지만 Android 패키지명과 서명 인증서, 허용 API, 쿼터 등을 적절히 제한한다. 이런 제한은 위험을 줄이는 통제이며 단독으로 완벽한 인증을 보장하지 않는다.
  • Firebase 설정: Firebase 서비스용 키는 프로젝트 식별에 쓰이며 일반적으로 앱에 포함할 수 있다. 데이터 보호는 Security Rules와 App Check 및 API 제한 등으로 처리해야 한다.
  • RevenueCat 공개 SDK 키: 공개 사용을 전제로 한 키를 사용한다. 비밀 API 키는 앱에 넣지 않는다.
  • Sentry DSN: 클라이언트 설정에 포함될 수 있지만 원치 않는 이벤트나 사용량 증가를 감시하고 제한해야 한다.
  • Algolia 검색 전용 키: 필요한 인덱스와 권한으로 범위를 제한해 사용할 수 있다. 관리자 키는 서버에 보관한다. Algolia 문서도 검색 전용 키와 관리자 키를 구분한다.
  • OpenAI·Anthropic 등 유료 API의 비밀키: 앱에 내장하지 않고 서버에서 보호한다. 사용자별 권한, 예산과 호출량 제한도 적용한다.
  • 블록체인 RPC 키: 공급자가 공개 클라이언트 사용을 허용하는지 확인하고 허용 범위와 쿼터를 제한한다. 고권한 또는 민감한 과금 키는 서버에서 관리한다.

검색 전용 키라도 모든 사용자가 모든 검색 결과에 접근해도 되는 것은 아니다. 사용자별 데이터가 있다면 서버에서 권한에 맞게 제한된 키를 발급하거나 서버 측에서 접근을 통제해야 한다.

이미 관리자 키를 배포했다면

앱에 Algolia 관리자 키처럼 쓰기·삭제 권한이 있는 키가 포함된 사실을 발견했다면 유출된 것으로 간주하고 신속히 대응한다.

  1. 서비스 제공자의 절차에 따라 키를 폐기하거나 교체한다. 서비스 중단을 줄이기 위해 새 키 적용과 기존 키 폐기 순서를 계획하되, 노출된 권한의 위험을 우선 고려한다.
  2. 관리 작업과 인덱싱을 서버 환경으로 옮기고 앱에는 필요한 최소 권한만 남긴다.
  3. 기존 키의 사용 기록, 과금, 데이터 변경 여부를 조사한다.
  4. 수정된 앱을 배포하고, 이전 앱 버전도 이미 공개된 키를 갖고 있다는 사실을 고려한다.

secure storage가 해결하는 문제

flutter_secure_storage 같은 저장소는 사용자별 토큰을 기기에서 보관할 때 운영체제의 보안 저장 기능을 활용하는 데 유용하다. 하지만 모든 설치본에 공통 비밀을 넣은 뒤 이를 암호화해 저장하는 방식은 그 비밀을 앱 사용자로부터 확실히 숨기는 수단이 아니다. 앱이 비밀을 꺼내 사용할 수 있어야 하므로 공격자도 앱과 실행 환경을 분석할 수 있다.

지갑 개인키는 서비스 공통 API 비밀과 구분해야 한다. 사용자별 개인키는 적절한 지갑 설계에 따라 기기 보안 영역에서 생성·보관할 수 있지만, 동일한 개인키를 앱 코드에 내장해 모두에게 배포해서는 안 된다. 기기의 보안 기능과 키 반출 가능 여부는 플랫폼과 구현에 따라 다르다.

배포 전 점검

릴리스 파이프라인에서는 저장소 스캔에 더해 실제 배포되는 APK와 AAB, 생성된 설정 파일과 리소스를 검사한다. 비밀 스캔은 누락을 발견하는 보조 수단이며 암호화·인코딩되거나 탐지 규칙에 없는 모든 비밀을 찾아낸다는 보장은 없다.

또한 공개 가능한 키 목록과 서버 전용 비밀 목록을 구분하고, 앱에 포함되는 값의 권한·사용량 제한을 정기적으로 확인한다. 의심스러운 호출을 탐지할 수 있도록 사용량과 과금 알림도 설정한다.

핵심은 간단하다. 앱에 배포된 공통 비밀은 추출될 수 있다고 가정하고, 실제 보호는 서버의 권한 검증과 제한된 키 설계로 구현한다.

참고 자료

Discussion

No comments yet