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
2
3
4
5
6
7
8
9
10
11
12
13
14
15class LoadSoActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_load_so)
buttonLoadSo.setOnClickListener {
System.load("/system/lib/libart.so")
}
buttonLoadSo2.setOnClickListener {
System.loadLibrary("art")
}
}
}
完整代码和更多测试见 LoadSoActivity。
测试中发现无论是调用 System.load()
还是 System.loadLibrary()
加载 libart.so
(系统的so库,非公开 API) 均会导致以下异常:1
2
3
4
5
6E/AndroidRuntime: FATAL EXCEPTION: main
Process: com.sunmoonblog.cmdemo, PID: 8432
java.lang.UnsatisfiedLinkError: dlopen failed: library "/system/lib/libart.so" needed or
dlopened by "/system/lib64/libnativeloader.so" is not accessible for the namespace "classloader-namespace"
at java.lang.Runtime.load0(Runtime.java:928)
at java.lang.System.load(System.java:1624)
异常信息很明确:第三方应该不应当尝试使用非公开的方法。当然,官方对此早有说明。
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
2void *fake_dlopen(const char *libpath, int flags);
void *fake_dlsym(void *handle, const char *name);
fake_dlopen()
- 跟dlopen()
一样,用于 load and link a dynamic library or bundlefake_dlsym()
- 跟dlsym()
一样,用于 get address of a symbol
这里 演示了 dlsym()
的用法,主要代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13typedef int (*CACULATE_FUNC)(int, int);
int main(int argc, char* argv[])
{
void* handle = dlopen("libop.so", RTLD_NOW);
void* sym_add = dlsym(handle, "add");
CACULATE_FUNC add_func = sym_add;
printf("%d + %d = %d\n", 1, 11, add_func(11, 1));
return 0;
}
很容易使用 Nougat_dlfunctions 提供的 fake_dlsym()
函数替代 dlsym()
:1
2
3
4
5
6
7
8
9
10
11int main(int argc, char* argv[])
{
void* handle = fake_dlopen("libop.so", RTLD_NOW);
void* sym_add = fake_dlsym(handle, "add");
CACULATE_FUNC add_func = sym_add;
printf("%d + %d = %d\n", 1, 11, add_func(11, 1));
return 0;
}
原理分析
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct {
uint8_t e_ident[ELF_NIDENT];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry;
Elf32_Off e_phoff;
Elf32_Off e_shoff; /* Section header offset */
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize; /* Size of section header entry */
Elf32_Half e_shnum; /* Number of section header entries */
Elf32_Half e_shstrndx;
} Elf32_Ehdr;
简单来说,一个 ELF 文件由一个 Header 及 多个 Section 组成。每个 Section 也有自己的 Section Header,其类型为 Elf32_Shdr
。Elf32_Shdr
定义如下:1
2
3
4
5
6
7
8
9
10
11
12typedef struct {
Elf32_Word sh_name;
Elf32_Word sh_type;
Elf32_Word sh_flags;
Elf32_Addr sh_addr;
Elf32_Off sh_offset; /* Offset in file */
Elf32_Word sh_size; /* Size of section */
Elf32_Word sh_link;
Elf32_Word sh_info;
Elf32_Word sh_addralign;
Elf32_Word sh_entsize;
} Elf32_Shdr;
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
2
3
4
5
6
7
8typedef struct {
Elf32_Word st_name;
Elf32_Addr st_value;
Elf32_Word st_size;
uint8_t st_info;
uint8_t st_other;
Elf32_Half st_shndx;
} Elf32_Sym;
Nougat_dlfunctions 处理 elf
的方式相当简单粗暴:找到关键信息后直接拷贝一份并保存下来。关键信息包括 .dynsym
,.dynstr
等等。具体可以对照着代码看: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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50void *fake_dlopen(const char *libpath, int flags)
{
FILE *maps;
char buff[256];
struct ctx *ctx = 0;
off_t load_addr, size;
int k, fd = -1, found = 0;
void *shoff;
Elf_Ehdr *elf = MAP_FAILED;
...
ctx = (struct ctx *) calloc(1, sizeof(struct ctx));
if(!ctx) fatal("no memory for %s", libpath);
ctx->load_addr = (void *) load_addr;
shoff = ((void *) elf) + elf->e_shoff;
for(k = 0; k < elf->e_shnum; k++, shoff += elf->e_shentsize) {
Elf_Shdr *sh = (Elf_Shdr *) shoff;
log_dbg("%s: k=%d shdr=%p type=%x", __func__, k, sh, sh->sh_type);
switch(sh->sh_type) {
case SHT_DYNSYM:
if(ctx->dynsym) fatal("%s: duplicate DYNSYM sections", libpath); /* .dynsym */
ctx->dynsym = malloc(sh->sh_size);
if(!ctx->dynsym) fatal("%s: no memory for .dynsym", libpath);
memcpy(ctx->dynsym, ((void *) elf) + sh->sh_offset, sh->sh_size);
ctx->nsyms = (sh->sh_size/sizeof(Elf_Sym)) ;
break;
case SHT_STRTAB:
if(ctx->dynstr) break; /* .dynstr is guaranteed to be the first STRTAB */
ctx->dynstr = malloc(sh->sh_size);
if(!ctx->dynstr) fatal("%s: no memory for .dynstr", libpath);
memcpy(ctx->dynstr, ((void *) elf) + sh->sh_offset, sh->sh_size);
break;
case SHT_PROGBITS:
if(!ctx->dynstr || !ctx->dynsym) break;
/* won't even bother checking against the section name */
ctx->bias = (off_t) sh->sh_addr - (off_t) sh->sh_offset;
k = elf->e_shnum; /* exit for */
break;
}
}
...
return 0;
}
ctx
的结构如下:1
2
3
4
5
6
7struct ctx {
void *load_addr;
void *dynstr; // 保存了 string table
void *dynsym; // 保存了 dynamic linking table
int nsyms; // 保存了 dynamic linking table 的条目大小
off_t bias; // ? 不清楚
};
最后,根据指定的方法名在 ctx->dynsym
(即之前保存的 dynamic linking table) 进行查找。代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17void *fake_dlsym(void *handle, const char *name)
{
int k;
struct ctx *ctx = (struct ctx *) handle;
Elf_Sym *sym = (Elf_Sym *) ctx->dynsym;
char *strings = (char *) ctx->dynstr;
for(k = 0; k < ctx->nsyms; k++, sym++)
if(strcmp(strings + sym->st_name, name) == 0) {
/* NB: sym->st_value is an offset into the section for relocatables,
but a VMA for shared libs or exe files, so we have to subtract the bias */
void *ret = ctx->load_addr + sym->st_value - ctx->bias;
log_info("%s found at %p", name, ret);
return ret;
}
return 0;
}
如果方法名跟 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 - 在