- fishhook 的本質(zhì)是遍歷 image 中的懶加載和非懶加載表,將里面的函數(shù)地址替換成自定義的函數(shù)地址;
- 因為 objc 的方法調(diào)用走的是消息查找和轉(zhuǎn)發(fā),所以 fishhook 并不能起作用,fishhook 只能替換 C 系函數(shù),即非消息轉(zhuǎn)發(fā)的函數(shù);
- 懶加載和非懶加載表是因為動態(tài)庫需要依賴其他動態(tài)庫中的符號而產(chǎn)生的,動態(tài)庫內(nèi)部沒有公開的函數(shù)或者是被 static 修飾的函數(shù)不會被創(chuàng)建到懶加載表或者非懶加載表中,所以 fishhook 只能 hook 動態(tài)庫中的公開函數(shù);
前言
這里需要對 mach-o 有比較全面的理解,詳情見 mach-O結(jié)構(gòu)分析,不展開了。
大概說下:
- mach-O 分為三部分,第一部分是 header,表示 Mach-O 的一些基本信息。第三部分是數(shù)據(jù)區(qū),就是一團(tuán)一團(tuán)的代碼或者數(shù)據(jù);
- 第二部分是 Load Command,存儲著不同類型的 Command,主要用于保存一些信息。有 Load Command 的會指向數(shù)據(jù)區(qū)的某一段數(shù)據(jù)并描述這一段數(shù)據(jù)的一些信息,有的 Load Command 不指向具體的數(shù)據(jù),單純的用于記錄一些信息,比如記錄 dyld 的路徑、main 函數(shù)的位置等;
- 不同的類型的 Command 對應(yīng)著不同的結(jié)構(gòu)體,
load_command結(jié)構(gòu)體類似于基類,其他類型的 command 結(jié)構(gòu)體可以理解成繼承自這個結(jié)構(gòu)體; -
segment_command只是其中一種,表示這個 command 指向數(shù)據(jù)區(qū)具體的 segment ,如__TEXT、__DATA/__DATA_CONST都是這種類型。而動態(tài)鏈接最關(guān)鍵的__LINKEDIT也是這種類型,只是在 MachOView 上沒有直接在數(shù)據(jù)區(qū)標(biāo)出這個 segment; - 這里使用到的還有類型為
LC_SYMTAB和LC_DYSYMTAB,對應(yīng)的結(jié)構(gòu)體為symtab_command和dysymtab_command。這兩個 command 不指向具體的 segment,只是為動態(tài)鏈接器(dyld)提供一些信息,最重要的信息就是符號表和字符串表相對于 Mach-O 文件在磁盤中的文件偏移,即fileoff(具體為symoff/stroff);
一. 添加監(jiān)聽
fishhook 的第一步是調(diào)用 dyld 提供的接口來對一些事件進(jìn)行監(jiān)聽,主要代碼如下:
if (!_rebindings_head->next) {
_dyld_register_func_for_add_image(_rebind_symbols_for_image);
} else {
uint32_t c = _dyld_image_count();
for (uint32_t i = 0; i < c; i++) {
_rebind_symbols_for_image(_dyld_get_image_header(i), _dyld_get_image_vmaddr_slide(i));
}
}
調(diào)用 _dyld_register_func_for_add_image() 傳入一個函數(shù)作為回調(diào),兩種情況下觸發(fā)回調(diào)函數(shù):
- 有新的 image 被 load;
- 添加監(jiān)聽時,已存在的 image 觸發(fā)一次回調(diào);
回調(diào)函數(shù)格式如下:
extern void _dyld_register_func_for_add_image(void (*func)(const struct mach_header* mh, intptr_t vmaddr_slide));
兩個參數(shù)的意義:
-
mach_header:第一個參數(shù)就是 image 在該進(jìn)程的虛擬內(nèi)存中的初始地址; -
vmaddr_slide:當(dāng)前獨立虛擬內(nèi)存空間的 ALSR 偏移;
代碼主要形式為主工程代碼、動態(tài)庫、靜態(tài)庫。因為靜態(tài)庫直接被復(fù)制到了主工程,所以不需要考慮靜態(tài)庫的情況。動態(tài)庫又分為共享緩存庫、非共享緩存庫。所以,這兩個地址不一定相等,大概有這么幾種情況:
- 主工程;
- 動態(tài)共享緩存庫;
- 插入的動態(tài)庫;
當(dāng) image 為主工程時,slide 就是 ALSR 中的偏移。又因為主工程一般都會包含一個 __PAGEZERO,這個 segment 在 disk 中大小為 0,在虛擬內(nèi)存中有固定的的大小:

所以主工程的 mach_header = __PAGEZERO + vmaddr_slide;
當(dāng) image 為共享緩存庫時,這些庫都是被存在 shared cache 中的,所以這些 image 的 slide 都是相同的:

如上圖,共享緩存庫的 vmaddr_slide 都是 0xa1098000。
當(dāng) image 為插入的動態(tài)庫或者工程自己嵌入的動態(tài)庫時,這些動態(tài)庫既不是保存在共享緩存庫中,也不是和主工程處在統(tǒng)一個虛擬內(nèi)存空間,而是獨立的空間。正因為空間獨立,所以動態(tài)庫中的代碼或者符號和主工程重復(fù)時,也不會出錯。這些動態(tài)庫的地址和偏移如下:

總結(jié):
- 代碼在虛擬內(nèi)存中大概有三種形式:主工程、共享緩存庫、嵌入的動態(tài)庫;
- 主工程因為一般都包含
__PAGEZERO,需要映射__PAGEZERO。這個 segment 主要是兼容 32 位系統(tǒng),或者說更像是一個限制,因為如果訪問__PAGEZERO內(nèi)的地址,都會當(dāng)做空指針處理,強(qiáng)行修改就會BAD_ACCESS; - 共享緩存庫作為一個大的容器存放著許多系統(tǒng)的動態(tài)庫;
- 嵌入的動態(tài)庫擁有獨立的虛擬內(nèi)存空間;
- 另外,內(nèi)核代碼常駐進(jìn)程,當(dāng)程序啟動時,會被映射到該進(jìn)程的虛擬空間特定的地方,這些代碼一般都是一些中斷指令,屬于內(nèi)核態(tài)代碼,用戶態(tài)無法訪問;
二、計算 Load Command 地址
監(jiān)聽完成后會觸發(fā)回調(diào)進(jìn)而進(jìn)入下一步,主要邏輯在 rebind_symbols_for_image() 中;
PS:可以在這個函數(shù)的開始部分打斷點獲取當(dāng)前 image 相關(guān)的信息:

header 其實在 fishhook 中沒怎么發(fā)揮作用,只是用來計算出 Load Command 的地址,代碼如下:
// header指針指向__TEXT初始地址
// _TEXT頭部是一個Header(mach_header_t結(jié)構(gòu)體),緊接著是Loac Command
uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t);
因為 Load Command 緊跟在 Header 之后,所以代碼很簡單,就是首地址 + header 的 size;
三、 獲取三個 command 的地址
這個階段就是遍歷 load command,獲取三個 command :linkedit、symtab、dysymtab;
這一步主要代碼如下:
// header->ncmds為loadcommand總共包含的段數(shù)
for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
cur_seg_cmd = (segment_command_t *)cur;
if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
if (strcmp(cur_seg_cmd->segname, SEG_LINKEDIT) == 0) {
//__LINKEDIT
linkedit_segment = cur_seg_cmd;
}
} else if (cur_seg_cmd->cmd == LC_SYMTAB) {
// symbol table
symtab_cmd = (struct symtab_command*)cur_seg_cmd;
} else if (cur_seg_cmd->cmd == LC_DYSYMTAB) {
// indrect symbol table
dysymtab_cmd = (struct dysymtab_command*)cur_seg_cmd;
}
}
源碼分析:
ncmds 是 header 結(jié)構(gòu)體的屬性,表示 Load Command 的個數(shù),遍歷就是基于此;
LC_SEGMENT_ARCH_DEPENDENT 是經(jīng)過 fishhook 二次封裝的宏定義,表示 LC_SEGMENT / LC_SEGMENT_64 這種類型的結(jié)構(gòu)體。__LINKEDIT 就是這種類型:

而 LC_SYMTAB 和 LC_DYSYMTAB 的類型分別為表示符號表和動態(tài)符號表的 Load Command 類型:


經(jīng)過上述代碼,拿到了三個 command 的地址,接下來就要看看怎么使用這三個 command 了;
四、計算linkedit_base
先看這一句代碼:
uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
linkedit_segment 就是上一步中獲取到的三個 command 中的一個。但是這么一句簡單的代碼其實包含很多問題:
- vmaddr 和 fileoff 是什么?
- linkedit_base 為什么這么算?
- linkedit_base 的意義是什么?
五、vmaddr 和 fileoff
首先解決第一個問題:
- vmaddr 和 fileoff 是什么?
先說結(jié)論:
vmaddr:該 segment 在虛擬內(nèi)存中相對于文件起始位置的偏移;
fileoff:該 segment 在硬盤存儲中相對于文件起始位置的偏移;
解釋:
首先,參考 《Mach-O Runtime Architecture》可以知道 mach-O 文件是一種文件格式,用于存儲 macos 相關(guān)架構(gòu)上的可執(zhí)行文件。
另外,在 iOS/MacOS 中采用的是進(jìn)程級別的虛擬緩存。對于 iOS 而言,每個 App 在啟動之前都會新生成一個進(jìn)程,且為其分配和物理內(nèi)存大小一樣的虛擬內(nèi)存,并和物理內(nèi)存建立聯(lián)系,當(dāng)然這個映射關(guān)系是操作系統(tǒng)來控制。
再者,在程序運行時,mach-O 文件會被加載到虛擬內(nèi)存中。但是 segment 會按照一定的方式進(jìn)行內(nèi)存對齊。文檔上寫的是按頁對齊,但是實際上感覺不止如此,這里暫時不深究,需要知道的是:
- segment 因為內(nèi)存對齊的原因?qū)е拢禾摂M內(nèi)存中的 size >= 磁盤存儲中的 size;
最典型的例子就是 __PAGEZERO 段,在磁盤中不占據(jù)空間,被加載進(jìn)入內(nèi)存后占據(jù) 0x100000000 的空間,即一頁。所以主工程的起始地址一般為:slide + pagezero;
官方文檔的表述:

來看看實例:

如上圖,F(xiàn)oundation 和 UIKit 的 __LINKEDIT 段的 VMSize 都比 FileSize 要大,而且就上圖而言,看上去像是以 0x2000 對齊(0x181490->0x183000);
再來看個實例:

如上圖 __DATA 的 vmsize 都是大于 filesize 的,但是一個感覺是按照 0x10000對齊(0xC5000 -> 0xD0000),而另一個感覺像是按照 0x3000對齊(0x363000 -> 0x369000)。這就是為啥感覺對齊規(guī)則不確定的原因,文檔上也沒找到說明,暫不深究吧~~~
再來看個相等的情況:

如上圖,Foundation 和 UIKit的 __TEXT 段在虛擬內(nèi)存和磁盤存儲中的大小都是一致的;
至此,總結(jié)一下吧,我們知道了:
- segment 加載進(jìn)入虛擬緩存后會按照一定規(guī)則對齊,導(dǎo)致虛擬緩存中的大小大于等于磁盤中的大小;
那么繼續(xù),vmaddr 和 fileoff 表示什么?
先看 vmaddr:
首先將 fishhook 的代碼斷點打在本章的那一行代碼,然后計算:

如上圖,可知:
linkedit_segment->vmaddr+ slide = __LINKEDIT 段在虛擬內(nèi)存中的起始位置;
所以:
- vmaddr 就是 segment 初始位置在虛擬內(nèi)存中相對于 image 初始位置的偏移;
先不要關(guān)注 linkedit_base 是什么,后文會講;
再來看看 fileoff:
先看看 Foundation 中 __LINKEDIT 的 command 信息:

因為 Foundation 是 fat 模式,包含兩個架構(gòu),所以 x86_64 的架構(gòu)文件起始位置并不是 0:

我們把上面的兩個位置相加:
0x4BF000 + 0x3A5000 = 0x864000
接下來見證奇跡的時刻,來看看 mach-O 文件中 __LINKEDIT :

