如何获取 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 系统库有一点肮脏,那么还有没有别的方式?
于是趁着这次重构的机会,我对想到的四种方式分别进行了实验:

  1. 直接 dlopen object
  2. 调用 dl_iterate_phdr 获取
  3. 读取 /proc/self/maps
  4. hook linker

前提

模块使用 Zygisk 的 postAppSpecialize API 在 app 进程权限下运行:

1
2
3
4
5
6
7
8
void postAppSpecialize(const AppSpecializeArgs *args) override {
const char *process = env->GetStringUTFChars(args->nice_name, nullptr);
if (strcmp(process, TARGET_APP_ID) == 0) {
LOGD("app name: %s, pid: %d", process, getpid());
entrypoint();
}
env->ReleaseStringUTFChars(args->nice_name, process);
}

后续所有的操作均在 entrypoint() 中执行。

1. 直接 dlopen object

主要思路是直接通过 dlopen 获取 handle,然后再通过 dlinfo 获取其基址。
通常的 Linux 系统可以采用这种方式,但 Android 的 libdl 中没有包含 dlinfo,所以我们需要使用 xDL 中的 xdl_open()xdl_info() 来实现,使用方法和本家基本一致。

首先尝试 postAppSpecialize 后立即调用 xdl_open()

1
2
3
4
void entrypoint() {
void *il2cppHandle = xdl_open("libil2cpp.so", XDL_DEFAULT);
LOGD("get libil2cpp handle at: %lX", (size_t) il2cppHandle);
}

但会发现获取到的指针为 0,原因不明。即便把 so 改成绝对路径也无法 open。
根据 postAppSpecialize 的文档,在这个阶段 app 已经在 sandbox 中运行,拥有与 app 相同的权限,所以应该不会是权限问题。有可能是 app 还需要进行一些初始化操作后才能正常链接。

postAppSpecialize 后立即调用这条路走不通,那么等待一段固定的时间后再尝试呢?
由于 postAppSpecialize 是在进程的主线程中执行的,在这里等待会阻塞 app 启动,所以需要开一个新线程:

1
2
3
4
static std::thread _hookThread;
void entrypoint() {
_hookThread = std::thread(hookThreadEntryPoint);
}

注意这里把 thread 变量放在了全局,是因为如果将其放在 entrypoint() 函数内,函数在启动新线程后会立即返回,栈上的 thread 会被自动销毁。根据 std::thread::~thread 文档如果 thread class 在被销毁时线程还在执行的话会自动调用 std::terminate() 强行终止进程,所以需要将其放在走出 scope 后也不会被销毁的地方,这里选择将其放在 ELF 的 .bss section 中。

然后在新线程中 sleep 10 秒后执行 dlopen,再通过 handle 获取 info。

1
2
3
4
5
6
7
8
9
static void hookThreadEntryPoint() {
std::this_thread::sleep_for(std::chrono::seconds(10));
void *il2cppHandle = xdl_open("libil2cpp.so", XDL_DEFAULT);
LOGD("get libil2cpp handle at: %lX", (size_t) il2cppHandle);

auto info = std::make_unique<xdl_info_t>();
xdl_info(il2cppHandle, XDL_DI_DLINFO, info.get());
LOGD("get libil2cpp base at: %lX", (size_t) info->dli_fbase);
}

10 秒其实已经足够使 il2cpp 进行自然地装载了,所以这里地 dlopen 其实不会真正地将 libil2cpp.so 读取到内存中,而是重用游戏已经读取到内存中的地址并返回新的 handle。

看看执行的结果:

1
2
D  get libil2cpp handle at: B400007834F5A810
D get libil2cpp base at: 75C41BA000

成功获取到了 base address。

2. 调用 dl_iterate_phdr 获取

思路和 dlopen 差不多,等待一段时间 libil2cpp.so 被装载后调用 dl_iterate_phdr 对被 app 读取的所有 so 库进行遍历,找到 libil2cpp.so 后返回其基址。同样因为 xDL 库拥有更便捷的 xdl_iterate_phdr,这里使用它进行操作:

1
2
3
4
5
6
7
8
9
10
11
12
static void hookThreadEntryPoint() {
std::this_thread::sleep_for(std::chrono::seconds(10));
xdl_iterate_phdr(callBack, nullptr, XDL_DEFAULT);
}

static int callBack(dl_phdr_info *info, size_t size, void *data) {
if (std::string(info->dlpi_name).ends_with("libil2cpp.so")) {
LOGD("get libil2cpp.so at %lX", (size_t) info->dlpi_addr);
return 1;
}
return 0;
}

执行结果:

1
D  get libil2cpp at: 76D14AC000

3. 读取 /proc/self/maps

