본문 바로가기

← 블로그

iOS/바이너리

배포된 iOS 앱에 프레임워크 주입하기

이미 빌드되고 서명된 iOS 앱에 소스 없이 프레임워크를 주입하는 원리. Frameworks 배치, LC_LOAD_DYLIB로 main보다 먼저 실행시키기, 안쪽부터 재서명, 그리고 변조된 앱을 못 돌게 막는 방어까지 그림으로 정리합니다.

이미 빌드와 서명까지 끝난 iOS 앱에 소스코드 없이 코드를 추가하는 방법을 다룹니다. IPA를 풀어 프레임워크 하나를 넣고, 실행 바이너리에 load command 한 줄을 더한 뒤 다시 서명하면, 그 코드가 앱 안에서, 그것도 앱의 main보다 먼저 실행됩니다.

용어부터 정리합니다. iOS 앱에 심는 동적 라이브러리는 보통 .framework 번들입니다. 프레임워크 안에 실제 코드인 dylib(Mach-O)가 들어 있고, 이 dylib를 dyld(동적 링커)가 로드합니다. 넣는 대상은 프레임워크, 로드하는 주체는 dyld입니다.

완성된 앱의 바이너리를 직접 다룰 수 있으면 이런 일이 가능합니다. 이 기법은 후킹이나 계측 같은 후처리에도 쓰이고, 동시에 앱을 변조하는 공격의 출발점이기도 합니다. 원리를 따라가 보고, 마지막에 이런 변조를 막는 방어까지 정리합니다.

코드가 앱에 끼어드는 원리

프레임워크는 어디에 놓나?


IPA는 zip으로 풀리는 앱 번들입니다. 풀면 Payload/Some.app/ 안에 실행 바이너리와 리소스가 있고, 프레임워크는 Frameworks/에 놓입니다.

Some.ipa
└── Payload/
    └── Some.app/
        ├── Some            실행 바이너리
        ├── Info.plist
        └── Frameworks/     프레임워크 자리

우리 프레임워크는 이 Frameworks/에 복사합니다. dyld가 @rpath를 풀 때 이 경로를 뒤지기 때문입니다. 이 구조에는 “이 영역을 수정하지 말라”는 물리적 제약이 없습니다. 무결성을 지키는 유일한 장치는 코드 서명뿐입니다.

놓기만 해서는 로드되지 않는다


Frameworks/에 프레임워크를 복사하는 것만으로는 아무 일도 일어나지 않습니다. 아무도 그것을 호출하지 않기 때문입니다. 호스트 앱이 실행될 때 읽어 들이도록 목록에 등록해야 합니다.

그 목록이 메인 바이너리의 LC_LOAD_DYLIB load command입니다. 앱이 의존하는 라이브러리마다 이 커맨드가 하나씩 있고, dyld는 실행 시 이 목록을 훑어 각 라이브러리를 메모리에 매핑합니다. 여기에 우리 프레임워크 안의 dylib를 가리키는 항목을 한 줄 더 박으면 됩니다. otool -L로 현재 목록과 추가될 형태를 확인합니다.

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

LC_LOAD_DYLIB 하나에는 라이브러리 경로와 함께 호환 버전, 현재 버전이 담깁니다. otool -L의 각 줄이 이 커맨드 하나입니다. dyld는 목록을 위에서부터 순서대로 처리하고, 각 라이브러리가 의존하는 것을 먼저 로드합니다(깊이 우선). 덕분에 의존 라이브러리는 자기를 쓰는 쪽보다 항상 먼저 준비됩니다. 주입 항목은 보통 목록 끝에 붙이는데, 어디에 붙든 앱의 main보다는 먼저 로드됩니다.

@rpath는 어디를 뒤지나?


