本文详细记录了把
.o变成.ko在安卓系统上完整加载的全过程探索,一起来感受ELF格式探索的奇妙之旅作者:shixuan
经作者授权,文章重排优化后在[软件安全与逆向分析]公众号发布

有一段用户空间代码需要在内核里跑。正常做法是用内核构建系统进行编译生成ko模块,但那样得维护一套Kbuild,而且代码里的用户空间惯用法改起来很痛苦。于是就冒出一个念头:能不能直接把编译好的二进制文件直接"转"成ko模块?
直觉上这应该可行——反正.ko就是ELF可重定位文件(ET_REL),普通的 .o 编译产物也是 ET_REL。格式骨架一样,差的无非是元数据。
真的上手之后,才发现坑比想象中多得多。
先理清几个基本概念。ELF 文件有四种类型:
关键认知:so文件是 ET_DYN,ko文件是ET_REL。它们的ELF类型就不同。
.so文件是 ET_DYN(动态链接库),结构上和.ko有本质差异:
内核加载模块的第一步,就是检查ELF类型——必须是ET_REL,否则直接返回-ENOEXEC。ET_DYN的.so连第一道门都过不去。这个检查在 kernel/module.c的elf_validity_check() 中:
// kernel/module.c: elf_validity_check()if (memcmp(info->hdr->e_ident, ELFMAG, SELFMAG) != 0 || info->hdr->e_type != ET_REL // ← 只接受 ET_REL || !elf_check_arch(info->hdr) || info->hdr->e_shentsize != sizeof(Elf_Shdr))return -ENOEXEC;.so里塞满了动态链接基础设施:.dynamic, .dynsym, .dynstr, .hash, .gnu.hash, .got, .got.plt, .plt.got, .rel.dyn, .rel.plt, .interp。
这些段包含了各种不必要的信息,对内核模块加载器毫无意义,必须全部删除。
.so 的符号名长这样:
puts@GLIBC_2.2.5malloc@GLIBC_2.0内核导出符号可没有这些@后缀。用带后缀的名字去查内核符号表,内核在 simplify_symbols() 里调用 resolve_symbol_wait() 做严格的 strcmp 比对,当然查不到。
.so 里的函数调用默认走 PLT(过程链接表),会生成大量 PLT 相关的重定位条目。内核加载器内部的 apply_relocations() 遍历所有 SHT_RELA 段逐个处理重定位,这些 PLT 重定位条目会被逐一处理,但处理逻辑和用户空间 ld.so 完全不同,结果就是错位。
结论:用 gcc -c -fPIC 编译成 .o(ET_REL),直接从 ET_REL 转 ET_REL。
在讲具体坑之前,先沿着内核源码(以 Linux 5.10 为例)把模块加载的完整路径走一遍。后面所有坑的根因都能在这条链路上找到。
第一步:ELF 合法性校验 —— elf_validity_check()
// kernel/module.cstaticintelf_validity_check(struct load_info *info){if (info->len < sizeof(*(info->hdr)))return -ENOEXEC;if (memcmp(info->hdr->e_ident, ELFMAG, SELFMAG) != 0 || info->hdr->e_type != ET_REL // ① 必须是 ET_REL || !elf_check_arch(info->hdr) // ② 架构必须匹配 || info->hdr->e_shentsize != sizeof(Elf_Shdr))return -ENOEXEC;// ③ 段头表必须在文件范围内if (info->hdr->e_shoff >= info->len || (info->hdr->e_shnum * sizeof(Elf_Shdr) > info->len - info->hdr->e_shoff))return -ENOEXEC; info->sechdrs = (void *)info->hdr + info->hdr->e_shoff;// ... 后续还会校验段名字符串表索引的有效性}三项硬性检查:ELF 魔数、ET_REL 类型、架构匹配。任何一个不过就直接 -ENOEXEC。这就是为什么 .so 不行,同时也意味着我们不能修改 ELF header 把 ET_DYN 改成 ET_REL 了事——架构检查 elf_check_arch() 在 ARM64 上还会验证段结构的完整性。
第二步:内核元数据校验 —— check_modinfo()
// kernel/module.cstaticintcheck_modinfo(struct module *mod, struct load_info *info, int flags){constchar *modmagic = get_modinfo(info, "vermagic"); // 从 .modinfo 提取int err;if (flags & MODULE_INIT_IGNORE_VERMAGIC) modmagic = NULL;if (!modmagic) { err = try_to_force_load(mod, "bad vermagic"); // 没有 vermagic → 污染内核if (err)return err; } elseif (!same_magic(modmagic, vermagic, info->index.vers)) { pr_err("%s: version magic '%s' should be '%s'\n", info->name, modmagic, vermagic); // vermagic 不匹配 → 直接拒载return -ENOEXEC; }if (!get_modinfo(info, "intree")) { // 检查是否为树内模块if (!test_taint(TAINT_OOT_MODULE)) pr_warn("%s: loading out-of-tree module taints kernel.\n", mod->name); add_taint_module(mod, TAINT_OOT_MODULE, LOCKDEP_STILL_OK); } check_modinfo_retpoline(mod, info); // 检查 retpoline// ... staging, livepatch 等检查}vermagic 的比对逻辑在 same_magic() 中:
// kernel/module.cstaticinlineintsame_magic(constchar *amagic, constchar *bmagic,bool has_crcs){if (has_crcs) { amagic += strcspn(amagic, " "); bmagic += strcspn(bmagic, " "); }returnstrcmp(amagic, bmagic) == 0; // ← 严格字符串比对}如果内核启用了 CONFIG_MODVERSIONS,会跳过 vermagic 里第一个空格之前的内容再做比对(因为那部分是 UTS_RELEASE)。否则就是完整的 strcmp。
第三步:架构特定处理 —— module_frob_arch_sections()
在分配模块内存之前,内核调用架构钩子检查和预处理段结构。ARM64 的实现(arch/arm64/kernel/module-plts.c)尤其值得关注:
// arch/arm64/kernel/module-plts.cintmodule_frob_arch_sections(Elf_Ehdr *ehdr, Elf_Shdr *sechdrs,char *secstrings, struct module *mod){unsignedlong core_plts = 0;unsignedlong init_plts = 0; Elf_Shdr *tramp = NULL;int i;for (i = 0; i < ehdr->e_shnum; i++) {if (!strcmp(secstrings + sechdrs[i].sh_name, ".plt")) mod->arch.core.plt_shndx = i;elseif (!strcmp(secstrings + sechdrs[i].sh_name, ".init.plt")) mod->arch.init.plt_shndx = i;elseif (!strcmp(secstrings + sechdrs[i].sh_name,".text.ftrace_trampoline")) tramp = sechdrs + i; }if (!mod->arch.core.plt_shndx || !mod->arch.init.plt_shndx) { pr_err("%s: module PLT section(s) missing\n", mod->name);return -ENOEXEC; // ← .plt 和 .init.plt 缺一不可 }// ... PLT 条目预分配逻辑}ARM64 的模块链接脚本同样印证了这一点(arch/arm64/include/asm/module.lds.h):
// arch/arm64/include/asm/module.lds.hSECTIONS { .plt 0 : { BYTE(0) } .init.plt 0 : { BYTE(0) } .text.ftrace_trampoline 0 : { BYTE(0) }}内核构建工具链生成的 .ko 天然带这三个段(大小可以为 0,内容为 1 字节占位)。自己构建的 .ko 如果缺少这些段,ARM64 的 module_frob_arch_sections 直接返回 -ENOEXEC。x86_64 没有这个硬性要求。
第四步:符号解析 —— simplify_symbols()
// kernel/module.cstaticintsimplify_symbols(struct module *mod, conststruct load_info *info){ Elf_Shdr *symsec = &info->sechdrs[info->index.sym]; Elf_Sym *sym = (void *)symsec->sh_addr;conststructkernel_symbol *ksym;for (i = 1; i < symsec->sh_size / sizeof(Elf_Sym); i++) {constchar *name = info->strtab + sym[i].st_name;switch (sym[i].st_shndx) {case SHN_UNDEF: // ← 关键:未定义符号 ksym = resolve_symbol_wait(mod, info, name);if (ksym && !IS_ERR(ksym)) { sym[i].st_value = kernel_symbol_value(ksym);break; // 在内核符号表中找到了 }if (!ksym && ELF_ST_BIND(sym[i].st_info) == STB_WEAK)break; // 弱符号,允许不存在 ret = PTR_ERR(ksym) ?: -ENOENT; pr_warn("%s: Unknown symbol %s (err %d)\n", mod->name, name, ret);break; // 找不到 → 失败case SHN_ABS:break; // 绝对符号,不需要重定位default: secbase = info->sechdrs[sym[i].st_shndx].sh_addr; sym[i].st_value += secbase; // 段内符号,加上段基址break; } }return ret;}核心逻辑:
shndx == SHN_UNDEFshndx == SHN_ABS所以 UND 符号的 shndx 必须是 0。如果你在转换时把它改成了 SHN_ABS(0xFFF1),它就不会进入 resolve_symbol_wait 分支,内核不会去查符号表,外部引用全部悬空。
第五步:重定位处理 —— apply_relocations()
// kernel/module.cstaticintapply_relocations(struct module *mod, conststruct load_info *info){int err = 0;for (i = 1; i < info->hdr->e_shnum; i++) {unsignedint infosec = info->sechdrs[i].sh_info; // 目标段索引if (infosec >= info->hdr->e_shnum)continue;if (!(info->sechdrs[infosec].sh_flags & SHF_ALLOC))continue; // 跳过未分配段if (info->sechdrs[i].sh_type == SHT_REL) err = apply_relocate(info->sechdrs, info->strtab, info->index.sym, i, mod);elseif (info->sechdrs[i].sh_type == SHT_RELA) err = apply_relocate_add(info->sechdrs, info->strtab, info->index.sym, i, mod); // ← 处理 RELAif (err < 0)break; }return err;}这段逻辑遍历所有段头,找出类型为 SHT_RELA 的段(重定位段),调用架构特定的 apply_relocate_add() 逐条处理。
.rela.gnu.linkonce.this_module 就是在这里被处理的。内核遍历到这个段时,把 init_module 和 cleanup_module 的最终地址写入 .gnu.linkonce.this_module 段内对应偏移处。这些偏移正是 struct module 中 init/exit 函数指针的位置。
第六步:发起初始化 —— do_init_module()
// kernel/module.cstatic noinline intdo_init_module(struct module *mod){int ret = 0;structmod_initfree *freeinit; freeinit = kmalloc(sizeof(*freeinit), GFP_KERNEL); freeinit->module_init = mod->init_layout.base; do_mod_ctors(mod);if (mod->init != NULL) // ← 关键判断 ret = do_one_initcall(mod->init); // 通过函数指针调用if (ret < 0)goto fail_free_freeinit; mod->state = MODULE_STATE_LIVE;// ... uevent, async 同步, 释放 init 内存}内核调用 mod->init 这个函数指针。这个指针的值是在第五步的重定位处理中填入的。如果 .rela.gnu.linkonce.this_module 段不存在或偏移量不正确,mod->init 就是 NULL,内核跳过整个初始化流程,不报任何错误。
同样,模块卸载时(kernel/module.c 的 free_module() 路径):
// kernel/module.c: 模块卸载路径if (mod->exit != NULL) mod->exit();mod->exit 也是通过重定位填入的。两个函数指针,两条重定位,缺一不可。
第七步:CRC 校验 —— check_version()
如果内核开启了 CONFIG_MODVERSIONS,每个外部符号引用都要比对 CRC:
// kernel/module.cstaticintcheck_version(conststruct load_info *info,constchar *symname,struct module *mod,const s32 *crc){ Elf_Shdr *sechdrs = info->sechdrs;unsignedint versindex = info->index.vers;structmodversion_info *versions;if (!crc)return1; // 内核没提供 CRC,放行if (versindex == 0)return try_to_force_load(mod, symname) == 0; // 模块没 __versions 段 versions = (void *)sechdrs[versindex].sh_addr; num_versions = sechdrs[versindex].sh_size / sizeof(struct modversion_info);for (i = 0; i < num_versions; i++) {if (strcmp(versions[i].name, symname) != 0)continue;if (versions[i].crc == crcval)return1; // CRC 匹配goto bad_version; // 符号名匹配但 CRC 不匹配 → 失败 } pr_warn_once("%s: no symbol version for %s\n", info->name, symname);return1; // 没找到对应条目,警告但放行}模块的 __versions 段存储了 struct modversion_info 数组(64 字节每项:CRC + 符号名)。内核逐个比对 CRC 值,不匹配则加载失败。其中 module_layout 这个符号的 CRC 实质上代表了整个 struct module 的结构签名。
以上就是模块从 insmod 到 init 执行经过的全部内核关卡。接下来看转换过程中的具体坑。
搞清楚内核加载路径后,我设计了两阶段流水线:
开发机 目标设备-------- ---------.o 文件 reference.ko (任意已有模块) │ │ ▼ ▼[离线转换] ──► .ko (带占位值) ──► [原位修补] ──► .ko (可加载)阶段一(离线转换)在开发机上完成 ELF 结构层面的转换:删掉不需要的段、保留需要的段、补充内核元数据段、重新索引符号和重定位。所有不确定的内核参数填入占位值。
阶段二(原位修补)在目标设备上运行。找一个目标设备上已有的、能正常加载的 .ko 作为"参考",从中提取所有内核特定参数,覆写占位值。
这个设计的核心思想是:转换工具不需要知道目标内核的任何细节。vermagic、struct module 大小、字段偏移、CRC——全部由参考 .ko 提供。
以下按排查难度排序。
坑 1:段的白名单与黑名单
转换的第一步:决定哪些段保留、哪些丢弃。
必须丢弃的段:
.dynamic, .dynsym, .dynstr, .hash, .gnu.hash 等共十余个段).got, .got.plt, .plt.got, .plt.sec).rel.*, .rela.*)——因为段索引已变,旧的重定位条目引用的段索引失效,必须删除后基于新段表重新生成必须保留的段:
.text.data, .rodata, .bss(基本代码和数据).init_array.eh_frame 等辅助段.comment.note.* 等信息段ARM64 上必须额外创建的空段:
从上面 module_frob_arch_sections() 的源码可以看到,ARM64 直接按段名查找 .plt 和 .init.plt,找不到就返回 -ENOEXEC。ARM64 的链接脚本 .lds.h 也明确定义了这三个空段。所以转换阶段必须生成:
.plt.init.plt.text.ftrace_trampoline缺任何一个,内核直接拒载,错误信息只是 "module PLT section(s) missing",不给具体缺少哪个。
原始重定位段必须删除。删除了部分段、重构了段索引后,旧的重定位条目引用的段索引已失效。如果新旧重定位段并存(比如两套 .rela.text),加载器在处理第二条重定位时发现目标位置已有非零值,会报 "Invalid relocation target, existing value is nonzero"。
坑 2:ET_REL 的地址是段相对的,库给你的可能是绝对的
ET_REL 文件里的地址全部是段相对的:
st_value = 该符号在其所属段内的偏移r_offset = 在目标段内的偏移位置比如符号的 value 应该是类似 0x10 的值("函数入口在 .text 段偏移 0x10 处"),绝对不是 0x7f0000001000 这样的虚拟地址。
对应到内核源码,simplify_symbols() 里的 default 分支:
default: secbase = info->sechdrs[sym[i].st_shndx].sh_addr; // 段的加载基址 sym[i].st_value += secbase; // st_value + 段基址 = 绝对地址内核假设 st_value 是段相对偏移,然后加上段加载基址得到最终地址。如果你的 st_value 已经是个绝对 VA,再加上段基址就飞到九霄云外了。
但 ELF 解析库在处理 ET_REL 时,某些 API 返回的却是绝对虚拟地址——内部走的是处理 ET_DYN/ET_EXEC 的逻辑分支。
因此需要把所有符号值和重定位偏移都显式减掉所在段的虚拟基地址,不能依赖"ET_REL 的段 VA 都是 0"的假设。
坑 3:重定位类型里藏着架构前缀
ELF 解析库(这里用的是 LIEF)在表示重定位类型时,把架构信息编码进了类型值的高位:
如果直接把 LIEF 编码的类型值写回 ELF 的 r_info 字段,接收方按标准解码会得到完全不同的数字。用掩码 0x7FFFFFF(低 27 位,对应 ELF 规范中 r_info 的低位布局)剥离架构前缀即可。
坑 4:UND 符号不要碰
内核模块引用的外部符号——_printk, kmalloc, kfree——在原文件里 shndx = 0(SHN_UNDEF)。
回顾上面的 simplify_symbols() 源码:
case SHN_UNDEF: ksym = resolve_symbol_wait(mod, info, name); // 只有 shndx==0 才走这里if (ksym && !IS_ERR(ksym)) { sym[i].st_value = kernel_symbol_value(ksym);break; }内核根据 shndx == SHN_UNDEF 来判断是否需要在全局符号表里搜索。SHN_UNDEF 的值就是 0。
在转换过程中,段的增删导致段索引需要重新映射。写映射逻辑时,很容易写出:
if (orig_shndx > 0 && orig_shndx < SHN_ABS) 映射到新段索引else shndx = SHN_ABS // ← shndx==0 落入了这个分支!shndx = 0 被改写成了 SHN_ABS(0xFFF1)。内核看到 SHN_ABS,直接走 break 分支,st_value 保持不变——对 UND 符号来说就是 0。外部调用全部悬空,内核不会去符号表里找。
教训:shndx 为 0 时必须原样保持 0。一个 if (orig_shndx == 0) 的提前判断就够。
坑 5:空名字的符号不一定是垃圾
这是最反直觉的一个坑。
写符号过滤逻辑时,很自然会跳过"名字为空、value 为 0、size 为 0"的符号。但有一种叫 STT_SECTION 的符号类型——表示"段本身"。它的名字确实是空的,value 也可以是 0,但它是重定位的重要目标。
什么时候重定位会引用STT_SECTION
.rodata.rodata 段的基址.text.text 段的基址这些重定位通过 shndx 字段关联到 STT_SECTION 符号,再由 STT_SECTION 符号的 shndx 找到目标段,最终由 simplify_symbols() 的 default 分支加上段基址。
如果 STT_SECTION 符号被当成无效条目清理掉了,引用了它的重定位条目就找不到目标,要么指向符号 0(空符号),要么符号索引越界。
教训:符号的生死不能单靠名字和 value 判断。类型为 STT_SECTION 的必须保留,并建立原始段索引到新符号索引的映射表。
坑 6:符号版本后缀
从 .so 文件提取符号时(即使最终不用 .so 做输入,这个坑也值得记),符号名可能带版本后缀:
puts@GLIBC_2.2.5__cxa_atexit@GLIBC_2.2.5这是 GNU 符号版本控制机制。内核的 resolve_symbol_wait() 做的是直接 strcmp,@GLIBC_2.2.5 这种后缀当然对不上。在构建输出符号表时,查找 @ 字符并截断就行。
坑 7:vermagic 精确匹配
从 check_modinfo() 和 same_magic() 的源码可以看出,vermagic 做的是严格字符串比对(或跳过第一个空格前缀后的比对)。vermagic 的构成由 include/linux/vermagic.h 的宏拼装决定:
// include/linux/vermagic.h#define VERMAGIC_STRING \ UTS_RELEASE " " \ MODULE_VERMAGIC_SMP MODULE_VERMAGIC_PREEMPT \ MODULE_VERMAGIC_MODULE_UNLOAD MODULE_VERMAGIC_MODVERSIONS \ MODULE_ARCH_VERMAGIC \ MODULE_RANDSTRUCT其中每个宏是否展开取决于对应的 CONFIG 选项:
CONFIG_SMP"SMP "CONFIG_PREEMPT_BUILD"preempt "CONFIG_MODULE_UNLOAD"mod_unload "CONFIG_MODVERSIONS"modversions "MODULE_ARCH_VERMAGIC"aarch64",x86_64 上是空串一个典型的 vermagic 长这样:
6.19.11+kali-amd64 SMP preempt mod_unload注意末尾的 mod_unload 后面可能有一个空格(取决于宏展开时 "mod_unload " 的尾随空格),这个空格也参与比对。
处理vermagic时不能在转换阶段猜测目标内核参数,而要从参考 .ko 的 .modinfo 段中提取完整值,再完整覆写到目标。
坑 8:struct module 的大小和布局不可预测
struct module 是内核在内存里为每个模块维护的数据结构(定义在 include/linux/module.h,几百行的巨型结构体)。它的大小和字段布局完全取决于内核编译配置:
CONFIG_MODULE_UNLOADCONFIG_SYSFSCONFIG_KALLSYMSCONFIG_TRACEPOINTS同一内核版本、不同 defconfig,sizeof(struct module) 可能差几百到上千字节。
如果一个 .ko 的 .gnu.linkonce.this_module 段大小和目标内核不一致,layout_and_allocate() 在分配模块内存时会按内核自己的 sizeof 来布局,大小对不上会导致段覆盖或越界访问。
这里直接从参考 .ko 复制整个 .gnu.linkonce.this_module 段数据。参考 .ko 本就是用这个内核的 Kbuild 编译出来的,它的 struct module 一定正确。
模块名也在这个结构体里。但名字字段的偏移同样是内核版本决定的:
不硬编码偏移。在参考的 struct module 数据里搜索参考模块自己的名字字符串,定位到名字字段,然后在那里写入新的模块名。
坑 9:.modinfo 的字段规范
.modinfo 是一个嵌入在 ELF 段里的 key=value\0 格式字符串表。内核通过 get_modinfo(info, "字段名") 查找其中的键值对,比如前面看到的 get_modinfo(info, "vermagic")。
必须包含的字段(结合 check_modinfo() 源码和内核约定):
vermagic | check_modinfo() | |
name | check_modinfo() | |
license | ||
intree | check_modinfo() | |
retpoline | check_modinfo_retpoline() | |
init | ||
cleanup |
特别需要强调:init=init_module 和 cleanup=cleanup_module 只是 modprobe 等工具的约定,内核本身不解析这两个字段来找入口函数。内核唯一找 init/exit 的途径是通过 struct module 里的函数指针(见下一个坑)。
坑 10:init_module 为什么没有被调用 —— 核心坑
这是花了最长时间 debug 的问题。
现象:模块加载成功(insmod 返回 0),卸载也成功,没有错误日志。但 init 函数里的代码就是没执行。把 init 的返回值改成 -1,加载居然还是成功——说明 init 根本没被调用到。
回看 do_init_module() 的源码:
// kernel/module.cif (mod->init != NULL) ret = do_one_initcall(mod->init);if (ret < 0) {goto fail_free_freeinit;}mod->init 的值是从哪里来的?不是符号表查找,不是 .modinfo 的 init= 字段。是 apply_relocations() 在处理 .rela.gnu.linkonce.this_module 段时填入的。
在处理重定位时,内核遍历所有 SHT_RELA 段,遇到 .rela.gnu.linkonce.this_module,执行类似以下操作:
r_offset 0x138: 将符号 init_module 的绝对地址写入 .gnu.linkonce.this_module + 0x138r_offset 0x4c0: 将符号 cleanup_module 的绝对地址写入 .gnu.linkonce.this_module + 0x4c0这两个偏移量(0x138 和 0x4c0)正是 struct module 内部 init/exit 函数指针的偏移位置。
真实 .ko 的 .rela.gnu.linkonce.this_module 段内容示例(x86_64 Linux 6.19):
Offset Type Symbol0x0138 R_X86_64_64 init_module0x04c0 R_X86_64_64 cleanup_moduleARM64安卓5.10上的真实数据:
Offset Type Symbol0x0190 R_AARCH64_ABS64 init_module0x03c0 R_AARCH64_ABS64 cleanup_module不同架构、不同内核版本的偏移量不同。但原理一样:内核靠重定位把函数指针填进 struct module,不是靠名字查找。
所以必须在转换阶段生成 .rela.gnu.linkonce.this_module 段,包含指向 init_module 和 cleanup_module 的两条重定位。偏移量先用已知值初始化(如 x86_64 用 0x138/0x4c0,ARM64 用 0x190/0x3c0),然后在目标修补阶段从参考 .ko 的同名重定位段中提取实际偏移,不匹配就修正。
如果没有这个段或其偏移量是错的,mod->init 就是 NULL,内核静默跳过 init,不报任何错误。
坑 11:重定位目标段的判定与重新索引
重定位条目的 r_offset 标记了"在目标段偏移处写入修正值"。但重定位条目本身需要被分组归属到不同的目标段。
可靠的做法是分两级查找:
r_offset(绝对 VA)去匹配所有保留段的 VA 范围,找到包含它的段如果两步都找不到目标段——比如重定位指向的是已经删掉的段——就跳过,不写入。
每条重定位的 r_info 字段需要重新计算:高 32 位填入符号在新符号表中的索引(不是原始索引),低 32 位填入剥离架构前缀后的重定位类型。
坑 12:重定位段命名和 ELF 结构
输出的重定位段命名规则:.rela + 目标段名。比如目标段是 .gnu.linkonce.this_module,重定位段就是 .rela.gnu.linkonce.this_module。目标段是 .text,重定位段就是 .rela.text。
每个 SHT_RELA 段的 ELF 段头中:
sh_link.symtab(符号表段索引)sh_infosh_typeSHT_RELA(或 SHT_REL,取决于架构)apply_relocations() 遍历段时依赖 sh_info 找到目标段、sh_link 找到符号表。这两个索引写错一个,内核要么找不到目标段(跳过)、要么读到错误的符号表(重定位算错)。
坑 13:ARM64 的特殊性总结
ARM64 内核模块有一个 x86_64 没有的硬性段依赖。从 ARM64 的 module_frob_arch_sections() 源码可以直接看到:.plt 和 .init.plt 缺一不可,查找不到直接返回 -ENOEXEC。
另外,在 ARM64 的 module.lds.h 链接脚本中,这三个特殊段在默认链接布局中就必须存在。如果模块不是走内核 Kbuild 编译的(比如我们),必须手搓这三个段。
其他 ARM64 差异:
aarch64 后缀_printk 而非 printk.o可以转.ko,.so不行。elf_validity_check() 第一行就检查 e_type != ET_REL,.so 是 ET_DYN,连门都进不去。用 gcc -c -fPIC 编译出的 .o 转 .ko 是最干净的路。
不要猜测目标内核的任何参数。vermagic 的拼装受十几个 CONFIG 宏控制,struct module 的布局受几十个 CONFIG 影响。从目标设备上已有的 .ko 提取,比自己猜准确得多。
让内核调用 init 的唯一途径是 .rela.gnu.linkonce.this_module 里的重定位。别被 .modinfo 里的 init= 误导——那是 modprobe 看的。内核在 apply_relocations() 中填充 mod->init,在 do_init_module() 中调用。重定位是唯一的数据通道。
空名字符号不一定是垃圾。STT_SECTION 符号名空但被重定位引用。删了它,段基址引用全错。
UND 符号的 shndx 必须是 0。内核在 simplify_symbols() 里用 case SHN_UNDEF 分发。改成 SHN_ABS 就不会去符号表搜索了。
ET_REL 里所有地址都是段相对的。库返回的绝对 VA 不能直接用,全部减掉段基址。内核在 simplify_symbols() 的 default 分支做 st_value + secbase,它假设 st_value 是段偏移,不是绝对值。
重定位类型编码有坑。LIEF 在标准类型值上加了架构前缀(x86_64 加 0x80000000,ARM64 加 0x100),写回前用 & 0x7FFFFFF 剥掉。
struct module 的名字字段偏移不要硬编码。用参考模块名在参考 struct 数据里搜索定位。
ARM64 多三个必需段。.plt、.init.plt、.text.ftrace_trampoline,缺一个 module_frob_arch_sections() 就报错。不是可选项。
修补工具必须尽可能是零依赖的。目标设备可能没有 libstdc++、没有 Python、没有 cmake。纯 C + elf.h,一个 C 编译器就能跑,才能真正做到"放到任何设备上都能用"。
基础 ELF 转换只能让模块在格式上被内核接受。真正推到 ARM64 安卓GKI设备后,还要继续面对 SELinux、vermagic、BTI、PAC、SCS、CFI 以及厂商驱动对 section 布局的额外假设。
完成基础 ELF 转换后,我们得到了一份格式上合法的 .ko 文件,并在 x86 Kali 环境中成功实现加载。
信心满满地推送到目标设备:
结果 insmod 直接甩回来一个让人摸不着头脑的错误。
坑 14:SELinux —— “File exists” 背后的权限墙
$ insmod /data/local/tmp/test.koinsmod: can't insert 'test.ko': File exists文件明明存在,且可读。ls -l 检查权限正常。dmesg 没有任何输出。strace insmod 到关键系统调用时直接返回:
-1 EEXIST这个问题与 ELF 格式无关,完全是 Android 安全机制在起作用。
Android 对 /data/local/tmp/ 目录应用了严格的 SELinux 文件上下文限制。
insmod 在执行 finit_module() 系统调用之前,需要访问 .ko 文件,而 SELinux 在这种情况下会拒绝访问。
为了不暴露攻击面信息,Android 的 SELinux 错误码映射策略将本该返回的 EACCESS 转换成了 EEXIST —— 一个经典的反侦察设计。
即使你已经 su 到 root,SELinux 仍会介入。因为 root 用户的行为也受安全策略约束。
临时关闭 SELinux 是验证阶段最快的途径:
setenforce 0之后 insmod 得以进入内核加载流程。
生产环境可考虑将模块放置于 SELinux 豁免路径,例如:
/vendor/lib/modules/坑 15:Exec format error —— 跨平台的 vermagic 陷阱
在 Kali Linux x86_64 上验证通过的 .ko,推送到 ARM64安卓设备后:
$ insmod /data/local/tmp/test.koinsmod: can't insert 'test.ko': Exec format errordmesg 输出 vermagic 不匹配。
但我们明明已经从参考 .ko 中提取了 vermagic 并覆写,为什么还是不对?
内核的 vermagic 校验由 same_magic() 完成:
staticinlineintsame_magic(constchar *amagic, constchar *bmagic,bool has_crcs){if (has_crcs) { amagic += strcspn(amagic, " "); bmagic += strcspn(bmagic, " "); }returnstrcmp(amagic, bmagic) == 0;}关键逻辑如下:
如果模块不包含 __versions 段,即 has_crcs == false→ 完整字符串比对,版本号和后缀都参与。
如果模块包含 __versions 段,即 has_crcs == true→ 跳过第一个空格前缀,也就是版本号,只比对后缀。
我们的 .ko 是带 __versions 段的,所以理论上版本号不同没关系。
但问题在于 Kali 和 Android 的后缀不同:
SMP preempt mod_unload | |
SMP preempt mod_unload aarch64 |
Android 多了 aarch64 架构标识。
后缀不匹配,strcmp 直接失败,内核返回 -ENOEXEC,用户空间显示:
Exec format error至于为什么修补没生效 —— 那是开发流程问题:fixup_ko 的编译产物是旧的,还没包含 vermagic 覆写逻辑。重新编译即可。
带 __versions 的模块,内核不关心版本号前缀,但后缀必须精确匹配。
后缀由十几个 CONFIG_ 宏拼装而成,包含:
跨设备、跨架构时,必须从目标设备的参考 .ko 中完整提取后缀,不能复用开发机的值。
越过 SELinux 和 vermagic 之后,模块终于进入了内核加载器的内部路径。
但迎接它的是更底层的 ARM64 安全机制 —— 这些特性由 CPU 和编译器联合强制执行。
坑 16:BTI —— 间接跳转的守门人
BTI 全称为 Branch Target Identification。
insmod 正常返回 0,但设备紧跟着直接重启。
从 pstore 中提取的崩溃日志显示,CPU 在 init_module 入口处触发了:
Target Branch ExceptionARMv8.5 引入了 BTI 硬件强制保护。
当一个间接跳转发生时,例如:
BR xNBLR xNCPU 会检查目标地址的第一条指令是否为合法的 BTI 着陆指令。
常见 BTI 着陆指令包括:
bti c | |
bti j |
若目标地址没有合法 BTI 指令,CPU 会立即触发异常。
内核通过页表中的 GP,也就是 Guarded Page 位控制 BTI 的使能。
当开启:
CONFIG_ARM64_BTI_KERNEL=ymodule_enable_text_rox() 在设置模块 .text 段为可执行时,会顺便通过 PTE_MAYBE_GP 设置 GP 位:
// arch/arm64/mm/pageattr.cintset_memory_x(unsignedlong addr, int numpages){return change_memory_common(addr, numpages, __pgprot(PTE_MAYBE_GP), // 设置 GP 位 __pgprot(PTE_PXN));}这意味着一旦 GP 位置位,该段任何间接跳转的目标都必须经过 BTI 校验。
内核调用模块初始化函数是通过函数指针完成的:
mod->init()这是一个间接调用。
我们的测试代码使用普通 NDK Clang 编译,默认不生成 BTI 着陆指令。
在 GP 位打开的情况下,CPU 发现 init_module 的头指令不是:
bti c于是产生 Target Branch Exception,最终导致 kernel panic。
必须告诉编译器生成 BTI 兼容代码:
clang -mbranch-protection=standard -c test.c -o test.o-mbranch-protection=standard 会在每个可被间接调用的函数开头生成:
bti c同时它还会启用 PAC,也就是下一个坑的主角。
坑 17:PAC —— 返回地址的签名校验
PAC 全称为 Pointer Authentication。
BTI 修复后,设备依然在加载模块时重启。
这次崩溃点发生在函数返回时,而非入口。
ARMv8.3 引入了 PAC,用于保护函数返回地址的完整性。
在函数入口,用栈指针 SP 作为 modifier 对返回地址 LR 进行签名:
paciasp在函数出口,再用:
autiasp验证签名。
若签名不通过,则触发 Authentication Fault,直接终止执行。
内核开启:
CONFIG_ARM64_PTR_AUTH_KERNEL=y后,所有内核代码都使用 PAC。
模块代码如果不同步启用 PAC,就会出现这样的场景:
LR 已被内核签名。ret 时若没有 autiasp,CPU 直接放行返回地址,但内核调用者期望 PAC 状态一致。关键约束是:模块与内核的 PAC 密钥必须一致。
这个一致性通过都使用同一编译选项生成相同的指令序列来保证。
与 BTI 完全相同:
clang -mbranch-protection=standard -c test.c -o test.o-mbranch-protection=standard 同时生成 BTI 和 PAC 指令,一根编译选项解决两者。
坑 18:SCS —— 已悄悄绕过的幸运坑
SCS 全称为 Shadow Call Stack。
当开启:
CONFIG_SHADOW_CALL_STACK=y内核会启用影子调用栈。
SCS 使用 x18 寄存器保存一个独立于正常栈的返回地址链。
在静态 SCS 实现下,也就是:
CONFIG_DYNAMIC_SCS 未设置所有内核代码都必须遵守 SCS 规约,即不能随意使用 x18 寄存器。
任何对 x18 的写操作都会破坏影子调用栈,导致诡异的返回地址错误。
由于 KPM 编译时使用了与内核兼容的 Clang,并传递了:
-fsanitize=shadow-call-stack该选项会隐式启用 SCS 指令生成,因此这个坑被自动绕过。
但还需注意:
若以后使用手写汇编,必须保留
x18作为 SCS 指针的约定,否则会一脚踩进去。
坑 19:CFI —— 本次战斗的核心战役
CFI 全称为 Control Flow Integrity。
BTI 和 PAC 两关打通后,模块加载仍导致重启。
pstore 日志显示:
CFI failure at init_module+0x0/0xc [test](target: init_module+0x0/0xc [test]; expected type: 0x00000000)我们终于触碰到了安卓GKI最核心的安全机制:CFI。背景:kCFI 还是 CFI_ICALL?内核 Makefile 中声明使用:
# Makefileifdef CONFIG_CFI_CLANGCC_FLAGS_CFI := -fsanitize=kcfikCFI 的原理是每个可间接调用函数的入口前 4 字节保存一个类型哈希值。调用方在进行间接调用前,会检查:
*(target - 4)是否与期望的哈希值匹配。
不匹配则执行 BRK 指令陷入内核,最终导致 panic。逻辑上,只要我们用同样的编译器、同样的标志编译模块,生成的哈希就能匹配。但事实并非如此。我们从目标设备提取了一个正常工作的参考模块 asix.ko,分析发现:
0x00000000,没有 kCFI 哈希。__cfi_check 函数,大小超过 1700 字节。.cfi_jt 跳转表。这印证了一个关键事实:
GKI 预编译模块实际使用的是 CFI_ICALL,也就是 UBSan 风格的 CFI,而非 kCFI。
Makefile 的声明与预编译模块的实际行为并不一致。
第一次尝试:无 CFI,直接崩溃
即使知道内核可能是 CFI_ICALL,我们仍先用标准编译试试水。
结果如前所述:
CFI failure at init_module内核 panic。
第二次尝试:kCFI 哈希,哈希不匹配
改用:
-fsanitize=kcfi编译后,错误变成:
CFI failure at init_module+0x0/0x2c [test_kcfi](target: init_module+0x0/0x2c [test_kcfi]; expected type: 0x36b1c5a6)我们生成的哈希值是:
0x36b1c5a6但内核期望的是 AOSP 预编译模块所用的哈希。
不同版本的 Clang,对同一函数原型生成的 CFI 哈希不同。
开发机上的 Clang 与 AOSP 构建内核时的 Clang 版本不一致,哈希体系不兼容,因此 kCFI 这条路也走不通。
第三次尝试:CFI_ICALL + LTO,加载成功!但……
根据 asix.ko 的格式特征,我们转向 CFI_ICALL。
CFI_ICALL 是 Clang 的:
-fsanitize=cfi实现,依赖 LTO,也就是链接时优化,来生成跨编译单元的类型检查。
编译命令:
clang -flto=thin -fsanitize=cfi -fsanitize-cfi-cross-dso \ -fvisibility=hidden -mbranch-protection=standard \ -c test.c -o test.o之后用 clang -r 将 LLVM bitcode 链接为 ELF relocatable 文件。
关键参数如下:
-flto=thin | |
-fsanitize=cfi | |
-fsanitize-cfi-cross-dso | |
-fvisibility=hidden | |
-mbranch-protection=standard |
这样生成的模块内部结构包括:
__cfi_check 函数接收 (地址, 类型哈希),验证该地址是否属于某个合法间接调用目标。
.cfi_jt 跳转表为每个地址可被间接调用的函数生成一个 8 字节 CFI 桩,例如:
bti cb function_name.cfi此时 init_module 符号指向这个桩,真正的代码在:
init_module.cfi这一次,模块加载成功:
test_cfi: no symbol version for printkcalling init_module+0x0/0x8 [test_cfi] @ 5515HelloWorld: KPatcher ARM64 module loaded!initcall init_module+0x0/0x8 [test_cfi] returned 0 after 6 usecs注意:
init_module+0x0/0x8其中 0x8 表示 init_module 的大小是 8 字节,而非实际代码大小。
这证实了它指向的是跳转表桩。
然而,胜利的喜悦只持续了几秒钟 —— 手机卡死了。
坑 20:mrdump 崩溃 —— 手机卡死的真正根因
insmod 命令在内核中阻塞,手机完全无响应:
恢复后,dmesg 里出现了令人意外的崩溃:
[ 980.921362] initcall init_module+0x0/0x8 [test_cfi] returned 0 after 4 usecs[ 980.921373] Unable to handle kernel NULL pointer dereference at virtual address 0000000000000008[ 980.948712] pc : load_ko_addr_list+0x148/0x294 [mrdump][ 980.949203] mrdump_module_callback+0x24/0x44 [mrdump][ 980.949206] blocking_notifier_call_chain+0x7c/0x100[ 980.949210] do_init_module+0x74/0x410崩溃不在我们的代码里,而在一个叫 mrdump 的驱动中。
完整调用链如下。
梳理出来的调用链如下:
sys_finit_module() → load_module() → do_init_module() → do_one_initcall(mod->init) // ← init_module 成功返回 0 ✓ → mod->state = MODULE_STATE_LIVE → blocking_notifier_call_chain( &module_notify_list, MODULE_STATE_LIVE, mod) // 通知所有关心模块状态变化的回调 → mrdump_module_callback() // MediaTek mrdump 驱动的回调 → load_ko_addr_list() // ← 空指针解引用!崩溃!问题出在 CFI_ICALL 编译所产生的 ELF section 布局上。
clang -r 将 LLVM bitcode 转换为 ELF 时,生成了一堆非标准的 section 名称:
.text | 0 | |
.text.__cfi_check | __cfi_check | 0x101c |
.text..L.cfi.jumptable | cleanup_module | 8 |
.text..L.cfi.jumptable.1 | init_module | 8 |
.text.init_module.cfi | 0x28 | |
.text.cleanup_module.cfi | 0x10 | |
…… | …… | …… |
核心问题是:
.text段大小为 0,所有实际代码分散在.text.*子段中。
MediaTek 的 mrdump 驱动注册了模块状态通知回调。
当模块变为:
MODULE_STATE_LIVE时,blocking_notifier_call_chain() 会调用到:
mrdump_module_callback()后者再调用:
load_ko_addr_list()这个函数会遍历模块的 ELF section 表,遇到一个大小为 0 的 .text 段,以及大量非标准的 .text.* 子段。
其中一个查找操作返回 NULL 后未做空检查,直接解引用访问结构体成员,也就是偏移 0x8,导致空指针崩溃:
NULL pointer dereference at virtual address 0000000000000008为什么内核本身不受影响?
内核的模块加载器在处理 section 时,主要基于 ELF Flags 分类,而不是根据名称。
例如:
// 部分简化逻辑if (sh_flags & SHF_EXECINSTR) {// 这个 section 是代码,放入 MOD_TEXT 区域}因此 .text.__cfi_check 虽然名字非标准,但因为其 Flags 包含:
SHF_EXECINSTR内核仍能正确将其归入代码区域。在内存权限设置等环节,内核不依赖具体名称,所以兼容了这些非标准名字。但 mrdump 这样的第三方驱动就没有这么健壮,它很可能针对标准 section 名做了硬编码假设。
为什么手机是“卡死”而非“重启”?blocking_notifier_call_chain() 的实现是阻塞式的,且持有 module_mutex。当回调中发生 Oops 时:
mrdumpmodule_mutex 一直被持有。这不同于 kernel panic。
BUG() 会让 CPU 停摆,而这里更像是一次 Oops —— 内核最终还能继续运行,所以手机在数分钟后得以恢复。
必须将所有 .text.* 子段合并回标准的 .text 段。
使用 linker script 在 clang -r 阶段完成合并:
/* merge_text.lds */SECTIONS{ .text : { *(.text) *(.text.*) } .init.text : { *(.init.text) } .exit.text : { *(.exit.text) } .rodata : { *(.rodata*) } .data : { *(.data*) } .bss : { *(.bss*) } .rela.text : { *(.rela.text) *(.rela.text.*) } .gnu.linkonce.this_module : { *(.gnu.linkonce.this_module) } .init.plt : { *(.init.plt) } /* ... 其余辅助段 ... */}重新链接:
clang -r -target aarch64-linux-gnu \ -Wl,-T,merge_text.lds \ test.o -o test_merged.o得到的模块 section 布局恢复标准:
.text | __cfi_check、CFI 桩、init、cleanup |
.rodata | |
.gnu.linkonce.this_module | struct module |
.modinfo | |
…… | …… |
再次加载:
$ insmod /data/local/tmp/test_cfi_fixed.koINSMOD_SUCCESS秒级返回,无卡死。
查看日志:
$ dmesg | grep test_cfi输出:
test_cfi: no symbol version for printkcalling init_module+0x0/0x8 [test_cfi] @ 5515HelloWorld: KPatcher ARM64 module loaded!initcall init_module+0x0/0x8 [test_cfi] returned 0 after 6 usecs无 mrdump 崩溃,无 Oops,一切正常。
通过上面的排查,格式正确的.ko已经可以在 ARM64 安卓GKI设备上正常加载和卸载。
总结一下这 7 个坑带来的核心教训:
SELinux 会拦截 insmod,并返回极具误导性的 File exists。必须关闭 SELinux 或使用豁免路径。
Vermagic 必须精确匹配。修补时还要注意工具本身是否更新到位。
BTI 强制要求间接跳转目标为 BTI 着陆指令。启用 -mbranch-protection=standard 即可。
PAC 强制要求返回地址签名验证。它与 BTI 共享同一编译标志。
CFI 是核心挑战。Makefile 声明 kCFI,但 GKI 预编译模块实际使用 CFI_ICALL。应以参考模块的二进制特征为准。
CFI_ICALL 会产生非标准 ELF section 名。典型表现是空的 .text,代码散落在 .text.* 中,这会导致厂商驱动在遍历 section 时空指针崩溃。
用Linker Script合并section名,才能同时兼容内核和第三方驱动的预期。
至此,模块终于能在 ARM64 安卓GKI设备上安全、正常地加载和卸载。
当基础格式和安全机制都打通后,还会面临一个更根本的问题:是否必须依赖内核原生的模块加载器?
如果希望在目标设备运行时动态接收、解析、重定位并执行受控二进制,就必须自己实现一个运行在内核空间的迷你加载器。下面记录自研 KPM Loader 从设计到落地过程中遇到的关键问题。原始调试过程中一共记录了第 21 到第 39 个坑,经过复盘后,将其中被后续方案完全覆盖的临时坑合并,最终整理为第 21 到第 35 个核心坑。
KPatcher 的离线转换方案解决了“生成合法 .ko”的问题,但它有一个根本局限:
所有 ELF 转换、section 修补、符号处理和重定位修复都必须在开发机上完成。
如果希望目标设备在运行时直接接收二进制、完成解析、重定位和执行,就需要一个运行在内核空间的加载器。
KPM Loader 应运而生。
它是一个独立内核模块,通过 /proc 接口接收一种自定义格式的二进制,称为 KPM,并在内核空间完成:
本质上,这就是一个迷你的内核模块加载器。
内核原生加载器走过的每一步:
我们都要自己实现一遍。
而那些在内核加载器里被成熟代码处理好的细节,自己动手时全都变成了坑。
坑 21:kallsyms_lookup_name 地址的 KASLR 时效性
每次都按流程获取 kallsyms_lookup_name 地址:
insmod loader.ko kaddr=0x...模块可以加载成功。但有时前一秒还能正常工作的模块,下一秒就崩溃。崩溃类型是:
IABT也就是 Instruction Abort。
崩溃点恰好发生在调用 kallsyms_lookup_name() 的位置。
安卓GKI内核启用了KASLR:
Kernel Address Space Layout Randomization每次设备重启后,内核符号的虚拟地址都会重新随机化。典型错误流程如下:
# 终端 1:获取地址adb shell grep kallsyms_lookup_name /proc/kallsyms# 输出:# ffffffeedaaa69b8 T kallsyms_lookup_name# 设备中途崩溃重启,KASLR 重新随机化# 终端 2:继续使用旧地址adb shell insmod loader.ko kaddr=0xffffffeedaaa69b8# 旧地址已经失效,跳转到随机位置,触发 IABT两个 adb 会话之间设备恰好发生了重启,符号地址已经变化,但 loader 仍在使用旧地址。
始终在同一次启动周期内重新获取地址,并立即加载模块。
示例:
ADDR=$(adb shell "su -c 'grep kallsyms_lookup_name /proc/kallsyms | head -1'" \ | awk '{print "0x"$1}')adb shell "su -c 'insmod /data/local/tmp/loader.ko kaddr=$ADDR'"KASLR 地址只在单次启动周期内有效。
任何崩溃、重启、软重启之后,都必须重新获取所有 kallsyms 地址。
这是动态分析内核环境的基础纪律。
坑 22:hex_to_ulong 的 0x 前缀陷阱
通过 /proc/kpm_loader 写入了正确的 kallsyms_lookup_name 地址:
echo kaddr 0xffffffeedaaa69b8 > /proc/kpm_loader但 dmesg 显示:
kallsyms_lookup_name set to 0后续所有符号解析全部失败。
自写的 hex_to_ulong() 函数没有处理 0x 前缀。
有 bug 的解析逻辑类似这样:
staticinthex_to_ulong(constchar *s, int max_len, unsignedlong *out){unsignedlong v = 0;int i;for (i = 0; i < max_len && s[i]; i++) {/* * 遇到非十六进制字符就停止。 * * 输入是 0xffffffeedaaa69b8: * s[0] = '0' * s[1] = 'x' * * 'x' 不是十六进制字符,于是循环在 i = 1 处退出。 */ }/* * 函数错误地认为 i > 0 就是解析成功, * 最终 out 被设置成 0。 */}也就是说:
0xffffffeedaaa69b8被错误解析成:
0而且函数还返回“成功”。
在解析前显式跳过 0x 或 0X 前缀:
if (s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) s += 2;十六进制解析函数必须显式处理 0x 前缀。内核日志、/proc/kallsyms 输出、用户空间脚本提取出来的地址,几乎都会保留这个前缀。如果解析器不处理它,就会解析出 0,而且很可能没有任何错误提示。
坑 23:ARM64 上 module_alloc() 返回的是不可执行内存
KPM 加载完成后调用入口函数:
kpm_init()结果触发:
IABT也就是 Instruction Abort。崩溃地址正好落在 KPM 的 .text 段中。这说明代码所在内存不可执行。在 ARM64 GKI 上,module_alloc() 返回的内存属性通常是:
PAGE_KERNEL也就是:
在 ARM64 上,不可执行由 PXN 控制:
PXN = Privileged Execute Never因此,module_alloc() 得到的内存默认不是可执行内存。这和很多 x86_64 环境不同。x86 上开发 loader 时,这类问题经常不会暴露;但在 ARM64安卓GKI上,PXN 是硬件强制的。不能一分配完就直接执行。正确流程应该是:
阶段 1:分配内存 module_alloc() 得到 RW / NX 内存阶段 2:写入内容 复制 section 解析符号 生成 PLT / GOT 应用 relocation阶段 3:切换权限 set_memory_x() 设置 text 可执行阶段 4:刷新指令缓存 flush_icache阶段 5:调用入口函数 kpm_init()示例流程:
void *base = module_alloc(total_size);/* 阶段 1:清零 */memset(base, 0, total_size);/* 阶段 2:所有写入都在 RW 阶段完成 */kpm_move_sections(mod, info, base);kpm_simplify_symbols(mod, info);kpm_build_got(mod, info);kpm_apply_relocations(mod, info);/* 阶段 3:之后才切换为可执行 */kpm_make_exec(base, total_size);/* 阶段 4:刷新 icache */kpm_flush_icache(base, total_size);/* 阶段 5:调用 KPM */call_kpm_init(mod->init, args, event, reserved);ARM64 的 PXN 是硬件级约束。不能假设 module_alloc() 返回的内存默认可执行。所有代码写入完成后,必须显式切换为可执行。
坑 24:WXN 硬件上的写入时序问题
重定位计算完全正确,但写入指令后读回仍是旧值。表现像是写操作失败,但没有明确报错。
部分 ARM64 硬件实现支持 WXN:
Write XOR Execute也就是页面不能同时拥有写权限和执行权限。错误流程如下:
kpm_alloc_exec(size) → 分配内存 → 立即 set_memory_x()kpm_move_sections() → 复制 .text 内容kpm_apply_relocations() → patch 指令问题在于:
在 relocation patching 之前,页面已经被设置成可执行。
此时如果硬件或内核策略不允许可执行页继续写入,那么后续对 .text 的写操作可能失败或行为异常。
必须严格遵循:
RW 阶段: 复制 section 应用 relocation 写 PLT / GOT / thunk 完成所有 patchingRX 阶段: 切换为可执行 刷新 icache 不再写 .text也就是:
/* RW 阶段 */base = module_alloc(size);memset(base, 0, size);kpm_move_sections(...);kpm_apply_relocations(...);kpm_generate_trampolines(...);/* RX 阶段 */set_memory_x(...);flush_icache_range(...);/* 执行阶段 */call_kpm_init(...);ARM64 上写权限和执行权限必须分阶段管理。只要 .text 进入可执行阶段,就不应该再继续 patch 它。
坑 25:GKI 符号可见性限制
编译阶段一切正常,但运行时:
kallsyms_lookup("module_alloc")返回:
NULL类似的问题还出现在:
set_memory_xset_memory_rw__flush_icache_rangeaarch64_insn_patch_text_nosync安卓GKI严格限制导出符号列表。某个符号即使存在于 /proc/kallsyms 中,也不代表普通模块可以直接引用。
常见情况是:
printk | |
kmallockfree | |
vmallocvfree | |
filp_openfilp_close | |
module_alloc | |
set_memory_x | |
set_memory_rw | |
__flush_icache_range | |
aarch64_insn_patch_text_nosync |
KPM Loader 又恰好需要这些不导出的核心函数。
在 loader 初始化阶段,通过传入的 kallsyms_lookup_name 地址统一解析,并缓存所需符号。
示例结构:
staticunsignedlong cached_module_alloc;staticunsignedlong cached_set_memory_x;staticunsignedlong cached_set_memory_rw;staticunsignedlong cached_flush_icache;staticunsignedlong cached_insn_patch;staticconstchar *needed_syms[] = {"module_alloc","set_memory_x","set_memory_rw","__flush_icache_range","aarch64_insn_patch_text_nosync",NULL};初始化时统一解析:
for (int i = 0; needed_syms[i]; i++) {unsignedlong addr = kallsyms_lookup(needed_syms[i]);if (!addr) { pr_warn("kpm_loader: symbol %s not found\n", needed_syms[i]);continue; }/* * 根据符号名缓存到对应变量。 */}不要假设某个内核符号在 GKI 上一定导出。
写内核 loader 需要的关键函数,往往恰好不在 GKI 对外导出列表中。
坑 26:AArch64 重定位类型常量整体偏移一位
KPM 中的:
printk("kpm_test: init called\n");输出了乱码,而不是正常字符串。
进一步调试发现,ADRP 指令编码异常:
正确值:0xB0000000实际值:0x9001FF00对比:
正确: 0xB0000000 = adrp x0, #0x1000 指向 rodata 页面实际: 0x9001FF00 = adrp x0, #0x7FC000 指向约 8MB 外的随机页面但重定位数学本身是对的:
val = mod->start + 0x1000place = mod->start + 0x8sval = 0x1000imm = 1理论上 ADRP 应该被编码成:
0xB0000000但实际却变成了:
0x9001FF00loader 中定义的 AArch64 relocation type 常量,从 MOVW_PREL_G0 开始整体偏移了一位。
错误表大致如下:
R_AARCH64_MOVW_PREL_G0 | 0x112 | 0x111 |
R_AARCH64_MOVW_PREL_G0_NC | 0x113 | 0x112 |
R_AARCH64_ADR_PREL_PG_HI21 | 0x114 | 0x113 |
R_AARCH64_ADR_PREL_PG_HI21_NC | 0x115 | 0x114 |
R_AARCH64_ADD_ABS_LO12_NC | 0x116 | 0x115 |
R_AARCH64_LDST8_ABS_LO12_NC | 0x117 | 0x116 |
R_AARCH64_TSTBR14 | 0x118 | 0x117 |
R_AARCH64_CONDBR19 | 0x119 | 0x118 |
这导致真实的 ADRP relocation 被错误匹配到了 MOVW relocation 分支。
最终出现这样的污染路径:
真实 relocation: type = 275 含义 = R_AARCH64_ADR_PREL_PG_HI21错误匹配: type 275 被当成 MOVW_PREL_G0_NC结果: 用 MOVW 编码逻辑 patch ADRP 指令于是:
原始 ADRP 指令 = 0x90000000错误 imm = 0xFF8错误编码结果 = 0x9001FF00严格按照 ARM ELF 规范修正 relocation 常量:
#define R_AARCH64_MOVW_PREL_G0 0x111#define R_AARCH64_MOVW_PREL_G0_NC 0x112#define R_AARCH64_ADR_PREL_PG_HI21 0x113#define R_AARCH64_ADR_PREL_PG_HI21_NC 0x114#define R_AARCH64_ADD_ABS_LO12_NC 0x115#define R_AARCH64_LDST8_ABS_LO12_NC 0x116#define R_AARCH64_TSTBR14 0x117#define R_AARCH64_CONDBR19 0x118修复后:
ADRP: new_insn=b0000000kpm_test: init calledELF relocation type 常量不能凭记忆手写。必须和权威来源交叉验证,例如:
include/elf/aarch64.h一个常量错位,会导致后续多个 relocation 类型互相顶替,症状非常隐蔽。
坑 27:ET_REL 非 SHF_ALLOC 段的 sh_addr 必须手动设置
重定位引擎处理 .rela.text 等 relocation section 时,需要访问:
sechdrs[].sh_addr但对于:
.symtab.strtab.rela.text.rela.data这些非 SHF_ALLOC 段,sh_addr 仍然是 0。结果访问空地址,触发内核 Oops。对于 ET_REL 文件,section header 中的 sh_addr 通常是 0。
因为它还没有被最终链接,也没有运行时地址。内核原生 module loader 会在加载过程中设置 section 的运行时地址。但自己写 loader 时,这一步需要手动完成。尤其要注意:重定位处理不仅需要访问 SHF_ALLOC 段,也需要访问非 SHF_ALLOC 的符号表、字符串表和 relocation 表。
例如:
Elf_Sym *symtab = (Elf_Sym *)sechdrs[symindex].sh_addr;char *strtab = (char *)sechdrs[strindex].sh_addr;Elf_Rela *rela = (Elf_Rela *)sechdrs[relsec].sh_addr;如果这些 sh_addr 没有设置,就会访问 NULL。在 loader setup 阶段,对所有非 SHF_ALLOC 段设置 sh_addr,让它们指向文件缓冲区中的原始位置:
for (unsignedint i = 0; i < shnum; i++) {if (!(info->sechdrs[i].sh_flags & SHF_ALLOC)) { info->sechdrs[i].sh_addr = (unsignedlong)info->hdr + info->sechdrs[i].sh_offset; }}ET_REL 文件中的 sh_addr 不能直接相信。自己写 loader 时,非 ALLOC 段也必须拥有一个可访问的内存地址。否则符号表、字符串表、relocation 表都会在运行时访问失败。
坑 28:字符串指针转换的 section 偏移陷阱
KPM 加载成功,但 dmesg 显示:
loading module '' version ''模块名和版本号都是空字符串。但检查 KPM 文件中的 .kpm.info 段,字符串明明存在。在 ELF 文件解析阶段,KPM 的元数据指针:
nameversionlicense都指向文件缓冲区中的 .kpm.info 段。当 KPM 被搬迁到运行时内存后,需要把这些指针从“文件地址”转换成“运行时地址”。
错误写法是:
mod->info.name = info->name - (constchar *)info->hdr + mod->info.base;这个公式的问题是它以整个 ELF 文件头作为基准,而不是以 .kpm.info 段起始地址作为基准。实际数据流类似:
info->name = hdr + section_offset + string_offsetinfo->hdr = hdrmod->info.base = runtime 中 .kpm.info 段地址错误公式会把 section_offset 也加进运行时地址里,导致最终指针偏移过头。必须以 .kpm.info 段在文件中的起始地址为基准:
unsignedlong info_sec_offset = info->sechdrs[info->info_idx].sh_offset;constchar *info_file_base = (constchar *)info->hdr + info_sec_offset;mod->info.name = info->name - info_file_base + mod->info.base;mod->info.version = info->version - info_file_base + mod->info.base;mod->info.license = info->license - info_file_base + mod->info.base;文件地址转换成运行时地址时,减法基准必须正确。如果指针指向 section 内部,就必须减去:
hdr + section_offset而不是只减去:
hdr坑 29:CFI 保护代码调用非 CFI KPM 的边界问题
KPM 加载流程全部完成,section 搬迁和 relocation 都正确。但在调用:
kpm_init()时设备静默重启。最后一条日志停在:
icache flushed, about to call KPM init之后没有更多 dmesg。KPM Loader 自身是用 CFI_ICALL + LTO 编译的。这意味着 loader 中通过函数指针发起的间接调用,会被编译器插入 CFI 检查。而 KPM 二进制没有使用相同的 CFI 体系编译。于是当 loader 调用:
fn(args, event, reserved);时,编译器会插入类型检查:
检查目标函数是否拥有匹配的 CFI 类型信息但 KPM 的入口函数没有对应的 CFI 信息,于是触发 CFI failure。更复杂的是,CFI 问题并不只存在于:
loader → KPM这一条路径。还会存在于:
KPM → KPM 内部函数指针内核 → KPM 注册的回调KPM thunk / trampoline → 非标准代码页因此,单独处理某一个调用点是不够的。这类边界不能靠单点绕过,处理上需要覆盖两层。对 loader 主动调用 KPM 的入口函数,使用桥接函数并在桥接函数上关闭 CFI 检查:
__attribute__((no_sanitize("cfi")))staticlongcall_kpm_init(kpm_initcall_t fn,constchar *args,constchar *event,void *reserved){return fn(args, event, reserved);}__attribute__((no_sanitize("cfi")))staticlongcall_kpm_exit(kpm_exitcall_t fn, void *reserved){return fn(reserved);}这解决的是:
loader → KPM这一条直接调用路径。同时,对 KPM 代码区、hook 区、thunk 区、trampoline 区等额外分配的可执行代码页建立统一的区域追踪机制。
抽象逻辑如下:
staticboolis_kpm_exec_area(unsignedlong addr){return is_kpm_area(addr) || is_hook_area(addr) || is_thunk_area(addr) || is_trampoline_area(addr);}最终判断原则是:
所有非内核原生构建体系生成的可执行代码页,都必须被 loader 明确登记和识别。
这样才能避免某些间接调用路径仍然落回内核 CFI 的默认失败路径。CFI 不是只在“调用入口函数”时才会触发。只要存在函数指针、回调、trampoline、thunk,就可能进入 CFI 检查路径。因此 CFI 适配必须从“单点修复”升级为“可执行区域治理”。
坑 30:KPM 入口函数必须有 BTI 着陆指令
CFI 边界处理后,崩溃转移到:
kpm_init函数入口。
pstore 日志显示:
Target Branch ExceptionARM64 BTI 要求间接跳转目标地址的第一条指令必须是合法的 BTI landing pad。KPM Loader 通过函数指针调用:
mod->init()这是一个间接调用。因此 CPU 会检查 kpm_init 第一条指令是否为合法 BTI 指令。如果 KPM 是用普通汇编或未启用 BTI 的编译器选项生成的,那么函数入口没有:
bti c就会触发 Target Branch Exception。如果 KPM 用汇编写,入口函数需要显式添加 BTI landing pad:
kpm_init: hint #34 // bti c stp x29, x30, [sp, #-16]! ...kpm_exit: hint #34 // bti c stp x29, x30, [sp, #-16]! ...如果 KPM 用 C 编写,则使用:
-mbranch-protection=standard只要函数是通过函数指针、回调或 thunk 间接进入的,它就必须满足 BTI 要求。KPM 是否是“模块内部代码”并不重要。从 CPU 视角看,它只是一个间接跳转目标。
坑 31:vfree() 需要原始分配地址
释放thunk时调用:
vfree(kp_thunk_addrs[idx]);触发内核警告:
Trying to vfree() bad addressthunk 分配时,实际流程类似:
mem = vmalloc(PAGE_SIZE)thunk = mem + 8kp_thunk_addrs[idx] = thunk这里把 thunk 放在 mem + 8 处,是为了避开函数入口前读取 CFI hash 时可能踩到 guard page 的问题。但 vfree() 要求传入的是:
vmalloc 返回的原始地址也就是mem,而不是mem + 8。因此直接释放 thunk 会被内核认为是非法地址。释放前恢复页起始地址:
void *page = (void *)((unsignedlong)kp_thunk_addrs[idx] & ~(PAGE_SIZE - 1));vfree(page);vfree() 必须接收 vmalloc 返回的原始指针。
任何偏移后的地址都不能直接传给 vfree()。
坑 32:只读内核数据区不能依赖 set_memory_rw()
对某些内核只读数据区执行写入时,触发:
Unable to handle kernel write to read-only memory即使提前调用了:
set_memory_rw()仍然无效。安卓GKI中,一些关键内核数据在初始化后会被标记为:
__ro_after_init这类区域通常位于内核自身的 .data / .rodata 相关映射中。
set_memory_rw() 对 vmalloc/module_alloc 这类动态映射区域更有效,但对内核线性映射或初始化后只读区域不一定能生效。
错误流程是:
set_memory_rw(target) → 实际失败或不适用直接写 target → 触发只读内存写入异常对于这类地址,必须使用架构允许的内核 patching 机制,而不是直接写。
在 ARM64 上,常见思路是通过内核提供的指令/文本 patch 接口完成临时可写映射、写入和恢复。
核心原则是:
不要假设 set_memory_rw 可以修改所有内核地址。set_memory_rw() 不是万能写权限开关。
对于 __ro_after_init 或内核自身只读映射,直接写入很容易触发 Oops。
坑 33:页边界函数入口读取 CFI hash 会踩 guard page
KPM 中某个函数地址恰好页对齐:
0x...000在读取:
*(func - 4)获取 CFI hash 时,触发:
do_translation_faultvmalloc/module_alloc 区域前后可能存在 guard page。
当函数入口正好位于页起始位置时:
func = page_startfunc - 4 = previous_page + PAGE_SIZE - 4而前一个页面可能是未映射的 guard page。
于是读取 func - 4 会触发页错误。
读取函数入口前 4 字节之前,必须检查页内偏移:
if ((func_addr & (PAGE_SIZE - 1)) >= 4) { u32 cfi_hash = *(u32 *)(func_addr - 4);/* * 使用 cfi_hash */}如果函数入口位于页起始位置,就不能直接读取 func - 4。
读取函数入口前的元数据时,必须考虑页边界。
尤其是 vmalloc/module_alloc 产生的区域,前后 guard page 会让 addr - 4 这种访问变得危险。
坑 34:GOT 双重解引用与函数指针变量的地址层级
加载复杂 KPM 时,loader 报错:
unsupported RELA type 312后续补上 GOT relocation 支持后,又出现新的崩溃:
跳转到 0x7fd503233f触发 do_translation_fault进一步反汇编发现,KPM 调用外部函数时生成了类似模式:
adrp x8, :got:symbolldr x8, [x8, #:lo12]ldr x8, [x8]blr x8也就是说,它不是直接调用 GOT 槽中的地址,而是做了两次解引用。复杂 KPM 会产生 GOT 重定位。type 312 对应 GOT 相关 relocation,例如:
R_AARCH64_LD64_GOT_LO12_NC如果 KPM 使用:
-fPIC-mcmodel=large编译,就很容易产生 GOT 访问。
因此 loader 必须支持 GOT relocation。对于某些编译模型,KPM 对外部符号的访问可能不是:
GOT 槽 = 函数地址直接 blr 函数地址而是:
GOT 槽 = 某个指针变量的地址第一次 ldr:取出指针变量地址第二次 ldr:读取指针变量的值blr:调用最终函数如果 loader 直接把函数地址填进 GOT 槽,那么第二次 ldr 就会把函数开头的机器码当成指针读取。
例如函数开头如果是 PAC 指令:
0xd503233f那么读取出来的“地址”就可能变成类似:
0x7fd503233f最终跳转到垃圾地址。
更隐蔽的一层是符号声明类型。KernelPatch 中有些外部符号被声明为函数指针变量:
externvoid(*printk)(constchar *fmt, ...);externunsignedlong(*kallsyms_lookup_name)(constchar *name);这和普通函数声明完全不同:
externvoidprintk(constchar *fmt, ...);两者语义区别如下:
extern void printk(...) | printk | |
extern void (*printk)(...) | printk |
如果声明是:
externvoid(*printk)(constchar *fmt, ...);那么KPM会生成:
先找到 printk 变量地址再读取变量中的函数地址最后调用因此 loader 不能把 printk 解析成函数地址,而应该提供一个“指针变量”:
staticvoid(*kp_printk_ptr)(constchar *fmt, ...);kp_printk_ptr = printk;/* * 注意:这里传的是变量地址,而不是函数地址。 */local_syms[PRINTK_IDX].addr = (unsignedlong)&kp_printk_ptr;同理:
staticunsignedlong(*kp_kallsyms_lookup_name_ptr)(constchar *name);kp_kallsyms_lookup_name_ptr = kp_kallsyms_lookup_name;local_syms[KALLSYMS_IDX].addr = (unsignedlong)&kp_kallsyms_lookup_name_ptr;因此 loader 需要区分三种地址层级:
对于需要包装的外部函数,可以分配一层 wrapper slot:
u64 *slot = &wrap_pool[wrap_count++];*slot = real_func_addr;/* * GOT 槽中放 slot 地址,而不是 real_func_addr。 */got_entry = (u64)slot;这样 KPM 的双重解引用链条就成立:
GOT entry → wrapper slot 地址 → wrapper slot 中保存真实函数地址 → BLR 真实函数外部符号解析不能只问“这个符号的地址是多少”。
还必须问KPM编译器认为这个符号是什么?
它可能是:
声明类型不同,编译器生成的访问模式完全不同。
loader 必须提供匹配的地址层级。
坑 35:local_syms 表与初始化代码错位
KPM 入口函数中第一个:
printk("init args=%s\n", args);就触发崩溃。
反汇编发现:
ldr x8, [x24]blr x8但 x24 并不是 printk 指针变量地址,而是另一个 local symbol 的地址,甚至可能是某个完全无关的 helper 函数。
local_syms[] 表声明和 local_syms_init() 初始化函数是两份平行维护的列表。
表声明类似:
index 21 = printkindex 22 = hook_unwrap_removeindex 23 = sp_el0_is_current...但初始化代码中却写成了:
local_syms[21].addr = (unsignedlong)&kp_sp_el0_is_current;local_syms[22].addr = (unsignedlong)&kp_thread_info_in_task;local_syms[23].addr = (unsignedlong)&kp_sp_el0_is_thread_info;从某个索引开始,表项和初始化代码整体错位。
结果就是:
KPM 想解析 printk实际拿到 sp_el0_is_current后续再叠加“函数指针变量地址层级”的问题,就会表现成非常混乱的跳转崩溃。
至少要保证表声明和初始化顺序完全一致:
local_syms[21].addr = (unsignedlong)&kp_printk_ptr;local_syms[22].addr = (unsignedlong)&kp_hook_unwrap_remove;local_syms[23].addr = (unsignedlong)&kp_sp_el0_is_current;local_syms[24].addr = (unsignedlong)&kp_thread_info_in_task;local_syms[25].addr = (unsignedlong)&kp_sp_el0_is_thread_info;local_syms[26].addr = (unsignedlong)&kp_thread_size;local_syms[27].addr = (unsignedlong)&kp_task_in_thread_info_offset;更好的方式是使用 X-Macro 或集中式表定义,避免声明和初始化分离。
例如:
#define LOCAL_SYM_TABLE(X) \ X(printk, &kp_printk_ptr) \ X(hook_unwrap_remove, kp_hook_unwrap_remove) \ X(sp_el0_is_current, kp_sp_el0_is_current) \ X(thread_info_in_task, kp_thread_info_in_task)然后用同一张表同时生成:
平行维护的列表是 bug 温床。
只要中间插入或删除一次元素,就可能造成后续所有索引整体错位。
符号表这种核心数据结构,必须尽量做到单一事实来源。
这条链路从用户态编译产物开始,先解决.o与.ko的ELF边界,再进入安卓GKI设备上的安全机制适配,最后落到KPM Loader在内核空间自行解析、重定位和执行KPM二进制。表面看是“把文件加载起来”,实际每一步都在补齐原本由Kbuild、链接器、内核模块加载器和架构代码共同承担的工作。
离线转换阶段的核心问题是格式正确性。.so是ET_DYN,.ko是ET_REL,二者不是删几个section就能互换。真正可行的路线是从ET_REL输入出发,保留必要段,重建符号表和重定位表,补齐.modinfo、.gnu.linkonce.this_module以及ARM64所需的.plt、.init.plt、.text.ftrace_trampoline。其中最关键的数据通道,是.rela.gnu.linkonce.this_module把init_module和cleanup_module写入struct module中的函数指针位置。
安卓GKI适配阶段的核心问题是执行环境正确性。SELinux可能让insmod在文件访问阶段就失败,vermagic后缀必须与目标设备匹配,BTI、PAC、SCS和CFI则要求模块代码必须符合目标内核的控制流和返回地址保护约束。即便内核本身能接受非标准section,厂商驱动也可能依赖传统.text布局,因此CFI_ICALL生成的.text.*子段还需要通过linker script合并回标准布局。
运行时Loader阶段的核心问题是把内核模块加载器的关键能力重新实现一遍。KASLR要求所有kallsyms地址只在单次启动周期内有效;module_alloc()返回的内存要先写入、重定位,再切换到可执行并刷新icache;GKI不保证导出loader需要的关键符号,只能通过已知入口间接解析;AArch64重定位常量、非ALLOC段sh_addr、section内指针转换、GOT双重解引用和local_syms表索引,都必须逐项处理。
最终收敛下来的经验很直接:不能猜目标内核参数,不能假设用户态ELF语义能套进内核,不能把地址层级和section基准混在一起,不能在ARM64上忽略RW与RX的阶段边界,也不能把CFI、BTI、PAC当成编译选项层面的附属问题。KPM Loader要稳定运行,必须同时尊重ELF格式、安卓GKI安全机制、AArch64指令语义和loader自身的数据结构一致性。
整篇文章看似是在讲把.so变成.ko,但真正贯穿其中的主题其实是:
当你试图绕开成熟的内核构建与加载体系时,原本被工具链、内核 loader、链接器和架构代码替你处理掉的细节,会一个不漏地回到你面前。
ELF格式只是第一层。真正困难的是:
内核开发的残酷在于,它很少给你清晰的错误提示。
很多时候,一个relocation常量写错、一个地址多解引用一次、一个函数入口少了 bti c,最终表现出来的都只是:
静默重启Instruction AbortTranslation FaultKernel panic但也正因如此,每一次踩坑都格外有价值。希望这篇实战记录能帮助同样在 ARM64 安卓GKI设备上做内核开发、调试和研究的人,少走一些弯路。
以上就是全文内容。本文编写完成与工具开发完成时,作者还没有关注到
KernelPatch项目,很多内容是自己实现。截止当前,有一个Android-kernel-inline-hook-framework项目,它封装了KernelPatch实现了APatch很多相关的功能,算是与本文的功能重合地方,只是没有实现KPM加载的功能。但可以关注学习一波!https://github.com/ChwnWang0/Android-kernel-inline-hook-framework
最后,感谢本文作者的分享,也期待他的重构版本工具发布!