Mach-O 바이너리 구조
iOS와 macOS 실행 파일 포맷 Mach-O를 헤더, 로드 커맨드, 세그먼트, 섹션 순서로 뜯어봅니다. 파일과 메모리 배치의 차이(ASLR), Fat Binary, dyld의 로딩 과정을 그림과 otool 출력으로 정리합니다.
iOS와 macOS의 실행 파일은 모두 Mach-O 포맷입니다. 앱 바이너리, 동적 라이브러리(dylib), 프레임워크, 디버그 심볼까지 전부 Mach-O입니다. 이 글은 Mach-O를 헤더, 로드 커맨드, 세그먼트, 섹션 순서로 뜯어보고, 파일이 메모리에 어떻게 올라가는지, dyld가 무엇을 하는지까지 otool과 lipo 출력으로 확인합니다.
어떤 파일이 Mach-O인가?
file 명령이 가장 빠릅니다.
file MyApp
MyApp: Mach-O 64-bit executable arm64
Mach-O 파일은 맨 앞 4바이트인 매직 넘버로 자신을 밝힙니다.
| 매직 | 값 | 의미 |
|---|---|---|
MH_MAGIC_64 | 0xFEEDFACF | 64비트 Mach-O |
MH_MAGIC | 0xFEEDFACE | 32비트 Mach-O |
FAT_MAGIC | 0xCAFEBABE | 여러 아키텍처를 담은 Fat Binary |
요즘 iOS 앱은 전부 64비트라 0xFEEDFACF로 시작합니다. 무엇을 담은 파일인지는 헤더의 filetype이 정합니다. 실행 파일은 MH_EXECUTE(0x2), 동적 라이브러리는 MH_DYLIB(0x6), 디버그 심볼 파일은 MH_DSYM(0xa)입니다. 셋 다 같은 Mach-O 구조를 씁니다.
전체 레이아웃
하나의 Mach-O는 세 부분으로 나뉩니다. 맨 앞 헤더, 그다음 로드 커맨드 목록, 그리고 실제 코드와 데이터를 담은 세그먼트입니다.
로더(dyld)는 헤더와 로드 커맨드만 읽어 나머지를 메모리에 배치합니다. 그래서 이 둘이 Mach-O를 이해하는 열쇠입니다.
헤더에는 무엇이 있나?
헤더는 파일 맨 앞의 고정 크기 구조체입니다. otool -h로 봅니다.
otool -h MyApp
magic cputype filetype ncmds sizeofcmds
0xfeedfacf 16777228 2 22 3312
핵심 필드는 네 개입니다. cputype은 CPU 종류(16777228은 arm64), filetype은 파일 종류(2는 실행 파일), ncmds는 로드 커맨드 개수, sizeofcmds는 로드 커맨드 전체 크기입니다. 로더는 헤더 바로 뒤부터 sizeofcmds 바이트를 읽어 ncmds개의 로드 커맨드를 훑습니다.
로드 커맨드는 무엇인가?
로드 커맨드는 로더에게 내리는 지시 목록입니다. 이 세그먼트를 이 주소에 올려라, 심볼 테이블은 파일 여기 있다, 이 라이브러리를 먼저 로드해라, 진입점은 여기다. 이런 항목이 헤더 뒤에 줄줄이 이어집니다. otool -l로 전부 볼 수 있습니다.
| 로드 커맨드 | 값 | 하는 일 |
|---|---|---|
LC_SEGMENT_64 | 0x19 | 세그먼트 하나를 메모리에 매핑 |
LC_SYMTAB | 0x2 | 심볼/문자열 테이블 위치 |
LC_DYSYMTAB | 0xB | 동적 심볼 테이블 위치 |
LC_DYLD_INFO_ONLY | 0x22 | rebase/bind/export 정보 위치 |
LC_LOAD_DYLIB | 0xC | 의존 동적 라이브러리 로드 |
LC_MAIN | 0x28 | 진입점(main) 오프셋 |
LC_CODE_SIGNATURE | 0x1D | 코드 서명 블롭 위치 |
LC_UUID | 0x1B | 빌드마다 고유한 식별 UUID |
LC_SEGMENT_64는 세그먼트마다 하나씩 있어 그 세그먼트를 어디에 올릴지 정합니다. 나머지 중 위치를 담은 커맨드(심볼 테이블, dyld 정보, 코드 서명)는 전부 __LINKEDIT 세그먼트 안을 가리킵니다. 그래서 바이너리를 고쳐 __LINKEDIT이 밀리면 이 오프셋들도 같이 고쳐야 합니다.
세그먼트와 섹션
세그먼트는 메모리에 올라갈 때의 권한(읽기, 실행, 쓰기) 단위입니다. iOS 실행 파일의 주요 세그먼트는 이렇습니다.
| 세그먼트 | 권한 | 담는 것 |
|---|---|---|
__PAGEZERO | 없음 | 주소 0부터의 접근 금지 영역(널 포인터 참조를 잡는 덫) |
__TEXT | 읽기+실행 | 기계어 코드와 읽기 전용 상수 |
__DATA_CONST | 읽기 | 초기화 후 바뀌지 않는 데이터(포인터 등) |
__DATA | 읽기+쓰기 | 실행 중 값이 바뀌는 데이터 |
__LINKEDIT | 읽기 | 심볼/문자열 테이블, dyld 정보, 코드 서명 |
권한이 갈리는 데는 이유가 있습니다. 코드가 담긴 __TEXT는 실행은 되지만 쓸 수 없어서(읽기+실행) 실행 중 코드를 덮어쓰는 공격이 막힙니다. 반대로 __DATA는 쓸 수 있지만 실행할 수 없습니다. 하나의 페이지가 쓰기와 실행을 동시에 갖지 못하게 하는 것이 현대 OS 보호의 기본입니다.
세그먼트 안은 다시 섹션으로 나뉩니다. __TEXT의 __text(기계어), __cstring(C 문자열 상수), __DATA의 __data(초기화된 전역 변수), __bss(0으로 초기화될 변수) 등입니다. __bss 같은 섹션은 파일에는 바이트가 없고 메모리에서만 자리를 잡습니다. 런타임에 0으로 채워질 공간만 예약해 두기 때문에 파일 크기를 아낍니다.
파일과 메모리는 다르게 놓인다
Mach-O를 이해할 때 가장 헷갈리는 지점입니다. 섹션 하나에는 위치가 두 개 있습니다. 파일 안 위치인 offset과 메모리 주소인 addr입니다. otool -l에서 나란히 보입니다.
otool -l MyApp | grep -A4 sectname
sectname __text
segname __TEXT
addr 0x0000000100005000
offset 20480
파일의 offset은 앞 섹션들의 크기를 더한 값이고, 메모리의 addr은 앱이 올라갈 기준 주소에 그만큼을 더한 값입니다. 게다가 iOS는 보안을 위해 실행할 때마다 앱을 임의의 주소로 올립니다. 이것이 ASLR이고, 매번 더해지는 임의의 값을 슬라이드라고 부릅니다.
주소가 매번 바뀌기 때문에 코드는 데이터를 고정된 절대 주소로 가리킬 수 없습니다. 대신 지금 실행 중인 명령 위치에서 얼마 떨어졌는지를 계산하는 상대 주소 방식을 씁니다. 바이너리를 직접 고쳐 무언가의 위치를 옮기면 이 참조들이 어긋나는데, 그 문제를 푸는 법은 Mach-O에 커스텀 섹션 추가하기에서 다룹니다.
Fat Binary: 한 파일에 여러 아키텍처
하나의 파일에 여러 CPU 아키텍처의 Mach-O를 함께 담을 수 있습니다. 이를 Fat Binary(유니버설 바이너리)라 하고, 각 아키텍처 몫을 슬라이스라고 부릅니다. 맨 앞의 Fat 헤더가 각 슬라이스가 파일 어디에서 시작하고 크기가 얼마인지를 가리킵니다.
lipo -info로 슬라이스 구성을 봅니다.
lipo -info MyApp
Architectures in the fat file: MyApp are: arm64 arm64e
arm64e는 포인터에 서명을 붙여 위변조를 막는 Pointer Authentication(PAC)을 쓰는 아키텍처입니다. 기기와 빌드 설정이 맞으면 앱이 arm64와 arm64e 두 슬라이스를 담아 이런 Fat Binary가 됩니다. 설치할 때는 기기에 맞는 슬라이스 하나만 골라 씁니다.
앱이 실행될 때 dyld가 하는 일
지금까지 본 조각들이 실행 순간 어떻게 맞물리는지 정리하면 이렇습니다.
- 커널이 실행 파일을 열어 헤더의 매직과 아키텍처를 확인하고,
arm64e를 지원하면LC_CODE_SIGNATURE로 서명을 검증합니다. - 동적 링커 dyld가 로드 커맨드를 훑어
LC_SEGMENT_64대로 세그먼트를 메모리에 매핑합니다. 이때 슬라이드가 더해집니다. LC_LOAD_DYLIB에 적힌 의존 라이브러리들을 같은 방식으로 올립니다.LC_DYLD_INFO_ONLY의 정보로 포인터를 슬라이드에 맞게 보정(rebase)하고, 다른 라이브러리의 심볼과 연결(bind)합니다.- 초기화 함수(생성자)를 실행한 뒤
LC_MAIN이 가리키는 진입점으로 점프합니다.
로드 커맨드가 지도이고 세그먼트가 실제 내용이라는 구조가, 이 다섯 단계를 관통합니다.
직접 확인하는 도구
| 명령 | 용도 |
|---|---|
file | Mach-O인지, 몇 비트인지, 어떤 아키텍처인지 |
otool -h | 헤더(매직/타입/커맨드 수) |
otool -l | 로드 커맨드 전체(세그먼트/섹션/오프셋) |
otool -L | 의존하는 동적 라이브러리 목록 |
lipo -info | Fat Binary의 슬라이스 구성 |
nm | 심볼 목록 |
dyld_info | rebase/bind/export 등 dyld 정보 |
포맷의 구조체 원형은 Apple의 <mach-o/loader.h> 헤더에 선언돼 있고, LIEF의 Mach-O 문서에도 정리돼 있습니다.