在上篇文章程式碼注入,竊取微信密碼中我們們已經簡單的提到了MachO,在用Framework做程式碼注入的時候,必須先向MachO的Load Commons中插入該Framework的的相對路徑,讓我們的iPhone在執行MachO的時候能夠識別並載入Framework!
窺一斑而知全豹,從這些許內容其實已經可以瞭解到MachO在我們APP中的地位是多麼的重要。同樣,在我們們逆向的實踐中,MachO也是一道繞不過去門檻!
老規矩,片頭先上福利:點選下載demo
這篇文章會用到的工具有:
廢話不多說,本篇文章將會從以下幾點細說到底什麼是MachO!
- 什麼是MachO
- MachO的檔案結構
- 從DYLD原始碼的角度看APP啟動流程 (重點!!!)
一、什麼是MachO
Mach-O其實是Mach Object檔案格式的縮寫,是mac以及iOS上可執行檔案的格式, 類似於windows上的PE格式 (Portable Executable ), linux上的elf格式 (Executable and Linking Format)
1、常見的MachO檔案
a、目標檔案:.o
b、庫檔案:.a .dylib Framework
c、可執行檔案:dyld .dsym
2、如何檢視檔案格式
我們可以通過file指令檢視檔案的具體格式
目前已知的架構分為armv7,armv7s,arm64,i386,x86_64等等,MachO中其實也是這些架構的集合。可以隨意建立一個空工程:Dome1(空工程就不給Demo了)
檢視Build出的Dome1.ipa中的MachO
將最低版本設定為iOS 12,用release打包出的Dome1.ipa中的MachO
將最低版本設定為iOS 8,用release打包出的Dome1.ipa中的MachO
從上面三張圖就可以確定MachO可以是多架構的二進位制檔案,稱之為「通用二進位制檔案」
通用二進位制檔案是蘋果公司提出的一種程式程式碼。能同時適用多種架構的二進位制檔案 a. 同一個程式包中同時為多種架構提供最理想的效能。 b. 因為需要儲存多種程式碼,通用二進位制應用程式通常比單一平臺二進位制的程式要大。 c. 但是由於兩種架構有共通的非執行資源,所以並不會達到單一版本的兩倍之多。 d. 而且由於執行中只呼叫一部分程式碼,執行起來也不需要額外的記憶體。
注:其實除了更改最低版本號可以改變MachO的架構,在XCode的中也可以主動設定
3、拆分、重組MachO
// 使用lipo -info 可以檢視MachO檔案包含的架構
$ lipo -info MachO檔案
// 使用lipo –thin 拆分某種架構
$ lipo MachO檔案 –thin 架構 –output 輸出檔案路徑
// 使用lipo -create 合併多種架構
$ lipo -create MachO1 MachO2 -output 輸出檔案路徑
複製程式碼
二、MachO的檔案結構
先上一張官網圖:
MachO分為三部分結構:Header、Load Commons、Data1、Header
Header 包含該二進位制檔案的一般資訊 位元組順序、架構型別、載入指令的數量等。 使得可以快速確認一些資訊,比如當前檔案用於32位還是64位,對應的處理器是什麼、檔案型別是什麼
本文從兩個視角分析Header,分別是「用MachOView視覺化後直觀的檢視」和「系統原始碼解析」
- 用MachOView視覺化後直觀的檢視 上篇文章已經講過使用MacOView可以直接檢視一個MachO檔案,如下圖
- 系統原始碼解析 在MachO的原始碼檔案中同樣有對應的欄位。如下圖:
2、Load Commons
上圖Load Commons中的大部分欄位在下表中可以找到相關的含義。Load commands是一張包含很多內容的表。 內容包括區域的位置、符號表、動態符號表等。
名稱 | 含義 |
---|---|
LC_SEGMENT_64 | 將檔案中(32位或64位)的段對映到程式地址空間中 |
LC_DYLD_INFO_ONLY | 動態連結相關資訊 |
LC_SYMTAB | 符號地址 |
LC_DYSYMTAB | 動態符號表地址 |
LC_LOAD_DYLINKER | 使用誰載入,我們使用dyld |
LC_UUID | 檔案的UUID |
LC_VERSION_MIN_MACOSX | 支援最低的作業系統版本 |
LC_SOURCE_VERSION | 原始碼版本 |
LC_MAIN | 設定程式主執行緒的入口地址和棧大小 |
LC_LOAD_DYLIB | 依賴庫的路徑,包含三方庫 |
LC_FUNCTION_STARTS | 函式起始地址表 |
LC_CODE_SIGNATURE | 程式碼簽名 |
其中LC_LOAD_DYLINKER
和LC_LOAD_DYLIB
-
LC_LOAD_DYLINKER 該欄位標明我們的MachO是被誰載入進去的。
可以理解為LC_LOAD_DYLINKER指向的地址是微信APP載入小程式的引擎,而我們的MachO是小程式。在上圖中可以看到我們的Demo1的LC_LOAD_DYLINKER指向的地址就是dyld
。dyld
確實是用來載入我們app的,在下面一節將會對dyld
的原始碼進行分析,講述dyld
是如何對MachO進行載入的。 -
LC_LOAD_DYLIB 該欄位標記了所有動態庫的地址,只有在LC_LOAD_DYLIB中有標記,我們MachO外部的動態庫(如:Framework)才能被
dyld
正確的引用,否則dyld
不會主動載入,這也是上篇文章,程式碼注入的關鍵所在!
3、Data
Data 通常是物件檔案中最大的部分,包含Segement的具體資料,如靜態C字串,帶引數/不帶引數的OC方法,帶引數/不帶引數的C函式。
在Demo1中編寫一下程式碼
- 靜態C字串
- 靜態OC字串
- 帶引數的OC方法
- 不帶引數的OC方法
- 帶引數的C函式
- 不帶引數的C函式
如圖:
檢視MachO中對應的Data段:cstring
,methname
,如下兩圖:
可以看到,全域性靜態C字元(myCString
),方法裡面的字串(myCFuncAString:%d
,myCFuncString
,%s
,myOCFuncAString:%s
,myOCFuncString:%s
)都被儲存在data段的cstring
裡了,哪怕是%d
,%s
等等這樣的引數型別字串也被儲存在內。但所有同樣的字串只會被儲存一次。
同樣所有的OC方法都被儲存在methname
裡了。
這裡有個問題: 在這兩個表中並沒有看到全域性的靜態OC字串(
myOCString
)和C函式(myCFuncA(int a)
,myCFunc()
)這裡為什麼沒有?他們應該會被以是形式儲存在哪裡?
上面用cstring
和methname
距離了data段的作用,同樣的所有類名,協議名等也是以同樣形式儲存在這。
上面已經對MachO有了一個大概的瞭解,接下來本文就對dyld
這麼一個重要的東西進行一個初探。
三、從DYLD原始碼的角度看APP啟動流程
1、在main函式中斷點檢視
首先思考,在main函式中結束通話點能不能檢視到APP啟動對應的堆疊?
這部分其實靠想,靠猜測很難有答案,我們直接用XCode直接嘗試:
可以看到在main函式斷點並不能看到啟動的對應堆疊,說明main函式也是被別人呼叫的,而不是處於app啟動的堆疊中。
既然main查不到啟動堆疊,那麼比app更早執行的load方式是否可以找得到呢?
2、在load方法中斷點檢視
同樣的,直接XCode除錯:
在這可以發現更多的資訊,比如在堆疊底部的彙編(這裡用的是手機除錯,所以是arm64架構)可以很明顯的發現,是呼叫了用dyld中的dyldbootstrap檔案中的start方法。
馬不停蹄,開啟dyld原始碼,找到對應的dyldbootstrap檔案中的start函式。
點選這裡下載dyld原始碼
3、在dyldbootstrap中檢視start函式
//
// 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
// 滑塊,ASLR技術,地址偏移,是MachO檔案在記憶體中的地址重定向
slide = slideOfMainExecutable(dyldsMachHeader);
bool shouldRebase = slide != 0;
#if __has_feature(ptrauth_calls)
shouldRebase = true;
#endif
if ( shouldRebase ) {
// 重定向
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
// 正在的啟動函式,在dyld中的_main函式中
uintptr_t appsSlide = slideOfMainExecutable(appsMachHeader);
return dyld::_main(appsMachHeader, appsSlide, argc, argv, envp, apple, startGlue);
}
複製程式碼
從start函式的原始碼可得知道:dlyd會記憶體中找到一塊地址給MachO使用,也就是ASLR,記憶體偏移。
最後start函式執行了一個main函式(這個可以不是我們app中的main函式,而是dyld的)並返回。同樣的,我們不能只蹭一蹭,要進去幹!
4、在dlyd中檢視main函式
這個函式厲害了,如下圖,足足快500行了!
我們抓住其中的關鍵程式碼,足步分析在main函式之前dyld到底幫我們做了哪一些事情。
1、配置環境變數
從main函式的初始,到函式getHostInfo()
之前都是在配置一些環境變數,已經一些執行緒相關的,涉及內容太過底層,這就不一一分析了(其實是能力不及?)
if
判斷,其實裡面都是對應的環境變數,這些都是可以在XCode進行相關的配置,進行對應的操作(如Log相關資訊)。
2、載入共享快取庫
在原始碼中可以看到在我們iOS系統中,共享快取庫被明確一定會被載入。在iOS系統中,每個程式依賴的動態庫都需要通過dyld(位於/usr/lib/dyld)一個一個載入到記憶體,然而如果在每個程式執行的時候都重複的去載入一次,勢必造成執行緩慢,為了優化啟動速度和提高程式效能,共享快取機制就應運而生。所有預設的動態連結庫被合併成一個大的快取檔案,放到/System/Library/Caches/com.apple.dyld/目錄下,按不同的架構儲存分別儲存著。其中包括UIKit,Foundation等基礎庫。
因為這種機制的存在,使得iOS在的對這些基礎庫的載入的時候時間和記憶體都得到節約!
但是有時因為共享快取庫的機制的存在使得iOS在共享快取庫裡面的C函式,也就是系統C函式變的不是那麼靜態,有了些許OC執行時的特性!
這部分內容將會在下一篇文章著重講解!從不一樣的角度看Runtime!
3、例項化主程式
載入主程式其實就是對MachO檔案中LoadCommons段的一些列載入! 我們繼續對程式碼的跟進,如下6張圖:
補充:例項化完之後呼叫addImage(image),將例項化出來的映象加入所有的映象列表sAllImages,主程式永遠是sAllImages的第一個物件!
從原始碼可以看出,載入主程式這一步其實很簡單,就是將MachO檔案中的部分資訊一步一步的放入記憶體。其中從最後一張圖可以瞭解到:
- 最大的segment數量為256個!
- 最大的動態庫(包括系統的個自定義的)個數為4096個!
4、載入動態連結庫
載入動態連結庫,如XCode的ViewDebug、MainThreadChecker,我們之後程式碼注入的庫也是通過這種形式新增的!
5、連結主程式
link函式裡面其實就是對之前的imges(不是圖片,這是映象)進行一些核心操作,這部分Apple沒有開源出來,只能看到些許原始碼,有興許的同學可以自行查閱:
6、載入Load和特定的C++的建構函式方法
無論是從之前斷點load方法還是我們現在一步步對原始碼的根據,都能瞭解到,dyld
的initializeMainExecutable
就是就載入load的入口:
並且最後都能接到一個結論:
由dyld
的notifySingle
函式經過一系列的跳轉,最終會跳轉到objc
原始碼中的call_load_methods
函式!!
那麼這中間的的過程到底是怎麼樣的呢?看下方的gif:
最後找到函式_dyld_objc_notify_register
,就在全域性都找不到一個呼叫的地方了,其實這個函式本身就不是給dyld
呼叫的,而是提供給外部呼叫的。怎麼找到是誰呼叫了_dyld_objc_notify_register
呢?
繼續開啟之前的Demo1,在工程中加上_dyld_objc_notify_register
的符號斷點看看。
執行工程,斷住之後再次檢視函式呼叫棧:
這就可以很清晰的看到,原來是objc_init
呼叫了我們們的_dyld_objc_notify_register
函式。
同樣開啟objc
的原始碼(點選下載objc原始碼 )
快速定位_dyld_objc_notify_register
的呼叫位置。如圖:
這樣dyld是如何載入我們們的load方法就被找到了。
期間如果有細心的同學可能看到了在notifySingle
後面緊跟著doInitialization
這樣一個函式,這是一個系統特定的C++建構函式的呼叫方法。
這種C++建構函式有特定的寫法,如下:
__attribute__((constructor)) void CPFunc(){
printf("C++Func1");
}
複製程式碼
有興趣的同學可以嘗試實現一次,在MachO檔案中找到對應的方法! 當然,這在Demo1也是有的。
7、尋找APP的main函式並呼叫
當上面的load和C++方法載入完成之後就會回到dyld的main方法裡面,尋找APP的main函式並呼叫。
最終dyld的main函式中的主要流程就已經走完了,當然這7個步驟是一條主線,期間還會有很多其他的步驟,過程非常繁瑣,這就不一一舉例了。大家可以通過閱讀dyld的原始碼盡收眼底。
四、總結
本文講述了MachO的概述,檔案結構,在從其中Load Commons中的LC_LOAD_DYLINKER引出dyld
,接下根據dyld
原始碼分析了APP的啟動流程。分別是:
1、配置環境變數
2、載入共享快取庫
3、例項化主程式
4、載入動態連結庫
5、連結主程式
6、載入Load和特定的C++的建構函式方法
7、尋找APP的main函式並呼叫
另外dyld
中LC_LOAD_DYLIB的(載入動態連結庫)存在,為我們逆向注入程式碼提供了無限可能。
MachO中其實還有一些符號表,為系統提供查詢對應的方法名稱提供了路徑,這些在下一張文章中將會更加詳細的講到。
五、參考
1、Dynamic Linking of Imported Functions in Mach-O
2、《iOS應用逆向工程》沙梓社,吳航 著 ,機械工業出版社