本文是系列第三篇,也是收官之作。
前两篇讲述了如何通过 ELF 格式转换,将用户空间编译产物变成 .ko,并踩平了 Android GKI 设备上的安全特性陷阱,包括 SELinux、vermagic、BTI、PAC、CFI 和厂商驱动对 ELF section 布局的敏感问题。
当基础格式和安全机制都打通后,我们面临一个更根本的问题:
是否必须依赖内核原生的模块加载器?
如果希望在目标设备运行时动态接收、解析、重定位并执行受控二进制,就必须自己实现一个运行在内核空间的迷你加载器。
本篇记录自研内核模块加载器 KPM Loader 从设计到落地过程中遇到的关键问题。原始调试过程中一共记录了第 21 到第 39 个坑,经过复盘后,将其中被后续方案完全覆盖的临时坑合并,最终整理为第 21 到第 34 个核心坑。
KPatcher 的离线转换方案解决了“生成合法 .ko”的问题,但它有一个根本局限:
所有 ELF 转换、section 修补、符号处理和重定位修复都必须在开发机上完成。
如果希望目标设备在运行时直接接收二进制、完成解析、重定位和执行,就需要一个运行在内核空间的加载器。
KPM Loader 应运而生。
它是一个独立内核模块,通过 /proc 接口接收一种自定义格式的二进制,称为 KPM,并在内核空间完成:
本质上,这就是一个迷你的内核模块加载器。
内核原生加载器走过的每一步:
我们都要自己实现一遍。
而那些在内核加载器里被成熟代码处理好的细节,自己动手时全都变成了坑。
每次都按流程获取 kallsyms_lookup_name 地址:
模块可以加载成功。
但有时前一秒还能正常工作的模块,下一秒就崩溃。
崩溃类型是:
也就是 Instruction Abort。
崩溃点恰好发生在调用 kallsyms_lookup_name() 的位置。
Android GKI 内核启用了 KASLR:
每次设备重启后,内核符号的虚拟地址都会重新随机化。
典型错误流程如下:
两个 adb 会话之间设备恰好发生了重启,符号地址已经变化,但 loader 仍在使用旧地址。
始终在同一次启动周期内重新获取地址,并立即加载模块。
示例:
KASLR 地址只在单次启动周期内有效。
任何崩溃、重启、软重启之后,都必须重新获取所有 kallsyms 地址。
这是动态分析内核环境的基础纪律。
通过 /proc/kpm_loader 写入了正确的 kallsyms_lookup_name 地址:
但 dmesg 显示:
后续所有符号解析全部失败。
自写的 hex_to_ulong() 函数没有处理 0x 前缀。
有 bug 的解析逻辑类似这样:
也就是说:
被错误解析成:
而且函数还返回“成功”。
在解析前显式跳过 0x 或 0X 前缀:
十六进制解析函数必须显式处理 0x 前缀。
内核日志、/proc/kallsyms 输出、用户空间脚本提取出来的地址,几乎都会保留这个前缀。
如果解析器不处理它,就会解析出 0,而且很可能没有任何错误提示。
KPM 加载完成后调用入口函数:
结果触发:
也就是 Instruction Abort。
崩溃地址正好落在 KPM 的 .text 段中。
这说明代码所在内存不可执行。
在 ARM64 GKI 上,module_alloc() 返回的内存属性通常是:
也就是:
在 ARM64 上,不可执行由 PXN 控制:
因此,module_alloc() 得到的内存默认不是可执行内存。
这和很多 x86_64 环境不同。x86 上开发 loader 时,这类问题经常不会暴露;但在 ARM64 Android GKI 上,PXN 是硬件强制的。
不能一分配完就直接执行。
正确流程应该是:
示例流程:
ARM64 的 PXN 是硬件级约束。
不能假设 module_alloc() 返回的内存默认可执行。
所有代码写入完成后,必须显式切换为可执行。
重定位计算完全正确,但写入指令后读回仍是旧值。
表现像是写操作失败,但没有明确报错。
部分 ARM64 硬件实现支持 WXN:
也就是页面不能同时拥有写权限和执行权限。
错误流程如下:
问题在于:
在 relocation patching 之前,页面已经被设置成可执行。
此时如果硬件或内核策略不允许可执行页继续写入,那么后续对 .text 的写操作可能失败或行为异常。
必须严格遵循:
也就是:
ARM64 上写权限和执行权限必须分阶段管理。
只要 .text 进入可执行阶段,就不应该再继续 patch 它。
编译阶段一切正常,但运行时:
返回:
类似的问题还出现在:
Android GKI 严格限制导出符号列表。
某个符号即使存在于 /proc/kallsyms 中,也不代表普通模块可以直接引用。
常见情况是:
KPM Loader 又恰好需要这些不导出的核心函数。
在 loader 初始化阶段,通过传入的 kallsyms_lookup_name 地址统一解析,并缓存所需符号。
示例结构:
初始化时统一解析:
不要假设某个内核符号在 GKI 上一定导出。
写内核 loader 需要的关键函数,往往恰好不在 GKI 对外导出列表中。
KPM 中的:
输出了乱码,而不是正常字符串。
进一步调试发现,ADRP 指令编码异常:
对比:
但重定位数学本身是对的:
理论上 ADRP 应该被编码成:
但实际却变成了:
loader 中定义的 AArch64 relocation type 常量,从 MOVW_PREL_G0 开始整体偏移了一位。
错误表大致如下:
这导致真实的 ADRP relocation 被错误匹配到了 MOVW relocation 分支。
最终出现这样的污染路径:
于是:
严格按照 ARM ELF 规范修正 relocation 常量:
修复后:
ELF relocation type 常量不能凭记忆手写。
必须和权威来源交叉验证,例如:
一个常量错位,会导致后续多个 relocation 类型互相顶替,症状非常隐蔽。
重定位引擎处理 .rela.text 等 relocation section 时,需要访问:
但对于:
这些非 SHF_ALLOC 段,sh_addr 仍然是 0。
结果访问空地址,触发内核 Oops。
对于 ET_REL 文件,section header 中的 sh_addr 通常是 0。
因为它还没有被最终链接,也没有运行时地址。
内核原生 module loader 会在加载过程中设置 section 的运行时地址。
但自己写 loader 时,这一步需要手动完成。
尤其要注意:
重定位处理不仅需要访问 SHF_ALLOC 段,也需要访问非 SHF_ALLOC 的符号表、字符串表和 relocation 表。
例如:
如果这些 sh_addr 没有设置,就会访问 NULL。
在 loader setup 阶段,对所有非 SHF_ALLOC 段设置 sh_addr,让它们指向文件缓冲区中的原始位置:
ET_REL 文件中的 sh_addr 不能直接相信。
自己写 loader 时,非 ALLOC 段也必须拥有一个可访问的内存地址。
否则符号表、字符串表、relocation 表都会在运行时访问失败。
KPM 加载成功,但 dmesg 显示:
模块名和版本号都是空字符串。
但检查 KPM 文件中的 .kpm.info 段,字符串明明存在。
在 ELF 文件解析阶段,KPM 的元数据指针:
都指向文件缓冲区中的 .kpm.info 段。
当 KPM 被搬迁到运行时内存后,需要把这些指针从“文件地址”转换成“运行时地址”。
错误写法是:
这个公式的问题是:
它以整个 ELF 文件头作为基准,而不是以 .kpm.info 段起始地址作为基准。
实际数据流类似:
错误公式会把 section_offset 也加进运行时地址里,导致最终指针偏移过头。
必须以 .kpm.info 段在文件中的起始地址为基准:
文件地址转换成运行时地址时,减法基准必须正确。
如果指针指向 section 内部,就必须减去:
而不是只减去:
KPM 加载流程全部完成,section 搬迁和 relocation 都正确。
但在调用:
时设备静默重启。
最后一条日志停在:
之后没有更多 dmesg。
KPM Loader 自身是用 CFI_ICALL + LTO 编译的。
这意味着 loader 中通过函数指针发起的间接调用,会被编译器插入 CFI 检查。
而 KPM 二进制没有使用相同的 CFI 体系编译。
于是当 loader 调用:
时,编译器会插入类型检查:
但 KPM 的入口函数没有对应的 CFI 信息,于是触发 CFI failure。
更复杂的是,CFI 问题并不只存在于:
这一条路径。
还会存在于:
因此,单独处理某一个调用点是不够的。
最终方案需要同时覆盖两层:
对 loader 主动调用 KPM 的入口函数,使用桥接函数,并在桥接函数上关闭 CFI 检查:
这解决的是:
这一条直接调用路径。
对于 KPM 代码区、hook 区、thunk 区、trampoline 区等额外分配的可执行代码页,需要建立统一的区域追踪机制。
抽象逻辑如下:
最终判断原则是:
所有非内核原生构建体系生成的可执行代码页,都必须被 loader 明确登记和识别。
这样才能避免某些间接调用路径仍然落回内核 CFI 的默认失败路径。
CFI 不是只在“调用入口函数”时才会触发。
只要存在函数指针、回调、trampoline、thunk,就可能进入 CFI 检查路径。
因此 CFI 适配必须从“单点修复”升级为“可执行区域治理”。
CFI 边界处理后,崩溃转移到:
函数入口。
pstore 日志显示:
ARM64 BTI 要求间接跳转目标地址的第一条指令必须是合法的 BTI landing pad。
KPM Loader 通过函数指针调用:
这是一个间接调用。
因此 CPU 会检查 kpm_init 第一条指令是否为合法 BTI 指令。
如果 KPM 是用普通汇编或未启用 BTI 的编译器选项生成的,那么函数入口没有:
就会触发 Target Branch Exception。
如果 KPM 用汇编写,入口函数需要显式添加 BTI landing pad:
如果 KPM 用 C 编写,则使用:
只要函数是通过函数指针、回调或 thunk 间接进入的,它就必须满足 BTI 要求。
KPM 是否是“模块内部代码”并不重要。
从 CPU 视角看,它只是一个间接跳转目标。
释放 thunk 时调用:
触发内核警告:
thunk 分配时,实际流程类似:
这里把 thunk 放在 mem + 8 处,是为了避开函数入口前读取 CFI hash 时可能踩到 guard page 的问题。
但 vfree() 要求传入的是:
也就是:
而不是:
因此直接释放 thunk 会被内核认为是非法地址。
释放前恢复页起始地址:
vfree() 必须接收 vmalloc 返回的原始指针。
任何偏移后的地址都不能直接传给 vfree()。
对某些内核只读数据区执行写入时,触发:
即使提前调用了:
仍然无效。
Android GKI 中,一些关键内核数据在初始化后会被标记为:
这类区域通常位于内核自身的 .data / .rodata 相关映射中。
set_memory_rw() 对 vmalloc/module_alloc 这类动态映射区域更有效,但对内核线性映射或初始化后只读区域不一定能生效。
错误流程是:
对于这类地址,必须使用架构允许的内核 patching 机制,而不是直接写。
在 ARM64 上,常见思路是通过内核提供的指令/文本 patch 接口完成临时可写映射、写入和恢复。
核心原则是:
set_memory_rw() 不是万能写权限开关。
对于 __ro_after_init 或内核自身只读映射,直接写入很容易触发 Oops。
KPM 中某个函数地址恰好页对齐:
在读取:
获取 CFI hash 时,触发:
vmalloc/module_alloc 区域前后可能存在 guard page。
当函数入口正好位于页起始位置时:
而前一个页面可能是未映射的 guard page。
于是读取 func - 4 会触发页错误。
读取函数入口前 4 字节之前,必须检查页内偏移:
如果函数入口位于页起始位置,就不能直接读取 func - 4。
读取函数入口前的元数据时,必须考虑页边界。
尤其是 vmalloc/module_alloc 产生的区域,前后 guard page 会让 addr - 4 这种访问变得危险。
加载复杂 KPM 时,loader 报错:
后续补上 GOT relocation 支持后,又出现新的崩溃:
进一步反汇编发现,KPM 调用外部函数时生成了类似模式:
也就是说,它不是直接调用 GOT 槽中的地址,而是做了两次解引用。
type 312 对应 GOT 相关 relocation,例如:
如果 KPM 使用:
编译,就很容易产生 GOT 访问。
因此 loader 必须支持 GOT relocation。
对于某些编译模型,KPM 对外部符号的访问不是:
而是:
如果 loader 直接把函数地址填进 GOT 槽,那么第二次 ldr 就会把函数开头的机器码当成指针读取。
例如函数开头如果是 PAC 指令:
那么读取出来的“地址”就可能变成类似:
最终跳转到垃圾地址。
KernelPatch 中有些外部符号被声明为函数指针变量:
这和普通函数声明完全不同:
两者语义区别如下:
如果声明是:
那么 KPM 会生成:
因此 loader 不能把 printk 解析成函数地址,而应该提供一个“指针变量”:
同理:
loader 需要区分三种地址层级:
对于需要包装的外部函数,可以分配一层 wrapper slot:
这样 KPM 的双重解引用链条就成立:
外部符号解析不能只问“这个符号的地址是多少”。
还必须问:
KPM 编译器认为这个符号是什么?
它可能是:
声明类型不同,编译器生成的访问模式完全不同。
loader 必须提供匹配的地址层级。
KPM 入口函数中第一个:
就触发崩溃。
反汇编发现:
但 x24 并不是 printk 指针变量地址,而是另一个 local symbol 的地址,甚至可能是某个完全无关的 helper 函数。
local_syms[] 表声明和 local_syms_init() 初始化函数是两份平行维护的列表。
表声明类似:
但初始化代码中却写成了:
从某个索引开始,表项和初始化代码整体错位。
结果就是:
后续再叠加“函数指针变量地址层级”的问题,就会表现成非常混乱的跳转崩溃。
至少要保证表声明和初始化顺序完全一致:
更好的方式是使用 X-Macro 或集中式表定义,避免声明和初始化分离。
例如:
然后用同一张表同时生成:
平行维护的列表是 bug 温床。
只要中间插入或删除一次元素,就可能造成后续所有索引整体错位。
符号表这种核心数据结构,必须尽量做到:
经过整理后,KPM Loader 的核心坑从原始记录中的第 21 到第 39 坑,收敛为第 21 到第 35 坑。
这些问题可以归纳为六类。
KASLR 地址只在单次启动周期内有效。
设备一旦重启,所有 kallsyms 地址都必须重新获取。
内核地址字符串通常带 0x 前缀。
自写 parser 必须显式处理,否则很容易把地址解析成 0。
ARM64 上 module_alloc() 通常返回 RW / NX 内存。
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 1天前
被孤木落编辑
,原因: