綠樹陰濃夏日長,樓台倒影入池塘。--《唐高骈·山亭夏日》
iOS系統生成的可執行程式或者動态庫檔案的存儲布局格式被稱之為mach-o格式。檔案中存放着程式的代碼和資料,而程式運作時系統會為其建立一個程序,以及配置設定虛拟記憶體空間。同時會把程式檔案中的内容加載到虛拟記憶體位址空間中去,這種加載的方法一般采用記憶體映射檔案的技術來實作。所謂的映像可以了解為将一個程式檔案的内容加載到程序虛拟記憶體中的内容,也就是說程序的映像就是程式磁盤檔案在記憶體中的一個副本。 一般來說一個程序中映像的内容和記憶體布局結構會和程式檔案的内容以及存儲布局結構一緻,映像的首位址是一個<code>struct mach_header</code>的結構體指針。映像中内容的排列布局和程式檔案都是以段(Segment)為機關進行排列的。但是有一些情況映像的記憶體布局和内容可能會和程式檔案的記憶體布局和内容不一緻:
映像中的資料段部分,因為資料段部分大多是可以被讀寫通路的,也就是說可以在運作時被修改,或者某些資訊會進行rebase處理。是以資料段不能被程序之間共享,而是每個程序單獨維護一份。當然為了效率和性能系統會采用一種稱之為Copy on write的技術來實作單獨副本的拷貝的。通常隻有不可變的代碼段部分才會是記憶體和檔案中的内容保持一緻,并且多程序共享。一個很常見的例子就是程序中加載的動态庫和架構中的代碼段部分通常都是所有程序共享。
即使是代碼段也有可能映像中的内容和程式檔案中的内容不一緻。有一些映像中的某些段的内容會是系統中緩存的段,而不是程式檔案對應的段。一個很有代表性的例子就是CoreLocation這個庫,當這個庫被加載時你就會發現其映像中的有一些代碼段的内容其實是系統緩存的内容而不是程式檔案中的内容。
是以說程式檔案和程式被加載後在記憶體中映像之間并不是一一對應的。程式檔案和映像之間的關系就如程式和程序之間的關系是一樣的。在程式運作後對其在程序中所有的mach-o資料結構的通路都是基于映像而不是基于程式檔案的。
建構一個程式時為了友善計算和處理會為這個程式設定一個預設在記憶體中加載的基位址。這樣在程式中所有涉及到位址存儲的代碼中的位址變量都是以這個基位址為标準的。比如我們在代碼中有變量儲存一個函數的位址或者在rumtime中的OC類的方法結構體:<code>struct method_t</code>中的imp儲存的函數的位址等等。正常情況下如果我們的程式加載時也是按照程式中指定的基位址加載到虛拟記憶體中對應的位址時則一切都正常而且也不需要做任何的改變。但實際情況則不同:
任何一個庫或者可執行程式在建構時都會指定一個加載的基位址,但是卻無法保證這個基位址的唯一性。和無法保證程式映像的位址區間不産生重疊。是以有可能出現多個庫加載到記憶體時的重疊覆寫的情況。
iOS系統為保證的應用安全采用了一種稱之為**ASLR(Address space layout randomization)**的技術。這種技術會使得每個程式或者庫每次運作加載到記憶體中時的基位址都不是固定而是随機的,這種機制會增加黑客的破解難度。
上面的兩種情況表明一個程式或者庫加載到記憶體時的真實的基位址和程式建構時指定的基位址是不一樣的。系統會為可執行程式和每個庫選擇不重疊的區域進行加載。但是這樣就會出現在程式中所有以建構時基位址為标準的那些位址指針出現通路異常,因為這些位址值并不是真實在記憶體中的位址值。
為了解決這個問題系統會在建構的程式或庫中添加一個特殊的load command指令:LC_DYLD_INFO或者LC_DYLD_INFO_ONLY。這部分資訊用來記錄所有需要進行位址調整的位置。這樣當程式被加載到記憶體時,加載器就會将需要調整的位址分别進行調整處理,以便轉化為真實的記憶體位址。這個過程稱之為基位址重定向(rebase)。
假設程式建構時指定的基位址為A,程式中某處儲存的一個函數指針位址為x,而程式被加載到記憶體時的真實基位址為B。也就是說真實的基位址和建構時的基位址的偏移差就是B-A。我們稱這個偏移內插補點為Slide值。是以真實的位址x被調整後應該是: x + (B - A)了。
一個程式在建構時的基位址值可以在程式的第一個名為__TEXT的代碼段描述結構體<code>struct segment_command</code>中的vmaddr資料成員中擷取,而程式被加載後的得到的映像的mach-o頭部結構體<code>struct mach_header</code>指針則是映像被加載的真實的基位址,是以:
映像的Slide值 = 映像的mach_header結構體指針 - 映像的第一個__TEXT代碼段描述結構體<code>struct segmeng_command</code>中的vmaddr資料成員的值。
當然系統也提供了接口API來擷取可執行程式或者庫的映像的Slide值。這個将會在下面介紹。
mach-o檔案由諸多的load command組成,每個load command所代表的是一種資料類型。比如有的load command是用來存放程式代碼和全局變量資料,有的load command是用來存放符号表,有的load command是用來存放代碼簽名資訊等。每種load command都是結構體<code>struct load_command</code>的擴充結構體。其中的cmd字段用來描述這種load command的類型。
類型為LC_SEGMENT或者為LC_SEGMENT_64的load command被稱之為段(Segment)。一個可執行程式中的代碼和全局變量資料都儲存在段中。描述段的資訊是一個<code>struct segment_command</code>結構體。一個程式中可以存在着很多的段,每個段有一個唯一的段名(segment name)。比如一個可執行程式中所有的代碼都儲存在名字為:__TEXT的代碼段中,而所有的資料都儲存在名字為:__DATA的資料段中。段以頁為邊界進行對齊。
每個段則由多個節(Section)組成。節是内容分類的最小管理單元。每個節的描述資訊是一個稱之為:<code>struct section</code>的結構體。每個節有一個唯一的名稱用來辨別這個節。比如代碼段中有一個名為:__text的節用來儲存程式中使用者編寫的源代碼對應的機器指令,而一個名為:__stub_helper的節則儲存所有調用的外部函數的樁代碼。下面的一張圖展示的就是程式中的段和節的結構布局:

