본문 바로가기

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

← 기술 블로그

iOS·바이너리

배포된 iOS 앱에 동적 라이브러리 추가하기

공식 배포된 IPA를 추출한 뒤 내부 Frameworks 디렉터리에 dylib를 추가하고, Mach-O load command를 수정해 주입 라이브러리를 로드하도록 조작한 후 자체 인증서로 재서명해 실행하는 절차를 단계별로 보여준다. 해당 방식이 보안 SDK 삽입과 악성 코드 변조에 동일하게 활용될 수 있음을 짚고, 코드 서명 무결성 검증 및 재서명 탐지 기능으로 이를 방어하는 방법을 다룬다.

앱스토어에서 내려받은 앱은 이미 서명이 완료된 완제품이다. 그런데 그 IPA를 풀어 프레임워크 하나를 끼워 넣고, 바이너리에 한 줄을 추가한 뒤 다시 서명하면 내 코드가 그 앱 안에서 작동한다. 빌드 소스도 원개발자의 동의도 필요하지 않다.

이 과정은 합법적인 보안 후처리의 기반이면서 동시에 공격자가 앱을 탈취할 때 사용하는 첫 단계가 된다. 같은 기술이 어떻게 양면으로 작동하는지 단계별로 살펴본다.

IPA는 그냥 zip이다

IPA는 별도의 독립 포맷이 아니라 단순히 zip 압축 파일이다. 확장자만 ipa로 바꾼 채 압축을 풀면 내부 구조가 그대로 드러난다.

unzip Some.ipa -d extracted
# extracted/Payload/Some.app/ 아래에 모든 게 있다

Payload/ 디렉터리 아래에는 *.app 번들이 있고, 그 안에는 실행 바이너리(Mach-O) 파일과 Info.plist, 각종 리소스, 그리고 Frameworks/ 폴더 속의 동적 라이브러리들이 들어 있다.

Some.ipa (= zip) Payload/ Some.app/ Some 실행 바이너리 (Mach-O) Info.plist entitlement · 번들 메타 Frameworks/ ← 여기에 dylib를 넣는다

이 구조 안에는 “이 영역을 수정하지 말라”는 식의 물리적 제약 장치가 존재하지 않는다. 무결성을 보장하는 유일한 메커니즘은 코드 서명뿐이다.

파일만 넣어서는 아무 일도 일어나지 않는다

후처리 단계에서 코드를 심으려면 두 가지 준비가 필요하다.

  • 첫째, 내가 작성한 코드를 담은 .dylib(또는 .framework) 파일을 Frameworks/ 디렉토리에 복사한다.
  • 둘째, 호스트 앱이 실행될 때 그 dylib가 로드되도록 만들어야 한다.

단순히 파일만 넣어두면 아무도 그걸 호출하지 않아 아무 일도 일어나지 않는다. 로드를 강제하는 가장 직접적인 방법은 Mach-O의 load command를 이용하는 것이다.

메인 바이너리의 헤더에 LC_LOAD_DYLIB(또는 weak 형식의 변형) 항목을 하나 추가하면, dyld가 앱 실행 시 해당 경로의 라이브러리를 읽어와 매핑한다. otool로 현재 의존성을 확인하면, 추가하려는 항목이 어떤 형태로 들어가는지 바로 확인할 수 있다.

otool -L extracted/Payload/Some.app/Some
# Some:
#   /System/Library/Frameworks/UIKit.framework/UIKit ...
#   @rpath/MyInjected.dylib   ← 이 한 줄을 헤더에 새로 박는다

dylib의 +load 메서드나 생성자에 코드를 연결해두면 호스트 앱의 main 함수보다 먼저 내 코드가 동작한다. 결국 load command 한 줄로 실행 진입점을 앞당기는 셈이다.

이 선점에는 양면이 있다. 공격자에겐 후킹과 계측의 출발점이 되지만, 보안 SDK 입장에서는 앱보다 먼저 보호 로직을 깨우는 자리이기도 하다.

한 줄이 깨뜨리는 것 — 그리고 재서명

load command를 추가하면 바이너리의 바이트가 달라진다. 바이트가 바뀌는 순간 기존 코드 서명은 즉시 무효가 된다. iOS는 유효하지 않은 서명 바이너리를 실행하지 않는다. 그래서 마지막 단계는 언제나 재서명이다.

