LIEF로 Fat Binary를 다시 저장했더니 문자열 주소가 페이지 단위로 밀렸다
서명된 IPA를 후처리하려고 LIEF로 Fat Binary를 재저장했더니 cstring 주소가 16KB 페이지 정렬 단위로 어긋났다. 오픈소스의 재배치 가정을 직접 rewriting으로 넘긴 디버깅 일지.
문자열 주소가 깨졌는데, 랜덤이 아니라 정확히 한 페이지(16KB)씩 일정하게 밀려 있었다. 깨짐이 규칙적이면 원인도 규칙적이라는 뜻이다. 이 글은 그 16KB라는 단서 하나로 LIEF를 어디까지 믿고 어디서 직접 바이너리를 다시 쓸지 경계를 그은 기록이다.
배경: 왜 빌드가 아니라 후처리인가
IPA 업로드만으로 보호 기능을 주입하려면 빌드 통합이라는 전제를 버려야 한다. 호스트 앱을 다시 빌드할 수 없으니, 이미 서명된 Mach-O를 후처리로 가공하는 수밖에 없다.
그 구조 변경은 LIEF로 처리했다. Mach-O의 세그먼트·섹션을 파싱하고 다시 직렬화해 주는 라이브러리다. 새 데이터 섹션을 추가하고, 다시 서명하면 파이프라인이 닫힌다.
단일 슬라이스(arm64) IPA에서는 이 파이프라인이 잘 돌았다. 문제는 그다음이었다.
증상: 같은 패치인데 슬라이스 하나만 깨진다
Xcode 26의 강화 보안은 통합 바이너리(arm64 + arm64e Fat Binary)를 강제한다. 같은 파이프라인을 그 통합 바이너리에 태우자 깨졌다.
증상이 묘했다. 새 데이터 섹션을 추가하고 LIEF로 save한 뒤 다시 load하면, 문자열을 가리키던 주소들이 정확히 한 페이지(16KB) 크기만큼 통째로 밀려 있었다. 가령 어떤 cstring 참조가 특정 주소를 가리켜야 하는데, 재저장 후엔 정확히 한 페이지 낮은 주소를 가리켰다.
값이 랜덤하게 깨지는 게 아니었다. 페이지 정렬 단위로 일정하게 어긋나는 점이 단서였다. 깨짐의 폭이 16KB로 고정이면, 원인은 16KB 단위로 움직이는 무언가 — 즉 페이지 정렬에 있다.
원인: Fat 슬라이스 offset과 LIEF의 재배치 가정이 충돌한다
Fat Binary의 레이아웃부터 짚자. 맨 앞에 여러 슬라이스의 파일 오프셋과 크기를 적은 헤더가 있고, 그 뒤에 각 아키텍처의 Mach-O가 페이지 정렬되어 이어 붙는다.
문제는 여기서 시작된다. 한 슬라이스에 섹션을 추가하면 그 슬라이스의 크기가 커지고, 뒤따르는 슬라이스의 시작 오프셋도 한 페이지 밀린다.
LIEF는 Mach-O를 다시 쓰면서 세그먼트와 섹션을 자기 가정대로 재배치한다. 단일 바이너리에서는 이 가정이 맞아떨어진다. 파일 = 하나의 Mach-O이므로, 라이브러리가 보는 위치와 실제 위치가 일치하기 때문이다.
하지만 Fat 컨테이너 안의 한 슬라이스만 건드릴 때는 다르다. LIEF가 보는 “파일 내 위치”와 Fat 헤더가 약속한 슬라이스 offset/size가 어긋난다. 페이지 정렬을 다시 맞추는 과정에서 cstring 영역이 한 페이지 밀려 들어가고, 그 영역을 절대 주소로 참조하던 값들이 전부 한 페이지 크기만큼 빗나갔다.
절대 주소를 재계산하지 않고 옛 가정으로 박아 둔 게 화근이었다. 16KB라는 깨짐의 폭은 곧 페이지 정렬의 폭이었다.
해법: LIEF 되는 단계와 직접 rewriting 단계로 이원화
처음엔 LIEF 옵션을 조정해 한 번에 풀려고 했다. 하지만 컨테이너 헤더와 슬라이스 본문을 동시에 일관되게 맞추는 건 라이브러리의 추상화 너머의 일이었다.
그래서 한 단계로 풀려는 걸 포기했다. 통합 바이너리 처리 객체 안에서 phase를 둘로 나눴다.
두 단계의 역할은 이렇게 갈린다.
- LIEF 가능 단계 — 슬라이스 내부 구조 변경 중 LIEF의 재배치 가정이 깨지지 않는 작업은 그대로 라이브러리에 맡긴다. 빠르고 안전하다.
- 비-LIEF(직접 rewriting) 단계 — 슬라이스 경계를 건드리는 작업, 특히 임의 섹션 추가는 LIEF에 맡기지 않는다. Fat 헤더의 슬라이스 offset/size 필드를 직접 다시 쓰고, cstring 같은 영역도 파일에 직접 접근해 손본다.
이 분리 자체가 핵심 판단이었다. 라이브러리가 잘하는 영역과 못 믿을 영역의 경계를 코드로 못 박은 것이다.
하지만 어디서 LIEF를 버리고 직접 쓰기로 넘어갈지의 경계선은, 한 페이지 단위로 어긋나는 증상 패턴을 보고 직접 그었다.
재현: 어긋남을 눈으로 확인하는 법
이 증상은 공개 도구만으로 재현·검증할 수 있다. IPA는 zip이므로 풀어서 앱 번들 안의 실행 바이너리를 꺼낸다.
# IPA 압축 해제 → Payload/<App>.app/<App> 이 실행 바이너리
unzip -o app.ipa -d app_extracted
# Fat 슬라이스 구성과 각 슬라이스의 파일 offset/size 확인
otool -f -h app_extracted/Payload/*.app/*
# 슬라이스별 cstring 섹션 위치
otool -arch arm64e -l app_extracted/Payload/*.app/* | grep -A4 __cstring
otool -f가 찍어 주는 슬라이스 offset을 재저장 전후로 비교하면, 뒤쪽 슬라이스의 offset이 정확히 페이지 크기만큼 움직였는지 바로 보인다. 검증 마지막에 서명을 다시 입히고 무결성을 확인한다.
codesign -f -s "<identity>" app_extracted/Payload/*.app/*
codesign -v --verbose=4 app_extracted/Payload/*.app/*
심화: 원본 참조를 안 깨고 무파괴로 재매핑하기
직접 rewriting으로 넘어가니 더 까다로운 문제가 남았다. 64비트 문자열 참조 값을 상위/하위로 쪼개 다뤄야 했다.
핵심 아이디어는 참조를 통째로 새 값으로 덮어쓰지 않는 것이다. 상위 절반은 베이스로 그대로 유지하고, 하위 절반만 다시 계산한다. 의사코드로 옮기면 이렇다.
# 64비트 cstring 참조를 무파괴로 재매핑
base = high32(ref) # 상위 절반: 그대로 유지
old_off = low32(ref) - old_cstring_start # 하위 절반: 원래 cstring 섹션 기준 offset
new_low = new_section_start + old_off # 옮겨 간 새 섹션 시작점에 더함
new_ref = combine(base, new_low) # 상위·하위 다시 합침
이렇게 하면 원본이 참조하던 논리적 관계는 그대로 둔 채, 실제 가리키는 물리적 위치만 새 영역으로 무파괴 재매핑된다. 페이지가 밀려도 참조가 따라온다.
공격과 방어는 같은 조작의 양면이다
후처리 바이너리 조작은 공격 기법이기도 하다. 그래서 이 글의 결론은 거기서 닫지 않는다.
핵심은 같은 조작을 들어오는 IPA가 아니라 보호하려는 앱에 정밀하게, 그리고 무파괴로 적용할 수 있느냐다. 슬라이스 offset 한 칸이 어긋나면 앱이 죽는다는 건, 거꾸로 정상 무결성에서 한 칸이라도 벗어난 변조는 곧바로 티가 난다는 뜻이기도 하다.
임의 변조가 16KB 페이지 단위의 정렬과 절대 주소 참조를 동시에 일관되게 유지하기는 쉽지 않다. 그 일관성을 지키며 보호 코드를 심는 쪽과, 그걸 깨고 들어오는 쪽의 차이를 이 디버깅 내내 절감했다.
검증은 실제 상용 앱 IPA 여러 건에 적용해 정상 구동까지 확인하는 것으로 닫았다.