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

相关文章
相关标签/搜索