首页
社区
课程
招聘
[原创]从零手写 ARM64 自定义 Linker
发表于: 1天前 808

[原创]从零手写 ARM64 自定义 Linker

1天前
808

学完 ELF 文件格式和 Android linker 的加载流程后,一直有种意犹未尽的感觉——原理看懂了,却没动手实操一遍,总觉得稍有遗憾。本文就是把这次从零实现的完整过程记录下来,拆解其中的思路与落地。一是温习前面所学,把散落的知识点串成一条完整链路;二是为后续理解主流加壳、脱壳的手法打地基——知己知彼,方能百战不殆。本文只是建立好这个地基,而地基之上的种种骚操作,或许在下一篇文章中会出现?

真机 Pixel 3、Android 12,架构 arm64-v8a;编译使用 NDK 25.1.8937393。as版本:2026.1.1

整体目标很清晰:不借助系统的 dlopen/dlsym,用 C++ 手写一个 ARM64 加载器,把一个 .so 从文件变成内存里可调用的代码,最终通过自己实现的符号查找拿到导出函数地址并成功调用。

在做法上,先用系统 dlopen 跑通一次目标 so,留作后续比对的标准答案。之后照着系统 linker 的真实流程,把整条链路拆成几个阶段逐一实现。

装载,是按 program header 的描述把 so 的各个段映射进内存,并算出文件地址到内存地址的关键偏移 load_bias,后面每一步都要用它换算地址。解析 dynamic 段,是遍历其中的各个 tag,把符号表、字符串表、重定位表、初始化函数这些散落的元信息收集进自己的 soinfo 结构。符号查找,是手写 gnu_hash 的查找算法,按名字从 so 里定位符号地址;对本 so 未定义的外部符号如 printf,则采用受控的兜底解析。重定位,是遍历两张 RELA 表,按 ARM64 的四种类型逐条修正内存中的地址,把 GOT 填成正确的函数地址。初始化,是按先 DT_INIT 后 DT_INIT_ARRAY 的顺序,手动触发 so 的构造函数。最后收官,实现自己的 dlsym,从加载好的 so 里查出导出函数 add 的地址,强转成函数指针调用,验证结果与系统 dlopen 一致。

创建libtarget.c文件,写入以下内容:

去NDK路径下找这个cmd命令

如果找不到可以使用这个命令去检索(搜哪个磁盘下面的path后的路径就换成哪个)

image

像这样啥也没输出就是成功了,目录下会产出一个libtarget.so。

image

as创建一个Native项目

image

在左侧 app/src/main/​ 上右键 → New → Directory,输入 jniLibs/arm64-v8a​(一次性带斜杠创建两层)。然后把那个编好的 libtarget.so​ 复制进 app/src/main/jniLibs/arm64-v8a/libtarget.so

注意!!!

这里创建的是arm64的 需要在真机上运行

image

这里private native void runLoaderRef() 去手敲 然后用ALT+回车去创建

image

运行测试:

image

目标:不实现任何实际加载逻辑,先把整个程序的类结构、调用链、数据结构搭出来,能编译能跑能打印,确立后续所有代码的填充位置。

新建 cpp/soinfo.h

本文只保留加载并调用单个 SO 所需的字段,其余一并裁去。

fini_func / fini_array(析构相关)裁去,加载后的 SO 不支持优雅卸载。本文不涉及卸载,进程退出时由系统回收。

TLS 字段(线程局部存储)裁去,SO 若使用 __thread 变量将崩溃。目标 SO 未使用。

符号版本字段(verdef / verneed)裁去,无法处理带符号版本的 SO。目标 SO 不含此类信息。

ARM_exidx(异常处理表)裁去,C++ 异常展开可能出错。目标 SO 为 C 代码且不抛异常。

sysv hash 字段裁去,目标 SO 仅含 gnu_hash。

next 指针与 soinfo 链表裁去,该结构用于串联全部已加载 SO 以支持全局符号查找。本文只加载单个 SO,无此需要。

这里按照类型分成五个组方便后续的字段新增与修改

这节主要就是将框架和流程跑通 不需要做具体的内容实现 搭建宏观上的调用链 然后在后续填坑就行

新建 cpp/mylinker.h

新建 cpp/mylinker.cpp

这里是没有find_library步骤的。系统要在一堆路径找到so文件、检查是否加载过、处理依赖....

我们这里只加载一个so 并且知道路径,因此不需要find_library这个逻辑。

新建 cpp/soinfo.cpp

这里先做空实现和日志打印就行

在cmakelists里面新增这两个.cpp文件

image

修改native-lib.cpp

将系统的dlopen替换成自己dlopen去加载so

image

一个小坑:这里写完这节的逻辑后最好先在菜单 Build → Clean Project,然后 Build → Rebuild Project,再运行。

不然有可能跑的还是旧的app就会出现下图这样 运行流程对不上

image

native-lib.cpp 改回带参数版本,并把绝对路径传给 dlopen

MainActivity.java 对应改成传参

image

接下来实现 soinfo::load 的第一段,只做到读文件头与校验。

soinfo.cpp 顶部补头文件:

soinfo 中新增两个字段保存 fd 与文件头,装载阶段需跨函数使用。

soinfo.h 成员方法里加一个声明:

load 实现如下,依次打开文件、读取 ELF 文件头、校验。

verify_elf_header 沿用系统 linker 的校验逻辑,逐项确认文件为 64 位、小端、ARM64 的共享库,任一不符即返回 false。

运行结果:

image

image

本节手动实现段的映射流程,使加载器映射的段能在 /proc/pid/maps 中被观察到。为便于调试,先采用 file-backed 方式打通整条链路。

先定义页对齐宏,新建 cpp/linker_common.h​。PAGE_START 向下取整到页边界,PAGE_END 向上取整,PAGE_OFFSET 取页内偏移。

soinfo.h 新增字段与方法声明。此处的 phdr_table 指向从文件临时映射读到的 PHT,与前面声明的 phdr 不同——后者指向 SO 映射进内存后、内存中的 PHT。

soinfo.cpp 补充头文件与实现。mmap 的 offset 参数必须页对齐,而 e_phoff(64)并非页对齐,因此将起点向下对齐到 page_min(0),映射一整页,再令 phdr_table 指向页内 PAGE_OFFSET(64) = 64 处。

在 load 里调用

image

这里可以在read_program_headers 的 return 前把PHT的段进行遍历打印看看是不是对的

打印结果与预期一致,没啥问题。

image

一个 SO 可能包含多个 PT_LOAD 段,它们在虚拟地址空间中彼此间隔固定。为保证这些段映射后相对位置正确,须先一次性预留一块足以容纳全部段的连续地址空间,再逐段填入。预留空间的大小 load_size,等于最高段末尾(向上页对齐)减去最低段起始(向下页对齐)。

load_bias 是文件虚拟地址到内存实际地址的偏移,等于 mmap 返回的真实起始地址减去页对齐后的最小 p_vaddr,后续所有地址换算均依赖它。

soinfo.h 新增一个方法声明。

实现分四步。先遍历所有 PT_LOAD 段,求出最小的 p_vaddr 和最大的 p_vaddr + p_memsz;再将两端页对齐,低地址向下、高地址向上,二者之差即 load_size;随后以 PROT_NONE、匿名方式一次性 mmap 出这块连续空间,此时只占位、不设权限、不映射内容;最后由预留空间的起始地址与页对齐后的最小 p_vaddr 算出 load_bias。之所以用 PROT_NONE 占位,是因为此处目的仅是圈定一段连续且不被其他分配占用的地址范围,段的真实内容与权限留待下一节逐段映射时覆盖。

soinfo.cpp 实现:

在load函数调用

image

image

