gohook 一個支持運行時替換 golang 函數的庫實現


運行時替換函數對 golang 這類靜態語言來說並不是件容易的事情,語言層面的不支持導致只能從機器碼層面做些奇怪 hack,往往艱難,但如能成功,那掙脫牢籠帶來的成就感,想想就讓人興奮。

gohook##

gohook 實現了對函數的暴力攔截,無論是普通函數,還是成員函數都可以強行攔截替換,並支持回調原來的舊函數,效果如下(更多使用方式/接口等請參考 github 上的單元測試[1],以及 example 目錄下的使用示例):


                                                       圖-1

以上代碼可以在 github 上找到[1],Linux/golang 1.4 1.12  下運行,輸出如下所示:


                                                   圖-2

Hook() 函數原型很簡單:

func Hook(target, replacement, trampoline interface{}) error {}

該函數接受三個參數,第一個參數是要 hook 的目標函數,第二個參數是替換函數,第三個參數則比較神奇,它用來支持跳轉到舊函數,可以理解函數替身,hook 完成后,調用 trampoline 則相當於調用舊的目標函數(target),第三個參數可以傳入 nil,此時表示不需要支持回調舊函數。

gohook 不僅可以 hook 一般過程式函數,也支持 hook 對象的成員函數,如下圖。

圖-3

HookMethod 原型如下,其中參數 instance 為對象,method 為方法名:

func HookMethod(instance interface{}, method string, replacement, trampoline interface{}) error {}

圖 3 運行結果如下:


                                                 圖-4

目前 GitHub 上有類似功能的第三方實現 go monkey[2],gohook 的實現受其啟發,但 gohook 相較之有如下幾個明顯優點:

  • 跳轉效率更高: 大部分情況下 gohook 通過五字節跳轉,無棧操作,更可靠,且性能更好,實現上也更容易理解。
  • 更安全可靠:跳轉需要修改和拷貝指令,極容易影響 call/jmp/ret 等舊指令,本實現支持修復函數內 call/jmp 指令。
  • 支持回調舊函數: 這是最大優點,也是 gohook 實現的初衷。
  • 不依賴 runtime 內部實現: gomonkey 因為跳轉指令的原因依賴 reflect.value 來獲取 funval,而 value 內部結構並不開放,導致 go monkey  對 runtime 的內部實現產生了依賴。

實現解析##

Hook 的原理是通過修改目標函數入口的指令,實現跳轉到新函數,這方面和 c/c++ 類似實踐的原理相同,具體可以參考[3]。原理好懂,實現上其實比較坎坷,源碼細節請參考[13],關鍵有幾點:

1. 函數地址獲取###

與 c/c++ 不同,golang 中函數地址並不直接暴露,但是可以利用函數對象獲取,通過將函數對象用反射的 Value 包裝一層,可以實現由 Value 的 Pointer() 函數返回函數對象中包含的真實地址,golang 文檔對此有特別說明[10]。

2.跳轉代碼生成###

跳轉指令取決於硬件平台,對於 x86/x64 來說,有幾種方式,具體可以參考文檔[3],或者 intel 開發者手冊[4],gohook 的實現優先選用 5 字節的相對地址跳轉,該指令用四個字節表示位移,最多可以跳轉到半徑為 2 GB 以內的地址。

這對大部分的程序來說足夠了,如果程序的代碼段超出了 2GB(難以想像),gohook 則通過把目標函數絕對地址壓到棧上,再執行 ret 指令實現跳轉。

這兩種跳轉方式的結合使得跳轉實現起來相對 gomonkey 簡單容易很多,gomonkey 選用了 indirect jump,該指令需要一個函數地址的中間變量存放到寄存器,因此這個變量必須保證不會被回收,還得注意該寄存器不會被目標函數使用,導致實現上很別扭且不安全(跳轉代碼必須放到函數的最開始一段,不能放在中間),更嚴重的是,因為需要直接使用函數對象,gomonkey 必須猜測 value 對象的內存布局來獲取其中的 function ptr,runtime 實現一改,這里就得跪。

3.成員函數的處理###

成員函數在 golang 中與普通函數幾乎一樣,唯一區別是成員函數的第一個參數是對象的引用,因此 hook 成員函數與 hook 一般函數本質上是一樣的,無需特殊處理。

值得注意到是子類調用基類函數這種場景,golang 編譯時會為子類生成一個基類函數的包裝(wrapper),這個包裝存在的目的是給通過接口調用基類函數時所使用,其作用從匯編角度看似乎是用於把對象的地址進行處理和傳遞,最后跳到基類函數中(具體原因沒深究)。

所以在 hook 對象的成員函數時有兩種方式,一種是通過子類來 hook,一種是通過基類來 hook,前者只覆蓋通過接口調用函數這種場景,后者則能處理所有場景,對於 hook 第三方庫來說,經常基類可能是不開放的,這時 gohook 能發揮的作用就比較有限。當然按 golang 開發的慣例來說,這種繼承(嚴格來說繼承也不存在)一般會配合接口來實現類似多態的功能,因此 hook 子類通常也能解決大部分場景了。

如果上面的描述有些抽象,請參看 example 目錄下的 example3.go[12].

4.回調舊函數###

回調舊函數是很難的,很多問題需要處理,目標函數因為入口地址要被修改,本質上一部分指令會被破壞,因此如果想回調舊函數,有幾種方式可以做到:

1.將被損壞的指令拷貝出來,在需要回調舊函數時,先將指令恢復回去,再調用舊函數。
2.將被損壞的指令拷貝到另一個地方,並在末尾加上跳轉指令轉回舊函數體中相應的位置。
3.將整個舊函數拷貝一份,並修復其中的跳轉指令。

gohook 目前采用了第二種方案(后續會支持第三種),主要考慮有幾個:

  • 方案一無法重入,在 golang 協程環境下幾乎無法實際使用。
  • 拷貝整個函數消耗較大,且事先無法預測目標函數的大小,函數替身難以准備。

無論是拷貝一部分指令還是全部指令,其中面臨一個問題必須解決,函數指令中的跳轉指令必須進行修復。

跳轉指令主要有三類:call/jmp/conditional jmp,具體來說,是要處理這三類指令中的相對跳轉指令,gohook 已經處理了所有能處理的指令,不能處理的主要是部分場景下的兩字節指令的跳轉,原因是指令拷貝后,目標地址和跳轉指令之間的距離很可能會超過一個字節所能表示,此時無法直接修復,當然同樣問題對四字節相對地址跳轉來說也可能會存在,只是概率小很多,gohook 目前能檢測這種情況的存在,如果無法修復就放棄(方案三理論上可以通過替換指令克服這個問題)。

幸運的是,golang 為了實現棧的自動增長,會在每個函數的開頭加入指令對當前的棧進行檢查,使得在需要時能對棧空間做擴充處理,無論是目前的 copy stack(contigious stack) 還是 split stack[5][6][7],函數入口的 prologue 都相當長,參考下圖. 而 gohook 理想情況下只需要五字節跳轉,最差情況 14 字節跳轉,目前 golang 版本下,根本不會覆蓋正常的函數邏輯指令,因此指令修復大部分情況下只是修復函數末尾用於處理棧增長的跳轉指令,這種跳轉用近距離2字節指令的可能性相對小很多。

圖-5

5.遞歸處理###

遞歸函數會自己調用自己,從匯編的角度看,通常就是一個五字節相對地址的 call 指令,如果我們替換當前函數,那么這個遞歸應該調到哪里去才對呢?

當前 gohook 的實現是跳到新函數,我個人認為這樣邏輯上似乎合理些。另一方面,在不修復指令的情況下,遞歸默認跳回函數開頭,執行插入的跳轉指令也是走到新函數,這樣行為反而一致。

實現上為達到這個目的,在需要修復指令的情況下,就需要做些特殊處理,目前做法是當看見是相對地址的 call 指令,就額外看看目的地址是不是跳到函數開頭,如果是就不修復。

為什么只處理 Call,而不處理 jmp 呢?因為 Go 在函數末尾插入了處理棧增長的代碼,這部分代碼最后會跳轉回函數入口的地方,用的 JMP 指令,另外就是,函數體中也可能會有跳回函數開頭的理論性可能(可能性很小很小),因此如果所有跳回開頭的指令都不修復,那么這部分邏輯就出問題了,想象一下,runtime 一幫你增長棧就跳到新函數,場面太靈異。

只處理相對地址的 Call 指令理論上也是不完全夠的,雖然大部分情況遞歸用五字節 call 很經濟實惠,但如果遞歸可以通過尾遞歸進行優化,這時編譯器很可能可能就會用  jmp 指令來跳轉,gcc 在這方面對 c 代碼有成熟的優化案例,幸運的是目前 golang 沒聽說有尾遞歸優化,所以以后再說了,畢竟這個優化也不是那么容易的。

注意事項##

  • 項目原意是用來輔助作測試,目前仍在初級階段,並未全面測試和生產驗證,可靠性有待驗證。
  • 特殊情況下通過 push/retn 跳轉時,需要臨時占用 8 字節棧空間,而這 8 字節空間不會被 golang 運行時提前感知,極端情況下,如果剛好處在棧的末尾理論上可能會有問題,但
  • 是根據[8][9]關於棧處理的描述,golang 對每個棧保留了幾百字節的額外空間用來作優化,允許越過 stackmin 字節(通常是 128 bytes),因此可能也不會有問題,這個問題我目前還不確定。
  • 特殊情況下會因為某些指令因為距離溢出無法修復,從而無法 hook。
  • 修復指令需要知道函數的大小,目前 gohook 通過 elf 導出的調試信息進行判斷,如果二進制 strip 過,則通過 function prologue 進行暴力搜索,對部分特殊庫函數可能無法成功。
  • 過小的函數有可能會被 inline,此時無法 hook(編譯時加上-gcflags='-m'選項可以查看哪些函數被 inline,另外就是如果自己寫的函數不希望被 inline,可以加上 // go:noline 來告訴編譯器不要對其進行 inline,gcflags 也可以指示編譯器不要對代碼進行內聯,如-gcflags=all='-l')。
  • 32 位環境下沒有完整驗證過,理論上可行,測試代碼也沒問題。

引用##

1、https://github.com/kmalloc/gohook

2、https://github.com/bouk/monkey

3、http://jbremer.org/x86-api-hooking-demystified/

4、https://software.intel.com/sites/default/files/managed/39/c5/325462-sdm-vol-1-2abcd-3abcd.pdf

5、https://agis.io/post/contiguous-stacks-golang/

6、https://dave.cheney.net/2013/06/02/why-is-a-goroutines-stack-infinite

7、https://blog.cloudflare.com/how-stacks-are-handled-in-go/

8、https://golang.org/src/runtime/stack.go

9、http://blog.nella.org/?p=849
10、https://golang.org/pkg/reflect/#Value.Pointer
11、https://github.com/golang/go/blob/master/src/runtime/runtime2.go#L187
12、https://github.com/kmalloc/gohook/blob/master/example/example3.go
13、https://onedrive.live.com/View.aspx?resid=7804A3BDAEB13A9F!58083&authkey=!AKVlLS9s9KYh07s


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM