Xcode 26 Enhanced Security가 강제하는 arm64e Fat Binary, 켜지기 전에 후처리 인프라를 늘렸다
Xcode 26 Enhanced Security가 arm64e Fat Binary를 강제한다. 고객이 켜기 전에 단일 슬라이스 가정의 후처리 도구를 Fat 헤더 파싱으로 확장하고, 빌드 의존성을 외부에 떠넘기지 않는 시나리오를 고른 과정.
Apple이 Xcode 26에서 Enhanced Security를 정식 빌드 옵션으로 올렸다. 켜는 순간 우리 IPA 후처리가 입력으로 받던 바이너리의 모양이 바뀐다. 고객이 이 토글을 누르기 전에 후처리 인프라를 먼저 늘려 둔 과정을 정리한다.
Enhanced Security가 바꾸는 것
Enhanced Security를 켜면 Pointer Authentication(PAC)과 Typed Allocation 같은 메모리 안전 장치가 따라 들어온다. PAC는 함수 포인터 위변조와 ROP를 비싸게 만들고, Typed Allocation은 힙 타입 혼동을 좁힌다.
문제는 PAC가 arm64e에서만 동작한다는 점이다. 그래서 이 옵션을 켠 앱은 arm64와 arm64e 두 슬라이스를 한 파일에 담은 Fat Binary가 된다. 단일 슬라이스를 가정하던 도구 입장에선 입력의 헤더 레이아웃부터 어긋난다.
방어 제품을 만드는 쪽에서 이건 단순한 옵션 추가가 아니었다. Apple이 곧 권장으로 밀 기본값이라, “고객이 켜는 순간 우리 후처리가 안 돈다”가 곧 현실이 된다는 신호였다.
Fat Binary는 여러 아키텍처 슬라이스를 하나의 파일로 묶은 구조다. 맨 앞 Fat 헤더가 각 슬라이스의 위치(offset)와 크기(size)를 가리키고, 그 뒤로 아키텍처별 Mach-O가 정렬 경계에 맞춰 나열된다.
IPA를 펼쳐 보면 이 변화가 어디서 드러나는지 바로 보인다. .ipa는 사실상 zip이라 그대로 풀 수 있고, 앱 실행 파일의 슬라이스 구성은 공개 도구로 확인된다.
# IPA는 zip 컨테이너 — 그대로 압축 해제
unzip -q MyApp.ipa -d MyApp_extracted
cd MyApp_extracted/Payload/MyApp.app
# 실행 파일이 품은 아키텍처 슬라이스 확인
lipo -info MyApp
# 단일: Non-fat file: MyApp is architecture: arm64
# Fat: Architectures in the fat file: MyApp are: arm64 arm64e
# Fat 헤더가 가리키는 슬라이스 offset/size를 직접 본다
otool -f -V MyApp
lipo -info가 arm64 arm64e를 뱉는 순간이 우리 후처리가 단일 슬라이스 가정을 깨는 지점이다.
왜 선행으로 잡았나
우리 IPA 후처리 파이프라인은 arm64 단일 슬라이스만 가정하고 Mach-O 헤더를 직접 파싱한다. 빌드 통합이 아니라 이미 서명된 바이너리를 가공하는 구조라(이 전제 자체는 별도 SaaS 트랙에서 다뤘다), 입력이 Fat Binary로 바뀌면 헤더 레이아웃부터 어긋난다.
고객 활성화 요청은 아직 없었다. 그래도 들어오고 나서 대응을 시작하면 늦다고 판단해 선행 트랙으로 잡았다. Apple 권장 기본값의 변화는 예고된 부채라, 청구서가 오기 전에 갚는 쪽을 골랐다.
빌드 단: backdeploy 심볼 충돌
먼저 정적 라이브러리를 Enhanced Security와 함께 빌드하는 것부터 막혔다. 낮은 배포 타깃에서는 컴파일러가 구버전 OS 호환용 backdeploy 경로의 할당 심볼을 끌어오는데, 이게 Enhanced Security가 요구하는 Typed Allocation 시그니처와 충돌해 컴파일이 깨졌다.
“둘 중 하나를 끄자”는 우회가 가장 쉬웠다. 하지만 그건 곧 고객사에 OS 버전 하한이나 빌드 제약을 떠넘기는 일이라 채택하지 않았다.
결국 Typed Allocation의 C++ 설정을 컴파일러 기본값으로 명시 고정하는 쪽으로 닫았다. 옵션을 끄는 게 아니라 시그니처 해석을 한쪽으로 못박은 것이다. 기능은 그대로 두고 충돌만 제거했다.
시나리오를 먼저 고른다
구현보다 먼저 한 일은 대응 시나리오 비교다. 정적 라이브러리·고객사 프로젝트·배포 절차 세 축으로 네 가지 경우를 표로 깔았다.
기각한 안과 이유는 이렇다.
- 양쪽 다 켜고 배포 라이브러리 버전을 올리는 안 — 우리 빌드 환경에 외부 의존성을 강제한다.
- 배포 타깃을 올리는 안 — 구형 OS 사용자를 떨군다.
채택한 건 라이브러리는 그대로 두되 arm64e 슬라이스를 별도로 추가하는 안이었다. 이유는 세 가지다.
- 우리 빌드 환경에 외부 버전 의존을 강제하지 않는다.
- 고객사는 Apple 권장 옵션을 그대로 켜면 된다.
- 추가 작업이 후처리 도구 한 곳으로 격리된다.
검증 없이 코드부터 만졌다면 변경 범위가 세 군데로 번졌을 일이다. 시나리오를 먼저 깔아 둔 덕에 손대는 면을 한 곳으로 좁혔다.
후처리 단: Fat 헤더를 파싱하는 객체
핵심 구현은 단일 슬라이스만 알던 기존 바이너리 매니저 위에, Fat 헤더를 해석하는 통합 바이너리 처리 객체를 새로 올린 것이다. Fat 헤더를 파싱해 슬라이스별로 개별 매니저를 만들고, 각 슬라이스를 순회하며 같은 패치 단계(문자열 암호화, 섹션 임의 생성 등)를 적용한다. 호출부 입장에선 입력이 단일이든 Fat이든 동일한 단계로 흐른다.
흐름을 의사코드로 옮기면 이렇다.
fat_header = read_fat_header(binary) # magic, nfat_arch
if not fat_header: # 단일 슬라이스 경로 — 기존과 동일
apply_patch_steps(single_manager)
return
managers = []
for arch in fat_header.archs: # 슬라이스마다 offset/size로 잘라
slice = binary[arch.offset : arch.offset + arch.size]
managers.append(make_manager(slice))
for m in managers: # 같은 패치 단계를 순회 적용
apply_patch_steps(m) # 문자열 암호화 · 섹션 임의 생성 …
rewrite_fat_offsets(fat_header, managers) # 어긋난 offset/size 다시 쓰기
여기서 막혔던 지점이 하나 있다. 외부 바이너리 라이브러리를 Fat으로 다시 저장하면 슬라이스 내부 오프셋이 정렬 경계 단위로 어긋났다. 같은 패턴을 이전 SaaS 트랙에서 한 번 겪어 둔 게 도움이 됐다.
해법은 단계를 둘로 가르는 것이었다. 라이브러리 기능으로 안전하게 처리되는 단계와, 그게 오프셋을 깨는 단계를 분리했다. 후자는 Fat 헤더의 슬라이스 offset·size 필드를 직접 다시 쓰는 경로로 우회했다.
다만 어느 단계를 라이브러리에 맡기고 어디서부터 손으로 헤더를 고칠지의 경계는 내가 그었다. 자동화가 빠르게 짜 준 건 코드고, 깨지는 지점의 경계 판단은 도구가 대신해 주지 못했다.
켜지 않은 채로 둔다
arm64e 슬라이스는 의도적으로 비활성 상태로 두고 점진 활성화하는 토글을 남겼다. 첫 고객사 케이스가 생기기 전에는 검증할 실제 페이로드가 없었기 때문이다.
그래서 기존 패치 단계가 새 객체 위에서 도는지만 단위 테스트로 확인해 두고 실활성화는 미뤘다. 검증 못 한 걸 켜 두는 것보다, 안전한 기본값에 토글을 남기는 쪽이 정직하다.
결과
통합 바이너리 처리 객체는 후처리 도구 최근 릴리즈에 들어갔다. Premium 6개 모듈의 arm64e 호환 여부와 빌드 절차는 비교표로 정리됐다. 고객사가 Enhanced Security를 켜겠다고 하면, 채택해 둔 시나리오 순서대로 1~2주 안에 대응할 수 있다.
핵심은 하나다. Apple의 보안 기본값 변화를 고객이 밟기 전에 읽고, 그 비용을 고객 빌드 환경이 아니라 우리 후처리 쪽으로 흡수하도록 설계한 것. 청구서는 결국 누군가 받는다 — 받을 곳을 미리 골라 둔 셈이다.