
CVE-2026-98260 (posix-cpu-timers exec UAF) exploit research: S9280 Android 14 GKI 6.1 — UAF trigger + zero-write primitive + SELinux Permissive. Handoff doc in HANDOFF.md
exec: Cleanup POSIX timers right after de_thread() (author Hyunwoo Kim, upstream 2026-09-11,
Fixes: 55e8c8eb2c7b = since 5.7).
A CLOCK_THREAD_CPUTIME_ID timer is attached to a non-leader thread E; in execve(),
after de_thread() swaps the tid, the timer fails to look up the task by PID and the node
remains in E's posix_cputimers. If exec_mmap()'s mmap_read_lock_killable() returns
-EINTR (exec fails and the cleanup code comes later), do_exit() → exit_itimers() frees
the k_itimer but the node is not removed; when the parent process reaps via wait4(),
cleanup_timerqueue() → timerqueue_del() operates on the already-freed object → UAF.
| Branch | posixtimer_exec | Status |
|---|---|---|
| linux-6.1.y | absent | unfixed |
| linux-5.15.y | absent | unfixed |
| linux-6.6.y | present | fixed (6.6.158+) |
| linux-6.12.y | present | fixed (6.12.112+) |
→ This project's targets (the 5.15 / 6.1 lines, plus 6.6 below 6.6.158) are all in the affected range.
fragment_vmas() uses memfd + MAP_FIXED to split the address
space into --vmas (default 50000) non-mergeable VMAs (each has vm_pgoff = 0, and
adjacent addresses have non-contiguous pgoff → vma_merge fails). The locker thread repeatedly
calls mprotect over the whole range, and walking tens of thousands of VMAs keeps the mmap
write lock held for milliseconds.tgkill(tgid, old tid, 0) returning ESRCH
to determine that "de_thread() has completed the tid swap", and only then sends SIGKILL.exec_mmap() in mmap_read_lock_killable()
(the read lock is blocked by the write lock), and SIGKILL arrives microseconds later → that
call returns -EINTR → exec fails. The window (ms) ≫ reaction time (µs), so no luck is involved.⚠️ Note the distinction: the trigger is deterministic; going from UAF to arbitrary write /
cred overwrite depends on orchestrating slab reuse of posix_timers_cache (order, objects/slab,
CPU partial, memcg ownership), which is measurable, iterable heap engineering, not a race.
export ANDROID_NDK_HOME=/home/nt/项目/samsung_root_research/06_工具链/android-ndk-r29
make # 产物 trigger(aarch64 / Android 21+ / 仅依赖 libc、libdl)
adb push trigger /data/local/tmp/
adb shell "chmod 755 /data/local/tmp/trigger"
adb shell "/data/local/tmp/trigger --check" # 先看环境
adb shell "/data/local/tmp/trigger; echo exit=\$?"
Success indicators (any one of the three suffices):
[*] Starting trigger, the device reboots/blacks out (most direct when panic_on_oops=1)exit=137/139), and the system is still alive afterwardcleanup_timerqueue / timerqueue_del /
rb_erase / rb_nextOn failure (not triggered), the program retries --attempts times by itself and prints the wait4 result of each round,
then finally suggests tuning parameters: --vmas 120000 --lockers 4 --cpu 0.
panic_on_oops may be 0: in that case the task is killed and the kernel keeps running with a corrupted rbtree;
it is recommended to run 2~3 times in a row to observe whether a subsequent panic occurs.测试环境:S9280ZHU6EZIF,内核 6.1.162-android14-11-34343818-abS9280ZHU6EZIF
(构建 2026-09-20),未 root,shell 域(uid 2000),SELinux Enforcing。
| Round | Observation | Meaning |
|---|---|---|
| Every round | Parent process tgkill(child_tgid, etid, 0) returns ESRCH | de_thread() has completed the tid exchange |
| Every round | Then kill(child_tgid, SIGKILL), child process rc=-9 | exec was aborted (not rc=0 of a successful exec) |
--no-killer control | exec takes 277 ms from armed to finish (287 ms with killer) | The mmap write lock indeed blocks mmap_read_lock_killable() in exec_mmap() |
All 5/5 rounds hit "ESRCH → SIGKILL → exec failure"; not once did exec succeed.
This proves that the window for aborting exec after de_thread is controllable and stable; the window comes from the locker's
mprotect (60k VMAs, 200–700 ms per call), while the parent process reacts at the µs level.
de_thread() calls zap_other_threads() to kill all
other threads in the same process; a killer written in the child process dies itself at the moment of de_thread,
and never sees ESRCH (manifesting as exec succeeding every time).SIGKILL is ineffective—at that point the child process is not yet dead and
k_itimer has not yet been freed by RCU. The correct order is
waitid(WNOWAIT) to wait for the zombie → wait for the RCU callback to free → grab the page → finally wait4 to trigger the reclaim path.At present, the step "node still in the queue after free" has been stably achieved, but the rb_erase at the moment of wait4
produces no oops—indicating that the victim slab page has not yet been grabbed by our spray (the partial slab of posix_timers_cache
is still cached, and the page has not returned to the buddy allocator). This belongs to the exploit's reclaim choreography,
not a race problem; it needs to follow the approach in the vivo notes: first use k_itimer itself to fill/empty the slab
to force whole pages to be returned, then use an order-0 carrier (AIO ring, etc.) to precisely catch the split half-page.
Suggested next step: use this machine as a baseline and do targeted reclaim following the
approach in docs/08-reclaim-choreography.md (vivo notes); or use a self-built kernel with KASAN to verify whether the UAF actually occurs.
The public chain (Honor uid0.c) relies on tracefs kmem events to do two key things:
| Purpose | For Honor 611/6.12 | This device (Samsung 6.1.162) |
|---|---|---|
Find victim k_itimer address V (to compute payload's in-page offset) | kmem:kmem_cache_alloc | Event exists, but events/kmem/*/enable is 0644 root:root, so the shell cannot enable it (events/sched/*/enable is writable, kmem is not) |
| Determine whether the order-1 slab page has been returned to the buddy (precise page-reclaim timing) | kmem:mm_page_free | Same as above: exists but cannot be enabled |
| Read the raw trace buffer | per_cpu/cpuN/trace_pipe_raw | Readable (-r--r----- root readtracefs) ✓ |
| KASLR slide | static site of each trace event | Doable: sched events can be enabled, and the project already has a similar technique ✓ |