前面以 PROT_NONE 预留了一块连续空地,本节将每个 PT_LOAD 段映射进去并设置权限。段在内存中的位置为 load_bias + p_vaddr​,load_bias 至此首次参与地址换算。

映射同样使用 mmap,但与预留时不同:带 fd 真实读取文件,带 MAP_FIXED 指定地址,权限取自段本身,形式为 mmap(对齐后的 load_bias+p_vaddr, 段长, 段权限, MAP_FIXED|MAP_PRIVATE, fd, 对齐后的 p_offset)

MAP_FIXED 要求映射到指定地址,不允许系统另择位置——段与段之间的相对距离在编译期已固定,不能改变。之所以能这样用,是因为该地址范围已在上一节被 PROT_NONE 占下,此处以真实内容覆盖。因此顺序不可颠倒,必须先预留后映射。

地址与文件偏移都需页对齐,而 p_vaddr、p_offset 未必对齐,处理方式是两者一并向下取整到页边界。二者能够对应,是因为 ELF 保证同一段的 p_vaddr 与 p_offset 具有相同的页内偏移。权限由 p_flags 的 PF_R/W/X 转为 PROT_R/W/X,代码段为 r-x,数据段为 rw-。

BSS 补零仅在 memsz 大于 filesz 时发生,且最易遗漏。filesz 是段在文件中的大小,memsz 是段在内存镜像中的大小,二者之差即 BSS,需以零填充。上面的 mmap 只映射了 filesz,其余分两种情况补齐:一是段尾不足一页的部分,从 filesz 处到本页末尾,该范围已被 mmap 读入但属文件的脏数据,用 memset 清零;二是超出一页的部分,另做一次匿名 mmap(fd = -1)补齐零页。

soinfo.h 新增一个方法声明,其后在 soinfo.cpp 中实现(其中 flags_to_prot 负责将 p_flags 转为 mmap 的权限位)。

soinfo.cpp 实现:

在 load 里调用(接在 reserve_address_space 之后):

image

读取 Program Header Table 一节曾为读 PHT 临时 mmap 了文件头所在的一页(phdr_mmap)。如今各段已映射进内存,PHT 作为文件内容也随之进入镜像,本节在镜像中重新定位它,存入 phdr,再释放此前那份临时映射。

不继续沿用临时映射有两个原因:其一,它本就约定用完即弃;其二,后续查找 dynamic、symtab 等一律以 load_bias + p_vaddr 在镜像内换算地址,若混用一份镜像外的 PHT,概念上并不统一。系统 linker 同样在镜像中重新定位,此处与之保持一致。

定位有两种方式,按优先级选取。首选 PT_PHDR 段,它专门记录 PHT 自身在内存中的位置,存在时直接取 load_bias + p_vaddr,最为准确。若无 PT_PHDR,则退而取 p_offset 为 0 的第一个 PT_LOAD 段:ELF 文件头位于文件起始,PHT 紧随其后,这一区域必被某个从偏移 0 起始的 PT_LOAD 映入内存,地址为 load_bias + p_vaddr + e_phoff。两种方式算出的是同一地址,PT_PHDR 更为直接。

此处有一个顺序需注意:遍历查找 PT_PHDR 时用的仍是 phdr_table(指向临时映射),因此必须先算出新地址存好,最后再 munmap 临时映射。若顺序颠倒,phdr_table 将成为悬空指针。

find_phdr 是 load 的最后一步,成功后即可 close(fd)——各段已在内存中,不再依赖文件描述符。

soinfo.h 新增一个方法声明,其后在 soinfo.cpp 中实现。

soinfo.cpp 实现:

在 load 里调用(接在 load_segments 之后,这也是 load 的最后一步):

image

本节遍历 dynamic 段,采用 ARM64 的 tag 集合,将散落在文件各处的元信息收集进 soinfo。

