前言
Hook 本意是指钩子,它表明的便是在某个函数的上下文做自界说的处理来完结咱们想要的黑科技
大家可能比较了解 Java 层的一些 Hook 技能,比方反射,动态署理,或许 ASM 字节码插桩
在 Java 层之外,Android 体系还有很大一部分归于 Native 层,有时不可避免的需求用到 Native Hook 技能
本文首要介绍 Native Hook 是什么,以及怎么经过一个比方快速上手
Native Hook 是什么?
Native Hook 技能一般有以下两种完结办法
- PLT Hook:经过修正 GOT 表,将方针函数的地址指向自界说的 Hook 函数的地址,从而阻拦和修正方针函数的行为。
- Inline Hook:直接将函数开端处的指令替换为跳转指令,使得原函数直接跳转到 Hook 的方针函数函数
咱们下面来分别介绍一下
PLT Hook
PLT Hook 用一句描述便是:经过修正 GOT 表,将方针函数的地址指向自界说的 Hook 函数的地址,从而阻拦和修正方针函数的行为。那么 GOT 表是什么呢?这需求咱们对 SO 库文件的 ELF 文件格局和动态链接进程有所了解。
ELF(Executable and Linkable Format) 文件格局是一种可履行文件和可链接文件格局,它是现代Unix和Linux体系上最常见的二进制文件格局之一,so 库其实便是一个 ELF 文件
ELF 文件格局也比较复杂,咱们这儿首要关心.plt
与.got
两个表
- The Global Offset Table/大局偏移量表 (GOT)。简略来说便是在数据段的地址表,假定咱们有一些代码段的指令引用一些地址变量,编译器会引用 GOT 表来代替直接引用肯定地址,因为肯定地址在编译期是无法知道的,只要重定位后才会得到 ,GOT 自己本身将会包含函数引用的肯定地址。
- The Procedure Linkage Table/进程链接表 (PLT)。PLT 不同于 GOT,它坐落代码段,动态库的每一个外部函数都会在 PLT 中有一条记载,每一条 PLT 记载都是一小段可履行代码。一般来说,外部代码都是在调用 PLT 表里的记载,然后 PLT 的相应记载会负责调用实践的函数。咱们一般把这种设定叫作“蹦床”(Trampoline)。
简略来说,对于其它 so 中的函数,在编译期无法承认其地址,只要在运转时才能获取,因而需求查询 GOT 表来查询外部函数的肯定地址。外部库函数的肯定地址在 got 表中的初始值都是 0 ,只要当实践调用这个函数时,Linker 程序才会写入实践的地址。
因而如果咱们想要完结 native hook,只需求把 got 表中的方针函数的地址修正为咱们自界说的地址即可。
那么在这个进程中,PLT 表的效果又是什么呢?
实践上,在函数调用的进程中,会先跳转到 PLT 表,它坐落代码段,每一条 PLT 记载都是一小段可履行代码,这段代码会查询 GOT 表,获取真实地址然后跳转对应的函数
听起来有些多此一举,实践上 PLT 表能够起到推迟绑定的效果,只要当真正调用方针函数时,got 表中才会去绑定真实地址,如果没有调用则不绑定。因为许多函数可能在程序履行完时都不会被用到,那么一开端把一切函数都链接好实践是一种浪费。这便是 PLT 表起到的效果
因而在 So 中调用外部函数的实践进程如下所示:
在了解了 PLT HOOK 的底子原理之后,其实咱们能够自己解析 got 表然后替换为自界说的函数地址完结 hook,也能够运用一些现已比较老练的库
- github.com/bytedance/b…
- github.com/iqiyi/xHook
本文后边的实战都是运用 bhook 完结 hook的
Inline Hook
从上面的介绍咱们能够看出,PLT Hook 存在必定的局限性,它只能 hook 外部 so 的调用,但如果要 hook 当时的 so 呢?
Hook so 内部调用能够经过 Inline Hook 完结
Inline Hook 是经过在程序运转时动态修正内存中的汇编指令,来改变程序履行流程的一种 Hook 办法,它的底子原理是直接将函数开端处的指令替换为跳转指令,使得原函数直接跳转到 Hook 的方针函数函数,并保留被掩盖的原指令以完结后续再调用回来的目的。
Inline Hook 的底子流程如上所示,首要分为以下几步
- 拷贝原函数的头部两条汇编指令,并掩盖成跳转到自界说函数的指令
- 履行自界说函数,再履行前面被掩盖的两条指令
- 履行后续指令
与 PLT Hook 办法比较,Inline Hook 愈加强大,简直能够 Hook 任何函数,但因为其完结十分复杂,需求直接修正汇编指令,因而会有许多兼容性问题,不太安稳,因而如果想要运用的话引荐直接运用相应的开源库,比方字节开源的:github.com/bytedance/a…
小结
总得来说,两种 Native Hook 办法各有好坏,可依据实践情况运用
- PLT HooK的长处在于安稳,缺点则在于只能 Hook 外部函数的调用
- Inline Hook的长处在于能够 hook so 内部调用,缺点则在于不行安稳,存在必定的兼容问题
Native Hook 实战
接下来咱们经过 Native Hook 技能来完结对 Native 内存请求的监控,首要支撑以下功能
- 添加对 malloc, free 函数的 hook,支撑核算 so 的内存请求与释放情况
- 当请求超大内存时,支撑获取 native 仓库以定位问题
- 直接获取的 native 仓库是个 16 进制数组,无法看出有用信息,因而还需求将解析仓库复原出 so 名与函数信息
Hook 函数
咱们这儿经过 bhook 库来完结对 malloc, free 函数的 hook,如下所示
void *malloc_proxy(size_t len) {
BYTEHOOK_STACK_SCOPE();
Dl_info callerInfo = {};
if (dladdr(__builtin_return_address(0), &callerInfo)) {
// 核算分配的内存
onMalloc(callerInfo.dli_fname, len);
}
// ...
void *object = BYTEHOOK_CALL_PREV(malloc_proxy, len);
objMap[object] = len;
return object;
}
void free_proxy(void *__ptr) {
BYTEHOOK_STACK_SCOPE();
Dl_info callerInfo = {};
if (dladdr(__builtin_return_address(0), &callerInfo)) {
auto len = objMap.find(__ptr);
// 核算 free的内存
onFree(callerInfo.dli_fname, len->second);
}
return BYTEHOOK_CALL_PREV(free_proxy, __ptr);
}
void hookMemory() {
bytehook_hook_all(nullptr, "malloc", (void *) malloc_proxy,
nullptr,
nullptr);
bytehook_hook_all(nullptr, "free", (void *) free_proxy,
nullptr,
nullptr);
}
hook 的逻辑十分简略,经过调用bytehook_hook_all
,指定要hook
的办法与署理办法,一切的malloc
办法调用都会被署理到malloc_proxy
办法,一切的free
办法调用也会被署理到free_proxy
办法中
然后咱们在署理办法中加入 so 分配与收回内存的监控,就能够核算出一个 so 库一共请求了多少内存,释放了多少内存,打印出来的日志如下所示
So /apex/com.android.art/lib64/libart-compiler.so allocated 581632 bytes, freed 2883686 bytes
So /apex/com.android.art/lib64/libc++.so allocated 2779694 bytes, freed 2640 bytes
So /apex/com.android.art/lib64/liblzma.so allocated 9071256 bytes, freed 9071256 bytes
So /apex/com.android.i18n/lib64/libicuuc.so allocated 36428 bytes, freed 1164 bytes
So /apex/com.android.runtime/lib64/bionic/libc.so allocated 33360 bytes, freed 0 bytes
So /apex/com.android.vndk.v30/lib64/libc++.so allocated 10944 bytes, freed 0 bytes
So /data/app/~~6Mf4VQY4K16aFji1rZ4dkg==/com.zj.android.performance-XHR75TrkIVbXU9GfDYgZGg==/lib/arm64/libandroid-performance.so allocated 184549424 bytes, freed 184549424 bytes
So /data/app/~~6Mf4VQY4K16aFji1rZ4dkg==/com.zj.android.performance-XHR75TrkIVbXU9GfDYgZGg==/lib/arm64/libmemory-hook.so allocated 3310960 bytes, freed 2776978 bytes
So /system/lib64/libbinder.so allocated 129408 bytes, freed 462200 bytes
So /system/lib64/libc++.so allocated 14428072 bytes, freed 864 bytes
So /system/lib64/libhwui.so allocated 725687 bytes, freed 11980415 bytes
So /system/lib64/libutils.so allocated 1896281 bytes, freed 2132345 bytes
So /system/lib64/libz.so allocated 565024 bytes, freed 565024 bytes
获取 native 仓库
除了核算 So 内存运用情况之外,在请求超大内存时,咱们也能够获取 native 仓库以方便定位问题
现在,在 Android 中获取 Native 仓库的办法底子上都是经过 CFI 来完结的。CFI 代表 Call Frame Information,即帧调用信息。在程序运转时,当 Native 函数履行进入栈指令时,它会将对应指令的信息(即 CFI )写入 so 文件中的 .eh_frame 和 .eh_frame_hdr 段中,这两个段是 so 文件中的段之一。因而,要获取 Native 仓库,只需求读取这两个段中的数据即可。
在 Android 体系中,咱们能够运用 libunwind 库来直接获取 Native 仓库信息,其底层原理实践上也是经过读取 CFI 来完结的
#include <unwind.h> //引入 unwind 库
struct backtrace_stack {
void **current;
void **end;
};
static _Unwind_Reason_Code unwind_callback(struct _Unwind_Context *context, void *data) {
auto *state = (struct backtrace_stack *) (data);
uintptr_t pc = _Unwind_GetIP(context); // 获取 pc 值,即肯定地址
if (pc) {
if (state->current == state->end) {
return _URC_END_OF_STACK;
} else {
*state->current++ = (void *) (pc);
}
}
return _URC_NO_REASON;
}
static size_t fill_backtraces_buffer(void **buffer, int max) {
struct backtrace_stack stack = {buffer, buffer + max};
_Unwind_Backtrace(unwind_callback, &stack);
return stack.current - buffer;
}
void *malloc_proxy(size_t len) {
// ...
if (len > 80 * 1024 * 1024) {
// 当请求内存大小大于 80M 时获取仓库
int maxStackSize = 30;
void *buffer[maxStackSize];
int count = fill_backtraces_buffer(buffer, maxStackSize);
dumpBacktrace(buffer, count);
}
// ...
return object;
}
如上所示,当请求内存大小大于 80M 时,咱们经过 unwind 来获取仓库,将将获取的仓库放入 buffer 数组中
但是咱们获得的 buffer 数组仅仅 16 进制的地址,底子看不出有用信息,如下所示:
因而咱们还要进行下一步,复原仓库信息
复原仓库信息
要将16进制的地址仓库复原成带有用信息的仓库,一般需求完结以下三个过程:
- 首先要承认相关的 so 文件名称;
- 接下来需求核算相应的偏移地址;
- 最终,基于带符号表(ELF文件中的一张表,存放了函数、办法、变量等名称符号信息)的so文件,复原指针对应的函数名和行数。
承认 so 文件名称
咱们能够经过dladdr
函数来查询 so 文件名,函数界说如下
int dladdr ( void * addr , Dl_info * info ) ;
typedef struct {
const char *dli_fname; //地址对应的 so 名
void *dli_fbase; //对应so库的基地址
const char *dli_sname; //如果so库有符号表,这会显现离地址最近的函数名
void *dli_saddr; //符号表中,离地址最近的函数的地址
} Dl_info;
咱们将函数地址传入dladdr
函数,就能够获取相应的 so 库名称与基地址,如下所示:
void dumpBacktrace(void **buffer, size_t count) {
for (int i = 0; i < count; ++i) {
void *addr = buffer[i];
Dl_info info = {};
if (dladdr(addr, &info)) {
LOG("# %d : %p : %s(%s)(%p)", i, addr, info.dli_fname,
info.dli_sname, info.dli_saddr);
}
}
}
经过dladdr
函数获取的仓库打印如下所示:
# 0 : 0x767174309c : /data/app/~~6Mf4VQY4K16aFji1rZ4dkg==/com.zj.android.performance-XHR75TrkIVbXU9GfDYgZGg==/lib/arm64/libmemory-hook.so(0x2609c)((null))(0x0)
# 1 : 0x7671742ec8 : /data/app/~~6Mf4VQY4K16aFji1rZ4dkg==/com.zj.android.performance-XHR75TrkIVbXU9GfDYgZGg==/lib/arm64/libmemory-hook.so(0x25ec8)(_Z12malloc_proxym)(0x7671742dbc)
# 2 : 0x76712f23d8 : /data/app/~~6Mf4VQY4K16aFji1rZ4dkg==/com.zj.android.performance-XHR75TrkIVbXU9GfDYgZGg==/lib/arm64/libandroid-performance.so(0x203d8)(Java_com_zj_android_performance_jni_NativeLibTest_testMalloc)(0x76712f23a8)
# 3 : 0x7691222248 : /apex/com.android.art/lib64/libart.so(0x222248)((null))(0x0)
# 4 : 0x7691218968 : /apex/com.android.art/lib64/libart.so(0x218968)((null))(0x0)
# 5 : 0x7691285ff4 : /apex/com.android.art/lib64/libart.so(0x285ff4)(_ZN3art9ArtMethod6InvokeEPNS_6ThreadEPjjPNS_6JValueEPKc)(0x7691285f30)
# 6 : 0x76913ea3ec : /apex/com.android.art/lib64/libart.so(0x3ea3ec)(_ZN3art11interpreter34ArtInterpreterToCompiledCodeBridgeEPNS_6ThreadEPNS_9ArtMethodEPNS_11ShadowFrameEtPNS_6JValueE)(0x76913ea254)
# 7 : 0x76913e4f88 : /apex/com.android.art/lib64/libart.so(0x3e4f88)(_ZN3art11interpreter6DoCallILb0ELb0EEEbPNS_9ArtMethodEPNS_6ThreadERNS_11ShadowFrameEPKNS_11InstructionEtPNS_6JValueE)(0x76913e4c48)
# 8 : 0x769175fd10 : /apex/com.android.art/lib64/libart.so(0x75fd10)(MterpInvokeVirtual)(0x769175f878)
# 9 : 0x7691203818 : /apex/com.android.art/lib64/libart.so(0x203818)((null))(0x0)
# 10 : 0x769176b3f4 : /apex/com.android.art/lib64/libart.so(
# 11 : 0x7691203998 : /apex/com.android.art/lib64/libart.so(0x203998)((null))(0x0)
# 12 : 0x769176b3f4 : /apex/com.android.art/lib64/libart.so(
# 13 : 0x7691203998 : /apex/com.android.art/lib64/libart.so(0x203998)((null))(0x0)
# 14 : 0x76913dcd30 : /apex/com.android.art/lib64/libart.so(0x3dcd30)((null))(0x0)
malloc 92274688 byte success
能够看出,在有符号表的情况下,so 名与函数名都正确的打印出来了,而对于 libart,因为现已移除了符号表,则显现为 null ,地址也为 0
核算函数偏移地址
经过dladdr
函数,咱们现已获取了仓库的 so 名与函数名,那可不能够具体定位到到底是函数的哪一行呈现了问题吗?
咱们能够经过 NDK 的 addr2line 东西,依据函数偏移地址,获取地址对应的函数名、行号等信息
addr2line -C -f -e xxx.so 函数偏移地址
-C:将初级其他符号名解码为用户级其他姓名。
-e:指定需求转换地址的可履行文件名
-f:在显现文件名、行号信息的一起显现函数名。
咱们在仓库中获取的地址是函数的肯定地址,要获取偏移地址减去 so 的基地址就能够了
偏移地址 = 函数的肯定地址 – 库文件的基地址
void dumpBacktrace(void **buffer, size_t count) {
for (int i = 0; i < count; ++i) {
if (dladdr(addr, &info)) {
// 核算偏移地址
const uintptr_t address_relative = (uintptr_t) addr - (uintptr_t) info.dli_fbase;
LOG("# %d : %p : %s(%p)(%s)(%p)", i, addr, info.dli_fname, address_relative,
info.dli_sname, info.dli_saddr);
}
}
}
从上面的日志也能够看出,有问题的Java_com_zj_android_performance_jni_NativeLibTest_testMalloc
函数的偏移地址是0x203d8
复原函数名及行号
现在,咱们现已得知了函数的偏移地址,接下来就能够运用 addr2line 东西来获取行号了。在 Android 的 NDK 中现已提供了这个东西,坐落 /ndk/xxx/toolchains/aarch64-linux-android-4.9/prebuilt/darwin-x86_64/bin 目录中。如果您运用的是 M1 电脑,能够挑选 aarch64 目录。
arm-linux-androideabi-addr2line -C -f -e libandroid_performance.so 0x203d8
需求留意的是,这儿的 so 有必要是带符号表的,因而需求在编译产物中的 native_libs 目录去找(留意不是 stripped_native_libs 目录)
运转以上命令后,得到的结果如下
能够看出,现已定位到了具体的函数名与行号
总结
本文首要介绍了 Native Hook 是什么以及常见完结方案,一起经过一个监控 native 内存的比方进行了实践
在 Android 应用性能优化中,Native Hook 技能广泛应用于内存优化、启动优化等方面,如 bitmap hook、pthread hook、GC 抑制等。因而,如果您希望在相关范畴进行技能优化,把握 Native Hook 技能将是十分有必要的。
参考资料
本文首要是对Android 性能优化小册相关内容的学习实践,感兴趣的同学能够点击查看
源码
本文一切源码可见:github.com/RicardoJian…