论坛首页 移动安全专区 阅读主题

[原创]把 .o 变成 .ko(二):GKI 安全特性的铁幕

397 浏览 3 回复
#1 楼主 2026-06-01 21:09:09
本文是系列第二篇。第一篇讲述了如何通过 ELF 格式转换,将用户空间编译产物变成可被内核识别的 .ko 文件,并踩平了前 13 个坑。本篇继续在 ARM64 Android GKI 设备上的实战 —— 当基础格式正确之后,真正的战斗才刚开始。完成基础 ELF 转换后,我们得到了一份格式上合法的 .ko 文件,并在 x86 Kali 环境中成功实现加载。信心满满地推送到目标设备:结果 insmod 直接甩回来一个让人摸不着头脑的错误。文件明明存在,且可读。ls -l 检查权限正常。dmesg 没有任何输出。strace insmod 到关键系统调用时直接返回:这个问题与 ELF 格式无关,完全是 Android 安全机制在起作用。Android 对 /data/local/tmp/ 目录应用了严格的 SELinux 文件上下文限制。insmod 在执行 finit_module() 系统调用之前,需要访问 .ko 文件,而 SELinux 在这种情况下会拒绝访问。为了不暴露攻击面信息,Android 的 SELinux 错误码映射策略将本该返回的 EACCESS 转换成了 EEXIST —— 一个经典的反侦察设计。即使你已经 su 到 root,SELinux 仍会介入。因为 root 用户的行为也受安全策略约束。临时关闭 SELinux 是验证阶段最快的途径:之后 insmod 得以进入内核加载流程。生产环境可考虑将模块放置于 SELinux 豁免路径,例如:在 Kali Linux x86_64 上验证通过的 .ko,推送到 ARM64 Android 设备后:dmesg 输出 vermagic 不匹配。但我们明明已经从参考 .ko 中提取了 vermagic 并覆写,为什么还是不对?内核的 vermagic 校验由 same_magic() 完成:关键逻辑如下:如果模块不包含 __versions 段,即 has_crcs == false→ 完整字符串比对,版本号和后缀都参与。如果模块包含 __versions 段,即 has_crcs == true→ 跳过第一个空格前缀,也就是版本号,只比对后缀。我们的 .ko 是带 __versions 段的,所以理论上版本号不同没关系。但问题在于 Kali 和 Android 的后缀不同:Android 多了 aarch64 架构标识。后缀不匹配,strcmp 直接失败,内核返回 -ENOEXEC,用户空间显示:至于为什么修补没生效 —— 那是开发流程问题:fixup_ko 的编译产物是旧的,还没包含 vermagic 覆写逻辑。重新编译即可。带 __versions 的模块,内核不关心版本号前缀,但后缀必须精确匹配。后缀由十几个 CONFIG_ 宏拼装而成,包含:跨设备、跨架构时,必须从目标设备的参考 .ko 中完整提取后缀,不能复用开发机的值。越过 SELinux 和 vermagic 之后,模块终于进入了内核加载器的内部路径。但迎接它的是更底层的 ARM64 安全机制 —— 这些特性由 CPU 和编译器联合强制执行。BTI 全称为 Branch Target Identification。insmod 正常返回 0,但设备紧跟着直接重启。从 pstore 中提取的崩溃日志显示,CPU 在 init_module 入口处触发了:ARMv8.5 引入了 BTI 硬件强制保护。当一个间接跳转发生时,例如:CPU 会检查目标地址的第一条指令是否为合法的 BTI 着陆指令。常见 BTI 着陆指令包括:若目标地址没有合法 BTI 指令,CPU 会立即触发异常。内核通过页表中的 GP,也就是 Guarded Page 位控制 BTI 的使能。当开启:module_enable_text_rox() 在设置模块 .text 段为可执行时,会顺便通过 PTE_MAYBE_GP 设置 GP 位:这意味着一旦 GP 位置位,该段任何间接跳转的目标都必须经过 BTI 校验。内核调用模块初始化函数是通过函数指针完成的:这是一个间接调用。我们的测试代码使用普通 NDK Clang 编译,默认不生成 BTI 着陆指令。在 GP 位打开的情况下,CPU 发现 init_module 的头指令不是:于是产生 Target Branch Exception,最终导致 kernel panic。必须告诉编译器生成 BTI 兼容代码:-mbranch-protection=standard 会在每个可被间接调用的函数开头生成:同时它还会启用 PAC,也就是下一个坑的主角。PAC 全称为 Pointer Authentication。BTI 修复后,设备依然在加载模块时重启。这次崩溃点发生在函数返回时,而非入口。ARMv8.3 引入了 PAC,用于保护函数返回地址的完整性。在函数入口,用栈指针 SP 作为 modifier 对返回地址 LR 进行签名:在函数出口,再用:验证签名。若签名不通过,则触发 Authentication Fault,直接终止执行。内核开启:后,所有内核代码都使用 PAC。模块代码如果不同步启用 PAC,就会出现这样的场景:关键约束是:模块与内核的 PAC 密钥必须一致。这个一致性通过都使用同一编译选项生成相同的指令序列来保证。与 BTI 完全相同:-mbranch-protection=standard 同时生成 BTI 和 PAC 指令,一根编译选项解决两者。SCS 全称为 Shadow Call Stack。当开启:内核会启用影子调用栈。SCS 使用 x18 寄存器保存一个独立于正常栈的返回地址链。在静态 SCS 实现下,也就是:所有内核代码都必须遵守 SCS 规约,即不能随意使用 x18 寄存器。任何对 x18 的写操作都会破坏影子调用栈,导致诡异的返回地址错误。由于 KPM 编译时使用了与内核兼容的 Clang,并传递了:该选项会隐式启用 SCS 指令生成,因此这个坑被自动绕过。但还需注意:若以后使用手写汇编,必须保留 x18 作为 SCS 指针的约定,否则会一脚踩进去。CFI 全称为 Control Flow Integrity。BTI 和 PAC 两关打通后,模块加载仍导致重启。pstore 日志显示:我们终于触碰到了 Android GKI 最核心的安全机制:CFI。内核 Makefile 中声明使用:kCFI 的原理是:每个可间接调用函数的入口前 4 字节保存一个类型哈希值。调用方在进行间接调用前,会检查:是否与期望的哈希值匹配。不匹配则执行 BRK 指令陷入内核,最终导致 panic。逻辑上,只要我们用同样的编译器、同样的标志编译模块,生成的哈希就能匹配。但事实并非如此。我们从目标设备提取了一个正常工作的参考模块 asix.ko,分析发现:这印证了一个关键事实:GKI 预编译模块实际使用的是 CFI_ICALL,也就是 UBSan 风格的 CFI,而非 kCFI。Makefile 的声明与预编译模块的实际行为并不一致。即使知道内核可能是 CFI_ICALL,我们仍先用标准编译试试水。结果如前所述:内核 panic。改用:编译后,错误

...(已截断)

---
来源: 看雪论坛
原文链接: https://bbs.kanxue.com/thread-291096.htm
#2 2026-06-01 21:09:09
谢谢分享
#3 2026-06-01 21:09:09
tql
#4 2026-06-01 21:09:09
感谢分享

请登录后参与讨论

立即登录 注册账号