APatch KPM 开发:绕过 DirtySepolicy / Duck Detector 的 SELinux Root 检测
从 30 次死机重启中学到的:不是所有内核函数都能 hook,不是所有 syscall 都会经过文件系统,不是所有 kfunc 都存在于你的内核版本。
前言:DirtySepolicy
2024 年 8 月,LSPosed 团队发现了一个"不可能在用户态绕过"的 Root 检测方案,代号 DirtySepolicy。2026 年 5 月公开后,很快被各种检测工具集成。
这个方案聪明得让人恼火:它不做传统的事——查文件、查挂载点、查进程名。它只做一件事——直接问 SELinux 内核。
Android 的 App Zygote 进程(u:r:app_zygote:s0)天生就有查询 SELinux 策略的权限。检测工具利用这个进程,app可以直接问内核:"哥们,u:r:magisk:s0 这个 domain 存在吗?untrusted_app 能通过 binder 调用 magisk 吗?"
如果内核回答"存在"或"允许"——恭喜,你的设备已经被精准识别为已 Root。不管你的 Magisk Hide、Shamiko、TrickyStore 做得多么完美,SELinux 不会撒谎。
DirtySepolicy 官方文档里有一句话:"唯一绕过的方式是修改内核本身"。
好,那就改内核。
环境概述
- 设备: Xiaomi 手机,KernelPatch (APatch) Next
- 内核: Linux 5.4.210-qgki(高通 GKI,ARM64)
- 交叉编译器: GCC 11.2,
aarch64-none-elf-
- KPM 框架: APatch Kernel Patch Module,裸机编译,
-fno-builtin -mgeneral-regs-only
- 检测目标:
org.lsposed.dirtysepolicy(LSPosed 原版)
1:LSM hook 层面拦截 App Zygote 的查询,使其返回"干净"的结果
最初的思路很直观——在内核里找到 SELinux 做访问决策的那几个函数,inline hook 掉,看到 App Zygote 来问脏规则就撒谎。
目标函数
// 访问向量计算 — 用户态查询的入口
int security_compute_av_user(u32 ssid, u32 tsid, u16 tclass, struct av_decision *avd);
// 进程属性设置 — /proc/self/attr/current 写入路径
int security_setprocattr(const char *lsm, const char *name, void *value, size_t size);
方案
用 KPatch 框架的 hook_wrapN API,在 BEFORE callback 里修改输入参数。对于 AV 查询,把脏 SID 替换成一个安全 SID(如 u:r:kernel:s0);对于 procattr 写入,把写入长度改为 0。原函数照常执行,但因为输入已经被我们"污染"了,它自然会返回"安全"的结果。
头头是道。代码写好,编译,加载——手机重启。
调试过程:二分法排雷
接下来是漫长而枯燥的排错循环(本文略去中间 20+ 个诊断版本号):
| 测试 |
结论 |
单 hook setprocattr,空 callback |
✅ 不崩 |
单 hook compute_av_user,空 callback |
✅ 不崩 |
| 双 hook,空 callback |
✅ 不崩 |
双 hook,仅 pr_info 日志 |
✅ 不崩 |
双 hook,修改 args->argX(输入参数) |
❌ 崩 |
setprocattr 单独改 args->arg3=0 |
✅ 不崩 |
compute_av_user 单独改 args->arg0/arg1 |
❌ 崩 |
破案了:security_compute_av_user 这个函数不能碰——任何对 hook callback 中参数的修改都会导致内核 panic。即使只是改输入寄存器、让原函数自己跑完也不行。
为什么?
用 dmesg 追踪 hook 命中发现了更根本的问题:inline hook 根本就没被触发过。代码里打了 TRACE 日志,security_context_to_sid、security_setprocattr 的 hook 返回值显示rc=0(安装成功),但调用计数始终为零。
真相藏在一个不起眼的编译选项里:LTO(Link Time Optimization,链接时优化)。
现代 Android GKI 内核编译时开启了 LTO。编译器会把那些"小"的导出函数直接内联到它们的调用者体内。security_context_to_sid 的 exported symbol 还在(所以我们能 hook),但sel_write_access(selinuxfs 的 write 处理函数)里对它的调用已经被替换成了内联代码——根本不经过那个 symbol。
这就好比你在大门口的密码锁上装了一个监听器,但所有人都走车库进了屋。
2:HOOK syscall
既然内核函数这条路走不通,那就往上走一层——系统调用(syscall)。![]()
所有用户态程序想读写文件,最终都要经过 syscall。DirtySepolicy 调用 Java API SELinux.checkSELinuxAccess(),底层会在 libselinux 中打开 /sys/fs/selinux/access,写入 "scontext tcontext tclass perm",再读回结果。这些操作对应的 syscall 是 openat、write、read。
用 fp_hook_syscalln 替换 inline hook
这次换用 函数指针替换(fp_hook_syscalln)而不是代码修补(hook_wrap)。前者只改 syscall 表里的一个函数指针,不碰任何代码段,比 inline hook 安全得多——项目里另一个模块 draw-bypass 已经用这种方式 hook 了六个 syscall,稳定运行。
fp_hook_syscalln(__NR_write, 3, before_write, NULL, NULL);
谁在访问 selinuxfs?我不到啊
最初的 hook 只追踪 App Zygote 进程(用 SID 2165 识别),结果一个 syscall 都没抓到。放宽到所有进程,ioctl、writev、read 全加上——依然没有 selinuxfs 的痕迹。
加了 openat 追踪,想通过文件路径找线索。这时发现了第二个隐藏问题:
kfunc_lookup_name(strncpy_from_user_nofault);
// → kf_strncpy_from_user_nofault = 0000000000000000
strncpy_from_user_nofault 这个函数在 5.4 内核上不存在。内核版本 5.10+ 才有。KPM 框架虽然声明了这个 kfunc,但底层 kallsyms_lookup_name 返回 NULL,所以所有读用户态 buffer 的操作都静默失败了。
换成标准版的 strncpy_from_user(5.4 上有),一切豁然开朗:
[kpm-sel] BLOCK #1 fd=65: 'u:r:shell:s0 u:r:su:s0 2 2'
[kpm-sel] BLOCK #2 fd=65: 'u:r:adbroot:s0'
[kpm-sel] BLOCK #3 fd=65: 'u:r:app_zygote:s0 u:r:adbroot:s0 2 800000'
[kpm-sel] BLOCK #6 fd=65: 'u:r:magisk:s0'
[kpm-sel] BLOCK #7 fd=65: 'u:r:app_zygote:s0 u:r:magisk:s0 2 800000'
这就是 DirtySepolicy 在问内核:"Magisk 的 context 存在吗?App Zygote 能通过 binder 调 magisk 吗?adbd 能 transition 到 adbroot 吗?"
我们一一拦截,全部返回 -EINVAL。
死机卡顿与最后的优化
初版虽然能拦,但性能堪忧——38000+ 次扫描导致手机卡死重启。罪魁祸首是没过滤 fd:每个进程每次写 stdout(fd=1)我们都在扫描 buffer 内容。
加了一行过滤条件后问题消失:
if (fd <= 2 || !ubuf || count < 10 || count > 800) return;
跳过 stdin/stdout/stderr,跳过太短(< 10 字节,不可能是 SELinux context)和太长(> 800 字节,不可能是 selinuxfs 写入)的内容,扫描量从 38000 降到了实际命中的几个到几十个。
最终方案剖析
v4.0.0 的完整代码不到 100 行,核心逻辑如下:
用户态程序 write(fd, buf, count)
│
▼
sys_call_table[__NR_write]
│
▼
before_write(args) ← 我们的 hook
│
fd ≤ 2? buf = NULL? count 不在 10~800?
│
是 → return(走原始 sys_write)
否 ↓
│
strncpy_from_user(kbuf, buf, count) ← 读用户态 buffer
│
失败 → return(走原始 sys_write)
成功 ↓
│
find_dirty(kbuf) ← 子串搜索脏 context 列表
│
未命中 → return(走原始 sys_write)
命中 ↓
│
args->skip_origin = 1
args->ret = -EINVAL ← 跳过原始 sys_write,直接返回"无效参数"
三个关键技术点
1. kfunc 符号解析
KPM 运行在裸机环境中——没有 libc,没有内核链接。调用内核函数必须通过 kfunc 机制,运行时解析:
// 声明(不带 extern,让编译器分配 .bss 空间)
long kfunc_def(strncpy_from_user)(char *dst, const char __user *src, long count);
// 初始化时解析
kfunc_lookup_name(strncpy_from_user);
// → 等价于: kf_strncpy_from_user = kallsyms_lookup_name("strncpy_from_user");
// 调用时检查
if (!kfunc(strncpy_from_user)) return; // 函数不存在就跳过
copied = kfunc(strncpy_from_user)(kbuf, ubuf, len);
不同内核版本的 kallsyms 导出表不同。我们在 5.4 上发现 strncpy_from_user_nofault 不存在,但 strncpy_from_user 存在——这种差异需要通过 dmesg 日志中打印的 kfunc 指针地址(0x0000000000000000 vs 0xffffff...)来诊断。
2. 声明不可遗漏的细节:不带 extern
KPM 的 kfunc 声明如果带上 extern 关键字,符号在 .kpm ELF 的 symtab 中是 *UND*(未定义),KPatch loader 会尝试在内核中查找名为 kf_xxx 的符号并失败报错:
[-] KP E unknown symbol: kf_strncpy_from_user
去掉 extern,编译器产生 tentative definition,符号进入 .bss 段,加载器不再报错。这是在 v2.x 阶段踩的第一个坑,困扰了很久。
3. 为什么是 syscall 而不是内核函数
整个开发过程的核心教训用一张表就能说明白:
| 层次 |
方法 |
结果 |
| SELinux LSM 内部函数 |
hook_wrap inline hook |
LTO 内联,hook 从不触发;部分函数修改参数后崩溃 |
| Syscall 表函数指针 |
fp_hook_syscalln |
稳定,draw-bypass 验证过的机制 |
| 识别 App Zygote 进程 |
task_struct 内存偏移 |
5.4 内核的 comm 偏移和 5.10+ 不同,全猜错 |
| 识别 App Zygote 进程 |
security_task_getsecid via kfunc |
可用,但最终不需要(直接过滤 write 内容即可) |
| 读取用户态 buffer |
strncpy_from_user_nofault |
5.4 内核不存在此函数,kfunc 返回 NULL |
| 读取用户态 buffer |
strncpy_from_user |
✅ 可用 |
脏 Context 列表
当前拦截的 SELinux context 覆盖了主流 Root 方案的所有已知特征:
Magisk: u:r:magisk:s0, u:object_r:magisk_file:s0
KernelSU: u:r:ksu:s0, u:object_r:ksu_file:s0,
u:object_r:ksu_exec:s0, u:r:ksud:s0, 等等
LSPosed: u:object_r:lsposed_file:s0
Xposed: u:object_r:xposed_data:s0, u:object_r:xposed_file:s0
su/adb root: u:r:su:s0, u:r:adbroot:s0
DuckDetector: u:r:droidspacesd:s0, u:r:msd_daemon:s0,
u:r:msd_app:s0, u:r:duckdetector_dirty_policy_sentinel:s0
通用特征: u:object_r:tmpfs:s0, u:object_r:adb_data_file:s0
如果新的 Root 方案或检测工具引入了新的 SELinux context,需要在 dirty_ctx[] 数组中追加。
构建与部署
# 编译(需要在有 aarch64-none-elf- 交叉编译器的环境中)
cd kpms/kpm_selinux_filter
make clean && make
# 部署
adb push kpm_selinux_filter.kpm /data/local/tmp/
# 通过 APatch App 加载,或命令行
su -c kpmod load /data/local/tmp/kpm_selinux_filter.kpm
产物是一个 5KB 的 .kpm 文件(ELF 64-bit LSB relocatable, ARM aarch64),重启后消失,需配合 APatch 的自动加载功能使用。
未解之谜和局限
-
DirtySepolicy 的 syscall 路径:为什么我们追踪 openat 时看不到 selinuxfs 的路径?很可能 openat 发生在我们的 hook 安装之前(App Zygote 的预热阶段),或者使用了 ioctl 等非标准路径。不过最终方案不依赖路径追踪——直接看 write buffer 内容就够了。
-
Android 版本差异:在 Android 13+ 上,SELinux.checkSELinuxAccess() 的实现可能切换到 Binder IPC 路径(通过 system_server 代理),届时 write syscall 层面会抓不到。那时可能需要回到内核函数 hook 方案,或者寻找其他拦截点。
-
LTO 内联:这是整个项目最大的技术障碍。目前的 syscall 方案巧妙绕过了它,但如果未来需要在更底层做拦截(比如不只是 selinuxfs 写入,还包括内核内部的 SELinux 查询),就需要找到对抗 LTO 的方法——可能的思路包括通过 kprobes、或者在内核编译配置中禁用特定函数的 LTO。
-
Detect-then-bypass 的博弈:这个模块本质上是反应式的——我们知道检测工具在查哪些 context,然后去拦截。如果检测工具换了新的 context 名称或者换了新的检测维度(比如不查 context 存在性而查策略加载次数),模块需要跟着更新。
总结
从"在 SELinux 函数上加 inline hook"这个看似理所当然的起点出发,经历了 LTO 内联的毒打、kfunc 符号解析的坑、syscall 追踪的盲区、用户态 buffer 读取函数的版本差异,最终收敛到了一个不到 100 行的 syscall 过滤方案。
核心代码极其简单:
if (fd <= 2 || !ubuf || count < 10 || count > 800) return;
if (!kfunc(strncpy_from_user)) return;
copied = kfunc(strncpy_from_user)(kbuf, ubuf, count);
if (copied > 0 && find_dirty(kbuf, copied)) {
args->skip_origin = 1;
args->ret = -EINVAL;
}
但这 100 行背后是 30+ 次手机重启、无数个 dmesg 追踪输出、以及"为什么 inline hook 不触发"这个问题硬控了我两个小时。
内核编程的困难往往不在于逻辑复杂,而在于每一个假设都可能不成立——你以为函数可以 hook,它被 LTO 内联了;你以为 kfunc 都存在,它们在 5.4 上不叫这个名字;你以为 syscall 追踪能覆盖所有路径,检测的预加载发生在你 hook 之前;你以为直接读内存可以获取进程名,这个内核的 task_struct 布局和文档不一样。
在内核里,没有东西是免费的。每一个读用户态内存的操作都可能 sleep,每一次 hook 都可能崩溃,每一个 kfunc 都可能不存在。
">好在,最终结果是干净的。
2026 年 6 月,基于 KernelPatch/APatch KPM 框架开发。代码、Makefile 和本文档位于 kpms/kpm_selinux_filter/ 目录。