
Recherche d'exploitation de CVE-2026-98260 (UAF exec de posix-cpu-timers) : S9280 Android 14 GKI 6.1 — déclencheur d'UAF + primitive d'écriture de zéros + SELinux Permissive. Document de passation dans HANDOFF.md
exec: Cleanup POSIX timers right after de_thread() (auteur Hyunwoo Kim, amont du 2026-09-11,
Fixes: 55e8c8eb2c7b = depuis 5.7).
Un minuteur CLOCK_THREAD_CPUTIME_ID est attaché au thread non-leader E ; dans execve(),
après l'échange de tid par de_thread(), la recherche de la tâche par PID pour le minuteur échoue et le nœud reste dans les
posix_cputimers de E. Si mmap_read_lock_killable() de exec_mmap() renvoie -EINTR
(échec de l'exec et le code de nettoyage se trouve plus loin), do_exit() → exit_itimers() libère le k_itimer
mais le nœud n'est pas retiré ; lorsque le processus parent le récupère via wait4(), cleanup_timerqueue() → timerqueue_del()
opère sur un objet déjà libéré → UAF.
| Branche | posixtimer_exec | État |
|---|---|---|
| linux-6.1.y | absent | non corrigé |
| linux-5.15.y | absent | non corrigé |
| linux-6.6.y | présent | corrigé (6.6.158+) |
| linux-6.12.y | présent | corrigé (6.12.112+) |
→ Les cibles de ce projet (lignes 5.15 / 6.1, ainsi que les 6.6 antérieurs à 6.6.158) sont toutes dans la plage affectée.
fragment_vmas() utilise memfd + MAP_FIXED pour découper l'espace d'adressage en
--vmas (50000 par défaut) VMA non fusionnables (le vm_pgoff de chacune vaut 0,
les pgoff d'adresses adjacentes ne sont pas contigus → vma_merge échoue). Le thread locker répète mprotect
sur toute la plage, et le parcours de dizaines de milliers de VMA fait que le verrou en écriture mmap est détenu pendant des millisecondes.tgkill(tgid, ancien tid, 0) renvoyant ESRCH pour déterminer
que « de_thread() a terminé l'échange de tid », et n'envoie SIGKILL qu'à ce moment-là.exec_mmap() sur mmap_read_lock_killable()
(le verrou en lecture étant bloqué par le verrou en écriture), et le SIGKILL arrive quelques microsecondes plus tard → cet appel renvoie -EINTR
→ l'exec échoue. La fenêtre (ms) ≫ le temps de réaction (µs), donc cela ne dépend pas de la chance.⚠️ À distinguer : le déclenchement est déterministe ; le passage de l'UAF à une écriture arbitraire/modification des cred dépend de
l'orchestration de réutilisation du slab de posix_timers_cache (order, objects/slab, CPU partial,
appartenance memcg), ce qui relève d'un travail d'ingénierie du tas mesurable et itératif, pas d'une course.
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=\$?"
Indicateurs de succès (un seul des trois suffit) :
[*] 开始触发, l'appareil redémarre / l'écran devient noir (le plus direct lorsque panic_on_oops=1)exit=137/139), et le système reste ensuite en viecleanup_timerqueue / timerqueue_del /
rb_erase / rb_nextEn cas d'échec (non déclenché), le programme réessaie lui-même --attempts fois et affiche le résultat de wait4 à chaque tour,
puis suggère en dernier lieu d'ajuster les paramètres : --vmas 120000 --lockers 4 --cpu 0.
panic_on_oops peut valoir 0 : dans ce cas la tâche est tuée et le noyau continue de tourner avec un rbtree corrompu ;
il est recommandé d'enchaîner 2 à 3 exécutions pour observer l'apparition d'un panic ultérieur.Environnement de test : S9280ZHU6EZIF, noyau 6.1.162-android14-11-34343818-abS9280ZHU6EZIF
(construit le 2026-09-20), non rooté, domaine shell (uid 2000), SELinux Enforcing.
| Tour | Observation | Signification |
|---|---|---|
| Chaque tour | Le processus parent tgkill(child_tgid, etid, 0) renvoie ESRCH | de_thread() a terminé l'échange de tid |
| Chaque tour | Puis kill(child_tgid, SIGKILL), le processus enfant renvoie rc=-9 | exec a été interrompu (et non rc=0 d'un exec réussi) |
Contrôle --no-killer | L'exec de l'état armed jusqu'à la fin prend 277 ms (287 ms avec killer) | Le verrou d'écriture mmap bloque bien le mmap_read_lock_killable() de exec_mmap() |
5 tours sur 5 ont tous donné « ESRCH → SIGKILL → échec de l'exec », aucun exec n'a réussi.
Cela prouve que la fenêtre d'interruption de l'exec après de_thread est contrôlable et stable ; la fenêtre provient du
mprotect du locker (60 000 VMA, 200–700 ms par appel), la réaction du processus parent étant de l'ordre de la µs.
de_thread() appelle zap_other_threads() qui tue tous les
autres threads du même processus ; un killer placé dans le processus enfant mourrait lui-même au moment de de_thread,
et ne verrait jamais ESRCH (ce qui se traduit par un exec qui réussit à chaque fois).SIGKILL est inefficace — à ce moment l'enfant n'est pas encore mort et
k_itimer n'a pas encore été libéré par RCU. L'ordre correct est
waitid(WNOWAIT) attendre le zombie → attendre la libération par le callback RCU → récupérer la page → enfin wait4 déclencher le chemin de reclaim.Actuellement, l'étape « le nœud reste dans la file après libération » est atteinte de manière stable, mais le rb_erase
au moment de wait4 ne provoque pas d'oops — cela indique que la page slab victime n'a pas encore été récupérée par notre spray (le partial slab de
posix_timers_cache est encore en cache, la page n'est pas retournée au buddy). Cela relève de l'orchestration du reclaim de l'exploit,
non d'un problème de course ; il faut procéder comme dans les notes vivo : d'abord saturer/vider le slab avec k_itimer lui-même pour
provoquer le retour de la page entière, puis utiliser un porteur order-0 (AIO ring, etc.) pour récupérer précisément la demi-page issue du split.
Suggestion pour la suite : prendre cette machine comme référence et effectuer un reclaim ciblé selon l'approche de
docs/08-reclaim-choreography.md (notes vivo) ; ou vérifier avec un noyau auto-compilé avec KASAN si l'UAF se produit réellement.
La chaîne publique (Honor uid0.c) dépend des événements kmem de tracefs pour deux choses essentielles :