1. 1. 前言
  2. 2. Root 的本质:突破 Android 多层权限体系
    1. 2.1. 1.1 Android 的多层安全机制
    2. 2.2. 1.2 第一层:UID/GID 与 Capabilities
      1. 2.2.1. Capabilities:细分的 root 权限
    3. 2.3. 1.3 第二层:SELinux 强制访问控制
      1. 2.3.1. SELinux:强制访问控制
    4. 2.4. 1.4 第三层:Seccomp 系统调用过滤
      1. 2.4.1. Seccomp 的工作原理
    5. 2.5. 1.5 其他安全机制
    6. 2.6. 1.6 完整的 Root 攻击面
  3. 3. GKI 时代:为什么内核 Root 方案突然火了
    1. 3.1. 2.1 GKI 是什么?
    2. 3.2. 2.2 为什么 GKI 催生了内核 Root 方案?
  4. 4. KernelSU Next 原理深度剖析
    1. 4.1. 3.0 两种工作模式:LKM vs GKI
    2. 4.2. 3.1 LKM 模式:kernelsu.ko 的编译
    3. 4.3. 3.2 LKM 模式启动流程:从 ksuinit 到内核模块
      1. 4.3.1. 手动符号解析的核心流程
    4. 4.4. 3.2 Hook 机制:如何拦截系统调用
      1. 4.4.1. 方式一:LSM 钩子
        1. 4.4.1.1. override_security_head:突破只读保护的黑魔法
      2. 4.4.2. 方式二:Kprobes(兼容老内核)
        1. 4.4.2.1. Kprobes 基本原理
        2. 4.4.2.2. Kprobes 工作流程详解
        3. 4.4.2.3. 架构相关的寄存器访问
        4. 4.4.2.4. Kprobes 的优缺点
      3. 4.4.3. kernelsu.ko 的完整工作流程
        1. 4.4.3.1. 1. 模块初始化总览
        2. 4.4.3.2. 2. ksu_core_init() - 核心 Hook 初始化
        3. 4.4.3.3. 3. ksu_workqueue - 异步任务队列
        4. 4.4.3.4. 4. ksu_allowlist_init() - 应用白名单初始化
        5. 4.4.3.5. 5. ksu_throne_tracker_init() - Manager 追踪
        6. 4.4.3.6. 6. ksu_sucompat_init() - Su 兼容模式
        7. 4.4.3.7. 7. ksu_ksud_init() - ksud 用户/内核空间通信
        8. 4.4.3.8. 9. Hook 函数列表汇总
    5. 4.5. 3.3 Root 授权流程
      1. 4.5.1. 完整授权流程图
      2. 4.5.2. escape_to_root() 核心函数详解
        1. 4.5.2.1. 函数签名与入口
        2. 4.5.2.2. 步骤 1:修改 UID/GID(核心提权)
        3. 4.5.2.3. 步骤 2:设置 Capabilities(能力集)
        4. 4.5.2.4. 步骤 3:设置附加组(Supplementary Groups)
        5. 4.5.2.5. 步骤 4:提交凭证(不可逆操作)
        6. 4.5.2.6. 步骤 5:禁用 Seccomp(安全计算模式)
        7. 4.5.2.7. 步骤 6:切换 SELinux 上下文
      3. 4.5.3. SUSFS
    6. 4.6. 3.4 模块系统:OverlayFS vs Magic Mount
      1. 4.6.1. Magic Mount(传承自 Magisk)
      2. 4.6.2. OverlayFS(KSU Next 推荐)
  5. 5. KernelSU 2.0/3.0 更新了什么?
  6. 6. 总结与展望
  7. 7. 参考资料

见微知著-KernelSu内核Root方案

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 ← 受限很多

# KernelSU 使用的上下文
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
# kernel/Makefile(简化版)
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
# ... 更多 .o 文件

obj-$(CONFIG_KSU) += kernelsu.o

编译命令

1
2
3
4
5
6
7
8
9
# 使用外部模块编译方式(Out-of-tree build)
make ARCH=arm64 \
CROSS_COMPILE=aarch64-linux-android- \
-C /path/to/kernel-source \
M=$(pwd)/kernel \
CONFIG_KSU=m \
modules

# 生成 kernel/kernelsu.ko

关键区别

  • 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
// ksuinit/src/loader.rs 简化逻辑
pub fn load_module(path: &str) -> Result<()> {
// 1. 读取 kernelsu.ko 文件
let mut buffer = fs::read(path)?;
let elf = Elf::parse(&buffer)?;

// 2. 解析 /proc/kallsyms 获取内核符号表
// kallsyms 包含了内核所有导出符号的地址
// 格式:ffffffffc0123456 T printk
let kernel_symbols = parse_kallsyms()?;

// 3. 遍历模块的符号表,找到未定义符号
for sym in elf.syms.iter_mut() {
if sym.st_shndx != SHN_UNDEF {
continue; // 跳过已定义的符号
}

let name = elf.strtab.get_at(sym.st_name)?;

// 4. 在 kallsyms 中查找符号的真实地址
let real_addr = kernel_symbols.get(name)?;

// 5. 重定位:修改符号的属性
sym.st_shndx = SHN_ABS; // 标记为绝对地址
sym.st_value = *real_addr; // 填入真实地址

// 6. 写回 ELF 文件的内存缓冲区
buffer.pwrite_with(sym, offset, ctx)?;
}

// 7. 调用 init_module() 加载已重定位的模块
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
// kernel/core_hook.c
void __init ksu_lsm_hook_init(void) {
// 1. 找到 capability 模块的 task_prctl 钩子
void *cap_prctl = GET_SYMBOL_ADDR(cap_task_prctl);

// 2. 通过钩子函数地址反推 security_hook_heads 的位置
// security_hook_heads 是个全局结构,存储所有 LSM 钩子链表
struct hlist_head *prctl_head = find_head_addr(cap_prctl);

// KSU_LSM_HOOK_HACK_INIT 这个宏展开是下面的操作

// 3. 复制原有的钩子链表(这块内存是只读的)
struct hlist_head *new_head = copy_security_hlist(prctl_head);

// 4. 把自己的钩子添加到链表头部
hlist_add_tail_rcu(&ksu_hook.list, new_head);

// 5. 用 vmap 重新映射只读内存,替换链表头
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
// kernel/core_hook.c
static int override_security_head(void *head, const void *new_head, size_t len)
{
// 步骤 1: 获取页基址和偏移
// head 指向 security_hook_heads.task_prctl
unsigned long base = (unsigned long)head & PAGE_MASK; // 页对齐地址
unsigned long offset = offset_in_page(head); // 页内偏移

// 步骤 2: 确保修改范围不跨页(安全检查)
BUG_ON(offset + len > PAGE_SIZE); // 如果跨页就崩溃

// 步骤 3: 将虚拟地址转换为物理页
// __pa() 将虚拟地址转为物理地址
// phys_to_page() 获取对应的 struct page
struct page *page = phys_to_page(__pa(base));
if (!page) {
return -EFAULT;
}

// 步骤 4: 重新映射页面为可写(关键!)
// vmap() 会创建一个新的虚拟地址映射
// 参数: &page(物理页), 1(页数), VM_MAP(标志), PAGE_KERNEL(可读写)
void *addr = vmap(&page, 1, VM_MAP, PAGE_KERNEL);
if (!addr) {
return -ENOMEM;
}

// 步骤 5: 临时禁用中断,原子修改内存
local_irq_disable(); // 防止 CPU 调度打断操作
memcpy(addr + offset, new_head, len); // 修改钩子链表头指针!
local_irq_enable();

// 步骤 6: 解除映射
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 ← 已修改! │ │
│ └────────────────────────────────┘ │
└─────────────────────────────────────┘

关键技术点解析

  1. PAGE_MASK 与页对齐

    1
    2
    3
    4
    5
    // PAGE_MASK = 0xFFFFF000 (4KB 页)
    // 假设 head = 0xffffffc012345678
    unsigned long base = 0xffffffc012345678 & 0xFFFFF000
    = 0xffffffc012345000 // 页起始地址
    unsigned long offset = 0x678 // 页内偏移
  2. vmap() 的作用

    • 创建一个新的虚拟地址映射到同一物理页
    • 新映射的权限是 PAGE_KERNEL(可读写)
    • 原始映射仍然保持只读
    • 内核内存管理器(MMU)维护两个虚拟地址指向同一物理地址
  3. 为什么要禁用中断?

    1
    2
    3
    local_irq_disable();  // 禁用本 CPU 中断
    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
// 假设我们要 hook task_prctl
void ksu_lsm_hook_init(void)
{
// 1. 找到 capability 模块的 cap_task_prctl 钩子函数
void *cap_prctl = kallsyms_lookup_name("cap_task_prctl");

// 2. 反推出 security_hook_heads.task_prctl 的地址
// 原理:cap_prctl 的 security_hook_list 结构包含了指向链表头的指针
struct hlist_head *prctl_head = find_head_addr(cap_prctl);
// prctl_head 现在指向 security_hook_heads.task_prctl

// 3. 复制原有钩子链表
struct hlist_head *new_head = copy_security_hlist(prctl_head);
/*
* new_head 是新分配的链表,内容是:
* [cap_prctl_hook] -> [yama_prctl_hook] -> NULL
*/

// 4. 在新链表尾部添加 KSU 的钩子
hlist_add_tail_rcu(&ksu_task_prctl_hook.list, new_head);
/*
* new_head 现在是:
* [cap_prctl_hook] -> [yama_prctl_hook] -> [ksu_prctl_hook] -> NULL
*/

// 5. 用 override_security_head 替换原链表头指针
override_security_head(prctl_head, new_head, sizeof(*new_head));
/*
* 原本: security_hook_heads.task_prctl -> [cap_prctl] -> [yama] -> NULL
* 现在: security_hook_heads.task_prctl -> [cap_prctl] -> [yama] -> [ksu] -> NULL
*/
}

方式二: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
// kernel/core_hook.c
static int handler_pre(struct kprobe *p, struct pt_regs *regs)
{
// 获取真实的寄存器上下文(架构相关)
struct pt_regs *real_regs = PT_REAL_REGS(regs);

// 提取 prctl 系统调用的参数
int option = (int)PT_REGS_PARM1(real_regs); // 第1个参数
unsigned long arg2 = (unsigned long)PT_REGS_PARM2(real_regs); // 第2个参数
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);

// 调用 KSU 的 prctl 处理函数
return ksu_handle_prctl(option, arg2, arg3, arg4, arg5);
}

// 定义 kprobe 结构
static struct kprobe prctl_kp = {
.symbol_name = PRCTL_SYMBOL, // "__arm64_sys_prctl" 或 "__x64_sys_prctl"
.pre_handler = handler_pre, // 前置处理函数
};

// 注册 kprobe
int ksu_kprobe_init(void)
{
int rc = register_kprobe(&prctl_kp);
if (rc) {
pr_err("prctl kprobe failed: %d\n", rc);
return rc;
}

// 也可以 hook 其他函数,如 vfs_rename
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
// ARM64 架构
#ifdef CONFIG_ARM64
// 系统调用参数在 x0-x5 寄存器
#define PT_REGS_PARM1(x) ((x)->regs[0]) // x0
#define PT_REGS_PARM2(x) ((x)->regs[1]) // x1
#define PT_REGS_PARM3(x) ((x)->regs[2]) // x2
// ...
#endif

// x86_64 架构
#ifdef CONFIG_X86_64
// 系统调用参数在 rdi, rsi, rdx, r10, r8, r9
#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 的优缺点

优点

  1. 通用性强

    • 几乎所有现代 Linux 内核都支持(需要 CONFIG_KPROBES=y
    • 不依赖特定的内核版本或编译选项
  2. 灵活性高

    • 可以 hook 任意内核函数(包括静态函数,通过地址)
    • 支持前置处理(pre_handler)、后置处理(post_handler)、错误处理(fault_handler)
  3. 动态可控

    • 可以在运行时注册/注销
    • 支持临时禁用(disable_kprobe

缺点

  1. 性能开销
    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
// kernel/ksu.c
module_init(kernelsu_init); // ← LKM 模式下由 insmod 调用

int kernelsu_init(void)
{
// 1. 初始化核心 hook
ksu_core_init();

// 2. 创建工作队列(用于异步任务)
ksu_workqueue = alloc_ordered_workqueue("kernelsu_work_queue", 0);

// 3. 初始化 allowlist(应用白名单),数据结构的初始化
ksu_allowlist_init();

// 4. 初始化 throne_tracker(Manager 追踪),这个暂时没用
ksu_throne_tracker_init();

// 5. 初始化 Kprobe hooks(如果启用)
#ifdef CONFIG_KSU_KPROBES_HOOK
ksu_sucompat_init(); // su 兼容模式
ksu_ksud_init(); // ksud 守护进程通信
#endif

// 6. 隐藏模块(非 DEBUG 模式)
#ifdef MODULE
#ifndef CONFIG_KSU_DEBUG
kobject_del(&THIS_MODULE->mkobj.kobj); // 从 sysfs 移除模块
#endif
#endif

return 0;
}

下面我们逐个函数详细展开。


2. ksu_core_init() - 核心 Hook 初始化

这是整个 KernelSU 的心脏,负责建立内核 hook 机制。

1
2
3
4
5
6
7
8
9
// kernel/core_hook.c
void ksu_core_init(void)
{
#ifdef CONFIG_KSU_LSM_SECURITY_HOOKS
ksu_lsm_hook_init(); // 优先使用 LSM
#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)
{
// 找 prctl 的符号地址
void *cap_prctl = GET_SYMBOL_ADDR(cap_task_prctl);
// 在全局的 security_hook_heads 找到 head
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");
}

// inode_rename 这里修改比较 trick 通过找 inode_killpriv,然后再通过编译时结构体中的偏移量来计算运行时 inode_rename 的 index
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);
}
// 同样对 setuid 来进行 lsm hook
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);

作用:创建一个有序工作队列,用于处理耗时任务。

使用场景

  1. 保存/加载 allowlist

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // kernel/allowlist.c
    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);

    // 异步保存到 /data/adb/ksu/.allowlist
    queue_work(ksu_workqueue, &ksu_save_work);
  2. 动态卸载 Kprobe

    1
    2
    3
    4
    5
    // kernel/ksud.c
    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
// kernel/allowlist.c
void ksu_allowlist_init(void)
{
int i;

// 1. 编译时检查(确保数据结构大小正确)
BUILD_BUG_ON(sizeof(allow_list_bitmap) != PAGE_SIZE); // 4KB
BUILD_BUG_ON(sizeof(allow_list_arr) != PAGE_SIZE);

// 2. 初始化 allow_list_arr(快速查找数组)
for (i = 0; i < ARRAY_SIZE(allow_list_arr); i++)
allow_list_arr[i] = -1; // -1 表示空槽位

// 3. 初始化链表头(用于存储详细的应用配置)
INIT_LIST_HEAD(&allow_list);

// 4. 初始化工作队列任务
INIT_WORK(&ksu_save_work, do_save_allow_list);
INIT_WORK(&ksu_load_work, do_load_allow_list);

// 5. 初始化默认的 root 配置
init_default_profiles();
}

数据结构详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1. 位图(快速判断 uid 是否在白名单)
static uint8_t allow_list_bitmap[PAGE_SIZE]; // 4KB = 32768 bits
#define BITMAP_UID_MAX 32767

// 2. 数组(快速遍历白名单 uid)
static int allow_list_arr[PAGE_SIZE / sizeof(int)]; // 1024 个 uid
static int allow_list_pointer = 0; // 数组中实际存储的 uid 数量

// 3. 链表(存储详细的应用配置)
struct perm_data {
struct list_head list;
struct app_profile profile; // 包含 uid、capabilities、selinux_domain 等
};
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()
{
// 完整的 capabilities(所有权限)
kernel_cap_t full_cap = CAP_FULL_SET;

// 默认 root profile
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");

// 默认 non-root profile(umount modules)
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"

// 文件格式:
// [文件头]
// u32 magic = 0x7f4b5355 (' KSU')
// u32 version = 3
// [应用配置列表]
// struct app_profile profile1
// struct app_profile profile2
// ...

5. ksu_throne_tracker_init() - Manager 追踪
1
2
3
4
5
// kernel/throne_tracker.c
void ksu_throne_tracker_init()
{
// nothing to do
}

实际的追踪逻辑在哪里?

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) {
// skip kernel threads
return 0;
}

if (current_uid().val != 1000) {
// skip non system uid
return 0;
}

if (!old_dentry || !new_dentry) {
return 0;
}

// /data/system/packages.list.tmp -> /data/system/packages.list
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;
}

// 调用到 track_throne 中,主要是为了找到 manager 的 uid

// 整个过程还挺复杂的,遍历几个文件夹,同时防止跨系统挂载,这个有个核心 is_manager_apk 用来校验 base.apk 的签名是否是预置的签名

bool is_manager_apk(char *path)
{
// set debug info to print size and hash to kernel log
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);
}

