前文講解了dyld加載Mach-O的用戶態(tài)過程,大家都知道Mach-O代表的是蘋果系統(tǒng)的可執(zhí)行文件,那你們了解Mach-O的內(nèi)部組成嗎?我們寫的代碼存儲在Mach-O的什么位置,我們寫的函數(shù)方法又是怎么找到具體位置調(diào)用執(zhí)行的?本文將帶大家深入了解Mach-O文件內(nèi)部構(gòu)造。
????Mach-O就是Mach object的簡寫,而Mach是早期的一個微內(nèi)核。
? ? 我們都知道Mach-O是可執(zhí)行文件,那么它到底有幾種文件類型呢?

可以在xnu源碼中,查看到Mach-O格式的詳細定義(https://opensource.apple.com/tarballs/xnu/)
可以在目錄????/EXTERNAL_HEADERS/mach-o/loader.h????文件里查看到具體的宏定義。
文件類型比較多,在此筆者只介紹iOS開發(fā)中能接觸到的類型
1、MH_OBJECT ? ?: ? 我們寫的代碼 編譯后得到的目標文件(.o) 靜態(tài)庫文件(.a),靜態(tài)庫其實就是N個.o合并在一起
2、MH_EXECUTE ? ? : ? ? 這個是我們最熟悉的可執(zhí)行文件
3、MH_DYLIB ? ?: ? ?動態(tài)庫文件,XXX.dylib ? ?XXX.framework/xx
4、MH_DYLINKER ? ?:????動態(tài)鏈接編輯器,其實就是我們前文分析的dyld,mac存放的目錄/usr/lib/dyld
5、MH_DSYM ? ?:????存儲著二進制文件符號信息的文件(常用于分析APP的崩潰信息)
我們可以在Xcode中查看或者修改target的Mach-O類型,如下圖:

那么dyld可以加載哪幾種類型的mach-o文件呢?
在dyld2.cpp文件中的loadPhase6函數(shù)中有相關(guān)的判斷:

只有MH_EXECUTE、MH_DYLIB、MH_BUNDLE這3種類型的mach-o才能被dyld加載,其余的類型都會拋出錯誤。
MachOView
使用MachOView來具體分析Mach-O文件結(jié)構(gòu),我們可以在github上下載MachOView源碼,編譯后即可直接使用。
在具體分析之前,我們先看看蘋果官方是怎么描述的:https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/MachOTopics/0-Introduction/introduction.html
官方是這樣描述的

一個Mach-O文件包含3個主要區(qū)域:
1、Header :存儲著mach-o的文件類型、目標架構(gòu)類型等
2、Load commands :描述文件在虛擬內(nèi)存中的邏輯結(jié)構(gòu)、布局(其實就是一張索引表)
3、Raw segment data :在Load commands中定義的Segment的原始數(shù)據(jù)
真實情況是不是這樣呢?我們打開MachOView,然后在頂部菜單選擇file->open->xxx 來驗證下。

咦,怎么好像跟文檔說得不一樣。其實這是因為加載的mach-o文件是個胖二進制文件,包含了2個架構(gòu),一個arm64,一個armv7。
胖二進制文件會比單架構(gòu)的mach-o文件多一個Fat Header段。這個段從右側(cè)的數(shù)據(jù)可以看到保存了每個架構(gòu)的一些信息,比如cup type, subtype ,偏移量offset和大小size等。
我們可以在終端使用命令:
lipo -thin arm64(架構(gòu),也可以使用armv7) XXX(胖二進制文件路徑) -output XXX(輸出路徑)
得到分離的單架構(gòu)mach-o文件。
Header段

上圖為Header在mach-o里的存儲的內(nèi)容。
從Description這一列可以看出Header有這么幾項屬性:Magic Number、CPU Type,CPU SubType ...... 等等。
再打開之前下載的mach-o源碼頭文件,看看里面Header的數(shù)據(jù)結(jié)構(gòu)是怎么定義的。

flags為不同的文件標簽的組合,每個標簽占一個位,可以用位或來進行組合,常見的標簽有:
MH_NOUNDEFS: 該文件沒有未定義的引用
MH_DYLDLINK: 該文件將要作為動態(tài)鏈接器的輸入,不能再被靜態(tài)鏈接器修改
MH_TWOLEVEL: 該文件使用兩級名字空間綁定
MH_PIE: 可執(zhí)行文件會被加載到隨機地址,只對MH_EXECUTE有效
更多的標簽讀者可以在文件中自行查看。
另外一個值得關(guān)注的就是ncmds和sizeofcmds,分別指定了 LOAD_COMMAND 的個數(shù)以及總大小,從這里也大概能猜到,每個 command 的大小是可變的。
Load Commands

在進行具體分析之前,筆者先說明一下幾個重要的LC_段代表的含義。
__PAGEZERO段,__PAGEZERO是一個特殊的段,主要目的是將低地址占用,防止用戶空間訪問。個人理解這是對空指針引用類型漏洞的一種緩解措施(即常見的ESC_BAD_ACCESS錯誤)。
__TEXT段:一般用來存放不可修改的數(shù)據(jù),比如代碼和const字符串。
__DATA:數(shù)據(jù)段,一般包括可讀寫的內(nèi)容,我們定義的靜態(tài)變量,全局變量等都存儲在這個段。
LC_LOAD_DYLIB(XXX):代表mach-o內(nèi)部用到了這些庫,需要進行鏈接,綁定

Load Commands是mach-o的一個索引,也是體現(xiàn)mach-o拓展性的地方,每個 command 的頭兩個word分別表示類型和大小。


所有的LC_SEGMENT_64在代碼里都用上圖的結(jié)構(gòu)體來表示。
cmd:代表當前是LC_的哪個段
cmdsize:代表當前段的大小
segname:代表當前段的別名,即括號內(nèi)的內(nèi)容,例如__PAGEZERO
vmaddr:代表當前段在虛擬內(nèi)存中的地址
vmsize:代表占用了虛擬內(nèi)存的大小
fileoff:代表當前段映射到內(nèi)存中在mach-o文件中的偏移量
filesize:代表當前段在mach-o文件中占用空間的大小
maxprot、initprot:當前段的權(quán)限,比如read、write、execute等
nsects:當前段包含多少個sections,只有__TEXT、__DATA這2個段才有sections
flags:一些標志位
從注釋翻譯的解釋完全不能明白這些fileoff、filesize等成員變量的值到底有什么作用,在mach-o中如何表現(xiàn),現(xiàn)在我們根據(jù)__TEXT的具體data來實戰(zhàn)分析結(jié)構(gòu)體各個成員變量的值到底有什么意義。
我們以__TEXT段為例:

cmd:LC_SEGMENT_64
cmdsize:1032,那么這個值的意義是什么呢?
從上圖,我們可以知道,__TEXT段的地址為00000068,1032在內(nèi)存中存儲的16進制值是0x408,即Data那一列存儲的值。
16進制的0x68+0x408 = 0x470,(不會算的讀者可以打開Mac自帶的計算器,在菜單欄選中 ?顯示->編程器,則可以直接進行16進制的加減法 ),那么我們再看看__TEXT段的下一個段__DATA段的起始位置是多少?

上圖可以看到__DATA段的起始位置就是00000470。
總結(jié):這個值就是告訴我們當前segment段在mach-o中占用的總大小。
segname:__TEXT,這個沒什么好解釋的,就是當前段的名字。
vmaddr:值為 ?0000000100000000,我們知道m(xù)ach-o映射到內(nèi)存中就是4個G的大小,而vmaddr的value換算下就是4G。
vmsize: 值為 ?0000000000F58000
這2個值要放到一起說明。

看上圖,在下方Section64(__DATA,__got)段的起始地址就是00F58000,是不是和vmsize一致?
再注意看__DATA段的上面那個段,是__TEXT段,請注意,Section64(XXX)段代表的是真正存放數(shù)據(jù)的段,與LC_xxx段有著本質(zhì)的區(qū)別,LC_XXX段是索引,不存放具體的數(shù)據(jù)。
而vmaddr是什么呢?其實是Mach-o文件加載到虛擬內(nèi)存的地址的起始位置,在這里每個mach-o文件都是固定的數(shù)值。讀者肯定會有疑惑,如果內(nèi)存起始地址寫死在文件里,那就相當于我可以根據(jù)地址隨意訪問mach-o中的任意數(shù)據(jù)了嗎?
蘋果為了防止出現(xiàn)這種情況,對真實的內(nèi)存地址是做了隨機偏移的,也就是傳說中的ASLR,全稱為:Address Space Layout Randomization
也就是說,真實的地址 =?vmaddr + ASLR的偏移量
但是要注意,debug調(diào)試模式下,ASLR的值是0。
fileoff:全0,也就是說TEXT段映射的時候會將當前文件頭部分也映射到進程空間中。__TEXT段從0開始,不能很好的說明問題,我們再看看__DATA段的值

看到?jīng)],__DATA的起始位置地址就是__TEXT的大小。
filesize:0000000000F58000,前面已經(jīng)分析過了,__TEXT段到__DATA段的長度就是這個長度。
maxprot、initprot:VM_PROT_READ、VM_PROT_EXECUTE,說明__TEXT段的數(shù)據(jù)允許讀,允許執(zhí)行。
讀者可以再查看下__DATA段的值,為VM_PROT_READ、VM_PROT_WRITE,說明__DATA段的數(shù)據(jù)是有讀寫權(quán)限的。
nsects:值為12,說明__TEXT段有12個section。注意:只有__TEXT、__DATA這2個段才有sections
flags:為空
上面分析的是__TEXT、__DATA段的具體值代表的意思,以及怎么運用這些值在mach-o中查找相關(guān)數(shù)據(jù)。
Load Command段還有非常多的段,不同的段,數(shù)據(jù)結(jié)構(gòu)也不一定相同,但是數(shù)據(jù)分析都是大同小異的。對于后續(xù)的其他段的數(shù)據(jù)結(jié)構(gòu)不再一一詳細分析,所有的數(shù)據(jù)結(jié)構(gòu)都在XNU源碼的"/EXTERNAL_HEADERS/mach-o/loader.h"這個目錄下有定義,有興趣的讀者可以自行查閱。
Section64
需要注意的是如果segment包含一個或者多個section,那么在該segment結(jié)構(gòu)體之后就緊跟著對應各個section頭,總大小也包括在cmdsize之中,其結(jié)構(gòu)如下

從上面的lc_segment_64結(jié)構(gòu)體分析,發(fā)現(xiàn)很多成員變量都是相似的。相信讀者肯定可以get到section_64結(jié)構(gòu)體各個成員變量的具體含義。
筆者在此僅以Section64 Header(__text)為例

之前筆者已經(jīng)告訴大家這其實是個索引,真正存放數(shù)據(jù)的位置不在這,那么在哪呢?看上圖右側(cè),offset的值為00005940,真正的代碼數(shù)據(jù)起始地址就在mach-o偏移00005940的位置。我們滑動鼠標滾輪往下找到Section64(__TEXT,__text)來驗證一下。

沒錯吧。我們在將鼠標點到Assembly,看看這些數(shù)據(jù)到底是什么樣子的。

從上圖可以看到一個一個匯編指令,大家都知道我們寫的代碼在被編譯的時候會被編譯成機器語言也就是匯編語言存儲在mach-o中,所以上圖驗證了__TEXT段存儲的就是我們寫的代碼。
我們再看看__TEXT Segment 具體有哪些Section,這些Section又代表什么含義?

__text: 可執(zhí)行文件的代碼區(qū)域
__objc_methname: 方法名
__objc_classname: 類名
__objc_methtype: 方法簽名
__cstring: 類 C 風格的字符串
LC_DYLD_INFO_ONLY
這個Command的信息主要是提供給動態(tài)鏈接器dyld的,其結(jié)構(gòu)如下:

雖然看起來很復雜,但實際上它的目的就是為了給dyld提供能夠加載目標MachO所需要的必要信息:?
1、因為可能加載到隨機地址,所以需要rebase信息;
2、如果進程依賴其他鏡像的符號,則綁定需要bind信息;
3、對于C++程序而言可能需要weak bind實現(xiàn)代碼/數(shù)據(jù)復用;
4、對于一些外部符號不需要立即綁定的可以延時加載,這就需要lazy bind信息;
5、對于導出符號也需要對應的export信息。
xxx_off代表該信息在mach-o中的偏移位置,根據(jù)這個偏移值,我們可以在mach-o下面的Dynamic Load Info段找到我們要找的具體信息。
我們來看看Dynamic Load Info里面就是存放著什么

Dynamic Load Info存放的信息是不是和LC_DYLD_INFO_ONLY中的索引一樣,我們完全可以這樣理解:LC_DYLD_INFO_ONLY 就是?Dynamic Load Info段的索引。
rebase/bind
為了描述這些rebase/bind信息,dyld定義了一套偽指令,用來描述具體的操作(opcode)及其操作數(shù)據(jù)。以延時綁定為例,我們從操作符看起來是這樣:

從右側(cè)我們可以獲得以下信息:
name:_AUGraphInitialize
offset:uleb128編碼的值 0xC006,如果我們直接以0xC006這個地址去查找,會發(fā)現(xiàn)找到的信息是不對的。因為uleb128編碼的數(shù)據(jù)是不能直接使用的,需要經(jīng)過轉(zhuǎn)換才能使用。
對于uleb128編碼來說,其特點如下:
1)一個uleb128編碼的整形值,其占用的字節(jié)數(shù)是不確定的,長度有可能在1到5個字節(jié)之間變化;
2)一個uleb128編碼的整形值,是以字節(jié)中最高位是否為0來表示字節(jié)流有沒有結(jié)束的。
那么轉(zhuǎn)換方法如下,以0xC006為例,先將其從小端轉(zhuǎn)換成大端,得到0x06C0。
然后再展開成二進制的01數(shù)據(jù):0000 0110 1100 0000,然后從低位往高位算,以1為起始開始,每第8位的值刪除,然后再將刪除后的所有7位組合起來。以0x06C0為例:
源數(shù)據(jù): ? ? ?0000 0110 1100 0000
刪除第8位: ?0000 0110 100 0000 ?-- ?轉(zhuǎn)換成16進制為 0x340。
你以為就結(jié)束了嗎?其實沒有,這個數(shù)據(jù)只是一個相對的偏移量,還要加上一個起始地址才能找到真實存放地址。那么這個起始地址是什么呢?之前我們說過,數(shù)據(jù)段存放的是可以讀寫的數(shù)據(jù),而rebase和bind是不是需要對指針重新計算,所以這些數(shù)據(jù)都是存放在__DATA段的,那這個起始位置就很清楚了,就是__DATA段的起始位置。上文已經(jīng)查到__DATA的起始地址是0xF58000。
那么加上轉(zhuǎn)換得到的值0x340,即得到真實數(shù)據(jù)的地址0xF58340。
我們找到__DATA段的這個地址去驗證下:

完美!而且從上圖不難發(fā)現(xiàn),所有的symbol數(shù)據(jù)都是存放在(__DATA,__got)和(__DATA,__la_symbol_ptr)這兩個段的。
(__DATA,__got)這個段是存放非懶加載的符號指針,即在加載階段就已經(jīng)綁定好了符號地址,比如dyld_stub_binder,這個函數(shù)是用于動態(tài)綁定函數(shù)符號地址的。
(__DATA,__la_symbol_ptr)是存放懶加載的符號指針的,即在運行過程中再進行動態(tài)查找具體的函數(shù)地址。
Binding
我們來看看binding在mach-o中具體是怎么做的!
看上圖_AUGraphInitialize符號存放的數(shù)據(jù):00000000 100BD6948。這是一段地址,我們來找找這個地址在哪個段,最后發(fā)現(xiàn)在Section64(__TEXT,__stub_helper)段

不難發(fā)現(xiàn),這個地址存放的是一段匯編指令,但是他真實要執(zhí)行的指令不是100BD6948,而是100BD694C,因為寄存器存放指令的地址也要算上,也就是說,要加寄存器的內(nèi)存,一條指令占4個字節(jié),所以要加上4個字節(jié),即得到100BD694C,這條指令是 b ?#0x100bd690c,b是跳轉(zhuǎn)的意思,意思是跳轉(zhuǎn)到0x100bd690c這個地址去執(zhí)行。再看上面100BD6940的指令,也是?b ?#0x100bd690c,這條指令其實就是其他符號被調(diào)用的時候執(zhí)行的匯編代碼。也就是說所有需要binging的符號都會執(zhí)行到這條指令,這其實就是binging的中間跳板。然后通過這個地址的命令去尋址真實地址,通過dyld_stub_binder函數(shù)獲取,dyld_stub_binder這個函數(shù)的符號是非延遲綁定的,會在dyld進行加載的時候就進行綁定(該函數(shù)符號存放在Section(__DATA,__got)段的最末尾)。最后會將通過dyld_stub_binder找到的真實地址寫入到(__DATA,__la_symbol_ptr)或者(__DATA,__got)對應函數(shù)符號地址的data中。下次再調(diào)用這個函數(shù)的時候就可以根據(jù)這個存入的數(shù)據(jù)直接調(diào)用了。
以上就是Binging的具體過程了。
Rebase
那么Rebase的過程又是怎么樣的呢?這就要提到Section64(__TEXT,__stubs)這個段了,這個段存放的全都是以 101CXXXXXXXXXXXX 開頭的數(shù)據(jù)。101C其實就是匯編指令adrp。
事實上在代碼執(zhí)行到需要rebase的函數(shù)時,會跳轉(zhuǎn)到

