
GhostLock (CVE-2026-43499) for OPPO Find X5 Pro (PFEM10) — OPlus watchdog & heap-spray detector reverse engineering
English · 中文
GhostLock (CVE-2026-43499) port for the OPPO Find X5 Pro on ColorOS 16. Reaches a uid=0 child process and a loaded kernelsu.ko; the root process is intercepted.
CVE-2026-43499 — futex PI use-after-free. remove_waiter() clears current->pi_blocked_on when current is the requeuer, on the -EDEADLK rollback path of rt_mutex_start_proxy_lock().
remove_waiter @ 0xffffffc0081ed254 — pre-fix shape.
| Device | OPPO Find X5 Pro (PFEM10) |
| SoC | SM8450 / Adreno 730 |
| OS | ColorOS 16.0.3.520 (CN01) |
| Kernel | 5.10.236-android12-9-o-gaf2075ad2c06 |
| Bootloader | locked, green |
| VA_BITS | 39 — KIMAGE_TEXT_BASE = 0xffffffc008000000 |
| Stage | |
|---|---|
Compact waiter trigger (CMP_REQUEUE_PI → EDEADLK) | works |
task_struct leak (perf) | works |
PI write (8-byte; value = 0 or a valid kernel address) | works |
task+0x778 or task+0x780 alone → Uid=root | works — but a single-field landing leaves the task divergent, and that is a latent hard BUG_ON. See the divergence hazard |
| Both fields written with ONE value (a consistent pair) | ❌ never produced with a sprayed page. Only ever observed with the global init_cred alias (09-14, CONTROL=1). The runner now enforces it (SAME_VALUE=1); not run on device |
Credential laundering (setresgid + setresuid) | implemented behind V12_LAUNDER=1; not run on device |
kernelsu.ko loaded | works |
| Root process survives | ⚠ not established — see below |
| Reboot mechanism | ❌ not established. One candidate (the divergence) is now excluded; see below |
probe_state as a landing criterion | ❌ wrong — do not use. Three counterexamples; see the table below |
| pstore/ramoops panic channel | ⚠ instrument exists; channel never validated (no null test yet) |
| "The victim spins in pure userspace" | ⚠ no reading yet — uid.stream now records utime/stime/nvcsw so it can be checked |
| pi-side single-pass dual write | ⚠ not established; pi.pc/pi.left are hard-coded 0 in fdset_map.h |
Path A (UMH / modprobe_path) | STATIC_USERMODEHELPER_PATH="" |
On "root process survives": the runs in evidence/kill.log
reach uid=0 and load kernelsu.ko, and in the run that actually polled for it
the KernelSU manager process survived 120 s with kernelsu still Live in
/proc/modules. In a later run the same chain left the Android framework
services unreachable (Can't find service: package/power/input/phone/wifi)
while the module was still Live. No [ROOTCHECK-*] kernel line and no
$$sys_call_number@@ payload has ever been captured, so the cause of the
later run's state is not attributed. See evidence/notes.md
§2.3, §2.4 and §7.
task_struct
| Field | Offset |
|---|---|
real_cred / cred | 0x778 / 0x780 |
cached syscallno | 0xdf8 |
cached uid / euid / gid / egid | 0xe00 / 0xe08 / 0xe10 / 0xe18 |
thread_info
| Field | Offset |
|---|---|
flags | 0x0 |
addr_limit | 0x8 |
ttbr0 | 0x10 |
preempt_count | 0x18 |
cred
| Field | Offset | Field | Offset |
|---|---|---|---|
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 leak target task_struct → file
W7 stage 1 task+0x778 = V (real_cred → private sprayed page; V observed)
W7 stage 2 task+0x780 = V (cred) ★ V12_W7_VALUE=V — THE SAME VALUE, not a new page
W7 stage 3 V+8 = 0 (LOCAL repair of the page that was installed, ZERO shape)
LT child fexecve(memfd of loader) — no execve of a /data path
loader ksud late-load → kernelsu ... Live
Stage 2 must be given stage 1's value explicitly. Stages 1 and 2 are two
independent processes, each with its own spray, so "write the cred page to both
slots" is a trap: read naively it produces (pageA, pageB), and because
commit_creds compares pointers, that pair is divergent even when both writes
land. This is not hypothetical — it is exactly what runs 3 and 9 did:
run 9 0x778 shot write value = 0xffffff88679bade0
0x780 shot write value = 0xffffff8785d6ade0 <- a different page
run 3 0x778 shot write value = 0xffffff8787b5ade0
0x780 shot write value = 0xffffff881bad2de0 <- a different page
run_bootA.sh therefore fires stage 2 with V12_W7_VALUE=<stage 1's observed value> and refuses to fire it at all if that value cannot be recovered.
HOLD must outlive stage 2, or stage 1's page is freed and reallocated and "the
same value" becomes a dangling pointer. See
the same-value rule.
One page per boot gets repaired. Stage 3 zeroes V+8. With two different
pages, zeroing both would erase the gid/suid stamp (below) and make a divergence
look like agreement, so the runner repairs only the page that was actually
installed and stops if the two values disagree.
The cred page is built by payload.c: all eight id fields zero, all five
capability sets full, and user / user_ns / group_info pointed at
root_user / init_user_ns / init_groups. Stage 3 exists because the write's
side effect always clobbers cred+8 (gid/suid) of whatever cred it installs.
init_cred — an explicit dichotomyTwo sections here used to contradict each other ("never the global init_cred"
vs "CONTROL=1 reproduces cell 2", and cell 2 is init_cred). Both statements
are true of different roles:
init_cred pointer makes the side effect
corrupt init_cred+8 globally — init_cred is shared by every kernel
thread, and Uid: 0 0 4294967176 0 is precisely that corruption. The code
refuses this path unless V12_ALLOW_INIT_CRED=1 is set deliberately.0xffffff802a7e0be0) to both slots, so
real_cred == cred by construction — that is why it survived to execve.
CONTROL=1 reproduces it. It is a control, not a configuration to build on.perf leak: PERF_TYPE_SOFTWARE / PERF_COUNT_SW_CPU_CLOCK, PERF_SAMPLE_REGS_INTR, exclude_user=1.
Accept [0xffffff8400000000, 0xffffff90000000), votes ≥ 15%.
The UAF is driven through rb_erase_cached Case 1-left. That gives two
stores, not one:
*(write_target) = write_value // the store you aim
*(write_value + 0x08) = write_target // unavoidable side effect
write_value must be 8-byte aligned with bit 0 clear — it is either 0 or a
valid kernel address. This is why g_boot_state cannot be set with this
primitive: the byte you need to become 1 has its low bit forced to 0 by
the alignment requirement, and write_value is the same quantity as the
address the side effect lands at.
write_value points at