装载阶段已将 SO 搬入内存,但符号表、重定位表、构造函数等所在位置尚未可知——这些信息全部记录在 dynamic 段中,是整个链接过程的信息中枢。本节只做一件事:定位 PT_DYNAMIC,算出其内存地址存入 dynamic,作为后续解析的入口。

定位方式与查找内存中 PHT 时相同:遍历 phdr,找到 p_type == PT_DYNAMIC​ 的项,取 load_bias + p_vaddr​。dynamic 段是一个 ElfW(Dyn)​ 数组,以 ElfW(Dyn)* 保存。

只取第一个,找到即停,与系统一致。

soinfo.h 新增一个方法声明,其后在 soinfo.cpp 中实现。

soinfo.cpp 实现:

调用放在 parse_dynamic 开头

image

上一步已取得 dynamic 入口,即一个 ElfW(Dyn) 数组。本节从头遍历,每项是一组 <tag, value>,通过一个大 switch 按 tag 将元信息填入 soinfo,遇到 DT_NULL(tag 为 0)终止。

value 有两种含义,切勿混淆,这也是本节最主要的出错点。地址型的 tag(如 STRTAB、SYMTAB、RELA 等)需以 load_bias + d_ptr 换算为内存地址;数值型的 tag(如 RELASZ 及各类计数)则直接取用,不加 bias。

需要处理的 tag 按用途分为几组。字符串表与符号表,分别记录 strtab、symtab 的地址。gnu_hash 此处只保存表的起点地址,各字段(bucket、chain、bloom)的拆解留到符号查找一节与查找逻辑一并说明;DT_HASH 现代 SO 一般不含,忽略即可。

重定位相关,ARM64 使用 RELA 而非 32 位的 REL:RELA 为 .rela.dyn 地址,RELASZ 除以 24 得表项数;JMPREL 为 .rela.plt 地址,PLTRELSZ 除以 24 得表项数。PLTREL 的值必须为 DT_RELA,若为 REL 则报错;dynamic 中一旦出现 DT_REL、DT_RELSZ 也报错——以此与 32 位划清界限。

初始化相关,INIT 为老式的单个初始化函数,INIT_ARRAY 为构造函数数组,INIT_ARRAYSZ 除以指针大小得表项数。依赖相关,NEEDED 的 value 是 strtab 中的偏移而非地址,须以 strtab + d_val 才能取到名字;由于遍历到 NEEDED 时 strtab 未必已确定,此处先攒下偏移,遍历结束后再统一翻译成名字。

soinfo.h 新增两个临时字段:一个保存 gnu_hash 表起点,一个保存尚未翻译的 needed 偏移列表。随后在 soinfo.cpp 中实现完整的遍历逻辑,接在 find_dynamic 之后:遍历时按上述分组填充各字段,遍历结束后将字节数换算为表项数、翻译 needed 名字,并打印各项结果以便核对。

soinfo.cpp 实现(接在 find_dynamic 之后):

image


[内核课程]《Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

最后于 1天前 被北袅编辑 ,原因:
收藏
免费 0
打赏
分享
最新回复 (8)
雪    币: 3376
活跃值: (12662)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
厉害啊
1天前
0
雪    币: 7
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3
向大佬学习
1天前
0
雪    币: 200
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
4
自实现linker最后再调用一次dlopen是为了啥,那不是又走了一遍系统link
15小时前
1
雪    币: 779
活跃值: (1067)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
5
看看
12小时前
0
雪    币: 3931
活跃值: (5179)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6
过来瞧瞧
12小时前
0
雪    币: 1551
活跃值: (5318)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
7
8小时前
0
雪    币: 6662
活跃值: (16744)
能力值: ( LV9,RANK:270 )
在线值:
发帖
回帖
粉丝
8
这个支持的版本是多少,我最近想搞一个,就是ptrace注入so文件不在目标进程调用dlopen打开,防止目标app hook dlopen被检测。
5小时前
0
雪    币: 206
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
9
66666666666
5小时前
0
游客
登录 | 注册 方可回帖
返回