如何從鏈接原理的角度理解 fishhook 的設計思路?

原文鏈接

最近在三刷《程序員的自我修養(yǎng):鏈接、裝載與庫》,為了加深對于相關知識的理解,我又閱讀了 fishhook 的源碼。本文希望從程序的鏈接原理出發(fā),詳細介紹 fishhook 的設計原理,學習其中的設計思想。

概述

Fishhook 是 Facebook 開源的一款面向 iOS/macOS 平臺的 符號動態(tài)重綁定 工具,允許開發(fā)者在運行時修改 Mach-O 中的符號(函數),從而實現 動態(tài)庫 的函數 hook 能力。

Fishhook 提供了兩個用于符號重綁定的接口,分別是:

int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel);

int rebind_symbols_image(void *header,
                         intptr_t slide,
                         struct rebinding rebindings[],
                         size_t rebindings_nel);

其中,rebind_symbols 可以在所有動態(tài)庫范圍內進行符號重綁定,而 rebind_symbols_image 則限制了動態(tài)庫的范圍,只能指定某一個動態(tài)庫。

這里,我們先預設幾個問題,后面會逐步進行解答:

  • 問題一:fishhook 是在什么時候完成函數 hook 的?
  • 問題二:fishhook 為什么只支持 hook 動態(tài)庫函數?

為了能介紹清楚 fishhook 的實現原理,本文我將重點介紹程序的鏈接原理,包括:靜態(tài)鏈接、動態(tài)鏈接。其中,涉及到的術語和概念主要是基于 ELF 可執(zhí)行文件(或目標文件),在真正介紹 fishhook 的原理時,我會將 Mach-O 中的術語與 ELF 進行比較和映射,從而達到一個舉一反三的效果。

可執(zhí)行文件格式

在介紹鏈接原理之前,我們有必要先了解一下可執(zhí)行文件(目標文件)的基本格式,不同的平臺有著不同的格式,分別是:

  • 對于 Windows 平臺,其采用的是 PE(Portable Executable) 格式
  • 對于 Linux 平臺,其采用的是 ELF(Executable Linkable Format) 格式
  • 對于 iOS/macOS 平臺,其采用的是 Mach-O(Mach Object) 格式

盡管不同平臺的可執(zhí)行文件格式不同,但是它們的組織結構和規(guī)則是基本類似的。如下圖所示,不同格式的可執(zhí)行文件基本都包含如下幾個部分:

  • 文件頭
  • segment 表
  • section 表
  • section 數據
executable-file-01.png

文件頭用于描述可執(zhí)行文件的元信息,包括:文件類型、系統(tǒng)版本、segment 表的位置和大小、section 表的位置和大小等等。Section 表本質上是一個索引表,其存儲了每一個 section 的元信息,比如對應 section 在文件中的位置和大小。至于 section,它是可執(zhí)行文件的基本組成單元,常見 section 有:.text、.data.bss、.symtab、.strtab 等。

那么 segment 表的作用又是什么呢?

section 與 segment

事實上,兩者的區(qū)別主要在于:section 用于描述可執(zhí)行文件的靜態(tài)存儲布局,segment 用于描述可執(zhí)行文件的裝載內存布局。

我們知道可執(zhí)行文件是以 section 為基本單元存儲的,section 的類型非常多,如:.data、.text.rodata 等。假如,我們的可執(zhí)行文件中有兩個 section,分別是 .init.text,兩者的大小分別是 3500B 和 4100B。假設系統(tǒng)的頁面大小為 4KB,我們來分別看一下基于 section 裝載和基于 segment 裝載的內存占用情況。

下圖右部所示為基于 section 裝載的內存占用情況,其中 .init 單獨占用一個頁,且頁沒有全部使用;.text 會單獨占用兩個頁,且第二頁絕大多數內存空間沒有使用,總共浪費內存 3 x 4KB - 3500B - 4100B = 4688B。

下圖左部所示為基于 segment 裝載的內存占用情況,.text 占用了兩個頁,且與 .init 共享了一個頁,總共浪費內存 2 x 4KB - 3500B - 4100B = 592B。

executable-file-02.png

很顯然,相比于基于 section 裝載,基于 segment 裝載對于內存占用的優(yōu)化非常明顯,內存碎片更少。在實際中,程序在裝載時會將相同權限的 section 合并在一個 segment 中,比如:.init.text 都合并成為可讀可執(zhí)行權限的 segment,作為代碼段;可讀可寫的 section 合并在為一個 segment,作為數據段。

程序的鏈接原理

鏈接(Linking) 的本質是把多個目標文件相互拼接到一起,使得函數調用、變量訪問等指令能夠找到正確的內存地址。然而,這一切都是圍繞著 符號(Symbol) 完成的。

