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

gohook 一个支持运行时替换 golang 函数的库实现的更多相关文章

  1. Dalvik模式下基于Android运行时类加载的函数dexFindClass脱壳

    本文博客地址:http://blog.csdn.net/qq1084283172/article/details/78003184 前段时间在看雪论坛发现了<发现一个安卓万能脱壳方法>这篇 ...

  2. Android6.0运行时权限(基于RxPermission开源库)

    版权声明:本文为HaiyuKing原创文章,转载请注明出处! 前言 在6.0以前的系统,都是权限一刀切的处理方式,只要用户安装,Manifest申请的权限都会被赋予,并且安装后权限也撤销不了. And ...

  3. JAVA调用系统命令或可执行程序--返回一个Runtime运行时对象,然后启动另外一个进程来执行命令

    通过 java.lang.Runtime 类可以方便的调用操作系统命令,或者一个可执行程序,下面的小例子我在windows和linux分别测试过,都通过.基本原理是,首先通过 Runtime.getR ...

  4. MEF在运行时替换插件

    利用AppDomain的ShadowCopy特性. var setup = new AppDomainSetup { CachePath = cachePath, ShadowCopyFiles = ...

  5. c# 运行时替换某文件源代码(将XML 转换成 某个枚举并写入源文件)

    var sr = new StreamReader(Server.MapPath("~/WEB-INF/rule.config")); XmlDocument doc = new ...

  6. C# 运行时替换方法(需要unsafe编译)

    /* https://stackoverflow.com/questions/7299097/dynamically-replace-the-contents-of-a-c-sharp-method ...

  7. phpstudy运行时出现没有安装VC库

    系统默认的VC库是安装在C:\Program Files\Common Files\microsoft shared\VC的文件夹里,当运行PHP Study是出现如下的提示: 可以到下面的网站去下载 ...

  8. linux下实现在程序运行时的函数替换(热补丁)

    声明:以下的代码成果,是参考了网上的injso技术,在本文的最后会给出地址,同时非常感谢injso技术原作者的分享. 但是injso文章中的代码存在一些问题,所以后面出现的代码是经过作者修改和检测的. ...

  9. linux下实现在程序运行时的函数替换(热补丁)【转】

    转自:http://www.cnblogs.com/leo0000/p/5632642.html 声明:以下的代码成果,是参考了网上的injso技术,在本文的最后会给出地址,同时非常感谢injso技术 ...

随机推荐

  1. 能避开很多坑的mysql面试题,你知道吗?

    最近有一些朋友问我一些mysql相关的面试题,有一些比较基础,有些比较偏.这里就总结一些常见的mysql面试题吧,都是自己平时工作的总结以及经验.大家看完,能避开很多坑.而且很多问题,都是面试中也经常 ...

  2. 【开发者portal在线开发插件系列四】数组 及 可变长度数组

    基础篇 基础场景见上面两个帖子,这里单独说明数组和可变长度数组的用法. 话不多说,开始今天的演(表)示(演) Profile和插件开发 添加一个string类型的属性: 在插件里添加一条数据上报消息: ...

  3. Python面试的一些心得,与Python练习题分享

    关于基础 项目打算招聘一个自动化运维,主要需求是python.Linux与shell脚本能力.但面试几天发现一些问题: 简历虚假 这个不管哪行,简历含水量大都是普遍存在的,看简历犀利的一比,一面是能力 ...

  4. luogu P2899 [USACO08JAN]手机网络Cell Phone Network |贪心

    include include include include include include define db double using namespace std; const int N=1e ...

  5. ZOJ 3195 Design the city (LCA 模板题)

    Cerror is the mayor of city HangZhou. As you may know, the traffic system of this city is so terribl ...

  6. python学习笔记—DataFrame和Series的排序

    更多大数据分析.建模等内容请关注公众号<bigdatamodeling> ################################### 排序 ################## ...

  7. Django 04

    目录 视图层 三个常用方法 JsonResponse FBV 和 CBV 模板层 模板语法 模板传值 过滤器 标签 自定义过滤器和标签 模板的继承 模板的导入 视图层 三个常用方法 HttpRespo ...

  8. Python3 常用的几个内置方法

    目录 max()/min() filter() 过滤 map() 映射 sorted筛选 reduce()减少 max()/min() 传入一个参数 (可迭代对象), 返回这个可迭代对象中最大的元素 ...

  9. 【Webpack】373- 一看就懂之 webpack 高级配置与优化

    本文原载于 SegmentFault 社区专栏 前海拾贝 作者:JS_Even_JS 一.打包多页面应用 所谓打包多页面,就是同时打包出多个 html 页面,打包多页面也是使用 html-webpac ...

  10. 每周一练 之 数据结构与算法(Set)

    这是第四周的练习题,五一放假结束,该收拾好状态啦. 下面是之前分享的链接: 1.每周一练 之 数据结构与算法(Stack) 2.每周一练 之 数据结构与算法(LinkedList) 2.每周一练 之 ...