把.o变成.ko:GKI安全特性的铁幕

admin 2026-06-20 04:53:32 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文详细分析了在ARM64AndroidGKI设备上加载内核模块时遇到的SELinux权限限制、vermagic校验不匹配、BTI/PAC/SCS/CFI等安全机制导致的六大技术难题。通过实际案例解析了各安全特性的工作原理,并提供了具体的编译参数和配置解决方案,最终成功实现模块加载。 综合评分: 87 文章分类: 内核安全,移动安全,漏洞分析,安全开发,逆向分析


cover_image

把 .o 变成 .ko:GKI 安全特性的铁幕

孤木落 孤木落

看雪学苑

2026年6月19日 17:59 上海

在小说阅读器读本章

去阅读

ARM64 Android GKI 内核模块实战:SELinux、vermagic、BTI、PAC、SCS、CFI 与 mrdump

#

本文是系列第二篇。第一篇讲述了如何通过 ELF 格式转换,将用户空间编译产物变成可被内核识别的 .ko 文件,并踩平了前 13 个坑。

本篇继续在 ARM64 Android GKI 设备上的实战 —— 当基础格式正确之后,真正的战斗才刚开始。

完成基础 ELF 转换后,我们得到了一份格式上合法的 .ko 文件,并在 x86 Kali 环境中成功实现加载。

信心满满地推送到目标设备:

  • SoC:MediaTek
  • 系统:Android 12
  • 内核:5.10.198
  • 安全特性:全开

结果 insmod 直接甩回来一个让人摸不着头脑的错误。

坑 14:SELinux —— “File exists” 背后的权限墙

现象

$ insmod /data/local/tmp/test.ko
insmod: 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 Android 设备后:

$ insmod /data/local/tmp/test.ko
insmod: can't insert 'test.ko': Exec format error

dmesg 输出 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 的后缀不同:

| 环境 | vermagic 后缀示例 | | — | — | | Kali x86_64 | SMP preempt mod_unload | | Android ARM64 | SMP preempt mod_unload aarch64 |

Android 多了 aarch64 架构标识。

后缀不匹配,strcmp 直接失败,内核返回 -ENOEXEC,用户空间显示:

Exec formaterror

至于为什么修补没生效 —— 那是开发流程问题:fixup_ko 的编译产物是旧的,还没包含 vermagic 覆写逻辑。重新编译即可。

核心认知

带 __versions 的模块,内核不关心版本号前缀,但后缀必须精确匹配。

后缀由十几个 CONFIG_ 宏拼装而成,包含:

  • 架构标识
  • SMP
  • 抢占模型
  • 模块卸载支持
  • 其他内核构建特征

跨设备、跨架构时,必须从目标设备的参考 .ko 中完整提取后缀,不能复用开发机的值。

越过 SELinux 和 vermagic 之后,模块终于进入了内核加载器的内部路径。

但迎接它的是更底层的 ARM64 安全机制 —— 这些特性由 CPU 和编译器联合强制执行。

坑 16:BTI —— 间接跳转的守门人

BTI 全称为 Branch Target Identification

现象

insmod 正常返回 0,但设备紧跟着直接重启。

从 pstore 中提取的崩溃日志显示,CPU 在 init_module 入口处触发了:

Target Branch Exception

BTI 机制

ARMv8.5 引入了 BTI 硬件强制保护。

当一个间接跳转发生时,例如:

BR  xN
BLR xN

CPU 会检查目标地址的第一条指令是否为合法的 BTI 着陆指令。

常见 BTI 着陆指令包括:

| 指令 | 用途 | | — | — | | bti c | 用于间接函数调用 | | bti j | 用于间接跳转 |

若目标地址没有合法 BTI 指令,CPU 会立即触发异常。

内核通过页表中的 GP,也就是 Guarded Page 位控制 BTI 的使能。

当开启:

CONFIG_ARM64_BTI_KERNEL=y

module_enable_text_rox() 在设置模块 .text 段为可执行时,会顺便通过 PTE_MAYBE_GP 设置 GP 位:

