其他
字节跳动开源 Android PLT hook 方案 bhook
字节的 Android PLT hook 方案 bhook 开源了。bhook 支持 Android 4.1 - 12 (API level 16 - 31),支持 armeabi-v7a, arm64-v8a, x86 和 x86_64,使用 MIT 许可证授权。字节的大多数 Android App 都在线上使用了 bhook 作为 PLT hook 方案。字节内部有 20 多个不同技术纬度的 SDK 使用了 bhook。bhook 在线上稳定性,功能性,性能等多个方面都达到了预期。👉GitHub 地址:https://github.com/bytedance/bhook
2.1 Execution View(执行视图)
连接视图:ELF 未被加载到内存执行前,以 section 为单位的数据组织形式。 执行视图:ELF 被加载到内存后,以 segment 为单位的数据组织形式。
mmap
将 ELF 加载到内存中,执行 relocation(重定位)把外部引用的绝对地址填入 GOT 表和 DATA 中,然后设置内存页的权限,最后调用 init_array 中的各个初始化函数。.dynamic
是专门为 linker 设计的,其中包含了 linker 解析和加载 ELF 时会用到的各项数据的索引。linker 在解析完 ELF 头和执行视图的内容后,就会开始解析 .dynamic
。.bss
:未初始化的数据。比如:没有赋初值的全局变量和静态变量。(.bss
不占用 ELF 文件体积).data
:已初始化的非只读数据。比如:int g_value = 1;
,或者size_t (*strlen_ptr)(const char *) = strlen;
(初始化过程需要 linker relocation 参与才能知道外部strlen
函数的绝对地址).rodata
:已初始化的只读数据,加载完成后所属内存页会被 linker 设置为只读。比如:const int g_value = 1;
。.data.rel.ro
:已初始化的只读数据,初始化过程需要 linker relocation 参与,加载完成后所属内存页会被 linker 设置为只读。比如:const size_t (*strlen)(const char *) = strlen;
。
2.4 Code(代码)
.text
:大多数函数被编译成二进制机器指令后,会存放在这里。.init_array
:有时候我们需要在 ELF 被加载后立刻自动执行一些逻辑,比如定义一个全局的 C++ 类的实例,这时候就需要在.init_array
中调用这个类的构造函数。另外,也可以用__attribute__((constructor))
定义单独的 init 函数。.plt
:对外部或内部的符号的调用跳板,.plt
会从.got
或.data
或.data.rel.ro
中查询符号的绝对地址,然后执行跳转。
2.5 Symbol(符号)
.dynstr
:动态链接符号的字符串池,保存了动态链接过程中用到的所有字符串信息,比如:函数名,全局变量名。.dynsym
:动态链接符号的索引信息表,起到“关联”和“描述”的作用。
导出符号:指当前 ELF 提供给外部使用的符号。比如:libc.so 中的 open
就是 libc.so 的导出符号。导入符号:指当前 ELF 需要使用的外部符号。比如:你自己的 libtest.so 如果用到了 open
,那么open
就会被定义为 libtest.so 的导入符号。
.symtab
,.strtab
和 .gnu_debugdata
中。.dynsym
中的偏移位置。.hash
:SYSV hash。其中包含了所有的动态链接符号。.gnu.hash
:GNU hash。只包含动态链接符号中的导出符号。
.hash
和 .gnu.hash
,也可能只包含其中一个。具体看 ELF 编译时的静态链接参数 -Wl,--hash-style
,可以设置为 sysv
或 gnu
或 both
。从 Android 6.0 开始,linker 支持了 .gnu.hash
的解析。.got.plt
:保存外部函数的绝对地址。这就是我们经常会听到的 “GOT 表”。.data
,.data.rel.ro
:保存外部数据(包括函数指针)的绝对地址。
.rel.plt
,.rela.plt
:用于关联.dynsym
和.got.plt
。这就是我们经常会听到的 “PLT 表”。.rel.dyn
,.rela.dyn
,.rel.dyn.aps2
,.rela.dyn.aps2
:用于关联.dynsym
和.data
,.data.rel.ro
。
.rel.dyn
和 .rela.dyn
数据,这是一种 sleb128 编码格式的数据,读取时需要特别的解码逻辑。四、Android PLT hook
4.1 PLT hook 基本原理
了解了 ELF 格式和 linker 的 relocation 过程之后,PLT hook 的过程就不言自明了。它做了和 relocation 类似的事情。即:通过符号名,先在 hash table 中找到对应的符号信息(在 .dynsym
中),再找到对应的 PLT 信息(在 .rel.plt
或 .rela.plt
或 .rel.dyn
或 .rela.dyn
或 .rel.dyn.aps2
或 .rela.dyn.aps2
中),最后找到绝对地址信息(在 .got.plt
或 .data
或 .data.rel.ro
中)。最后要做的就是修改这个绝对地址的值,改为我们需要的自己的“代理函数”的地址。
mprotect
设置当前地址位置所在内存页为“可写”的,因为 linker 在做完 relocation 后会把 .got.plt
和 .data.rel.ro
设置为只读的。修改完之后,需要用 __builtin___clear_cache
来清除该内存位置的 CPU cache,以使修改能立刻生效。4.2 xHook 的不足之处
native 崩溃兜底机制有缺陷,导致线上崩溃无法完全避免。 无法自动对新加载的 ELF 执行 hook。(需要外部反复调用 refresh
来“发现”新加载的 ELF。但是在什么时机调用refresh
呢?频率太高会影响性能,频率太低会导致 hook 不及时)由于依赖于链式调用的机制。如果一个调用点被多次 hook,在对某个 proxy 函数执行 unhook 后,链中后续的 proxy 函数就会丢失。 只使用了读 maps 的方式来遍历 ELF。在高版本 Android 系统和部分机型中兼容性不好,经常会发生 hook 不到的情况。 API 设计中使用了正则来指定 hook 哪些目标 ELF,运行效率不佳。 需要在真正执行 hook 前,注册完所有的 hook 点,一旦开始执行 hook(调用 refresh
后),不能再添加 hook 点。这种设计是很不友好的。无法适配 Android 8.0 引入 Linker Namespace 机制(同一个函数符号,在进程中可能存在多个实现)。
4.3 更完善的 Android PLT hook 方案
要有一套真正可靠的 native 崩溃兜底机制,来避免可控范围内的 native 崩溃。 可以随时 hook 和 unhook 单个、部分、全部的调用者 ELF。 当新的 ELF 被加载到内存后,它应该自动的被执行所有预定的 hook 操作。 多个使用方如果 hook 了同一个调用点,它们应该可以彼此独立的执行 unhook,相互不干扰。 为了适配 Android linker namespace,应该可以指定 hook 函数的被调用者 ELF。 能自动避免由于 hook 引起的意外的“递归调用”和“环形调用”。比如: open
的 proxy 函数中调用了read
,然后read
的 proxy 函数中又调用了open
。如果这两个 proxy 存在于两个独立的 SDK 中,此时形成的环形调用将很难在 SDK 开发阶段被发现。如果在更多的 SDK 之间形成了一个更大的 proxy 函数调用环,情况将会失去控制。proxy 函数中要能以正常的方式获取 backtrace(libunwind、libunwindstack、llvm libunwind、FP unwind 等)。有大量的业务场景是需要 hook 后在 proxy 函数中抓取和保存 backtrace,然后在特定的时机 dump 和聚合这些 backtrace,符号化后再将数据投递到服务端,从而监控和发现业务问题。 hook 管理机制本身带来的额外性能损耗要足够低。
五、字节 bhook 介绍
5.1 DL monitor
dlopen
和 android_dlopen_ext
完成的,通过 dlclose
则可以卸载 so 库。dlopen
系统库;从 8.0 开始引入了 linker namespace 机制,并且 libdl.so 不再是 linker 的虚拟入口,而成为了一个真实的 so 文件。对于 linker 来说,Android 7.0 和 8.0 是两个重要的版本。dlopen
系统库的限制,否则 hook dlopen
和 android_dlopen_ext
之后,在代理函数中是无法直接调用原始的 dlopen
和 android_dlopen_ext
函数的。dlopen
和 android_dlopen_ext
后不再调用原函数,而是通过调用 linker 和 libdl.so 内部函数的方式绕过了限制。主要用到了以下几个符号对应的内部函数:__dl__ZL10dlopen_extPKciPK17android_dlextinfoPv
__dl__Z9do_dlopenPKciPK17android_dlextinfoPv
__dl__Z23linker_get_error_bufferv
__dl__ZL23__bionic_format_dlerrorPKcS0_
Android 8.0+ libdl.so:
__loader_dlopen
__loader_android_dlopen_ext
5.2 trampoline
.got.plt
(和 .data
和 .data.rel.ro
)中的绝对地址就可以了。但是这种方式会导致“同一个 hook 点的多个 proxy 函数形成链式调用”(类似于 Linux 通过 sigaction
注册的 signal handler),如果其中一个 proxy 被 unhook 了,那么“链” 中后续的 proxy 也会丢失。xHook 就存在这个问题:mmap
和 mprotect
来创建 shellcode。按照术语惯例,我们把这里创建的跳转逻辑称为 trampoline(蹦床):六、native 崩溃兜底
在 DL monitor 初始化的过程中,对 dlclose
的 hook 尚未完成时,此时 linker 执行了dlclose
,恰恰 dlclose 了我们正在执行dlclose
hook 操作的 ELF。ELF 文件可能意外损坏,导致 linker 加载了格式不正确的 ELF。
int *p = NULL;
TRY(SIGSEGV, SIGBUS) {
*p = 1;
} CATCH() {
LOG("There was a problem, but it's okay.");
} EXIT
sigsetjmp
保存寄存器和 sigmask,当发生崩溃时,在信号处理函数中用 siglongjmp
跳转到 catch 块中并恢复 sigmask。ART sigchain 代理了 sigaction
,sigprocmask
等函数,我们需要用dlsym
在 libc.so 中找到原始的函数再调用它们。bionic 和 ART sigchain 在某些 AOSP 版本上存在 bug,所以我们需要优先使用 sigaction64
和sigprocmask64
,而不是sigaction
和sigprocmask
。在正确的地方用正确的方式设置 sigmask 很重要。 我们的 try-catch 机制运行于多线程环境中,所以需要以某种线程独立的方式来保存 sigjmp_buf
。考虑到性能和更多使用场景,整个机制需要无锁、无堆内存分配、无 TLS 操作、线程安全,异步信号安全。
.c
和 一个 .h
文件,并且没有任何外部依赖),这样做的好处是容易移植和复用。如果你想把这个模块用在自己的工程中,请注意以下几点:native 崩溃兜底属于“高危”操作,可能引起不确定的难以排查的问题。所以能不用尽量不要用。 纯业务类型的 native 库请不要使用 native 崩溃兜底。而是应该让崩溃暴露出来,然后修复问题。 try 块中的逻辑越少越好。比如兜底 sigsegv 和 sigbus 时,最好 try 块中只有一些内存地址的读操作和单个写操作,尽量不要调用外部函数(包括 malloc
,free
,new
,delete
等)。try 块中尽量不要使用 C++。某些 C++ 的语法封装,编译器会为它生成一些意外的逻辑(比如读写 C++ TLS 变量,编译器会生成 _emutls_get_address
调用,其中可能会调用malloc
)。在当前的设计中:try 块中请不要调用 return,否则会跳过 catch 或 exit 块中的回收逻辑,引起难以排查的问题。另外,在 try 块中不可以嵌套使用另一个“相同信号的 try”。