티스토리 뷰

모바일 앱 취약점 점검을 하다 보면 이런 구조를 자주 만납니다. iOS 네이티브(Swift)는 껍데기, 실제 기능은 전부 Flutter(Dart)

Flutter 쪽은 --obfuscate로 난독화가 되어 있는데, 네이티브 쪽은 아무 조치가 없습니다. 심볼을 떠보면 벤더와 플랫폼 심볼만 나오고요. 개발팀은 "네이티브에는 로직이 없어서 할 게 없다"고 답변합니다. 이걸 양호로 볼 수 있을까요? 아니면 "네이티브 난독화 미적용"으로 지적해야 할까요?

 

이 글에서는 Flutter 앱의 네이티브 영역에 실제로 무엇이 들어 있는지, 왜 그 부분은 난독화 대상이 아닌지, 그리고 그럼에도 반드시 확인해야 하는 한 가지를 정리했습니다.


🎯 상황 정리

점검 현장에서 마주치는 전형적인 구성입니다.

영역 구현 내용 난독화 상태
Flutter (Dart) 실 기능 · 비즈니스 로직 적용
iOS 네이티브 (Swift) Flutter 진입점, 플러그인 등록, 채널 중계 미적용
TalsecRuntime 등 RASP SDK 루팅·후킹·디버깅·무결성 탐지 벤더 바이너리, 적용 불가

 

심볼 분석 결과도 깨끗합니다. 자체 개발 코드에서 온 심볼은 보이지 않고 벤더·플랫폼 심볼만 남아 있습니다.

그런데 여기서 걸리는 게 있습니다. "심볼이 안 보인다"와 "보호할 게 없다"는 다른 말이라는 점입니다. 전자는 strip 설정의 결과일 뿐이고, 후자가 판정의 근거가 되어야 합니다. 이 차이를 구분하지 못하면 재심에서 반드시 막힙니다.


🧩 Flutter 앱은 세 덩어리로 나뉩니다

 

먼저 용어를 정리하고 가겠습니다. 네이티브 영역은 하나의 Swift 코드 덩어리입니다. 그 코드가 왼쪽으로는 iOS 운영체제와, 오른쪽으로는 Flutter와 이야기할 뿐입니다. 이 두 연결이 성격이 완전히 다릅니다.

 

연결 무엇으로 연결되나 심볼 사용
① iOS ↔ 네이티브 클래스명 · 메서드명 (심볼) 사용함
② 네이티브 ↔ Flutter 채널명 문자열 사용 안 함

 

①번은 이름을 바꾸면 앱이 죽습니다

iOS는 Swift로 짜도 상당 부분이 Objective-C 런타임의 동적 디스패치로 동작합니다. 실행 중에 "AppDelegate" 같은 문자열을 가지고 클래스와 메서드를 찾아서 호출합니다.

난독화는 이 이름을 다른 걸로 바꾸는 작업이니, 찾을 대상이 사라져 버립니다.

대상 누가 이름으로 찾는가
AppDelegate 클래스 Info.plist에 클래스명이 문자열로 기재됨. 실행 시 iOS가 그 문자열로 조회
@objc 붙은 메서드 objc_msgSend가 셀렉터 이름으로 디스패치
GeneratedPluginRegistrant 플러그인 등록 시 클래스명 기반 조회
Storyboard / XIB IBOutlet · IBAction 이름 문자열로 바인딩
KVC / KVO, Codable 프로퍼티 이름이 곧 JSON 키 / 관찰 대상 키

Flutter 앱의 네이티브 영역은 거의 전부 이 목록에 해당합니다. 진입점 코드가 곧 AppDelegate + 플러그인 등록 + @objc 노출부이기 때문입니다.

💡 흔히 "iOS는 난독화 도구가 없어서 못 한다"고 하는데, 정확하지 않습니다. iXGuard, Appdome 같은 상용 도구는 존재합니다. 다만 런타임이 이름으로 참조하는 심볼은 화이트리스트로 제외해야만 앱이 돕니다. 즉 도구가 없는 게 아니라, 바꿔도 되는 심볼은 이미 strip으로 사라졌고 남은 심볼은 바꾸면 안 되는 것입니다.


🔍 ②번은 심볼을 아예 쓰지 않습니다

여기가 이 글의 핵심입니다.

 

Flutter와 네이티브는 함수 심볼로 서로를 호출하지 않습니다. 일종의 프로세스 내 메시지 전달 구조이고, 양쪽이 맞추는 것은 채널명 문자열입니다.

// Flutter (Dart)
static const platform = MethodChannel('com.company.app/security');
await platform.invokeMethod('isJailbroken');
// 네이티브 (Swift)
let channel = FlutterMethodChannel(
    name: "com.company.app/security",
    binaryMessenger: controller.binaryMessenger)
channel.setMethodCallHandler { call, result in
    if call.method == "isJailbroken" { ... }
}

 

Flutter 공식 API 문서도 채널의 논리적 정체성은 그 이름으로 정해지며, 이름이 같은 채널끼리는 서로의 통신에 간섭한다고 명시하고 있습니다. 이름이 곧 주소인 구조입니다.

그래서 난독화를 해도 채널 정보는 남습니다

요소 strip / 난독화로 제거되나 이유
Swift 내부 함수명 제거됨 로컬 심볼, 런타임에 불필요
채널명 · 메서드명 문자열 제거 안 됨 __cstring 섹션의 문자열 리터럴
@objc 노출 클래스 · 셀렉터 제거 안 됨 지우면 앱이 죽음

 

Dart 난독화도 마찬가지입니다. Flutter의 코드 난독화는 릴리스 빌드에서만 동작하며, 리소스를 암호화하거나 리버스 엔지니어링을 막아주지 않습니다. 심볼을 더 알아보기 어려운 이름으로 바꿀 뿐입니다. 문자열 리터럴은 대상이 아닙니다. strings 한 번이면 채널 목록이 나옵니다. 난독화를 완벽히 적용해도 마찬가지입니다. 세상의 모든 Flutter 앱이 동일한 상태입니다.


⚠️ 그럼 채널명이 노출되는 건 문제 아닌가요?

여기서 자연스럽게 나오는 질문입니다. "구조를 알면 해커가 그 통로로 메시지를 넣을 수 있는 것 아니냐." 결론부터 말하면 MethodChannel은 외부에서 노크할 수 있는 창구가 아닙니다.

 

공격 방식 채널명을 알면 가능한가 전제 조건
다른 앱에서 채널로 메시지 전송 불가능 OS에 등록된 IPC 엔드포인트가 아님. 앱 내부 메신저
원격(네트워크)에서 호출 불가능 외부 노출 경로 자체가 없음
Frida 등으로 후킹해 호출 가능 이미 프로세스 내 코드 실행 권한 확보 상태

 

세 번째가 유일한 현실적 경로인데, 여기가 중요합니다. 프로세스 안에 들어온 공격자는 채널명을 몰라도 이미 뭐든 할 수 있습니다. 메모리를 읽고, 함수를 후킹하고, 반환값을 조작합니다. 채널명을 아는 건 그 사람의 작업 시간을 조금 줄여줄 뿐입니다.

건물 안내판에 "3층 회계팀"이라고 적혀 있는 것과 같습니다. 안내판을 지운다고 보안이 되는 게 아니라, 회계팀 문이 잠겨 있느냐가 보안입니다.

🚨 다만 문이 잠겨 있는지는 반드시 확인하세요

이게 이 항목에서 진짜 건질 게 있는 지점입니다. 노출 자체는 문제가 아닌데, 그 경계에 보안 판단이 놓여 있으면 얘기가 완전히 달라집니다.

