Блог

Frag Gap (CVE-2026-53362): 15 байт, которые превращают UDP-сокет в root

Frag_Gap_(CVE-2026-53362)
CVE / Docker / Linux / Безопасность

Frag Gap (CVE-2026-53362): 15 байт, которые превращают UDP-сокет в root

Обычный процесс на вашем сервере открывает UDPv6-сокет, склеивает в него пару splice()-вызовов из pipe — и через несколько секунд читает и пишет в произвольную физическую память. Не через баг в вашем приложении. Не через уязвимый systemd-сервис. А через код в ядре, который считает одни и те же 15 байт дважды: один раз как часть данных, второй раз как что-то, что уже лежит в другом месте. Эта путаница называется Frag Gap — CVE-2026-53362, локальная эскалация привилегий до root, для которой уже есть рабочий эксплойт и подробный разбор от исследователей физику́ба (physicube) и qwerty, опубликовавших находку 20 июля 2026 года. По данным Red Hat, уязвимости присвоен CVSS 7.0 (вектор AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H), CWE-131 — некорректный расчёт размера буфера; Amazon Linux в своём advisory даёт немного другую оценку — 7.8. Расхождение в десятые доли — обычная история для CVE ядра: разные CNA пересчитывают вектор по-своему, но в обоих случаях это High-класс с полным ущербом для confidentiality, integrity и availability, то есть спор идёт не о серьёзности бага, а о паре десятых балла. Патч есть, эксплуатация публичная, но пока не отмечена CISA как атака «в дикой природе» — баг найден и раскрыт через kernelCTF, соревнование Google по поиску уязвимостей ядра.

Специфика в том, что баг живёт в подсистеме UDP corking для IPv6 — коде, который есть на каждом Linux-сервере с включённым CONFIG_IPV6=y, а это подавляющее большинство современных дистрибутивов. Триггер не требует специальных прав: обычный непривилегированный локальный пользователь может дойти до root через несколько системных вызовов.

ЧТО ТАКОЕ UDP CORKING И FRAGGAP

UDP corking — механизм склейки нескольких вызовов send() в один датаграмма, прежде чем она уйдёт в сеть. Включается флагом UDP_CORK: пока сокет «закупорен», данные копятся в буфере ядра, а не отправляются немедленно. Это оптимизация — меньше пакетов, меньше накладных расходов на заголовки.

Проблема начинается, когда накопленные данные пересекают границу фрагмента — максимальный размер, который влезает в один сетевой пакет (MTU минус заголовки). Функция __ip6_append_data() считает байты, которые «перелезли» через эту границу из предыдущего skb (socket buffer — структура ядра, которая хранит сетевой пакет), и называет их fraggap. Эти байты нужно перенести в линейную область следующего skb — то есть именно скопировать как данные, а не как что-то внешнее.

Здесь и происходит путаница. Есть два способа выделить память под новый skb: обычный (non-paged) и paged — тот, что используется при MSG_MORE, NETIF_F_SG или больших фрагментах. В non-paged ветке fraggap учитывается правильно — переменная alloclen включает эти байты. В paged ветке — нет:

datalen = length + fraggap;
fraglen = datalen + fragheaderlen;
pagedlen = 0;

/* ... */

else {
    alloclen = fragheaderlen + transhdrlen;
    pagedlen = datalen - transhdrlen;
}

datalen уже включает fraggap. Но alloclen — размер, который реально выделяется под линейную область — его не учитывает. А pagedlen, наоборот, наследует лишние байты от datalen. В результате переменная copy схлопывается в отрицательное значение, равное -fraggap. Раньше это приводило к ошибке -EINVAL — ядро просто отказывалось выполнять операцию. Но коммит ce650a166335, добавленный для поддержки MSG_SPLICE_PAGES (zero-copy передачи данных из pipe напрямую в сокет), снял эту проверку именно для случая с отрицательным copy. С этого момента баг стал реально эксплуатируемым.

ГДЕ ИМЕННО ПРОИСХОДИТ ЗАПИСЬ ЗА ГРАНИЦЕЙ

Дальше код игнорирует то, что fraggap — это данные, которым не нашлось места, и всё равно копирует их в линейную область:

data = skb_put(skb, fraglen - pagedlen);
data += fragheaderlen;

if (fraggap) {
    skb->csum = skb_copy_and_csum_bits(
        skb_prev, maxfraglen,
        data + transhdrlen, fraggap);
}

skb_put() сдвигает «хвост» буфера ровно до skb->end — конца выделенной под данные памяти. Сразу за этим концом в памяти лежит структура skb_shared_info — служебные метаданные пакета: количество фрагментов, флаги, указатель на деструктор. Копирование fraggap байт происходит именно в эту область — за пределами того, что было реально выделено под данные.

В разборе от physicube и qwerty расчёт выглядит так: маршрутизирующий заголовок IPv6 в 320 байт даёт fragheaderlen = 360. Первый skb длиной 1287 байт. Граница фрагмента — 1272 байта. Разница — ровно 15 байт fraggap. Эти 15 байт попадают точно в начало skb_shared_info: смещения от +0x00 до +0x0e.

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

Атака построена на двух вызовах splice() в закупоренный UDPv6-сокет. Первый переносит 919 байт из pipe — с учётом UDP- и routing-заголовков итоговый skb получается ровно 1287 байт, на 15 больше границы фрагмента. UDP_CORK удерживает его в очереди вместо немедленной отправки. Второй splice() добавляет ещё 20 байт — и именно на этом шаге пересчёт copy уходит в минус, создавая новый skb с fraggap = 15.

Из 15 доступных байт атакующему нужен только один — байт со смещением +0x02, поле nr_frags в skb_shared_info. Это счётчик количества прикреплённых страниц-фрагментов. В норме он равен нулю у свежего skb, но конкретно эта область памяти зануляется не полностью: функция __finalize_skb_around() обнуляет структуру только до поля dataref, а массив frags[], где как раз хранятся дескрипторы страниц, идёт после него и может содержать данные от предыдущего использования той же памяти. Установив nr_frags = 1 через OOB-запись, эксплойт заставляет ядро поверить, что frags[0] — валидный дескриптор страницы, хотя эта страница уже была освобождена.

Дальше — классическая техника грумминга: эксплойт заранее строит валидный дескриптор страницы в том же кэше объектов через серию tee()-вызовов, поднимающих refcount исходной pipe-страницы до девяти, чтобы она пережила освобождение и переиспользование памяти. Когда сокет закрывается, деструктор skb вызывает put_page() для всех страниц, число которых определяется тем самым nr_frags — включая ту, что была активирована через OOB. Получается висящая (dangling) страница: физическая память освобождена обратно в аллокатор, но на неё всё ещё указывает pipe_buffer в pipe атакующего.

Дальше эксплойт вызывает mmap() и обращается к нему так, чтобы аллокатор переиспользовал именно эту физическую страницу под таблицу страниц (page table entry, PTE). Как только это происходит, запись через pipe в старую висящую страницу превращается в запись в собственную PTE процесса — а значит, атакующий может менять физический адрес, на который указывает его же виртуальная память. Это даёт произвольное чтение и запись физической памяти из userspace.

Финальный шаг — найти в физической памяти переменную ядра core_pattern (она определяет, какая программа запускается при аварийном завершении процесса с core dump) и переписать её на путь вида |/proc/%P/fd/666 %P. Когда дочерний процесс аварийно завершается по SIGSEGV, ядро запускает эту команду от имени root.

ЧТО МЕНЯЕТСЯ В ЗАВИСИМОСТИ ОТ КОНФИГУРАЦИИ СИСТЕМЫ

Условие для срабатывания одно, но жёсткое: CONFIG_IPV6=y. Если IPv6-стек в ядре отключён на этапе сборки — вектор атаки для IPv6-пути не существует. Но у бага была и IPv4-версия с той же логической ошибкой, закрытая отдельным патчем — коммитом eca856950f7c. Отключить только IPv6 недостаточно, если у вас непропатченное ядро: путь через IPv4 подтверждён эксплуатируемым, это в разборе явно подтвердил Sultan Alsawaf из команды CIQ.

Контейнеризация сама по себе не спасает. Уязвимость живёт в сетевом стеке ядра, а не в userspace-коде, поэтому непривилегированный пользователь внутри Docker-контейнера без дополнительных ограничений на syscalls (seccomp-профиль, блокирующий splice() или сетевые операции) точно так же добирается до root на хосте — если контейнер использует хостовое ядро, что верно почти всегда для Docker.

TIMELINE

Ошибка в учёте fraggap появилась в ядре 12 июля 2022 года — коммит 773ba4fe9104 «ipv6: avoid partial copy for zc», часть работы над zero-copy передачей данных. Год спустя, 2 августа 2023 года, коммит ce650a166335 снял проверку на отрицательный copy для случая MSG_SPLICE_PAGES — именно этот момент сделал баг реально триггерящимся, а не просто латентной логической ошибкой. Три года эта комбинация просто ждала, пока кто-то соберёт из неё эксплойт.

