挖到 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\
”, 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
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论