找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1535

积分

0

好友

195

主题
发表于 2026-2-12 10:58:26 | 查看: 31| 回复: 0

建议学习过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根据注释这里说明如果路径包含斜杠/,则去尝试加载,这里我们不分析。然后往下这里分为三部分。

  1. 第一部分是ns->get_ld_library_paths(),获取的是LD_LIBRARY_PATH,尝试到这里去加载so
  2. 第二部分是ns->get_default_library_paths(),获取的是默认路径(如 /system/lib /apex/...)
  3. 第三部分是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. 场景 1:SO 被打包在其他文件中(如静态库、镜像文件、加壳后的壳 SO):比如加壳 SO 中,原始 SO(Payload)的 ELF 数据被加密后存储在壳 SO 的数据段,有固定的文件偏移;
  2. 场景 2:共享库的 “片段加载”:极少数情况下,SO 可能被分割成多个片段存储,加载时需指定片段在文件中的起始偏移;file_offset不是所有要加载的 SO 都是 “独立的 .so 文件”—— 存在以下场景,SO 数据并非从文件起始位置开始。

那这部分对于普通的so应该就是0。接着load_library方法开始调用load_library的第二个重载。

首先是soinfo_alloc,分配soinfo,task->set_soinfo(si);

然后核心是,task调用read方法,这里说结论,read方法会去读取的内容如下:

  1. ELF文件头,验证文件头,对应代码ReadElfHeader(),VerifyElfHeader()
  2. 程序头表,对应代码ReadProgramHeaders()
  3. 节头表,对应代码ReadSectionHeaders()
  4. 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 标志

  • 目的:确保LD_PRELOAD预加载库的符号 “全局可见”。

  • 逻辑解析

    1. LD_PRELOAD是用户 / 系统指定的 “优先加载库”(比如用于 hook、调试),其设计初衷就是要覆盖其他库的符号,因此必须强制加入 Global Group;
    2. si->get_dt_flags_1():获取库当前的动态段标志位(DT_FLAGS_1,ELF 动态段条目,存储扩展标志);
    3. | DF_1_GLOBAL:通过位或运算,强制设置DF_1_GLOBAL标志(不影响其他已有标志);
    4. set_dt_flags_1():将修改后的标志位写回soinfo
  • 核心结论:预加载库默认全局可见,无论其自身是否编译时设置DF_1_GLOBAL

Step 4-2:筛选本次加载的 DF_1_GLOBAL 库

  • 目的:收集本次加载过程中(比如dlopen一个库时,连带加载的依赖库),本身带有DF_1_GLOBAL标志且 “未完成链接” 的库,作为 Global Group 的新成员。

  • 关键条件解析

    1. !si->is_linked():筛选 “未完成链接” 的库。Linker 加载库的流程是 “先加载文件→再解析依赖→最后链接(符号绑定、重定位)”,这里确保只处理 “本次加载周期中新增的、还没完成链接的库”(避免重复处理已存在的全局库);
    2. (si->get_dt_flags_1() & DF_1_GLOBAL) != 0:筛选本身带有DF_1_GLOBAL标志的库(该标志通常是库编译时,通过链接器参数或属性设置的,比如系统库libc.so编译时就会带该标志)。
  • 结果new_global_group_members列表存储了 “本次加载的、需要加入全局组的新库”。

Step 4-3:将新全局库加入所有已链接的命名空间

  • 目的:让新加入 Global Group 的库,其符号对全进程所有命名空间可见(核心步骤)

  • 实现方式:遍历所有已链接的命名空间(accessible_namespaces),并将新成员添加到它们的全局符号视图中。

  • 影响:此后任何命名空间在进行符号查找时,都能看到这些新加入的全局库。

最终结论:Step 4 的核心是“构建全局符号共享机制”

  1. 强制预加载库(LD_PRELOAD)成为全局库;
  2. 筛选本次加载的、自带DF_1_GLOBAL标志的新库;
  3. 将这些新全局库 “同步” 到所有已链接的命名空间,实现符号全局可见。

本质是平衡 “命名空间的隔离性” 和 “核心库的共享性”—— 大部分库默认隔离,只有标记为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分析,得出结论

核心结论

  1. 每个加载成功的.so都有唯一的主命名空间,存储在soinfo中;
  2. 主命名空间由 “第一次加载该库的发起者命名空间” 决定,一旦确定永不改变;
  3. 间接加载(依赖)的库,默认继承父库的主命名空间,但若库已在其他命名空间加载,则复用原主命名空间;
  4. 主命名空间是 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 表项,指向最终的函数地址。
  • 过程
    1. GOT[func] 初始指向 PLT 中的 stub 代码(用于触发 dl_runtime_resolve)。
    2. 第一次调用时,跳转到 stub,由 linker 解析符号 func。
    3. linker 找到 func 地址后,将 GOT[func] 修改为 func 的真实地址。
    4. 后续调用直接跳转到 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 可运行。

Android Linker加载流程示意图




上一篇:CTF密码爆破实战指南:从HTTP基础认证到SQL时间盲注入门
下一篇:CVE-2026-20841漏洞修复:Windows记事本远程代码执行风险需警惕
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-23 13:00 , Processed in 0.716713 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表