建议学习过ELF文件结构的再来读这份代码,这里推荐读完《程序员的自我修养——链接、装载与库》,以及《ARM汇编与逆向工程 蓝狐卷 基础知识》的第二章知识,然后再来看,这一系列文章。
从这篇文章开始,基于aosp8.1加载SO源码的部分研究,打算将分为3篇写完7个加载步骤,流程十分长,学完对ELF的认识就不会只停留在书籍上了,对研究ELF文件格式、so加固将会有所帮助。
还有一篇番外篇,关于自定义Linker的现实,以及结合开源的so脱壳脚本分析。
直接从find_libraries讲起,一共分为7部分
第一次调用该方法的加载目标so时候,我后面都称为目标so,传递的参数,如下。
代码如下:
find_libraries(ns,needed_by,&name,1,&si,nullptr,0,rtld_flags,extinfo,false,true,readers_map)
so的加载都会被封装成一个LoadTask,load_tasks是本次加载任务链表,是一个链表结构,library_names_count是为1,所在在load_tasks当前只有目标so,下面的代码是为soinfo = nullptr,参数传递的是nullptr。进行soinfo空间分配。
代码如下:
// Step 0: prepare.
/**
* 步骤 0
*/
LoadTaskList load_tasks;
/**
* 创建一个加载任务LoadTask
*/
for (size_t i = 0; i < library_names_count; ++i) {
const char* name = library_names[i];
load_tasks.push_back(LoadTask::create(name, start_with, ns, &readers_map));
}
// If soinfos array is null allocate one on stack.
// The array is needed in case of failure; for example
// when library_names[] = {libone.so, libtwo.so} and libone.so
// is loaded correctly but libtwo.so failed for some reason.
// In this case libone.so should be unloaded on return.
// See also implementation of failure_guard below.
// 分配soinfo数组
std::unique_ptr<soinfo[]> soinfos;
if (soinfos_array == nullptr) {
soinfos = std::make_unique<soinfo*[]>(library_names_count);
soinfos_array = soinfos.get();
}
// Create a LoadTask for each library and add it to the load_tasks list.
for (size_t i = 0; i < library_names_count; ++i) {
const char* name = library_names[i];
load_tasks.push_back(LoadTask::create(name, start_with, ns, &readers_map));
}
// Record the start time before loading any library.
uint64_t start_time = get_microtime();
// Try to load all libraries.
soinfo* si = nullptr;
for (auto&& task : load_tasks) {
if (!task->load()) {
return false;
}
}
我们关注主分支流程open_library方法。
strchr(name, '/') != nullptr根据注释这里说明如果路径包含斜杠/,则去尝试加载,这里我们不分析。然后往下这里分为三部分。
- 第一部分是
ns->get_ld_library_paths(),获取的是LD_LIBRARY_PATH,尝试到这里去加载so
- 第二部分是
ns->get_default_library_paths(),获取的是默认路径(如 /system/lib /apex/...)
- 第三部分是
g_default_namespace.get_default_library_paths(),获取的是系统默认 namespace 的路径(灰名单机制),这个就是防止app种直接dlopen系统库的限制,但是还是开放了一部分的系统运行用户加载,我那时研究的加载libart.so,这个路径我在第一份实习公司的时候涉及修改研究这部分内容
这里有一篇文章写得非常好,https://www.52pojie.cn/thread-948942-1-1.html,关于Android7.0以上命名空间详解(dlopen限制)。
代码如下:
/**
* open_library
*/
static int open_library(android_namespace_t* ns,
ZipArchiveCache* zip_archive_cache,
const char* name, soinfo *needed_by,
off64_t* file_offset, std::string* realpath) {
TRACE("[ opening %s at namespace %s]", name, ns->get_name());
// If the name contains a slash, we should attempt to open it directly and not search the paths.
if (strchr(name, '/') != nullptr) {
int fd = -1;
*file_offset = 0;
if (!realpath_fd(fd, realpath)) {
PRINT("warning: unable to get realpath for the library \"%s\". Will use given path.", buf);
*realpath = buf;
}
}
if (fd != -1) {
return fd;
}
return -1;
}
到此,我们继续回到load_library方法,load_library方法代码片段如下:
/**
* 赋值fd,以及file_offset
*/
task->set_fd(fd, true);
task->set_file_offset(file_offset);
然后设置task的fd。以及文件偏移。结合AI分析:
- 场景 1:SO 被打包在其他文件中(如静态库、镜像文件、加壳后的壳 SO):比如加壳 SO 中,原始 SO(Payload)的 ELF 数据被加密后存储在壳 SO 的数据段,有固定的文件偏移;
- 场景 2:共享库的 “片段加载”:极少数情况下,SO 可能被分割成多个片段存储,加载时需指定片段在文件中的起始偏移;file_offset不是所有要加载的 SO 都是 “独立的 .so 文件”—— 存在以下场景,SO 数据并非从文件起始位置开始。
那这部分对于普通的so应该就是0。接着load_library方法开始调用load_library的第二个重载。
首先是soinfo_alloc,分配soinfo,task->set_soinfo(si);
然后核心是,task调用read方法,这里说结论,read方法会去读取的内容如下:
- ELF文件头,验证文件头,对应代码ReadElfHeader(),VerifyElfHeader()
- 程序头表,对应代码ReadProgramHeaders()
- 节头表,对应代码ReadSectionHeaders()
- dynamic节头表,对应代码ReadDynamicSection()
/**
* 2
*/
static bool load_library(android_namespace_t* ns,
LoadTask* task,
const android_dlextinfo* extinfo,
soinfo** out_soinfo,
const char* realpath,
bool search_linked_namespaces) {
if (!task->load()) {
return false;
}
}
开始分析task的load方法。
这里get_elf_reader获取了,前面步骤使用的ElfReader,然后开始调用ElfReader的Load方法,该方法的实现位于linker_phdr.cpp,上一节文章中,如何进行加载so,映射了程序头表、节头表、dynamic section 到内存地址中,从这里开始,加载程序头表的load类型。这里有三个核心方法ReserveAddressSpace和LoadSegments以及FindPhdr。
代码如下:
/**
* load
*/
bool load() {
ElfReader& elf_reader = get_elf_reader();
/**
* 核心是处理 ELF 文件的 Program Header (PT_LOAD),把它们 mmap 进内存。
* 打开 so 文件(或使用 extinfo 中的 fd)
* 读取 ELF header
* 读取 Program Header Table
* 找到所有 PT_LOAD 段
* 使用 mmap() 将 PT_LOAD 段映射到内存
* 修正对齐、权限(PROT_READ/WRITE/EXEC)
* 找到加载后的 phdr 在内存中的实际地址
*/
if (!elf_reader.Load(extinfo_)) {
return false;
}
/**
* 根据装载结果,更新 soinfo 的成员变量
*/
si_->base = elf_reader.load_start();//so 被加载的位置
si_->size = elf_reader.load_size();//表示整个 so 映射到内存的大小:
si_->set_mapped_by_caller(elf_reader.is_mapped_by_caller());//判断 so 是否是外部提供的
si_->set_load_bias(elf_reader.load_bias());
si_->set_phdr(elf_reader.loaded_phdr());
si_->set_phnum(elf_reader.phdr_num());
}
下面是find_libraries的代码片段,我们先看注释。这部分挺容易的。
下面这段结合AI分析,得出的结论:
先明确核心背景:Android Linker 支持 “命名空间(Namespace)” 机制,默认情况下,一个命名空间内的库只能访问本命名空间内的符号(隔离性);而Global Group是 “打破隔离” 的特殊机制,加入该组的库,其符号会被所有命名空间共享。
目标就是全局符号组(Global Group)构建让标记为DF_1_GLOBAL的库,其全局符号对全进程所有 “命名空间(Namespace)” 可见。
到这里,就是为了构建全局符号表,为后续的重定位和链接做准备。注释都写得很清楚了。
Step 4-1:强制为 LD_PRELOAD 库设置 DF_1_GLOBAL 标志
Step 4-2:筛选本次加载的 DF_1_GLOBAL 库
Step 4-3:将新全局库加入所有已链接的命名空间
-
目的:让新加入 Global Group 的库,其符号对全进程所有命名空间可见(核心步骤)
-
实现方式:遍历所有已链接的命名空间(accessible_namespaces),并将新成员添加到它们的全局符号视图中。
-
影响:此后任何命名空间在进行符号查找时,都能看到这些新加入的全局库。
最终结论:Step 4 的核心是“构建全局符号共享机制”
- 强制预加载库(LD_PRELOAD)成为全局库;
- 筛选本次加载的、自带
DF_1_GLOBAL标志的新库;
- 将这些新全局库 “同步” 到所有已链接的命名空间,实现符号全局可见。
本质是平衡 “命名空间的隔离性” 和 “核心库的共享性”—— 大部分库默认隔离,只有标记为DF_1_GLOBAL的库(系统核心库、预加载库)才全局共享,既保证了进程稳定性,又满足了通用符号的共享需求。
接下来分析,Step 5的代码片段。在讲解这段代码之前,我们需要知道,每个so都有自己的Primary Namespace主命名空,存在soinfo结构体当中。然后可以有都多个次命名空间。Linker 的命名空间(Namespace)核心是 “隔离性”—— 每个命名空间有自己的库搜索路径(比如app命名空间搜索/data/app/xxx/lib/,platform命名空间搜索/system/lib/)和可见库集合。
if (si->get_primary_namespace() != ns)满足这个条件才会进去分支。
只处理“主命名空间 != 当前目标命名空间”的库(跨命名空间引用),就会递归调用find_libraries,去到so的主命名空间进行加载。举个例子:如果一个 so 是在其他 namespace 找到的,但它是我需要的依赖,那我必须在那个 namespace 再执行一遍依赖解析(find_libraries)。
对于soinfo* needed_by = task->get_needed_by();获取的是“谁需要我”,例如有一个依赖关系为libA -> libB,此时加载的B,get_needed_by获取到就是A,所以,在needed_by->get_primary_namespace()条件下,如果等于ns,这个ns指的是B的name space,所以如果相等的话,那就是A依赖B,所以对于此时的si(也就是libB)它的引用数要加1,跨域依赖的引用计数递增(防止误卸载)
至此,大概的流程就是如此,讲解完毕。
代码如下:
// Step 5: link dependencies with their primary namespace
for (auto&& task : load_tasks) {
soinfo* si = task->get_soinfo();
if (si->get_primary_namespace() != ns) {
if (si->get_primary_namespace() != nullptr) {
if (!find_libraries(ns, si, si->get_soname(), 1, &si, nullptr, 0, rtld_flags, extinfo, false, true, readers_map)) {
return false;
}
}
} else {
return false;
}
}
总结
结合AI分析,得出结论
核心结论:
- 每个加载成功的
.so都有唯一的主命名空间,存储在soinfo中;
- 主命名空间由 “第一次加载该库的发起者命名空间” 决定,一旦确定永不改变;
- 间接加载(依赖)的库,默认继承父库的主命名空间,但若库已在其他命名空间加载,则复用原主命名空间;
- 主命名空间是 Linker 实现依赖解析、隔离性、生命周期管理的核心依据。
这里开始讲解最后一步,前面经历了这么长的流程,终于到了,重定位和链接的阶段,实际上这部分是核心重点,也是难点。我们结合《程序员的自我修养——链接、装载与库》这本书来,理解一些关键流程。
// Step 6: link libraries in this namespace
// Step 6: 真正执行 link / relocation
soinfo_list_t local_group;
walk_dependencies_tree(
(start_with != nullptr && add_as_cdhildren) ? &start_with : soinfos,
(start_with != nullptr && add_as_children) ? 1 : soinfos_count,
[&] (soinfo* si) {
if (ns->is_accessible(si)) {
local_group.push_back(si);
return kWalkContinue;
}
return kSkipThisNode;
});
bool linked = soinfo_link_image(this, global_group, local_group, extinfo);
/**
* 链接成功
* local_group全部设置成set_linked,已经链接状态
*/
if (linked) {
local_group.for_each([](soinfo* si) {
if (!si->is_linked()) {
si->set_linked();
}
});
failure_guard.Disable();
}
接下来我们重点查看link_image方法,动态链接,是如何做的。
bool soinfo::link_image(const soinfo_list_t& global_group,const soinfo_list_t& local_group,const android_dlextinfo* extinfo) {
local_group_root_ = local_group.front();
if (local_group_root_ == nullptr) {
local_group_root_ = this;
}
if ((flags_ & FLAG_LINKER) == 0 && local_group_root_ == this) {
target_sdk_version_ = get_application_target_sdk_version();
}
VersionTracker version_tracker;
if (!version_tracker.init(this)) {
return false;
}
#if !defined(__LP64__)
if (has_text_relocations) {
// Fail if app is targeting M or above.
if (get_application_target_sdk_version() >= __ANDROID_API_M__) {
DL_ERR("Error: read-only relocations (used by text segment) found in %s "
"shared object; use a recent GCC to compile the source code with "
"-fPIC. This may also appear when linking non-position independent "
"executable against no-PIC .so files during incremental linking.",
get_realpath());
return false;
}
}
#endif
if (!prelink_image(&version_tracker)) {
return false;
}
if (!relocate_image(&global_group, &local_group, &version_tracker)) {
return false;
}
if (!set_soname()) {
return false;
}
return true;
}
开始分析prelink_image方法。
bool soinfo::prelink_image(VersionTracker* version_tracker) {
const ElfW(Dyn)* d;
ElfW(Addr) load_bias = get_load_bias();
const ElfW(Phdr)* dynamic = phdr;
size_t dynamic_count = phnum;
for (size_t i = 0; i < dynamic_count; ++i) {
if (dynamic[i].p_type == PT_DYNAMIC) {
dynamic = reinterpret_cast<const ElfW(Phdr)*>(load_bias + dynamic[i].p_vaddr);
break;
}
}
if (dynamic == nullptr) {
DL_ERR("%s has no PT_DYNAMIC segment", get_realpath());
return false;
}
for (d = reinterpret_cast<const ElfW(Dyn)*>(load_bias + dynamic->p_vaddr); d->d_tag != DT_NULL; ++d) {
switch (d->d_tag) {
case DT_SONAME:
d->d_val = load_bias + d->d_val;
break;
}
}
return true;
}
看到第一个方法lookup_version_info,该方法是跟符号版本管理相关的,想了解这部分的,可以看我推荐看的书《程序员的自我修养——链接、装载与库》,这里面有关于符号版本管理。
/**
* 在 Linux 等系统中,共享库(.so)会存在 “版本迭代”(比如 libc.so.6 是 libc 的第 6 个大版本)。
* 为了兼容旧程序(比如旧程序依赖 libc.so.6 的旧版本符号,而系统安装了更新版 libc),ELF 引入了 符号版本控制 机制
* 同一个符号名可能对应多个版本(比如 printf@GLIBC_2.2.5 和 printf@GLIBC_2.34)
* 程序链接时会记录 “依赖的符号版本”,运行时动态链接器必须找到完全匹配版本的符号,否则可能因接口不兼容导致程序崩溃。
*/
开始讲解第一个核心方法soinfo_do_lookup,该方法根据符号名字,来查找符号所在so的符号,值得注意的是,这就是为什么so不能抹除so的动态符号表,动态字符串表。忽略has_DT_SYMBOLIC的判断,感兴趣的可以去了解一下,AI分析一下,就是执行符号查找的优先级。
然后就到了方法中的注释// 1. Look for it in global_group,很容易知道,这个是在全局库当中查找符号,如libc.so等就是全局库global_group。
调用了global_group的visit方法,传入的闭包函数。我们接着分析一下这个闭包函数做了什么。调用了global_si->find_symbol_by_name(symbol_name, vi, &s),查找当前的soinfo,根据名字查找符号。
代码如下:
/**
* 在全局global_group中查找符号,以及local_group依赖的so中查找符号
* 传递的参数(this, sym_name, vi, &lsi, global_group, local_group, &s)
* si_from为当前so
* sym_name为需要查找符号的名字
* lsi为local_group
* s为查找到的符号
*/
soinfo_do_lookup(si_from, sym_name, vi, &lsi, global_group, local_group, &s);
接下来分析relocate_image方法。
bool soinfo::relocate_image(const soinfo_list_t* global_group,
const soinfo_list_t* local_group,
VersionTracker* version_tracker) {
ElfW(Rela)* rel = nullptr;
size_t rel_count = 0;
ElfW(Rela)* relr = nullptr;
size_t relr_count = 0;
ElfW(Rela)* plt_rel = nullptr;
size_t plt_rel_count = 0;
// 获取重定位表
if (!get_relocation_info(&rel, &rel_count, &relr, &relr_count, &plt_rel, &plt_rel_count)) {
return false;
}
// 处理常规重定位
if (!apply_relocations(global_group, local_group, version_tracker, rel, rel_count)) {
return false;
}
// 处理PLT重定位
if (!apply_relocations(global_group, local_group, version_tracker, plt_rel, plt_rel_count)) {
return false;
}
return true;
}
开始分析apply_relocations方法。
struct Elf64_Rela {
Elf64_Addr r_offset; // Location at which to apply the relocation.
Elf64_Xword r_info; // The symbol index and relocation type.
Elf64_Sxword r_addend; // Constant addend used to compute the reloc value.
};
// 1. 获取符号索引(r_info 的高 32 位)
Elf64_Word getSymbol() const { return (Elf64_Word) (r_info >> 32); }
// 2. 获取重定位类型(r_info 的低 32 位)
Elf64_Word getType() const { return (Elf64_Word) (r_info & 0xffffffffL); }
void setSymbol(Elf64_Word s) { setSymbolAndType(s, getType()); }
void setType(Elf64_Word t) { setSymbolAndType(getSymbol(), t); }
void setSymbolAndType(Elf64_Word s, Elf64_Word t) {
r_info = ((Elf64_Xword)s << 32) + (t&0xffffffffL);
};
重定位类型详解
JUMP_SLOT (R_AARCH64_JUMP_SLOT)
- 作用:用于延迟绑定(Lazy Binding),填充 PLT 表项,指向最终的函数地址。
- 过程:
- GOT[func] 初始指向 PLT 中的 stub 代码(用于触发 dl_runtime_resolve)。
- 第一次调用时,跳转到 stub,由 linker 解析符号 func。
- linker 找到 func 地址后,将 GOT[func] 修改为 func 的真实地址。
- 后续调用直接跳转到 func,无需再次解析。
GLOB_DAT (R_AARCH64_GLOB_DAT)
- 作用:解析全局变量符号,将 GOT 中对应项设置为变量的实际内存地址。
- 特点:与 JUMP_SLOT 类似,但用于数据而非函数,通常在加载时立即解析。
RELATIVE (R_AARCH64_RELATIVE)
- 作用:处理基于加载基址的相对偏移。计算
*(GOT + offset) = base + addend。
- 用途:用于位置无关代码(PIC)中访问自身数据段,如
static 变量。
ABS (R_AARCH64_ABS64)
- 作用:将符号的绝对地址写入指定内存位置。
- 风险:破坏位置无关性,现代共享库应避免使用。
COPY
- 作用:当可执行文件引用了一个共享库中的全局变量时,linker 会在可执行文件的
.bss 段中为该变量分配空间,并将共享库中该变量的初始值复制过去。
- 典型场景:C 语言中的
extern int errno;。虽然 errno 是一个全局变量,但每个线程需要有独立的副本,因此采用 COPY 重定位。
示例
假设有一个可执行文件 exe 和一个共享库 libc.so:
// libc.so
int global_var = 42; // 定义在共享库中
// exe
extern int global_var; // 声明为外部变量
int main() {
printf("%d\n", global_var); // 实际访问的是自己.bss中的副本
return 0;
}
linker 做的事:
memcpy(exe.global_var, libc.global_var, sizeof(int));
总结对比表
| 重定位类型 |
场景 |
是否查找符号 |
写入内容 |
举例 |
| JUMP_SLOT |
函数调用(PLT/GOT) |
✔ |
GOT = 符号地址 |
printf |
| GLOB_DAT |
全局变量 |
✔ |
GOT = 符号地址 |
errno |
| RELATIVE |
自身数据指针 |
✘ |
*(base+offset) = base+addend |
静态指针 |
| ABS |
绝对地址引用 |
✔ |
*(addr) = 符号地址 |
&global |
| COPY |
exe 复制共享库变量 |
✔ |
memcpy() |
全局变量初始化 |
总结
Step 4 决定哪些库的符号是全局可见的(构建 Global Group);
Step 5 处理跨 namespace 加载的依赖(并维护引用计数);
Step 6 执行真正的符号解析与重定位(link_image),让 so 可运行。
