Mach-O 執行檔案格式
macOS/iOS 的 Mach-O 執行檔、dylib 或 bundle。
- MIME 類型
- application/x-mach-binary
- 副檔名
- .dylib, .bundle, .o
- 分類
- 執行檔與位元碼
Magic number(檔案簽章)
| 0x00000000 | CFFAEDFE | ···· | Mach-O 64-bit LE magic |
關於 Mach-O 執行檔格式
Mach-O 是 macOS 與 iOS 的原生二進位格式,而且和 ELF 不同,它完全不用固定大小的表:七或八個欄位的標頭之後,接的是一串長度不一的 load command,每一個自己宣告型別與長度,載入器按順序走過去。這種設計讓同一個檔案能同時帶著 segment 映射、動態連結器路徑、最低系統版本、進入點與程式碼簽章,而它們都不需要各自保留一個固定欄位。magic 本身兼作位元組序與位元寬度的標記:CF FA ED FE 是 64 位元小端序映像、CE FA ED FE 是 32 位元,位元組反過來的那兩種則屬於大端序的 PowerPC 年代。
檔頭結構,一個位元組一個位元組看
| 位移 | 長度 | 欄位 | 意義 |
|---|---|---|---|
| 0 | 4 | magic | 64 位元小端序映像在磁碟上是 CF FA ED FE——以小端序讀出來就是 0xFEEDFACF。CE FA ED FE 是 32 位元形式(0xFEEDFACE),FE ED FA CF 與 FE ED FA CE 則是 PowerPC 執行檔帶的大端序變體。所以 4 個位元組同時告訴你格式、字組寬度與位元組序。 |
| 4 | 4 | cputype | x86-64 是 0x01000007,arm64 是 0x0100000C:最高位元 0x01000000 代表 64 位元,低位元是基礎架構(7 是 x86、12 是 ARM)。同一架構的 32 位元建置不帶那個旗標,所以單獨的 7 是 i386、單獨的 12 是 32 位元 ARM。 |
| 8 | 4 | cpusubtype | 架構內的變體,最高幾個位元拿來當能力旗標。Apple silicon 的執行檔這裡常常是 0x80000002——子型別 2、arm64e、帶著最高旗標——分辨 arm64e 系統執行檔與一般 arm64 應用程式,靠的就是這個欄位。 |
| 12 | 4 | filetype | 1 是 MH_OBJECT(.o)、2 是 MH_EXECUTE、6 是 MH_DYLIB、8 是 MH_BUNDLE,另外還有給除錯資訊用的 MH_DSYM。你手上的東西到底是程式、程式庫還是可載入外掛,位元組層級的答案就在這裡,因為實務上 .dylib、.so 和完全沒有副檔名的檔案都存在。 |
| 16 | 4 | ncmds | 標頭後面有幾個 load command。一支陽春的命令列工具大約十五到二十個;大型 framework 多得多,而且因為沒有索引,每一個都得依序走過去。 |
| 20 | 4 | sizeofcmds | 所有 load command 加起來佔的位元組數。逐一走過每個 command 自己的 cmdsize 並加總,結果必須剛好落在這個數字上——這是這個格式提供的最強單一完整性檢查,也是懷疑檔案被編輯過時第一個該跑的。 |
| 24 | 4 | flags | 屬性位元:映像是不是 PIE、有沒有使用兩層命名空間查找、有沒有未定義符號。典型的現代執行檔這裡大概是 0x00200085 之類的值。 |
| 28 | 4 | reserved | 只有 64 位元標頭才有,而且永遠是 0。它存在的目的純粹是把後面的東西對齊到 8 個位元組,所以 64 位元檔案的第一個 load command 落在位移 32,32 位元檔案落在 28。 |
| 32 | variable | First load command | 每個 load command 都以 4 位元組的 cmd 和 4 位元組的 cmdsize 開頭,所以讀取端可以跳過看不懂的那些。通常第一個是 LC_SEGMENT_64(0x19),而分類時真正要看的是 LC_LOAD_DYLIB、LC_MAIN、LC_CODE_SIGNATURE 與 LC_BUILD_VERSION。 |
怎麼跟長得像的格式分開
| 容易搞混的 | 怎麼分 |
|---|---|
| Mach-O(universal/fat) | universal binary 不是「多了幾個欄位的 Mach-O」——它是一個外殼,裡面裝著好幾份完整的 Mach-O 映像。它開頭是 CA FE BA BE(大端序)而不是 CF FA ED FE,單架構執行檔的 magic 要往後、到每個 fat_arch 項目指名的位移才會出現。把 fat 檔案當成 thin 讀,第一個 cputype 就會是一堆胡說八道。 |
| Java class 檔 | 那個著名的撞號,但只撞 fat 形式:Java class 檔開頭也是 CA FE BA BE。接下來 4 個位元組就能結案——class 檔的 major_version(位元組 6-7)是 45 以上,而 fat binary 那個位置是 nfat_arch,一個小數字,所以高位那半是 0。 |
| ELF 執行檔 | 兩者的目的檔都用 .o,共享程式庫也都用不帶前綴的名字,所以副檔名幫不上忙;Linux 上建的 .so 和 macOS 上建的 .dylib 在目錄清單裡長得一模一樣。位移 0 決定一切:7F 45 4C 46 是 ELF,CF FA ED FE 是 Mach-O。要注意 Mach-O 的 magic 在磁碟上是位元組反過來存的,所以 hex dump 讀到 CF FA ED FE,文件上寫的卻是 0xFEEDFACF。 |
| Big-endian (CIGAM) Mach-O | PowerPC 年代的檔案把同樣的 magic 位元組交換後存放:FE ED FA CE 與 FE ED FA CF。此時標頭與每個 load command 裡的每個欄位也都是大端序,所以只認得小端序 magic 的解析器不會讀錯這些檔案——它會正確地拒收。 |
常見的壞法
- 在 Apple silicon 上,任何就地編輯都算。arm64 程式碼即使只是 ad-hoc 簽章也是簽過的,所以在 text segment 裡改一個位元組,就讓 LC_CODE_SIGNATURE 裡的雜湊失效,核心會在啟動時直接把行程殺掉,而不是執行改過的程式碼。要修補 Mach-O 就得重新簽章;這裡沒有「在 ELF 裡默默改個位元組」的對應做法。
- 被截斷。load command 排在檔案前段、解析得毫無問題,所以下載一半的檔案照樣報出正確的架構與檔案型別,只是截斷點之後的 segment 資料不見了。檢查方式是走過所有 load command,把每個 segment 的 fileoff 加 filesize 和實際檔案長度比一比。
- sizeofcmds 和實際 command 對不上了。插入或刪掉一個 load command 卻沒有修正 ncmds 和 sizeofcmds,就會留下一個看起來很合理的標頭,然後走訪一路衝進 segment 資料——工具之所以報出某個荒謬的 load command 型別而不是一句錯誤訊息,原因就在這裡。
- 從 fat binary 裡切出來的 slice 長度不對。用 fat 標頭的位移、卻配上別人的大小把某一片複製出來,會得到一個 Mach-O 標頭完美、尾巴缺一截的檔案;不用 lipo 而手動切架構,通常就是這個下場。
- 文字模式傳輸或亂入的 BOM。任何二進位檔都一樣:換行轉換讓第一個 0D 0A 之後的東西全部位移,而前面被塞了 BOM 則會把 magic 擠出位移 0,此時即使每個位元組的程式碼都還在,檔案也認不出來了。
這個格式現在還在用嗎
每一個 macOS 與 iOS 的執行檔、framework、dylib、核心擴充與 .o 檔都是 Mach-O,而且它是 Apple 載入器唯一接受的格式,所以從來沒有東西取代得了它。它是經由 Mach 從 NeXTSTEP 繼承來的,這也是為什麼同樣的概念,它的詞彙——load command、segment 與 section、dyld——和 ELF 不一樣。實務上今天出貨的 Mach-O 檔案多半是 app bundle 裡的單架構 arm64 映像;universal 形式則是開發者在過渡期建置的東西,以及 Homebrew 與 Rosetta 年代的軟體到現在還在出的東西。對逐位元組檢視的人來說,Apple 程式碼簽章規則帶來的重要結論是:Mach-O 實際上是唯讀的——你要怎麼解剖它都行,但改過的副本在重新簽章之前不會啟動。
在這個站上打開 Mach-O 執行檔
這些全部在你的瀏覽器裡跑,檔案不會離開你的電腦。
相關格式
容易和 Mach-O 執行檔搞混的格式,可以比對它們的開頭位元組。
| 格式 | 開頭位元組 | 副檔名 |
|---|---|---|
| Mach-O(universal/fat) | CA FE BA BE | .dylib |
| ELF 執行檔 | 7F 45 4C 46 | .elf |
| PE(Windows 執行檔) | 4D 5A | .exe |