您的位置:首页 > 编程语言 > Go语言

gohook 一个支持运行时替换 golang 函数的库实现

2019-06-04 15:10 3139 查看

运行时替换函数对 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/

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

9、http://blog.nella.org/?p=849

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: 
相关文章推荐