
Android kernel LPE research for CVE-2026-98260 (posix-cpu-timers UAF): trigger PoC, fake-cred and real-cred uid-0 shell chains, plus a Shizuku/KernelSU bootstrap app.
English | 简体中文
Android local privilege escalation research for a use-after-free in the posix-cpu-timers exit path. The repository ships a trigger-only PoC, a fake-cred verification exploit, a real-cred uid-0 shell stage, a Shizuku-based KernelSU app, and a vulnerability pre-check.
Branch
magicos-11.0.0.109-6.12.58: this branch is dual-profile. The APK and native binaries select a profile from the exactuname -rat runtime: MagicOS 10.0.0 / 6.12.38 is the verified profile, while MagicOS 11.0.0.109 / 6.12.58 is statically adapted and its device-side runtime validation is still pending. The APK bundles both 226-symbolko-symstables and selects the matching one.
| Item | Value |
|---|---|
| CVE | CVE-2026-98260 |
| Class | LPE (use-after-free, timerqueue rb-tree erase) |
| Target | Honor MagicPad3 Pro 13.3 YLP-W00, MagicOS 10.0.0 / 11.0.0.109 (Android 16), branch magicos-11.0.0.109-6.12.58 |
| Kernel | 6.12.38-android16-5-gfde7767f6ef6-abogki481467632-4k (verified)6.12.58-android16-6-g3a7ac1674240-abogki541502914-4k (adapted) |
| Profile status | dual-profile; 6.12.38 verified, 6.12.58 runtime validation pending |
| SELinux | Enforcing |
| Patches | absent on the tested build |
| Mitigations | KASLR on, SMEP/SMAP/PAN on, KASAN compiled but disabled |
| Bootloader | locked and not unlockable; no partition writes |
| Component | Status | Result |
|---|---|---|
src/poc.c | verified on 6.12.38 | destructive trigger; reap panics in cleanup_timerqueue |
src/exp.c | verified on 6.12.38 | fake cred; getuid()==0 && geteuid()==0; cannot exec |
src/uid0.c | verified on 6.12.38 | real cred; uid-0 shell in u:r:shell:s0 |
app/ | verified on 6.12.38 | Shizuku + KernelSU bootstrap; uid 0 in u:r:ksu:s0 |
src/vuln_precheck.h | added | profile/environment gate; --check / --force |
| 6.12.58 profile | adapted | sites, selinux_state and its 226-symbol ko-syms table re-derived; device run pending |
| Dual-profile APK | tested on 6.12.38 | profile and ko-syms selection by uname -r; 6.12.38 live-KSU path passed |
Current release: v1.4.3-app.
The v1.4.2-app pre-release was withdrawn on 2026-10-30; its tag is kept for
history only. See docs/release-notes.md for the
release record and known gaps.
src/poc.c trigger only; the reap panics on the tested kernel
src/exp.c fake-cred chain; verifies uid 0 via getuid/geteuid
src/uid0.c real-cred chain; zeroes cred->uid/gid and execs sh
src/kernel_profile.h per-release call sites, symbols and structure offsets
src/trace_ids.h runtime kmem trace event IDs; unprivileged probe
src/vuln_precheck.h shared profile/environment gate
app/ Android root app (Shizuku + KernelSU bootstrap)
docs/analysis.md root cause, exploit chain and device notes
scripts/dump_device.sh root device dump: all .ko/modinfo, kernel/config/policy, perf/tracefs, vendor mount, harness, active boot.img
Makefile single build entry (NDK cross-compile, app APK)
README.zh-CN.md Chinese version of this document
A non-leader thread that created a CLOCK_THREAD_CPUTIME_ID posix timer and
then fails execve() after de_thread() keeps the timer node queued. The
SIGKILL that interrupts the exec makes the thread exit through
exit_itimers(), which cannot disarm a timer whose task is already exiting.
The k_itimer is freed while its timerqueue_node is still linked in the
task's posix_cputimers queue. When the parent reaps the zombie,
release_task() -> posix_cpu_timers_exit() -> cleanup_timerqueue() calls
timerqueue_del()/rb_erase() on the freed node.
The erase is the write primitive: rb_erase() stores a controlled
__rb_parent_color value into a controlled rb_left target. The exploit
choreographs slab/page reclaim so the freed node sits in a page filled with
the attacker's payload, then chooses the target and value.
See docs/analysis.md for the observed panic trace, the
YLP-W00 offsets, the tracefs-based leaks, the SLUB/RCU choreography, the
two-stage fake-cred chain and the one-write real-cred chain.
Requirements: Android NDK r29 at /opt/android-ndk-r29 (override with
NDK=). Targets are aarch64, API 24 by default. The app additionally needs
the Android SDK (build-tools 37.0.0, platform 37), a JDK, and the Shizuku jars
under ~/.local/share/android-cve-env/cve98260-app (override with
APP_DEPS=). Build artifacts stay in build/ and are git-ignored. The
Shizuku jars are user-supplied; scripts/fetch-deps.sh verifies them against
deps.lock (SHA-256) and never downloads anything.
| Target | Output / action |
|---|---|
make / make poc | build/poc |
make exp | build/exp |
make uid0 | build/uid0 (static; the shell stage re-execs itself) |
make build-all | build poc + exp + uid0 + apk |
make host | build/poc.static for host-side qemu validation |
make emu | run build/poc.static under qemu (with --force) |
make apk | app/CVE98260Root.apk |
make clean | remove build/ |
make help | list targets |
poc, exp and uid0 call a profile gate before touching the kernel. It
accepts either exact profile release (6.12.38 verified, 6.12.58 adapted),
requires arm64 and 4 KiB pages, and for exp/uid0 also checks tracefs/kmem
availability and (for the fake-cred chain) panic_on_warn=0. Any other
release is unconfirmed and the program exits 2:
build/uid0 --check # print the verdict, do not trigger
build/uid0 --check --force # same, overriding an unconfirmed environment
Use --force or CVE98260_FORCE=1 only after verifying the kernel
independently. --force relaxes the environment checks only; exp, uid0
and the app still require an exact profile from kernel_profile.h. The gate
is deliberately not a runtime probe: a probe that reaches the bug would
itself leave a stale timerqueue node behind.
On YLP-W00 an unprivileged shell cannot read /proc/sys/kernel/panic_on_warn
or /proc/cmdline; for a known profile the gate assumes the 6.12.38-validated
value 0. The same assumption is used for the adapted 6.12.58 profile until
its runtime validation completes. Unknown releases stay blocked.
The exploit starts as an ordinary shell process (uid 2000, SELinux
u:r:shell:s0) and does not use su. Do not launch it through su: the
tested condition is an unprivileged start.
make uid0
adb push build/uid0 /data/local/tmp/uid0
adb shell chmod 755 /data/local/tmp/uid0
adb shell /data/local/tmp/uid0 # runs id and prints the context
adb shell /data/local/tmp/uid0 shell # interactive sh -i
Before the full chain, use the non-destructive modes:
adb shell /data/local/tmp/uid0 --probe-ids # discover kmem trace event IDs
adb shell /data/local/tmp/uid0 leak # timer/cred leak only; no UAF/reap
kmem trace event IDs are assigned at boot and are not stable across kernel
builds (6.12.38 uses 318/319/322/324). --probe-ids isolates one event at a
time through the writable rss_stat/trigger and discovers the raw record
types; normal runs probe automatically. leak prints [base], the full
timer_site/slide, and the cred allocation site without reaching
run_plant().
root is static, and its child re-executes the binary to obtain a private
real cred, so it must stay at the pushed path (it is exec'd through
/proc/self/exe).
On success the output ends with: