Шесть инструкций – ровно на таком по ширине окне держится вся защита от локального root в epoll ядра Linux, и обычная попытка гонки почти никогда в него не попадает. CVE-2026-46242, известная как Bad Epoll, показывает, что узкое окно – не то же самое, что закрытое. Исследователь Джэён Чон растянул эту гонку через ложное разделение кэш-линий и таймерное прерывание, довёл эксплойт до 99% надёжности на стабильном ядре 6.12.67 и 98% на ChromeOS, и в итоге получил root из-под процесса без единой капабилити – тем же путём, каким может исполняться код внутри песочницы Chrome-рендерера.
Пикантность в происхождении бага. Один коммит 2023 года внёс в epoll сразу две независимые гонки. Первую, CVE-2026-43074, нашёл ИИ Anthropic Mythos – само по себе результат, достойный внимания, потому что гонки в ядре исторически плохо поддаются автоматизированному поиску. Вторую, Bad Epoll, Mythos не заметил, хотя разбирал тот же файл на глубину, достаточную для находки соседнего бага. Причина – не невнимательность, а то, что после фикса первой гонки use-after-free второй почти никогда не палит KASAN. Человек всё равно её нашёл, построил oracle для гонки без побочных эффектов и довёл до готового эксплойта на kernelCTF.
Смотрим на уровень инструкций – именно там всё решается. Баг живёт в файле fs/eventpoll.c, в связке двух функций: __ep_remove() отвязывает epoll-объект при закрытии, а eventpoll_release() решает, нужно ли ядру доделать что-то при закрытии обычного файла. Возьмём два epoll-дескриптора: ep_waiter подписан на события ep_target через EPOLL_CTL_ADD, то есть первый следит за вторым. Закрытие обоих одновременно на разных CPU сталкивает их пути освобождения памяти:
CPU 1: close(ep_waiter) CPU 0: close(ep_target)
__ep_remove(epi)
spin_lock(&file_target->f_lock)
WRITE_ONCE(file_target->f_ep, NULL)
======== ОКНО ГОНКИ ========
__fput(file_target)
eventpoll_release(file_target)
READ_ONCE(file_target->f_ep) == NULL
return // лишний скип
ep_free(epoll_target)
kfree(epoll_target) // ОСВОБОЖДЕНО
======== КОНЕЦ ОКНА ========
hlist_del_rcu(&epi->fllink)
*pprev = 0 // запись в уже освобождённый epoll_target, offset 160 kmalloc-192
spin_unlock(&file_target->f_lock)
__ep_remove() обнуляет file->f_ep под спинлоком f_lock – и ещё несколько инструкций после этого продолжает писать через указатели в тот же объект. Параллельный __fput() в этот момент проверяет f_ep на lockless fast path eventpoll_release(): видит NULL, решает, что делать нечего, пропускает eventpoll_release_file() и освобождает struct eventpoll цели. __ep_remove() об этом не знает и продолжает писать в уже освобождённую память – классический use-after-free. Окно держится спинлоком, а значит физически нужны два разных CPU: тот, что взял f_lock, сам себя прервать не может.
Всё это – следствие одного коммита 2023 года (58c9b016e128, «epoll: use refcount to reduce ep_mutex contention»), около 2500 строк diff'а. В нём оказались сразу два независимых race condition. Первый нашёл Anthropic Mythos – это CVE-2026-43074, патч (07712db8) вышел в mainline 2 апреля 2026 года и сделал освобождение struct eventpoll RCU-отложенным. Это закрыло гонку, которую видел Mythos, но не тронуло вторую – use-after-free на struct file. Этот объект живёт в SLAB_TYPESAFE_BY_RCU-кэше и освобождается под тем же спинлоком, минуя RCU grace period. Это и есть Bad Epoll: баг оставался в коде до финального патча a6dc643c6931, который дошёл до mainline только 24 апреля – через два месяца после первого репорта в security@kernel.org (17 февраля) и неудачной первой попытки мейнтейнеров его закрыть.
Есть ирония: сегодняшний публичный PoC собран ещё в феврале, до фикса CVE-2026-43074, и использует именно UAF на struct eventpoll – ту гонку, которую позже закрыл патч Mythos. Отдельная, более тихая UAF на struct file, ради которой баг и получил собственный CVE, пережила этот патч и потребовала повторного репорта 22 апреля.
Mythos справился с непростой задачей: гонки в ядре плохо ловятся статическим анализом, автоматике они почти не даются. Но Bad Epoll осталась в слепой зоне по двум причинам. Во-первых, окно физически крошечное: от WRITE_ONCE(f_ep, NULL) до hlist_del_rcu() – считанные инструкции, и держится оно спинлоком, а значит требует двух ядер CPU одновременно. Такое интерливание тяжело смоделировать, просто читая код. Во-вторых, после фикса первой гонки эта UAF почти никогда не поднимает KASAN: filp-кэш помечен SLAB_TYPESAFE_BY_RCU, путь держит спинлок, и стандартный детектор памяти молчит. Ни одного крэша, ни одного лишнего сигнала на этапе ревью – только код, который выглядит корректным.
Само окно слишком короткое, чтобы выиграть гонку в лоб. Поэтому основная работа эксплойта – не с памятью, а с таймингами. __ep_remove() берёт spin_lock(&file->f_lock) прямо перед окном, а f_lock лежит в той же 64-байтовой кэш-линии, что и f_op. Значит, чтение is_file_epoll() внутри окна почти всегда попадает в кэш и пролетает мгновенно. Эксплойт бьёт по этому же полю с другого CPU: параллельный поток крутит close(dup(ep_target)), который трогает только атомарный f_count и не берёт f_lock, зато инвалидирует кэш-линию с f_op на CPU жертвы. Это false sharing – техника из «Racing against the clock» Джанна Хорна.
Кэш-линию раскачали – но таймер ещё нужно попасть ровно в это окно. Тут работает timerfd с примерно тремя тысячами асинхронных epoll-waiter'ов: срабатывая, он заставляет обработчик обойти всю очередь и на время застопорить CPU жертвы, растягивая окно. Дальше – подбор момента старта: основной поток замеряет длительность своего кэш-всплеска и ставит таймер на его середину, а гоняющий поток стартует close() за launch_ahead наносекунд до срабатывания и по длительности close() узнаёт, попало ли прерывание внутрь окна. Перебор диапазона launch_ahead и выбор из лучшего найденного значения снижают число нужных попыток больше чем вдвое.
Промах в такой гонке не должен ронять ядро – значит, нужен способ узнавать исход без единого краша. Для этого вторая пара epoll-объектов, ep_uaf_target / ep_uaf_waiter, ставится в позицию, где запись нуля из гонки либо обнулит список наблюдателей epoll_uaf_target->refs, либо нет. Проверяется это через побочный эффект штатной защиты epoll от циклов:
static int ep_loop_check(struct eventpoll *ep, struct eventpoll *to)
/* ep = epoll_uaf_target, to = eventpoll от ep_race_fds[3] */
{
depth = ep_loop_check_proc(to, 0);
if (depth > EP_MAX_NESTS)
return -1;
upwards_depth = ep_get_upwards_depth_proc(ep, 0);
return (depth + 1 + upwards_depth > EP_MAX_NESTS) ? -1 : 0;
}
Заранее собранная цепочка из трёх вложенных epoll даёт depth == 3, так что итог зависит только от upwards_depth – глубины обхода epoll_uaf_target->refs:
Выигрыш: refs.first == 0 → upwards_depth == 0 → epoll_ctl() = 0
Промах: refs.first != 0 → upwards_depth == 1 → epoll_ctl() = -ELOOP
Промах стоит одного системного вызова и мгновенного повтора – никакого риска для стабильности таргета.
Запись нуля в refs.first – это всего восемь байт в чужой памяти, и сама по себе она мало что даёт. Усиление в том, куда именно она бьёт: в eventpoll, за которым уже наблюдает другой eventpoll. Обнуление обрывает только прямую ссылку в списке – обратные указатели epitem-а, fllink.pprev и ffd.file, остаются висящими на уже освобождённые struct eventpoll и struct file. А поскольку epi->ffd.file никогда не поднимал f_count цели, файл может быть освобождён по-настоящему.
Дальше – cross-cache. filp-кэш помечен SLAB_TYPESAFE_BY_RCU, поэтому опустошённая страница слэба сначала уходит в RCU и только потом возвращается buddy-аллокатору. Эксплойт окружает целевой файл балластными open("/dev/null"), чтобы гарантированно опустошить его страницу, и одновременно выталкивает её в buddy, переполняя per-CPU partial-список (техника Cross-X, CCS 2025). После RCU-callback'а страница реаллоцируется как backing-страницы pipe_buffer: труба заранее раздута через F_SETPIPE_SZ до 256 страниц вместо стандартных 16, так что одна из них с высокой вероятностью ляжет ровно на освобождённую страницу filp (Page Spray, USENIX Security 2024). С этого момента чтение и запись в трубу дают полный контроль над содержимым мёртвого struct file, на который всё ещё указывает висящий epi->ffd.file.
Подделанный struct file сразу даёт примитив чтения. Логика простая: у поддельного файла подставляется свой f_inode, а дальше ядро само разыменовывает этот указатель и печатает то, что за ним стоит.
Триггер – обычное чтение /proc/self/fdinfo/<ep_uaf_waiter>. Оно вызывает ep_show_fdinfo(), которая без проверок печатает два поля инода, на который указывает поддельный файл:
struct inode *inode = file_inode(epi->ffd.file);
seq_printf(m, "... ino:%lx sdev:%x\n", inode->i_ino, inode->i_sb->s_dev);
Подставив нужный f_inode, эксплойт сдвигает то, что читает эта строка, на любой адрес по своему выбору:
f_inode = addr - offset(inode, i_ino)
→ inode->i_ino печатает 8 байт по адресу addr
→ inode->i_sb должен указывать на что-то валидное,
иначе seq_printf уронит ядро
Это ограниченное чтение – срабатывает только там, где рядом с addr случайно лежит валидный указатель. Но и этого хватает: сначала эксплойт проверяет init_task.comm (подтверждает, что cross-cache зацепил нужную страницу), затем проходит дерево процессов от init_task через children/sibling и находит собственный task_struct.
Второй шаг снимает само ограничение. i_sb подставляется не абы куда, а на поле sas_ss_sp уже найденного task_struct – а это поле процесс волен переписать сам, через sigaltstack():
f_inode = ¤t->sas_ss_sp - offset(inode, i_sb)
sigaltstack(ss_sp = addr - offset(super_block, s_dev))
→ inode->i_sb->s_dev читает 4 байта по любому адресу addr
Теперь читать можно куда угодно без оговорок. Этим примитивом эксплойт вычисляет адрес будущей ROP-страницы, проходя task->files → fdt → fd[rop_pipe] → private_data → bufs[0].page.
Финальный примитив стандартный для повреждённых struct file. Вызов epoll_wait() на висящем waiter'е доходит до vfs_poll(), а тот слепо разыменовывает указатель на функцию:
static __poll_t ep_item_poll(const struct epitem *epi, poll_table *pt, int depth)
{
struct file *file = epi_fget(epi); /* rdx = file->f_count + 1 */
if (!is_file_epoll(file))
res = vfs_poll(file, pt); /* file->f_op->poll(file, pt) */
}
Подделанный f_op указывает на управляемую ROP-страницу, и file->f_op->poll разыменовывается прямо в первый гаджет пивота. Побочный эффект epi_fget() заодно даёт контроль над регистром rdx, потому что тот всегда равен f_count + 1:
f_op = virt // адрес ROP-страницы
f_count = virt - 1 // epi_fget() поднимет его на 1
→ rdx == virt ровно к моменту первого гаджета
На LTS-таргете дальше идёт цепочка из четырёх pivot-гаджетов:
PIVOT1: mov rax,[rdx+0x38] ; mov [rsp+0x20],rdx ; mov rdi,rdx ; mov rax,[rax] ; call rax
PIVOT2: mov rax,[rdi] ; mov rbx,rdi ; mov r13,[rdi+0x18] ; mov r12,[rdi+0x8] ; mov rax,[rax+0x148] ; call rax
PIVOT3: mov rax,[rdi+0x70] ; mov rcx,[rdi+0xb8] ; mov rdx,[rdi+0x80] ; mov rax,[rax+0x10] ; mov rdi,rcx ; jmp rax
PIVOT4: push [rcx] ; rcr byte [rbx+0x5d],0x41 ; pop rsp ; ret
На ChromeOS хватает двух гаджетов и ret-слайда – разница только в раскладке struct file_operations, приём тот же. Как только rsp переезжает на подготовленную страницу, начинается ROP: commit_creds(&init_cred) подменяет учётные данные процесса на root, switch_task_namespaces(find_task_by_vpid(1), &init_nsproxy) возвращает его в initial namespace, и управление уходит обратно в userspace, где execve() поднимает root shell.
Ещё до этого эксплойт снимает KASLR отдельным prefetch side-channel (Gruss et al., CCS 2016; техника EntryBleed) – каналом, не связанным с самой гонкой.
На таргетах kernelCTF – lts-6.12.67 и cos-121-18867.294.100 – эксплойт добирается до root в 99% и 98% запусков, укладываясь в пятиминутный бюджет kernelCTF на попытку. Все зафиксированные неудачи – не провал гонки, а сбой утечки KASLR на одной конкретной модели процессора, AMD EPYC 9V45, где prefetch-канал изредка возвращает неверную базу и роняет эксплойт в панику ещё до атаки на epoll.
Требования для триггера – никаких: ни капабилити, ни user namespace, только включённый CONFIG_EPOLL, который есть почти везде. Это ставит Bad Epoll в число буквально десятка из примерно ста тридцати эксплойтов kernelCTF, вообще способных рутить Android – большинству привилегированных багов ядра нужны модули, которых на Android просто нет. По той же причине баг триггерится изнутри песочницы Chrome-рендерера, блокирующей почти всё остальное, – ровно тот сценарий, что Project Zero уже показывал через MSG_OOB. На Android полный root пока не опубликован: автор пишет, что PoC на Pixel 10 (ядро 6.6+) триггерит саму UAF, а рабочий рут-эксплойт ещё готовится; устройства на ядре 6.1 бага не касаются вовсе – он появился только в 6.4.
Название не случайно – это очередная находка в серии Android-рутящих багов с префиксом «Bad», после Bad Binder, Bad IO_uring и Bad Spin. При этом у Bad Epoll нет kill switch – в отличие от багов вроде Copy Fail, которые нейтрализуются выгрузкой уязвимого модуля, epoll как базовый механизм ядра выключить нельзя, можно только патчить. И патчили небыстро: первая попытка мейнтейнеров не закрыла проблему до конца, а корректный фикс дошёл до mainline только через два месяца после первого репорта.
Настоящая причина Bad Epoll – обычная инженерная ошибка. В 2023 году epoll отрефакторили ради снижения contention на общем мьютексе: вместо одной надёжной блокировки код стал полагаться на lockless-проверку f_ep == NULL под частичным спинлоком. Такой размен – классически опасное место в конкурентном коде. Одну грубую, но надёжную гарантию (никто не тронет объект, пока держится общий мьютекс) меняют на набор мелких локальных допущений, которые обязаны выполняться одновременно во всех местах, где поле читают и пишут. В одном и том же diff на 2500 строк это допущение не выполнилось дважды – и оба раза по одной причине: код читает флаг без синхронизации с тем, кто его пишет, и один поток успевает освободить память раньше, чем второй закончит с ней работать.
Это и есть главный урок: скорость конкурентного кода почти всегда покупается ценой точности инвариантов, а рефакторинг «уберём mutex, добавим lockless fast path» – самое частое место, где ядро потом ловит use-after-free. Bad Epoll не исключение из этого правила, а образцовый его пример.
Технический разбор и эксплойт – Джэён Чон, при участии CompSec Lab (Сеульский национальный университет).
Автор: hacker@shifry.local