본문 바로가기

← 블로그

iOS/바이너리

Mach-O 바이너리에 커스텀 섹션 추가하기

이미 빌드되고 서명된 iOS 앱에 소스코드 없이 커스텀 섹션을 추가하는 방법. LIEF로 그냥 넣으면 왜 앱이 죽는지, __LINKEDIT 앞에 넣어 참조를 지키는 방법, Fat Binary까지 다룹니다.

이 글에서는 이미 빌드와 서명까지 마친 iOS 앱에 커스텀 섹션을 추가해 데이터를 넣는 방법을 다룹니다. 앱스토어에 배포되는 형태의 서명된 앱에도 그대로 적용됩니다.

여기서는 소스코드나 빌드 환경 없이 완성된 바이너리 하나만 가지고 작업합니다. 그래서 바이너리를 직접 뜯어 섹션을 넣습니다.

전체 흐름은 이렇습니다. IPA를 풀어 실행 바이너리를 꺼내고, Mach-O 구조를 파악한 뒤, 섹션을 추가하고, 그 과정에서 깨지는 참조를 바로잡고, 마지막에 다시 서명합니다.

들어가기 전에

이 글은 IPA와 Mach-O의 기본 구조를 안다고 가정합니다. 낯설면 IPA 파일이란?Mach-O 바이너리 구조를 먼저 읽어 보세요. 여기서는 이 글에 꼭 필요한 두 가지만 짚습니다.

첫째, 섹션의 파일 오프셋은 앞 섹션들의 크기를 모두 더한 값입니다. 그래서 중간에 내용을 끼우면 그 뒤 섹션의 오프셋이 전부 밀립니다. 뒤로 밀린 내용을 가리키던 값들이 이 밀림을 따라오지 못하면 앱이 깨지는데, 그 이야기가 이 글의 본론입니다.

둘째, 바이너리를 직접 뜯어 고치려면 Mach-O를 파싱하고 다시 저장하는 도구가 필요합니다. 이 글에서는 LIEF로 섹션을 추가합니다. ELF, PE, Mach-O를 하나의 API로 다루는 크로스포맷 라이브러리로, 바이너리 조작에 널리 쓰입니다.

섹션 넣기와 참조 맞추기

소스코드가 있으면 뒤에 이어지는 과정은 대부분 필요 없습니다. 먼저 그 경우를 짚은 다음, 소스 없이 바이너리를 직접 고치는 본론으로 들어갑니다. 아키텍처가 하나뿐인 바이너리(arm64 하나로 빌드된 흔한 경우)부터 보고, 아키텍처가 여러 개인 Fat Binary는 뒤에서 따로 다룹니다.

소스코드가 있다면?


소스코드가 있으면 섹션 추가는 컴파일러에게 맡기면 됩니다. 변수에 __attribute__((section("__DATA,__mysection")))를 붙이면 그 값이 지정한 섹션에 담깁니다. 빌드하면 링커가 이 섹션을 배치하고, 그에 맞춰 다른 섹션의 오프셋과 코드의 참조를 전부 다시 계산합니다. 어긋나는 값이 남지 않으니 따로 손볼 것이 없습니다.

이 글이 하는 일은 결국 링커의 이 작업을 소스 없이 손으로 재현하는 것입니다. 완성된 앱에는 링커를 다시 돌릴 소스도 빌드 환경도 없기 때문입니다.

LIEF로 그냥 넣으면?


가장 단순한 방법은 LIEF의 add_section을 그대로 부르는 것입니다.

import lief

binary = lief.MachO.parse("MyApp").at(0)
section = lief.MachO.Section("__mysection", [0x41] * 0x4000)
binary.add_section(section)
binary.write("MyApp.patched")

한 줄로 섹션이 들어가지만, 이렇게 만든 앱은 실행하자마자 죽는 일이 많습니다. 섹션만 넘기는 이 형태는 새 섹션을 __TEXT에 넣어서, 그 뒤의 코드가 통째로 뒤로 밀립니다. LIEF는 밀린 배치에 맞춰 Mach-O를 다시 쓰는데, 로드 커맨드의 오프셋은 이 과정에서 갱신되지만 바이너리 안에 값으로 박혀 있는 위치들은 갱신되지 않습니다.

가장 잡아내기 쉬운 예가 생성자입니다. __attribute__((constructor))가 붙은 함수는 __TEXT,__init_offsets 섹션에 이미지 시작점 기준 오프셋으로 기록됩니다. add_section__TEXT를 밀면 생성자 코드는 뒤로 옮겨가는데, 이 오프셋 값은 옛 위치를 그대로 가리킵니다. 앱을 켜면 dyld가 엉뚱한 자리를 생성자로 알고 뛰어들어 그대로 죽습니다.

원본 LIEF 재배치 후 __init_offsets 값 = 0x4b0 가리킴 생성자 코드 0x4b0 __init_offsets 값 = 0x4b0 (그대로) 새 섹션 (삽입) 잘못 점프 → 크래시 생성자 코드 0x44b0 (밀림)

이건 LIEF만의 결함이라기보다, 위치를 값으로 박아 둔 것과 재배치가 만나면 반드시 생기는 문제입니다. 같은 증상이 LIEF 레포에도 이슈로 올라와 있습니다(LIEF #1229). 그런데 이렇게 어긋나는 값은 생성자 오프셋 하나가 아닙니다. 문자열을 가리키는 참조, 각종 절대 위치가 전부 같은 이유로 틀어집니다. 그래서 접근을 바꿉니다. 밀어야 깨지니, 처음부터 밀지 않는 자리에 넣습니다.

어디에 넣느냐가 먼저다


Mach-O의 세그먼트는 대체로 코드가 든 __TEXT, 데이터가 든 __DATA 계열, 그리고 맨 끝의 __LINKEDIT 순서로 놓입니다. __LINKEDIT은 심볼 테이블, 문자열 테이블, dyld 바인딩 정보, 코드 서명처럼 실행에 필요한 메타데이터가 모이는 세그먼트입니다.

새 섹션은 __LINKEDIT 바로 앞 세그먼트의 꼬리에 넣습니다. 그러면 뒤로 밀리는 것은 __LINKEDIT 하나뿐입니다. 코드도, 코드가 절대 주소로 가리키던 문자열이나 생성자 오프셋도 전혀 움직이지 않습니다.

원본 새 섹션 추가 후 __TEXT (코드) __DATA (대상) __LINKEDIT __TEXT (코드) 안 움직임 __DATA + 새 섹션 꼬리에 삽입 __LINKEDIT 이것만 밀림 오프셋만 보정

밀린 __LINKEDIT을 고치는 일은 코드에 박힌 절대 주소를 쫓는 것과 성격이 다릅니다. __LINKEDIT의 위치는 로드 커맨드의 오프셋 필드로만 참조됩니다. 이 필드들은 헤더의 정해진 자리에 있어서, 밀린 만큼 값을 더해 주면 그대로 맞습니다. 고칠 수 있는 참조만 남기는 배치입니다.

파일에 없는 섹션은?


배치를 정했으면 크기를 맞출 차례입니다. 그 전에 걸리는 것이 하나 있습니다. 세그먼트 안에는 파일에는 바이트가 없고 메모리에서만 자리를 잡는 섹션이 있습니다. __bss__common 같은 종류로, 런타임에 0으로 채워질 공간만 예약해 둡니다. 이런 섹션은 파일 오프셋이 0이고 가상 주소만 있습니다.

이 섹션들 때문에 세그먼트의 파일 크기가 메모리 크기보다 작습니다. 이 상태에서 새 섹션을 파일에 그냥 이어 붙이면, 새 섹션의 파일 오프셋과 가상 주소의 관계가 어긋나 로더가 기대하는 정렬이 깨집니다. 역시 앱이 죽습니다.

그래서 새 섹션을 넣기 전에, 이런 섹션들의 크기 합만큼 파일을 먼저 채웁니다. 파일 크기를 메모리 크기에 맞춰 둔 다음 실제 섹션을 넣으면, 파일 오프셋과 가상 주소가 다시 나란해집니다.

LIEF는 등록만, 나머지는 손으로


이제 실제로 넣습니다. 전체 순서는 여섯 단계이고, LIEF는 그중 한 단계, 로드 커맨드에 섹션 항목을 등록하는 데만 씁니다.

  1. 손대기 전에 원본 상태를 통째로 백업합니다. 헤더 커맨드 영역, 각 세그먼트 커맨드의 바이트, 모든 섹션의 파일 오프셋과 가상 주소를 그대로 떠 둡니다. 뒤에서 되돌릴 기준입니다.
  2. 밀림에 영향받을 참조를 미리 추립니다. 로드 커맨드 중 __LINKEDIT 뒤를 가리키는 오프셋을 가진 것들을 목록으로 잡아 둡니다. 나중에 이 목록만 보정합니다.
  3. LIEF로 섹션 항목만 만듭니다. add_section에 대상 세그먼트를 지정해 부르면 로드 커맨드에 새 섹션 항목이 생깁니다. LIEF는 이때 섹션 오프셋을 자기 방식으로 다시 계산하지만, 필요한 것은 항목이 생겼다는 사실뿐입니다.
  4. LIEF가 바꾼 배치를 원복합니다. 1번 백업으로 기존 섹션의 오프셋과 가상 주소를 되돌립니다. 새 항목만 남고 나머지 배치는 원본과 같아집니다. 새 섹션은 대상 세그먼트 꼬리에 자리 잡고, 크기는 페이지 단위(16KB의 배수)로 맞춥니다.
  5. 파일에 공간을 물리적으로 만듭니다. 새 섹션 자리부터 파일 끝까지를 읽어 두고, 그 자리에 섹션 크기만큼 0을 채운 뒤, 읽어 둔 내용을 그만큼 뒤로 밀어 다시 씁니다. 이 삽입으로 __LINKEDIT이 통째로 밀립니다.
  6. 밀린 참조와 세그먼트를 보정합니다. 2번에서 추린 로드 커맨드 오프셋에 삽입한 크기를 더하고, 새 섹션이 든 세그먼트의 크기를 늘리고, 그 뒤 __LINKEDIT 세그먼트의 시작 위치를 밀린 만큼 옮깁니다. 세그먼트 커맨드는 파일 안 정해진 자리라 바이트 단위로 직접 씁니다.

마지막 단계에서 보정하는 로드 커맨드는 __LINKEDIT에 데이터를 두는 것들입니다.

로드 커맨드안에 든 오프셋가리키는 대상
LC_SYMTABsymoff, stroff심볼 테이블, 문자열 테이블
LC_DYSYMTABindirectsymoff간접 심볼 테이블
LC_DYLD_INFOrebase/bind/export offdyld 바인딩/익스포트 정보
LC_FUNCTION_STARTSdataoff함수 시작 표
LC_CODE_SIGNATUREdataoff코드 서명

이 오프셋들은 전부 파일에서 __LINKEDIT이 시작하는 지점 뒤를 가리킵니다. __LINKEDIT이 한 덩어리로 밀렸으니 각 오프셋에 같은 값을 더하면 다시 맞습니다. 값이 박힌 자리가 헤더 안이라 하나씩 짚어 고칠 수 있다는 것이, 앞의 배치가 노린 바입니다.

Fat Binary

이제 아키텍처가 여러 개인 경우입니다. 둘 이상을 한 파일에 담은 것이 Fat Binary이고, 여기서는 컨테이너 때문에 손볼 곳이 하나 더 생깁니다.

슬라이스란?


Fat Binary(유니버설 바이너리)는 여러 아키텍처의 코드를 한 파일에 담은 형식입니다. 한 파일에 든 각 아키텍처 몫의 Mach-O를 슬라이스라고 부릅니다. 새로운 개념은 아닙니다. iOS도 예전에는 armv7과 arm64를 함께 담아 배포했고(32비트 지원을 끊으면서 arm64 하나만 남았습니다), macOS는 애플 실리콘 전환 이후 x86_64와 arm64를 담은 유니버설 바이너리를 씁니다.

iOS에서 Fat Binary가 다시 등장하는 대표적인 경우가 Xcode 26의 Enhanced Security입니다. 이 옵션을 켜면 Pointer Authentication(PAC)이 적용되는데, PAC는 arm64e에서만 동작하므로 앱이 arm64와 arm64e 두 슬라이스를 담은 Fat Binary로 빌드됩니다. lipo -info로 슬라이스 구성을 확인할 수 있습니다.

lipo -info MyApp/Payload/MyApp.app/MyApp
Architectures in the fat file: MyApp are: arm64 arm64e

슬라이스가 둘 이상이면 파일 맨 앞에 Fat 헤더가 붙어 각 슬라이스의 위치를 가리킵니다. otool -f로 그 위치를 볼 수 있습니다.

otool -f MyApp/Payload/MyApp.app/MyApp
architecture 0        # arm64
    offset 16384
    size   9200000
architecture 1        # arm64e
    offset 9216384
    size   9400000

슬라이스 하나에 앞의 방법을 그대로 적용하면 그 슬라이스 안은 맞습니다. 문제는 컨테이너입니다. 슬라이스가 새 섹션만큼(페이지 단위라 16KB의 배수) 커지면, 그 뒤에 오는 슬라이스의 시작 위치가 전부 그만큼 밀립니다. Fat 헤더에 적힌 슬라이스 오프셋이 옛 위치를 가리키면 로더는 뒤 슬라이스를 엉뚱한 지점에서 읽습니다.

변경 전 섹션 추가 후 Fat 헤더 슬라이스 arm64 슬라이스 arm64e offset = X Fat 헤더 슬라이스 arm64 + 새 섹션 size += 16KB 슬라이스 arm64e offset = X + 16KB

Fat 헤더의 오프셋도 고친다


슬라이스 하나를 고쳤으면 Fat 헤더로 올라갑니다. Fat 헤더에는 각 슬라이스가 파일 어디에서 시작하고 크기가 얼마인지가 적혀 있습니다. 방금 키운 슬라이스는 크기를 늘리고, 그 뒤 슬라이스들의 시작 오프셋에는 늘어난 만큼을 더합니다. 늘어난 폭은 새 섹션 크기이고 페이지 단위라 16KB의 배수입니다. 컨테이너 쪽 오프셋까지 맞춰야 뒤 슬라이스가 제자리에서 읽힙니다.

어디까지 직접 고쳐야 하나?


작업은 두 층으로 나뉩니다.

  • LIEF에 맡기는 부분: 슬라이스 하나를 파싱해 새 섹션 항목을 로드 커맨드에 등록하는 일입니다. 포맷을 아는 도구가 하는 편이 안전합니다.
  • 직접 쓰는 부분: 슬라이스 경계를 넘거나 정확한 파일 위치에 값을 써야 하는 일입니다. Fat 헤더의 슬라이스 오프셋, 슬라이스 안의 __LINKEDIT 오프셋, 세그먼트의 크기와 위치를 파일에서 바이트 단위로 고칩니다. 슬라이스 안의 오프셋은 슬라이스 시작점 기준으로 계산해, 파일에서는 슬라이스 시작 위치를 더한 자리에 씁니다.

경계는 단순합니다. 포맷 파서의 추상화 안에서 끝나는 일은 LIEF에 맡기고, 컨테이너와 파일 오프셋을 직접 만져야 하는 일은 손으로 합니다.

검증과 활용

수정한 뒤의 확인과, 이 기법의 실제 쓰임입니다.

고친 뒤 어떻게 검증하나?


바이너리를 고친 뒤에는 두 가지를 확인합니다. 먼저 슬라이스 오프셋이 의도대로만 움직였는지 봅니다.

otool -f MyApp/Payload/MyApp.app/MyApp

저장 전후의 슬라이스 offset을 비교해, 뒤 슬라이스가 페이지 크기만큼만 이동했는지, __LINKEDIT 오프셋이 그에 맞게 보정됐는지 확인합니다. 마지막에는 기기에 올려 앱이 실행되고 생성자까지 정상 동작하는지를 봅니다.

바이너리를 수정하면 기존 서명은 무효가 됩니다. iOS는 서명이 유효하지 않은 앱을 실행하지 않으므로, 마지막에 다시 서명하고 검증합니다.

codesign -f -s "<identity>" MyApp/Payload/MyApp.app/MyApp
codesign -v --verbose=4 MyApp/Payload/MyApp.app/MyApp

서명이 통과하면 이 앱을 다시 IPA로 묶어 기기에 설치할 수 있습니다.

커스텀 섹션으로 뭘 할 수 있나?


섹션 추가가 진짜 쓸모 있는 건 빌드에 손댈 수 없을 때입니다. 소스코드나 빌드 환경이 없는 완성된 앱에, 새 데이터를 바이너리 안에 직접 넣을 수 있기 때문입니다.

  • 빌드 없는 보안 후처리: 무결성 검증에 쓸 기준값이나 암호화한 문자열 테이블처럼, 보호에 필요한 데이터를 앱을 다시 빌드하지 않고 심습니다. 이 기법이 실제로 가장 많이 쓰이는 곳입니다.
  • 바이너리 내부 리소스 추가: 리소스를 별도 파일이 아니라 바이너리 안에 둬야 할 때 씁니다. 코드 서명이 그 데이터까지 함께 덮어야 하거나, 런타임이 섹션을 직접 읽어야 하는 경우입니다.
  • 코드 실행: 여기에 load command를 더하면 앱이 실행될 때 특정 코드를 먼저 로드하게 만들 수도 있습니다. 이건 배포된 iOS 앱에 프레임워크 주입하기에서 다룹니다.

반대로 단순한 설정값이나 메타데이터 정도라면 굳이 섹션까지 추가할 필요가 없습니다. Info.plist나 리소스 파일로 충분합니다. 섹션 조작은 ‘바이너리 안에 있어야만 하는’ 데이터에 씁니다.

정리


섹션을 추가할 때 핵심은 코드를 밀지 않는 것입니다. __LINKEDIT 바로 앞에 넣어 밀리는 대상을 __LINKEDIT 하나로 좁히고, LIEF로는 섹션 항목만 등록한 뒤, __LINKEDIT 오프셋과 세그먼트 크기를 손으로 보정합니다. Fat Binary에서는 여기에 Fat 헤더의 슬라이스 오프셋 보정이 더해집니다. 넣는 데이터가 무엇이든 절차 자체는 같습니다.