Suzu macOS Playground

M02 · 階梯 1 · L1

Mach-O 格式與 dyld 載入器

Mach-O header / load commands / segments / sections、fat binary、LINKEDIT、chained fixups、arm64 vs arm64e,以及 dyld 如何把映像載入虛擬位址空間。

● 前置:M01 otool · dyld_info · nm · size · codesign

被信任鏈驗過的「映像」長什麼樣

M01 看的是開機鏈「每級驗下級」。被驗的那個映像——核心、dylib、你的 App 執行檔——在 macOS 上幾乎都是 Mach-O 格式。看懂 Mach-O,後面 M03(簽章)、M07(sandbox)、M12(逆向)、M13(漏洞模型)才有立足點:簽章蓋在 Mach-O 的某個 segment 上、注入面藏在 load command 裡、r-x 對 rw- 的邊界就是記憶體破壞攻防的戰場。

一個 Mach-O 檔案就三層:

mach_header  →  load commands(一串「指令」)  →  raw data(segments 的實際內容)

header 說「我是誰」(架構、檔案類型、有幾條 load command);load commands 是核心——它們告訴 dyld:把哪段檔案映射到哪個虛擬位址、用什麼權限、連哪些 dylib、進入點在哪、簽章在哪。下面的解剖器載入一個本機 clang 編譯的真實 arm64 樣本,三個視圖連動,點任一結構看它對應的原始位元組:

Mach-O 解剖器

模型邊界:本工具真解析 Mach-O 結構(對照本機 otool 驗證), 但不做 CMS 簽章驗證、不算 CodeDirectory CDHash(M03)。上傳的檔案全程留在你的瀏覽器,不會上傳。僅在自有/獲授權的樣本上練習(DESIGN.md §10)。

試試看:點 mach_header_64 的 flags 欄位,看哪 4 個 bytes 被高亮;再點 __TEXT segment,注意它的保護是 r-x。最後看底部 LC_CODE_SIGNATURE → 那是 M03 的入口。

mach_header:前 32 bytes 決定一切

64-bit Mach-O 的標頭是固定 32 bytes。以樣本為例,前 32 bytes 是:

cf fa ed fe  0c 00 00 01  00 00 00 00  02 00 00 00
11 00 00 00  20 04 00 00  85 00 20 00  00 00 00 00

逐欄位解(little-endian):

欄位bytes值意義
magiccf fa ed fe0xfeedfacfMH_MAGIC_64(64-bit、host little-endian)
cputype0c 00 00 010x0100000cCPU_TYPE_ARM64(0x01000000 ABI64 ∣ 12)
cpusubtype00 00 00 000ARM64_ALL,caps=0x00
filetype02 00 00 002MH_EXECUTE
ncmds11 00 00 001717 條 load command
sizeofcmds20 04 00 000x420load commands 共 1056 bytes
flags85 00 20 000x200085NOUNDEFS ∣ DYLDLINK ∣ TWOLEVEL ∣ PIE

PIE(Position-Independent Executable)那一位代表載入時可被 ASLR 隨機平移——這是後面 M13 講緩解機制時的起點。

arm64 vs arm64e(別搞混)

cputype 同樣是 arm64,但 cpusubtype 才區分 arm64 與 arm64e:

  • arm64:cpusubtype = ARM64_ALL(0),caps 0x00(本樣本)。
  • arm64e:cpusubtype = ARM64E(2),且高位 caps 帶 PTRAUTH_ABI(versioned PAC)。arm64e 的指標被 PAC(Pointer Authentication Code)簽章——這是 M13 的硬體緩解主題。第三方 App 預設仍是 arm64;arm64e 目前主要用於平台二進位與特定情境。解剖器會在標頭把 arm64e 標出來。

load commands:dyld 的施工圖

樣本的 17 條 load command 大致分三類:

  1. 記憶體佈局:LC_SEGMENT_64(__PAGEZERO / __TEXT / __DATA_CONST / __LINKEDIT),每個 segment 再帶 sections。
  2. 連結與載入:LC_LOAD_DYLINKER(/usr/lib/dyld)、LC_LOAD_DYLIB(/usr/lib/libSystem.B.dylib)、LC_MAIN(entryoff 進入點)、LC_DYLD_CHAINED_FIXUPS、LC_DYLD_EXPORTS_TRIE、LC_SYMTAB/LC_DYSYMTAB。
  3. 中介資料:LC_UUID、LC_BUILD_VERSION(platform/minos/sdk)、LC_FUNCTION_STARTS、LC_DATA_IN_CODE、LC_CODE_SIGNATURE。

segments 與 W^X:__TEXT 為何是 r-x

解剖器底部的保護條是本章重點。每個 segment 有 initprot(初始 r/w/x):

segment保護為什麼
__PAGEZERO---vmsize 0x100000000,無任何權限 → NULL 解參考立刻 fault
__TEXTr-x程式碼 + 唯讀常數:可執行、不可寫
__DATA_CONSTrw-指標表:載入期可寫,填完 fixups 後被 dyld remap 成唯讀
__LINKEDITr--符號表 / fixups / 簽章原料:唯讀

沒有任何一個 segment 同時 w 和 x,這就是 W^X(Write XOR eXecute):攻擊者若想注入並執行 shellcode,得先想辦法繞過「能寫的不能執行、能執行的不能寫」。這條邊界貫穿後面整個漏洞模型章。

__DATA_CONST 的細節:它的 initprot 靜態是 rw-(otool 看到的就是這個),但該 segment 帶 SG_READ_ONLY (0x10) 旗標——dyld 套完 chained fixups、把 GOT/指標表填好後,會 vm_protect 把它降成 r--。執行期它其實是唯讀。這正是 __DATA_CONST 之所以存在(而非全丟進 __DATA)的理由:把「啟動後就不該再變」的指標保護起來,縮小可被竄改的攻擊面。

chained fixups(現代 dyld)

樣本帶的是 LC_DYLD_CHAINED_FIXUPS,不是舊式 LC_DYLD_INFO_ONLY 的 rebase/bind opcode 串。現代 dyld(macOS 12+)把 rebase + bind 編碼成 __DATA 裡的指標鏈:每個待修指標的高位存「下一個 fixup 的距離」,dyld 邊走鏈邊套用 slide(ASLR 位移)與綁定匯入符號。好處是更省空間、可被簽章覆蓋。arm64e 上這些指標還會帶 PAC。(完整 fixup 動畫見 §9 路線圖 v1。)

LINKEDIT 與簽章的交界(→ M03)

__LINKEDIT 是檔案尾端的「資料池」:符號表、字串表、function starts、chained fixups、以及程式碼簽章都塞在這裡。LC_CODE_SIGNATURE 指向其中一段 EMBEDDED_SIGNATURE SuperBlob。

clang 在 Apple Silicon 上會自動幫 arm64 二進位蓋 ad-hoc 簽章,所以連這個玩具樣本都有真實簽章。解剖器把 SuperBlob 的索引層列出來(有哪些 slot:CodeDirectory、Requirements…),但到此為止——CodeDirectory 的完整欄位與 CDHash 真算是 M03 的事。

先記一個重點(M03 會證明):SHA-256 的 CDHash 是完整 32 bytes。很多工具(含 codesign 的某些欄位)只顯示截斷後的 20 bytes,那是顯示用的縮寫,不是真正的 cdhash。本機實測即可看到兩者並存:

CandidateCDHash      sha256=210b5ed03a8bd3ca33e66012226a801f9112f823          ← 截斷 20-byte(顯示)
CandidateCDHashFull  sha256=210b5ed03a8bd3ca...492fa19e                       ← 完整 32-byte(真值)

🟢 真機操作卡(在你自己的 Mac 上對照)

解剖器的每個欄位都能用內建工具驗證。隨便挑一個你自己的二進位(或 /bin/ls):

otool -hv /bin/ls            # mach_header:magic/cputype/filetype/flags
otool -l  /bin/ls            # 全部 load commands(逐條 cmd/cmdsize/欄位)
size -m   /bin/ls            # segments 與 sections 大小
nm        /bin/ls            # 符號表(LC_SYMTAB)
dyld_info -fixups /bin/ls    # chained fixups(看現代 dyld 的指標鏈)
codesign -dvvv /bin/ls       # 簽章:CodeDirectory、CDHash、flags(→ M03)
lipo -archs /bin/ls          # 是否 fat、含哪些架構切片

⚠ 注意架構差異:/bin/ls 之類的系統二進位在現代 macOS 上多半是 fat(x86_64 + arm64e),沒有「純 arm64」切片——lipo -archs /bin/ls 會印出 x86_64 arm64e,otool -hv 會顯示 cpusubtype E 並帶 PTRAUTH。這跟內建樣本(純 arm64、ARM64_ALL、無 PAC)正好相反,剛好當 arm64 vs arm64e 的對照。要做 apples-to-apples 對照,可自己 clang -arch arm64 hello.c 編一個純 arm64 binary。

這些都是唯讀觀察,安全。把 otool -l 的輸出和解剖器並排,驗證的是「你在真機看到的 == 模擬器解釋的」。要解剖你自己的二進位,直接用解剖器的「上傳你的二進位」(檔案不離開瀏覽器)。


下一步:M03 程式碼簽章與 entitlements — 把這個 LC_CODE_SIGNATURE 拆開,看 CodeDirectory、CDHash 真算雪崩,與 entitlement taxonomy。