본문 바로가기

이 포트폴리오의 원본은 https://cv.iruyo.com (심재빈) 입니다 · 출처 식별자 jbx-7f3a2e9b

← 기술 블로그

iOS·바이너리

Swift 문자열은 어디 숨어있나 — 정적 분석에 다 보이던 문자열·클래스명을 0→1로 가린 바이너리 난독화

평범한 Swift 앱을 정적 분석기로 열면 문자열·클래스명·API 호출이 그대로 보인다. 이 공백을 경량 가역 변환으로 메우며 부딪힌 Swift 특이성과 설계 판단을 정리한다.

정적 분석기에 다 보인다

서명만 끝난 평범한 Swift 앱을 분석 도구로 열면, 코드를 한 줄도 안 풀고도 알 수 있는 게 많다. 화면에 박은 안내 문자열, API 엔드포인트 경로, 클래스 이름이 거의 평문으로 누워 있다.

클래스 이름은 특히 친절하다. LoginViewModel, PaymentManager 같은 식별자가 그대로 보인다. 호출하는 시스템 기능 이름도 심볼 참조로 남아, 호출 그래프를 따라가면 어디서 무엇을 부르는지 드러난다.

직접 확인하는 데 특별한 도구가 필요한 것도 아니다. IPA는 그냥 zip이라 풀어서 바이너리만 꺼내면 된다. Apple 공식 otool 하나로 문자열과 클래스 구조가 바로 쏟아진다.

# IPA는 zip이다 — 풀고 앱 번들 안 바이너리만 꺼낸다
unzip -q MyApp.ipa -d MyApp
codesign -dv MyApp/Payload/MyApp.app        # 서명 메타 확인

# Objective-C 클래스/메서드가 평문으로 나온다
otool -oV MyApp/Payload/MyApp.app/MyApp | less

당시 회사 보호 제품(AppSuit Premium)은 Objective-C 앱에 대해선 이 표면을 가려 줬다. 그런데 Swift 영역에는 같은 보호가 들어가지 않았다. 2022년 말부터 Swift 앱 보호를 의뢰하는 고객이 늘면서, 이 공백이 제품 차원의 구멍이 됐다.

문자열 암호화·클래스명 난독화·동적 API 숨김 세 가지를 0→1로 만들어 메우는 게 내 일이었다.

먼저 짚을 것. 목표는 “완벽한 비밀”이 아니다. 키와 복호 루틴이 같은 바이너리 안에 있는 이상, 시간을 충분히 들인 동적 분석은 결국 원본을 복원한다.

방어의 목적은 다르다. 정적 분석 한 번으로 끝나던 일을 동적 분석·실행 추적까지 강제해, 공격 비용을 한 단계 올리는 것이다. 이 전제를 흐리면 설계 판단이 전부 어긋난다.

Swift라서 다른 지점

가릴 대상을 찾으려면 문자열이 어디 있는지부터 알아야 한다. 그런데 Swift는 Objective-C와 분포가 다르다. Objective-C 시절의 가정으로 섹션 하나만 훑으면 절반을 놓친다.

Swift String 리터럴은 한 군데 모여 있지 않다. __TEXT,__const, __DATA,__const 등 여러 세그먼트·섹션에 흩어진다. 그래서 어떤 처리든 “대상 세그먼트 목록”을 먼저 정직하게 확정하는 게 1순위였다.

섹션 분포 자체는 공개 도구로 바로 볼 수 있다.

# 세그먼트/섹션 레이아웃을 덤프 — 문자열이 어느 섹션에 흩어졌는지 본다
otool -l MyApp/Payload/MyApp.app/MyApp | grep -A4 'sectname'

대략 이런 그림이다. 한 섹션만 보고 끝내면 다른 섹션의 문자열은 그대로 남는다.

__TEXT 세그먼트 __cstring C 문자열 리터럴 __const Swift String 리터럴 __swift5_typeref 타입 메타데이터 참조 __objc_classname @objc 클래스 이름 __DATA 세그먼트 __const Swift String 리터럴 __cfstring CFString 구조체 (base+offset) __objc_const

강조 박스 = 대상 목록에 반드시 넣어야 할 섹션 한 섹션만 처리하면 나머지는 평문으로 남는다

CFString은 또 별개다. 단순 바이트열이 아니라 구조체라, 통째로 변환하면 깨진다. 그래서 구조체의 base address는 보존하고, 가리키는 offset만 재계산하는 식으로 따로 다뤘다.

클래스명은 더 까다로웠다. Swift 클래스라도 @objc로 노출되면 Objective-C 런타임을 경유하고, 그러면 클래스 이름이 Mach-O의 클래스명 섹션에 문자열로 남는다.

순수 Swift 클래스와 @objc 클래스가 경로가 갈린다. 그래서 분기를 그어, 후자는 런타임이 참조하는 내부 데이터 섹션의 이름 자체를 다른 값으로 갈아끼웠다. 가리는 게 아니라 대체다.