那么到底什么是符號?舉個例子,目標文件 B 調用了目標文件 A 中的函數 foo。對此,我們認為目標文件 A 定義了函數 foo,目標文件 B 引用了函數 foo。在鏈接過程中,我們將函數和變量統(tǒng)稱為 符號(Symbol),函數名和和變量名統(tǒng)稱為 符號名(Symbol Name)。因此,我們也可以認為目標文件 A 包含了函數 foo符號定義(Symbol Definition),目標文件 B 包含了函數 foo符號引用(Symbol Reference)。

這時候問題來了,鏈接過程是如何基于符號完成對二進制指令中內存地址的修正呢?對此,我們可以先來了解一下靜態(tài)鏈接。

靜態(tài)鏈接

靜態(tài)鏈接會在編譯期將多個目標文件合并為一個可執(zhí)行文件。因此,里面包含了所有的符號、重定位項、字符串等。

在編譯過程中,編譯器會為每一個變量或函數生成一個符號項,符號項包含的信息主要有:

  • 符號名:即一個指向字符串表的索引,比如:字符串 foo 在字符串表中的偏移量。
  • 符號類型:類型有很多,比如:全局符號、局部符號、未定義符號等。
  • 符號值符號定義 的內存地址,用于修正二進制指令中的內存地址。這個地址修正的過程被稱為 重定位

此外,編譯器還會為每個變量引用或函數引用生成一個重定位項。由于每一個重定位項記錄了每一次對于符號的引用,因此,我們可以將其稱為符號引用項。這樣也就構成了符號定義和符號引用的一對多關系,畢竟,我們可以在不同的地方引用同一個變量或函數。

基于如下示意圖,靜態(tài)鏈接的整體工作原理大概可以分為以下三個步驟:

  • 根據重定位項中的符號索引,去符號表找到對應的符號項,并獲取到對應符號的符號值,即內存地址。
  • 根據重定位項中的重定位地址,找到代碼段中對應的字節(jié)地址,將其修正為步驟一獲取到的內存地址。
  • 遍歷重定位表中的所有重定位項,重復步驟一和步驟二。
static-linking-01.png

由于靜態(tài)鏈接時,程序所依賴的所有目標文件都已經合并在了一個可執(zhí)行文件中,因此幾乎不存在符號項中的符號值(內存地址)不確定的情況,對此,靜態(tài)鏈接器只需要基于重定位表進行重定位即可。這其實就是大家常說『靜態(tài)鏈接的重點是重定位』的原因。

動態(tài)鏈接

動態(tài)鏈接的基本思想是 將程序按照模塊拆分成各個獨立的部分,在運行時將它們鏈接在一起形成一個完成的程序,而不是像靜態(tài)鏈接一樣在編譯時把所有的模塊都鏈接成一個獨立的可執(zhí)行文件。因此,動態(tài)鏈接可以有效解決靜態(tài)鏈接存在的 內存空間浪費程序更新困難 的問題。

那么對于動態(tài)鏈接,我們是否可以直接采用靜態(tài)鏈接的做法呢?這種方案理論上可以,但卻不是最優(yōu)解,因為靜態(tài)鏈接會修改代碼段,我們很難讓共享對象在被多次重定位之后也能繼續(xù)安全穩(wěn)定的運行。

舉一個例子,如下所示,一個動態(tài)共享對象 X 內部會引用外部的一個變量 a。當程序 A 與動態(tài)共享對象 X 完成重定位后,X 代碼段中的某個指令的訪存地址可能是一個值;當程序 B 與動態(tài)共享對象 X 完成重定位后,X 代碼段中同位置的訪存地址可能會被修改成另一個值。這時候,必然會出現其他程序無法正常執(zhí)行的情況。

dynamic-linking-01.png

關于如何解決多進程之間的重定位沖突問題,我們可以引用一句經典名言來描述動態(tài)鏈接的解決方案。

計算機領域中的任何問題都可以通過增加一個中間層來解決

當然,在具體的實現中,動態(tài)鏈接根據鏈接的時機,還可以分為 裝載時鏈接(Load-Time Linking)延遲鏈接(Lazy Linking)。兩者的實現思路只有略微的差異,下面我們將分別進行介紹。

裝載時鏈接

下圖所示為裝載時鏈接的工作原理示意圖。對于共享對象而言,其代碼段會被多個進程所共享,因此不能直接在代碼段中進行重定位,修改內存地址??紤]到多進程共享對象時,共享對象會為每個進程拷貝一份數據段,支持修改。因此,一種稱為 地址無關代碼(PIC,Position-Independent Code) 的技術誕生了,其基本思想是:在編譯時配置 PIC 編譯選項,將指令部分中需要被修改的部分分離出來,跟數據部分放在一起。這樣指令部分可以保持不變,而數據部分可以在每個進程中有一個獨立的副本。

