作者:evilpan
原文鏈接:https://evilpan.com/2020/09/06/macho-inside-out/
之前寫了一篇深入淺出ELF,作為姊妹篇這次就來聊聊MacOS的可執行文件格式MachO。
Mach-O 101
在之前的文章中我們說過,可執行文件的使命有兩個,一是方便開發者在編譯、鏈接時提供可擴展的封裝結構;二是在執行時能給操作系統(內核)提供內存映射信息。MachO也不例外。
MachO本身沒有什麼特別的含義,就是Mach object
的簡寫,而Mach是早期的一個微內核。和ELF一樣,MachO也極具拓展性,從全局視角來看,MachO文件可以分為三個部分,分別是:
- Mach Header: 文件頭信息
- 可變長度的LOAD_COMMAND信息
- 上述LOAD_COMMAND中所用到的具體信息(segments)
這裡的segment可以理解為一段連續的內存空間,擁有對應的讀/寫/執行權限,並且在內存中總是頁對齊的。每個segment由一個或者多個section組成,section表示特定含義數據或者代碼的集合(不需要頁對齊)。在macOS中,通常約定segment的名稱為雙下劃線加全大寫字母(如__TEXT
),section的名稱為雙下劃線加小寫字母(如__text
)。
下面對這三個部分進行分別介紹。
注: MachO文件結構的表示通常分為32位和64位兩種,本文以64位為例,畢竟這是歷史的進程。
Header
文件頭信息參考mach-o/loader.h中的定義如下:
/* * The 64-bit mach header appears at the very beginning of object files for * 64-bit architectures. */ struct mach_header_64 { uint32_t magic; /* mach magic number identifier */ cpu_type_t cputype; /* cpu specifier */ cpu_subtype_t cpusubtype; /* machine specifier */ uint32_t filetype; /* type of file */ uint32_t ncmds; /* number of load commands */ uint32_t sizeofcmds; /* the size of all the load commands */ uint32_t flags; /* flags */ uint32_t reserved; /* reserved */ }; /* Constant for the magic field of the mach_header_64 (64-bit architectures) */ #define MH_MAGIC_64 0xfeedfacf /* the 64-bit mach magic number */ #define MH_CIGAM_64 0xcffaedfe /* NXSwapInt(MH_MAGIC_64) */
filetype
表示類型,常見的有:
- MH_OBJECT: 可重定向的目標文件
- MH_EXECUTE: 可執行文件
- MH_DYLIB: 動態綁定的共享庫文件
- …
flags
為不同的文件標籤的組合,每個標籤佔一個位,可以用位或來進行組合,常見的標籤有:
- MH_NOUNDEFS: 該文件沒有未定義的引用
- MH_DYLDLINK: 該文件將要作為動態鏈接器的輸入,不能再被靜態鏈接器修改
- MH_TWOLEVEL: 該文件使用兩級名字空間綁定
- MH_PIE: 可執行文件會被加載到隨機地址,只對
MH_EXECUTE
有效 - …
另外一個值得關注的就是ncmds
和sizeofcmds
,分別指定了 LOAD_COMMAND 的個數以及總大小,從這裡也大概能猜到,每個 command 的大小是可變的。
Command
LOAD_COMMAND
是體現MachO文件拓展性的地方,每個 command 的頭兩個word分別表示類型和大小,如下:
struct load_command { uint32_t cmd; /* type of load command */ uint32_t cmdsize; /* total size of command in bytes */ };
不同的cmd
類型都會有其對應的結構體來描述其內容,cmdsize
表示的是整個cmd的大小,即包括頭部和內容。也就是說在處理的時候當前cmd的位置加上cmdsize就是下一個cmd的位置。注意每個command的大小(即cmdsize)需要word對齊,對於32位CPU來說是4位元組,64位則是8位元組;同時對齊末尾的填充部分必須是0。
loader.h
中絕大部分的篇幅就是用來定義各種不同command類型的結構體了,這裡挑選一些比較常見的來進行介紹。
LC_SEGMENT
LC_SEGMENT
/LC_SEGMENT64
可以說是最重要的一個command。表示當前文件的一部分將會映射到目標進程(task)的地址空間中,包括程序運行所需要的所有代碼和數據。假設當前MachO文件的起始地址為begin,則映射的內容為:
- 原始地址(文件地址): begin + fileoff,大小為filesize
- 目的地址(進程虛址): vmaddr,大小為vmsize
其中vmsize >= filesize
,如果有多出來的部分需要填充為零。segment_command的結構體表示如下:
struct segment_command_64 { /* for 64-bit architectures */ uint32_t cmd; /* LC_SEGMENT_64 */ uint32_t cmdsize; /* includes sizeof section_64 structs */ char segname[16]; /* segment name */ uint64_t vmaddr; /* memory address of this segment */ uint64_t vmsize; /* memory size of this segment */ uint64_t fileoff; /* file offset of this segment */ uint64_t filesize; /* amount to map from the file */ vm_prot_t maxprot; /* maximum VM protection */ vm_prot_t initprot; /* initial VM protection */ uint32_t nsects; /* number of sections in segment */ uint32_t flags; /* flags */ };
maxprot/initprot表示對應segment虛擬地址空間的RWX權限。如果segment包含一個或者多個section,那麼在該segment結構體之後就緊跟着對應各個section頭,總大小也包括在cmdsize之中,其結構如下:
struct section_64 { /* for 64-bit architectures */ char sectname[16]; /* name of this section */ char segname[16]; /* segment this section goes in */ uint64_t addr; /* memory address of this section */ uint64_t size; /* size in bytes of this section */ uint32_t offset; /* file offset of this section */ uint32_t align; /* section alignment (power of 2) */ uint32_t reloff; /* file offset of relocation entries */ uint32_t nreloc; /* number of relocation entries */ uint32_t flags; /* flags (section type and attributes)*/ uint32_t reserved1; /* reserved (for offset or index) */ uint32_t reserved2; /* reserved (for count or sizeof) */ uint32_t reserved3; /* reserved */ };
每個section頭對應一個section,位置在相對文件起始地址的offset處,大小為size位元組,對應的虛擬地址為addr。這裡的align對齊指的是在虛擬地址空間中的對齊,實際上在文件中是連續存放的,因為有size指定大小。reloff和nreloc與符號的重定向有關,在下面的加載過程一節中再進行介紹。
從這裡可以看出,section的內容和segment是不連續存放的,只是section header在對應segment之後。而segment的vmsize實際上會大於segment+section_header的大小(即cmdsize),猜測多出來的空間是內核加載MachO時將對應section內容填充進去,後面將會對這一猜測進行驗證。
TEXT
__TEXT
段包含__text
、__stubs
、__stub_helper
、__cstring
等section,一般用來存放不可修改的數據,比如代碼和const字符串,可以用otool
查看對應的section內容:
$ otool -V main -s __TEXT __stubs main: Contents of (__TEXT,__stubs) section 0000000100000f6a jmpq *0xa8(%rip) ## literal pool symbol address: _printf 0000000100000f70 jmpq *0xaa(%rip) ## literal pool symbol address: _set_foo
在實際的MachO可執行文件中觀察發現TEXT的fileoff為0,也就是說TEXT段映射的時候會將當前文件頭部分也映射到進程空間中。
(lldbinit) image dump sections main Sections for '/Users/evilpan/temp/macho-test/main' (x86_64): SectID Type Load Address Perm File Off. File Size Flags Section Name ---------- ---------------- --------------------------------------- ---- ---------- ---------- ---------- ---------------------------- 0x00000100 container [0x0000000000000000-0x0000000100000000)* --- 0x00000000 0x00000000 0x00000000 main.__PAGEZERO 0x00000200 container [0x0000000100000000-0x0000000100001000) r-x 0x00000000 0x00001000 0x00000000 main.__TEXT 0x00000001 code [0x0000000100000ee0-0x0000000100000f6a) r-x 0x00000ee0 0x0000008a 0x80000400 main.__TEXT.__text 0x00000002 code [0x0000000100000f6a-0x0000000100000f76) r-x 0x00000f6a 0x0000000c 0x80000408 main.__TEXT.__stubs 0x00000003 code [0x0000000100000f78-0x0000000100000f9c) r-x 0x00000f78 0x00000024 0x80000400 main.__TEXT.__stub_helper 0x00000004 data-cstr [0x0000000100000f9c-0x0000000100000fb0) r-x 0x00000f9c 0x00000014 0x00000002 main.__TEXT.__cstring 0x00000005 compact-unwind [0x0000000100000fb0-0x0000000100000ff8) r-x 0x00000fb0 0x00000048 0x00000000 main.__TEXT.__unwind_info 0x00000300 container [0x0000000100001000-0x0000000100002000) rw- 0x00001000 0x00001000 0x00000000 main.__DATA 0x00000006 data-ptrs [0x0000000100001000-0x0000000100001008) rw- 0x00001000 0x00000008 0x00000006 main.__DATA.__nl_symbol_ptr 0x00000007 data-ptrs [0x0000000100001008-0x0000000100001018) rw- 0x00001008 0x00000010 0x00000006 main.__DATA.__got 0x00000008 data-ptrs [0x0000000100001018-0x0000000100001028) rw- 0x00001018 0x00000010 0x00000007 main.__DATA.__la_symbol_ptr 0x00000009 zero-fill [0x0000000100001028-0x000000010000102c) rw- 0x00000000 0x00000000 0x00000001 main.__DATA.__common 0x00000400 container [0x0000000100002000-0x0000000100007000) r-- 0x00002000 0x00004a90 0x00000000 main.__LINKEDIT
上面例子中__TEXT
段的的vm_size和file_size都是0x1000
,這個大小在文件中正好是第一個__DATA
section的起始地址:
__PAGEZERO
是一個特殊的段,主要目的是將低地址佔用,防止用戶空間訪問。個人理解這是對空指針引用類型漏洞的一種緩解措施,Linux內核中也有mmap_min_addr來限制用戶可以mmap映射的最低地址。
DATA
__DATA
段則包含__got
、__nl_symbol_ptr
、__la_symbol_ptr
等section,一般包括可讀寫的內容。
LINKEDIT
另外一個重要的段為__LINKEDIT
,其中包含需要被動態鏈接器使用的信息,包括符號表、字符串表、重定位項表、簽名等。該段和PAGEZERO
一樣的是末尾沒有額外的section信息,所以cmdsize都等於72(sizeof(struct segment_command_64)
)。其內容即begin + fileoff
指向的地方保存linkedit command的內容,這個內容的格式根據具體cmd的不同而不同。LINKEDIT可以理解為元數據,值得一提的是,經過觀察,fileoff +filesize
即為MachO文件末尾,也就是等於文件的大小。
那麼LINKEDIT塊中的內容是什麼格式呢?其實大部分有其專門的格式,比如對Dynamic Loader Info
來說是位元組碼,對於符號表來說是符號表結構體,對於函數地址項來說是uleb128
編碼的地址值,……因此LINKEDIT可謂包羅萬象,需要具體問題具體分析,下面介紹的幾個command就是其中幾個例子。
LC_CODE_SIGNATURE
Signature Command指定當前文件的簽名信息,沒有單獨的結構體,而是使用下面這個結構來表示:
struct linkedit_data_command { uint32_t cmd; /* LC_CODE_SIGNATURE, LC_SEGMENT_SPLIT_INFO, LC_FUNCTION_STARTS, LC_DATA_IN_CODE, LC_DYLIB_CODE_SIGN_DRS or LC_LINKER_OPTIMIZATION_HINT. */ uint32_t cmdsize; /* sizeof(struct linkedit_data_command) */ uint32_t dataoff; /* file offset of data in __LINKEDIT segment */ uint32_t datasize; /* file size of data in __LINKEDIT segment */ };
cmd/cmdsize和前面LC_SEGMENT的含義類似,只不過cmdsize是個常數,等於當前結構體的大小。dataoff表示前面信息在LINKEDIT數據中的偏移,注意這裡不是相對文件頭的偏移;datasize則表示簽名信息的大小。
蘋果的簽名數據格式並不是常規類型,對其詳細介紹超過了本文的範圍,對於具體的簽名實現有興趣的可以參考Jonathan大神的*OS Internal
或者Code Signing – Hashed Out。使用jtool工具可以打印出詳細的簽名信息,如下所示:
$ jtool2 --sig -v main An embedded signature of 1953 bytes, with 3 blobs: Blob 0: Type: 0 @36: Code Directory (213 bytes) Version: 20100 Flags: none CodeLimit: 0x22c0 Identifier: main (@0x30) CDHash: f3d8c9a75487ecc6f3adbddca11ad987a171e8974e6df15e857d2ac962e4b886 (computed) # of hashes: 3 code (4K pages) + 2 special Hashes @117 size: 32 Type: SHA-256 Blob 1: Type: 2 @249: Requirement Set (80 bytes) with 1 requirement: Unknown opcode 14 - has Apple changed the op codes?Please notify J! 0: Designated Requirement (@20, 48 bytes): Ident(main) AND Blob 2: Type: 10000 @329: Blob Wrapper (1624 bytes) (0x10000 is CMS (RFC3852) signature) Timestamp: 00:12:38 2020/09/06
當然官方的codesign -d
也可以。
LC_DYLD_INFO_ONLY
這個Command的信息主要是提供給動態鏈接器dyld
的,其結構如下:
struct dyld_info_command { uint32_t cmd; /* LC_DYLD_INFO or LC_DYLD_INFO_ONLY */ uint32_t cmdsize; /* sizeof(struct dyld_info_command) */ uint32_t rebase_off; /* file offset to rebase info */ uint32_t rebase_size; /* size of rebase info */ uint32_t bind_off; /* file offset to binding info */ uint32_t bind_size; /* size of binding info */ uint32_t weak_bind_off; /* file offset to weak binding info */ uint32_t weak_bind_size; /* size of weak binding info */ uint32_t lazy_bind_off; /* file offset to lazy binding info */ uint32_t lazy_bind_size; /* size of lazy binding infs */ uint32_t export_off; /* file offset to lazy binding info */ uint32_t export_size; /* size of lazy binding infs */ }
雖然看起來很複雜,但實際上它的目的就是為了給dyld提供能夠加載目標MachO所需要的必要信息: 因為可能加載到隨機地址,所以需要rebase信息;如果進程依賴其他鏡像的符號,則綁定需要bind信息;對於C++程序而言可能需要weak bind實現代碼/數據復用;對於一些外部符號不需要立即綁定的可以延時加載,這就需要lazy bind信息;對於導出符號也需要對應的export信息。
為了描述這些rebase/bind信息,dyld定義了一套偽指令,用來描述具體的操作(opcode)及其操作數據。以延時綁定為例,操作符看起來是這樣:
其表達的實際含義用中文來描述就是:
_printf
符號(來自libSystem.B.dylib
)延時綁定到0x1018
偏移地址;_set_foo
符號(來自libfoo.dylib
)延時綁定到0x1020
偏移地址;
其中0x1018/0x1020地址在__DATA
段,更準確來說是在__la_symbol_ptr
這個section中,可以自行編譯驗證。
LC_XXX_DYLIB
LC_LOAD_{,WEAK_}DYLIB
用來告訴內核(實際上是dyld)當前可執行文件需要使用哪些動態庫,而其結構如下:
struct dylib { union lc_str name; /* library's path name */ uint32_t timestamp; /* library's build time stamp */ uint32_t current_version; /* library's current version number */ uint32_t compatibility_version; /* library's compatibility vers number*/ }; struct dylib_command { uint32_t cmd; /* LC_ID_DYLIB, LC_LOAD_{,WEAK_}DYLIB, LC_REEXPORT_DYLIB */ uint32_t cmdsize; /* includes pathname string */ struct dylib dylib; /* the library identification */ };
動態庫(filetype為MH_DYLIB
)中會包含 LC_ID_DYLIB
command 來說明自己是個什麼庫,包括名稱、版本、時間戳等信息。需要注意的是lc_str
並不是字符串本身,而是字符串的偏移值,字符串信息在command的內容之後,該偏移指的是距離command起始位置的偏移。
LC_REEXPORT_DYLIB
表示加載並重新導出dylib
其他
除了上面的介紹,還有許多其他類型的 command ,比如LC_FUNCTION_STARTS
表示函數入口地址,LC_MAIN
表示主函數地址,LC_ENCRYPTION_INFO
表示加密的segment段等等,可以在遇到的時候用查看loader.h
的定義,這裡就不再贅述了。
加載過程
MachO的加載和ELF的加載過程沒有太大區別,還是系統調用->內核處理->返回執行
的一般流程,對於靜態鏈接程序返回執行是直接返回到程序入口地址,而動態鏈接程序則需要在程序開始執行之前進行重定向,因此這裡也按照這個順序介紹。
內核空間
內核空間的主要任務是創建新taks並初始化內存頁和對應的權限,我們主要關注MachO文件的處理部分,即parse_machfile
函數,文件為[bsd/kern/mach_loader.c][src],其主要功能為檢查header以及cmdsize等長度符合預期,然後通過4次循環來處理不同的信息,如下:
/* * Scan through the commands, processing each one as necessary. * We parse in three passes through the headers: * 0: determine if TEXT and DATA boundary can be page-aligned * 1: thread state, uuid, code signature * 2: segments * 3: dyld, encryption, check entry point */
這裡重點關注pass2,關鍵代碼如下:
其中很多command比如LC_LOAD_DYLIB
、LC_DYLD_INFO_ONLY
等不在內核態中進行處理,直接進入default分支忽略。
load_segment
這個函數主要負責加載segment到內存中,實現有幾個值得一提的點:
- total_section_size = lcp->cmdsize – sizeof(struct segment_command_64);這是文件後面section的大小
- LC_SEGMENT_32會轉換為LC_SEGMENT_64,使用widen_segment_command
- 映射的內存地址是
slide + scp->vmaddr
,slide為隨機化的地址偏移(如果有的話),映射的內存大小是scp->vmsize
- 文件中對應內容起始位置是
scp->fileoff
,大小為scp->filesize
- file_offset = pager_offset + scp->fileoff,是該segment在內核空間中的地址,需要頁對齊
- 對於0地址頁的映射,由於用戶空間不能訪問,因此直接增加了vm能訪問的最低地址值(vm_map_raise_min_offset),僅允許對
PAGEZERO
段執行0地址的映射命令
對於映射的地址和大小,都需要是4k頁對齊的,並且最終使用map_segment
進行映射:
file_start = vm_map_trunc_page(file_offset, effective_page_mask); file_end = vm_map_round_page(file_offset + file_size, effective_page_mask); vm_start = vm_map_trunc_page(vm_offset, effective_page_mask); vm_end = vm_map_round_page(vm_offset + vm_size, effective_page_mask); ret = map_segment(map, vm_start, vm_end, control, file_start, file_end, initprot, maxprot, result);
根據對代碼的分析,內核中並不關心具體section的內容,即不解析單個section頭的具體字段,而是以segment為單位進行映射。一般而言映射的是具體內容的值,比如__DATA
段就映射了數據段。前面也說過__TEXT
段比較特別,它是從文件開頭開始映射的,一直到代碼段的末尾(數據段的開頭)。
load_code_signature
MachO和ELF的一個最大不同點,或者說XNU和Linux的不同點是前者原生支持了對可執行文件的簽名認證,文件的簽名信息保存在LINKEDIT數據段,在前面我們已經介紹過了LC_CODE_SIGNATURE
的內容。
load_dylinker
load_main
函數主要用來處理LC_MAIN
命令,這裏面包括了可執行文件的入口地址以及棧大小信息。但是在內核中並不需要關心main函數信息,而只需要關心入口信息(entry_point)。因此在load_main中只對棧和線程進行初始化,並且修改對應的result信息:
result->user_stack = addr; result->user_stack -= slide; result->needs_dynlinker = TRUE; result->using_lcmain = TRUE; ret = thread_state_initialize( thread );
此時result->entry_point
還是0(MACH_VM_MIN_ADDRESS)。
另外一個能決定入口地址的command是
LC_UNIXTHREAD
,類似於UNIX中直接將start
符號導出,該符號應該是在crt1.o
里的,但蘋果並不默認提供。也就是說如果想要靜態編譯,需要自己下載源文件自己去編譯,或者自己鏈接並導出這個符號。蘋果不支持靜態編譯的原因是出於兼容性的考慮。
在load_main結束后,需要加載動態鏈接器:
/* Make sure if we need dyld, we got it */ if (result->needs_dynlinker && !dlp) { ret = LOAD_FAILURE; } if ((ret == LOAD_SUCCESS) && (dlp != 0)) { /* * load the dylinker, and slide it by the independent DYLD ASLR * offset regardless of the PIE-ness of the main binary. */ ret = load_dylinker(dlp, dlarchbits, map, thread, depth, dyld_aslr_offset, result, imgp); }
動態鏈接器就是dyld
,在LC_LOAD_DYLINKER
命令中指定,通常是/usr/lib/dyld
。load_dylinker內部也同樣調用了parse_machfile
函數,因此大部分操作是類似的。注意到這裡其實涉及到了遞歸調用,因此需要在該函數中加depth參數表示遞歸層數。
dyld文件中有LC_UNIXTHREAD
命令,因此其result->entry_point
將被設置,在原先的parse_mach返回到load_machfile后,則初始化新的內核task並將執行流交還給用戶空間,對於大部分程序而言,就是跳轉到dyld的起始地址執行。
用戶空間
從內核回到用戶空間,便跳轉到目標的入口地址開始執行。對於靜態鏈接鏈接程序,實際上執行的是dyld中的指令,該程序的源碼可以參考opensource-apple/dyld。
dyld的起始地址固定為0x1000
,這個地址對應的符號是__dyld_start
,文件定義在src/dyldStartup.s
。這部分代碼和crt0.o
中的代碼是一樣的,主要是用來初始化C Runtime,唯一的不同點是有個額外的參數用來指定MachO文件頭的地址。
初始化完成後調用call __ZN13dyldbootstrap5startEPK12macho_headeriPPKclS2_Pm
,demangle之後為下面的函數:
// // This is code to bootstrap dyld. This work in normally done for a program by dyld and crt. // In dyld we have to do this manually. // uintptr_t start(const struct macho_header* appsMachHeader, int argc, const char* argv[], intptr_t slide, const struct macho_header* dyldsMachHeader, uintptr_t* startGlue) { // if kernel had to slide dyld, we need to fix up load sensitive locations // we have to do this before using any global variables if ( slide != 0 ) { rebaseDyld(dyldsMachHeader, slide); } // allow dyld to use mach messaging mach_init(); // kernel sets up env pointer to be just past end of agv array const char** envp = &argv[argc+1]; // kernel sets up apple pointer to be just past end of envp array const char** apple = envp; while(*apple != NULL) { ++apple; } ++apple; // set up random value for stack canary __guard_setup(apple); #if DYLD_INITIALIZER_SUPPORT // run all C++ initializers inside dyld runDyldInitializers(dyldsMachHeader, slide, argc, argv, envp, apple); #endif // now that we are done bootstrapping dyld, call dyld's main uintptr_t appsSlide = slideOfMainExecutable(appsMachHeader); return dyld::_main(appsMachHeader, appsSlide, argc, argv, envp, apple, startGlue); }
所以dyld真正的入口地址是dyld::_main
,該函數的功能主要有:
- 初始化上下文(setContext)
- 將可執行文件的路徑轉為絕對路徑
- 處理環境變量
- 判斷是否需要加載共享緩存庫,如果需要加載則直接映射到內存中(mapSharedCache)
- 加載注入的動態庫(sEnv.DYLD_INSERT_LIBRARIES)
- 鏈接主程序(dyld::link),實際上用的是虛函數
ImageLoader::link
- initializeMainExecutable: 運行初始化函數(
__mod_init_funcs
) - 執行最終的目標程序(LC_MAIN/LC_UNIXTHREAD)
最終目標程序正常執行,就像自己直接啟動一樣。下面挑幾個比較關鍵的點進行深入分析。
linking
鏈接是dyld的主要功能,執行實際動態鏈接功能的是link函數,除了鏈接待執行的目標程序,還鏈接所有插入的其他動態庫:
// link main executable gLinkContext.linkingMainExecutable = true; link(sMainExecutable, sEnv.DYLD_BIND_AT_LAUNCH, true, ImageLoader::RPathChain(NULL, NULL)); // link any inserted libraries // do this after linking main executable so that any dylibs pulled in by inserted // dylibs (e.g. libSystem) will not be in front of dylibs the program uses if ( sInsertedDylibCount > 0 ) { for(unsigned int i=0; i < sInsertedDylibCount; ++i) { ImageLoader* image = sAllImages[i+1]; link(image, sEnv.DYLD_BIND_AT_LAUNCH, true, ImageLoader::RPathChain(NULL, NULL)); image->setNeverUnloadRecursive(); } // only INSERTED libraries can interpose // register interposing info after all inserted libraries are bound so chaining works for(unsigned int i=0; i < sInsertedDylibCount; ++i) { ImageLoader* image = sAllImages[i+1]; image->registerInterposing(); } }
而dyld:link
使用的是具體ImageLoader的link多態實現:
void link(ImageLoader* image, bool forceLazysBound, bool neverUnload, const ImageLoader::RPathChain& loaderRPaths) { // add to list of known images. This did not happen at creation time for bundles if ( image->isBundle() && !image->isLinked() ) addImage(image); // we detect root images as those not linked in yet if ( !image->isLinked() ) addRootImage(image); // process images try { image->link(gLinkContext, forceLazysBound, false, neverUnload, loaderRPaths); } catch (const char* msg) { garbageCollectImages(); throw; } }
sMainExecutable的實現在開源代碼中並沒有給出,不過參考基類的默認實現如下:
void ImageLoader::link(const LinkContext& context, bool forceLazysBound, bool preflightOnly, bool neverUnload, const RPathChain& loaderRPaths) { //dyld::log("ImageLoader::link(%s) refCount=%d, neverUnload=%d/n", this->getPath(), fDlopenReferenceCount, fNeverUnload); // clear error strings (*context.setErrorStrings)(dyld_error_kind_none, NULL, NULL, NULL); uint64_t t0 = mach_absolute_time(); this->recursiveLoadLibraries(context, preflightOnly, loaderRPaths); context.notifyBatch(dyld_image_state_dependents_mapped); // we only do the loading step for preflights if ( preflightOnly ) return; uint64_t t1 = mach_absolute_time(); context.clearAllDepths(); this->recursiveUpdateDepth(context.imageCount()); uint64_t t2 = mach_absolute_time(); this->recursiveRebase(context); context.notifyBatch(dyld_image_state_rebased); uint64_t t3 = mach_absolute_time(); this->recursiveBind(context, forceLazysBound, neverUnload); uint64_t t4 = mach_absolute_time(); if ( !context.linkingMainExecutable ) this->weakBind(context); uint64_t t5 = mach_absolute_time(); context.notifyBatch(dyld_image_state_bound); uint64_t t6 = mach_absolute_time(); std::vector<DOFInfo> dofs; this->recursiveGetDOFSections(context, dofs); context.registerDOFs(dofs); uint64_t t7 = mach_absolute_time(); // interpose any dynamically loaded images if ( !context.linkingMainExecutable && (fgInterposingTuples.size() != 0) ) { this->recursiveApplyInterposing(context); } // clear error strings // ... }
主要做的就是這幾步:
- recursiveLoadLibraries
- recursiveUpdateDepth
- recursiveRebase
- recursiveBind
- weakBind
- recursiveGetDOFSections
根據名字不難看出其作用,其中大部分函數名稱帶recursive,這是因為動態庫本身也可能會依賴其他的動態庫,因此需要遞歸進行處理(當然循環依賴會有對應的處理)。其中recursiveUpdateDepth
不太直觀,其實作用只是為了對鏡像進行排序,被依賴的庫會出現在依賴者之前。
dyld_cache
在上面第4步中說到要加載共享緩存庫,這是個什麼東西呢?這一步的目的其實是為了加速動態庫的加載過程。對於我們自己編譯的macOS命令行程序可能還好,但是對於圖形界面應用來說,每個應用啟動時需要加載的動態庫可能有上百個,而其中很大一部分是系統庫,比如UIKit、Foundation等。因此蘋果就事先把這些常用的庫打包成緩存,程序啟動時候直接映射到內存中,而無需逐個執行繁瑣的處理和解析。
映射共享緩存庫的函數為mapSharedCache
,首先檢查共享緩存庫是否已經映射過:
static int __attribute__((noinline)) _shared_region_check_np(uint64_t* start_address) { if ( gLinkContext.sharedRegionMode == ImageLoader::kUseSharedRegion ) return syscall(294, start_address); return -1; }
294號系統調用定義在內核中(bsd/kern/syscalls.master):
294 AUE_NULL ALL { int shared_region_check_np(uint64_t *start_address) NO_SYSCALL_STUB; } 295 AUE_NULL ALL { int shared_region_map_np(int fd, uint32_t count, const struct shared_file_mapping_np *mappings) NO_SYSCALL_STUB; }
內核中的實現也比較簡單,忽略錯誤檢查,關鍵的代碼如下:
int shared_region_check_np( __unused struct proc *p, struct shared_region_check_np_args *uap, __unused int *retvalp) { // ... shared_region = vm_shared_region_get(current_task()); if (shared_region != NULL) { /* retrieve address of its first mapping... */ kr = vm_shared_region_start_address(shared_region, &start_address); /* ... and give it to the caller */ error = copyout(&start_address, (user_addr_t) uap->start_address, sizeof (start_address)); // ... vm_shared_region_deallocate(shared_region); } }
其內部實現姑且不管,繼續回到用戶空間,所返回的地址可以強制轉換為dyld_cache_header
格式:
struct dyld_cache_header { char magic[16]; // e.g. "dyld_v0 i386" uint32_t mappingOffset; // file offset to first dyld_cache_mapping_info uint32_t mappingCount; // number of dyld_cache_mapping_info entries uint32_t imagesOffset; // file offset to first dyld_cache_image_info uint32_t imagesCount; // number of dyld_cache_image_info entries uint64_t dyldBaseAddress; // base address of dyld when cache was built uint64_t codeSignatureOffset; // file offset of code signature blob uint64_t codeSignatureSize; // size of code signature blob (zero means to end of file) uint64_t slideInfoOffset; // file offset of kernel slid info uint64_t slideInfoSize; // size of kernel slid info uint64_t localSymbolsOffset; // file offset of where local symbols are stored uint64_t localSymbolsSize; // size of local symbols information uint8_t uuid[16]; // unique value for each shared cache file uint64_t cacheType; // 1 for development, 0 for optimized };
檢查共享緩存空間存在則直接複製其UUID到進程的sharedCacheUUID
中,然後直接使用該緩存。
如果不存在,就需要進行創建,創建的過程如下:
- 如果是x86環境,需要判斷當前是否為安全啟動模式,則會刪除之前余留的cache文件,路徑為
/var/db/dyld/dyld_shared_cache_$arch
- 打開sharedCache文件,對於IPhone路徑為
/System/Library/Caches/com.apple.dyld/dyld_shared_cache_$arch
- 讀取文件的前8192位元組轉換為
struct dyld_cache_header
,並做一些合法性檢查 - 處理cache文件,主要是將mapping信息提取出來,保存為一個數組
mappings[]
- 調用
_shared_region_map_and_slide_np
映射每個mapping
mapping信息如下:
struct dyld_cache_mapping_info { uint64_t address; uint64_t size; uint64_t fileOffset; uint32_t maxProt; uint32_t initProt; };
和之前提到的segment信息類似,沒有feilsize,因為不存在padding。
_shared_region_map_and_slide_np
函數分別處理每個mapping,並最終使用mmap
來完成cache到內存的映射操作。
每個mapping info對應一個
struct shared_file_mapping_np
,但是這個結構體的定義在開源代碼中沒找到,並且在蘋果文檔中也進行了隱藏,見: https://developer.apple.com/documentation/kernel/shared_file_mapping_np
後記
本文通過對MachO文件的文件格式研究,介紹了MacOS和iOS中可執行文件的加載過程,從內核中的處理一直到動態連接器dyld的代碼分析。可以看出MachO與ELF相比實現方式各有千秋,但是在內核中原生增加了對代碼的簽名和加密,其實ELF也很容易實現類似的功能,但開放系統需要更多考慮兼容性的問題,不像蘋果可以大刀闊斧的隨便改。
對於MachO的深入理解其實也有助於日常的相關研究,比如Apple Store的加密實現以及代碼簽名的大致原理,還有針對dyld_cache的處理等,其中每一項都值得去深入挖掘。而且本文也沒有介紹到全部的MachO特性,比如Objective-C相關的段,具體的實戰部分後面有時間會再去整理一下。
參考資料
- Overview of the Mach-O Executable Format
- Mach Object Files
- apple/darwin-xnu
- opensource-apple/dyld
- RTFSC
本文由 Seebug Paper 發佈,如需轉載請註明來源。本文地址:https://paper.seebug.org/1363/
转载请注明:IMGIT » 深入淺出 MachO