gohook 一個支持運行時替換 golang 函數(shù)的庫實現(xiàn)
運行時替換函數(shù)對 golang 這類靜態(tài)語言來說并不是件容易的事情,語言層面的不支持導(dǎo)致只能從機器碼層面做些奇怪 hack,往往艱難,但如能成功,那掙脫牢籠帶來的成就感,想想就讓人興奮。
gohook##
gohook 實現(xiàn)了對函數(shù)的暴力攔截,無論是普通函數(shù),還是成員函數(shù)都可以強行攔截替換,并支持回調(diào)原來的舊函數(shù),效果如下(更多使用方式/接口等請參考 github 上的單元測試[1],以及 example 目錄下的使用示例):

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

圖-2
Hook() 函數(shù)原型很簡單:
func Hook(target, replacement, trampoline interface{}) error {}
該函數(shù)接受三個參數(shù),第一個參數(shù)是要 hook 的目標(biāo)函數(shù),第二個參數(shù)是替換函數(shù),第三個參數(shù)則比較神奇,它用來支持跳轉(zhuǎn)到舊函數(shù),可以理解函數(shù)替身,hook 完成后,調(diào)用 trampoline 則相當(dāng)于調(diào)用舊的目標(biāo)函數(shù)(target),第三個參數(shù)可以傳入 nil,此時表示不需要支持回調(diào)舊函數(shù)。
gohook 不僅可以 hook 一般過程式函數(shù),也支持 hook 對象的成員函數(shù),如下圖。

圖-3
HookMethod 原型如下,其中參數(shù) instance 為對象,method 為方法名:
func HookMethod(instance interface{}, method string, replacement, trampoline interface{}) error {}
圖 3 運行結(jié)果如下:

