如何获取 Android 应用内存中已加载 ELF 的基址
最近在 refactor 一个使用了多年已经年久失修的 Android il2cpp hook 模块。虽然并不是不能继续使用下去,但其构造和代码内容已经是一团混乱,实在不符合现代的 coding aesthetics,于是打算从零开始重写,所以比起 refactor 其实更像是 re-create。
既然要 hook native 动态链接库,那么其中关键的一步就是获取 ELF 在内存中的基址,获取到基址之后加上目标 symbol 在 ELF 内部的偏移量便可计算出目标函数的地址进行 hook。当然有些 hook library 提供了不需要函数地址的便捷方式,能够直接通过 library name 和 symbol name 进行 hook,然而对于 libil2cpp.so 的 symbol 来说除了其自身的 API 以外,游戏中定义的 symbol 全部被清除掉只剩下 C# 层的 symbol 被放在 metadata 中了,所以如果想直接 hook 游戏函数无法通过这种方式实现。但因为 il2cpp 自身 API 的 symbol 是暴露出来的,所以也可以 dlsym 其 API 间接地找到游戏函数,不过本文不对此做讨论。
那么如何才能获取到 ELF 的地址?旧模块采用的是 hook linker 的方式获取 object handle。这是一种可行的实现,但总觉得 hook 系统库有一点肮脏,那么还有没有别的方式?
于是趁着这次重构的机会,我对想到的四种方式分别进行了实验:
- 直接
dlopen
object - 调用
dl_iterate_phdr
获取 - 读取
/proc/self/maps
- hook linker
前提
模块使用 Zygisk 的 postAppSpecialize
API 在 app 进程权限下运行:
1 | void postAppSpecialize(const AppSpecializeArgs *args) override { |
后续所有的操作均在 entrypoint()
中执行。
1. 直接 dlopen
object
主要思路是直接通过 dlopen 获取 handle,然后再通过 dlinfo 获取其基址。
通常的 Linux 系统可以采用这种方式,但 Android 的 libdl 中没有包含 dlinfo
,所以我们需要使用 xDL 中的 xdl_open()
和 xdl_info()
来实现,使用方法和本家基本一致。
首先尝试 postAppSpecialize
后立即调用 xdl_open()
:
1 | void entrypoint() { |
但会发现获取到的指针为 0,原因不明。即便把 so 改成绝对路径也无法 open。
根据 postAppSpecialize
的文档,在这个阶段 app 已经在 sandbox 中运行,拥有与 app 相同的权限,所以应该不会是权限问题。有可能是 app 还需要进行一些初始化操作后才能正常链接。
postAppSpecialize
后立即调用这条路走不通,那么等待一段固定的时间后再尝试呢?
由于 postAppSpecialize
是在进程的主线程中执行的,在这里等待会阻塞 app 启动,所以需要开一个新线程:
1 | static std::thread _hookThread; |
注意这里把 thread
变量放在了全局,是因为如果将其放在 entrypoint()
函数内,函数在启动新线程后会立即返回,栈上的 thread 会被自动销毁。根据 std::thread::~thread 文档如果 thread class 在被销毁时线程还在执行的话会自动调用 std::terminate()
强行终止进程,所以需要将其放在走出 scope 后也不会被销毁的地方,这里选择将其放在 ELF 的 .bss section 中。
然后在新线程中 sleep 10 秒后执行 dlopen,再通过 handle 获取 info。
1 | static void hookThreadEntryPoint() { |
10 秒其实已经足够使 il2cpp 进行自然地装载了,所以这里地 dlopen 其实不会真正地将 libil2cpp.so 读取到内存中,而是重用游戏已经读取到内存中的地址并返回新的 handle。
看看执行的结果:
1 | D get libil2cpp handle at: B400007834F5A810 |
成功获取到了 base address。
2. 调用 dl_iterate_phdr
获取
思路和 dlopen
差不多,等待一段时间 libil2cpp.so 被装载后调用 dl_iterate_phdr 对被 app 读取的所有 so 库进行遍历,找到 libil2cpp.so 后返回其基址。同样因为 xDL 库拥有更便捷的 xdl_iterate_phdr
,这里使用它进行操作:
1 | static void hookThreadEntryPoint() { |
执行结果:
1 | D get libil2cpp at: 76D14AC000 |
3. 读取 /proc/self/maps
简单粗暴的方式,遍历自身内存 map 寻找 libil2cpp.so:
1 | static void hookThreadEntryPoint() { |
执行结果:
1 | D get libil2cpp.so at 73096a7000 |
4. hook linker
传统方式,通过在 linker 相关函数处插入检测点,当检测到链接的动态库是 libil2cpp.so 时保存一份 handle 的副本供后续使用。
这里描述一下该如何找到这个相关函数。
首先读取 linker64
的 symbol 表可以找到如下函数:
1 | $ readelf -s linker64 | grep dlopen |
这里有 5 个看上去都有关联的函数,对外可见性一部分是 global 一部分是 hidden,我们应该选取哪一个作为目标呢?
根据 AOSP bionic 文档的描述,dlopen
函数是被定义在
libdl.so 中的,但这个库只提供了函数存根,真正的实现是在 linker64 中。整个跟踪路线是这个样子的:
1 | __attribute__((__weak__)) |
source: https://cs.android.com/android/platform/superproject/main/+/main:bionic/libdl/libdl.cpp;l=86
在 libdl.so 中定义的 weak global symbol,将工作转移给 __loader_dlopen 函数。
1 | void* __loader_dlopen(const char* filename, int flags, const void* caller_addr) { |
source: https://cs.android.com/android/platform/superproject/main/+/main:bionic/linker/dlfcn.cpp;l=158
linker64 中定义的函数,其声明中包含 __attribute__((visibility("default")))
。这里就是前面 linker64 的 symbol 表中对应的函数,可以看到它也只做了转发,下一跳是 dlopen_ext
。
1 | static void* dlopen_ext(const char* filename, |
source: https://cs.android.com/android/platform/superproject/main/+/main:bionic/linker/dlfcn.cpp;l=137
dlopen_ext
函数前面修饰了 static
,所以其 Bind 是 LOCAL;没有带 visibility 修饰符,所以 Vis 是 DEFAULT。这与前面 linker64 的 symbol 表的 __dl__ZL10dlopen_extPKciP
所对应。
从源码可以看出这个函数主要也只是在做转发,下一跳是 do_dlopen
。
1 | void* do_dlopen(const char* name, int flags, |
source: https://cs.android.com/android/platform/superproject/main/+/main:bionic/linker/linker.cpp;l=2116
这便是真正的实现函数。由于代码太长这里只剪了一小块。
该函数对应的是前面 symbol 表中的 __dl__Z9do_dlopenPKciPK17
,所以我们应该 hook 的目标就是这个函数。
顺带一提,Android 的 linker(bionic linker) 与一般 Linux 中的 linker 最大的不同之处之一是不支持 RPATH,所以在写 Zygisk 模块时如果用到了别的动态链接库,必须要将其放入
/system/lib64
中,而不是在 so 的 Program Header 中写入 RUNPATH。
找到了目标函数就很简单了:
1 | static void *(*do_dlopen_orig)(const char *, int, const void *, const void *) = nullptr; |
这里使用了 ShadowHook 作为 hook library。
大致上做的事情是,在 linker64 的 do_dlopen
上插个点,如果 open 的是 libil2cpp.so
的话立即调用 xdl_open
新获取一个 handle,再通过 xdl_info
获取基址。
可能有些读者会想到,这里为什么要新 xdl_open
一次?直接用 do_dlopen
返回的 handle 不行吗?答案是前面也提到了,因为 Android bionic linker 没有实现 dlinfo,所以无法通过 handle 获取基址,所以这里需要借助 xDL 库的 xdl_info
,而本家和 xDL 的 handle 是不通用的,所以得新取一个。
执行结果如下:
1 | D get libil2cpp base at: 6DC6BC0000 |
总结
从上面可以看出这些方法都可以获得 ELF 的基址,对比一下这四种方法。
方法 | 能获取 handle | 需要手动等待 | 说明 |
---|---|---|---|
dlopen | yes | yes | 简单粗暴 |
dl_iterate_phdr | no | yes | 正攻法 |
读取 maps | no | yes | 字符串匹配效率低 |
hook linker | yes | no | 略复杂 |
这样一比较起来,看来还是传统的 hook linker 的方式更占优势,因为既能够获取到 handle,也不需要手动等待一段不明时间,而是在目标库被链接之后立即就能触发额外逻辑。
Refereces
- dlopen(3): https://man7.org/linux/man-pages/man3/dlopen.3.html
- dlinfo(3): https://man7.org/linux/man-pages/man3/dlinfo.3.html
- dl_iterate_phdr(3): https://man7.org/linux/man-pages/man3/dl_iterate_phdr.3.html
- AOSP code search: https://cs.android.com
- Cpp Reference: https://en.cppreference.com
- xDL: https://github.com/hexhacking/xDL
- ShadowHook: https://github.com/bytedance/android-inline-hook