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
15
class 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
6
E/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
2
void *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 bundle
  • fake_dlsym() - 跟 dlsym() 一样,用于 get address of a symbol

这里 演示了 dlsym() 的用法,主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef 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
11
int 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
2
address           perms offset  dev   inode   pathname
08048000-08056000 r-xp 00000000 03:0c 64593 /usr/sbin/gpm
  • address - 进程地址空间的起始地址
  • permissions - 访问权限
  • pathname - 如果该区域是从文件映射来的,pathname 为文件名

知道进程的内存布局后,不难从其中获取到指定路径的 so 文件的基址。基址保存在 load_addr

然后,Nougat_dlfunctions 调用 mmap() 对该 so 文件进行内存映射,映射结果为 elf 并对其进行解析。elf 数据结构类型是 Elf_EhdrElf_Ehdr 定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# define ELF_NIDENT	16

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_ShdrElf32_Shdr 定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
typedef 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_SymElf32_Sym 定义如下:

1
2
3
4
5
6
7
8
typedef 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
50
void *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
7
struct 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
17
void *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
  • fake_dlsym()
    • search - 在 .dynsym 查找 createBitmap 字符串对应的项
    • calculate - 如果第4步中查找成功,最后一步就是计算 createBitmap 方法的地址。计算规则: load_addr + sym->st_value - bias

参考