圖-4
目前 GitHub 上有類似功能的第三方實現(xiàn) go monkey[2],gohook 的實現(xiàn)受其啟發(fā),但 gohook 相較之有如下幾個明顯優(yōu)點:
- 跳轉(zhuǎn)效率更高: 大部分情況下 gohook 通過五字節(jié)跳轉(zhuǎn),無棧操作,更可靠,且性能更好,實現(xiàn)上也更容易理解。
- 更安全可靠:跳轉(zhuǎn)需要修改和拷貝指令,極容易影響 call/jmp/ret 等舊指令,本實現(xiàn)支持修復(fù)函數(shù)內(nèi) call/jmp 指令。
- 支持回調(diào)舊函數(shù): 這是最大優(yōu)點,也是 gohook 實現(xiàn)的初衷。
- 不依賴 runtime 內(nèi)部實現(xiàn): gomonkey 因為跳轉(zhuǎn)指令的原因依賴 reflect.value 來獲取 funval,而 value 內(nèi)部結(jié)構(gòu)并不開放,導(dǎo)致 go monkey 對 runtime 的內(nèi)部實現(xiàn)產(chǎn)生了依賴。
實現(xiàn)解析##
Hook 的原理是通過修改目標(biāo)函數(shù)入口的指令,實現(xiàn)跳轉(zhuǎn)到新函數(shù),這方面和 c/c++ 類似實踐的原理相同,具體可以參考[3]。原理好懂,實現(xiàn)上其實比較坎坷,源碼細(xì)節(jié)請參考[13],關(guān)鍵有幾點:
1. 函數(shù)地址獲取###
與 c/c++ 不同,golang 中函數(shù)地址并不直接暴露,但是可以利用函數(shù)對象獲取,通過將函數(shù)對象用反射的 Value 包裝一層,可以實現(xiàn)由 Value 的 Pointer() 函數(shù)返回函數(shù)對象中包含的真實地址,golang 文檔對此有特別說明[10]。
2.跳轉(zhuǎn)代碼生成###
跳轉(zhuǎn)指令取決于硬件平臺,對于 x86/x64 來說,有幾種方式,具體可以參考文檔[3],或者 intel 開發(fā)者手冊[4],gohook 的實現(xiàn)優(yōu)先選用 5 字節(jié)的相對地址跳轉(zhuǎn),該指令用四個字節(jié)表示位移,最多可以跳轉(zhuǎn)到半徑為 2 GB 以內(nèi)的地址。
這對大部分的程序來說足夠了,如果程序的代碼段超出了 2GB(難以想像),gohook 則通過把目標(biāo)函數(shù)絕對地址壓到棧上,再執(zhí)行 ret 指令實現(xiàn)跳轉(zhuǎn)。
這兩種跳轉(zhuǎn)方式的結(jié)合使得跳轉(zhuǎn)實現(xiàn)起來相對 gomonkey 簡單容易很多,gomonkey 選用了 indirect jump,該指令需要一個函數(shù)地址的中間變量存放到寄存器,因此這個變量必須保證不會被回收,還得注意該寄存器不會被目標(biāo)函數(shù)使用,導(dǎo)致實現(xiàn)上很別扭且不安全(跳轉(zhuǎn)代碼必須放到函數(shù)的最開始一段,不能放在中間),更嚴(yán)重的是,因為需要直接使用函數(shù)對象,gomonkey 必須猜測 value 對象的內(nèi)存布局來獲取其中的 function ptr,runtime 實現(xiàn)一改,這里就得跪。
3.成員函數(shù)的處理###
成員函數(shù)在 golang 中與普通函數(shù)幾乎一樣,唯一區(qū)別是成員函數(shù)的第一個參數(shù)是對象的引用,因此 hook 成員函數(shù)與 hook 一般函數(shù)本質(zhì)上是一樣的,無需特殊處理。
值得注意到是子類調(diào)用基類函數(shù)這種場景,golang 編譯時會為子類生成一個基類函數(shù)的包裝(wrapper),這個包裝存在的目的是給通過接口調(diào)用基類函數(shù)時所使用,其作用從匯編角度看似乎是用于把對象的地址進行處理和傳遞,最后跳到基類函數(shù)中(具體原因沒深究)。
所以在 hook 對象的成員函數(shù)時有兩種方式,一種是通過子類來 hook,一種是通過基類來 hook,前者只覆蓋通過接口調(diào)用函數(shù)這種場景,后者則能處理所有場景,對于 hook 第三方庫來說,經(jīng)?;惪赡苁遣婚_放的,這時 gohook 能發(fā)揮的作用就比較有限。當(dāng)然按 golang 開發(fā)的慣例來說,這種繼承(嚴(yán)格來說繼承也不存在)一般會配合接口來實現(xiàn)類似多態(tài)的功能,因此 hook 子類通常也能解決大部分場景了。
如果上面的描述有些抽象,請參看 example 目錄下的 example3.go[12].
4.回調(diào)舊函數(shù)###
回調(diào)舊函數(shù)是很難的,很多問題需要處理,目標(biāo)函數(shù)因為入口地址要被修改,本質(zhì)上一部分指令會被破壞,因此如果想回調(diào)舊函數(shù),有幾種方式可以做到:
1.將被損壞的指令拷貝出來,在需要回調(diào)舊函數(shù)時,先將指令恢復(fù)回去,再調(diào)用舊函數(shù)。
2.將被損壞的指令拷貝到另一個地方,并在末尾加上跳轉(zhuǎn)指令轉(zhuǎn)回舊函數(shù)體中相應(yīng)的位置。
3.將整個舊函數(shù)拷貝一份,并修復(fù)其中的跳轉(zhuǎn)指令。
gohook 目前采用了第二種方案(后續(xù)會支持第三種),主要考慮有幾個:
- 方案一無法重入,在 golang 協(xié)程環(huán)境下幾乎無法實際使用。
- 拷貝整個函數(shù)消耗較大,且事先無法預(yù)測目標(biāo)函數(shù)的大小,函數(shù)替身難以準(zhǔn)備。
無論是拷貝一部分指令還是全部指令,其中面臨一個問題必須解決,函數(shù)指令中的跳轉(zhuǎn)指令必須進行修復(fù)。
跳轉(zhuǎn)指令主要有三類:call/jmp/conditional jmp,具體來說,是要處理這三類指令中的相對跳轉(zhuǎn)指令,gohook 已經(jīng)處理了所有能處理的指令,不能處理的主要是部分場景下的兩字節(jié)指令的跳轉(zhuǎn),原因是指令拷貝后,目標(biāo)地址和跳轉(zhuǎn)指令之間的距離很可能會超過一個字節(jié)所能表示,此時無法直接修復(fù),當(dāng)然同樣問題對四字節(jié)相對地址跳轉(zhuǎn)來說也可能會存在,只是概率小很多,gohook 目前能檢測這種情況的存在,如果無法修復(fù)就放棄(方案三理論上可以通過替換指令克服這個問題)。
幸運的是,golang 為了實現(xiàn)棧的自動增長,會在每個函數(shù)的開頭加入指令對當(dāng)前的棧進行檢查,使得在需要時能對??臻g做擴充處理,無論是目前的 copy stack(contigious stack) 還是 split stack[5][6][7],函數(shù)入口的 prologue 都相當(dāng)長,參考下圖. 而 gohook 理想情況下只需要五字節(jié)跳轉(zhuǎn),最差情況 14 字節(jié)跳轉(zhuǎn),目前 golang 版本下,根本不會覆蓋正常的函數(shù)邏輯指令,因此指令修復(fù)大部分情況下只是修復(fù)函數(shù)末尾用于處理棧增長的跳轉(zhuǎn)指令,這種跳轉(zhuǎn)用近距離2字節(jié)指令的可能性相對小很多。

