
Investigación de explotación de CVE-2026-98260 (UAF en exec de posix-cpu-timers): S9280 Android 14 GKI 6.1 — disparador de UAF + primitiva de escritura cero + SELinux Permissive. Documento de traspaso en HANDOFF.md
exec: Cleanup POSIX timers right after de_thread() (autor Hyunwoo Kim, upstream 2026-09-11,
Fixes: 55e8c8eb2c7b = desde 5.7).
En un hilo no líder E se registró un temporizador CLOCK_THREAD_CPUTIME_ID; en execve(),
tras el intercambio de tid en de_thread(), el temporizador falla al buscar la tarea por PID y el nodo permanece en el
posix_cputimers de E. Si mmap_read_lock_killable() de exec_mmap() devuelve -EINTR
(exec falla y el código de limpieza está más adelante), do_exit() → exit_itimers() libera el k_itimer
pero el nodo no se elimina; cuando el proceso padre lo recolecta con wait4(), cleanup_timerqueue() → timerqueue_del()
opera sobre un objeto ya liberado → UAF.
| Rama | posixtimer_exec | Estado |
|---|---|---|
| linux-6.1.y | ausente | sin corregir |
| linux-5.15.y | ausente | sin corregir |
| linux-6.6.y | presente | corregido (6.6.158+) |
| linux-6.12.y | presente | corregido (6.12.112+) |
→ Los objetivos de este proyecto (líneas 5.15 / 6.1, y 6.6 anterior a 6.6.158) están todos dentro del rango afectado.
fragment_vmas() usa memfd + MAP_FIXED para dividir el espacio de direcciones en
--vmas (por defecto 50000) VMA no fusionables (el vm_pgoff de cada una es 0,
los pgoff de direcciones adyacentes no son contiguos → vma_merge falla). El hilo locker repite mprotect
sobre todo el rango, recorriendo decenas de miles de VMA, de modo que el bloqueo de escritura de mmap se mantiene durante milisegundos.tgkill(tgid, tid antiguo, 0) devolviendo ESRCH para determinar
que "de_thread() ya completó el intercambio de tid", y solo entonces envía SIGKILL.exec_mmap() necesariamente se bloquea en mmap_read_lock_killable()
(el bloqueo de lectura queda bloqueado por el de escritura), y el SIGKILL llega microsegundos después → esa llamada devuelve -EINTR
→ exec falla. La ventana (ms) ≫ el tiempo de reacción (µs), por lo que no depende de la suerte.⚠️ Nota para distinguir: la activación es determinista; el paso de UAF a escritura arbitraria/cambio de cred depende de
la orquestación de reutilización de slab de posix_timers_cache (order, objects/slab, CPU partial,
pertenencia a memcg), lo cual es ingeniería de heap medible e iterable, no una carrera.
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=\$?"
Indicadores de éxito (basta con uno de los tres):
[*] 开始触发, el dispositivo se reinicia / la pantalla se apaga (lo más directo cuando panic_on_oops=1)exit=137/139), y después el sistema sigue vivocleanup_timerqueue / timerqueue_del /
rb_erase / rb_nextEn caso de fallo (no se dispara), el programa reintenta por sí mismo --attempts veces e imprime el resultado de wait4 de cada ronda,
y al final sugiere ajustar parámetros: --vmas 120000 --lockers 4 --cpu 0.
panic_on_oops puede ser 0: en ese caso la tarea es terminada y el kernel continúa ejecutándose con un rbtree corrupto;
se recomienda ejecutar 2~3 veces seguidas para observar si aparece un panic posterior.Entorno de prueba: S9280ZHU6EZIF, kernel 6.1.162-android14-11-34343818-abS9280ZHU6EZIF
(compilación 2026-09-20), sin root, dominio shell (uid 2000), SELinux Enforcing.
| Ronda | Observación | Significado |
|---|---|---|
| Cada ronda | El proceso padre tgkill(child_tgid, etid, 0) devuelve ESRCH | de_thread() ya completó el intercambio de tid |
| Cada ronda | Después kill(child_tgid, SIGKILL), el proceso hijo rc=-9 | exec fue abortado (no es el rc=0 de un exec exitoso) |
Control --no-killer | exec desde armed hasta el final tarda 277 ms (con killer, 287 ms) | El lock de escritura de mmap efectivamente bloqueó mmap_read_lock_killable() de exec_mmap() |
5/5 rondas acertaron "ESRCH → SIGKILL → fallo de exec", ninguna vez el exec tuvo éxito.
Esto demuestra que la ventana para abortar el exec después de de_thread es controlable y estable; la ventana proviene del
mprotect del locker (60 mil VMA, 200–700 ms por vez), y la reacción del proceso padre es de nivel µs.
de_thread() ejecuta zap_other_threads() y mata todos los
demás hilos del mismo proceso; un killer escrito en el proceso hijo morirá él mismo en el momento de de_thread,
y nunca verá ESRCH (se manifiesta como que el exec tiene éxito siempre).SIGKILL es inútil — en ese momento el proceso hijo aún no ha muerto y
k_itimer aún no ha sido liberado por RCU. El orden correcto es
waitid(WNOWAIT) esperar al zombi → esperar a que el callback de RCU libere → tomar la página → finalmente wait4 para disparar la ruta de reclaim.Actualmente el paso de "el nodo sigue en la cola tras la liberación" ya se logra de forma estable, pero el rb_erase en el momento de wait4
no produce oops — lo que indica que la página de slab víctima aún no ha sido tomada por nuestro spray (el partial slab de posix_timers_cache
sigue en caché, la página no volvió al buddy). Esto pertenece a la orquestación de reclaim del exploit,
no es un problema de carrera; hay que seguir el enfoque de las notas de vivo: primero llenar/vaciar el slab con el propio k_itimer
para forzar la devolución de la página completa, y luego usar un portador order-0 (AIO ring, etc.) para recibir con precisión la media página dividida.
Sugerencia para el siguiente paso: tomar esta máquina como referencia y hacer reclaim dirigido siguiendo la idea de docs/08-reclaim-choreography.md (notas de vivo);
o verificar con un kernel compilado propio con KASAN si el UAF realmente ocurre.
La cadena pública (uid0.c de Honor) depende de los eventos kmem de tracefs para dos cosas clave: