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

posted on 2019-06-04 15:10  twoon  阅读(11401)  评论(1编辑  收藏  举报