// arch/arm64/mm/pageattr.c
intset_memory_x(unsignedlong addr, int numpages){
returnchange_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 修复后,设备依然在加载模块时重启。

这次崩溃点发生在函数返回时,而非入口。

PAC 机制

ARMv8.3 引入了 PAC,用于保护函数返回地址的完整性。

在函数入口,用栈指针 SP 作为 modifier 对返回地址 LR 进行签名:

paciasp

在函数出口,再用:

autiasp

验证签名。

若签名不通过,则触发 Authentication Fault,直接终止执行。

内核开启:

CONFIG_ARM64_PTR_AUTH_KERNEL=y

后,所有内核代码都使用 PAC。

模块代码如果不同步启用 PAC,就会出现这样的场景:

  • 内核代码调用模块函数,返回时 LR 已被内核签名。
  • 模块函数内 ret 时若没有 autiasp,CPU 直接放行返回地址,但内核调用者期望 PAC 状态一致。
  • 更深层的问题:模块若通过内核 CFI 路径被间接调用,而模块未签名或未验证,会导致 PAC 上下文紊乱,最终在某次返回时触发 Authentication Fault。

关键约束是:模块与内核的 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)

我们终于触碰到了 Android GKI 最核心的安全机制:CFI。

19.1 背景:kCFI 还是 CFI_ICALL?

内核 Makefile 中声明使用:

# Makefile
ifdef CONFIG_CFI_CLANG
CC_FLAGS_CFI := -fsanitize=kcfi

kCFI 的原理是:

每个可间接调用函数的入口前 4 字节保存一个类型哈希值。

调用方在进行间接调用前,会检查:

*(target - 4)

是否与期望的哈希值匹配。

不匹配则执行 BRK 指令陷入内核,最终导致 panic。

逻辑上,只要我们用同样的编译器、同样的标志编译模块,生成的哈希就能匹配。

但事实并非如此。

我们从目标设备提取了一个正常工作的参考模块 asix.ko,分析发现:

  • 函数入口前 4 字节全是 0x00000000,没有 kCFI 哈希。
  • 模块内存在一个巨大的 __cfi_check 函数,大小超过 1700 字节。
  • 模块内存在 .cfi_jt 跳转表。

这印证了一个关键事实:

GKI 预编译模块实际使用的是 CFI_ICALL,也就是 UBSan 风格的 CFI,而非 kCFI。

Makefile 的声明与预编译模块的实际行为并不一致。

#

19.2 第一次尝试:无 CFI,直接崩溃

即使知道内核可能是 CFI_ICALL,我们仍先用标准编译试试水。

结果如前所述:

CFI failure at init_module

内核 panic。

19.3 第二次尝试:kCFI 哈希,哈希不匹配

改用:

-fsanitize=kcfi

编译后,错误变成:

CFIfailureatinit_module+0x0/0x2c[test_kcfi]
(target: init_module+0x0/0x2c [test_kcfi]; expected type: 0x36b1c5a6)

我们生成的哈希值是:

0x36b1c5a6

但内核期望的是 AOSP 预编译模块所用的哈希。

不同版本的 Clang,对同一函数原型生成的 CFI 哈希不同。

开发机上的 Clang 与 AOSP 构建内核时的 Clang 版本不一致,哈希体系不兼容,因此 kCFI 这条路也走不通。

19.4 第三次尝试: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 | 启用 ThinLTO,CFI 的前提 | | -fsanitize=cfi | 生成 CFI_ICALL 类型检查 | | -fsanitize-cfi-cross-dso | 跨 DSO 间接调用验证 | | -fvisibility=hidden | 隐藏非导出符号,配合 CFI 缩减检查范围 | | -mbranch-protection=standard | 生成 BTI 与 PAC 兼容指令 |

这样生成的模块内部结构包括:

  • __cfi_check 函数 接收 (地址, 类型哈希),验证该地址是否属于某个合法间接调用目标。
  • .cfi_jt 跳转表 为每个地址可被间接调用的函数生成一个 8 字节 CFI 桩,例如:
  bti c
  b   function_name.cfi

此时 init_module 符号指向这个桩,真正的代码在:

init_module.cfi

这一次,模块加载成功:

test_cfi:nosymbolversionforprintk
callinginit_module+0x0/0x8 [test_cfi] @5515
HelloWorld:KPatcherARM64moduleloaded!
initcallinit_module+0x0/0x8 [test_cfi] returned0after6usecs

注意:

init_module+0x0/0x8

其中 0x8 表示 init_module 的大小是 8 字节,而非实际代码大小。

这证实了它指向的是跳转表桩。

然而,胜利的喜悦只持续了几秒钟 —— 手机卡死了。

坑 20:mrdump 崩溃 —— 手机卡死的真正根因

现象

insmod 命令在内核中阻塞,手机完全无响应:

  • 屏幕触摸无效
  • 物理按键无效
  • 持续数分钟后才恢复

恢复后,dmesg 里出现了令人意外的崩溃:

[  980.921362]initcallinit_module+0x0/0x8[test_cfi]returned0after4usecs
[  980.921373]UnabletohandlekernelNULLpointerdereferenceatvirtualaddress0000000000000008
[  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()        // ← 空指针解引用!崩溃!

根因:非标准 Section 名称

问题出在 CFI_ICALL 编译所产生的 ELF section 布局上。

clang -r 将 LLVM bitcode 转换为 ELF 时,生成了一堆非标准的 section 名称:

| Section 名 | 内容 | 大小 | | — | — | — | | .text | 空的 | 0 | | .text.__cfi_check | __cfi_check 函数 | 0x101c | | .text..L.cfi.jumptable | cleanup_module CFI 桩 | 8 | | .text..L.cfi.jumptable.1 | init_module CFI 桩 | 8 | | .text.init_module.cfi | 实际 init 代码 | 0x28 | | .text.cleanup_module.cfi | 实际 cleanup 代码 | 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 时:

  • mrdump

    自己的 Oops 处理函数被调用,试图保存崩溃信息到存储。

  • 保存操作耗时很长,可能持续数分钟。

  • 在此期间,module_mutex 一直被持有。

  • 任何其他需要该锁的代码路径全部阻塞,表现为系统完全无响应。

这不同于 kernel panic。

BUG() 会让 CPU 停摆,而这里更像是一次 Oops —— 内核最终还能继续运行,所以手机在数分钟后得以恢复。

解决:Linker Script 合并 Section

必须将所有 .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 布局恢复标准:

| Section | 内容 | | — | — | | .text | 所有代码,包括 __cfi_check、CFI 桩、init、cleanup | | .rodata | 只读数据 | | .gnu.linkonce.this_module | struct module | | .modinfo | 模块元信息 | | …… | …… |

再次加载:

$ insmod /data/local/tmp/test_cfi_fixed.ko
INSMOD_SUCCESS

秒级返回,无卡死。

查看日志:

$ dmesg | grep test_cfi

输出:

test_cfi:nosymbolversionforprintk
callinginit_module+0x0/0x8 [test_cfi] @5515
HelloWorld:KPatcherARM64moduleloaded!
initcallinit_module+0x0/0x8 [test_cfi] returned0after6usecs

无 mrdump 崩溃,无 Oops,一切正常。

本篇小结

我们闯过了第二道关卡。

总结一下这 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 Android GKI 设备上安全、正常地加载和卸载。

但故事远未结束,下一篇,我们将进入更深的领域:

当需要自己动手写一个内核模块加载器时,又会踩到哪些仅靠格式转换无法预见的坑?

#

看雪ID:孤木落

https://bbs.kanxue.com/user-home-1044055.htm

*本文为看雪论坛优秀文章,由 孤木落 原创,转载请注明来自看雪社区

第十届安全开发者峰会【议题征集】-欢迎投稿

往期推荐

我们绕过了 GarudaDefender 整套 Frida 检测,但这已经不是重点了

一次 Flutter App 实战:还原 encData 参数解密流程

单机DMA劫持HyperV!调试+取证两种思路解决2026腾讯游戏安全技术竞赛决赛

Android风险环境检测——签名校验

和爱豆更近一步——爱豆聊天App反调试绕过

球分享

球点赞

球在看

点击阅读原文查看更多


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:看雪学苑 孤木落 孤木落《把 .o 变成 .ko:GKI 安全特性的铁幕》

评论:0   参与:  0