앞에서 본 @rpath/MyInjected.framework/MyInjected@rpath는 실제 경로가 아니라 실행할 때 채워지는 자리표시자입니다. dyld가 라이브러리를 찾을 때 쓰는 접두어가 셋 있습니다.

  • @executable_path: 메인 실행 파일이 있는 디렉토리(앱 번들 루트)
  • @loader_path: 지금 로드를 요청한 바이너리가 있는 디렉토리
  • @rpath: 바이너리에 적힌 런패스(LC_RPATH) 목록을 하나씩 대입해, 파일을 찾을 때까지 시도

iOS 앱은 대개 LC_RPATH@executable_path/Frameworks를 갖고 있습니다. 그래서 @rpath/MyInjected.framework/MyInjected<앱>/Frameworks/MyInjected.framework/MyInjected로 풀립니다. 프레임워크를 Frameworks/에 넣는 이유가 이것입니다. 앱에 그 런패스가 없다면 LC_RPATH도 한 줄 추가해 줍니다.

로드돼도, 부를 사람이 없다


LC_LOAD_DYLIB를 넣으면 dyld가 프레임워크를 메모리에 올려 줍니다. 그런데 문제가 하나 더 있습니다. 앱은 이 프레임워크를 부르지 않습니다. 앱이 빌드될 때는 없던 코드라, 앱 어디에도 이걸 호출하는 지점이 없습니다. 로드는 됐는데 실행될 계기가 없는 셈입니다.

그래서 호출자 없이 스스로 깨어나는 런타임 진입점을 씁니다.

  • +load(Objective-C): 클래스가 로드되는 순간 런타임이 자동으로 호출합니다. 아무도 부르지 않아도 실행됩니다. 단 +load는 Objective-C에만 있습니다.
  • __attribute__((constructor))(C): dyld가 이미지를 초기화할 때 자동으로 호출합니다. C쪽의 같은 역할입니다.

둘 다 앱의 main보다 먼저 실행됩니다. 여기서 main은 앱의 진입점으로, Objective-C는 main.m에 직접 두고 Swift는 @main이 대신 만들며, Mach-O의 LC_MAIN이 그 위치를 가리킵니다. dyld는 로드한 라이브러리들의 초기화 코드를 전부 실행한 뒤에야 main으로 넘어갑니다. 여러 라이브러리가 있으면 이 초기화는 로드된 순서, 곧 의존 관계를 먼저 지킨 순서로 실행됩니다.

앱 실행 dyld: 라이브러리 로드 LC_LOAD_DYLIB 목록 각 라이브러리 초기화 +load / 생성자 앱 main() 주입된 프레임워크 코드가 여기서, 앱 코드보다 먼저 실행

+load가 Objective-C 전용이라, 앱이 Swift로 짜였더라도 진입 스텁만은 Objective-C로 둡니다. 방법은 간단합니다. 작은 ObjC 클래스에 +load를 두고 그 안에서 Swift 함수를 부르면 됩니다. Swift 쪽은 @_cdecl로 C 링키지를 붙여, ObjC가 부를 수 있는 심볼로 그 함수를 노출합니다.

// 진입 스텁 (Objective-C): 런타임이 +load를 자동 호출
extern void protector_main(void);
@implementation Bootstrap
+ (void)load { protector_main(); }
@end
// 실제 로직 (Swift): C 링키지로 노출
@_cdecl("protector_main")
func protectorMain() { /* 보호 로직 */ }

진입점만 ObjC로 잡고 나머지는 전부 Swift로 짜는 polyglot 구성입니다. 드물게는 앱이 이미 호출하는 외부 심볼을 프레임워크가 같은 이름으로 내보내 그 호출에 편승할 수도 있지만, 이는 앱이 그 심볼을 실제로 부르는 경우로 한정됩니다.

이 “먼저”가 핵심입니다. 앱의 어떤 코드보다 먼저 실행되는 자리를 얻는 것이라, 후킹이든 계측이든 무결성 검사든 여기서 출발합니다.

서명을 다시 붙인다

한 바이트만 바뀌어도 서명이 깨진다


