A kernel-based root solution for Android
前言 最近几年伴随着 Google 推动 GKI, Android Root 方案逐渐迈向内核层,诞生了 Kernelsu,Apatch 等内核级 Root 方案,最近抽时间研读学习了下 Kernelsu 的源码,这里简单分享讨论下,这里以 Kernelsu-next(整体方案和 Kernlesu 1.x 时代类似) 为例子展开, commitId: c2f9fce615217dbc9367ae31e2b2f54065c8f935。
Root 的本质:突破 Android 多层权限体系 1.1 Android 的多层安全机制 Android 使用多层防护来限制应用权限:
1 2 3 4 5 6 7 8 9 ┌──────────────────────────────────────┐ │ 1. UID/GID │ ← 基础用户权限 ├──────────────────────────────────────┤ │ 2. Capabilities │ ← 细分的 root 权限 ├──────────────────────────────────────┤ │ 3. SELinux │ ← 强制访问控制 ├──────────────────────────────────────┤ │ 4. Seccomp │ ← 系统调用过滤 └──────────────────────────────────────┘
Root 的本质 :突破这些限制,获得系统完全控制权。
1.2 第一层:UID/GID 与 Capabilities Capabilities:细分的 root 权限 传统 Linux 中,UID 0 拥有所有权限。现代 Linux 将 root 权限拆分成多个独立的 Capability,例如:
Capability
作用
CAP_SETUID
修改进程 UID
CAP_DAC_OVERRIDE
绕过文件权限检查
CAP_SYS_MODULE
加载内核模块
CAP_SYS_ADMIN
系统管理(最重要)
1.3 第二层:SELinux 强制访问控制 即使拥有 UID 0 和所有 capabilities,在 Android 上仍然会被 SELinux 拦截。
SELinux:强制访问控制 SELinux 为每个进程分配安全上下文(Security Context),即使是 root 也受限制:
1 2 3 4 5 u:r:untrusted_app:s0 ← 受限很多 u:r:su:s0 ← 几乎不受限
核心思想 :每个操作都需要 SELinux 策略明确允许。
因此,现代 Root 方案通常采用 域转换 或 策略注入 的方式,在保持 SELinux 启用的前提下获得权限,也就是 MAC 的访问控制(主体/客体/访问策略)。
1.4 第三层:Seccomp 系统调用过滤 Android 8.0+ 对应用进程启用了 Seccomp-BPF(Secure Computing with Berkeley Packet Filter),这是一个系统调用白名单机制。
Seccomp 的工作原理 Seccomp 允许进程限制自己能调用的系统调用集合。一旦启用,任何不在白名单中的系统调用都会导致进程被杀死:
1 2 3 4 5 6 7 8 9 应用进程启动 ↓ Android Runtime 加载 Seccomp 过滤器 ↓ 进程只能调用约 200 个系统调用(共 400+ 个) ↓ 尝试调用 mount()、ptrace() 等危险系统调用 ↓ ❌ SECCOMP_RET_KILL - 进程被杀死
这也是容器 render 进程沙盒化的一部分基础支持。
1.5 其他安全机制 除了上述三层主要防御,Android 还有其他安全特性:
安全机制
作用
对 Root 的影响
dm-verity
验证 system 分区完整性
修改 /system 会导致启动失败
AVB 2.0
Verified Boot,验证启动链
修改 boot.img 需要解锁 Bootloader
CFI
控制流完整性检查
防止 GOT/PLT Hook
PAN/PXN
内存访问保护
限制内核访问用户空间内存
Namespace
资源隔离(PID/Mount/Network等)
进程看到的系统视图可能被隔离
1.6 完整的 Root 攻击面 综合上述所有层次,真正的 Root 需要突破:
1 2 3 4 5 6 7 8 9 10 11 ┌─────────────────────────────────────────┐ │ 应用层 │ ├─────────────────────────────────────────┤ │ ✓ 获得 UID 0 │ │ ✓ 获得所有 Linux Capabilities │ │ ✓ 切换到特权 SELinux 域 │ │ ✓ 绕过 Seccomp 系统调用过滤 │ │ ✓ 突破 Mount Namespace 隔离 │ │ ✓ 禁用 dm-verity(可选) │ │ ✓ 解锁 Bootloader(用于刷入修改镜像) │ └─────────────────────────────────────────┘
传统 Root 方案(Magisk)的做法 :
修改 boot.img,在用户空间通过 SUID 二进制文件提权
用 sepolicy 注入修改 SELinux 策略
通过 unshare() 创建独立的 Mount Namespace
内核 Root 方案(KernelSU)的优势 :
在内核空间直接修改进程凭证,绕过所有用户空间检查
无需 SUID 二进制文件,更隐蔽
直接操作 SELinux 内核结构,无需策略注入
不受 Seccomp 限制(代码运行在内核空间)
下一章我们将详细分析为什么 GKI 时代使得内核 Root 方案成为可能。
GKI 时代:为什么内核 Root 方案突然火了 2.1 GKI 是什么? GKI(Generic Kernel Image)是 Google 从 Android 11 开始推的内核通用化项目,可以看看这篇文章:https://blog.xzr.moe/archives/236/。没有细看整个 gki ko 模块校验,我理解 gki 内核是没有开强制模块签名校验的,因为要加载厂商自定义的 ko? 简单说就是:
以前 :每个厂商自己编译内核,版本乱七八糟,升级困难
现在 :Google 提供通用内核,厂商只需提供设备树和驱动模块
GKI 的分层架构:
1 2 3 4 5 6 7 8 9 10 11 12 ┌─────────────────────────────────────┐ │ Android Framework (AOSP) │ ├─────────────────────────────────────┤ │ GKI Kernel (Google 维护) │ ← 这层是统一的 │ - 核心内核代码 │ │ - 不包含厂商特定代码 │ ├─────────────────────────────────────┤ │ Vendor Modules (厂商驱动) │ ← LKM (可加载内核模块) │ - GPU 驱动 │ │ - 摄像头驱动 │ │ - SoC 特定功能 │ └─────────────────────────────────────┘
2.2 为什么 GKI 催生了内核 Root 方案? 以前要在内核层做文章,必须重新编译整个内核,这对普通用户来说门槛太高,同时可能国内的厂商根本没有放出完整的源码,你也编不了。但 GKI 的模块化设计提供了一个”后门”:
既然厂商可以加载自己的驱动模块,那我们也可以加载自己的”Root 模块”。
这就是 KernelSU 的核心思路:把 Root 功能做成一个内核模块(kernelsu.ko),在系统启动时加载,绕过所有用户空间的限制。
对比一下:
特性
Magisk
KernelSU
修改位置
boot.img (ramdisk)
内核模块
Root 实现
SUID 二进制
内核钩子
权限检查层级
用户空间
内核空间
检测难度
容易(文件、进程)
困难(需要内核级探测)
SELinux 绕过
需要注入策略
直接修改内核上下文
KernelSU Next 原理深度剖析 3.0 两种工作模式:LKM vs GKI KernelSU 支持两种不同的工作模式:
GKI 模式(内置模式) :
kernelsu 代码通过 setup.sh 集成到内核源码(drivers/kernelsu)
编译时直接链接进 vmlinux,内核启动时通过 module_init() 自动初始化
优点 :最稳定,适合模拟器、自编译内核用户
缺点 :必须重新编译内核
LKM 模式(动态加载模式) :
kernelsu 编译成独立的 kernelsu.ko 模块
通过 ksuinit 劫持 /init,在启动早期手动加载模块
优点 :不需要编译内核,兼容原厂/第三方内核,更新方便
缺点 :需要修改 ramdisk,依赖 ksuinit 兼容性,虽然 ksuinit 代码很少主要负责符号的解析和 ko 模块加载,但是不能保证后续内核更新会不会有其他问题。以及可能厂商内核有做些校验例如这个 issue:https://github.com/tiann/KernelSU/issues/1289
本文重点关注 LKM 模式 ,因为这是 KernelSU Next 的核心创新,也是普通用户最常用的方式。GKI 模式的详细原理(module_init() 机制、链接脚本等)可参考内核文档。
3.1 LKM 模式:kernelsu.ko 的编译 LKM 模式下,kernelsu.ko 被编译成独立的内核模块,无需修改内核源码,但是还是要下载对应的 android 内核源码,有兴趣可以具体看下 CI/CD 的流程就知道了。编译过程由 kernel/Makefile 控制:
1 2 3 4 5 6 7 8 9 10 kernelsu-objs := ksu.o kernelsu-objs += allowlist.o kernelsu-objs += apk_sign.o kernelsu-objs += core_hook.o kernelsu-objs += ksud.o kernelsu-objs += selinux/selinux.o obj-$(CONFIG_KSU) += kernelsu.o
编译命令 :
1 2 3 4 5 6 7 8 9 make ARCH=arm64 \ CROSS_COMPILE=aarch64-linux-android- \ -C /path/to/kernel-source \ M=$(pwd )/kernel \ CONFIG_KSU=m \ modules
关键区别 :
GKI 模式:CONFIG_KSU=y → 链接进 vmlinux
LKM 模式:CONFIG_KSU=m → 生成 kernelsu.ko
3.2 LKM 模式启动流程:从 ksuinit 到内核模块 我们知道内核启动需要加载 /init 文件,LKM 模式下的 kernelsu 通过替换 init 文件,动态加载自实现的 kernelsu.ko 内核模块。其中替换的文件就是 ksuinit ,之前一直被诟病没有开源,最近开源了源码,代码不多但很精巧。
GitHub: https://github.com/Kernel-SU/ksuinit
修改后的 boot.img 结构 :
1 2 3 4 5 6 7 boot.img (修改后) ├── kernel (未修改,原厂内核) └── ramdisk ├── init → ksuinit (替换!) ├── kernelsu.ko (新增) └── overlay.d/ (原始 init 备份) └── init
完整启动流程 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 ┌──────────────────────────────────────────────────────────┐ │ 1. Bootloader 加载内核 │ └──────────────────────────────────────────────────────────┘ ↓ ┌──────────────────────────────────────────────────────────┐ │ 2. 内核启动,执行 /init (其实是 ksuinit) │ │ - ksuinit 伪装成 init 进程 │ │ - 挂载 /proc、/sys 等基础文件系统 │ └──────────────────────────────────────────────────────────┘ ↓ ┌──────────────────────────────────────────────────────────┐ │ 3. ksuinit 加载内核模块 │ │ - 读取 kernelsu.ko │ │ - 解析 /proc/kallsyms 获取内核符号地址 │ │ - 重定位 ELF 文件,解析未定义符号 │ │ - 调用 init_module() 系统调用 │ └──────────────────────────────────────────────────────────┘ ↓ ┌──────────────────────────────────────────────────────────┐ │ 4. kernelsu.ko 初始化 │ │ - 注册 LSM 钩子或 Kprobe │ │ - 初始化白名单系统 │ │ - 启动 Manager 应用追踪 │ └──────────────────────────────────────────────────────────┘ ↓ ┌──────────────────────────────────────────────────────────┐ │ 5. ksuinit 把自己伪装回正常 init │ │ - unlink("/init") # 删除自己 │ │ - symlink("真正的init路径", "/init") │ │ - execve("/init", ...) # 启动真正的 init │ └──────────────────────────────────────────────────────────┘ ↓ ┌──────────────────────────────────────────────────────────┐ │ 6. Android 正常启动 │ │ - 内核模块已常驻内存 │ │ - 系统看不到任何异常 │ └──────────────────────────────────────────────────────────┘
手动符号解析的核心流程 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 pub fn load_module (path: &str ) -> Result <()> { let mut buffer = fs::read (path)?; let elf = Elf::parse (&buffer)?; let kernel_symbols = parse_kallsyms ()?; for sym in elf.syms.iter_mut () { if sym.st_shndx != SHN_UNDEF { continue ; } let name = elf.strtab.get_at (sym.st_name)?; let real_addr = kernel_symbols.get (name)?; sym.st_shndx = SHN_ABS; sym.st_value = *real_addr; buffer.pwrite_with (sym, offset, ctx)?; } init_module (&buffer, "" )?; }
3.2 Hook 机制:如何拦截系统调用 KernelSU Next (以及 Kernelsu 1.x)用了两种技术来拦截系统事件,LSM 和 Kprobe,但是目前在 25年 12 月 kernel su 的新版中为了避免侧信道攻击,已经在热点函数中废弃使用 LSM 和 Kprobe, 至于为什么,先了解下之前的 Hook 方案:
方式一:LSM 钩子 LSM(Linux Security Modules)是 Linux 的安全框架,SELinux、AppArmor 都基于它。理论上只有内核编译时静态链接的 LSM 才能注册钩子,但 KernelSU 用了比较 trick 的方案。
从 module_init 一路跟进到 ksu_lsm_hook_init 这个 lsm 核心 hook 的函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 void __init ksu_lsm_hook_init (void ) { void *cap_prctl = GET_SYMBOL_ADDR(cap_task_prctl); struct hlist_head *prctl_head = find_head_addr(cap_prctl); struct hlist_head *new_head = copy_security_hlist(prctl_head); hlist_add_tail_rcu(&ksu_hook.list , new_head); override_security_head(prctl_head, new_head); }
本质上是:
LSM 钩子链表在内核只读数据段(.rodata)
通过 vmap() 把物理页重新映射到可写虚拟地址
修改完后再 vunmap(),对内核透明
这样就成功注册了自己的钩子,而且不需要修改内核源码。
override_security_head:突破只读保护的黑魔法 这是整个 LSM Hook 机制中最核心的技巧。让我们深入理解它是如何工作的:
问题背景 :
1 2 3 4 5 6 7 8 9 10 11 12 13 security_hook_heads 结构(内核全局变量): ┌─────────────────────────────────────┐ │ struct security_hook_heads { │ │ struct hlist_head binder_xxx; │ │ struct hlist_head task_prctl; │ ← 我们要修改这个 │ struct hlist_head inode_rename; │ │ ... │ │ }; │ └─────────────────────────────────────┘ ↓ 这个结构位于内核的 .rodata 段(只读数据段) 编译时链接器会标记为 PAGE_KERNEL_RO(只读页) 任何直接写入操作会触发页错误(Page Fault)
为什么是只读的?
内核为了防止恶意代码篡改安全钩子,将 LSM 钩子链表设置为只读。这样即使加载了恶意内核模块,也无法直接修改钩子。
KernelSU 的绕过方法 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 static int override_security_head (void *head, const void *new_head, size_t len) { unsigned long base = (unsigned long )head & PAGE_MASK; unsigned long offset = offset_in_page(head); BUG_ON(offset + len > PAGE_SIZE); struct page *page = phys_to_page(__pa(base)); if (!page) { return -EFAULT; } void *addr = vmap(&page, 1 , VM_MAP, PAGE_KERNEL); if (!addr) { return -ENOMEM; } local_irq_disable(); memcpy (addr + offset, new_head, len); local_irq_enable(); vunmap(addr); return 0 ; }
内存映射示意图 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 原始状态: ┌─────────────────────────────────────┐ │ 虚拟地址 0xffffffc012345000 │ │ ↓ │ │ 物理页 (只读) │ │ ┌────────────────────────────────┐ │ │ │ security_hook_heads.task_prctl │ │ ← 尝试写入会 Page Fault │ │ = 0xffffffc056789000 │ │ │ └────────────────────────────────┘ │ └─────────────────────────────────────┘ vmap() 后: ┌─────────────────────────────────────┐ │ 原虚拟地址 0xffffffc012345000 │ (只读,不变) │ ↓ │ │ 物理页 │ │ ↑ │ │ 新虚拟地址 0xffffffd088888000 │ ← vmap 返回的地址 │ (可读写映射) │ └─────────────────────────────────────┘ 通过新地址修改后: ┌─────────────────────────────────────┐ │ 虚拟地址 0xffffffc012345000 │ │ ↓ │ │ 物理页 (仍标记为只读) │ │ ┌────────────────────────────────┐ │ │ │ security_hook_heads.task_prctl │ │ │ │ = 0xffffffd099999000 ← 已修改! │ │ │ └────────────────────────────────┘ │ └─────────────────────────────────────┘
关键技术点解析 :
PAGE_MASK 与页对齐 :
1 2 3 4 5 unsigned long base = 0xffffffc012345678 & 0xFFFFF000 = 0xffffffc012345000 unsigned long offset = 0x678
vmap() 的作用 :
创建一个新的虚拟地址映射到同一物理页
新映射的权限是 PAGE_KERNEL(可读写)
原始映射仍然保持只读
内核内存管理器(MMU)维护两个虚拟地址指向同一物理地址
为什么要禁用中断?
1 2 3 local_irq_disable(); memcpy (addr + offset, new_head, len);local_irq_enable();
确保 memcpy 操作是原子的
防止在修改一半时发生 CPU 调度
避免其他 CPU 看到不一致的状态(但不能完全避免,需要内存屏障)
完整流程示例 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 void ksu_lsm_hook_init (void ) { void *cap_prctl = kallsyms_lookup_name("cap_task_prctl" ); struct hlist_head *prctl_head = find_head_addr(cap_prctl); struct hlist_head *new_head = copy_security_hlist(prctl_head); hlist_add_tail_rcu(&ksu_task_prctl_hook.list , new_head); override_security_head(prctl_head, new_head, sizeof (*new_head)); }
方式二:Kprobes(兼容老内核) 如果内核不支持动态 LSM 钩子(如 4.4 以下的老内核),就用 Kprobe 作为备选方案。
Kprobes 基本原理 Kprobes 是 Linux 内核提供的动态调试机制,允许在几乎任何内核函数处插入探测点(probe),无需重新编译内核。
核心思想 :通过在目标函数入口替换指令为断点(breakpoint),当 CPU 执行到断点时触发异常,跳转到自定义处理函数。
KernelSU 的使用示例 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 static int handler_pre (struct kprobe *p, struct pt_regs *regs) { struct pt_regs *real_regs = PT_REAL_REGS(regs); int option = (int )PT_REGS_PARM1(real_regs); unsigned long arg2 = (unsigned long )PT_REGS_PARM2(real_regs); unsigned long arg3 = (unsigned long )PT_REGS_PARM3(real_regs); unsigned long arg4 = (unsigned long )PT_REGS_SYSCALL_PARM4(real_regs); unsigned long arg5 = (unsigned long )PT_REGS_PARM5(real_regs); return ksu_handle_prctl(option, arg2, arg3, arg4, arg5); } static struct kprobe prctl_kp = { .symbol_name = PRCTL_SYMBOL, .pre_handler = handler_pre, }; int ksu_kprobe_init (void ) { int rc = register_kprobe(&prctl_kp); if (rc) { pr_err("prctl kprobe failed: %d\n" , rc); return rc; } rc = register_kprobe(&renameat_kp); return rc; }
Kprobes 工作流程详解 1. 注册阶段 (register_kprobe):
1 2 3 4 5 6 7 8 9 10 11 12 13 用户调用 register_kprobe(&prctl_kp) ↓ 内核查找符号 "__arm64_sys_prctl" 的地址 ↓ 保存原始指令(通常是函数开头的几个字节) ↓ 替换为断点指令: - ARM64: BRK #<imm> (0xd4200000) - x86_64: INT3 (0xCC) ↓ 刷新指令缓存(I-Cache) ↓ 注册完成,等待触发
2. 触发阶段 (有进程调用 prctl() 时):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 用户空间: prctl(PR_SET_NAME, ...) ↓ 系统调用进入内核: __arm64_sys_prctl ↓ CPU 执行到被替换的断点指令 ↓ 触发异常(Breakpoint Exception) ↓ 异常处理器识别这是 kprobe 断点 ↓ 调用 kprobe->pre_handler (handler_pre) ↓ handler_pre 执行完毕,返回值决定后续行为: - 0: 继续执行原函数 - 非0: 跳过原函数(修改返回值) ↓ 如果继续执行: 1. 临时恢复原始指令 2. 单步执行(Single Step)原指令 3. 再次替换为断点 4. 继续执行后续代码
3. 内存布局变化 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 原始状态(prctl 函数开头): 地址: 0xffffffc012345678 指令: stp x29, x30, [sp, #-16]! // ARM64 函数序言 mov x29, sp ... 注册 kprobe 后: 地址: 0xffffffc012345678 指令: brk #0x400 // 断点指令(kprobe 魔数) <被保存的原始指令> // 存储在 kprobe 结构中 ... 执行时(单步模式): 临时恢复 → 执行原指令 → 再次设置断点
架构相关的寄存器访问 不同 CPU 架构的系统调用参数传递方式不同,KernelSU 通过宏适配:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 #ifdef CONFIG_ARM64 #define PT_REGS_PARM1(x) ((x)->regs[0]) #define PT_REGS_PARM2(x) ((x)->regs[1]) #define PT_REGS_PARM3(x) ((x)->regs[2]) #endif #ifdef CONFIG_X86_64 #define PT_REGS_PARM1(x) ((x)->di) #define PT_REGS_PARM2(x) ((x)->si) #define PT_REGS_PARM3(x) ((x)->dx) #endif
为什么需要 PT_REAL_REGS 宏?
1 struct pt_regs *real_regs = PT_REAL_REGS(regs);
某些架构(如 ARM64)的 kprobe 会传递一个 “包装后” 的寄存器结构,需要解包才能获取真实的系统调用参数。
Kprobes 的优缺点 优点 :
通用性强 :
几乎所有现代 Linux 内核都支持(需要 CONFIG_KPROBES=y)
不依赖特定的内核版本或编译选项
灵活性高 :
可以 hook 任意内核函数(包括静态函数,通过地址)
支持前置处理(pre_handler)、后置处理(post_handler)、错误处理(fault_handler)
动态可控 :
可以在运行时注册/注销
支持临时禁用(disable_kprobe)
缺点 :
性能开销 :1 2 正常函数调用: ~5ns Kprobe hook: 200-500ns ← 断点+异常处理开销
每次触发都要进入异常处理器
单步执行(Single Step)有额外开销
需要保存/恢复完整的寄存器上下文
kernelsu.ko 的完整工作流程 了解了 LSM 和 Kprobes 两种 hook 机制后,让我们看看 kernelsu.ko 模块加载后的完整执行流程。
1. 模块初始化总览 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 module_init(kernelsu_init); int kernelsu_init (void ) { ksu_core_init(); ksu_workqueue = alloc_ordered_workqueue("kernelsu_work_queue" , 0 ); ksu_allowlist_init(); ksu_throne_tracker_init(); #ifdef CONFIG_KSU_KPROBES_HOOK ksu_sucompat_init(); ksu_ksud_init(); #endif #ifdef MODULE #ifndef CONFIG_KSU_DEBUG kobject_del(&THIS_MODULE->mkobj.kobj); #endif #endif return 0 ; }
下面我们逐个函数详细展开。
2. ksu_core_init() - 核心 Hook 初始化 这是整个 KernelSU 的心脏,负责建立内核 hook 机制。
1 2 3 4 5 6 7 8 9 void ksu_core_init (void ) { #ifdef CONFIG_KSU_LSM_SECURITY_HOOKS ksu_lsm_hook_init(); #else pr_info("ksu_core_init: LSM hooks not in use.\n" ); #endif }
LSM Hook 初始化详细流程 (ksu_lsm_hook_init):
看下在 ko 模块模式下是怎么实现的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 void __init ksu_lsm_hook_init (void ) { void *cap_prctl = GET_SYMBOL_ADDR(cap_task_prctl); void *prctl_head = find_head_addr(cap_prctl, NULL ); if (prctl_head) { if (prctl_head != &security_hook_heads.task_prctl) { pr_warn("prctl's address has shifted!\n" ); } KSU_LSM_HOOK_HACK_INIT(prctl_head, task_prctl, ksu_task_prctl); } else { pr_warn("Failed to find task_prctl!\n" ); } int inode_killpriv_index = -1 ; void *cap_killpriv = GET_SYMBOL_ADDR(cap_inode_killpriv); find_head_addr(cap_killpriv, &inode_killpriv_index); if (inode_killpriv_index < 0 ) { pr_warn("Failed to find inode_rename, use kprobe instead!\n" ); register_kprobe(&renameat_kp); } else { int inode_rename_index = inode_killpriv_index + &security_hook_heads.inode_rename - &security_hook_heads.inode_killpriv; struct hlist_head *head_start = (struct hlist_head *)&security_hook_heads; void *inode_rename_head = head_start + inode_rename_index; if (inode_rename_head != &security_hook_heads.inode_rename) { pr_warn("inode_rename's address has shifted!\n" ); } KSU_LSM_HOOK_HACK_INIT(inode_rename_head, inode_rename, ksu_inode_rename); } void *cap_setuid = GET_SYMBOL_ADDR(cap_task_fix_setuid); void *setuid_head = find_head_addr(cap_setuid, NULL ); if (setuid_head) { if (setuid_head != &security_hook_heads.task_fix_setuid) { pr_warn("setuid's address has shifted!\n" ); } KSU_LSM_HOOK_HACK_INIT(setuid_head, task_fix_setuid, ksu_task_fix_setuid); } else { pr_warn("Failed to find task_fix_setuid!\n" ); } smp_mb(); }
3. ksu_workqueue - 异步任务队列 1 ksu_workqueue = alloc_ordered_workqueue("kernelsu_work_queue" , 0 );
作用 :创建一个有序工作队列,用于处理耗时任务。
使用场景 :
保存/加载 allowlist :
1 2 3 4 5 6 7 8 9 static struct work_struct ksu_save_work ;static struct work_struct ksu_load_work ;INIT_WORK(&ksu_save_work, do_save_allow_list); INIT_WORK(&ksu_load_work, do_load_allow_list); queue_work(ksu_workqueue, &ksu_save_work);
动态卸载 Kprobe :
1 2 3 4 5 static struct work_struct stop_vfs_read_work ;INIT_WORK(&stop_vfs_read_work, do_stop_vfs_read_hook); queue_work(ksu_workqueue, &stop_vfs_read_work);
为什么需要工作队列?
避免阻塞 :在 hook 处理函数中执行 IO 操作会导致系统卡顿
有序执行 :alloc_ordered_workqueue 保证任务按顺序执行(单线程队列)
内核线程 :任务在独立的内核线程中执行,不影响调用者
4. ksu_allowlist_init() - 应用白名单初始化 这是 KernelSU 的权限管理核心,决定哪些应用可以获得 root 权限。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 void ksu_allowlist_init (void ) { int i; BUILD_BUG_ON(sizeof (allow_list_bitmap) != PAGE_SIZE); BUILD_BUG_ON(sizeof (allow_list_arr) != PAGE_SIZE); for (i = 0 ; i < ARRAY_SIZE(allow_list_arr); i++) allow_list_arr[i] = -1 ; INIT_LIST_HEAD(&allow_list); INIT_WORK(&ksu_save_work, do_save_allow_list); INIT_WORK(&ksu_load_work, do_load_allow_list); init_default_profiles(); }
数据结构详解 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 static uint8_t allow_list_bitmap[PAGE_SIZE]; #define BITMAP_UID_MAX 32767 static int allow_list_arr[PAGE_SIZE / sizeof (int )]; static int allow_list_pointer = 0 ; struct perm_data { struct list_head list ; struct app_profile profile ; }; static struct list_head allow_list ;
三层数据结构的设计思想 :
数据结构
作用
查询复杂度
空间占用
allow_list_bitmap
快速判断 uid 是否存在
O(1)
4KB
allow_list_arr
快速遍历所有 uid
O(n)
4KB
allow_list
存储详细配置(链表)
O(n)
动态
默认 Root 配置 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 static void init_default_profiles () { kernel_cap_t full_cap = CAP_FULL_SET; default_root_profile.uid = 0 ; default_root_profile.gid = 0 ; default_root_profile.groups_count = 1 ; default_root_profile.groups[0 ] = 0 ; memcpy (&default_root_profile.capabilities.effective, &full_cap, ...); default_root_profile.namespaces = 0 ; strcpy (default_root_profile.selinux_domain, "u:r:su:s0" ); default_non_root_profile.umount_modules = true ; }
持久化机制 :
白名单放到了 /data/adb/ksu 下面,一般应用也访问不到
1 2 3 4 5 6 7 8 9 10 #define KERNEL_SU_ALLOWLIST "/data/adb/ksu/.allowlist"
5. ksu_throne_tracker_init() - Manager 追踪 1 2 3 4 5 void ksu_throne_tracker_init () { }
实际的追踪逻辑在哪里?
在 vfs_rename hook 中,可以看到是监控了 packages.list 的更新:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 static int ksu_inode_rename (struct inode *old_inode, struct dentry *old_dentry, struct inode *new_inode, struct dentry *new_dentry) { return ksu_handle_rename(old_dentry, new_dentry); } { if (!current->mm) { return 0 ; } if (current_uid().val != 1000 ) { return 0 ; } if (!old_dentry || !new_dentry) { return 0 ; } if (strcmp (new_dentry->d_iname, "packages.list" )) { return 0 ; } char path[128 ]; char *buf = dentry_path_raw(new_dentry, path, sizeof (path)); if (IS_ERR(buf)) { pr_err("dentry_path_raw failed.\n" ); return 0 ; } if (!strstr (buf, "/system/packages.list" )) { return 0 ; } pr_info("renameat: %s -> %s, new path: %s\n" , old_dentry->d_iname, new_dentry->d_iname, buf); track_throne(); return 0 ; } bool is_manager_apk (char *path) { pr_info("%s: expected size: %u, expected hash: %s\n" , path, expected_manager_size, expected_manager_hash); return check_v2_signature(path, expected_manager_size, expected_manager_hash); } ifndef KSU_NEXT_MANAGER_HASH KSU_NEXT_MANAGER_HASH := 79e590113 c4c4c0c222978e413a5faa801666957b1212a328e46c00c69821bf7 endif ccflags-y += -DEXPECTED_MANAGER_HASH=\"$(KSU_NEXT_MANAGER_HASH)\"
6. ksu_sucompat_init() - Su 兼容模式 这是 KernelSU 的一个巧妙设计:让传统的 su 命令检测逻辑认为系统已 root(也是后面测信道为什么能攻击的点),具体不展开了,笔比较简单主要是从。
1 2 3 4 5 6 7 8 9 10 11 12 13 void ksu_sucompat_init () { #ifdef CONFIG_KSU_KPROBES_HOOK su_kps[0 ] = init_kprobe(SYS_EXECVE_SYMBOL, execve_handler_pre); su_kps[1 ] = init_kprobe(SYS_FACCESSAT_SYMBOL, faccessat_handler_pre); su_kps[2 ] = init_kprobe(SYS_NEWFSTATAT_SYMBOL, newfstatat_handler_pre); su_kps[3 ] = init_kprobe("pts_unix98_lookup" , pts_unix98_lookup_pre); #else ksu_sucompat_non_kp = true ; #endif }
Hook 列表及作用 :
前面提到的 kprobe hook ,为了 su 兼容
Hook 函数
目标系统调用
作用
execve_handler_pre
execve()
拦截 su 命令执行,授予 root 权限
faccessat_handler_pre
faccessat()
伪造 /system/bin/su 文件存在
newfstatat_handler_pre
newfstatat()
伪造 su 文件属性(SUID、权限等)
pts_unix98_lookup_pre
pts 查找
检测伪终端访问(用于识别 shell 环境)
7. ksu_ksud_init() - ksud 用户/内核空间通信 这部分负责 KernelSU 内核模块与用户空间进程 ksud 的通信。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 void ksu_ksud_init () { #ifdef CONFIG_KSU_KPROBES_HOOK int ret; ret = register_kprobe(&execve_kp); pr_info("ksud: execve_kp: %d\n" , ret); ret = register_kprobe(&vfs_read_kp); pr_info("ksud: vfs_read_kp: %d\n" , ret); ret = register_kprobe(&input_event_kp); pr_info("ksud: input_event_kp: %d\n" , ret); INIT_WORK(&stop_vfs_read_work, do_stop_vfs_read_hook); INIT_WORK(&stop_execve_hook_work, do_stop_execve_hook); INIT_WORK(&stop_input_hook_work, do_stop_input_hook); #endif }
三个关键 Kprobe 的作用 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 static struct kprobe execve_kp = { .symbol_name = SYS_EXECVE_SYMBOL, .pre_handler = sys_execve_handler_pre, }; static struct kprobe vfs_read_kp = { .symbol_name = "vfs_read" , .pre_handler = vfs_read_handler_pre, }; static struct kprobe input_event_kp = { .symbol_name = "input_event" , .pre_handler = input_event_handler_pre, };
注入的 init.rc 脚本 :
用 root 权限来调用 ksud 这个用户态二进制来完成一些模块的加载等用户态操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 static const char KERNEL_SU_RC[] = "\n" "on post-fs-data\n" " start logd\n" // We should wait for the post-fs-data finish " exec u:r:su:s0 root -- " KSUD_PATH " post-fs-data\n" "\n" "on nonencrypted\n" " exec u:r:su:s0 root -- " KSUD_PATH " services\n" "\n" "on property:vold.decrypt=trigger_restart_framework\n" " exec u:r:su:s0 root -- " KSUD_PATH " services\n" "\n" "on property:sys.boot_completed=1\n" " exec u:r:su:s0 root -- " KSUD_PATH " boot-completed\n" "\n" "\n";
9. Hook 函数列表汇总 LSM Hook 方式 (优先使用,性能好):
Hook 函数
目标
作用
ksu_task_prctl
prctl() 系统调用
拦截 prctl(0xDEADBEEF, ...) 来进行用户态到内核态的调用
ksu_inode_rename
rename
用来追踪 manager 的 uid
ksu_task_fix_setuid
setuid 操作
跟踪进程权限变化,对于没有 root 应用来进行卸载 mount 等
3.3 Root 授权流程 当应用想要 root 权限时,整个流程涉及用户空间和内核空间的紧密协作。核心是通过 prctl 系统调用与内核模块通信,然后由内核直接修改进程凭证。
完整授权流程图 如果尝试执行 execve(“/system/bin/su”),那么会走兼容 su 的逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 ┌─────────────────┐ │ 应用进程 │ uid=10086 (普通应用) │ (shell 等授权应用) │ └────────┬────────┘ │ 1. 执行 su 命令 │ execve("/system/bin/su") ↓ ┌─────────────────┐ │ su 二进制 │ 这个文件由 ksud 提供 │ /data/adb/ksu/ │ 重点:没有 SUID 位! │ bin/su │ 普通可执行文件 └────────┬────────┘ │ 2. │ ↓ ┌──────────────────────────────────────────────────────────┐ │ 内核模块 (kernelsu.ko) │ │ ┌────────────────────────────────────────────────────┐ │ │ │ Kprobe 拦截 execve 系统调用 │ │ │ │ ksu_handle_execve_sucompat │ │ │ └────────────────────────────────────────────────────┘ │ │ ↓ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ 权限检查 │ │ │ │ if (!ksu_is_allow_uid(current_uid().val)) { │ │ │ │ return 0; │ │ │ │ } │ │ │ └────────────────────────────────────────────────────┘ │ │ ↓ 通过检查 │ │ ┌────────────────────────────────────────────────────┐ │ │ │ **核心函数:escape_to_root()** │ │ │ │ 直接在内核空间修改进程凭证 │ │ │ └────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────┘ ↓ ┌─────────────────┐ │ 应用进程 │ uid=0 (已获得 root) │ 现在拥有 │ gid=0, euid=0, egid=0 │ 完整 root 权限 │ SELinux context = u:r:su:s0 └─────────────────┘
escape_to_root() 核心函数详解 这是整个 KernelSU 最关键的函数,负责将普通应用进程提升为 root 权限。
函数签名与入口 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 void escape_to_root (void ) { struct cred *cred ; cred = prepare_creds(); if (!cred) { pr_warn("prepare_creds failed!\n" ); return ; } if (cred->euid.val == 0 ) { pr_warn("Already root, don't escape!\n" ); abort_creds(cred); return ; } struct root_profile *profile = ksu_get_root_profile(cred->uid.val); }
关键数据结构 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 struct root_profile { uid_t uid; gid_t gid; u32 groups_count; gid_t groups[KSU_MAX_GROUPS]; struct capabilities_struct { kernel_cap_t effective; kernel_cap_t permitted; kernel_cap_t inheritable; } capabilities; u32 namespaces; char selinux_domain[MAX_SELINUX_DOMAIN]; };
步骤 1:修改 UID/GID(核心提权) 1 2 3 4 5 6 7 8 9 10 cred->uid.val = profile->uid; cred->suid.val = profile->uid; cred->euid.val = profile->uid; cred->fsuid.val = profile->uid; cred->gid.val = profile->gid; cred->fsgid.val = profile->gid; cred->sgid.val = profile->gid; cred->egid.val = profile->gid;
UID 的四种类型 :
UID 类型
作用
示例场景
uid
真实 UID,标识进程的所有者
ps aux 显示的 UID
euid
有效 UID,用于权限检查
打开文件、访问资源时检查这个
suid
保存的 UID,用于 setuid 恢复
SUID 程序可以在 root/非 root 切换
fsuid
文件系统 UID,用于文件操作
创建文件时的 owner
为什么全部设为 0?
确保在任何权限检查场景下都被识别为 root
防止某些安全机制检查 euid != uid 来检测提权
步骤 2:设置 Capabilities(能力集) Linux 将传统的 root 权限拆分成 38 种独立的 capabilities(能力),例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 cred->securebits = 0 ; u64 cap_for_ksud = profile->capabilities.effective | CAP_DAC_READ_SEARCH; BUILD_BUG_ON(sizeof (profile->capabilities.effective) != sizeof (kernel_cap_t )); memcpy (&cred->cap_effective, &cap_for_ksud, sizeof (cred->cap_effective));memcpy (&cred->cap_permitted, &profile->capabilities.effective, sizeof (cred->cap_permitted)); memcpy (&cred->cap_bset, &profile->capabilities.effective, sizeof (cred->cap_bset)); memset (&cred->cap_ambient, 0 , sizeof (cred->cap_ambient));
四种 Capability 集合 :
能力集
作用
继承性
effective
当前进程实际生效的能力
用于权限检查
permitted
进程可以使用的能力上限
可以添加到 effective
inheritable
可以继承给子进程的能力
exec 后保留
bounding
能力边界集,限制可获得的能力
即使 setuid 也不能超越
ambient
非特权进程也能继承的能力(Android 常用)
KSU 清零以避免权限泄漏
常见的 Capabilities :
1 2 3 4 5 6 7 8 9 CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_CHOWN CAP_SETUID CAP_SETGID CAP_SYS_ADMIN CAP_NET_ADMIN CAP_SYS_MODULE ...
为什么临时添加 CAP_DAC_READ_SEARCH?
步骤 3:设置附加组(Supplementary Groups) 1 2 setup_groups(profile, cred);
setup_groups 实现 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 static void setup_groups (struct root_profile *profile, struct cred *cred) { if (profile->groups_count > KSU_MAX_GROUPS) { pr_warn("Failed to setgroups, too large group: %d!\n" , profile->uid); return ; } if (profile->groups_count == 1 && profile->groups[0 ] == 0 ) { if (cred->group_info) put_group_info(cred->group_info); cred->group_info = get_group_info(&root_groups); return ; } u32 ngroups = profile->groups_count; struct group_info *group_info = groups_alloc(ngroups); if (!group_info) { pr_warn("Failed to setgroups, ENOMEM for: %d\n" , profile->uid); return ; } int i; for (i = 0 ; i < ngroups; i++) { gid_t gid = profile->groups[i]; kgid_t kgid = make_kgid(current_user_ns(), gid); if (!gid_valid(kgid)) { pr_warn("Failed to setgroups, invalid gid: %d\n" , gid); put_group_info(group_info); return ; } #if LINUX_VERSION_CODE >= KERNEL_VERSION(4, 9, 0) group_info->gid[i] = kgid; #else GROUP_AT(group_info, i) = kgid; #endif } groups_sort(group_info); set_groups(cred, group_info); put_group_info(group_info); }
附加组的作用 :
在 Android 中,附加组用于控制各种权限:
1 2 3 4 5 6 7 8 9 10 11 12 uid=0 gid=0 groups =0,1004,1007,1011,1015,1028,3001,3002,3003,3006,3009,3011
步骤 4:提交凭证(不可逆操作)
commit_creds 会原子性地替换进程凭证,这是单向操作 ,一旦提权就无法回退。
步骤 5:禁用 Seccomp(安全计算模式) 1 2 3 4 5 6 spin_lock_irq(¤t->sighand->siglock); disable_seccomp(); spin_unlock_irq(¤t->sighand->siglock);
Seccomp 是什么?
Seccomp 是 Linux 沙箱机制,限制应用只能调用特定的系统调用(如只允许 read/write,禁止 mount/setuid)。
为什么要禁用?
Root 进程需要完整的系统调用权限,KSU 在内核直接清除 seccomp 过滤器。
步骤 6:切换 SELinux 上下文 1 2 setup_selinux(profile->selinux_domain);
为什么需要切换 SELinux?
即使有 root 权限(uid=0),如果 SELinux 上下文是普通应用(u:r:untrusted_app:s0),仍然会被拒绝很多操作。
切换到 u:r:su:s0 后,就能访问几乎所有系统资源。
SUSFS 一个隐藏 su 的模块这里不展开(主要是没空看🐶):https://github.com/sidex15/susfs4ksu-module,基于 kernelsu 的 su 隐藏方案,现在基本都会用这个模块来搭配 kernelsu
3.4 模块系统:OverlayFS vs Magic Mount 类似 magic 不修改文件,使用 mount 技术来无痕修改系统文件,KernelSU Next 支持两种模块挂载方式:
Magic Mount(传承自 Magisk) 原理是利用 mount 实现文件系统的挂载,本质上就是创建 mount 结构体,进行文件系统的挂载,本文不展开了(有个自挂载还比较有意思,本质就是创建个挂载点),下面是 ai 总结的区别,回头单独开个文章亲自补充下。
1 2 3 4 5 6 mount --bind /data/adb/modules/mod1/system/app/SomeApp/base.apk \ /system/app/SomeApp/base.apk
setUid 的拦截就是对普通应用卸载 unmount ksu 模块的修改
OverlayFS(KSU Next 推荐) 利用比较新的内核的 OverlayFS 特性实现联合挂载:
1 2 3 4 5 6 7 8 9 mount -t overlay overlay \ -o lowerdir=/system,upperdir=/data/adb/modules,workdir=/tmp \ /system
挂载的东西又是能单独开一篇万字长文的内容,这里也暂时不展开了,回头有空补一下文章。
KernelSU 2.0/3.0 更新了什么? 2.0 开始修复了测信道攻击,也是之前 kprobe hook 的调用性能问题导致的,源码我大概看了遍,基本重写了核心的 hook 逻辑,更新真勤奋啊。具体不展开了,大致列下修改的方案,主要是干掉了热路径上的 lsm/kprobes hook 来避免测信道的攻击:
通过 ioctl 通信,替换了 prctl,reboot 时候初始化了匿名 fd,通过这个 fd 进行通信不同 lsm hook prctl 了。
fsnotify 监控文件变更,而不是 lsm hook inode rename.
使用 tracepoint hook 替换了之前的 kprobes hook newfstatat /faccessat/execve/setresuid ,tracepoint 性能开销小,但不能任意插入hook
devpts 使用代理文件机制来做,不用之前的修改 inode selinux sid 的方案
……
总结与展望 面对内核级 Root 方案,已经很难做到去做应用层检测,可能的思路大致不过几种:
大数据的风控:Root 方案总是需要配合改机,账户,IP 等一系列下游的攻击手法,对抗的时候是有可能抓到聚集性特征的。
Root 模块的特征:内核 Root 本身由于非常的干净,所以去分析基于 Root 的攻击模块/应用去分析是更有可能的,毕竟 Root 社区的更新活跃度,肯定比黑产自己开发的要完善。
硬挖 Root 的洞:想要通杀 Root 检测,那就 read fxck source code,看看开源社区代码的本身问题,例如目前公开的测信道攻击。
但是从第一性原理来回看,当攻击者拿到了 Root 权限之后,对于用户态的应用去检测高权限本身就是不合理,或者说检测方案一定是不长久的,本质上来说最为长远的方案还是和硬件/系统厂商合作,将至高权拿回到防守方手中,不过依旧道阻且长。
洋洋洒洒几万字,这里简单学习了下 KernelSu Root。后面有空再写篇 Apatch 的。
参考资料