APatch KPM 开发实战——绕过 DirtySepolicy / Duck Detector 的 SELinux Root 检测
APatch KPM 开发实战:绕过 DirtySepolicy / Duck Detector 的 SELinux Root 检测
摘要
Android Root 环境隐藏攻防已从用户态文件隐藏、进程隐藏演进到内核层 SELinux 策略检测。LSPosed 团队公开的 DirtySepolicy 方案利用 App Zygote 进程的 SELinux 查询权限,直接探测内核中是否存在 Magisk、KernelSU 等注入的”脏”策略规则,号称”用户态无法绕过”。本文记录基于 KernelPatch/APatch 框架开发 KPM 内核模块(kpm_selinux_filter)的完整过程:从 inline hook 方案出发,经历 30 余次迭代、内核崩溃和诊断调试,最终采用 syscall 层拦截方案,以 5184 字节的轻量模块实现了对 DirtySepolicy 和 Duck Detector 的完全绕过。
1. 技术基础
1.1 KernelPatch / APatch 框架
KernelPatch 是一个 ARM64 Linux 内核热补丁框架,通过在合法内核镜像的 load_payload 段注入自定义 kpimg 二进制实现内核级代码执行。APatch 是基于 KernelPatch 的 Android Root 方案,支持动态加载 KPM(Kernel Patch Module)模块。
KPM 使用裸机 ARM64 交叉编译器(aarch64-none-elf-gcc)编译为可重定位 ELF 对象(.kpm),由 kpimg 内置的模块加载器加载。生命周期通过特殊 ELF section 管理:
1 | KPM_NAME("module-name"); |
1.2 Hook 机制
KernelPatch 提供两种 hook 机制:
| 机制 | API | 原理 | 风险 |
|---|---|---|---|
| Inline Hook | hook_wrapN() |
修改目标函数指令序言,跳转到 trampoline | 修改代码,可能崩溃 |
| FP Hook | fp_hook_syscalln() |
替换 sys_call_table 中的函数指针 |
仅改一个指针,安全 |
两种 hook 均支持 before/after 回调,回调可修改参数(args->argN)、返回值(args->ret)或跳过原函数(args->skip_origin=1)。
内核符号通过 kfunc 机制按需解析。关键约定:kfunc_def(name) 声明不可加 extern,否则编译器只生成声明不分配存储,.kpm 中符号标记为 *UND*,KPatch 加载器报 unknown symbol。
1 | // ❌ 错误 — extern 导致 *UND*,加载失败 |
1.3 检测原理
DirtySepolicy 和 Duck Detector 利用 Android App Zygote 进程(u:r:app_zygote:s0)在 doPreload() 阶段的 SELinux 查询权限,通过以下方式探测注入的 sepolicy 规则:
| 检测方式 | 接口 | 检测内容 |
|---|---|---|
| checkSELinuxAccess | Java API SELinux.checkSELinuxAccess() |
查询如 untrusted_app → magisk:s0 等规则是否 allow |
| contextExists | 写 /proc/self/attr/current 或 /sys/fs/selinux/context |
验证敏感 context(如 u:r:magisk:s0)是否存在 |
规则示例:
1 | untrusted_app → magisk:s0 binder call → Magisk 检测 |
上述两种检测方式最终都通过 write syscall 将检测字符串送入内核——这是本方案的核心立足点。
2. 方案探索
2.1 Inline Hook 尝试(v1.0–v2.5)
最初方案与同类 KPM 思路一致:用 hook_wrap 挂接 SELinux 内核函数,在函数内部修改参数或返回值来过滤查询。
目标 hook 点:
security_context_to_sid— 拦截 context 解析(先于 AV 查询)security_compute_av_user— 拦截 selinuxfs access 写入security_setprocattr— 拦截 /proc/self/attr/current 写入
结果:模块加载成功,hook 回调从未触发。 原因:GKI 内核启用了 LTO(链接时优化),security_context_to_sid 被内联到唯一调用者 sel_write_access 中。inline hook 替换了导出符号地址处的指令,但内核内部调用根本不经过该地址:
1 | sel_write_access() { |
2.2 崩溃诊断
除 LTO 绕过问题外,部分函数的 inline hook 直接导致内核 panic。通过二分法逐一验证:
| 版本 | hook 组合 | 行为 | 结果 |
|---|---|---|---|
| v2.3.1 | setprocattr only, 空 callback | - | ✅ |
| v2.3.2 | compute_av_user only, 空 callback | - | ✅ |
| v2.3.4 | 双 hook, pr_info only | 只读日志 | ✅ |
| v2.3.5 | 双 hook, BEFORE, avd->allowed=0 + skip_origin | 修改输出 | ❌ 崩溃 |
| v2.4.0 | 双 hook, BEFORE, argX=1 改写 | 修改输入 | ❌ 崩溃 |
| v2.4.2 | setprocattr only, BEFORE, arg3=0 | 单 hook, 改输入 | ✅ |
| v2.4.4 | compute_av_user only, BEFORE, arg 改写 | 单 hook, 改输入 | ❌ 崩溃 |
结论:
security_setprocattr的 BEFORE arg 修改是安全的——但被 LTO 绕过,无实际作用security_compute_av_user的任何修改(BEFORE/AFTER/arg/avd/ret/skip_origin 全覆盖)均导致内核 panic,崩溃发生在 hook 首次被触发时(约 30–50ms 后)。即使只改 BEFORE 的args->arg0这种”无害”操作也会崩溃。空 callback 完全正常,排除 hook 安装错误。疑与 ARM64 非标准函数序言或 CFI 有关。
2.3 转向 Syscall 层(v3.0–v3.2)
两个问题叠加意味着 inline hook 路线在 5.4.210-qgki 上完全走不通。于是转向 fp_hook_syscalln——已被 draw-bypass、hideproc 等模块验证安全的函数指针替换机制。
先后 hook 了 openat / write / read / writev / ioctl,追踪检测进程的 syscall 行为。关键发现:
App Zygote 进程(通过 kfunc
security_task_getsecid获取 SID=2165 识别)的 openat/write/read 记录始终为 0——App Zygote 不在 syscall 层操作 selinuxfsstrncpy_from_user_nofault在此 5.4 内核上不存在——kfunc 指针为0x0000000000000000。替代方案strncpy_from_user(会睡眠的版本)可用,且 write syscall 本身运行在进程上下文,调用安全替换为
strncpy_from_user后,成功捕获检测流量。DirtySepolicy 最终通过普通write()syscall(fd=65,即/sys/fs/selinux/下的文件)将检测字符串送入内核
1 | BLOCK #1 fd=65: 'u:r:shell:s0 u:r:su:s0 2 2' |
2.4 性能踩坑
v3.3.0 上线后系统卡死重启——原因是未过滤 fd ≤ 2(stdout/stderr),KPatch 自身的日志输出(包含 “magisk”/“ksu” 关键字)被拦截了 38000+ 次,导致 write syscall 大面积失败。
修复:fd <= 2 直接跳过,payload 限制在 10–800 字节。优化后系统稳定。
3. 最终实现(v4.0.0)
3.1 架构
1 | ┌─────────────────────────────────────────────────┐ |
3.2 核心代码(5184 字节)
1 | static void before_write(hook_fargs4_t *args, void *udata) { |
3.3 脏 Context 列表
1 | static const char *dirty_ctx[] = { |
3.4 性能优化要点
| 优化 | 说明 | 效果 |
|---|---|---|
fd ≤ 2 过滤 |
跳过 stdin/stdout/stderr | 消除 >99% 扫描(之前 38K 次卡死) |
10 ≤ count ≤ 800 |
排除单字节和大型 I/O | 进一步精简 |
| 滑动窗口匹配 | 栈缓冲区原地搜索,零额外分配 | O(n×m),n,m 均极小 |
| 零日志 | 生产版移除全部 pr_info |
无日志开销 |
fp_hook_syscalln |
只改一个指针,不改代码 | 无副作用 |
4. 方案对比
| 最初方案(inline hook) | 最终方案(syscall hook) | |
|---|---|---|
| Hook 位置 | 内核 SELinux 函数(深处) | write syscall(用户态入口) |
| Hook 数量 | 4–5 个函数 | 1 个 syscall |
| Hook 机制 | hook_wrap(修改代码) |
fp_hook_syscalln(改指针) |
| 过滤方式 | 修改 avd->allowed / 覆盖返回值 | 读 write buffer → 匹配字符串 → 返回 -EINVAL |
| 调用者识别 | 需预计算脏 SID、缓存 App Zygote SID | 无需——脏内容不论谁写都拦 |
| 状态管理 | 有状态(SID 缓存) | 无状态 |
| 模块大小 | ~14KB | 5184 字节 |
| LTO 影响 | 回调不触发 | 不受影响 |
compute_av_user 修改 |
100% 崩溃 | 不涉及 |
最初方案在 5.4.210-qgki 上被放弃的原因:
security_compute_av_user的任何修改都导致内核崩溃——BEFORE/AFTER/arg/avd/ret/skip_origin 全覆盖验证,无一例外- LTO 内联使 hook 回调从不触发——
security_context_to_sid等函数安装成功,但 kallsyms 地址不是实际调用入口
而 syscall 方案可行的原因:DirtySepolicy 无论走什么内核路径,数据都必须通过 write() syscall 进入内核——在城门设卡,不受城里修路(LTO 内联)的影响。
适用场景:LTO 不激进的较新内核(≥5.10)可考虑 inline hook 方案以获得更细粒度的调用者区分;5.4 或 LTO 激进的内核推荐 syscall 方案。
5. 局限性
- 脏 context 列表需手动维护:新 Root 方案可能引入新 context
- 仅拦截 write syscall:若检测工具改用
writev、pwrite64等,需扩展覆盖 - 不区分调用者:对所有进程一视同仁。但正常进程不会写脏 context,实际无影响
- 内核版本依赖:
strncpy_from_user在 5.4 可用;_nofault变体需 5.10+
6. 总结
通过 30 余次迭代,从 inline hook 到 syscall 层的完整迁移,最终以 5184 字节的轻量模块成功绕过 DirtySepolicy 和 Duck Detector 的全部 SELinux 检测项。
核心经验:
- Inline hook 在 LTO 内核上不可靠——导出符号不等于实际调用入口
- Syscall 层是最可靠的拦截点——函数指针替换(
fp_hook_syscalln)比代码修改(hook_wrap)更安全 - **kfunc 符号不可加
extern**——必须让编译器分配 .bss 空间 - 性能即正确性——回调在每次 write 时触发,必须在第一个
if过滤掉绝大多数调用 - 日志也会被拦截——fd≤2 过滤不仅是性能优化,更是功能正确性保障
加载
1 | cd kpms/kpm_selinux_filter && make |
参考
- KernelPatch — ARM64 Linux 内核热补丁框架
- DirtySepolicy — LSPosed 团队的 App Zygote SELinux 检测方案
- Duck Detector PR#22 — eltavine 的 SELinux context 有效性检测
- 看雪论坛:Android Root 环境隐藏:SELinux 查询探测与对抗 — selinux-query-guard KPM 方案参考