load command를 추가하면 바이너리의 바이트가 달라집니다. 그 순간 기존 코드 서명은 무효가 됩니다. iOS의 코드 서명은 바이너리의 코드 페이지마다, 그리고 번들의 파일마다 해시를 봉인해 두기 때문에 한 곳만 바뀌어도 검증이 실패합니다. iOS는 서명이 유효하지 않은 앱을 실행하지 않으므로, 마지막 단계는 언제나 재서명입니다.

안쪽부터 바깥으로 서명한다


재서명은 단일 명령이 아니라 정해진 순서를 따릅니다. 안쪽 파일부터 서명하고 바깥 번들을 마지막에 봉인해야, 바깥 서명이 안쪽 파일들의 바뀐 해시를 올바로 담습니다.

Some.app (번들) ② 번들 전체 서명 (안쪽 파일 해시까지 봉인) Frameworks/MyInjected.framework, 메인 바이너리 ① 안쪽 파일부터 서명
  • 새로 넣은 프레임워크에 먼저 서명합니다.
  • Info.plist의 entitlement를 프로비저닝이 허용하는 범위로 맞추고, 임베디드 프로비저닝 프로파일을 내 인증서 것으로 교체합니다.
  • 번들 전체를 codesign으로 다시 봉인하고 IPA로 재압축합니다.
codesign -f -s "Apple Development: me" \
  Payload/Some.app/Frameworks/MyInjected.framework
codesign -f -s "Apple Development: me" \
  --entitlements ents.plist \
  Payload/Some.app

LIEF만으로 안 되는 지점


초기에는 바이너리 조작 라이브러리 LIEF로 섹션을 통째로 옮기려 했습니다. 하지만 Fat Binary를 다시 저장하는 단계에서 문자열 영역 주소가 페이지 단위로 어긋났고, 참조가 깨진 문자열은 런타임에 엉뚱한 메모리를 가리켰습니다. 결국 참조를 손상시키지 않는 보정 로직을 직접 구현하고, 재서명을 파이프라인의 정식 단계로 넣어 해결했습니다. 이 문제는 Mach-O에 커스텀 섹션 추가하기에서 자세히 다룹니다.

같은 도구, 두 얼굴

정당한 후처리와 공격은 같은 절차다


추출, 프레임워크 주입, load command 추가, 재서명. 이 네 단계는 정상적인 후처리에도 그대로 쓰입니다. 빌드나 소스에 손대지 않고 완성된 앱에 후킹이나 계측, 보호 로직을 얹는 경우입니다.

공격자도 같은 순서를 따릅니다. 정상 앱을 추출해 자신의 프레임워크를 넣고, load command로 강제 로드한 뒤, 자기 계정으로 재서명합니다. 그 안에 결제 우회나 인증 탈취 같은 코드를 심고 사이드로딩으로 배포합니다.

같은 4단계 파이프라인 추출 프레임워크 주입 load command 강제 로드 재서명 정상 후처리 후킹, 계측, 무결성 검사 빌드 소스 없이 기능 추가 악성 페이로드 주입 결제 우회 / 인증 탈취 / 데이터 유출 사이드로딩 배포

같은 절차로 정상 기능도, 악성 코드도 주입됩니다. 차이는 절차가 아니라 그 안에 무엇을 넣느냐입니다.

막지 말고, 실행되지 못하게 한다


방어의 초점은 주입을 차단하는 데 있지 않습니다. zip 구조와 Mach-O 포맷이 열려 있는 이상 주입 자체를 원천 봉쇄할 수는 없습니다. 대신 주입된 상태로는 실행되지 못하게 만듭니다.

앱은 기동할 때 자신의 서명 주체와 로드된 이미지 목록을 스스로 점검해야 합니다. 서명한 주체가 원개발자가 아니거나 예상하지 못한 프레임워크가 끼어 있으면 비정상으로 판단하고 초기화 단계에서 즉시 멈춥니다. 관건은 이 검사 자체가 우회되거나 제거되지 않는 것입니다. 보호 로직을 앱보다 먼저 실행되게 심는 이유가 여기 있습니다.

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