Native Hook 技术,天使还是魔鬼

Hook 的不同流派?
对于Hook 技术,比较常见的有 GOT/PLT Hook、Trap Hook 以及Hook,下面逐个讲解这些 Hook 技术的实现原理和优劣比较 。
1. GOT/PLT Hook?
在-plus中,我们使用了 PLT Hook 技术来获取线程创建的堆栈 。先来回顾一下它的整个流程,我们将 .so 中的外部函数替换成自己的方法。
你可以发现 , GOT/PLT Hook 主要是用于替换某个 SO 的外部调用 , 通过将外部函数调用跳转成我们的目标函数 。GOT/PLT Hook 可以说是一个非常经典的 Hook 方法,它非常稳定,可以达到部署到生产环境的标准 。
那 GOT/PLT Hook 的实现原理究竟是什么呢?你需要先对 SO 库文件的 ELF 文件格式和动态链接过程有所了解 。
ELF 格式?
ELF()是可执行和链接格式,它是一个开放标准 , 各种 UNIX 系统的可执行文件大多采用 ELF 格式 。虽然 ELF 文件本身就支持三种不同的类型(重定位、执行、共享),不同的视图下格式稍微不同,不过它有一个统一的结构 , 这个结构如下图所示 。
网上介绍 ELF 格式的文章非常多 , 你可以参考《ELF 文件格式解析》 。顾名思义,对于 GOT/PLT Hook 来说,我们主要关心“.plt”和“.got”这两个节区:
我们也可以使用 -S来查看 ELF 文件的具体信息 。
链接过程?
接下来我们再来看看动态链接的过程,当需要使用一个库(.so 文件)的时候,我们需要调用(".so")来加载这个库 。
在我们调用了(".so")之后 , 系统首先会检查缓存中已加载的 ELF 文件列表 。如果未加载则执行加载过程,如果已加载则计数加一,忽略该调用 。然后系统会用从 .so 的节区中读取其所依赖的库,按照相同的加载逻辑,把未在缓存中的库加入加载列表 。
你可以使用下面这个命令来查看一个库的依赖:
readelf -d | grep NEEDED
下面我们大概了解一下系统是如何加载的 ELF 文件的 。
但是这里有一个关键点,在 ELF 文件格式中我们只有函数的绝对地址 。如果想在系统中运行,这里需要经过重定位 。这其实是一个比较复杂的问题 , 因为不同机器的 CPU 架构、加载顺序不同,导致我们只能在运行时计算出这个值 。不过还好动态加载器(//bin/)会帮助我们解决这个问题 。
如果你理解了动态链接的过程 , 我们再回头来思考一下“.got”和“.plt”它们的具体含义 。
PLT 和 GOT 记录是一一对应的,并且 GOT 表第一次解析后会包含调用函数的实际地址 。既然这样,那 PLT 的意义究竟是什么呢?PLT 从某种意义上赋予我们一种懒加载的能力 。当动态库首次被加载时,所有的函数地址并没有被解析 。下面让我们结合图来具体分析一下首次函数调用 , 请注意图中黑色箭头为跳转,紫色为指针 。
在解析前 GOT[n]会直接指向 jmp *GOT[n]的下一条指令 。在解析完成后,我们就得到了 func 的实际地址 , 动态加载器会将这个地址填入 GOT[n] , 然后调用 func 。
如果对上面的这个调用流程还有疑问,你可以参考《GOT 表和 PLT 表》这篇文章,它里面有一张图非常清晰 。
当第一次调用发生后,之后再调用函数 func 就高效简单很多 。首先调用 PLT[n],然后执行 jmp *GOT[n] 。GOT[n]直接指向 func , 这样就高效的完成了函数调用 。
总结一下 , 因为很多函数可能在程序执行完时都不会被用到,比如错误处理函数或一些用户很少用到的功能模块等 , 那么一开始把所有函数都链接好实际就是一种浪费 。为了提升动态链接的性能 , 我们可以使用 PLT 来实现延迟绑定的功能 。
对于函数运行的实际地址,我们依然需要通过 GOT 表得到,整个简化过程如下:
看到这里,相信你已经有了如何 Hack 这一过程的初步想法 。这里业界通常会根据修改 PLT 记录或者 GOT 记录区分为 GOT Hook 和 PLT Hook,但其本质原理十分接近 。
GOT/PLT Hook 实践?
GOT/PLT Hook 看似简单,但是实现起来也是有一些坑的 , 需要考虑兼容性的情况 。一般来说 , 推荐使用业界的成熟方案 。
如果不想深入它内部的原理,我们只需要直接使用这些开源的优秀方案就可以了 。因为这种 Hook 方式非常成熟稳定,除了 Hook 线程的创建,我们还有很多其他的使用范例 。
这种 Hook 方法也不是万能的,因为它只能替换导入函数的方式 。有时候我们不一定可以找到这样的外部调用函数 。如果想 Hook 函数的内部调用,这个时候就需要用到我们的 Trap Hook 或者Hook 了 。
2. Trap Hook?
对于函数内部的 Hook,你可以先从头想一下,会发现调试器就具备一切 Hook 框架具有的能力,可以在目标函数前断住程序,修改内存、程序段 , 继续执行 。相信很多同学都会使用调试器 , 但是对调试器如何工作却知之甚少 。下面让我们先了解一下软件调试器是如何工作的 。
?
一般软件调试器都是通过系统调用和配合来进行断点调试,首先我们来了解一下什么是,它又是如何断住程序运行,然后修改相关执行步骤的 。
所谓合格的底层程序员,对于未知知识的了解,第一步就是使用man命令来查看系统文档 。
The ()calla means by which one(the “”) mayandtheof(the “”), andandthe ’sand . It isused toandcall .
这段话直译过来就是, 提供了一种让一个程序()观察或者控制另一个程序()执行流程,以及修改被控制程序内存和寄存器的方法,主要用于实现调试断点和系统调用跟踪 。
我们再来简单了解一下调试器(GDB/LLDB)是如何使用的 。首先调试器会基于要调试进程是否已启动,来决定是使用 fork 或者到目标进程 。当调试器与目标程序绑定后 , 目标程序的任何 (除 )都会被调试器做先拦截,调试器会有机会对相关信号进行处理,然后再把执行权限交由目标程序继续执行 。可以你已经想到了,这其实已经达到了 Hook 的目的 。
如何 Hook?
但更进一步思考,如果我们不需要修改内存或者做类似调试器一样复杂的交互,我们完全可以不依赖  , 只需要接收相关即可 。这时我们就想到了句柄( ) 。对!我们完全可以主动 raise ,然后使用来实现类似的 Hook 效果 。
业界也有不少人将 Trap Hook 叫作断点 Hook , 它的原理就是在需要 Hook 的地方想办法触发断点,并捕获异常 。一般我们会利用或者 (非法指令异常)这两种信号 。下面以信号为例 , 具体的实现步骤如下 。
Trap Hook 实践?
Trap Hook 实践Trap Hook 兼容性非常好,它也可以在生产环境中大规模使用 。但是它最大的问题是效率比较低,不适合 Hook 非常频繁调用的函数 。
对于 Trap Hook 的实践方案,在“卡顿优化(下)”中,我提到过的,它就是通过定期发送信号来实现卡顿监控的 。
3.Hook?
跟 Trap Hook 一样 ,  Hook 也是函数内部调用的 Hook 。它直接将函数开始()处的指令更替为跳转指令,使得原函数直接跳转到 Hook 的目标函数函数,并保留原函数的调用接口以完成后续再调用回来的目的 。
与 GOT/PLT Hook 相比,Hook 可以不受 GOT/PLT 表的限制,几乎可以 Hook 任何函数 。不过其实现十分复杂,我至今没有见过可以用在生产环境的实现 。并且在 ARM 体系结构下,无法对叶子函数和很短的函数进行 Hook 。
在深入“邪恶的”细节前 , 我们需要先对Hook 的大体流程有一个简单的了解 。
如图所示,Hook 的基本思路就是在已有的代码段中插入跳转指令,把代码的执行流程转向我们实现的 Hook 函数中 , 然后再进行指令修复,并跳转回原函数继续执行 。这段描述看起来是不是十分简单而且清晰?
对于 Trap Hook,我们只需要在目标地址前插入特殊指令,并且在执行结束后把原始指令写回去就可以了 。但是对Hook 来说,它是直接进行指令级的复写与修复 。怎么理解呢?就相当于我们在运行过程中要去做 ASM 的字节码修改 。
当然Hook 远远比 ASM 操作更加复杂 , 因为它还涉及不同 CPU 架构带来的指令集适配问题,我们需要根据不同指令集来分别进行指令复写与跳转 。
下面我先来简单说明一下常见的 CPU 架构和指令集:
ARM64 目前我还没有适配 , 不过Play 要求所有应用在 2019 年 8 月 1 日之前需要支持 64 位,所以今年上半年也要折腾一下 。但它们的原理基本类似,下面我以最主流的 ARMv7 架构为例 , 为你庖丁解牛Hook 。
ARM32 指令集?
ARMv7 中有一种广为流传的PC=??=PC+8的说法 。这是指 ARMv7 中的三级流水线(取指、解码、执行),换句话说PC寄存器总是指向正在取指的指令,而不是指向正在执行的指令 。取指总会比执行快2个指令,在ARM32指令集下2个指令的长度为8个字节,所以??寄存器总是指向正在取指的指令,而不是指向正在执行的指令 。取指总会比执行快2个指令,在???32指令集下2个指令的长度为8个字节,所以PC寄存器的值总是比当前指令地址要大 8 。
是不是感觉有些复杂,其实这是为了引出 ARM 指令集的常用跳转方法:LDR PC, [PC, #-4] ;$
LDR PC, [PC, #-4] ;0xE51FF004$TRAMPOLIN_ADDR
在了解了三级流水线以后,就不会对这个 PC-4 有什么疑惑了 。
按照我们前面描述的Hook 的基本步骤,首先插入跳转指令,跳入我们的蹦床(),执行我们实现的 Hook 后函数 。这里还有一个“邪恶的”细节,由于指令执行是依赖当前运行环境的,即所有寄存器的值,而我们插入新的指令是有可能更改寄存器的状态的,所以我们要保存当前全部的寄存器状态到栈中,使用 BLX 指令跳转执行 Hook 后函数,执行完成后,再从栈中恢复所有的寄存器,最后才能像未 Hook 一样继续执行原先函数 。
在执行完 Hook 后的函数后,我们需要跳转回原先的函数继续执行 。这里不要忘记我们在一开始覆盖的 LDR 指令,我们需要先执行被我们复写的指令,然后再使用如下指令 , 继续执行原先函数 。
LDR PC, [PC, #-4]HOOKED_ADDR+8
是不是有一种大功告成的感觉?其实这里还有一个巨大的坑在等着我们,那就是指令修复 。前面我提到保存并恢复了寄存器原有的状态 , 已达到可以继续像原有程序一样的继续执行 。但仅仅是恢复寄存器就足够么?显然答案是否定的,虽然寄存器被我们完美恢复了 , 但是 2 条备份的指令被移动到了新的地址 。当执行它们的时候,PC寄存器的值是与原先不同的 。这条指令的操作如果涉及??寄存器的值是与原先不同的 。这条指令的操作如果涉及PC的值,那么它们将会执行出完全不同的结果 。
到这里我就不对指令修复再深入解析了,感兴趣的同学可以在留言区进行讨论 。
Hook 实践?
对于Hook,虽然它功能非常强大 , 而且执行效率也很高,但是业界目前还没有一套完全稳定可靠的开源方案 。Hook 一般会使用在自动化测试或者线上疑难问题的定位,例如“UI 优化”中说到 .so 崩溃问题的定位 , 我们就是利用Hook 去收集系统信息 。
业界也有一些不错的参考方案: -Cydia。在中,我们就使用它来 Hook 系统的内存分配函数 。-adbi 。支付宝在GC 抑制中使用的 Hook 框架,不过已经好几年没有更新了 。
各个流派的优缺点比较?
最后我们再来总结一下不同的 Hook 方式的优缺点:
GOT/PLT Hook 是一个比较中庸的方案 , 有较好的性能,中等的实现难度,但其只能 Hook 动态库之间的调用的函数,并且无法 Hook 未导出的私有函数 , 而且只存在安装与卸载 2 种状态 , 一旦安装就会 Hook 所有函数调用 。
Trap Hook 最为稳定,但由于需要切换运行模式(R0/R3),且依赖内核的信号机制,导致性能很差 。
Hook 是一个非常激进的方案,有很好的性能,并且也没有 PLT 作用域的限制,可以说是一个非常灵活、完美的方案 。但其实现难度极高,我至今也没有看到可以部署在生产环境的Hook 方案 , 因为涉及指令修复,需要编译器的各种优化 。
但是需要注意,无论是哪一种 Hook 都只能 Hook 到应用自身的进程 , 我们无法替换系统或其他应用进程的函数执行 。
总结?
【Native Hook 技术,天使还是魔鬼】总的来说Hook 是一门非常底层的技术,它会涉及库文件编译、加载、链接等方方面面的知识 , 而且很多底层知识是与甚至移动平台无关的 。在这一领域,做安全的同学可能会更有发言权 , 我来讲可能班门弄斧了 。不过希望通过这篇文章,让你对看似黑科技的 Hook 有一个大体的了解,希望可以在自己的平时的工作中使用 Hook 来完成一些看似不可能的任务,比如修复系统 Bug、线上监控内存分配等 。