// 可以看到是 makefile 中内置的
ifndef KSU_NEXT_MANAGER_HASH
KSU_NEXT_MANAGER_HASH := 79e590113c4c4c0c222978e413a5faa801666957b1212a328e46c00c69821bf7
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
// kernel/sucompat.c
void ksu_sucompat_init()
{
#ifdef CONFIG_KSU_KPROBES_HOOK
// 注册 4 个 Kprobe 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
// kernel/ksud.c
void ksu_ksud_init()
{
#ifdef CONFIG_KSU_KPROBES_HOOK
int ret;

// 1. Hook execve 系统调用(检测 init 启动)
ret = register_kprobe(&execve_kp);
pr_info("ksud: execve_kp: %d\n", ret);

// 2. Hook vfs_read(注入 init.rc 脚本)
ret = register_kprobe(&vfs_read_kp);
pr_info("ksud: vfs_read_kp: %d\n", ret);

// 3. Hook input_event(检测开机完成,安全保护模式降级掉各种模块)
ret = register_kprobe(&input_event_kp);
pr_info("ksud: input_event_kp: %d\n", ret);

// 4. 初始化 work 结构(用于动态卸载 hook)
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,
};

// 作用:
// - init 二阶段的时候 apply_kernelsu_rules 注入 kernelsu 自定义的 selinux 规则
// - ksu_android_ns_fs_check 做些 mount namespace 的兼容,Android init 进程和内核 init_task 使用了不同的 mount namespace 例如 wsa ?
// - 以及最后会卸载 execve hook:stop_execve_hook,减少性能影响

// 2. vfs_read hook - 注入 init.rc 脚本
static struct kprobe vfs_read_kp = {
.symbol_name = "vfs_read",
.pre_handler = vfs_read_handler_pre,
};

// 作用:
// - 拦截 init 进程读取 /atrace.rc
// - 在返回的内容末尾注入自定义脚本 KERNEL_SU_RC

// 3. input_event hook - 检测开机完成
static struct kprobe input_event_kp = {
.symbol_name = "input_event",
.pre_handler = input_event_handler_pre,
};

// 作用:
// - 监听音量键 3 次做安全模式来不启动安装的 kernelsu 模块,防止手机变砖直接卡死

注入的 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
// kernel/core_hook.c
void escape_to_root(void)
{
struct cred *cred;

// 1. 准备新的凭证结构体
cred = prepare_creds();
if (!cred) {
pr_warn("prepare_creds failed!\n");
return;
}

// 2. 防止重复提权
if (cred->euid.val == 0) {
pr_warn("Already root, don't escape!\n");
abort_creds(cred);
return;
}

// 3. 获取当前 UID 的 root profile(配置)
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
// kernel/allowlist.h
struct root_profile {
uid_t uid; // 目标 UID(通常是 0)
gid_t gid; // 目标 GID(通常是 0)
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]; // SELinux 上下文
};

步骤 1:修改 UID/GID(核心提权)
1
2
3
4
5
6
7
8
9
10
// 4. 修改用户和组 ID
cred->uid.val = profile->uid; // 真实 UID (通常设为 0)
cred->suid.val = profile->uid; // 保存的 UID
cred->euid.val = profile->uid; // 有效 UID (权限检查用这个)
cred->fsuid.val = profile->uid; // 文件系统 UID

cred->gid.val = profile->gid; // 真实 GID
cred->fsgid.val = profile->gid; // 文件系统 GID
cred->sgid.val = profile->gid; // 保存的 GID
cred->egid.val = profile->gid; // 有效 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
// 5. 设置 capabilities
cred->securebits = 0; // 清除安全位(允许提权)

// 需要 CAP_DAC_READ_SEARCH 因为 /data/adb/ksud 非 root 进程无法访问
// 这个能力会在 exec 后自动丢弃(不在 cap_inheritable 中)
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));

// 将 ambient caps 全部清零
// 修复 dbus cap dropping 时的 "operation not permitted" 错误
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 // 允许改变进程 UID
CAP_SETGID // 允许改变进程 GID
CAP_SYS_ADMIN // 系统管理员权限(挂载、unshare 等)
CAP_NET_ADMIN // 网络管理(iptables 等)
CAP_SYS_MODULE // 加载/卸载内核模块
...

为什么临时添加 CAP_DAC_READ_SEARCH

1
2
3
4
// /data/adb/ksud 的权限是 0700 (root:root)
// 普通进程 (uid=10086) 无法访问
// 临时添加这个能力以便执行 ksud
// exec 后会自动丢弃(因为不在 cap_inheritable 中)

步骤 3:设置附加组(Supplementary Groups)
1
2
// 6. 设置附加组
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;
}

// 快速路径:如果只有一个组且为 0,直接使用预定义的 root_groups
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;
}

// 分配新的 group_info 结构
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;
}

// 填充组 ID
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
# root 用户的典型组配置
uid=0 gid=0 groups=0,1004,1007,1011,1015,1028,3001,3002,3003,3006,3009,3011

# 组 ID 含义(Android 特定):
# 1004: AID_INPUT (访问 input 设备)
# 1007: AID_LOG (读取日志)
# 1015: AID_SDCARD_RW (SD 卡读写)
# 1028: AID_SDCARD_R (SD 卡只读)
# 3001: AID_NET_BT_ADMIN (蓝牙管理)
# 3002: AID_NET_BT (蓝牙)
# 3003: AID_INET (创建网络 socket)
# 3006: AID_NET_BW_STATS (网络统计)

步骤 4:提交凭证(不可逆操作)
1
2
// 7. 提交凭证(原子操作,不可撤销)
commit_creds(cred);

commit_creds 会原子性地替换进程凭证,这是单向操作,一旦提权就无法回退。


步骤 5:禁用 Seccomp(安全计算模式)
1
2
3
4
5
6
// 8. 禁用 Seccomp
// Seccomp 会限制进程可以调用的系统调用
// 必须在持有 sighand->siglock 的情况下操作
spin_lock_irq(&current->sighand->siglock);
disable_seccomp();
spin_unlock_irq(&current->sighand->siglock);

Seccomp 是什么?

Seccomp 是 Linux 沙箱机制,限制应用只能调用特定的系统调用(如只允许 read/write,禁止 mount/setuid)。

为什么要禁用?

Root 进程需要完整的系统调用权限,KSU 在内核直接清除 seccomp 过滤器。


步骤 6:切换 SELinux 上下文
1
2
// 9. 切换到 KSU 的 SELinux domain
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 namespace
# KSU 在特定应用的 namespace 里挂载模块

# 例如:修改 /system/app/SomeApp/base.apk
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
# OverlayFS 分层结构:
# - lowerdir: 原始 /system(只读)
# - upperdir: 模块修改层(读写)
# - workdir: 工作目录
# - merged: 合并后的视图

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 来避免测信道的攻击:

  1. 通过 ioctl 通信,替换了 prctl,reboot 时候初始化了匿名 fd,通过这个 fd 进行通信不同 lsm hook prctl 了。
  2. fsnotify 监控文件变更,而不是 lsm hook inode rename.
  3. 使用 tracepoint hook 替换了之前的 kprobes hook newfstatat /faccessat/execve/setresuid ,tracepoint 性能开销小,但不能任意插入hook
  4. devpts 使用代理文件机制来做,不用之前的修改 inode selinux sid 的方案
  5. ……

总结与展望

面对内核级 Root 方案,已经很难做到去做应用层检测,可能的思路大致不过几种:

  1. 大数据的风控:Root 方案总是需要配合改机,账户,IP 等一系列下游的攻击手法,对抗的时候是有可能抓到聚集性特征的。
  2. Root 模块的特征:内核 Root 本身由于非常的干净,所以去分析基于 Root 的攻击模块/应用去分析是更有可能的,毕竟 Root 社区的更新活跃度,肯定比黑产自己开发的要完善。
  3. 硬挖 Root 的洞:想要通杀 Root 检测,那就 read fxck source code,看看开源社区代码的本身问题,例如目前公开的测信道攻击。

但是从第一性原理来回看,当攻击者拿到了 Root 权限之后,对于用户态的应用去检测高权限本身就是不合理,或者说检测方案一定是不长久的,本质上来说最为长远的方案还是和硬件/系统厂商合作,将至高权拿回到防守方手中,不过依旧道阻且长。

洋洋洒洒几万字,这里简单学习了下 KernelSu Root。后面有空再写篇 Apatch 的。

参考资料