본문 바로가기

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

← 기술 블로그

iOS·바이너리

Mach-O에 섹션 하나 끼워넣었을 뿐인데 앱이 다 깨졌다

외부 IPA에 섹션을 추가하면 왜 뒤 offset이 연쇄로 밀려 문자열 참조가 깨지는지, 그리고 기존 offset을 보존하는 무파괴 섹션 추가와 문자열 암호화 대체 전략.

빌드를 다시 할 수 없을 때

섹션 하나를 끼워넣었을 뿐인데 멀쩡하던 앱이 엉뚱한 문자열을 뱉거나 그냥 죽었다. 코드는 한 줄도 안 건드렸는데 화면이 깨졌다. 이 글은 그 한 바이트가 어디서 어긋났는지, 그리고 어떻게 “아무것도 밀지 않고” 섹션을 넣는지에 대한 기록이다.

이미 서명된 IPA에 보호 기능을 심어야 하는 상황이 있다. 호스트 앱 소스도 없고, 고객사 빌드 파이프라인에 손댈 수도 없다. 정적 라이브러리를 링크하고 빌드 설정을 만지는 통상적인 SDK 통합이 불가능하다는 뜻이다.

남는 선택지는 하나다. 이미 빌드되어 서명까지 끝난 Mach-O 바이너리를 후처리로 가공한다. 빌드 단계가 아니라 산출물 단계에서 손을 댄다.

가공의 첫 단계는 단순해 보였다. 문자열 암호화나 API 호출 은닉에 쓸 데이터를 담을 자리가 필요하니, Mach-O에 데이터 섹션을 하나 추가하면 된다. 라이브러리로 섹션을 만들어 붙이고 저장했다. 그게 전부일 줄 알았다.

그리고 앱을 실행했다. 멀쩡하던 화면이 깨졌다. 섹션 하나 늘렸을 뿐인데 앱 전체가 무너졌다.

offset은 절대 주소가 아니다

원인은 Mach-O의 레이아웃에 있다. Mach-O는 세그먼트(__TEXT, __DATA 등)로 나뉘고, 각 세그먼트 안에 섹션(__text, __cstring 등)이 파일 오프셋 순서대로 배치된다.

헤더의 로드 커맨드가 “이 섹션은 파일 오프셋 X, 크기 Y”라고 기록하고, 코드는 그 오프셋을 기준으로 데이터를 참조한다. 명령줄에서 직접 확인할 수 있는 구조다.

# IPA는 그냥 zip — 풀면 Payload/<App>.app/<App> 이 Mach-O 본체다
unzip -q Example.ipa -d out
otool -l "out/Payload/Example.app/Example" | grep -A4 sectname

otool -l이 뱉는 섹션 헤더는 대략 이런 모양이다. 핵심은 offset 한 줄이다.

Section
  sectname __cstring
   segname __TEXT
      addr 0x…(base)
      size 0x…(size)
    offset 11255808        # <- 파일 안에서 이 섹션이 시작하는 위치
     align 2^0 (1)

문제는 이 오프셋이 상대적이라는 점이다. 어떤 섹션을 중간에 끼워넣으면, 그 뒤에 있던 모든 섹션이 추가한 섹션 크기만큼 파일 뒤로 밀린다.

밀린 자리를 헤더의 로드 커맨드가 따라 갱신해 주면 문제가 없어야 한다. 그런데 실제로는 헤더만 갱신되고, 코드 안에 이미 박혀 있는 참조는 옛 위치를 그대로 가리킨다.

원본 레이아웃 섹션 삽입 후 __text __cstring offset 11255808 __data

코드의 참조 포인터

__text 새 섹션 (삽입) __cstring offset +Δ 만큼 밀림 __data

옛 포인터는 그대로 → 엉뚱한 바이트를 읽음

대표적인 게 문자열이다. iOS 바이너리의 CFString은 64비트 값 안에 문자 데이터를 가리키는 포인터를 품고 있고, 코드 곳곳이 이 포인터로 __cstring 영역을 읽는다. 구조를 의사코드로 풀면 이렇다.

// __cstring을 가리키는 상수 CFString (개념 표현)
struct CFConstantString {
    uintptr_t isa;        // 클래스 포인터
    uint64_t  flags;
    char     *cstr;       // <- __cstring 안의 절대 위치를 박아둔 포인터
    uint64_t  length;
};

섹션을 끼워넣어 __cstring이 뒤로 밀리면, 헤더상의 섹션 시작 주소는 갱신돼도 이미 코드에 박혀 있던 cstr 포인터는 옛 주소를 그대로 가리킨다. 그래서 같은 코드가 엉뚱한 바이트를 문자열로 읽는다. 앱이 깨진 정체가 이거였다.