핸들러 구현 패턴 위험도 설명
보안 판단 결과(true/false)를 Dart로 반환 높음 반환값 고정만으로 우회 완료. 코드 몇 줄
Keychain 값 · 토큰 · 암호화 키를 Dart로 전달 높음 채널 메시지 가로채기로 탈취
인자를 검증 없이 파일 경로 · URL · 쿼리로 사용 높음 후킹으로 임의 인자 주입 시 경로 조작 등 발생
OS API 결과 단순 전달 (기기 모델명, OS 버전 등) 낮음 비밀 없음
// ⚠️ 이런 패턴이 보이면 멈추세요
channel.setMethodCallHandler { call, result in
    if call.method == "isJailbroken" {
        result(JailbreakDetector.check())   // ← 반환값 하나만 바꾸면 끝
    }
}

핵심은 이겁니다. 이 경계는 난독화로 보호되는 지점이 아닙니다. 이름을 아무리 가려도 공격자는 이름을 읽는 게 아니라 런타임에 후킹해서 값을 바꿉니다. 그래서 조치 방향도 난독화가 아닙니다.

 

  • ① 보안 판단을 클라이언트 경계에 두지 않기 — 서버 측 검증으로 이관 (권장)
  • ② 후킹 자체를 차단 — RASP(Talsec 등)로 탐지 후 앱 종료 또는 서버 통보
  • ③ 네이티브 핸들러는 Dart 입력을 신뢰하지 않기 — 같은 앱 안이라도 후킹당하면 공격자 입력입니다. 서버 API 입력값 검증하듯 똑같이 봐야 합니다

 

OWASP MASTG도 같은 관점입니다. 보안 관련 로직을 구현한 네이티브 Mach-O 코드가 난독화되어 있지 않으면 비즈니스 로직, 기기 어테스테이션, 환경 검사, 무결성 검사 같은 구현 세부가 노출된다는 것이 문제 정의입니다. 뒤집으면, 보안 관련 로직이 그 영역에 없으면 이 테스트의 적용 대상이 아닙니다.


🚫 이런 근거는 쓰지 마세요

보고서에 자주 등장하지만 재심에서 깨지는 표현들입니다.

① "심볼이 보이지 않으므로 안전함"

심볼이 안 보이는 건 STRIP_INSTALLED_PRODUCT 설정의 결과입니다. strip과 난독화는 다른 통제이고, 심사자는 이 차이를 압니다. "안 보인다"가 아니라 "보호할 로직이 없다"로 써야 합니다.

② "앱을 장악당한 상태면 난독화는 어차피 무의미함"

방향은 맞는데 표현이 과합니다. 이 문장은 "그럼 난독화 통제 자체가 무의미하다는 거냐"는 반문을 부릅니다.

표현 권장도
앱을 장악했으므로 난독화는 무의미함 피하기
해당 문자열은 난독화 대상이 아니므로 적용 여부와 무관하게 노출되며, 악용에는 별도 런타임 공격 권한이 필요함 권장

③ "iOS는 난독화 도구가 없어서 미적용"

앞서 적었듯 상용 도구는 존재합니다. "도구가 없다"는 사실과 다르고, 심사자가 도구 이름을 대면 바로 막힙니다.

④ 채널명 노출 자체를 지적사항으로 쓰기

프레임워크 구조라서 조치 방법이 없습니다. "그럼 어떻게 하라는 거냐"에 답이 없는 지적은 다음 점검에서 그대로 재등장합니다. 지적하려면 채널명이 아니라 핸들러 구현을 지적해야 합니다.


📋 점검 방법

주장만으로는 근거가 약합니다. 실제로 확인해서 증적을 남겨야 합니다.

1. Flutter 난독화 실제 적용 여부

--obfuscate--split-debug-info함께 써야만 동작합니다. 하나만 넣으면 적용되지 않으니 빌드 스크립트를 직접 확인하세요.

flutter build ipa --release \
  --obfuscate \
  --split-debug-info=build/symbols

빌드 산출물에서 Dart 심볼이 실제로 가려졌는지 봅니다.

unzip -q app.ipa -d extracted
strings extracted/Payload/Runner.app/Frameworks/App.framework/App \
  | grep -iE "ViewModel|Repository|Service|Controller" | head -20