__stub段該函數(shù)的地址。然后經(jīng)過一系列的地址計算,計算結(jié)果就是Section(__DATA,__la_symbol_ptr)中該函數(shù)的地址。然后按照上述Binging的過程就能查找到具體的地址了。
具體怎么計算的目前筆者還沒弄明白,有了解過程的讀者可以私信筆者,不勝感激!
可以參考深入理解Mach-O文件中的Rebase和Bind這篇博客。不過該博客是基于mac x86_64架構(gòu)的,arm64上的計算方式有所不同。
LC_SYMTAB

這個commond同時指定了兩個表(符號表、String表)的位置信息
LC_DYSYMTAB(動態(tài)符號表)

動態(tài)符號command定義了各種符號的偏移量和各種符號的個數(shù)(9種)。
LC_UUID
LC_UUID 用來標識唯一APP,命令的定義如下:

每個可執(zhí)行程序都有一個uuid,這樣根據(jù)不同的uuid能確定包。比如崩潰日志中就會包含uuid字段。表示是哪個包崩潰了
LC_LOAD_DYLINKER
該段定義了加載動態(tài)庫的工具dyld,并且保存了dyld的物理地址

LC_XXX_DYLIB
LC_LOAD_{,WEAK_}DYLIB用來告訴內(nèi)核(實際上是dyld)當前可執(zhí)行文件需要使用哪些動態(tài)庫,而其結(jié)構(gòu)如下:

動態(tài)庫(filetype為MH_DYLIB)中會包含?LC_ID_DYLIB?command 來說明自己是個什么庫,包括名稱、版本、時間戳等信息。需要注意的是lc_str并不是字符串本身,而是字符串的偏移值,字符串信息在command的內(nèi)容之后,該偏移指的是距離command起始位置的偏移。
其他段的意義:
LC_VERSION_MIN_IPHONEOS? : ?存儲著最低支持的iOS系統(tǒng)版本。
LC_MAIN ? : ? ?保存了main函數(shù)的進入地址。 ? ?
LC_PATH ? ?: ? ?保存Xcode上設(shè)置的相關(guān)路徑。
LC_FUNCTION_STARTS ? ?: ? ?存儲著方法的起始偏移地址。
LC_DATA_IN_CODE ? ?: ? ?存儲運行中代碼的存儲空間,即棧和堆空間的offset
LC_CODE_SIGNATURE ? ?: ? ?存儲mach-o文件以及代碼簽名在文件中的offset。
__DATA段

__nl_symbol_ptr: 非懶加載指針表,dyld 加載會立即綁定
__ls_symbol_ptr: 懶加載指針表
__mod_init_func: constructor 函數(shù)
__mod_term_func: destructor 函數(shù)
__objc_classlist: 類列表
__objc_nlclslist: 實現(xiàn)了 load 方法的類
__objc_protolist: protocol的列表
__objc_classrefs: 被引用的類列表
__objc _catlist: Category列表
Symbol Table?符號表
Dynamic Symbol Table 動態(tài)符號表
這個是重點中的重點,符號表是將地址和符號聯(lián)系起來的橋梁。符號表并不能直接存儲符號,而是存儲符號位于字符串表的位置。
String Table 字符串表
String表順序列出了二進制mach-O文件的中的所有可見字符串。串之間通過0x00分隔。可以通過相對String表起始位置的偏移量隨機訪問String表中的字符串。符號表結(jié)構(gòu)中的n_strx指定的就是String表中的偏移量。通過這個偏移量可以訪問到符號對應的具體字符串。
所有的變量名、函數(shù)名等,都以字符串的形式存儲在字符串表中
總結(jié)
最后,以一張圖作為總結(jié)吧
