
GhostLock (CVE-2026-43499) for OPPO Find X5 Pro (PFEM10) — OPlus watchdog & heap-spray detector reverse engineering
English · 中文
GhostLock(CVE-2026-43499)针对 ColorOS 16 上 OPPO Find X5 Pro 的移植。已做到 uid=0 子进程 + kernelsu.ko 载入;root 进程被拦截。
CVE-2026-43499 —— futex PI 栈 UAF。rt_mutex_start_proxy_lock() 的 -EDEADLK 回滚路径上,当 current 是 requeuer 而非 waiter 时,remove_waiter() 会清掉 current->pi_blocked_on,waiter 被留在已弹出的栈帧上。
remove_waiter @ 0xffffffc0081ed254 —— 修复前形态。
| 机型 | OPPO Find X5 Pro (PFEM10) |
| SoC | SM8450 / Adreno 730 |
| 系统 | ColorOS 16.0.3.520 (CN01) |
| 内核 | 5.10.236-android12-9-o-gaf2075ad2c06 |
| BL | 锁定,green |
| VA_BITS | 39 —— KIMAGE_TEXT_BASE = 0xffffffc008000000 |
| 阶段 | |
|---|---|
compact waiter 触发(CMP_REQUEUE_PI → EDEADLK) | 成功 |
task_struct 泄漏(perf) | 成功 |
PI 写原语(8 字节;值 = 0 或合法内核地址) | 成功 |
task+0x778 或 task+0x780 单独落地 → Uid=root | 成功 —— 但单字段落地会让任务进入分歧态,而这是一条潜伏的硬 BUG_ON。见分歧态那一节 |
| 两字段写成同一个值(一致对) | ❌ 用喷页从未产生过。 只在全局 init_cred 别名上出现过(09-14,CONTROL=1)。runner 现在强制它(SAME_VALUE=1);未上机 |
cred 洗白(setresgid + setresuid) | 已实现(V12_LAUNDER=1);未上机 |
kernelsu.ko 载入 | 成功 |
| root 进程存活 | ⚠ 未定论 —— 见下 |
| 重启机制 | ❌ 未建立。 一个候选(分歧态)现已被排除;见下 |
把 probe_state 当落地判据 | ❌ 错的 —— 不要用。 三个反例;见下表 |
| pstore/ramoops panic 通道 | ⚠ 仪器已有;通道从未验证(还没做 null test) |
| 「受害者在纯用户态自旋」 | ⚠ 还没有读数 —— uid.stream 现在记 utime/stime/nvcsw,所以它可被检验 |
| pi 侧单 pass 双写 | ⚠ 未建立;fdset_map.h 里 pi.pc/pi.left 被硬编码为 0 |
Path A(UMH / modprobe_path) | STATIC_USERMODEHELPER_PATH="" |
关于「root 进程存活」:evidence/kill.log 里的几次 run 都走到了 uid=0
并载入了 kernelsu.ko;其中真正做了轮询的那次,KernelSU 管理器进程存活了 120 秒,
/proc/modules 里 kernelsu 全程 Live。另一次同样链路跑完后,Android framework 的服务
不可达(Can't find service: package/power/input/phone/wifi),而模块仍然 Live。
从未抓到过 [ROOTCHECK-*] 内核行,也从未抓到 $$sys_call_number@@ 载荷,
所以后一次的状态归因不明。详见 evidence/notes.md §2.3、§2.4、§7。
task_struct
| 字段 | 偏移 |
|---|---|
real_cred / cred | 0x778 / 0x780 |
缓存的 syscallno | 0xdf8 |
缓存的 uid / euid / gid / egid | 0xe00 / 0xe08 / 0xe10 / 0xe18 |
thread_info
| 字段 | 偏移 |
|---|---|
flags | 0x0 |
addr_limit | 0x8 |
ttbr0 | 0x10 |
preempt_count | 0x18 |
cred
| 字段 | 偏移 | 字段 | 偏移 |
|---|---|---|---|
uid | 0x4 | cap_inheritable | 0x28 |
gid | 0x8 | cap_permitted | 0x30 |
suid | 0xc | cap_effective | 0x38 |
sgid | 0x10 | cap_bset | 0x40 |
euid | 0x14 | cap_ambient | 0x48 |
egid | 0x18 | ||
fsuid / fsgid | 0x1c / 0x20 |
LT perf 泄漏目标 task_struct → 落文件
W7 阶段 1 task+0x778 = V(real_cred → 喷页内的私有页;V 是**观测到的**值)
W7 阶段 2 task+0x780 = V(cred) ★ V12_W7_VALUE=V —— **同一个值**,不是新喷一页
W7 阶段 3 V+8 = 0(对**真正被装上**的那张页做**局部**修复,第二个进程,ZERO 形状)
LT 子进程 fexecve(loader 的 memfd) —— 不对 /data 路径做 execve
loader ksud late-load → kernelsu ... Live
阶段 2 必须显式拿到阶段 1 的值。 阶段 1 与阶段 2 是两个独立进程、各自喷各自的页,
所以「把 cred 页写进两个槽」是个陷阱:照字面读会产出 (pageA, pageB),
而因为 commit_creds 比的是指针,即便两枪都落地那也是分歧对。
这不是假设 —— run 3 和 run 9 干的就是这件事:
run 9 0x778 那一枪 write value = 0xffffff88679bade0
0x780 那一枪 write value = 0xffffff8785d6ade0 <- 另一张页
run 3 0x778 那一枪 write value = 0xffffff8787b5ade0
0x780 那一枪 write value = 0xffffff881bad2de0 <- 另一张页
因此 run_bootA.sh 用 V12_W7_VALUE=<阶段 1 观测到的值> 开阶段 2 那一枪,
抽不到值就干脆不发。HOLD 必须活过阶段 2,否则阶段 1 的页被释放并重新分配,
「同值」就变成悬垂指针。见同值规则那一节。
一次 boot 只修一张页。 阶段 3 清零 V+8。若两张页不同,把两页的 +8 都清零
会抹掉 gid/suid 那枚戳(见下),让分歧看起来一致,所以 runner 只修真正被装上的那张页,
且两枪值不一致时直接停。
cred 页由 payload.c 构造:8 个 id 字段全 0、5 组 caps 全满,user / user_ns /
group_info 指向 root_user / init_user_ns / init_groups。阶段 3 之所以存在,是因为写的副作用
必然把它装入的那张 cred 的 +8(gid/suid)打坏。
init_cred —— 一个显式的二分本节与另一处曾经互相打脸(「从来不是全局 init_cred」 vs 「CONTROL=1 复现 cell 2」,
而 cell 2 就是 init_cred)。两句话对不同角色都是真的:
init_cred 指针会让副作用全局写坏 init_cred+8 ——
init_cred 被所有内核线程共用,Uid: 0 0 4294967176 0 正是那次损坏。
除非显式设 V12_ALLOW_INIT_CRED=1,代码拒绝这条路。0xffffff802a7e0be0)写进两个槽,所以 real_cred == cred 是构造出来的 ——
这正是它能活到 execve 的原因。CONTROL=1 复现它。它是对照,不是可以依赖的配置。perf 泄漏:PERF_TYPE_SOFTWARE / PERF_COUNT_SW_CPU_CLOCK,PERF_SAMPLE_REGS_INTR,exclude_user=1。
取值范围 [0xffffff8400000000, 0xffffff90000000),票数 ≥ 15%。
本漏洞经 rb_erase_cached Case 1-left 触发,产生的是两次写、不是一次:
*(write_target) = write_value // 你要写的那一次
*(write_value + 0x08) = write_target // 躲不掉的副作用
write_value 必须 8 字节对齐、bit0 = 0 —— 所以它只能是 0 或合法内核地址。
这就是 8 字节写设不了 g_boot_state 的原因:你需要变成 1 的那个字节,
低位被对齐要求强制成 0;而 write_value 与副作用落点是同一个量。
write_value 指向的对象write_value 既是被写入的值,也是副作用写入的地址(+8)。把它指向内核全局对象,
就会把那对象写坏。
W7 以前就是这么干的 —— 把 write_value 指向 init_cred 别名 —— 回读里看得见。
来自 out/t5_w7_778.txt:
shape shift=0 wps=5: in[0]=0xffffff802a7e0be0 (write_value) in[2]=0xffffff8800cdd178 (write_target)
W7[W7] write_target= 0xffffff8800cdd178
Uid: 0 0 4294967176 0
当时 write_value 是 init_cred 别名,write_target 是 child_task+0x778。
init_cred+8 是 gid/suid,于是副作用把 0xffffff8800cdd178 写在那儿:
init_cred.gid = 0x00cdd178,而 init_cred.suid = 0xffffff88 = 4294967176 ——
正好就是上面 Uid: 行的第 3 个字段。把 init_cred+8 清零即可修复
(out/t5_repair.txt:Uid: 0 0 4294967176 0 → Uid: 0 0 0 0)—— 这就是"W7 阶段 3"的全部含义。
这条路径现在被代码拒绝。 V12_W7_INIT_CRED=1 会直接中止并说明原因,除非同时显式
V12_ALLOW_INIT_CRED=1;W2 / W6 / LTC 三条路径在私有 cred 页缺失时不再回落到 init_cred,
而是中止。默认(也是唯一合理的)路径就是喷页内的 cred 副本。
副作用本身躲不掉:write_value 必须就是那个 cred 指针,所以 cred+8 必然被写入
write_target。能选的只有它的落点 —— 而修复现在是对 cred_page+8 的局部清零
(阶段 3),不再是写进全局对象。
groups=读出垃圾是另一个症状,不是这个。它出现在一次gid/egid回读正常的 run 里, 所以不可能来自init_cred+8副作用;它指向假 cred 自己的group_info字段。见evidence/notes.md§10.6。
BUG_ON本原语每趟只写一个地址。task+0x778(real_cred)与 task+0x780(cred)是两个不同地址,
所以任何一次落地的 0x778-only 或 0x780-only 写入,都会让任务留下 cred != real_cred —— 分歧态。
在本镜像上,这个状态是硬 panic,不是警告。commit_creds 开头就是
BUG_ON(task->cred != task->real_cred):
commit_creds @0xffffffc008186784
0x1867a4 ldr x19, [x20, #0x778] ; old = task->real_cred
0x1867a8 ldr x8, [x20, #0x780] ; task->cred
0x1867ac cmp x8, x19
0x1867b0 b.ne #0xffffffc008186b68
0x186b68 brk #0x800 ; == BUG()
而本内核编了 CONFIG_PANIC_ON_OOPS=y(CONFIG_PANIC_ON_OOPS_VALUE=1)。
__put_cred @0xffffffc008185530 还有同族断言(usage != 0 → BUG;
cred == current->cred / current->real_cred → BUG)。
所以分歧态是潜伏的 —— 受害者进程只是自旋时什么都不会发生 —— 直到该任务上发生任何
commit_creds:setresuid / setresgid / setuid / setgid / capset,
或者 execve(经 install_exec_creds)。