https://github.com/iqiyi/xhook
作者居然还写了一篇安卓PLT hook的小白入门文章,还有配套的演示代码,原文在:https://github.com/iqiyi/xHook/blob/master/docs/overview/android_plt_hook_overview.zh-CN.md
你始终可以从 这里 访问本文的最新版本。
文中使用的示例代码可以从 这里 获取。文中提到的 xhook 开源项目可以从 这里 获取。
我们有一个新的动态库:libtest.so。
头文件 test.h
源文件 test.c
say_hello
的功能是在终端打印出 hello\n
这6个字符(包括结尾的 \n
)。
我们需要一个测试程序:main。
源文件 main.c
编译它们分别生成 libtest.so 和 main。运行一下:
太棒了!libtest.so 的代码虽然看上去有些愚蠢,但是它居然可以正确的工作,那还有什么可抱怨的呢?赶紧在新版 APP 中开始使用它吧!
遗憾的是,正如你可能已经发现的,libtest.so 存在严重的内存泄露问题,每调用一次 say_hello
函数,就会泄露 1024 字节的内存。新版 APP 上线后崩溃率开始上升,各种诡异的崩溃信息和报障信息跌撞而至。
幸运的是,我们修复了 libtest.so 的问题。可是以后怎么办呢?我们面临 2 个问题:
如果我们能对动态库中的函数调用做 hook(替换,拦截,窃听,或者你觉得任何正确的描述方式),那就能够做到很多我们想做的事情。比如 hook malloc
,calloc
,realloc
和 free
,我们就能统计出各个动态库分配了多少内存,哪些内存一直被占用没有释放。
这真的能做到吗?答案是:hook 我们自己的进程是完全可以的。hook 其他进程需要 root 权限(对于其他进程,没有 root 权限就没法修改它的内存空间,也没法注入代码)。幸运的是,我们只要 hook 自己就够了。
ELF(Executable and Linkable Format)是一种行业标准的二进制数据封装格式,主要用于封装可执行文件、动态库、object 文件和 core dumps 文件。
使用 google NDK 对源代码进行编译和链接,生成的动态库或可执行文件都是 ELF 格式的。用 readelf 可以查看 ELF 文件的基本信息,用 objdump 可以查看 ELF 文件的反汇编输出。
ELF 格式的概述可以参考 这里,完整定义可以参考 这里。其中最重要的部分是:ELF 文件头、SHT(section header table)、PHT(program header table)。
ELF 文件的起始处,有一个固定格式的定长的文件头(32 位架构为 52 字节,64 位架构为 64 字节)。ELF 文件头以 magic number 0x7F 0x45 0x4C 0x46
开始(其中后 3 个字节分别对应可见字符 E
L
F
)。
libtest.so 的 ELF 文件头信息:
ELF 文件头中包含了 SHT 和 PHT 在当前 ELF 文件中的起始位置和长度。例如,libtest.so 的 SHT 起始位置为 12744,长度 40 字节;PHT 起始位置为 52,长度 32字节。
ELF 以 section 为单位来组织和管理各种信息。ELF 使用 SHT 来记录所有 section 的基本信息。主要包括:section 的类型、在文件中的偏移量、大小、加载到内存后的虚拟内存相对地址、内存中字节的对齐方式等。
libtest.so 的 SHT:
比较重要,且和 hook 关系比较大的几个 section 是:
.rel.dyn
:除 .rel.plt
以外的重定位信息。(比如通过全局函数指针来调用外部函数)
ELF 被加载到内存时,是以 segment 为单位的。一个 segment 包含了一个或多个 section。ELF 使用 PHT 来记录所有 segment 的基本信息。主要包括:segment 的类型、在文件中的偏移量、大小、加载到内存后的虚拟内存相对地址、内存中字节的对齐方式等。
libtest.so 的 PHT:
所有类型为 PT_LOAD
的 segment 都会被动态链接器(linker)映射(mmap)到内存中。
我们关心的 hook 操作,属于动态形式的内存操作,因此主要关心的是执行视图,即 ELF 被加载到内存后,ELF 中的数据是如何组织和存放的。
这是一个十分重要和特殊的 section,其中包含了 ELF 中其他各个 section 的内存位置等信息。在执行视图中,总是会存在一个类型为 PT_DYNAMIC
的 segment,这个 segment 就包含了 .dynamic section 的内容。
无论是执行 hook 操作时,还是动态链接器执行动态链接时,都需要通过 PT_DYNAMIC
segment 来找到 .dynamic section 的内存位置,再进一步读取其他各项 section 的信息。
libtest.so 的 .dynamic section:
安卓中的动态链接器程序是 linker。源码在 这里。
动态链接(比如执行 dlopen)的大致步骤是:
等一下!我们似乎发现了什么!再看一遍重定位操作(relocate)的部分。难道我们只要从这些 .relxxx
中获取到“目标地址”,然后在“目标地址”中重新填上一个新的函数地址,这样就完成 hook 了吗?也许吧。
静态分析验证一下还是很容易的。以 armeabi-v7a 架构的 libtest.so 为例。先看一下 say_hello 函数对应的汇编代码吧。
找到了!say_hello
在地址 f61
,对应的汇编指令体积为 60
(10 进制)字节。用 objdump 查看 say_hello
的反汇编输出。
对 malloc
函数的调用对应于指令 blx dd4
。跳转到了地址 dd4
。看看这个地址里有什么吧:
果然,跳转到了 .plt
中,经过了几次地址计算,最后跳转到了地址 3f90
中的值指向的地址处,3f90
是个函数指针。
稍微解释一下:因为 arm 处理器使用 3 级流水线,所以第一条指令取到的 pc
的值是当前执行的指令地址 + 8
。
于是:dd4
+ 8
+ 3000
+ 1b4
= 3f90
。
地址 3f90
在哪里呢:
果然,在 .got
里。
顺便再看一下 .rel.plt
:
malloc
的地址居然正好存放在 3f90
里,这绝对不是巧合啊!还等什么,赶紧改代码吧。我们的 main.c 应该改成这样:
编译运行一下:
思路是正确的。但之所以还是失败了,是因为这段代码存在下面的 3 个问题:
我们需要解决这些问题。
在进程的内存空间中,各种 ELF 的加载地址是随机的,只有在运行时才能拿到加载地址,也就是基地址。我们需要知道 ELF 的基地址,才能将相对地址换算成绝对地址。
没有错,熟悉 Linux 开发的聪明的你一定知道,我们可以直接调用 dl_iterate_phdr
。详细的定义见 这里。
嗯,先等等,多年的 Android 开发被坑经历告诉我们,还是再看一眼 NDK 里的 linker.h
头文件吧:
为什么?!ARM 架构的 Android 5.0 以下版本居然不支持 dl_iterate_phdr
!我们的 APP 可是要支持 Android 4.0 以上的所有版本啊。特别是 ARM,怎么能不支持呢?!这还让不让人写代码啦!
幸运的是,我们想到了,我们还可以解析 /proc/self/maps
:
maps 返回的是指定进程的内存空间中 mmap
的映射信息,包括各种动态库、可执行文件(如:linker),栈空间,堆空间,甚至还包括字体文件。maps 格式的详细说明见 这里。
我们的 libtest.so 在 maps 中有 3 行记录。offset 为 0
的第一行的起始地址 b6ec6000
在绝大多数情况下就是我们寻找的基地址。
maps 返回的信息中已经包含了权限访问信息。如果要执行 hook,就需要写入的权限,可以使用 mprotect
来完成:
注意修改内存访问权限时,只能以“页”为单位。mprotect
的详细说明见 这里。
注意 .got
和 .data
的 section 类型是 PROGBITS
,也就是执行代码。处理器可能会对这部分数据做缓存。修改内存地址后,我们需要清除处理器的指令缓存,让处理器重新从内存中读取这部分指令。方法是调用 __builtin___clear_cache
:
注意清除指令缓存时,也只能以“页”为单位。__builtin___clear_cache
的详细说明见 这里。
我们把 main.c
修改为:
重新编译运行:
是的,成功了!我们并没有修改 libtest.so 的代码,甚至没有重新编译它。我们仅仅修改了 main 程序。
libtest.so 和 main 的源码放在 github 上,可以从 这里 获取到。(根据你使用的编译器不同,或者编译器的版本不同,生成的 libtest.so 中,也许 malloc
对应的地址不再是 0x3f90
,这时你需要先用 readelf 确认,然后再到 main.c
中修改。)
当然,我们已经开源了一个叫 xhook 的工具库。使用 xhook,你可以更优雅的完成对 libtest.so 的 hook 操作,也不必担心硬编码 0x3f90
导致的兼容性问题。
xhook 支持 armeabi, armeabi-v7a 和 arm64-v8a。支持 Android 4.0 (含) 以上版本 (API level >= 14)。经过了产品级的稳定性和兼容性验证。可以在 这里 获取 xhook
。
总结一下 xhook 中执行 PLT hook 的流程:
可以。而且对于格式解析来说,读文件是最稳妥的方式,因为 ELF 在运行时,原理上有很多 section 不需要一直保留在内存中,可以在加载完之后就从内存中丢弃,这样可以节省少量的内存。但是从实践的角度出发,各种平台的动态链接器和加载器,都不会这么做,可能它们认为增加的复杂度得不偿失。所以我们从内存中读取各种 ELF 信息就可以了,读文件反而增加了性能损耗。另外,某些系统库 ELF 文件,APP 也不一定有访问权限。
正如你已经注意到的,前面介绍 libtest.so 基地址获取时,为了简化概念和编码方便,用了“绝大多数情况下”这种不应该出现的描述方式。对于 hook 来说,精确的基地址计算流程是:
绝大多数的 ELF 第一个 PT_LOAD
segment 的 p_vaddr
都是 0
。
另外,之所以要在 maps 里找 offset 为 0
的行,是因为我们在执行 hook 之前,希望对内存中的 ELF 文件头进行校验,确保当前操作的是一个有效的 ELF,而这种 ELF 文件头只能出现在 offset 为 0
的 mmap 区域。
可以在 Android linker 的源码中搜索“load_bias”,可以找到很多详细的注释说明,也可以参考 linker 中对 load_bias_
变量的赋值程序逻辑。
会有一些影响。
对于外部函数的调用,可以分为 3 中情况:
一般情况下,产品级的 ELF 很少会使用 -O0 进行编译,所以也不必太纠结。但是如果你希望你的 ELF 尽量不被别人 PLT hook,那可以试试使用 -O0 来编译,然后尽量早的将外部函数的指针赋值给局部函数指针变量,之后一直使用这些局部函数指针来访问外部函数。
总之,查看 C/C++ 的源代码对这个问题的理解没有意义,需要查看使用不同的编译选项后,生成的 ELF 的反汇编输出,比较它们的区别,才能知道哪些情况由于什么原因导致无法被 PLT hook。
我们有时会遇到这样的问题:
可能的原因是:
问题分析:
先明确一个观点:不要只从应用层程序开发的角度来看待段错误,段错误不是洪水猛兽,它只是内核与用户进程的一种正常的交流方式。当用户进程访问了无权限或未 mmap 的虚拟内存地址时,内核向用户进程发送 SIGSEGV 信号,来通知用户进程,仅此而已。只要段错误的发生位置是可控的,我们就可以在用户进程中处理它。
解决方案:
具体代码可以参考 xhook
中的实现,在源码中搜索 siglongjmp
和 sigsetjmp
。
我们这里介绍的 hook 方式为 PLT hook,不能做 ELF 内部函数之间调用的 hook。
inline hook 可以做到,你需要先知道想要 hook 的内部函数符号名(symbol name)或者地址,然后可以 hook。
有很多开源和非开源的 inline hook 实现,比如:
inline hook 方案强大的同时可能带来以下的问题:
建议如果 PLT hook 够用的话,就不必尝试 inline hook 了。
caikelun#qiyi.com (请用 @ 替换 #)
Copyright (c) 2018, 爱奇艺, Inc. All rights reserved.
本文使用 Creative Commons 许可证 授权。
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课
最后于 2019-2-2 14:11
被admin编辑
,原因: 图片本地化