무거운 암호 대신 경량 가역 변환

문자열을 어떻게 가릴지에서 가장 큰 판단이 갈렸다. 처음엔 정식 블록 암호(AES)를 떠올렸다. 강하긴 하다.

그런데 측정해 보니 복호 비용이 앱 시작 속도를 직접 건드렸다. 암호화된 문자열은 실행 시점에 한꺼번에 원래 값으로 되돌리는데, 이 일괄 복호화가 기동 경로에 올라타 있다. 느려지면 사용자가 체감한다. 테스트에서 정식 암호는 경량 변환 대비 복호가 수 배 느렸다.

그래서 가역적이면서 복원 비용이 낮은 경량 변환을 택했다. 핵심은 강도 자체가 아니라, 단순 정적 추출을 깨는 것이다.

같은 키로 통째로 변환하면 패턴이 드러난다. 그래서 세그먼트·섹션별로 시드를 분기해, 한 군데를 뚫어도 전체가 자동으로 풀리지 않게 했다. 앞서 말한 전제와 일관된 선택이다. 어차피 동적 분석을 막지 못한다면, 막지도 못할 강한 암호로 기동 속도를 깎느니 정적 난도를 올리는 쪽에 비용을 쓴다.

전체는 두 단으로 갈렸다.

  • 빌드 후처리 단계 — 문자열을 변환된 형태로 심고, 기존 참조를 재매핑한다. 클래스명 난독화도 여기서 섹션을 손본다.
  • 런타임 진입점 — 실행 시점에 일괄 복호화한다.

흐름으로 보면 이렇다.

빌드 후처리 (정적) 서명된 IPA 입력 문자열 경량 변환 + 참조 재매핑 클래스명 대체 섹션 직접 수정 재서명 codesign

실행 시점 (런타임)

런타임 진입점에서 일괄 복호화 앱 기동 경로 — 여기 비용이 시작 속도를 건드린다

클래스명을 다 가리면 앱이 깨진다

여기서 막혔다. 클래스명을 일괄로 갈아끼우면 일부 앱이 런타임에 죽었다.

원인은 이름을 강제로 문자열로 참조하는 경로였다. NSKeyedArchiver로 직렬화한 객체는 복원할 때 저장해 둔 클래스 이름으로 클래스를 되찾는다. 의사코드로 보면 이렇게 동작한다.

// 직렬화 시: 클래스 이름이 문자열로 박힌다
archive["$class"] = "PaymentRecord"

// 복원 시: 저장된 이름으로 클래스를 되찾는다
cls = NSClassFromString(archive["$class"])   // "PaymentRecord"
obj = [[cls alloc] initWithCoder:archive]
//      ↑ 난독화로 이름이 바뀌면 cls == nil → 복원 실패

NSCoding과 Reflection 계열도 마찬가지다. 이름을 바꿔 버리면 아카이브 복원이 그 클래스를 못 찾고 실패한다. 난독화가 정상 기능을 깨는 것이다.

이건 더 똑똑한 난독화로 풀 문제가 아니었다. 닿으면 안 되는 영역을 인정하는 문제였다.

그래서 제외 규칙(Exclude Rule) 옵션을 설계했다. 고객이 자기 코드베이스에서 이름이 강제로 노출돼야 하는 클래스를 직접 명세하면, 그 클래스만 난독화 대상에서 빼는 방식이다.

보호 범위를 한 칸 줄이는 대신 앱이 안 깨지게 했다. 보안 기능이 정상 동작을 망가뜨리면 고객은 기능 자체를 꺼 버린다. 끌 일이 없게 만드는 게 실질적인 보호다.

가려졌는지 눈대중하지 않는다

검증은 눈대중으로 두지 않았다. 결과가 바이너리에서 정말 가려졌는지 확인하려고, Mach-O ARM64를 직접 파싱하고 명령어를 디코딩하는 분석 도구를 Python으로 만들었다.

“이 구현이 코드 레벨에서 어떻게 보이는가”를 시각화해, 적용 전후로 문자열·클래스명이 실제로 사라졌는지를 매번 자동으로 확인했다. 확인의 출발점은 누구나 쓰는 공개 명령이다.

# 적용 전후를 같은 명령으로 비교 — 평문이 사라졌는지 본다
strings -a before/MyApp | sort > before.txt
strings -a after/MyApp  | sort > after.txt
diff before.txt after.txt | grep '^<'   # before에만 있던 평문 = 가려진 것

이 도구는 QA 자동화와 후속 인력 교육, 이후 다른 제품 검증에도 그대로 재사용됐다.

정리하면, 이 작업이 한 일은 “Swift 앱을 안전하게” 만든 게 아니다. 정적 분석 한 번에 다 보이던 표면을 가려, 같은 정보를 얻으려면 동적 분석까지 가야 하도록 비용을 올린 것이다.

방어는 대개 이 모양이다. 비밀을 지키는 게 아니라, 캐내는 값을 비싸게 만드는 것.