挖到APatch内核底层!supercall一套机制搞定免密Root、SELinux全放行

admin 2026-08-09 05:07:31 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文深入分析了APatch内核模块中supercall系统调用的实现机制,重点介绍了其通过双层密钥验证(XOR常量时间比较与SHA256哈希比对)实现免密Root和SELinux全放行的技术细节。文章详细阐述了密钥的物理存储、信任分级以及管理器免密认证流程,为内核安全研究提供了实战经验。 综合评分: 85 文章分类: 红队,内网渗透,安全工具,免杀,实战经验


cover_image

挖到 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&nbsp;is_trusted_caller =&nbsp;0;int&nbsp;is_authed =&nbsp;0;if&nbsp;(has_preset_superkey()) {&nbsp;const&nbsp;char&nbsp;*__user key_user = (const&nbsp;char&nbsp;*__user)syscall_argn(args,&nbsp;0);&nbsp;char&nbsp;key[MAX_KEY_LEN];&nbsp;long&nbsp;len = compat_strncpy_from_user(key, key_user, MAX_KEY_LEN);&nbsp;if&nbsp;(len <=&nbsp;0)&nbsp;return;&nbsp;is_authed = !auth_superkey(key);&nbsp;is_trusted_caller = is_authed;}

这里是对比 superkey 是否正确,如果不正确就不放行。这个 auth_superkey 的实现非常讲究,下面单独拆开分析。

auth_superkey 的双层验证

// base/predata.c:31-60int&nbsp;auth_superkey(const&nbsp;char&nbsp;*key){&nbsp;int&nbsp;rc =&nbsp;0;&nbsp;// 第一层:逐字节 XOR 常量时间比较&nbsp;for&nbsp;(int&nbsp;i =&nbsp;0; superkey[i]; i++) {&nbsp;rc |= (superkey[i] ^ key[i]);&nbsp;}&nbsp;if&nbsp;(!rc)&nbsp;// rc=0 表示逐字节完全匹配&nbsp;goto&nbsp;out;&nbsp;&nbsp;// 第一层没过,尝试第二层&nbsp;if&nbsp;(!enable_root_key)&nbsp;// 没启用 root key 就直接失败&nbsp;goto&nbsp;out;&nbsp;&nbsp;// 第二层:SHA256 哈希比对&nbsp;BYTE hash[SHA256_BLOCK_SIZE];&nbsp;SHA256_CTX ctx;&nbsp;sha256_init(&ctx);&nbsp;sha256_update(&ctx, (const&nbsp;BYTE *)key, lib_strnlen(key, SUPER_KEY_LEN));&nbsp;sha256_final(&ctx, hash);&nbsp;int&nbsp;len = SHA256_BLOCK_SIZE > ROOT_SUPER_KEY_HASH_LEN&nbsp;? ROOT_SUPER_KEY_HASH_LEN : SHA256_BLOCK_SIZE;&nbsp;rc = lib_memcmp(root_superkey, hash, len);&nbsp;&nbsp;// 首次匹配成功 → 把明文密钥”下载”到内核&nbsp;static&nbsp;bool&nbsp;first_time =&nbsp;true;&nbsp;if&nbsp;(!rc && first_time) {&nbsp;first_time =&nbsp;false;&nbsp;reset_superkey(key);&nbsp;// 用明文覆盖 superkey&nbsp;enable_root_key =&nbsp;false;&nbsp;// 关闭 root key 验证,后续走第一层&nbsp;}&nbsp;out:&nbsp;return&nbsp;!!rc;&nbsp;// 0=成功, 1=失败(内核惯例)}

返回值语义(关键):!!rc 把任意非零压成 1。auth_superkey 返回0 表示成功,1 表示失败——标准内核”返回 0 表示 OK”的约定。所以 before 里的 is_authed = !auth_superkey(key) 意思是:验证成功时 !0 = 1(已认证),失败时 !1 = 0(未认证)。

第一层:XOR 常量时间比较

for&nbsp;(int&nbsp;i =&nbsp;0; superkey[i]; i++) {&nbsp;rc |= (superkey[i] ^ key[i]);}

不是 if (memcmp(…)),不是 if (strcmp(…))。rc |= 的设计有两个目的:

1.常量时间:无论第几个字节开始不同,循环都跑完全程,不会提前 break。这防止了基于时间的侧信道攻击——攻击者不能通过”比较耗时”来推测密钥前缀。

2.累积错误:rc 累积所有字节的差异位,最终只要有任何一位不同,rc 就非零。

第二层:Root Key Hash 验证

如果第一层 XOR 比较失败,且 enable_root_key 为真,则进入 SHA256 哈希验证。

这是一个预置哈希、运行时验证明文的机制。流程如下:

刷机时:&nbsp;kptools&nbsp;--root-skey&nbsp;&nbsp;→ SHA256 哈希写入 start_preset.root_superkey&nbsp;启动时:&nbsp;enable_root_key&nbsp;=&nbsp;true(由 predata_init 设置)&nbsp;superkey 是随机值(不知道真实密钥)
&nbsp;首次认证:&nbsp;用户态传入 ”my_real_key”&nbsp;→ SHA256(”my_real_key”)&nbsp;==&nbsp;root_superkey? ✅&nbsp;→ reset_superkey(”my_real_key”) 把明文写进 superkey&nbsp;→ enable_root_key&nbsp;=&nbsp;false&nbsp;关闭哈希验证通道&nbsp;→ 后续请求走第一层 XOR 快速路径

设计意图:刷机时不把明文密钥嵌入镜像,只嵌入 SHA256 哈希。即使有人 dump 了 boot.img,拿到的也只是哈希值,反推不出原文。等管理器首次连接时,”临时”用哈希验证一次,验证通过后把明文下载到内核内存中的 superkey 字段,之后就走快速的 XOR 比较。

has_preset_superkey:是否预设了密钥

// base/predata.c:114-117int&nbsp;has_preset_superkey(){&nbsp;return&nbsp;superkey_is_user_set || root_superkey_is_set;}

两个标志位在 predata_init() 里设置:

// base/predata.c:119-151void&nbsp;predata_init(){&nbsp;superkey = (char&nbsp;*)start_preset.superkey;&nbsp;// 指向镜像中预留的 64 字节&nbsp;root_superkey = (char&nbsp;*)start_preset.root_superkey;&nbsp;// 指向镜像中预留的 32 字节&nbsp;&nbsp;superkey_is_user_set = lib_strnlen(superkey, SUPER_KEY_LEN) >&nbsp;0;&nbsp;root_superkey_is_set = *(uint64_t *)root_superkey;&nbsp;&nbsp;enable_root_key =&nbsp;false;&nbsp;&nbsp;// 如果没预设密钥 → 生成随机密钥兜底&nbsp;if&nbsp;(!superkey_is_user_set) {&nbsp;enable_root_key =&nbsp;true;&nbsp;// 随机密钥没意义 → 开启 root key 通道&nbsp;int&nbsp;len = SUPER_KEY_LEN >&nbsp;16&nbsp;?&nbsp;16&nbsp;: SUPER_KEY_LEN;&nbsp;len--;&nbsp;for&nbsp;(int&nbsp;i =&nbsp;0; i < len; ++i) {&nbsp;uint64_t rand = rand_next() % (sizeof(bstr) -&nbsp;1);&nbsp;superkey[i] = bstr[rand];&nbsp;// 随机生成 15 字符密钥&nbsp;}&nbsp;}}