對映像進行操作的API都在<code><mach-o/dyld.h></code>中聲明。你可以import這個頭檔案來使用裡面定義的函數。下面我會分别介紹這些函數。
1.擷取目前程序中加載的映像的數量
2.擷取某個映像的mach-o頭部資訊結構體指針
函數的入參為映像在程序當中的索引号,函數傳回的值是一個映像的mach-o頭部資訊<code>struct mach_header</code>結構體指針,如果是64位系統則傳回的是<code>struct mach_header_64</code>結構體指針。你可以通過這個函數傳回的映像的頭部結構體來周遊和通路映像中的所有資訊和資料。
一個映像的頭部資訊結構體指針其實就是映像在記憶體中加載的基位址。
一般情況下索引為0的映像是dyld庫的映像,而索引為1的映像就是目前程序的可執行程式映像。
系統還提供一個沒有在頭檔案中聲明的函數:
這個函數傳回目前程序的可執行程式映像的頭部資訊結構體指針。因為這個函數沒有在某個具體的頭檔案中被聲明,是以當你要使用這個函數時需要在源代碼檔案的開頭進行聲明處理:
3.擷取程序中某個映像加載的Slide值
函數的入參為映像在程序當中的索引号,函數的傳回值是映像加載的Slide值。關于Slide值的介紹已經在上面有詳細說明。在mach-o格式程式中的結構體描述資訊中凡是涉及到指針字段都應該加上這個值才是真實的記憶體位址。
4.擷取程序中某個映像的名稱
函數的入參為映像在程序當中的索引号,函數的傳回值是映像對應庫的全路徑名稱,傳回的字元串我們不能修改也不必去銷毀它。
5.注冊映像加載和解除安裝的回調通知函數
如果你通過函數<code>_dyld_register_func_for_add_image</code>注冊了一個映像被加載時的回調函數時,那麼每當後續一個新的映像被加載但未初始化前就會調用注冊的回調函數,回調函數的兩個入參分别表示加載的映像的頭結構和對應的Slide值。如果在調用<code>_dyld_register_func_for_add_image</code>時系統已經加載了某些映像,則會分别對這些加載完畢的每個映像調用注冊的回調函數。
如果你通過函數<code>_dyld_register_func_for_remove_image</code>注冊了一個映像被解除安裝時的回調函數時,那麼每當一個映像被解除安裝前都會調用注冊的回調函數,回調函數的兩個入參分别表示解除安裝的映像的頭結構和對應的Slide值。
這兩個函數的作用通常用來做程式加載映像的監控以及一些統計處理。
6.擷取某個庫連結時和運作時的版本号
我們在XCODE工程中連結一些系統動态庫時,有時候會選擇某個具體版本的動态庫,但是有些作業系統可能不一定會提供對應版本的動态庫,這樣就會導緻程式運作時加載的動态庫版本和連結時指定的動态庫的版本不一緻。還有一種場景就是工程中并沒有連結對應的動态庫,但是因為其他庫會連結對應的動态庫,就會出現雖然沒有直接連結對應的動态庫但是還是會加載對應的動态庫的情況。 是以系統提供了這兩個API可以擷取某個動态庫連結和加載運作時的版本号。這兩個函數的入參都是動态庫的名稱,這個名稱是不帶路徑和擴充名以及不帶lib字首的庫名稱。函數傳回庫對應的版本号,如果庫不存在或者沒有被加載或者沒有被連結則傳回-1。比如下面的代碼:
如果我們的程式并沒有顯示的連結libc++.dylib則後者函數會傳回-1。而前者則一般都會傳回一個對應的libc++的版本号。
這兩個函數的主要用來做一些庫分析和運作監測等功能,比如可以檢測某個庫是否是一個在運作時被加載而不是顯示連結進來的動态庫。
7.擷取目前程序可執行程式的路徑檔案名
函數的入參buf和bufsize指明儲存可執行檔案路徑名的緩存和緩存的尺寸,其中的bufsize是要指明緩存的尺寸,并且會輸出可執行檔案路徑名稱的真實尺寸。如果函數調用傳回正确則傳回0,否則傳回-1。就比如下面的例子:
8.注冊目前線程結束時的回調函數
有時候我們想監控線程的結束事件,那麼就可以用這個函數來實作。這個函數用來監控目前線程的結束,當線程結束或者終止時就會調用注冊的回調函數,<code>_tlv_atexit</code>函數有兩個參數:第一個是一個回調函數指針,第二個是一個擴充參數,作為回調函數的入參來使用。
不明白為什麼這個函數會放在<mach-o/dyld.h>中聲明,完全不搭界!
對段和節進行操作的API都在<code>import <mach-o/getsect.h></code>中聲明。你可以import這個頭檔案來使用裡面定義的函數。當然如果你了解mach-o的檔案格式的話可以不用這些API,而是直接根據映像的頭部結構體<code>struct mach_header</code>來周遊和通路這些段和節。不過既然系統已經提供相關的API,那麼還是優先考慮用它們最合适了。下面我會分别介紹這些函數。
段和節操作的API在系統的libmacho.dylib庫中實作,這個庫暫時還沒有開源出來。
1. 擷取程序中映像的某段中某個節的非Slide的資料指針和尺寸
這兩個函數傳回程序中可執行程式映像或者某個加載的動态庫中的某個段中某個節的資料指針和尺寸。這兩個函數其實就是傳回對應的節描述資訊結構<code>struct section</code>中的addr和size兩個資料成員的值。需要注意的是傳回的位址值是沒有加上Slide值的指針,是以當我們要在程序中通路真實的位址時需要加上對應的Slide值,下面就是一個執行個體代碼:
getsectdata函數的代碼實作如下:
個人不建議用這個函數而是用下面會介紹到的getsectiondata函數更合适。
2.擷取段和節的邊界資訊
這幾個函數主要用來擷取指定段和節的結束位置,以及用來确定某個位址是否在指定的邊界内。需要注意的是這幾個函數傳回的邊界值是并未加Slide值的邊界值。下面是這幾個函數的内部實作:
3.擷取程序中可執行程式映像的段描述資訊
這兩個函數傳回程序中可執行程式映像的某個段的段描述資訊。段描述資訊是一個<code>struct segment_command</code>或者<code>struct segment_command_64</code>結構體。
比如下面代碼傳回程序中可執行程式映像代碼段__TEXT的段資訊。
4.擷取程序中可執行程式映像的某個段中某個節的描述資訊
這兩個函數分别傳回32位系統和64位系統中的程序中可執行程式映像的segname段中的sectname節的描述資訊。節的描述資訊是一個<code>struct section</code>或者<code>struct section_64</code>的結構體。比如下面的代碼傳回代碼段__TEXT中的代碼節__text的描述資訊:
5.擷取程序中映像的段的資料
函數傳回程序内指定映像mhp中的段segname中内容的位址指針,而整個段的尺寸則傳回到size所指的指針當中。這個函數的内部實作就是傳回段描述資訊結構<code>struct segment_command</code>中的vmaddr資料成員的值加上映像mhp的slide值。而size中傳回的就是段描述資訊結構中的vmsize資料成員。
因為在前面講過因為映像加載時的slide值的緣故,是以映像中的各種mach-o結構體中涉及到位址的資料成員的值都需要加上slide值才能得到映像在記憶體中的真實加載位址。
程序中每個映像中的第一個__TEXT段的資料的位址其實就是這個映像的mach_header頭結構的位址。這是一個比較特殊的情況。
下面的代碼示範的是擷取程序中第0個索引位置映像的__DATA段的資料。
6.擷取程序映像的某段中某節的資料
函數傳回程序内指定映像mhp中的段segname中sectname節中内容的位址指針,而整個節的尺寸則傳回到size所指的指針當中。這個函數的内部實作就是傳回節描述資訊結構<code>struct section</code>中的addr資料成員的值加上映像mhp的slide值。而size中傳回的就是段描述資訊結構中的size資料成員的值。
下面的例子擷取程序中第0個映像的"__TEXT"段中的"__text"節的資料位址指針和尺寸:
7.擷取mach-O檔案中的某個段中某個節的描述資訊
這一系列函數分别傳回32位系統和64位系統的mach-o檔案的節的描述資訊。每個函數都有segname和sectname分别指明要擷取的段名和節名。參數mhp則表明mach-o檔案的頭部結構指針。對于有一些系統或者mach-o檔案中的數值采用big-endian來編碼,是以對于這些采用big-endian編碼的結構來說就需要傳遞fSwap來确定是否交換這些編碼。
這一系列函數中的mhp結構不局限于程序中的映像的頭部結構,針對mach-o檔案的頭部結構也适用,如果你不了解映像和檔案的差別則請看文章中的開頭的介紹。
因為不管是程序中的映像的Section的排列以及mach-o檔案中的Section的排列都是一緻的,是以其實上述的getsectbyname的實作就是借助本節提供的函數實作的,其實作的代碼如下:
8.擷取mach-o檔案中的某段中的某個節的資料指針和尺寸
這兩個函數傳回32位系統或者64位系統中的某個mach-o檔案中的某個段中某個節的資料指針和尺寸。這兩個函數其實就是傳回對應的節描述資訊結構struct section中的addr值和size值。因為這兩個函數是針對mach-o檔案的,但是也可以用在對應的庫映像中,當應用在庫映像中時就要記得對傳回的結果加上對應的slide值才是真實的節資料所對應的位址!
iOS系統提供了所謂方法交換(method swizzling)的黑魔法機制。它可以在運作時替換掉某個類的某個方法的預設實作。然而技術有兩面性,對于越獄系統來說,惡意開發人員可以通過動态庫注入并利用方法交換的技巧來改變程式運作的原有邏輯,進而可以跨過一些正常檢測而謀取非法利益。
凡事有攻就有守,通過本文中介紹的API函數就可以在一定程度上檢測某個類中的某個方法是否被非法HOOK。以可執行程式中的某個類的執行個體方法為例。可執行程式中定義的類的執行個體方法的實作位址總是在可執行程式映像的位址區間範圍内,即使是這個方法被可執行程式中的其他方法HOOK了,這個HOOK的方法位址仍然是在可執行程式的映像位址區間範圍内,我們仍然認為這是一個合法的HOOK。如果可執行程式中的類的執行個體方法被惡意攻擊者通過動态庫注入并以方法交換的形式來HOOK原有方法的實作時,因為HOOK的方法位址是在惡意注入的動态庫映像的位址區間範圍内,是以我們就可以通過檢測這個類的執行個體方法的實作位址是否在可執行程式的映像的位址區間範圍内來判斷這個方法是否被惡意HOOK了。下面就是這種檢測的具體實作代碼,建議檢測的代碼用C函數來實作而不是用OC類的方法來實作,否則這個檢測邏輯也有可能被HOOK。