首页
社区
课程
招聘
[原创]把 .o 变成 .ko(三):自己写 Loader 才知道的事
发表于: 1天前 429

[原创]把 .o 变成 .ko(三):自己写 Loader 才知道的事

1天前
429

本文是系列第三篇,也是收官之作。

前两篇讲述了如何通过 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 的解析逻辑类似这样:

也就是说:

被错误解析成:

而且函数还返回“成功”。

在解析前显式跳过 0x0X 前缀:

十六进制解析函数必须显式处理 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 内存。


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

最后于 1天前 被孤木落编辑 ,原因:
收藏
免费 4
支持
分享
最新回复 (2)
雪    币: 1076
活跃值: (69)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
2
这三篇文章写完之后,实现的成果主要有两个:
1. 一套只使用ndk的跨版本兼容内核模块开发框架
2. 一个可跨版本兼容的kpm加载器(作为kernel su元模块使用),不需要patch内核,可在越狱模式的设备上使用
1天前
0
雪    币: 1076
活跃值: (69)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
3
本篇中加载器设计主要参考安卓内核自己的模块加载器和kernel patch的kpm加载器
坑点全部出现在仿制加载器的过程之中
1天前
0
游客
登录 | 注册 方可回帖
返回