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

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

gohook

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


                                                       圖-1github

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

  
                                                   圖-2api

Hook() 函數原型很簡單:安全

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

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

gohook 不只能夠 hook 通常過程式函數,也支持 hook 對象的成員函數,以下圖。佈局

                                                  圖-3post

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]。原理好懂,實現上其實比較坎坷,關鍵有幾點:

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 也能夠控制編譯器對代碼進行 inline 強度(aggressiveness),如-gcflags=all='-l=N',N 越小,強度越低)。
  • 32 位環境下沒有完整驗證過,理論上可行,測試代碼也沒問題。
     

    引用

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

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

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

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

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

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

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

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

九、http://blog.nella.org/?p=849
十、https://golang.org/pkg/reflect/#Value.Pointer
十一、https://github.com/golang/go/blob/master/src/runtime/runtime2.go#L187
十二、https://github.com/kmalloc/gohook/blob/master/example/example3.go

相關文章
相關標籤/搜索