M02 · 階梯 1 · L1
Mach-O 格式與 dyld 載入器
Mach-O header / load commands / segments / sections、fat binary、LINKEDIT、chained fixups、arm64 vs arm64e,以及 dyld 如何把映像載入虛擬位址空間。
被信任鏈驗過的「映像」長什麼樣
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 被高亮;再點__TEXTsegment,注意它的保護是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 | 值 | 意義 |
|---|---|---|---|
magic | cf fa ed fe | 0xfeedfacf | MH_MAGIC_64(64-bit、host little-endian) |
cputype | 0c 00 00 01 | 0x0100000c | CPU_TYPE_ARM64(0x01000000 ABI64 ∣ 12) |
cpusubtype | 00 00 00 00 | 0 | ARM64_ALL,caps=0x00 |
filetype | 02 00 00 00 | 2 | MH_EXECUTE |
ncmds | 11 00 00 00 | 17 | 17 條 load command |
sizeofcmds | 20 04 00 00 | 0x420 | load commands 共 1056 bytes |
flags | 85 00 20 00 | 0x200085 | NOUNDEFS ∣ DYLDLINK ∣ TWOLEVEL ∣ PIE |
PIE(Position-Independent Executable)那一位代表載入時可被 ASLR 隨機平移——這是後面 M13 講緩解機制時的起點。
arm64 vs arm64e(別搞混)
cputype 同樣是 arm64,但 cpusubtype 才區分 arm64 與 arm64e:
- arm64:
cpusubtype = ARM64_ALL(0),caps0x00(本樣本)。 - 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 大致分三類:
- 記憶體佈局:
LC_SEGMENT_64(__PAGEZERO / __TEXT / __DATA_CONST / __LINKEDIT),每個 segment 再帶 sections。 - 連結與載入:
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。 - 中介資料:
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 |
__TEXT | r-x | 程式碼 + 唯讀常數:可執行、不可寫 |
__DATA_CONST | rw- | 指標表:載入期可寫,填完 fixups 後被 dyld remap 成唯讀 |
__LINKEDIT | r-- | 符號表 / 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。