這不是巧合,也就是說:
- fileoff 就是對應(yīng)的 segment 的起始位置相對于文件起始位置的偏移;
至此,第一個問題解決,總結(jié)一下:
-
segment加載進(jìn)入虛擬緩存后會按照一定規(guī)則對齊,導(dǎo)致虛擬緩存中的大小大于等于磁盤中的大??; -
vmaddr表示 segment 在虛擬緩存中的相對于 image 的初始地址的偏移; -
fileoff指對應(yīng)數(shù)據(jù)在磁盤文件中,相對于初始位置的偏移;
六、三個表的初始地址計算原理
上文中值分析了 linkedit_base 的那一句代碼,接下來要和后面的代碼結(jié)合來看了:
// Find base symbol/string table addresses
uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
// symbol table
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
// string table
char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);
// dynamic symbol table
uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);
后面的三個計算都是基于 linkedit_base 計算符號表、字符串表、重定向表的位置;
先看一張圖:

如上圖:
① = LC_SYMTAB.symoff;
② = __LINKEDIT.vmaddr;
⑤ = __LINKEDIT.fileoff;
④ = ②-⑤(vmaddr-fileoff)
③ = ④
暫且不深究 segment 的對齊原則,現(xiàn)在可以確定的是對于 __LINKEDIT 段而言, vmaddr - fileoff 得到的值就是 __TEXT 段和 __DATA 段因為對齊而相對于磁盤存儲中多出來的空間,加上 slide 就成了 linkedit_base。
不關(guān)注 slide ,上述代碼中的第一句代碼就是在做一件事:計算 排在 __LINKEDIT 前面的 Segment 在被映射到虛擬內(nèi)存時,因為內(nèi)存對齊而多出來的 size;
主要是
__DATA多出來的 size,因為_TEXT和_DATA_CONST基本在靜態(tài)時期已經(jīng)做好了內(nèi)存對齊,所以 VMSize 和 fileSize 是一樣的;
另外,因為 symtab 和 dysymtab 本身就屬于 __LINKEDIT 這個 segment,而內(nèi)存對齊也只是在 Segment 后面補(bǔ) 0。所以這兩部分的數(shù)據(jù)不會因為 __LINKEDIT 的內(nèi)存對齊而改變位置。也就是說 symtab->fileoff 仍然是正確的,所以只需要加上虛擬內(nèi)存中多出來的 size 就可以計算出 symtab 在虛擬內(nèi)存中的位置。
上句話是計算原理的關(guān)鍵所在,值得多理解理解?。。?/p>
因此 ③ 和 ④ 的長度是相等的。而我們又知道 symtab -> symoff 就是圖中的 ①,最終如圖,符號表的位置計算為:
// 注意此處的linkedit_base未添加偏移哦~~~
linkedit_base = __LINKEDIT.vmaddr - __LINKEDIT.fileoff (②-⑤);
vm中符號表的位置 = linkedit_base + slide + symoff;
string表的位置 = linkedit_base + slide +stroff;
這樣就驗證了代碼的計算原理;
在 MachOView 中可以直觀的看到:
- 符號表 command:

LC_SYMTAB 指的是 symbol table,也就是符號表,不是樁函數(shù)表(__stubs)。從上圖可以看出,LC_SYMTAB 記錄了 symbol table 和 string table 的 offset 以及 size,其中兩個 offset 很重要;
- 重定向表的 command:

重定向表中記錄的 offset 就是用來基于 linkedit_base 進(jìn)行尋址的;
-
symtab屬于__LINKEDIT:
header 的 file 地址如下:

__LINKEDIT 的信息如下:

所以可以得出,在 file 中 __LINKEDIT 的結(jié)束地址為:
0x0005a620 + 0x58000 = 0x000b2620
而 symtab 的地址是:

可以看到,0x44200 < 0xb2620,所以 symtab 屬于 __LINKEDIT。
其實,除了 __TEXT 和 __DATA,包括簽名等信息都屬于 __LINKEDIT:

至此,可以知道后面兩個問題的答案了:
- linkedit_base 為什么這么算?
- linkedit_base 的意義是什么?
答:linkedit_base 去掉 slide 后的本質(zhì)是處于 __LINKEDIT 之前的 segment 因為內(nèi)存對齊規(guī)則而多出來的 size。又因為內(nèi)存對齊是在 Segment 后面補(bǔ) 0, section 不會因為內(nèi)存對齊而改變在 segment 中的位置,所以可以依據(jù) linkedit_base 計算出 symbol table、string table、indirect table 在虛擬內(nèi)存中的初始位置;
七、幾點補(bǔ)充
第一點要補(bǔ)充的是:
fishhook 中三個表的計算方式和 dyld 源碼略有不同,以 symtab 舉例:
//dyld源碼中的寫法
//uint8_t為char,&ptr[symtab_cmd->symoff] 等價于 linkedit_base + symtab_cmd->symoff,其意義是(*prt + sizeof(uint8_t) * symtab_cmd->symoff)
uint8_t *ptr = (uint8_t *)linkedit_base;
uint8_t *p2 = (uint8_t *)&ptr[symtab_cmd->symoff];
// symbol table(fishhook)
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
dyld 中寫法的核心在于 &pointer[adress],有點類似于數(shù)組指針的 +1 ,其含義是:
向后取 &{*pointer + adress * sizeof(pointer)};
此處不需要過于糾結(jié),只是看 dyld 源碼時看到不同,稍微研究了一下;
第二點要補(bǔ)充的是:
linkedit_base 的本質(zhì)是因為虛擬內(nèi)存對齊多出來的 size,所以如果虛擬內(nèi)存和磁盤緩存一樣大,那么 linkedit_base = 0 + slide = slide ,也就等于 image 的起始位置。這也是為什么使用 image lookup 查看內(nèi)存時,有時候會看到該地址為 __TEXT 段的初始位置,有時候啥也看不到。因為這個值本身不代表內(nèi)存地址,能看到只是因為 slide 碰巧為 0,此時這個 linkedit_base 正好表示 __LINKEDIT 段在內(nèi)存中的地址。
實例如下圖:

有時候卻啥也查不到或者結(jié)果比較懵逼:

所以,不要去直接查看 linkedit_segment->vmaddr - linkedit_segment->fileoff;,這個值不代表 mach-O 的某部分在內(nèi)存中的位置,而是單純的表示 vm 和 file 中 size 的差值;
八、尋找懶加載和非懶加載的setion
接下來,看下這句代碼:
cur = (uintptr_t)header + sizeof(mach_header_t);
這句代碼讓位置回到了 Load Command 的初始位置,后面又開始遍歷了:
for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
cur_seg_cmd = (segment_command_t *)cur;
if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
if (strcmp(cur_seg_cmd->segname, SEG_DATA) != 0 &&
strcmp(cur_seg_cmd->segname, SEG_DATA_CONST) != 0) {
// 不是SEG_DATA/SEG_DATA_CONST則退出,即只在這兩個段中查找
continue;
}
// 遍歷 segment 中的 section
for (uint j = 0; j < cur_seg_cmd->nsects; j++) {
section_t *sect =
(section_t *)(cur + sizeof(segment_command_t)) + j;
if ((sect->flags & SECTION_TYPE) == S_LAZY_SYMBOL_POINTERS) {
// 懶加載符號表
perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
}
if ((sect->flags & SECTION_TYPE) == S_NON_LAZY_SYMBOL_POINTERS) {
// 非懶加載符號表
perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
}
}
}
代碼分為幾步:
- 遍歷 Load Command;
- 只查找 segment 類型的 command;
- 只在
__DATA或者__DATA_CONST的 segment_command 中查找; - 遍歷 segment 的 section,找出
S_NON_LAZY_SYMBOL_POINTERS和S_LAZY_SYMBOL_POINTERS的 section; - 兩個 section 都調(diào)用 p
erform_rebinding_with_section方法;
總結(jié):這一步中,找到了__DATA/__DATA_CONST 段中的懶加載和非懶加載的 section;
這里的 section 之和不一定是 2,要看 TYPE 決定,只要是這兩種類型,都會調(diào)用 perform_rebinding_with_section 方法,即該方法的調(diào)用次數(shù)為懶加載和非懶加載表之和。比如 __got 和 __nl_symbol_ptr的 TYPE 都是非懶加載類型,如下圖:

估計和編譯器設(shè)置有關(guān),暫不深究;這一步的代碼代碼不復(fù)雜,這里就略過了,好好看看 perform_rebinding_with_section 方法;
九、reserved1字段的意義
perform_rebinding_with_section 這個方法的重點比較多,一個一個看:
首先是:
uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1;
這里的 section 只有兩種:懶加載 __la_symbol_ptr 或者非懶加載__nl_symbol_ptr/__got,對于 section 這個結(jié)構(gòu)體中,reserved1 的解釋如下:

即:
- 當(dāng)前表第一個符號在重定向表中的起始 index;
來看個實例:

來算一下:
// 其實位置 + size * index
(lldb) p/x 0x24b00 + 0x64 * 4
(int) $8 = 0x00024c90
再去重定向表中找確認(rèn):

如上圖,驗證成功;
所以,上述代碼的意義是:
- 找到當(dāng)前入?yún)?section 中第一個符號在重定向表中的位置;
那為什么要找到這個 index 呢?因為符號是按照類型一塊一塊整體存儲在符號表中的。符號的信息只有一份,保存在 symtab 中。符號表、重定向表、字符串表的關(guān)系如下:
-
symtab存儲符號,符號的 name 指向strtable; - 重定向表是
symtab的子集,存儲 index,該 index 和 symtab 中的 index 對應(yīng);
十、重綁定函數(shù)
繼續(xù)看代碼,perform_rebinding_with_section 代碼太多,先看外部的 for 循環(huán):
// 指向指針的指針,表中存儲的都是指針
void **indirect_symbol_bindings = (void **)((uintptr_t)slide + section->addr);
for (uint i = 0; i < section->size / sizeof(void *); i++) {
// 取出重定向表中的index
uint32_t symtab_index = indirect_symbol_indices[i];
//使用symtab_index在symbol table中取出符號對象(結(jié)構(gòu)體)
// 再取出該符號在string table 中的偏移
uint32_t strtab_offset = symtab[symtab_index].n_un.n_strx;
// 取出該符號的name
char *symbol_name = strtab + strtab_offset;
bool symbol_name_longer_than_1 = symbol_name[0] && symbol_name[1];
struct rebindings_entry *cur = rebindings;
// while......
// ......
symbol_loop:;
}
這個 for 循環(huán)就是遍歷 section 中的所有符號,并取出了兩個重要信息:
- 取出重定向表中的 index;
- 取出符號的 name;
具體流程就是:indirect.index -> symbol table -> string table;
再來看第二個 循環(huán):
//遍歷rebindings中的符號,即需要被替換的符號
while (cur) {
for (uint j = 0; j < cur->rebindings_nel; j++) {
if (symbol_name_longer_than_1 &&
strcmp(&symbol_name[1], cur->rebindings[j].name) == 0) {
// name命中
if (cur->rebindings[j].replaced != NULL &&
indirect_symbol_bindings[i] != cur->rebindings[j].replacement) {
// 將原函數(shù)的地址保存
*(cur->rebindings[j].replaced) = indirect_symbol_bindings[i];
}
// 替換重定向表中的指針為新函數(shù)地址
indirect_symbol_bindings[i] = cur->rebindings[j].replacement;
goto symbol_loop;
}
}
cur = cur->next;
}
提取這個 while 循環(huán),其實就兩句關(guān)鍵代碼:
// 保留原函數(shù)到replaced
*(cur->rebindings[j].replaced) = indirect_symbol_bindings[i];
// 修改表中的指針為自定義的函數(shù)
indirect_symbol_bindings[i] = cur->rebindings[j].replacement;
這個兩句代碼對應(yīng)著兩個關(guān)鍵步驟:
- 將重定向表中的指針賦值到 replaced 中,replaced 是我們自定義的一個函數(shù)指針,用于保存原函數(shù);
- 將重定向表中的指針修改成了我要要替換的函數(shù)地址,replacement 就是我們用于替換原函數(shù)的函數(shù)地址;
十一、懶加載和非懶加載表的補(bǔ)充
上一章節(jié)中其實有一句代碼也比較關(guān)鍵:
uint32_t symtab_index = indirect_symbol_indices[i];
這里直接按照 i++ 順序取出了重定向表中的數(shù)據(jù),這里有幾個知識點:
- 重定向表只存儲該符號位于符號表中的 index;
- 重定向表中的數(shù)據(jù)按類型分組,順序存儲;
- 重定向表、懶加載表、非懶加載表,各個類型的符號在這幾個表中排列順序都是一樣的;
這里有點拗口,按照個人的理解,這里跟編譯鏈接的過程有關(guān);大概的過程應(yīng)該是靜態(tài)編譯時期生成重定向表。這一步是將符號表中的外部符號按照類型取出存放到重定向表中,且只存儲 index,看下實例:

上圖中可以理解成重定向表中進(jìn)行了分組排序,例如 __stub 中的符號不會和 __got 中的符號位置互串。
緊接著,靜態(tài)編譯器根據(jù)重定向表在對應(yīng)的 section 中生成符號指針,這里就要區(qū)分兩種情況了:
懶加載:生成符號對應(yīng)的樁函數(shù),樁函數(shù)會去懶加載符號表中取出指針跳轉(zhuǎn)到對應(yīng)函數(shù)位置,懶加載表中的初始指針指向 stub_helper 函數(shù),進(jìn)而指向 binder 函數(shù);
非懶加載:不生成也不需要生成樁函數(shù),但是因為依賴的動態(tài)庫在動態(tài)鏈接時才 load,所以非懶加載符號表中的函數(shù)指針為 0;
如下圖:


總結(jié):
- 符號表中記錄了所有的符號,靜態(tài)依賴庫的符號會被直接拷貝進(jìn)入到主工程,生成最終的 mach-O 文件;
- 而依賴的動態(tài)庫源碼不會被拷貝到主工程中,之所以叫做動態(tài)庫,是因為程序被加載時才進(jìn)行鏈接,準(zhǔn)確來說在 dyld2 中,是在鏈接主程序時才加載依賴的動態(tài)庫;
- 符號表中存儲全量符號,而動態(tài)庫的符號額外存儲一份在重定向表中,為了節(jié)約內(nèi)存,表中只存儲該符號在符號表中的 index;
- 重定向表分組排序,依次印射到 __stub、懶加載表(__la_symbol_ptr)、非懶加載表(__nl_symbol_ptr、__got),這一步在靜態(tài)編譯時期就完成了;
- 懶加載符號的調(diào)用在靜態(tài)編譯時期就被替換成了樁函數(shù),樁函數(shù)只管取出懶加載符號表中的函數(shù)指針進(jìn)行跳轉(zhuǎn);
- 懶加載符號表中的函數(shù)指針初始化(靜態(tài)編譯時期)時指向 __stub_helper 進(jìn)而指向 binder 函數(shù);
- binder 函數(shù)在符號于運行時第一次被調(diào)用時進(jìn)行尋址,然后替換懶加載符號表中的函數(shù)指針為真實的函數(shù)地址;
- 非懶加載符號表中的指針初始化時值為 0,動態(tài)鏈接之后立馬進(jìn)行尋址,尋址完成后進(jìn)行替換;
- fishhook 的原理總結(jié)起來就是一句話:將懶加載和非懶加載表中符號的指向替換為自己的符號地址。因此,fishhook 沒辦法 hook 系統(tǒng)庫的內(nèi)部函數(shù)。因為這些符號表壓根就沒有暴露,在其他庫中也就沒有調(diào)用,自然無法進(jìn)行符號替換。
這里還有一點不確定,非懶加載的符號是和懶加載符號一樣?真實調(diào)用代碼被替換成樁函數(shù)?還是在動態(tài)鏈接時期直接替換成了函數(shù)地址?還是說基于 PIC (-fpic)技術(shù),在靜態(tài)鏈接時期已經(jīng)將調(diào)用代碼替換成了去非懶加載符號表中取出指針進(jìn)行跳轉(zhuǎn)的代碼?感覺更像第三種~~后面再深入~~
十二、fishhook中的replaced 最終保存的函數(shù)
replaced 是保存原函數(shù),如果是懶加載,懶加載表中一開始存儲的是 stub_helper 函數(shù),如果在調(diào)用該函數(shù)之前調(diào)用了 rebind 方法,replaced 中會被替換成 stub_helper 而不是原函數(shù)?如果是這樣,那么每次調(diào)用 replaced 函數(shù),都會去進(jìn)行一次重復(fù)綁定?
驗證:
- iphone7(10.3)
模擬器中 dyld 實際使用的是 dyld_sim,其 dyld 的版本是:

源碼如下:
// 指向函數(shù)的指針
static void (*sys_NSLog)(NSString *format, ...);
void xk_NSLog(NSString *format, ...) {
// format = [format stringByAppendingString:@"(我被hook了)"];
printf("hook succ\n");
sys_NSLog(format);
}
void rebind(void) {
// 定義結(jié)構(gòu)體
struct rebinding xkNSLogBind;
// 需要hook的函數(shù)的名稱
xkNSLogBind.name = "NSLog";
// 新函數(shù)的地址
xkNSLogBind.replacement = xk_NSLog;
// 保存被替換掉函數(shù)的指針
xkNSLogBind.replaced = (void *)&sys_NSLog;
// 創(chuàng)建需要hook的結(jié)構(gòu)體數(shù)組
struct rebinding rebind[1] = {xkNSLogBind};
// hook
rebind_symbols(rebind, 1);
}
int main(int argc, char * argv[]) {
rebind();
sys_NSLog(@"---");
sys_NSLog(@"---");
}
斷點之后的匯編:



其實上面的問題就是 fishhook 源碼必定會導(dǎo)致的現(xiàn)象。 fishhook 按照依賴庫的加載順序?qū)γ總€庫中的懶加載和非懶加載符號表進(jìn)行了替換,以此達(dá)到全局替換的目的;
正因為這樣的邏輯,fishhook會找到最后一個包含該函數(shù)(需要被替換的原函數(shù))的庫,或者說的更準(zhǔn)確一點,依賴庫中所有包含該符號的庫都會進(jìn)行一次替換。所以最后一個 stub_helper 函數(shù)保存到 replaced 中;
即:
- placed 中保存的是最后一個包含被替換函數(shù)的依賴庫中,函數(shù)的 stub_helper 函數(shù);
其實這個問題在 dyld2 和 dyld3 中不一樣,在 iOS14 中運行。然后使用一個奇技淫巧:
watchpoint set v sys_NSLog
或者直接在源碼中添加:

這樣就可以看到打印了:

直接查看這個內(nèi)存:

如上圖,在 rebind 操作完成之后,就已經(jīng)指向 Foundation 中真實的 NSLog 函數(shù)了。
其實上面可以看到還有一個0x101b6defe,這個依賴庫估計不是共享庫,這個指針指向的應(yīng)該是 stub_helper 函數(shù),可以自行驗證;所以最后一個包含該函數(shù)的庫如果不是共享庫,或者共享緩存是第一次加載的,那么rebind之后就可能是 stub_helper,具體現(xiàn)象和原因暫時不探討;
再來看看 dyld 版本:

很明顯,iOS14 的模擬器中的 dyld 已經(jīng)是 dyld3 的版本了;
關(guān)于這個現(xiàn)象查閱到以下資料:

即:dyld3 中使用了 lauch closure 機(jī)制,會導(dǎo)致流程不一樣;
至于 dyld3 中的具體流程,以后再分析,不是本文重點;
十三、留個疑問
非懶加載符號主動調(diào)用
有個有意思的現(xiàn)象:
非懶加載符號主動調(diào)用之后就變成懶加載了,比如 objc_msgSend :

這里留個疑問:
- 非懶加載符號在運行時是直接被替換成了函數(shù)指針,還是和懶加載符號一樣使用 stub 函數(shù)來調(diào)用?
- 如果是被替換成了真實的函數(shù)地址,那么 fishhook 中替換 __nl_symbol_ptr 就沒有意義?
感覺這里肯定有個知識點自己還不知道,暫時存疑吧~~