| | | | | | | — | — | — | — | — | | 刷机参数 | 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&nbsp;struct&nbsp;{&nbsp;// ...&nbsp;uint8_t&nbsp;superkey[SUPER_KEY_LEN];&nbsp;// 64 字节 (0x40)&nbsp;uint8_t&nbsp;root_superkey[ROOT_SUPER_KEY_HASH_LEN];&nbsp;// 32 字节 (0x20, = SHA256_BLOCK_SIZE)&nbsp;// ...}&nbsp;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&nbsp;reset_superkey(const&nbsp;char *key){&nbsp;lib_strlcpy(superkey, key,&nbsp;SUPER_KEY_LEN);&nbsp;dsb(ish);&nbsp;// 数据同步屏障,确保写入对其它 CPU 可见}

dsb(ish) 保证在多核环境下密钥写入后立刻对所有核可见,防止其它 CPU 读到旧值导致认证误判。

双布尔值的信任分级

回到 before 的认证逻辑,两个变量不是冗余——它们是不同维度的权限:

is_trusted_caller&nbsp;→ 能不能进这个门?(门口保安)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_* / ...&nbsp;// 需要 is_authed == 1 的命令(必须通过密钥或管理员身份)if&nbsp;(!is_authed)&nbsp;return&nbsp;-EPERM;SUPERCALL_SKEY_GET /&nbsp;SKEY_SET&nbsp;/&nbsp;SKEY_ROOT_ENABLE&nbsp;// 密钥管理SUPERCALL_KPM_LOAD / KPM_UNLOAD / KPM_CONTROL&nbsp;// 模块管理

管理器如何免密钥认证

管理器(me.bmax.apatch)不用传 superkey。内核启动时通过 APK 签名验证建立信任:

// base/predata.c:70-74static&nbsp;const&nbsp;struct&nbsp;trusted_manager_entry trusted_managers[] = {&nbsp;{ ”me.bmax.apatch”, {&nbsp;0xd7,&nbsp;0x1d,&nbsp;0xad,&nbsp;0xc0, ... } }&nbsp;// SHA256 签名摘要};&nbsp;// 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...)&nbsp;│&nbsp;▼before() 回调&nbsp;│&nbsp;├─ get_ap_mod_exclude(uid)? →&nbsp;return&nbsp;(放行,不拦截)&nbsp;│&nbsp;├─ has_preset_superkey()?&nbsp;│ └─ YES → 从 arg0 拷贝 key 到内核&nbsp;│ ├─ 拷贝失败 →&nbsp;return&nbsp;│ └─ auth_superkey(key):&nbsp;│ ├─ 第一层 XOR: key == superkey? → 成功 ✓&nbsp;│ └─ 第二层 SHA256: SHA256(key) == root_superkey?&nbsp;│ └─ 首次成功 → reset_superkey(key), 关闭 root key&nbsp;│ → is_authed = !auth_superkey(key)&nbsp;│&nbsp;├─ is_trusted_manager_uid(uid)? → is_trusted_caller=1, is_authed=1&nbsp;├─ is_su_allow_uid(uid)? → is_trusted_caller=1&nbsp;│&nbsp;├─ !is_trusted_caller →&nbsp;return&nbsp;(放行)&nbsp;│&nbsp;└─ supercall(is_authed, cmd, a1, a2, a3, a4)&nbsp;├─ 基础命令: HELLO / KLOG / 版本查询 (始终可用)&nbsp;├─ SU 命令: SU / SU_GRANT / SU_REVOKE / KSTORAGE (trusted即可)&nbsp;├─ 需认证: SKEY_* / KPM_* (is_authed=1&nbsp;才放行)&nbsp;└─ 其余 → -ENOSYS

supercall 函数内

before 通过认证后,将控制权交给 supercall(is_authed, cmd, a1, a2, a3, a4),这是一个典型的命令分发器。整个 switch 里有几十个 case,但绝大部分只是对配置数据的增删查改(增删白名单、读写路径、读写 SELinux 上下文),没有分析价值。真正值得深入的是三个与提权机制本身相关的命令。

SUPERCALL_SU:当前进程提权

这是最核心的 su 操作。调用链很短:

用户态&nbsp;sc_su(key, &profile)&nbsp;→&nbsp;syscall(45, key, cmd, profile)&nbsp;→&nbsp;before() 认证&nbsp;→&nbsp;supercall(is_authed,&nbsp;SUPERCALL_SU, profile)&nbsp;→&nbsp;call_su(profile)&nbsp;// 薄封装:把 profile 从用户态拷进来&nbsp;→&nbsp;commit_su(to_uid, sctx)&nbsp;// 真正的提权

call_su 只是用 memdup_user 把 su_profile 从用户态拷贝到内核态,然后调 commit_su。commit_su 才是灵魂:

// accctl.c:122-129int&nbsp;commit_su(uid_t&nbsp;to_uid,&nbsp;const&nbsp;char&nbsp;*sctx){&nbsp;if&nbsp;(all_allow_sid != SECSID_NULL && !to_uid) {&nbsp;return&nbsp;commit_kernel_su();&nbsp;// 走完整 root 通道&nbsp;}&nbsp;else&nbsp;{&nbsp;return&nbsp;commit_common_su(to_uid, sctx);&nbsp;// 走普通提权通道&nbsp;}}

两条通道的选择逻辑:如果设置了全局宽松 SELinux 上下文,且目标是 root(to_uid=0),就走内核级提权;否则走普通用户态提权。

通道一:commit_kernel_su —— 完整 root

// accctl.c:71-76int&nbsp;commit_kernel_su(){&nbsp;struct&nbsp;cred *new&nbsp;= prepare_kernel_cred(0);&nbsp;// NULL 参数 → 拿内核的 cred&nbsp;set_security_override(new, all_allow_sid);&nbsp;// 盖上宽松 SELinux 标签&nbsp;return&nbsp;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&nbsp;commit_common_su(uid_t&nbsp;to_uid,&nbsp;const&nbsp;char&nbsp;*sctx){&nbsp;struct&nbsp;task_struct&nbsp;*task = current;&nbsp;struct&nbsp;task_ext&nbsp;*ext =&nbsp;get_task_ext(task);&nbsp;&nbsp;// 第一步:禁用 SECCOMP&nbsp;struct&nbsp;thread_info&nbsp;*thi =&nbsp;current_thread_info();&nbsp;thi->flags &= ~(_TIF_SECCOMP);&nbsp;// 同时也直接清零 seccomp->mode&nbsp;struct&nbsp;seccomp&nbsp;*seccomp = (struct&nbsp;seccomp *)((uintptr_t)task + seccomp_offset);&nbsp;seccomp->mode = SECCOMP_MODE_DISABLED;&nbsp;&nbsp;// 第二步:构造新 cred&nbsp;ext->sel_allow =&nbsp;1;&nbsp;struct&nbsp;cred&nbsp;*new&nbsp;=&nbsp;prepare_creds();&nbsp;su_cred(new, to_uid);&nbsp;// 第三步:清空附加组&nbsp;struct&nbsp;group_info&nbsp;*group_info =&nbsp;groups_alloc(0);&nbsp;set_groups(new, group_info);&nbsp;// 第四步:可选 SELinux 上下文切换&nbsp;if&nbsp;(sctx && sctx[0]) {&nbsp;ext->sel_allow = !!set_security_override_from_ctx(new, sctx);&nbsp;}&nbsp;// 第五步:提交&nbsp;commit_creds(new);&nbsp;return&nbsp;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&nbsp;void&nbsp;su_cred(struct&nbsp;cred *cred,&nbsp;uid_t&nbsp;uid){&nbsp;// 五个 capability 集合全部写为 full_cap(全部能力位)&nbsp;*(kernel_cap_t&nbsp;*)((uintptr_t)cred + cred_offset.cap_inheritable_offset) = full_cap;&nbsp;*(kernel_cap_t&nbsp;*)((uintptr_t)cred + cred_offset.cap_permitted_offset) = full_cap;&nbsp;*(kernel_cap_t&nbsp;*)((uintptr_t)cred + cred_offset.cap_effective_offset) = full_cap;&nbsp;*(kernel_cap_t&nbsp;*)((uintptr_t)cred + cred_offset.cap_bset_offset) = full_cap;&nbsp;*(kernel_cap_t&nbsp;*)((uintptr_t)cred + cred_offset.cap_ambient_offset) = full_cap;&nbsp;// UID/GID 全部写为目标值&nbsp;*(uid_t&nbsp;*)((uintptr_t)cred + cred_offset.uid_offset) = uid;&nbsp;*(uid_t&nbsp;*)((uintptr_t)cred + cred_offset.euid_offset) = uid;&nbsp;*(uid_t&nbsp;*)((uintptr_t)cred + cred_offset.fsuid_offset) = uid;&nbsp;*(uid_t&nbsp;*)((uintptr_t)cred + cred_offset.suid_offset) = uid;&nbsp;*(uid_t&nbsp;*)((uintptr_t)cred + cred_offset.gid_offset) = uid;&nbsp;*(uid_t&nbsp;*)((uintptr_t)cred + cred_offset.egid_offset) = uid;&nbsp;*(uid_t&nbsp;*)((uintptr_t)cred + cred_offset.fsgid_offset) = uid;&nbsp;*(uid_t&nbsp;*)((uintptr_t)cred + cred_offset.sgid_offset) = uid;}

这里没有用内核的标准 API cap_raise、cred->uid = xxx,而是直接用结构体字段偏移量写内存。原因是 Linux 内核的 struct cred 不是导出结构体,不同内核版本的字段布局可能不同。所以 kptools 打补丁时预先计算好了各字段的偏移量,存在 patch_config 里,运行时直接用偏移量硬写。

(3)清空附加组

struct&nbsp;group_info *group_info = groups_alloc(0);&nbsp;// 分配 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&nbsp;task_su(pid_t&nbsp;pid,&nbsp;uid_t&nbsp;to_uid,&nbsp;const&nbsp;char&nbsp;*sctx){&nbsp;// 通过 PID 找到目标 task_struct&nbsp;struct&nbsp;task_struct&nbsp;*task =&nbsp;find_get_task_by_vpid(pid);&nbsp;struct&nbsp;task_ext&nbsp;*ext =&nbsp;get_task_ext(task);&nbsp;&nbsp;// 禁用目标的 SECCOMP(和 commit_common_su 一样)&nbsp;struct&nbsp;thread_info&nbsp;*thi =&nbsp;get_task_thread_info(task);&nbsp;thi->flags &= ~(_TIF_SECCOMP);&nbsp;struct&nbsp;seccomp&nbsp;*seccomp = (struct&nbsp;seccomp *)((uintptr_t)task + seccomp_offset);&nbsp;seccomp->mode = SECCOMP_MODE_DISABLED;&nbsp;&nbsp;// === 关键区别:不走 prepare_creds/commit_creds ===&nbsp;// 直接拿目标进程的 cred 指针,往里硬写&nbsp;struct&nbsp;cred&nbsp;*cred = *(struct&nbsp;cred **)((uintptr_t)task + cred_offset);&nbsp;su_cred(cred, to_uid);&nbsp;&nbsp;// 如果 real_cred 和 cred 不同,也一起改&nbsp;struct&nbsp;cred&nbsp;*real_cred = *(struct&nbsp;cred **)((uintptr_t)task + real_cred_offset);&nbsp;if&nbsp;(cred != real_cred) {&nbsp;su_cred(real_cred, to_uid);&nbsp;}&nbsp;&nbsp;ext->priv_sel_allow =&nbsp;1;&nbsp;// 标记为”特权绕过”&nbsp;return&nbsp;0;}

这个实现比 commit_common_su 暴力的多。commit_common_su 好歹走了内核的标准路径:prepare_creds()(COW 复制)→ 修改 → commit_creds()(原子替换 + RCU 延迟释放旧 cred)。

而 task_su 完全不讲武德:

task_struct ──→ cred ──→ [uid=10023, gid=10023,&nbsp;cap=0x0000...]&nbsp;直接往里写: [uid=0, gid=0,&nbsp;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&nbsp;set_all_allow_sctx(const&nbsp;char&nbsp;*sctx){&nbsp;if&nbsp;(!sctx || !sctx[0]) {&nbsp;all_allow_sctx[0] =&nbsp;0;&nbsp;all_allow_sid = SECSID_NULL;&nbsp;// 关闭全局宽松模式&nbsp;dsb(ish);&nbsp;return&nbsp;0;&nbsp;}&nbsp;&nbsp;// 把 SELinux 上下文字符串转成数字 SID&nbsp;int&nbsp;rc =&nbsp;security_secctx_to_secid(sctx,&nbsp;strlen(sctx), &all_allow_sid);&nbsp;if&nbsp;(!rc && all_allow_sid != SECSID_NULL) {&nbsp;strncpy(all_allow_sctx, sctx,&nbsp;sizeof(all_allow_sctx) -&nbsp;1);&nbsp;dsb(ish);&nbsp;}&nbsp;return&nbsp;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/...&nbsp;→&nbsp;SELinux&nbsp;AVC&nbsp;缓存查询&nbsp;→&nbsp;缓存命中&nbsp;→&nbsp;直接返回决策&nbsp;→&nbsp;缓存未命中&nbsp;→&nbsp;security_server 查询策略&nbsp;→&nbsp;avc_denied() 生成拒绝决策&nbsp;→&nbsp;slow_avc_audit() 记录审计日志

KernelPatch hook 了链路的最后两个节点:

// accctl.c:271-292int&nbsp;bypass_selinux(){&nbsp;// Hook 1: AVC 拒绝决策函数&nbsp;unsigned&nbsp;long&nbsp;avc_denied_addr = patch_config->avc_denied;&nbsp;hook((void&nbsp;*)avc_denied_addr, (void&nbsp;*)avc_denied_replace, &avc_denied_backup);&nbsp;&nbsp;// Hook 2: AVC 慢速审计函数&nbsp;unsigned&nbsp;long&nbsp;slow_avc_audit_addr = patch_config->slow_avc_audit;&nbsp;hook((void&nbsp;*)slow_avc_audit_addr, (void&nbsp;*)slow_avc_audit_replace, &slow_avc_audit_backup);&nbsp;&nbsp;return&nbsp;0;}

两个替换函数的逻辑完全一样,以 avc_denied_replace 为例:

// accctl.c:193-230static&nbsp;int&nbsp;avc_denied_replace(...,&nbsp;struct&nbsp;av_decision *_avd){&nbsp;// 条件一:全局宽松 SID 匹配&nbsp;if&nbsp;(all_allow_sid != SECSID_NULL) {&nbsp;u32 ssid = (u32)(u64)_ssid;&nbsp;// 兼容不同内核版本的 selinux_state 传递方式&nbsp;if&nbsp;((uint64_t)_state <=&nbsp;0xffffffffL) {&nbsp;ssid = (u32)(u64)_state;&nbsp;}&nbsp;if&nbsp;(ssid == all_allow_sid) {&nbsp;goto&nbsp;allow;&nbsp;}&nbsp;}&nbsp;&nbsp;// 条件二:当前进程被标记了 sel_allow 或 priv_sel_allow&nbsp;struct&nbsp;task_ext&nbsp;*ext =&nbsp;get_current_task_ext();&nbsp;if&nbsp;(ext->sel_allow || ext->priv_sel_allow) {&nbsp;goto&nbsp;allow;&nbsp;}&nbsp;&nbsp;// 两个条件都不满足 → 走原始函数,正常拒绝&nbsp;return&nbsp;avc_denied_backup(...);&nbsp;allow:&nbsp;avd->allowed =&nbsp;0xffffffff;&nbsp;// 所有权限位全开&nbsp;avd->auditallow =&nbsp;0;&nbsp;// 不记录允许审计&nbsp;avd->auditdeny =&nbsp;0;&nbsp;// 不记录拒绝审计&nbsp;return&nbsp;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&nbsp;void&nbsp;handle_before_execve(char&nbsp;**__user u_filename_p,&nbsp;char&nbsp;**__user uargv,&nbsp;void&nbsp;*udata){&nbsp;uid_t&nbsp;uid =&nbsp;current_uid();&nbsp;// 不在白名单也不在管理员名单 → 不管&nbsp;if&nbsp;(!is_su_allow_uid(uid) && !is_trusted_manager_uid(uid))&nbsp;return;&nbsp;&nbsp;char&nbsp;filename[SU_PATH_MAX_LEN];&nbsp;compat_strncpy_from_user(filename, *u_filename_p,&nbsp;sizeof(filename));&nbsp;&nbsp;// === 路径一:执行 su 二进制 (/system/bin/kp) ===&nbsp;if&nbsp;(!strcmp(current_su_path, filename)) {&nbsp;// 查出调用者的 su profile(目标 UID、SELinux 上下文)&nbsp;struct&nbsp;su_profile&nbsp;profile = { .to_uid =&nbsp;0&nbsp;};&nbsp;if&nbsp;(is_trusted_manager_uid(uid)) {&nbsp;// 管理员 → 直接用全局宽松 SELinux 上下文&nbsp;strncpy(profile.scontext, all_allow_sctx,&nbsp;sizeof(profile.scontext) -&nbsp;1);&nbsp;}&nbsp;else&nbsp;if&nbsp;(su_allow_uid_profile(0, uid, &profile)) {&nbsp;return;&nbsp;// 不在白名单&nbsp;}&nbsp;&nbsp;// 先提权,再改 exec 目标&nbsp;commit_su(profile.to_uid, profile.scontext);&nbsp;&nbsp;// 如果 /data/adb/apd 存在 → 重定向到 apd(高级 su 守护进程)&nbsp;// 否则 → 重定向到 /system/bin/sh(普通 root shell)&nbsp;*u_filename_p = (char&nbsp;*)copy_to_user_stack(sh_path,&nbsp;sizeof(sh_path));&nbsp;}&nbsp;// === 路径二:执行 truncate ===&nbsp;else&nbsp;if&nbsp;(!strcmp(SUPERCMD, filename)) {&nbsp;handle_supercmd(u_filename_p, uargv);&nbsp;// 解析命令行参数&nbsp;}}

整个流程:

用户在 shell 里敲: /system/bin/kp&nbsp;→ execve(”/system/bin/kp”, ...)&nbsp;→ execve hook 拦截 (handle_before_execve)&nbsp;→ is_su_allow_uid? ✅&nbsp;→ commit_su(0, sctx) ← 先把当前进程提权到 root&nbsp;→ *u_filename_p = ”/system/bin/sh” ← 然后把目标文件改成 sh&nbsp;→ 内核继续执行 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&nbsp;struct&nbsp;list_head kstorage_groups[4];&nbsp;// 4 个独立的存储组static&nbsp;DEFINE_SPINLOCK(kstorage_group_locks[4]);&nbsp;struct&nbsp;kstorage {&nbsp;struct&nbsp;list_head node;&nbsp;struct&nbsp;rcu_head rcu;&nbsp;int&nbsp;id;&nbsp;int&nbsp;dlen;&nbsp;char&nbsp;data[0];&nbsp;// 柔性数组,变长数据};

读写模式是标准的 RCU 设计:

读路径(无锁):&nbsp;rcu_read_lock()&nbsp;→&nbsp;list_for_each_entry_rcu(...) 遍历查找&nbsp;→ 拷贝数据&nbsp;→&nbsp;rcu_read_unlock()&nbsp;写路径(有锁,但用 RCU 更新指针):&nbsp;rcu_read_lock() // 先无锁查找&nbsp;→ 找到旧条目&nbsp;→&nbsp;spin_lock(&group_lock)&nbsp;→ list_replace_rcu / list_add_rcu // 原子替换链表指针&nbsp;→&nbsp;call_rcu(&old->rcu, reclaim_callback) // 等宽限期后释放旧内存&nbsp;→&nbsp;spin_unlock(&group_lock)

call_rcu 是关键:它注册一个回调,等所有 CPU 都经历了一次上下文切换(意味着没有任何读者还在持有指向旧条目的指针),再安全释放旧内存。这保证了读路径的无锁安全——读者要么看到旧数据,要么看到新数据,绝不会看到释放了一半的内存。

这个实现虽然只有 268 行,但正确实现了 RCU 的完整语义,是一个不错的内核并发编程案例。

完整提权链路总结

把从用户敲下 su 到拿到 root shell 的完整链路串起来:

┌───────────────────────────────────────────────────────────────────┐│ 用户敲下&nbsp;/system/bin/kp ││ │ ││ ├─ execve(”/system/bin/kp”) ││ │ └─ execve hook (handle_before_execve) ││ │ ├─ is_su_allow_uid(uid)?&nbsp;True&nbsp;││ │ ├─ commit_su(to_uid=0, sctx) ││ │ │ ├─ all_allow_sid&nbsp;!=&nbsp;NULL&nbsp;&&&nbsp;to_uid==0? ││ │ │ │ └─ commit_kernel_su() ││ │ │ │ ├─ prepare_kernel_cred(NULL)&nbsp;//&nbsp;内核级 cred││ │ │ │ ├─ set_security_override(all_allow_sid) ││ │ │ │ └─ commit_creds() ││ │ │ └─ 否则 commit_common_su() ││ │ │ ├─ 禁用 SECCOMP ││ │ │ ├─ su_cred(new,&nbsp;0)&nbsp;//&nbsp;偏移量写 UID/cap ││ │ │ ├─ groups_alloc(0)&nbsp;//&nbsp;清空附加组 ││ │ │ ├─ 可选 SELinux 上下文切换 ││ │ │ └─ commit_creds(new) ││ │ ├─ 重定向 filename → ”/system/bin/sh” ││ │ └─ 原始 execve 继续执行 → 启动 root shell ││ │ ││ └─ root shell 做任何操作 ││ └─ SELinux AVC 检查 ││ └─ avc_denied_replace() 被 hook ││ ├─ ext->sel_allow?&nbsp;True&nbsp;││ └─ allowed&nbsp;=&nbsp;0xffffffff&nbsp;→ 全部放行 │└───────────────────────────────────────────────────────────────────┘

三层防护、逐个击破:

| | | | | — | — | — | | 防护层 | 机制 | 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 全放行》

评论:0   参与:  0