執行時替換函式對 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]。原理好懂,實現上其實比較坎坷,關鍵有幾點:
1. 函式地址獲取
與 c/c++ 不同,golang 中函式地址並不直接暴露,但是可以利用函式物件獲取,通過將函式物件用反射的 Value 包裝一層,可以實現由 Value 的 Pointer() 函式返回函式物件中包含的真實地址。
2.跳轉程式碼生成
跳轉指令取決於硬體平臺,對於 x86/x64 來說,有幾種方式,具體可以參考文件[3],或者 intel 開發者手冊[4],gohook 的實現優先選用 5 位元組的相對地址跳轉,該指令用四個位元組表示位移,最多可以跳轉到半徑為 2 GB 以內的地址。
這對大部分的程式來說足夠了,如果程式的程式碼段超出了 2GB(難以想像),gohook 則通過把目標函式絕對地址壓到棧上,再執行 ret 指令實現跳轉。
這兩種跳轉方式的結合使得跳轉實現起來相對 gomonkey 簡單容易很多,gomonkey 選用了 indirect jump,該指令需要一個函式地址的中間變數存放到暫存器,因此這個變數必須保證不會被回收,還得注意該暫存器不會被目標函式使用,導致實現上很彆扭且不安全(跳轉程式碼必須放到函式的最開始一段,不能放在中間),更嚴重的是,因為需要直接使用函式物件,gomonkey 必須猜測 value 物件的記憶體佈局來獲取其中的 function value,runtime 實現一改,這裡就得跪。
3.成員函式的處理
成員函式在 golang 中與普通函式幾乎一樣,唯一區別是物件函式的第一個引數是物件的引用,因此 hook 成員函式與 hook 一般函式本質上是一樣的,無需特殊處理。
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。
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/