원본 클래스명이 그대로 나오면 난독화가 안 걸린 겁니다.

2. 네이티브 심볼 상태 확인

# 잔존 심볼 확인
nm -a extracted/Payload/Runner.app/Runner | head -50

# strip 여부 확인 (심볼 테이블 크기)
otool -l extracted/Payload/Runner.app/Runner | grep -A4 LC_SYMTAB

3. MethodChannel 목록 추출

이게 네이티브에 로직이 없다는 것을 직접 증명하는 자료가 됩니다.

strings extracted/Payload/Runner.app/Runner \
  | grep -E "^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_./-]+$" | sort -u

추출된 채널명을 소스와 대조해서 표로 만드세요.

채널명 메서드 처리 내용 보안 판단 개입
(예) com.app/device getModel OS API 결과 반환 없음
(예) com.app/security isJailbroken 판단 결과 반환 있음 → 확인 필요

4. Xcode 빌드 설정

설정 항목 권장값
STRIP_INSTALLED_PRODUCT Yes
DEPLOYMENT_POSTPROCESSING Yes
STRIP_STYLE All Symbols (또는 Non-Global)
DEBUG_INFORMATION_FORMAT (Release) DWARF with dSYM File
ENABLE_TESTABILITY (Release) No

체크리스트

확인 항목  
--obfuscate--split-debug-info가 함께 지정되어 있는가
App.framework에서 원본 Dart 클래스명이 검출되지 않는가
Release 빌드에 strip 설정이 적용되어 있는가
잔존 심볼이 벤더 · 플랫폼 심볼로 한정되는가
MethodChannel 목록과 각 핸들러 처리 내용을 확보했는가
핸들러가 보안 판단 결과를 Dart로 반환하지 않는가
핸들러가 키 · 토큰 · Keychain 값을 Dart로 전달하지 않는가
네이티브 소스에 하드코딩된 키 · URL · 인증서 핀이 없는가
URL Scheme, App Group, Pasteboard 등 외부 진입점을 확인했는가
RASP SDK가 Release 빌드에서 실제 동작하는가

💡 Dart 난독화는 문자열 리터럴을 난독화하지 않습니다. Flutter 쪽이 난독화되어 있어도 하드코딩된 API 키나 서버 URL은 그대로 노출됩니다. 난독화 항목과 별개로 반드시 같이 보세요.


🧭 판정 흐름

정리하면 판단 기준은 세 개뿐입니다.

질문 아니오
네이티브에 비즈니스 로직이 있는가 난독화 + 서버 검증 이관 다음 단계
채널 핸들러가 보안 판단 · 비밀값을 다루는가 서버 검증 이관 · RASP 보완 다음 단계
Flutter 영역 난독화가 적용되어 있는가 양호 Flutter 난독화 적용

💬 자주 나오는 질문

Q. Flutter 호출부가 난독화되어 있지 않아도 정말 괜찮나요?

네. 이유가 세 가지입니다.

  1. 호출 자체가 심볼을 쓰지 않으므로, 심볼을 가려도 얻는 게 없습니다
  2. 노출되는 코드가 오픈소스 Flutter의 정형 코드라 숨길 가치가 없습니다
  3. 진짜 자산인 비즈니스 로직은 Flutter 쪽에 있고, 거기는 난독화되어 있습니다

Q. 벤더 바이너리 SDK는 정말 손댈 방법이 없나요?

이용자 측에서는 없습니다. 선컴파일된 바이너리로 배포되므로 재컴파일이 불가능하고, 억지로 건드리면 코드 서명이 깨집니다.

대신 벤더 측 보호 적용 확인서나 보안 백서를 받아두세요. "우리가 못 한다"보다 "벤더가 자체 적용했다"가 훨씬 강한 근거입니다.

Q. RASP가 있으면 난독화를 안 해도 되나요?

성격이 다른 통제라 대체 관계는 아닙니다. 난독화는 정적 분석 난이도를 올리고, RASP는 동적 공격을 탐지·차단합니다.

다만 이 사례처럼 난독화 대상 자산이 그 영역에 없는 경우에는, RASP가 보완 통제로서 충분히 인정됩니다. 없는 것을 가릴 수는 없으니까요.

Q. 심볼이 깨끗하면 그걸로 증적이 되지 않나요?

부족합니다. 심볼이 안 보이는 건 strip의 결과이지 "보호 대상이 없다"의 증거가 아닙니다. MethodChannel 목록과 각 핸들러 처리 내용 표가 훨씬 직접적인 증거입니다. 채널이 몇 개이고 각각 뭘 주고받는지만 있으면 재심에서 추가 질문이 나오지 않습니다.

Q. 개발자 답변만으로 양호 처리해도 되나요?

권장하지 않습니다. 답변은 받되 점검원이 직접 확인한 근거를 한 줄이라도 남기세요. 위 2·3번 명령 결과 정도면 충분합니다. "개발자 답변 확인"만 있으면 재심에서 그대로 되돌아옵니다.


📮 점검 결과 문안 예시

그대로 쓰셔도 되는 형태로 정리했습니다.

Flutter(Dart) 영역 난독화 적용 완료(개발자 답변 및 점검원 확인).
iOS 네이티브 영역은 Flutter 진입점, 플러그인 등록, 채널 중계 코드로
한정되어 별도 비즈니스 로직 없음. 잔존 심볼은 프레임워크 동작에 필요한
필수 심볼로 변경 시 앱 구동 불가. 내부 보안 기능은 TalsecRuntime
(벤더 제공 바이너리 SDK)에서 처리되어 당사 난독화 적용 불가.
바이너리 심볼 분석 결과 벤더 및 플랫폼 심볼만 확인되어 양호 처리함.

각 문장이 어떤 반문을 막는지 대응시켜 보면 이렇습니다.

문장 방어하는 반문
Flutter 난독화 적용 실제 자산은 보호되나?
네이티브에 별도 로직 없음 Swift 코드는 왜 안 했나?
잔존 심볼은 필수 심볼 남은 건 왜 안 가렸나?
벤더 바이너리 SDK 보안 로직은 왜 안 했나?
심볼 분석 결과 확인은 했나?

 

핸들러 점검까지 마쳤다면 한 문장을 덧붙이면 더 단단해집니다.

MethodChannel 전수 확인 결과 네이티브→Flutter 반환값에 보안 판단 결과 및
인증정보가 포함되지 않음을 확인함.

🎉 정리

  1. 네이티브 영역은 하나의 Swift 코드 덩어리다. OS와의 연결(심볼 기반)과 Flutter와의 연결(문자열 기반)은 성격이 전혀 다르다.
  2. MethodChannel은 심볼을 쓰지 않는다. 양쪽이 맞추는 건 채널명 문자열이고, 그건 난독화 대상이 아니다.
  3. 채널명은 어차피 노출된다. 프레임워크 구조라 조치 방법이 없으니 지적사항으로 쓰기 어렵다.
  4. 채널명을 알아도 외부에서 호출할 수 없다. 악용하려면 이미 후킹 권한이 필요하다.
  5. 런타임 필수 심볼은 바꾸면 앱이 죽는다. "도구가 없어서"가 아니라 "바꾸면 안 되는 것"이 정확한 표현이다.
  6. 판정 근거는 '안 보인다'가 아니라 '보호할 게 없다'다. strip 결과를 근거로 쓰면 재심에서 막힌다.
  7. 진짜 확인할 건 채널 핸들러다. 보안 판단 결과나 비밀값을 주고받는다면 난독화가 아니라 서버 검증 이관이 답이다.

난독화 미적용처럼 보이지만, 대부분은 가릴 게 없다는 것을 증명하는 항목입니다. 대신 그 과정에서 "네이티브가 탈옥 여부를 true/false로 돌려주고 있더라" 같은 진짜 구멍이 하나씩 나옵니다. 형식적으로 넘기지 말고 채널 핸들러만큼은 한 번 열어보시길 권합니다.


🔗 참고 자료