재서명은 단일 명령이 아니라 정해진 순서를 따른다.

  • 먼저 dylib에 서명한다.
  • Info.plist의 entitlement를 실제 프로비저닝이 허락하는 범위로 조정한다.
  • 임베디드 프로비저닝 프로파일을 내 인증서 것으로 교체한다.
  • 번들 전체를 codesign으로 다시 봉인한 뒤 IPA로 재압축한다.
codesign -f -s "Apple Development: me" \
  extracted/Payload/Some.app/Frameworks/MyInjected.dylib
codesign -f -s "Apple Development: me" \
  --entitlements ents.plist \
  extracted/Payload/Some.app

이 과정을 자동화한 것이 내부 재서명 도구 AppSuitSign이다. 이 도구는 iOS의 .ipa뿐 아니라 macOS의 .pkg.app까지 입력 포맷을 감지해 처리 경로를 나눈다.

.pkg는 단순 압축물이 아니라 빌드 산출물이기 때문에 .ipa와 달랐다. 메타데이터와 패키지 단위의 서명을 각각 따로 보존해 줘야 했다.

막혔던 지점 — LIEF를 버린 이유

여기서 실제로 막혔던 부분을 남긴다. 초기에 바이너리 조작용 오픈소스 라이브러리 LIEF로 섹션 전체를 옮기려 했다.

하지만 Fat Binary를 다시 저장하는 단계에서 문자열 영역 주소가 페이지 단위로 어긋났다. 참조가 깨진 문자열은 런타임에 전혀 다른 메모리를 가리켰다.

결국 외부 라이브러리에 기대지 않았다. 문자열 참조 구조를 손상시키지 않는 보정 로직을 직접 구현하고, 재서명을 파이프라인의 정식 단계로 편입해 문제를 해결했다.

같은 파이프라인이 합법 주입과 악성 주입으로 갈린다

이 전 과정 — 추출, 프레임워크 인젝션, load command 추가, entitlement·프로비저닝 교체, 재서명 — 은 합법적인 후처리 단계로 활용된다.

기존에는 고객사가 직접 자기 앱에 정적 라이브러리를 링크하고 빌드 설정을 수정해야 했으나, IPA 파일 업로드만으로 보안 SDK가 주입되도록 구현한 것이 AppSuit Air다. 동일한 절차를 상용 앱 IPA에도 적용해 동작을 검증했다. 빌드 소스 없이 보호 기능을 바이너리에 내장할 수 있다는 점은 효율적이지만 동시에 위험성을 내포한다.

공격자 역시 같은 순서를 따른다. 정상 앱을 추출한 뒤 자신이 만든 dylib을 삽입하고, load command로 강제 로드한 다음 개발자 계정으로 다시 서명한다. 그 과정에서 결제 우회, 인증 탈취, 데이터 유출 코드 등을 심고 사이드로딩을 통해 배포한다.

동일한 도구로 보안 SDK도, 악성 페이로드도 주입된다. 차이는 도구가 아니라 그 안에 무엇을 넣느냐다.

같은 4단계 파이프라인 추출 dylib 인젝션 load command 강제 로드 재서명 보안 SDK 주입 (합법) · 보호 로직을 앱보다 먼저 실행 · 빌드 소스 없이 보안 기능 내재화 · AppSuit Air 악성 페이로드 주입 · 결제 우회 · 인증 탈취 · 데이터 유출 코드 추가 · 사이드로딩 배포

주입은 막을 수 없다 — 주입된 상태로는 실행되지 않게 만든다

방어의 초점은 “주입을 차단하는 것”이 아니다. zip 구조와 Mach-O 포맷이 열린 이상 주입 자체를 원천 봉쇄할 수는 없다. 대신 주입이 이루어진 상태로는 실행될 수 없게 만드는 방향으로 접근한다.

앱은 기동 시 자신의 바이너리와 로드된 이미지 목록을 점검해야 한다. 서명 주체가 원개발자가 아니거나, 예상하지 못한 dylib가 포함되어 있다면 비정상으로 판단한다.

이 검사가 우회되지 않도록 하는 것이 핵심이다. 우리 SDK는 보호 프레임워크가 제거되거나 무력화되면 앱이 초기화 단계에서 즉시 정지하도록 무결성 검증 메커니즘을 설계했다.

결국 요지는 하나다. 서명은 “누가 마지막으로 봉인했는가”만을 증명한다. 앱이 런타임에서 그 봉인 주체가 원개발자인지를 스스로 확인하지 않는다면, 그 서명은 누구나 다시 찍을 수 있는 도장에 불과하다.