오픈소스도 여기서 함께 막혔다. 라이브러리로 Fat Binary를 다시 저장하면 cstring 시작 주소가 페이지 정렬 단위만큼 어긋나는 사례를 만났다. 라이브러리가 알아서 맞춰 줄 거라 기대한 부분이 어긋난 것이다.

결국 라이브러리가 안전하게 다루는 경로와, 직접 파일을 재작성해야 하는 경로를 나눴다. 후자는 자체 구현으로 넘었다.**

깨뜨리지 않고 끼워넣기

해법의 핵심은 단순하다. 뒤 오프셋을 밀지 않으면 된다. 추가하는 섹션을 코드가 참조하지 않는 영역, 즉 기존 섹션들 뒤쪽 빈 공간에 배치하면, 앞에 있던 섹션들의 오프셋이 보존되고 문자열 참조도 그대로 산다.

이 “무파괴” 조건이 성립하는지로 섹션 추가가 가능한 케이스를 나눠 매트릭스를 만들었다.

무파괴 배치가 안 되는 케이스도 있다. 추가할 데이터가 커서 뒤를 밀 수밖에 없거나, 정렬 제약 때문에 빈자리에 못 넣는 경우다.

이때는 섹션 추가 자체를 포기하고 문자열 암호화로 대체한다. 새 섹션에 평문을 옮겨 담는 대신, 기존 __cstring을 그 자리에서 암호화해 두고 런타임에 일괄 복호화한다. 새 데이터 영역이 필요 없으니 오프셋도 건드리지 않는다.

다만 코드가 옛 평문 주소를 참조하던 구조는 그대로 남는다. 그래서 CFString 64비트 값을 둘로 가른다.

  • 상위 절반(베이스) — 그대로 유지한다.
  • 하위 절반(오프셋) — 새 데이터 영역 기준으로 재계산해 참조를 다시 잇는다.

이 분리·재계산 패턴은 사내에서 이미 검증된 보정 방식과 같다.

판단은 매트릭스로 내린다. 무파괴 배치가 되면 섹션 추가, 안 되면 문자열 암호화. 둘 다 막히면 진입점 명령어를 보호 로직으로 분기시키는 방식까지 단계적으로 내려간다.

정해진 한 방법을 고집하지 않는다. 바이너리의 빈 공간 상태에 따라 가장 비파괴적인 경로를 고른다.

보호 기능 주입 시작 뒤 오프셋 안 밀고 빈 공간에 배치 가능? 가능 무파괴 섹션 추가 불가 기존 __cstring을 제자리 암호화 가능? 가능 문자열 제자리 암호화 불가 진입점 명령어 분기

위에서 막히면 한 단계씩 아래로 — 가장 비파괴적인 경로를 고른다

왜 후처리로 막나

이건 공격이 아니라 방어다. 빌드 통합 없이 외부 IPA에 보호를 심는 정당한 후처리이고, 그래서 더 까다롭다.

소스도 빌드 환경도 없이 남이 서명해 둔 바이너리를 건드린다. 그러면서 앱의 동작은 한 바이트도 바꾸지 않아야 한다. 무파괴가 옵션이 아니라 전제인 이유다.

서명까지 끝난 바이너리를 손대면 서명이 깨지므로, 후처리 뒤에는 재서명이 따라붙는다. 서명 검증과 코드 디렉터리 확인은 표준 도구로 그대로 재현된다.

# 후처리·재서명 후 서명이 유효한지 표준 도구로 검증
codesign -v --verbose=4 "out/Payload/Example.app/Example"
codesign -d --entitlements :- "out/Payload/Example.app/Example"

오프셋이 왜 밀리는지를 정확히 알면, 거꾸로 밀지 않는 자리를 고를 수 있다. 그게 안 되는 바이너리에는 데이터를 새로 넣는 대신 있던 데이터를 암호화하는 쪽으로 우회한다.

같은 보호 기능을 바이너리 상태에 맞춰 다른 방법으로 심는 이 매트릭스가, IPA 업로드만으로 보안을 붙이는 SaaS를 다수 기업에 적용 가능하게 만든 토대였다. 다양한 일반 상용 앱 IPA에도 적용을 검증했다.

남긴 교훈은 하나다. 바이너리 후처리에서 “섹션을 추가한다”는 말은 곧 “뒤를 안 밀 수 있는가”라는 질문이다. 밀어야 한다면 추가가 아니라 다른 수단을 찾는 게 맞다.