Android dlopen 方法的使用限制及解决方案
近期在实现 hook Bitmap 的功能,而 hook 的第一步是拿到被 hook 方法的地址。获取方法地址的常规方法是使用 dlopen()
和 dlsym()
,但 Android 平台上对这两个方法的使用有限制,如何突破这个限制呢?
Android 对 dlsym 的限制
Android 7.0及以上版本对 dlopen()
和 dlsym()
函数的使用有限制。System.load()
和 System.loadLibrary()
是基于这两个函数实现的,所以也存在同样限制。
在华为 Nova 2 (Android 8.0) 上试验一下,代码如下:
1 | class LoadSoActivity : AppCompatActivity() { |
完整代码和更多测试见 LoadSoActivity。
测试中发现无论是调用 System.load()
还是 System.loadLibrary()
加载 libart.so
(系统的so库,非公开 API) 均会导致以下异常:
1 | E/AndroidRuntime: FATAL EXCEPTION: main |
异常信息很明确:第三方应该不应当尝试使用非公开的方法。当然,官方对此早有说明。
Starting in Android 7.0, the system prevents apps from dynamically linking against non-NDK libraries, which may cause your app to crash.
Android N will restrict which libraries your C/C++ code can link against at runtime.
官方的作出上述限制的理由是只允许应用访问公开的 API(无论是 Java 层还是 Native 层),不允许访问非公开的方法,以提升应用的稳定性。出发点是好的,但对于一些合理的使用场景,比如开发包/测试包中调用非公开方法,造成相当的不便。
解决方案
如何突破官方的限制呢?Nougat_dlfunctions 提供了一个不错的解决方案,可以完美避开官方限制。
Nougat_dlfunctions 是这样介绍自己的:
A hack for loading system libraries in Android Nougat where dlopen() has been disabled by some wise guys.
它的两个主要 API 如下。形式上跟 dlopen()
和 dlsysm()
完全一致,所以用起来非常简单。
1 | void *fake_dlopen(const char *libpath, int flags); |
fake_dlopen()
- 跟dlopen()
一样,用于 load and link a dynamic library or bundlefake_dlsym()
- 跟dlsym()
一样,用于 get address of a symbol
这里 演示了 dlsym()
的用法,主要代码如下:
1 | typedef int (*CACULATE_FUNC)(int, int); |
很容易使用 Nougat_dlfunctions 提供的 fake_dlsym()
函数替代 dlsym()
:
1 | int main(int argc, char* argv[]) |
原理分析
Nougat_dlfunctions 库的原理本身并不复杂,但由于涉及到 ELF 文件格式,其中的技术细节理解起来还是要花些时间。
要保证 fake_dlopen()
和 fake_dlsym()
函数可正常运行有一个前提:so 文件本身已经被加载到当前进程。所以更准确地说, Nougat_dlfunctions 库并不能主动加载系统库/非公开的库,而是从已加载的系统库/非公开库中找到感兴趣的方法地址。但即使这样,对很多应用场景已经够用了。比如,以 hook Bitmap 为例,可以利用这个库绕开 Android 系统限制拿到目标方法的地址。
再来看具体流程。
首先,fake_dlopen()
读取 /proc/self/maps
文件。这个文件记录了当前进程的内存布局。Understanding-linux-proc-id-maps 对此有简要的描述。
Each row in /proc/$PID/maps describes a region of contiguous virtual memory in a process or thread. Each row has the following fields:
1 | address perms offset dev inode pathname |
- address - 进程地址空间的起始地址
- permissions - 访问权限
- pathname - 如果该区域是从文件映射来的,pathname 为文件名
知道进程的内存布局后,不难从其中获取到指定路径的 so 文件的基址。基址保存在 load_addr
。
然后,Nougat_dlfunctions 调用 mmap()
对该 so 文件进行内存映射,映射结果为 elf
并对其进行解析。elf
数据结构类型是 Elf_Ehdr
。Elf_Ehdr
定义如下:
1 |
|
简单来说,一个 ELF 文件由一个 Header 及 多个 Section 组成。每个 Section 也有自己的 Section Header,其类型为 Elf32_Shdr
。Elf32_Shdr
定义如下:
1 | typedef struct { |
Section 有不同类型。更多细节可以参考ELF-64 Object File Format。这里重点关注这几种类型的 Section:
- 类型
SHT_DYNSYM
, 名字是.dynsym
- 这个 section 包含 dynamic linking tables - 类型
SHT_STRTAB
, 名字是.dynstr
- 这个 section 包含 string table - 类型
SHT_PROGBITS
- 这个 section 包含 information defined by the program
.dynsym
是符号表,表中的元素类型是 Elf32_Sym
。 Elf32_Sym
定义如下:
1 | typedef struct { |
Nougat_dlfunctions 处理 elf
的方式相当简单粗暴:找到关键信息后直接拷贝一份并保存下来。关键信息包括 .dynsym
,.dynstr
等等。具体可以对照着代码看:
1 | void *fake_dlopen(const char *libpath, int flags) |
ctx
的结构如下:
1 | struct ctx { |
最后,根据指定的方法名在 ctx->dynsym
(即之前保存的 dynamic linking table) 进行查找。代码如下:
1 | void *fake_dlsym(void *handle, const char *name) |
如果方法名跟 dynamic linking table 中的某一项匹配上了,对应的方法地址可以这样计算出来:
1 | ctx->load_addr + sym->st_value - ctx->bias |
看一个实际案例:hook Bitmap。假设我们感兴趣的方法名为 createBitmap
,
- 这个方法在
system/lib/libandroid_runtime.so
中 - 应用启动后 Android 系统会为当前进程加载
libandroid_runtime.so
proc/self/maps
中记录了当前进程的内存布局,其中包括libandroid_runtime.so
获取 createBitmap()
方法地址流程:
fake_dlopen()
- parse - 解析
proc/self/maps
, 得到libandroid_runtime.so
的基址load_addr
- mmap - 将
libandroid_runtime.so
mmap 映射成elf
- copy - 从
elf
拷贝.dynstr
和.dynsym
- parse - 解析
fake_dlsym()
- search - 在
.dynsym
查找createBitmap
字符串对应的项 - calculate - 如果第4步中查找成功,最后一步就是计算
createBitmap
方法的地址。计算规则:load_addr + sym->st_value - bias
- search - 在