15 мая 2026 года physicube и qwerty сообщили об уязвимости напрямую в [email protected]. IPv6-фикс (коммит 736b380e28d0) попал в сетевое дерево net 21 июня, а в основную ветку ядра — 25 июня. CVE-идентификатор CVE-2026-53362 Linux kernel CNA опубликовал 4 июля. 14 июля исследователи уведомили закрытый список рассылки linux-distros у Openwall — стандартная процедура координированного раскрытия для дистрибутивов. Полный разбор и рабочий эксплойт вышли публично 20 июля 2026 года — их можно найти на GitHub в репозитории qwerty-po/security-research.

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

Для хостинг-провайдеров с multi-tenant окружением это одна из худших категорий багов: непривилегированный пользователь на shared-хостинге, в VPS с общим ядром или в любом контейнере без строгого seccomp-профиля получает root на хосте — то есть доступ ко всем остальным клиентам на той же машине. Не нужен ни особый софт, ни специфическая конфигурация приложения — только рабочее ядро с включённым IPv6 и возможность открыть UDP-сокет, что обычно разрешено по умолчанию.

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

Отдельный повод для беспокойства — сама природа бага. Это не переполнение буфера в духе «забыли проверить длину». Это рассинхронизация учёта между двумя ветками кода, которые обрабатывают формально одни и те же байты по-разному. Такие баги систематически сложнее ловить статическим анализом и код-ревью, потому что каждая отдельная ветка выглядит корректной — проблема появляется только в сравнении двух путей исполнения.

UPDATE

Патч для IPv6 доступен в коммите 736b380e28d0, для IPv4 — в коммите eca856950f7c. Согласно официальному advisory, оба фикса включены в стабильные версии ядра 6.1.177, 6.6.144, 6.12.95, 6.18.38, 7.1.3, а также в 7.2-rc1.

На Debian и Ubuntu проверить установленную версию ядра можно так — команда выводит версию собранного в вашей системе пакета:

uname -r

Если версия ниже перечисленных выше в рамках своей ветки — ядро уязвимо. Дальше стандартное обновление через пакетный менеджер:

apt-get update && apt-get upgrade linux-image-$(uname -r | sed 's/-generic//')

Важный момент: на момент публикации этой статьи, по данным официальной страницы Ubuntu Security, для основных пакетов ядра linux, linux-aws, linux-gcp, linux-azure на релизах 24.04 LTS и 26.04 LTS статус всё ещё «Vulnerable» — исправленный пакет пока не выпущен. Проверяйте актуальный статус на странице ubuntu.com/security/CVE-2026-53362 перед тем, как считать вопрос закрытым: обновление через apt подтянет патч автоматически, как только Canonical выпустит собранный пакет, но до этого момента вам нужен временный workaround.

Пока патч не докатился до вашего дистрибутива, временная мера — заблокировать вектор через nftables, ограничив создание UDPv6-сокетов для непривилегированных пользователей, либо, если IPv6 в вашей инфраструктуре не используется вовсе, отключить его через sysctl. Учтите: для полного отключения IPv6 на Debian/Ubuntu нужны сразу оба параметра, иначе часть стека останется активной:

sysctl -w net.ipv6.conf.all.disable_ipv6=1
sysctl -w net.ipv6.conf.default.disable_ipv6=1

Первая команда отключает IPv6 на всех уже существующих интерфейсах, вторая — задаёт это как настройку по умолчанию для интерфейсов, которые появятся позже (например, после поднятия нового Docker-моста). Без второй команды новый контейнер снова окажется с активным IPv6-стеком. Это именно workaround, а не замена патча: как только обновлённое ядро будет доступно в вашем дистрибутиве — устанавливайте его и, если IPv6 нужен для работы сервисов, включайте обратно.

Для более точечного варианта, если полностью отключать IPv6 нельзя, можно ограничить доступ к splice() через seccomp-профиль для контейнеров и сервисных аккаунтов, не требующих этого syscall — но этот путь не описан в официальном advisory как рекомендованная мера, поэтому применяйте его как дополнительный defense-in-depth, а не как основную защиту.

ВЫВОДЫ

Если вы хостинг-провайдер с shared-инфраструктурой или управляете кластером с multi-tenant контейнерами — это приоритет номер один на этой неделе. Проверьте uname -r на каждом узле, сверьте с патченными версиями (6.1.177 / 6.6.144 / 6.12.95 / 6.18.38 / 7.1.3 / 7.2-rc1) и, если апдейт от дистрибутива ещё не готов — отключайте IPv6 через оба параметра sysctl прямо сейчас, а не после того, как обновление появится в репозитории.

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

Для клиентов hosting-провайдеров прямого действия не требуется — это ответственность провайдера, но имеет смысл спросить у него напрямую, когда именно будет закрыт CVE-2026-53362 на вашей инфраструктуре, особенно если вы делите узел с другими клиентами.

Leave your thought here

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