서명 깨지 않고 Mach-O 섹션 추가하기
기존에 서명된 IPA 바이너리를 재빌드 없이 수정하는 방법을 다룬다. Mach-O 파일 내부에 새로운 섹션을 삽입할 때 발생하는 오프셋 변위 문제와 그로 인한 문자열 참조 손상을 분석하고, 원본 오프셋을 유지한 채 임의 코드나 데이터(보호 SDK 등)를 삽입하는 무손상 레이아웃 방식을 설명한다. 섹션 추가 실패 시 적용 가능한 문자열 암호화 대체 전략도 함께 다룬다.
빌드를 못 건드릴 때 남는 칼
소스도 빌드 파이프라인도 없는 앱에 보호 기능을 심어야 하는 상황이 있다. 호스트 앱은 이미 고객사에서 빌드해 서명까지 마쳤고, 정적 라이브러리를 링크하거나 빌드 설정을 조정하는 통상적인 SDK 통합은 애초에 선택지가 아니다. 손에 남은 건 산출물 IPA 하나뿐이다.
이 상황에서 가능한 방향은 하나로 모인다. 이미 서명된 Mach-O 바이너리 자체를 후처리로 가공하는 것이다. 이렇게 하면 빌드 과정에 손대지 않고, 심지어 앱스토어에서 받은 IPA에도 보안 SDK나 임의 데이터를 삽입할 수 있다. 100개가 넘는 고객사 앱에 각자의 빌드 환경을 요구하지 않고 동일한 보호를 주입할 기반이 여기서 생긴다. 같은 기술이 악성 코드 삽입에도 쓰일 수 있으니, 후처리 구조를 정확히 이해하는 일은 방어 측면에서도 필요하다.
가공의 첫 단계는 단순해 보였다. 문자열 암호화나 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)
이 오프셋은 상대적이라는 점이 문제다. 어떤 섹션을 중간에 추가하면, 그 뒤에 있는 모든 섹션이 새로 추가된 섹션 크기만큼 뒤로 밀린다.
밀린 구간을 헤더의 로드 커맨드가 함께 갱신해 주면 괜찮다. 하지만 실제로는 헤더만 바뀌고, 코드 내부에 이미 상수로 박혀 있는 참조는 그대로 옛 위치를 가리킨다.
대표적인 예가 문자열이다. 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 시작 주소가 페이지 정렬 단위(0x4000, 16KB)만큼 어긋나는 경우가 있었다. 라이브러리가 알아서 정렬을 맞춰 줄 거라 예상했던 부분이 달랐던 것이다.
결국 라이브러리가 안전하게 처리해 주는 경로와, 직접 파일을 다시 써야 하는 경로를 구분했다. 후자의 경우는 라이브러리에 의존하지 않고 자체 구현으로 처리했다.
깨뜨리지 않고 끼워넣기
해법의 핵심은 단순하다. 뒤 오프셋을 밀지 않으면 된다. 새 섹션을 코드가 참조하지 않는 영역, 즉 기존 섹션 뒤쪽의 남는 공간에 배치하면 기존 섹션들의 오프셋이 보존되고 문자열 참조도 그대로 유지된다.
이 “무파괴” 조건이 충족되는지를 기준으로 섹션 추가 가능 여부를 나눠 매트릭스를 구성했다.
무파괴 배치가 불가능한 경우도 있다. 추가되는 데이터가 커서 뒤쪽을 밀지 않을 수 없거나, 정렬 제약 때문에 빈 공간에 넣지 못하는 상황이다.
그럴 때는 섹션 추가 대신 문자열 암호화를 택한다. 새 섹션에 평문을 옮겨 담는 대신, 기존 __cstring을 그 자리에서 암호화하고 런타임 시점에 일괄 복호화한다. 새 데이터 영역을 확보할 필요가 없으므로 오프셋을 건드리지 않는다.
다만 코드 구조에는 여전히 이전 평문 주소 참조가 남는다. 이를 맞추기 위해 CFString의 64비트 값을 두 부분으로 나눈다.
- 상위 절반(베이스) — 그대로 유지한다.
- 하위 절반(오프셋) — 새 데이터 기준으로 재계산해 참조를 다시 연결한다.
이 상위 유지와 하위 재계산 방식은 내부 후처리 툴에서 검증된 보정 절차와 동일하다.
전체 판단은 매트릭스를 따른다. 무파괴 배치가 가능하면 섹션을 추가하고, 불가능하면 문자열 암호화를 적용한다. 두 방법 모두 어려우면 진입점 명령어를 보호 로직으로 분기시키는 단계로 내려간다. 결국 바이너리의 빈 공간 상태에 따라 가장 영향이 적은 방식을 선택한다.
왜 후처리가 더 까다로운가
같은 기술이 양날의 성격을 가진다. 남의 서명된 바이너리에 코드를 삽입하는 일은 멀웨어가 수행하는 행위이기도 하다. 다만 한 가지 차이가 있다 — 빌드 과정과 통합하지 않은 채 외부 IPA에 보호를 입히는 정당한 후처리는 앱의 동작을 한 바이트도 달라지게 해서는 안 된다. 그래서 절차가 더욱 까다롭다. 무파괴가 선택이 아니라 전제이기 때문이다.
서명까지 완료된 바이너리를 수정하면 서명이 깨지므로, 후처리 뒤에는 반드시 재서명이 따라야 한다. 서명 검증과 코드 디렉터리 확인 과정은 표준 도구를 그대로 이용해 재현한다.
# 후처리·재서명 후 서명이 유효한지 표준 도구로 검증
codesign -v --verbose=4 "out/Payload/Example.app/Example"
codesign -d --entitlements :- "out/Payload/Example.app/Example"
오프셋이 밀리는 원인을 정확히 이해하면, 반대로 밀리지 않는 지점을 선택할 수 있다. 그게 불가능한 바이너리의 경우에는 새 데이터를 추가하는 대신 기존 데이터를 암호화하는 방식으로 우회한다.
하나의 보호 기능을 바이너리 상태에 맞춰 다른 방식으로 삽입하는 이 매트릭스가 IPA 업로드만으로 보안을 적용하는 SaaS를 100개가 넘는 서비스에 확장할 수 있게 한 실제 기반이었다. 일반 상용 앱의 IPA에서도 적용 가능함을 검증했다.
이 과정에서 얻은 교훈은 분명하다. 바이너리 후처리에서 ‘섹션을 추가한다’는 말은 곧 ‘뒤를 밀지 않고 추가할 수 있는가’라는 질문이다. 만약 밀어야 한다면, 추가가 아닌 다른 방식을 찾아야 한다.