圖-5
5.遞歸處理###
遞歸函數(shù)會自己調(diào)用自己,從匯編的角度看,通常就是一個五字節(jié)相對地址的 call 指令,如果我們替換當(dāng)前函數(shù),那么這個遞歸應(yīng)該調(diào)到哪里去才對呢?
當(dāng)前 gohook 的實現(xiàn)是跳到新函數(shù),我個人認(rèn)為這樣邏輯上似乎合理些。另一方面,在不修復(fù)指令的情況下,遞歸默認(rèn)跳回函數(shù)開頭,執(zhí)行插入的跳轉(zhuǎn)指令也是走到新函數(shù),這樣行為反而一致。
實現(xiàn)上為達(dá)到這個目的,在需要修復(fù)指令的情況下,就需要做些特殊處理,目前做法是當(dāng)看見是相對地址的 call 指令,就額外看看目的地址是不是跳到函數(shù)開頭,如果是就不修復(fù)。
為什么只處理 Call,而不處理 jmp 呢?因為 Go 在函數(shù)末尾插入了處理棧增長的代碼,這部分代碼最后會跳轉(zhuǎn)回函數(shù)入口的地方,用的 JMP 指令,另外就是,函數(shù)體中也可能會有跳回函數(shù)開頭的理論性可能(可能性很小很小),因此如果所有跳回開頭的指令都不修復(fù),那么這部分邏輯就出問題了,想象一下,runtime 一幫你增長棧就跳到新函數(shù),場面太靈異。
只處理相對地址的 Call 指令理論上也是不完全夠的,雖然大部分情況遞歸用五字節(jié) call 很經(jīng)濟實惠,但如果遞歸可以通過尾遞歸進行優(yōu)化,這時編譯器很可能可能就會用 jmp 指令來跳轉(zhuǎn),gcc 在這方面對 c 代碼有成熟的優(yōu)化案例,幸運的是目前 golang 沒聽說有尾遞歸優(yōu)化,所以以后再說了,畢竟這個優(yōu)化也不是那么容易的。
注意事項##
- 項目原意是用來輔助作測試,目前仍在初級階段,并未全面測試和生產(chǎn)驗證,可靠性有待驗證。
- 特殊情況下通過 push/retn 跳轉(zhuǎn)時,需要臨時占用 8 字節(jié)棧空間,而這 8 字節(jié)空間不會被 golang 運行時提前感知,極端情況下,如果剛好處在棧的末尾理論上可能會有問題,但
- 是根據(jù)[8][9]關(guān)于棧處理的描述,golang 對每個棧保留了幾百字節(jié)的額外空間用來作優(yōu)化,允許越過 stackmin 字節(jié)(通常是 128 bytes),因此可能也不會有問題,這個問題我目前還不確定。
- 特殊情況下會因為某些指令因為距離溢出無法修復(fù),從而無法 hook。
- 修復(fù)指令需要知道函數(shù)的大小,目前 gohook 通過 elf 導(dǎo)出的調(diào)試信息進行判斷,如果二進制 strip 過,則通過 function prologue 進行暴力搜索,對部分特殊庫函數(shù)可能無法成功。
- 過小的函數(shù)有可能會被 inline,此時無法 hook(編譯時加上
-gcflags='-m'選項可以查看哪些函數(shù)被 inline,另外就是如果自己寫的函數(shù)不希望被 inline,可以加上// go:noline來告訴編譯器不要對其進行 inline,gcflags 也可以指示編譯器不要對代碼進行內(nèi)聯(lián),如-gcflags=all='-l')。 - 32 位環(huán)境下沒有完整驗證過,理論上可行,測試代碼也沒問題。
引用##
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
浙公網(wǎng)安備 33010602011771號