
Исследование эксплойта CVE-2026-98260 (UAF в posix-cpu-timers при exec): S9280 Android 14 GKI 6.1 — триггер UAF + примитив записи нуля + SELinux Permissive. Документ передачи в HANDOFF.md
exec: Cleanup POSIX timers right after de_thread() (автор Hyunwoo Kim, upstream от 2026-09-11,
Fixes: 55e8c8eb2c7b = начиная с 5.7).
На не-leader потоке E установлен таймер CLOCK_THREAD_CPUTIME_ID; в execve()
после того, как de_thread() обменивает tid, обратный поиск task по PID для таймера завершается неудачей, и узел остаётся в
posix_cputimers потока E. Если mmap_read_lock_killable() в exec_mmap() возвращает -EINTR
(exec завершается неудачей, а код очистки находится дальше), то do_exit() → exit_itimers() освобождает k_itimer,
но узел не удаляется; при сборе родительским процессом через wait4() вызов cleanup_timerqueue() → timerqueue_del()
работает с уже освобождённым объектом → UAF.
| Ветка | posixtimer_exec | Статус |
|---|---|---|
| linux-6.1.y | отсутствует | не исправлено |
| linux-5.15.y | отсутствует | не исправлено |
| linux-6.6.y | присутствует | исправлено (6.6.158+) |
| linux-6.12.y | присутствует | исправлено (6.12.112+) |
→ Цели этого проекта (линии 5.15 / 6.1, а также 6.6 ниже 6.6.158) все попадают в затронутый диапазон.
fragment_vmas() с помощью memfd + MAP_FIXED разбивает адресное пространство на
--vmas (по умолчанию 50000) несмерживаемых VMA (у каждой vm_pgoff равен 0,
у соседних адресов pgoff не непрерывен → vma_merge завершается неудачей). Поток locker многократно вызывает mprotect
на всём диапазоне, и обход десятков тысяч VMA удерживает блокировку записи mmap на уровне миллисекунд.ESRCH от tgkill(tgid, старый tid, 0), чтобы определить,
что «de_thread() уже завершил обмен tid», и только тогда отправляет SIGKILL.exec_mmap() неизбежно блокируется на mmap_read_lock_killable()
(блокировка чтения перекрыта блокировкой записи), а SIGKILL приходит через микросекунды после этого → этот вызов возвращает -EINTR
→ exec завершается неудачей. Окно (мс) ≫ время реакции (мкс), поэтому удача не требуется.⚠️ Важно различать: срабатывание детерминировано; путь от UAF до произвольной записи/изменения cred зависит от
организации повторного использования slab в posix_timers_cache (order, objects/slab, CPU partial,
принадлежность к memcg) и представляет собой измеримую, итеративную работу с кучей, а не гонку.
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=\$?"
Признаки успеха (достаточно любого одного):
[*] 开始触发 происходит перезагрузка устройства/чёрный экран (наиболее прямолинейно при panic_on_oops=1)exit=137/139), и после этого система остаётся работоспособнойcleanup_timerqueue / timerqueue_del /
rb_erase / rb_nextПри неудаче (срабатывание не произошло) программа сама повторит попытку --attempts раз и выведет результат wait4 каждого раунда,
в конце подскажет параметры настройки: --vmas 120000 --lockers 4 --cpu 0.
panic_on_oops может быть равен 0: в этом случае задача будет убита, а ядро продолжит работу с повреждённым rbtree;
рекомендуется запустить 2–3 раза подряд и понаблюдать, не возникнет ли последующий panic.Тестовая среда: S9280ZHU6EZIF, ядро 6.1.162-android14-11-34343818-abS9280ZHU6EZIF
(сборка 2026-09-20), без root, домен shell (uid 2000), SELinux Enforcing.
| Раунд | Наблюдение | Значение |
|---|---|---|
| Каждый раунд | Родительский процесс tgkill(child_tgid, etid, 0) возвращает ESRCH | de_thread() уже завершил обмен tid |
| Каждый раунд | Затем kill(child_tgid, SIGKILL), дочерний процесс rc=-9 | exec прерван (а не rc=0 при успешном exec) |
Контроль --no-killer | exec от armed до завершения занимает 277 мс (с killer — 287 мс) | Блокировка записи mmap действительно блокирует mmap_read_lock_killable() в exec_mmap() |
Во всех 5/5 раундах зафиксировано «ESRCH → SIGKILL → сбой exec», ни разу exec не завершился успешно.
Это доказывает, что окно прерывания exec после de_thread управляемо и стабильно; окно создаётся
mprotect locker'а (60 тыс. VMA, однократно 200–700 мс), а реакция родительского процесса — на уровне µs.
de_thread() вызывает zap_other_threads() и убивает все
остальные потоки того же процесса; killer, размещённый в дочернем процессе, погибнет сам в момент de_thread
и никогда не увидит ESRCH (проявляется в том, что exec каждый раз успешен).SIGKILL неэффективен — в этот момент дочерний процесс ещё не умер,
k_itimer ещё не освобождён через RCU. Правильный порядок:
waitid(WNOWAIT) дождаться зомби → дождаться освобождения через RCU-колбэк → захватить страницу → в конце wait4 запустить путь reclaim.Сейчас шаг «после освобождения узел всё ещё в очереди» стабильно достигнут, но rb_erase в момент wait4
не даёт oops — это означает, что страница slab-жертвы ещё не захвачена нашим spray (partial slab
posix_timers_cache всё ещё кэшируется, страница не вернулась в buddy). Это относится к оркестрации reclaim эксплойта,
а не к проблеме гонки; нужно действовать по подходу из заметок vivo: сначала самим k_itimer заполнить/опустошить slab,
чтобы заставить целую страницу вернуться, затем носителем order-0 (AIO ring и т. п.) точно подхватить разделённую половинку страницы.
Рекомендация на следующий шаг: использовать эту машину как эталон и выполнить направленный reclaim по идее из docs/08-reclaim-choreography.md (заметки vivo);
либо проверить на самосборном ядре с KASAN, действительно ли происходит UAF.
Публичная цепочка (Honor uid0.c) полагается на kmem-события tracefs для двух ключевых вещей: