文章总结: 本文深入分析了APatch内核模块中supercall系统调用的实现机制,重点介绍了其通过双层密钥验证(XOR常量时间比较与SHA256哈希比对)实现免密Root和SELinux全放行的技术细节。文章详细阐述了密钥的物理存储、信任分级以及管理器免密认证流程,为内核安全研究提供了实战经验。 综合评分: 85 文章分类: 红队,内网渗透,安全工具,免杀,实战经验
挖到 APatch 内核底层!supercall 一套机制搞定免密 Root、SELinux 全放行
原创
人生导师 人生导师
OnePanda-Sec
2026年7月28日 10:00 浙江
在小说阅读器读本章
去阅读
OnePanada-Sec 招新啦
招新要求
- 热爱网络安全,喜欢 CTF;
- 拥有 CTF 比赛经验,有较好比赛成绩的;
- 乐于奉献、热爱分享,愿意提升自己同时帮助他人;
- 时间允许参加各类赛事,服从战队管理与安排;
- 各类比赛获奖者、能力出众者视情况考量;
- 未参与其他高校联队;
- 大一同学视情况放宽资历要求。
联系方式
请将个人简历发送至以下邮箱:
简历邮箱:[email protected]
这一篇就从这条线来进行分析内核的 root call 实现__NR_supercall 这个被定义为 45
int supercall_install(){ int rc = 0; hook_err_t err = hook_syscalln(__NR_supercall, 6, before, 0, 0); if (err) { log_boot(”install supercall hook error: %d\n”, err); rc = err; goto out; }out: return rc;}
写过 APatch 内核模块的佬应该是看出来的,这个 hook 和我们写模块的 hook 是一样的,都是 hook 特定的系统调用号,然后交给回调函数进行调用,主要还是看回调函数的逻辑
回调函数 before
int uid = current_uid();if (get_ap_mod_exclude(uid)) return;
看看是不是原始的正常系统调用,正常放行
int is_trusted_caller = 0;int is_authed = 0;if (has_preset_superkey()) { const char *__user key_user = (const char *__user)syscall_argn(args, 0); char key[MAX_KEY_LEN]; long len = compat_strncpy_from_user(key, key_user, MAX_KEY_LEN); if (len <= 0) return; is_authed = !auth_superkey(key); is_trusted_caller = is_authed;}
这里是对比 superkey 是否正确,如果不正确就不放行。这个 auth_superkey 的实现非常讲究,下面单独拆开分析。
auth_superkey 的双层验证
// base/predata.c:31-60int auth_superkey(const char *key){ int rc = 0; // 第一层:逐字节 XOR 常量时间比较 for (int i = 0; superkey[i]; i++) { rc |= (superkey[i] ^ key[i]); } if (!rc) // rc=0 表示逐字节完全匹配 goto out; // 第一层没过,尝试第二层 if (!enable_root_key) // 没启用 root key 就直接失败 goto out; // 第二层:SHA256 哈希比对 BYTE hash[SHA256_BLOCK_SIZE]; SHA256_CTX ctx; sha256_init(&ctx); sha256_update(&ctx, (const BYTE *)key, lib_strnlen(key, SUPER_KEY_LEN)); sha256_final(&ctx, hash); int len = SHA256_BLOCK_SIZE > ROOT_SUPER_KEY_HASH_LEN ? ROOT_SUPER_KEY_HASH_LEN : SHA256_BLOCK_SIZE; rc = lib_memcmp(root_superkey, hash, len); // 首次匹配成功 → 把明文密钥”下载”到内核 static bool first_time = true; if (!rc && first_time) { first_time = false; reset_superkey(key); // 用明文覆盖 superkey enable_root_key = false; // 关闭 root key 验证,后续走第一层 } out: return !!rc; // 0=成功, 1=失败(内核惯例)}
返回值语义(关键):!!rc 把任意非零压成 1。auth_superkey 返回0 表示成功,1 表示失败——标准内核”返回 0 表示 OK”的约定。所以 before 里的 is_authed = !auth_superkey(key) 意思是:验证成功时 !0 = 1(已认证),失败时 !1 = 0(未认证)。
第一层:XOR 常量时间比较
for (int i = 0; superkey[i]; i++) { rc |= (superkey[i] ^ key[i]);}
不是 if (memcmp(…)),不是 if (strcmp(…))。rc |= 的设计有两个目的:
1.常量时间:无论第几个字节开始不同,循环都跑完全程,不会提前 break。这防止了基于时间的侧信道攻击——攻击者不能通过”比较耗时”来推测密钥前缀。
2.累积错误:rc 累积所有字节的差异位,最终只要有任何一位不同,rc 就非零。
第二层:Root Key Hash 验证
如果第一层 XOR 比较失败,且 enable_root_key 为真,则进入 SHA256 哈希验证。
这是一个预置哈希、运行时验证明文的机制。流程如下:
刷机时: kptools --root-skey → SHA256 哈希写入 start_preset.root_superkey 启动时: enable_root_key = true(由 predata_init 设置) superkey 是随机值(不知道真实密钥)
首次认证: 用户态传入 ”my_real_key” → SHA256(”my_real_key”) == root_superkey? ✅ → reset_superkey(”my_real_key”) 把明文写进 superkey → enable_root_key = false 关闭哈希验证通道 → 后续请求走第一层 XOR 快速路径
设计意图:刷机时不把明文密钥嵌入镜像,只嵌入 SHA256 哈希。即使有人 dump 了 boot.img,拿到的也只是哈希值,反推不出原文。等管理器首次连接时,”临时”用哈希验证一次,验证通过后把明文下载到内核内存中的 superkey 字段,之后就走快速的 XOR 比较。
has_preset_superkey:是否预设了密钥
// base/predata.c:114-117int has_preset_superkey(){ return superkey_is_user_set || root_superkey_is_set;}
两个标志位在 predata_init() 里设置:
// base/predata.c:119-151void predata_init(){ superkey = (char *)start_preset.superkey; // 指向镜像中预留的 64 字节 root_superkey = (char *)start_preset.root_superkey; // 指向镜像中预留的 32 字节 superkey_is_user_set = lib_strnlen(superkey, SUPER_KEY_LEN) > 0; root_superkey_is_set = *(uint64_t *)root_superkey; enable_root_key = false; // 如果没预设密钥 → 生成随机密钥兜底 if (!superkey_is_user_set) { enable_root_key = true; // 随机密钥没意义 → 开启 root key 通道 int len = SUPER_KEY_LEN > 16 ? 16 : SUPER_KEY_LEN; len--; for (int i = 0; i < len; ++i) { uint64_t rand = rand_next() % (sizeof(bstr) - 1); superkey[i] = bstr[rand]; // 随机生成 15 字符密钥 } }}
| | | | | | | — | — | — | — | — | | 刷机参数 | superkey_is_user_set | root_superkey_is_set | enable_root_key | 行为 | | –skey KEY | ✅ | ❌ | false | 第一层 XOR 直接验证 | | –root-skey HASH | ❌ (随机填充) | ✅ | true | 必须走第二层 SHA256 | | 都不传 | ❌ | ❌ | true | 纯随机密钥,必须事后 |
#
密钥的物理存储位置
superkey 嵌入在内核镜像的 start_preset_t 结构体里,位于 .kp_start_preset 段:
// base/start.h:27-28typedef struct { // ... uint8_t superkey[SUPER_KEY_LEN]; // 64 字节 (0x40) uint8_t root_superkey[ROOT_SUPER_KEY_HASH_LEN]; // 32 字节 (0x20, = SHA256_BLOCK_SIZE) // ...} start_preset_t;
打补丁时,kptools –skey “xxx” 直接把明文字符串写入 start_preset.superkey 偏移处。kptools –root-skey “xxx” 则是把 32 字节的 SHA256 哈希写入 start_preset.root_superkey。
运行时通过 reset_superkey() 可以动态覆盖内存中的 superkey(这个内存区已被标记为可写):
// base/predata.c:62-66void reset_superkey(const char *key){ lib_strlcpy(superkey, key, SUPER_KEY_LEN); dsb(ish); // 数据同步屏障,确保写入对其它 CPU 可见}
dsb(ish) 保证在多核环境下密钥写入后立刻对所有核可见,防止其它 CPU 读到旧值导致认证误判。
双布尔值的信任分级
回到 before 的认证逻辑,两个变量不是冗余——它们是不同维度的权限:
is_trusted_caller → 能不能进这个门?(门口保安)is_authed → 进门后发什么级别的通行证?(前台)
所有可能的状态组合:
| | | | | | | | — | — | — | — | — | — | | 密钥匹配 | 管理员 UID | su 白名单 | is_trusted_caller | is_authed | 效果 | | ✅ | — | — | 1 | 1 | 完全信任,全部命令可用 | | ❌ | ✅ | — | 1 | 1 | 完全信任(管理员可提权但不需要密钥) | | ❌ | ❌ | ✅ | 1 | 0 | 可调 su,但 | | ❌ | ❌ | ❌ | 1 | 0 | 放行原始syscall,不拦截 |
关键差异在第三行:su 白名单用户(比如被管理器授权了 root 的普通 app)可以调 SUPERCALL_SU 切换 UID,但不能读/改 superkey,也不能加载/卸载内核模块。这两项在 supercall() 调度器里被 if (!is_authed) return -EPERM 卡住:
// supercall.c - 不需要 is_authed 的命令(su 白名单用户可调)SUPERCALL_HELLO / KLOG / SU / SU_TASK / KSTORAGE_* / ... // 需要 is_authed == 1 的命令(必须通过密钥或管理员身份)if (!is_authed) return -EPERM;SUPERCALL_SKEY_GET / SKEY_SET / SKEY_ROOT_ENABLE // 密钥管理SUPERCALL_KPM_LOAD / KPM_UNLOAD / KPM_CONTROL // 模块管理
管理器如何免密钥认证
管理器(me.bmax.apatch)不用传 superkey。内核启动时通过 APK 签名验证建立信任:
// base/predata.c:70-74static const struct trusted_manager_entry trusted_managers[] = { { ”me.bmax.apatch”, { 0xd7, 0x1d, 0xad, 0xc0, ... } } // SHA256 签名摘要}; // userd.c:1048 refresh_trusted_manager_uid_from_packages_list()// 1. 读 /data/system/packages.list 找到 me.bmax.apatch 的 APK 路径和 UID// 2. 解析 APK V2/V3/V3.1 签名块// 3. SHA256(签名证书) == trusted_managers[] 中的硬编码值?// 4. 匹配 → trusted_manager_uid =
之后管理器的任意进程调用 supercall 时,before 里发现 uid == trusted_manager_uid,直接给 is_authed = 1,无需密钥。
完整认证流程图
syscall(45, key, cmd|ver, args...) │ ▼before() 回调 │ ├─ get_ap_mod_exclude(uid)? → return (放行,不拦截) │ ├─ has_preset_superkey()? │ └─ YES → 从 arg0 拷贝 key 到内核 │ ├─ 拷贝失败 → return │ └─ auth_superkey(key): │ ├─ 第一层 XOR: key == superkey? → 成功 ✓ │ └─ 第二层 SHA256: SHA256(key) == root_superkey? │ └─ 首次成功 → reset_superkey(key), 关闭 root key │ → is_authed = !auth_superkey(key) │ ├─ is_trusted_manager_uid(uid)? → is_trusted_caller=1, is_authed=1 ├─ is_su_allow_uid(uid)? → is_trusted_caller=1 │ ├─ !is_trusted_caller → return (放行) │ └─ supercall(is_authed, cmd, a1, a2, a3, a4) ├─ 基础命令: HELLO / KLOG / 版本查询 (始终可用) ├─ SU 命令: SU / SU_GRANT / SU_REVOKE / KSTORAGE (trusted即可) ├─ 需认证: SKEY_* / KPM_* (is_authed=1 才放行) └─ 其余 → -ENOSYS
supercall 函数内
before 通过认证后,将控制权交给 supercall(is_authed, cmd, a1, a2, a3, a4),这是一个典型的命令分发器。整个 switch 里有几十个 case,但绝大部分只是对配置数据的增删查改(增删白名单、读写路径、读写 SELinux 上下文),没有分析价值。真正值得深入的是三个与提权机制本身相关的命令。
SUPERCALL_SU:当前进程提权
这是最核心的 su 操作。调用链很短:
用户态 sc_su(key, &profile) → syscall(45, key, cmd, profile) → before() 认证 → supercall(is_authed, SUPERCALL_SU, profile) → call_su(profile) // 薄封装:把 profile 从用户态拷进来 → commit_su(to_uid, sctx) // 真正的提权
call_su 只是用 memdup_user 把 su_profile 从用户态拷贝到内核态,然后调 commit_su。commit_su 才是灵魂:
// accctl.c:122-129int commit_su(uid_t to_uid, const char *sctx){ if (all_allow_sid != SECSID_NULL && !to_uid) { return commit_kernel_su(); // 走完整 root 通道 } else { return commit_common_su(to_uid, sctx); // 走普通提权通道 }}
两条通道的选择逻辑:如果设置了全局宽松 SELinux 上下文,且目标是 root(to_uid=0),就走内核级提权;否则走普通用户态提权。
通道一:commit_kernel_su —— 完整 root
// accctl.c:71-76int commit_kernel_su(){ struct cred *new = prepare_kernel_cred(0); // NULL 参数 → 拿内核的 cred set_security_override(new, all_allow_sid); // 盖上宽松 SELinux 标签 return commit_creds(new);}
prepare_kernel_cred(NULL) 是 Linux 内核的内部 API,作用是创建一个具有完整内核权限的 credential:UID=0, GID=0, 所有 capability 全开。这不是”变成 root”,这是”变成内核本身”——比 root 更高的一层。
然后再通过 set_security_override 把这个 cred 的 SELinux 安全上下文设为 all_allow_sid(一个预先配置好的宽松 domain),最后 commit_creds 提交。之后这个进程在内核眼里就是”内核自己人”,没有任何权限检查能拦住它。
通道二:commit_common_su —— 普通用户态提权
// accctl.c:78-120int commit_common_su(uid_t to_uid, const char *sctx){ struct task_struct *task = current; struct task_ext *ext = get_task_ext(task); // 第一步:禁用 SECCOMP struct thread_info *thi = current_thread_info(); thi->flags &= ~(_TIF_SECCOMP); // 同时也直接清零 seccomp->mode struct seccomp *seccomp = (struct seccomp *)((uintptr_t)task + seccomp_offset); seccomp->mode = SECCOMP_MODE_DISABLED; // 第二步:构造新 cred ext->sel_allow = 1; struct cred *new = prepare_creds(); su_cred(new, to_uid); // 第三步:清空附加组 struct group_info *group_info = groups_alloc(0); set_groups(new, group_info); // 第四步:可选 SELinux 上下文切换 if (sctx && sctx[0]) { ext->sel_allow = !!set_security_override_from_ctx(new, sctx); } // 第五步:提交 commit_creds(new); return 0;}
这个函数一次调用做了五件事:
(1)禁用 SECCOMP(安全计算模式)
seccomp 是 Linux 的沙箱机制,允许进程限制自己能调用的系统调用。如果一个 app 先设置了 seccomp 过滤器(比如只允许 read/write/exit),然后提权到 root,新的权限会被 seccomp 拦截——你有了 root 的 UID 但调不了 setuid 以外的 syscall,等于白提。
所以这里直接暴力清掉 _TIF_SECCOMP 标志位和 seccomp->mode,把沙箱彻底拆除。
(2)用偏移量直接写 cred 字段
// accctl.c:32-49static void su_cred(struct cred *cred, uid_t uid){ // 五个 capability 集合全部写为 full_cap(全部能力位) *(kernel_cap_t *)((uintptr_t)cred + cred_offset.cap_inheritable_offset) = full_cap; *(kernel_cap_t *)((uintptr_t)cred + cred_offset.cap_permitted_offset) = full_cap; *(kernel_cap_t *)((uintptr_t)cred + cred_offset.cap_effective_offset) = full_cap; *(kernel_cap_t *)((uintptr_t)cred + cred_offset.cap_bset_offset) = full_cap; *(kernel_cap_t *)((uintptr_t)cred + cred_offset.cap_ambient_offset) = full_cap; // UID/GID 全部写为目标值 *(uid_t *)((uintptr_t)cred + cred_offset.uid_offset) = uid; *(uid_t *)((uintptr_t)cred + cred_offset.euid_offset) = uid; *(uid_t *)((uintptr_t)cred + cred_offset.fsuid_offset) = uid; *(uid_t *)((uintptr_t)cred + cred_offset.suid_offset) = uid; *(uid_t *)((uintptr_t)cred + cred_offset.gid_offset) = uid; *(uid_t *)((uintptr_t)cred + cred_offset.egid_offset) = uid; *(uid_t *)((uintptr_t)cred + cred_offset.fsgid_offset) = uid; *(uid_t *)((uintptr_t)cred + cred_offset.sgid_offset) = uid;}
这里没有用内核的标准 API cap_raise、cred->uid = xxx,而是直接用结构体字段偏移量写内存。原因是 Linux 内核的 struct cred 不是导出结构体,不同内核版本的字段布局可能不同。所以 kptools 打补丁时预先计算好了各字段的偏移量,存在 patch_config 里,运行时直接用偏移量硬写。
(3)清空附加组
struct group_info *group_info = groups_alloc(0); // 分配 0 个组的 group_infoset_groups(new, group_info);
一个进程可能属于很多附加组(比如 adb 组、net_admin 组)。如果不清理,提权后这些组成员身份还在,可能导致意外的权限泄漏。直接分配一个空的 group_info 覆盖掉。
(4)可选 SELinux 上下文切换
如果 su_profile 里指定了 SELinux 上下文(例如 u:r:kp:s0),就调用 set_security_override_from_ctx 把新 cred 的 SELinux label 改掉。如果切换成功,ext->sel_allow 保持为 1,告诉 SELinux AVC hook “这个人以后全部放行”。
(5)提交
commit_creds(new) 把新 cred 挂到当前进程的 task_struct 上,切换完成。
SUPERCALL_SU_TASK:远程进程提权
SUPERCALL_SU 只能提权自己。如果要提权另一个进程(比如管理器想把某个后台进程变成 root),就要用 SUPERCALL_SU_TASK。
// accctl.c:132-181int task_su(pid_t pid, uid_t to_uid, const char *sctx){ // 通过 PID 找到目标 task_struct struct task_struct *task = find_get_task_by_vpid(pid); struct task_ext *ext = get_task_ext(task); // 禁用目标的 SECCOMP(和 commit_common_su 一样) struct thread_info *thi = get_task_thread_info(task); thi->flags &= ~(_TIF_SECCOMP); struct seccomp *seccomp = (struct seccomp *)((uintptr_t)task + seccomp_offset); seccomp->mode = SECCOMP_MODE_DISABLED; // === 关键区别:不走 prepare_creds/commit_creds === // 直接拿目标进程的 cred 指针,往里硬写 struct cred *cred = *(struct cred **)((uintptr_t)task + cred_offset); su_cred(cred, to_uid); // 如果 real_cred 和 cred 不同,也一起改 struct cred *real_cred = *(struct cred **)((uintptr_t)task + real_cred_offset); if (cred != real_cred) { su_cred(real_cred, to_uid); } ext->priv_sel_allow = 1; // 标记为”特权绕过” return 0;}
这个实现比 commit_common_su 暴力的多。commit_common_su 好歹走了内核的标准路径:prepare_creds()(COW 复制)→ 修改 → commit_creds()(原子替换 + RCU 延迟释放旧 cred)。
而 task_su 完全不讲武德:
task_struct ──→ cred ──→ [uid=10023, gid=10023, cap=0x0000...] 直接往里写: [uid=0, gid=0, cap=0xFFFF...]
没有 COW,没有 refcount,没有 RCU 保护(代码里甚至留了 // todo: rcu 的注释)。它直接通过偏移量算出目标进程的 cred 指针地址,然后取出指针,往里硬写 UID/GID/capabilities。
这意味着目标进程此时此刻正在使用这块 cred 内存——你在它不知情的情况下篡改了它的身份。这是一个”我知道内核数据结构长什么样,我就要直接改”的经典内核 hack 思路。
另外注意 ext->priv_sel_allow = 1,和 commit_common_su 里的 ext->sel_allow = 1 不同。priv_sel_allow 是”特权绕过”标记——即使 SELinux 上下文切换不成功,也要强制绕过。
SUPERCALL_SU_SET_ALLOW_SCTX:全局 root 开关
这个命令看起来只是”设置一个字符串”,但它是整个提权体系的总开关:
// accctl.c:51-68int set_all_allow_sctx(const char *sctx){ if (!sctx || !sctx[0]) { all_allow_sctx[0] = 0; all_allow_sid = SECSID_NULL; // 关闭全局宽松模式 dsb(ish); return 0; } // 把 SELinux 上下文字符串转成数字 SID int rc = security_secctx_to_secid(sctx, strlen(sctx), &all_allow_sid); if (!rc && all_allow_sid != SECSID_NULL) { strncpy(all_allow_sctx, sctx, sizeof(all_allow_sctx) - 1); dsb(ish); } return rc;}
它做的事很简单:接收一个 SELinux 上下文字符串(比如 “u:r:kp:s0″),调用内核的 security_secctx_to_secid 把它转成数字 SID,存进全局变量 all_allow_sid。
但这个全局变量影响面极大,它在三个地方被使用:
| | | | — | — | | 位置 | 效果 | | commit_su() | all_allow_sid != SECSID_NULL—>走commit_kernel_su 完整 root | | avc_denied_replace() | 源 SID 匹配 → SELinux 全部放行 | | slow_avc_audit_replace() | 源 SID 匹配 → 不记录审计日志 |
所以管理器启动后,第一件事就是调 sc_su_set_allow_sctx(“u:r:kp:s0”),把所有开关都打开。之后所有 su 请求(to_uid=0)都会走 commit_kernel_su 的完整 root 路径,而不是 commit_common_su 的普通提权。
SELinux 的完全绕过
提权只是改了 UID/GID/capability,在 Android 上还有第二道锁:SELinux。你把 UID 改成了 root,但 SELinux 的 domain 还是 untrusted_app,照样什么都做不了。所以必须同时解决 SELinux。
KernelPatch 的方案非常直接——劫持 SELinux 的 AVC 决策函数本身。
SELinux 的访问控制链路是这样的:
进程操作文件/socket/... → SELinux AVC 缓存查询 → 缓存命中 → 直接返回决策 → 缓存未命中 → security_server 查询策略 → avc_denied() 生成拒绝决策 → slow_avc_audit() 记录审计日志
KernelPatch hook 了链路的最后两个节点:
// accctl.c:271-292int bypass_selinux(){ // Hook 1: AVC 拒绝决策函数 unsigned long avc_denied_addr = patch_config->avc_denied; hook((void *)avc_denied_addr, (void *)avc_denied_replace, &avc_denied_backup); // Hook 2: AVC 慢速审计函数 unsigned long slow_avc_audit_addr = patch_config->slow_avc_audit; hook((void *)slow_avc_audit_addr, (void *)slow_avc_audit_replace, &slow_avc_audit_backup); return 0;}
两个替换函数的逻辑完全一样,以 avc_denied_replace 为例:
// accctl.c:193-230static int avc_denied_replace(..., struct av_decision *_avd){ // 条件一:全局宽松 SID 匹配 if (all_allow_sid != SECSID_NULL) { u32 ssid = (u32)(u64)_ssid; // 兼容不同内核版本的 selinux_state 传递方式 if ((uint64_t)_state <= 0xffffffffL) { ssid = (u32)(u64)_state; } if (ssid == all_allow_sid) { goto allow; } } // 条件二:当前进程被标记了 sel_allow 或 priv_sel_allow struct task_ext *ext = get_current_task_ext(); if (ext->sel_allow || ext->priv_sel_allow) { goto allow; } // 两个条件都不满足 → 走原始函数,正常拒绝 return avc_denied_backup(...); allow: avd->allowed = 0xffffffff; // 所有权限位全开 avd->auditallow = 0; // 不记录允许审计 avd->auditdeny = 0; // 不记录拒绝审计 return 0;}
这里的 trick 有两个:
(1)_state <= 0xffffffffL的兼容逻辑
不同 Linux 内核版本的 SELinux 内部函数签名略有不同。有的版本把 selinux_state 作为第一个参数传入,有的版本把它编码在 ssid 里。(uint64_t)_state <= 0xffffffffL 这个判断就是在区分这两种情况——如果 _state 的值很小(在 32 位范围内),说明它实际上是一个 SID 而不是指针。
(2)allowed = 0xffffffff
SELinux 的权限模型用位掩码表示。一个 class(比如 file)有多个权限位(read、write、open、getattr…)。0xffffffff 的意思是”这个 class 的全部权限位都置 1″——也就是所有操作全部允许。
这是在 SELinux 决策链的最末端做手脚。正常流程中,如果 SELinux 判定”拒绝”,avc_denied 函数会被调用来生成拒绝决策。但 KernelPatch 的替换函数在”自己人”的情况下直接跳到 allow 标签,把决策结果改为全部允许。从 SELinux 的角度看,它已经”正确执行了策略查询”,只是”恰好”在生成决策时被改成了允许。
slow_avc_audit_replace 同理,在”自己人”的情况下直接 return 0,阻止审计日志的产生——避免 logcat 里出现大量 SELinux 拒绝记录引起怀疑。
execve 拦截:shell 里的 su
除了通过 syscall(45) 直接调用,用户更需要一个在 shell 里敲的 su 命令。KernelPatch 通过 hook execve 系统调用来实现:
// sucompat.c:197-281static void handle_before_execve(char **__user u_filename_p, char **__user uargv, void *udata){ uid_t uid = current_uid(); // 不在白名单也不在管理员名单 → 不管 if (!is_su_allow_uid(uid) && !is_trusted_manager_uid(uid)) return; char filename[SU_PATH_MAX_LEN]; compat_strncpy_from_user(filename, *u_filename_p, sizeof(filename)); // === 路径一:执行 su 二进制 (/system/bin/kp) === if (!strcmp(current_su_path, filename)) { // 查出调用者的 su profile(目标 UID、SELinux 上下文) struct su_profile profile = { .to_uid = 0 }; if (is_trusted_manager_uid(uid)) { // 管理员 → 直接用全局宽松 SELinux 上下文 strncpy(profile.scontext, all_allow_sctx, sizeof(profile.scontext) - 1); } else if (su_allow_uid_profile(0, uid, &profile)) { return; // 不在白名单 } // 先提权,再改 exec 目标 commit_su(profile.to_uid, profile.scontext); // 如果 /data/adb/apd 存在 → 重定向到 apd(高级 su 守护进程) // 否则 → 重定向到 /system/bin/sh(普通 root shell) *u_filename_p = (char *)copy_to_user_stack(sh_path, sizeof(sh_path)); } // === 路径二:执行 truncate === else if (!strcmp(SUPERCMD, filename)) { handle_supercmd(u_filename_p, uargv); // 解析命令行参数 }}
整个流程:
用户在 shell 里敲: /system/bin/kp → execve(”/system/bin/kp”, ...) → execve hook 拦截 (handle_before_execve) → is_su_allow_uid? ✅ → commit_su(0, sctx) ← 先把当前进程提权到 root → *u_filename_p = ”/system/bin/sh” ← 然后把目标文件改成 sh → 内核继续执行 execve,启动的是 /system/bin/sh(以 root 身份)
关键技巧在于在 execve 还没完成时篡改了目标文件路径。handle_before_execve 是 before 回调,在原始 execve 系统调用体执行之前运行。它在内核栈上分配了一段空间,把 “/system/bin/sh” 拷贝进去,然后把用户态的 filename 指针改成指向这块内存。之后原始 execve 继续执行时,看到的就已经是 /system/bin/sh 了。
还有一个 truncate 路径:用户敲 truncate su,execve hook 识别出 SUPERCMD(/system/bin/truncate),转给 handle_supercmd() 处理。这个函数会解析命令行参数(su、exec、sumgr、key 等子命令),实现对应的功能。
白名单的存储后端:kstorage
su 白名单、模块排除名单等数据都存储在一个手写的、RCU 保护的键值存储里:
// kstorage.c 核心数据结构static struct list_head kstorage_groups[4]; // 4 个独立的存储组static DEFINE_SPINLOCK(kstorage_group_locks[4]); struct kstorage { struct list_head node; struct rcu_head rcu; int id; int dlen; char data[0]; // 柔性数组,变长数据};
读写模式是标准的 RCU 设计:
读路径(无锁): rcu_read_lock() → list_for_each_entry_rcu(...) 遍历查找 → 拷贝数据 → rcu_read_unlock() 写路径(有锁,但用 RCU 更新指针): rcu_read_lock() // 先无锁查找 → 找到旧条目 → spin_lock(&group_lock) → list_replace_rcu / list_add_rcu // 原子替换链表指针 → call_rcu(&old->rcu, reclaim_callback) // 等宽限期后释放旧内存 → spin_unlock(&group_lock)
call_rcu 是关键:它注册一个回调,等所有 CPU 都经历了一次上下文切换(意味着没有任何读者还在持有指向旧条目的指针),再安全释放旧内存。这保证了读路径的无锁安全——读者要么看到旧数据,要么看到新数据,绝不会看到释放了一半的内存。
这个实现虽然只有 268 行,但正确实现了 RCU 的完整语义,是一个不错的内核并发编程案例。
完整提权链路总结
把从用户敲下 su 到拿到 root shell 的完整链路串起来:
┌───────────────────────────────────────────────────────────────────┐│ 用户敲下 /system/bin/kp ││ │ ││ ├─ execve(”/system/bin/kp”) ││ │ └─ execve hook (handle_before_execve) ││ │ ├─ is_su_allow_uid(uid)? True ││ │ ├─ commit_su(to_uid=0, sctx) ││ │ │ ├─ all_allow_sid != NULL && to_uid==0? ││ │ │ │ └─ commit_kernel_su() ││ │ │ │ ├─ prepare_kernel_cred(NULL) // 内核级 cred││ │ │ │ ├─ set_security_override(all_allow_sid) ││ │ │ │ └─ commit_creds() ││ │ │ └─ 否则 commit_common_su() ││ │ │ ├─ 禁用 SECCOMP ││ │ │ ├─ su_cred(new, 0) // 偏移量写 UID/cap ││ │ │ ├─ groups_alloc(0) // 清空附加组 ││ │ │ ├─ 可选 SELinux 上下文切换 ││ │ │ └─ commit_creds(new) ││ │ ├─ 重定向 filename → ”/system/bin/sh” ││ │ └─ 原始 execve 继续执行 → 启动 root shell ││ │ ││ └─ root shell 做任何操作 ││ └─ SELinux AVC 检查 ││ └─ avc_denied_replace() 被 hook ││ ├─ ext->sel_allow? True ││ └─ allowed = 0xffffffff → 全部放行 │└───────────────────────────────────────────────────────────────────┘
三层防护、逐个击破:
| | | | | — | — | — | | 防护层 | 机制 | KernelPatch 的对策 | | UID/GID | discretionary access control | su_cred() 偏移量硬写所有 UID/GID 字段 | | Capability | Linux capabilities | 五个 capability 集合全部写为 | | SELinux | mandatory access control | hook avc_denied + slow_avc_audit,匹配到就把决策改为全部允许 | | SECCOMP | 系统调用过滤 | 直接清零 seccomp->mode 和 _TIF_SECCOMP |
这就是 APatch 从”用户敲下 su”到”拿到不受限 root shell”的完整内核实现。
微信QQ交流群
欢迎大家加入[ OnePanda-Sec ] 群聊一起交流 ^ ^
END
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:OnePanda-Sec 人生导师 人生导师《挖到 APatch 内核底层!supercall 一套机制搞定免密 Root、SELinux 全放行》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论