Блог

Bad Epoll (CVE-2026-46242): шесть инструкций гонки — и любой пользователь становится root

CVE-2026-46242_bad-epoll
CVE / Linux / Безопасность

Bad Epoll (CVE-2026-46242): шесть инструкций гонки — и любой пользователь становится root

Обычный пользователь без единой привилегии открывает терминал на вашем сервере, запускает эксплойт — и через пару секунд у него root. Не через дыру в веб-приложении, не через утёкший пароль, а через epoll — механизм ядра Linux, на котором держится буквально всё: nginx, systemd, любой асинхронный сервис, браузер, Android. Отключить его нельзя. Это не гипотетический сценарий: 30 мая 2026 года получил номер CVE-2026-46242, а 1 июля исследователь Jaeyoung Chung опубликовал рабочий эксплойт с надёжностью около 99% на тестовом ядре 6.12.67.

По шкале kernel.org и NVD уязвимость получила CVSS 3.1 — 7.8 (High), вектор AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H. Расшифровывается это так: атака локальная, сложность низкая, нужны минимальные привилегии, взаимодействие с пользователем не требуется, а результат — полный контроль над конфиденциальностью, целостностью и доступностью системы. Проще говоря: обычный локальный шелл — и через несколько попыток root. CWE-416 — use-after-free, классика жанра. Red Hat оценивает баг чуть скромнее, в 7.0, справедливо отмечая, что окно гонки крошечное — но 99% успеха эксплойта говорят сами за себя.

PoC уже публичный, лежит на GitHub у самого автора находки. В списке CISA KEV на момент публикации баг не числится, подтверждённой эксплуатации в реальных атаках нет — но патч без экстренного объявления безопасности спокойно ждал backport’а два месяца, и это ровно та ситуация, где спокойствие обманчиво.

ЧТО ТАКОЕ EPOLL

epoll — это способ, которым процесс в Linux эффективно следит сразу за множеством файловых дескрипторов: сокетов, пайпов, других epoll-инстансов. Вместо того чтобы опрашивать каждый дескриптор по очереди, процесс регистрирует их все в одном epoll-объекте и одним системным вызовом получает список тех, что готовы к чтению или записи. Именно на этом механизме держится производительность nginx, Node.js, asyncio в Python, event loop в Android — практически любой сервис, который обслуживает много соединений одновременно.

Внутри ядра каждый epoll-инстанс — это структура struct eventpoll, а каждый отслеживаемый дескриптор внутри неё представлен структурой struct epitem. Особый случай — когда epoll следит за другим epoll: получается связанный список объектов, где закрытие одного должно аккуратно отцепить его от другого. Именно в этой аккуратности и была найдена дыра.

КАК УСТРОЕН БАГ

Проблема в функции ep_remove(), если точнее — в ep_remove_file(). Функция должна снять эту связь безопасно: сначала обнулить file->f_ep под защитой file->f_lock, потом сделать всю остальную работу. Но код продолжал использовать переменную @file внутри критической секции уже после того, как f_ep был обнулён: вызывался is_file_epoll(), потом hlist_del_rcu() по указателю на head-элемент списка, и только затем — spin_unlock().

В этот промежуток может влезть конкурентный вызов __fput() — финальное освобождение файлового объекта, когда его счётчик ссылок падает до нуля. Если __fput() идёт по быстрому пути через eventpoll_release() именно в этот момент, он видит уже обнулённый f_ep, решает, что epoll-очистка не нужна, пропускает eventpoll_release_file() и сразу переходит к f_op->release, а затем к file_free().

Для случая, когда epoll следит за другим epoll, f_op->release — это ep_eventpoll_release(), которая вызывает ep_clear_and_put(), а та — ep_free(). Эта цепочка вызовов освобождает через kfree() ту самую наблюдаемую структуру eventpoll. Её встроенный hlist_head с именем ->refs находится ровно там, куда указывает epi->fllink.pprev — то есть указатель на предыдущий элемент связанного списка. Когда следом выполняется hlist_del_rcu(), операция *pprev = next записывает данные в уже освобождённую память из slab-кэша kmalloc-192.

Есть и вторая часть проблемы. struct file помечена флагом SLAB_TYPESAFE_BY_RCU — это значит, что слот памяти, который её хранит, может быть немедленно переиспользован функцией alloc_empty_file() для совершенно другого файла, с переинициализацией f_lock и f_ep, пока ep_remove() формально всё ещё считает, что держит блокировку на старый объект. В итоге получается управляемый атакующим вызов kmem_cache_free() не по тому slab-кэшу, который должен был быть освобождён.

КАК ЭТО ЭКСПЛУАТИРУЕТСЯ

Эксплойт использует четыре epoll-объекта, сгруппированных в две пары. Одна пара запускает саму гонку, вторая становится жертвой повреждения памяти. Восьмибайтовая запись use-after-free сначала превращается в контроль над структурой struct file через cross-cache атаку — технику, при которой атакующий заставляет аллокатор ядра переиспользовать освобождённую память ровно под тот объект, который ему нужен.

Получив контроль над содержимым файлового объекта, эксплойт добивается произвольного чтения памяти ядра через /proc/self/fdinfo — подвешенный struct file, отражённый через pipe, позволяет вытащить адреса ядра прямо из вывода этого псевдофайла. Дальше — перехват потока управления и выполнение ROP-цепочки (return-oriented programming: атакующий склеивает уже существующие в ядре фрагменты кода в нужную ему последовательность), которая и выдаёт root shell.

Само по себе окно гонки — примерно шесть машинных инструкций. Обычная попытка почти никогда в него не попадает. Эксплойт решает эту проблему таймер-прерыванием: он специально организует нагрузку так, чтобы прерывание таймера сработало ровно в середине уязвимого окна выполнения кода, и повторяет попытки в цикле, который не приводит к панике ядра при промахе. Именно этот механизм и даёт заявленную надёжность в 99% на цели lts-6.12.67 и 98% на cos-121-18867.294.100 в программе Google kernelCTF.

ПОЧЕМУ ЭТОТ БАГ ОСОБЕННЫЙ

Из примерно 130 уязвимостей, когда-либо эксплуатированных в рамках kernelCTF, лишь около десяти годятся для получения root на Android. Bad Epoll — одна из них. Большинство громких Linux LPE последних месяцев, вроде Copy Fail, требуют модулей, которые Android попросту не грузит. Здесь ограничения другие: баг живёт в самом ядре epoll, а не в опциональном модуле, и его можно триггернуть даже изнутри renderer-песочницы Chrome, которая блокирует почти все остальные уязвимости ядра. Это открывает теоретическую цепочку: эксплойт в рендерере браузера плюс Bad Epoll — и получаем выполнение кода на уровне ядра, ровно тот сценарий, что Google Project Zero описывала в статье про переход из Chrome renderer в ядро через MSG_OOB.

У Copy Fail и его вариантов есть простое временное решение — выгрузить уязвимый модуль. У Bad Epoll такого нет: epoll нельзя выключить, это базовая часть ядра, на которую полагаются операционная система, сетевые сервисы и браузеры. Единственный работающий вариант — установить патч.

ТАЙМЛАЙН

8 апреля 2023 года коммит 58c9b016e128 вносит в код epoll не один, а сразу два отдельных race condition — в участке кода примерно из 2500 строк. Оба впоследствии окажутся критическими уязвимостями с возможностью повышения привилегий.

17 февраля 2026 года команда Chung сообщает о найденном баге на [email protected]. В тот же день мейнтейнеры предлагают прототип патча — но он оказывается неполным фиксом, и обсуждение затормаживается.

2 апреля 2026 года в mainline попадает исправление для первого из двух багов — того самого, что нашла модель Anthropic Mythos и который получил номер CVE-2026-43074. Находка Mythos сама по себе показательна: race condition в ядре — один из самых сложных классов багов для обнаружения, и то, что фронтирная модель нашла один из них в этом участке кода, говорит о реальном прогрессе автоматизированного анализа безопасности.

22 апреля 2026 года команда Chung повторно сообщает об оставшейся, второй проблеме — той, что впоследствии станет Bad Epoll. 24 апреля 2026 года исправление для неё наконец попадает в mainline коммитом a6dc643c6931. А 1 июля 2026 года выходит полный технический разбор с рабочим PoC — то есть между тихим патчем и публичным раскрытием прошло больше двух месяцев.

КАК ЭТОТ БАГ ПРОПУСТИЛА ИИ-МОДЕЛЬ

Один и тот же коммит 2023 года заложил оба race condition в epoll. Mythos, исследуя этот участок кода, нашла первый — и не нашла второй, хотя логично предположить, что модель разбирала тот же код с достаточной глубиной, раз обнаружила соседний баг. Точную причину пропуска знать нельзя, но у исследователей есть две рабочие версии.

Во-первых, окно гонки крайне узкое — около шести инструкций, и представить точную последовательность чередования потоков сложно даже при непосредственном разборе уязвимого кода. Во-вторых, после того как первый баг (CVE-2026-43074) был исправлен, use-after-free второго бага перестал регулярно триггерить KASAN — основной детектор ошибок памяти в ядре. Без этого сигнала у модели, вероятно, не набралось достаточной уверенности, чтобы заявить о находке как о реальной уязвимости.

Показательно и то, что баг оказался сложным не только для обнаружения, но и для исправления: первый патч мейнтейнеров не устранил проблему полностью, и корректное исправление появилось только спустя два месяца после первого сообщения — необычно долгий срок для ядра, которое обычно реагирует на проблемы безопасности с приоритетом.

ПОЧЕМУ ЭТО ВАЖНО

Bad Epoll бьёт по инфраструктуре, где локальная изоляция — это и есть весь продукт: shared hosting, мультитенантные хосты, CI/CD-раннеры, контейнерные ноды с недоверенным кодом. Любой пользователь с обычным shell-доступом на таком сервере — потенциальный root. Причём epoll используется настолько повсеместно, что отключить эту поверхность атаки просто невозможно: локальная привилегированная эскалация здесь становится не теоретическим риском, а границей, которую действительно кто-то может пересечь.

Для хостинг-провайдеров это прямая угроза изоляции клиентов друг от друга на общем железе. Для команд, управляющих Kubernetes-кластерами и CI-раннерами — риск того, что скомпрометированный под-контейнер или недоверенный pipeline-скрипт получит root на ноде и доступ ко всем остальным нагрузкам на ней. Для владельцев обычных VPS и dedicated-серверов ситуация чуть спокойнее только в одном смысле: если у вас нет посторонних локальных пользователей, вектор атаки требует, чтобы кто-то уже получил хотя бы непривилегированный шелл — например, через уязвимость в веб-приложении. Но именно поэтому Bad Epoll — это тот самый второй шаг, который превращает любой initial access в полный компромисс сервера.

ОБНОВЛЕНИЕ

Официальный фикс — апстрим-коммит a6dc643c69311677c574a0f17a3f4d66a5f3744b, вошедший в основную ветку ядра. Для дистрибутивов исправление доступно как backport в стабильных ветках: 5.15.209 и позже в рамках линейки 5.15, 6.1.175 и позже в линейке 6.1, 6.18.33 и позже в линейке 6.18, 7.0.10 и позже в линейке 7.0, и полностью в 7.1 (коммит вошёл ещё на стадии 7.1-rc1). Сначала посмотрите, какую версию ядра вы реально запустили прямо сейчас — не ту, что установлена, а ту, что загружена в память:

uname -r

Голого номера версии недостаточно, потому что дистрибутивы бэкпортируют исправления в свои собственные ветки нумерации. Надёжный способ проверить, попал ли конкретно этот фикс в установленный у вас пакет ядра — прочитать changelog самого пакета и поискать в нём номер CVE. На Debian и Ubuntu это делается так:

apt-get changelog linux-image-$(uname -r) | grep -i "CVE-2026-46242"

Если команда возвращает строку с упоминанием CVE-2026-46242 — патч уже входит в установленный пакет ядра, и остаётся только перезагрузиться, чтобы он реально заработал. Если строка пустая — ядро ещё не содержит фикс, и нужно обновить пакет через apt update && apt upgrade, дождаться установки нового ядра и перезагрузиться. На RHEL, AlmaLinux, Rocky Linux и других RPM-based системах та же проверка делается через rpm:

rpm -q --changelog kernel | grep -i "CVE-2026-46242"

Логика идентична: пустой вывод значит, что фикса ещё нет и нужно обновиться через dnf update kernel (или yum update kernel на более старых системах), затем перезагрузить хост.

Отдельно стоит свериться со security-трекером конкретного дистрибутива, а не полагаться только на локальный changelog: у Debian, например, статус для веток bookworm и bullseye прямо помечен как not-affected — уязвимый код там попросту отсутствует, поскольку баг был внесён только начиная с ветки ядра 6.4. Более новые ветки Debian и другие дистрибутивы на актуальных ядрах нужно проверять отдельно.

После установки нового пакета ядра обязательна перезагрузка — живое ядро в памяти продолжает содержать уязвимый код до рестарта, простой апдейт пакета без ребута ничего не меняет для уже запущенной системы. Убедиться, что вы действительно загрузились с новым ядром, можно повторным запуском uname -r и сверкой номера версии с тем, что ожидалось после апдейта.

Никакого временного workaround’а внутри самого epoll не существует — механизм нельзя ни отключить, ни ограничить конфигурацией. Если по каким-то причинам перезагрузка невозможна прямо сейчас, единственный реальный способ снизить экспозицию до неё — сократить количество недоверенных локальных пользователей и процессов, способных выполнить произвольный код на хосте: убрать лишний shell-доступ, теснее изолировать CI-раннеры и контейнеры, вынести действительно недоверенные нагрузки на отдельные, специально выделенные и плотно мониторящиеся ноды. Это не устраняет уязвимость — это временно сокращает круг тех, кто может её достать.

Если перезагрузка production-хоста в принципе проблема, live-patching сервисы умеют закрывать подобные дыры без рестарта. На Ubuntu это canonical-livepatch — после установки клиента (sudo snap install canonical-livepatch и активация токена через sudo canonical-livepatch enable <ваш-токен>) статус патчинга проверяется командой:

sudo canonical-livepatch status --verbose

В выводе строка patch state: должна показывать all applicable livepatch kernel modules applied — это значит, что все доступные для вашего ядра live-патчи уже применены. Если видите kernel state: ✗ kernel not supported by Canonical или упоминание, что ядро вышло за пределы окна поддержки Livepatch — это ядро всё ещё не покрыто, и нужен полноценный апдейт пакета с перезагрузкой. Для RHEL-совместимых систем аналогичную роль играет Red Hat kpatch, для остальных дистрибутивов — сторонние решения вроде KernelCare или SUSE Live Patching. В любом случае, прежде чем полагаться на live-patch как на защиту, стоит явно подтвердить у поставщика, что патч именно для CVE-2026-46242 уже доставлен в ваш feed, а не только для родственного CVE-2026-43074: исправление одного use-after-free в этом участке кода не гарантирует автоматического закрытия второго, а live-patching отчёты о покрытии CVE иногда отстают от реального состояния feed’а.

ВЫВОДЫ

Если вы администрируете shared hosting или мультитенантные виртуальные машины — проверьте версию ядра прямо сейчас и сверьтесь с security-трекером вашего дистрибутива; для таких сред это должно встать в приоритет патчинга наравне с критическими RCE, поскольку граница изоляции между клиентами здесь буквально держится на этом коде.

Если вы управляете Kubernetes-кластером или CI/CD-инфраструктурой с раннерами, выполняющими недоверенный код — обновление ядра нод должно произойти до следующего цикла деплоя, а не по расписанию плановых патчей: это ровно тот тип уязвимости, который превращает скомпрометированный под в root на всей ноде.

Если у вас обычный dedicated-сервер или VPS без посторонних локальных пользователей — обновитесь при первой рутинной возможности, но без паники: вектор атаки требует уже имеющегося локального доступа, так что Bad Epoll здесь скорее вторая линия защиты, которую стоит закрыть, чем немедленно горящий инцидент.

И в любом случае — не полагайтесь на голый номер версии ядра. Сверьтесь с security-трекером именно вашего дистрибутива: backport-политики и статусы веток различаются, и то, что выглядит как старая уязвимая версия, может оказаться уже пропатченной, а свежая на первый взгляд — всё ещё содержать дыру.

Leave your thought here

Ваш адрес email не будет опубликован. Обязательные поля помечены *