简单粗暴的方式,遍历自身内存 map 寻找 libil2cpp.so:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static void hookThreadEntryPoint() {
std::ifstream ifstream("/proc/self/maps");
if (!ifstream.is_open()) {
LOGE("cannot open /proc/self/maps");
return;
}
auto _line = new char[512];
for (; ifstream.getline(_line, 512);) {
std::string line = std::string(_line);
if (!(line.contains("libil2cpp.so") && line.contains("r-xp"))) {
continue;
}
std::string addr = line.substr(0, line.find('-'));
LOGD("get libil2cpp.so at %s", addr.c_str());
break;
}
delete[] _line;
ifstream.close();
}

执行结果:

1
D  get libil2cpp.so at 73096a7000

4. hook linker

传统方式,通过在 linker 相关函数处插入检测点,当检测到链接的动态库是 libil2cpp.so 时保存一份 handle 的副本供后续使用。

这里描述一下该如何找到这个相关函数。

首先读取 linker64 的 symbol 表可以找到如下函数:

1
2
3
4
5
6
$ readelf -s linker64 | grep dlopen
9: 0000000000077af0 4 FUNC GLOBAL DEFAULT 10 __loader_android_dlopen_e
22: 0000000000078150 12 FUNC GLOBAL DEFAULT 10 __loader_dlopen
6639: 0000000000065340 240 FUNC LOCAL DEFAULT 10 __dl__ZL10dlopen_extPKciP
6948: 0000000000065430 3132 FUNC LOCAL HIDDEN 10 __dl__Z9do_dlopenPKciPK17
11972: 0000000000078150 12 FUNC GLOBAL DEFAULT 10 __dl___loader_dlopen

这里有 5 个看上去都有关联的函数,对外可见性一部分是 global 一部分是 hidden,我们应该选取哪一个作为目标呢?

根据 AOSP bionic 文档的描述,dlopen 函数是被定义在
libdl.so 中的,但这个库只提供了函数存根,真正的实现是在 linker64 中。整个跟踪路线是这个样子的:

libdl.cpp
1
2
3
4
5
__attribute__((__weak__))
void* dlopen(const char* filename, int flag) {
const void* caller_addr = __builtin_return_address(0);
return __loader_dlopen(filename, flag, caller_addr);
}

source: https://cs.android.com/android/platform/superproject/main/+/main:bionic/libdl/libdl.cpp;l=86

在 libdl.so 中定义的 weak global symbol,将工作转移给 __loader_dlopen 函数。

dlfcn.cpp
1
2
3
void* __loader_dlopen(const char* filename, int flags, const void* caller_addr) {
return dlopen_ext(filename, flags, nullptr, 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

dlfcn.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
static void* dlopen_ext(const char* filename,
int flags,
const android_dlextinfo* extinfo,
const void* caller_addr) {
ScopedPthreadMutexLocker locker(&g_dl_mutex);
g_linker_logger.ResetState();
void* result = do_dlopen(filename, flags, extinfo, caller_addr);
if (result == nullptr) {
__bionic_format_dlerror("dlopen failed", linker_get_error_buffer());
return nullptr;
}
return result;
}

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

linker.cpp
1
2
3
4
5
6
7
8
9
10
void* do_dlopen(const char* name, int flags,
const android_dlextinfo* extinfo,
const void* caller_addr) {
std::string trace_prefix = std::string("dlopen: ") + (name == nullptr ? "(nullptr)" : name);
ScopedTrace trace(trace_prefix.c_str());
ScopedTrace loading_trace((trace_prefix + " - loading and linking").c_str());
soinfo* const caller = find_containing_library(caller_addr);
android_namespace_t* ns = get_caller_namespace(caller);
...
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static void *(*do_dlopen_orig)(const char *, int, const void *, const void *) = nullptr;
void *do_dlopen(const char *name, int flags, const void *extinfo, const void *caller_addr) {
void *handle = do_dlopen_orig(name, flags, extinfo, caller_addr);
if (std::string(name).ends_with("libil2cpp.so")) {
void *xhandle = xdl_open("libil2cpp.so", XDL_DEFAULT);
auto info = std::make_unique<xdl_info_t>();
xdl_info(xhandle, XDL_DI_DLINFO, info.get());
LOGD("get libil2cpp base at: %lX", (size_t) info->dli_fbase);
xdl_close(xhandle);
}
return handle;
}
static void hookLinker() {
int suc = shadowhook_init(SHADOWHOOK_MODE_SHARED, false);
if (suc != 0) {
LOGE("failed to initialize shadowhook, err code: %d", suc);
LOGE("%s", shadowhook_to_errmsg(suc));
return;
}
void *stub = shadowhook_hook_sym_name(
"linker64",
"__dl__Z9do_dlopenPKciPK17android_dlextinfoPKv",
(void *) do_dlopen,
(void **) &do_dlopen_orig
);
}

这里使用了 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

 Comments
Comment plugin failed to load
Loading comment plugin