對于 PIC 技術,代碼運行性能會比靜態(tài)鏈接要差一點。因為指令在訪問外部變量或外部函數時,必須先通過指針去數據段找到對應的位置,再從中取出真實的內存地址,很顯然多了一次間接操作,損耗了性能。

dynamic-linking-02.png

在裝載前,共享對象 X 的符號表中的外部符號 bar 的內存地址是未定義的。但是,程序 A 的符號表中的符號 bar 的內存地址是確定的(因為符號 bar 的符號定義位于程序 A 中)。因此,在裝載時我們就可以決議出共享對象 X 的外部符號 bar 的地址。這個過程,我們稱之為 裝載時綁定(Load-Time Binding)裝載時符號綁定(Load-Time Symbol Binding)

當外部符號 bar 的內存地址綁定完成后,我們就可以進行后續(xù)的重定位了。其步驟和靜態(tài)鏈接的重定位類似,主要包括以下幾步:

  • 根據動態(tài)重定位項中的符號索引,去動態(tài)符號表中找到對應的符號項,并獲取對應符號的符號值,即裝載時綁定的內存地址。
  • 根據動態(tài)重定位項中的重定位地址,找到 數據段 中對應的字節(jié)地址,將其修正為步驟一獲取到的內存地址。
  • 遍歷動態(tài)重定位表中的所有重定位項,重復步驟一和步驟二。

在 PIC 技術中,編譯器會在數據段中為每一個符號存儲一個占位樁(stub),用于存儲符號的真實內存地址。這些占位樁組成了一個表,我們稱之為 全局偏移表(GOT,Global Offset Table)。

綜上述可以看出,裝載時鏈接包含了兩個重要的步驟,分別是裝載時綁定和重定位。雖然中間多了一步間接索引內存地址,損耗了一些性能,但是程序的靈活性和復用性提供了很多。

延遲鏈接

考慮到程序運行的局部性,實際上在進程生命周期中很多變量或函數并不會被調用。于是,誕生了延遲鏈接技術,可以支持進程只在第一次調用符號時才進行鏈接。

下圖所示為延遲鏈接的工作原理示意圖,本質上與裝載時鏈接差不多,主要區(qū)別在于:裝載時鏈接在數據段中使用了 GOT 存儲符號地址,延遲鏈接則在數據段中使用了 過程鏈接表(PLT,Procedure Linkage Table) 存儲符號地址。當 PLT 表項中符號的內存地址未決議時,PLT 表項中的占位樁(stub)存儲的是一段代碼的地址。當這段代碼完成符號綁定和重定位后,會將符號的真實內存地址回填到占位樁中,覆蓋默認的代碼地址,從而實現僅在第一次調用符號時才進行鏈接。

dynamic-linking-03.png

延遲鏈接的關鍵是如何實現在第一次調用符號時進行鏈接,這個過程包含了 延遲綁定(Lazy Binding) 和重定位。關于 PLT 的存儲,很多目標文件會將其存儲在命名為 got.plt 的 section 中,Mach-O 和 ELF 都是如此,這一點需要注意。

Fishhook 實現原理

核心思想

上述介紹了程序的鏈接原理,尤其是在理解了動態(tài)鏈接之后,如果你細想思考一下,很容易就能想到 fishhook 的設計思想。

下圖展示了 fishhook 的設計思想,非常簡單巧妙,核心思想就是 將目標符號(函數)對應的 GOT 表項或 PLT 表項中存儲的符號值(內存地址),替換成 hook 函數的內存地址。通過這種方式,無論是裝載時鏈接還是延遲鏈接,我們都可以實現對動態(tài)共享庫函數的 hook。

dynamic-linking-04.png

下面,我們來介紹一下 fishhook 實現細節(jié)中與 Mach-O 的相關概念。

Non-lazy Symbol Pointer & Lazy Symbol Pointer

如下所示為《Mach-O Programming Topics》中對兩者的解釋:

Non-lazy symbol references are resolved (bound to their definitions) by the dynamic linker when a module is loaded.A non-lazy symbol reference is essentially a symbol pointer—a pointer-sized piece of data. The compiler generates non-lazy symbol references for data symbols or function addresses.

Lazy symbol references are resolved by the dynamic linker the first time they are used (not at load time). Subsequent calls to the referenced symbol jump directly to the symbol’s definition.Lazy symbol references are made up of a symbol pointer and a symbol stub, a small amount of code that directly dereferences and jumps through the symbol pointer. The compiler generates lazy symbol references when it encounters a call to a function defined in another file.

Non-lazy Symbol Pointer 存儲的是指向符號定義的指針,它與 GOT 中的表項定義非常類似。由 Non-lazy Symbol Pointer 組成的表,在 Mach-O 中我們稱為 Non-lazy Symbol Pointer Table。

Lazy Symbol Pointer 包含一個指向符號定義的指針、一個占位樁以及一段代碼(可用于延遲綁定和重定位),它與 PLT 中的表項定義非常類似。由 Lazy Symbol Pointer 組成的表,在 Mach-O 中我們稱之為 Lazy Symbol Pointer Table。

Indirect Symbol Table

上述的 Non-lazy Symbol Pointer 和 Lazy Symbol Pointer 并沒有包含符號名相關的信息,然而在實際的符號查找、綁定的過程是需要用到的。因此,對于 Non-lazy Symbol Pointer Table 和 Lazy Symbol Pointer Table 各自有一個同步的間接符號表,可以用于配合完成鏈接工作。Fishhook 也是借助 Indirect Symbol Table 間接獲取符號名,然后與目標符號進行判等比較,從而最終完成 hook 工作。

Indirect Symbol Table 與 Symbol Pointer Table 的表項是一一對應的,比如:Indirect Symbol Table 中第 1601 項存儲的就是 Symbol Pointer Table 中第 1601 項的符號索引,如下圖所示。

dynamic-linking-05.png

Symbol Pointer 目標符號地址替換

Fishhook 的核心是 完成 Symbol Pointer 的地址替換,無論是 Non-lazy Symbol Pointer 還是 Lazy Symbol Pointer。其實現的關鍵步驟主要包括以下幾步:

  • 查找數據段,即 SEG_DATASEG_DATA_CONST
  • 在數據段中查找 LAZY_SYMNBOL_POINTERSNON_LAZY_SYMBOL_POINTERS 類型的 section
  • 分別對 LAZY_SYMBOL_POINTERSNON_LAZY_SYMBOL_POINTERS section 進行 Symbol Pointer 目標符號地址替換

Symbol Pointer 目標符號地址替換的過程主要有以下幾步:

  • 根據 LAZY_SYMBOL_POINTERSNON_LAZY_SYMBOL_POINTERS section 獲取其對應的 Indirect Symbol Table
  • 遍歷 section,同步遍歷 Indirect Symbol Table,獲取對應的符號名
  • 遍歷過程中,判斷符號名是否與目標符號名匹配。如果匹配,則將 Symbol Pointer 的符號地址替換成 hook 函數的地址;否則,繼續(xù)遍歷,直到結束。

這里涉及到了 fishhook 中的兩個函數實現,分別是 rebind_symbols_for_image 函數和 perform_rebinding_with_section 函數,有興趣的朋友可以自行閱讀,本文就不粘貼代碼了。

總結

至此,我們從鏈接原理的角度介紹了 fishhook 的設計思路。通過這種自頂向下的方法來分析,我們很快就可以聯想到如何去實現一個針對 ELF 格式的 hook 工具。

最后,我們再來回顧一下本文開頭預留的幾個問題。

問題一:fishhook 是在什么時候完成函數 hook 的?fishhook 會在調用 rebind_symbolsrebind_symbols_image 方法時去遍歷鏡像,從而完成對目標符號的地址替換。

問題二:fishhook 為什么只支持 hook 動態(tài)庫函數?動態(tài)庫的 PIC 技術支持在數據段進行重定位,因此允許我們進行目標地址修改。而 fishhook 的整個機制就是建立在動態(tài)鏈接原理的基礎上,因此進支持 hook 動態(tài)庫函數。

參考

  1. 《程序員的自我修養(yǎng):裝載、鏈接與庫》
  2. OS X ABI Mach-O File Format Reference
  3. Mach-O Programming Topics
  4. fishhook
  5. BSD Library Functions Manual——dyld(3)
  6. dladdr(3) — Linux manual page
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

  • fishhook 的本質是遍歷 image 中的懶加載和非懶加載表,將里面的函數地址替換成自定義的函數地址; 因為...
    康小曹閱讀 1,321評論 0 9
  • 前言 本篇文章開始給大家分享下Hook(鉤子)的原理,包括iOS系統(tǒng)原生的Method Swizzle,還有很有名...
    深圳_你要的昵稱閱讀 2,760評論 0 5
  • 一、Hook概述 HOOK中文譯為掛鉤或鉤子。在iOS逆向中是指改變程序運行流程的一種技術。通過hook可以讓別人...
    HotPotCat閱讀 5,491評論 1 12
  • 前言 雖然寫 fishhook 原理的文章有很多,但是總覺得不夠簡單直觀。大部分都是羅列大堆源碼進行講解,看得人云...
    微微笑的蝸牛閱讀 2,262評論 6 7
  • 這是Mach-O系列的第三篇 閱讀 FishHook源碼之前,你可能需要對以下知識有個簡單的了解 Mach-O文件...
    Joy___閱讀 7,939評論 9 45

友情鏈接更多精彩內容