Назад к блогу

Четыре локальные root-уязвимости в ядре Linux: механика DirtyAH6, PPPoEject, TUNderflow и DiagSpill

Четыре локальные root-уязвимости в ядре Linux: механика DirtyAH6, PPPoEject, TUNderflow и DiagSpill

В ядре Linux обнаружены четыре локальные уязвимости повышения привилегий до root — DirtyAH6, TUNderflow, PPPoEject и DiagSpill, — и все они относятся к повреждению памяти, хотя затрагивают совершенно разные подсистемы: XFRM/AH6 для IPv6, драйвер TUN, PPPoE и SCTP-диагностику. Разбор показывает, как ошибки в арифметике указателей и счётчиков приводят к выходу за границы буфера, и почему из-за природы этих багов готовые эксплойты приходится адаптировать под конкретное ядро и конфигурацию системы.

В oss-sec-треде разобраны четыре уязвимости ядра Linux, дающие непривилегированному локальному пользователю выполнение кода от root: DirtyAH6 (CVE-2026-80844), TUNderflow (CVE-2026-81000), PPPoEject (CVE-2026-68121) и DiagSpill (CVE-2026-74469). Все четыре — это повреждения памяти. Нетривиальность в том, что ошибки живут в разных подсистемах — XFRM/AH6 для IPv6, драйвере TUN, PPPoE и SCTP/sctp_diag — но упираются в одни и те же инварианты работы с skb и Netlink. Ниже — что именно ломается в каждой и почему эксплойт приходится подгонять под конкретную среду.

Общая картина: четыре ошибки, одна природа

Каждая уязвимость исправлена отдельным коммитом:

  • DirtyAH6 — 7bad4bda74dc4713f398d3b7624ff05478e3a568 («xfrm: ah6: validate routing header segments_left»);
  • TUNderflow — 447c9303942c439a117d9b76ce6d6e2116b38ee7 («net: tun: bound receive headroom»);
  • PPPoEject — e9c238f6fe42fb1b4dba3a578277de32cb487937 («pppoe: reload header pointer after dev_hard_header()»);
  • DiagSpill — bd0e9289e2642f6a5c54faad304ce0f41e926d22 («sctp: prevent peer transport count overflow»).

Первопричина везде одна — повреждение памяти. В DirtyAH6 неверный расчёт указателя даёт out-of-bounds доступ: «A raw IPv6 HDRINCL packet with hdrlen=2 and segments_left=255 moved the pointer back 4,064 bytes and passed a 4,064-byte length to memmove(), causing an out-of-bounds access.» В TUNderflow арифметика headroom переполняется и уводит skb->data за пределы буфера: «SKB_MAX_HEAD(4160) would underflow; prepad + linear and len - linear then wrapped, leaving skb->data 64 bytes beyond its 4,096-byte allocation.» В PPPoEject пишут по указателю на уже освобождённую память: «The subsequent header and length writes used that stale pointer.» В DiagSpill переполнение счётчика транспортов приводит к записи за границей Netlink-ответа: «sctp_diag then reserved no peer payload but copied the full list, spilling about 8 MiB past the end of the Netlink response.»

Именно из-за повреждения памяти PoC не универсальны: «Все четыре ошибки повреждают память, поэтому PoC приходится настраивать под конкретное ядро, процессор и расположение объектов в памяти.»

DirtyAH6: расчёт указателя в IPv6 AH

Уязвимость в подсистеме XFRM/AH6 для IPv6. Функция ipv6_rearrange_rthdr() берёт число адресов из hdrlen, а затем двигает указатель на segments - segments_left без проверки, что segments_left <= segments. При hdrlen=2 и segments_left=255 указатель уезжает назад на 4 064 байта, и в memmove() уходит длина 4 064 байта — это и есть выход за границы.

Рядом лежит вторая точка отказа — обработка AH-заголовков при вычислении контрольной суммы. skb_checksum_setup_ipv6 в цикле по расширениям для IPPROTO_AH вызывает skb_maybe_pull_tail(skb, off + sizeof(struct ip_auth_hdr), off + sizeof(struct ip_auth_hdr)), после чего читает hp = OPT_HDR(struct ip_auth_hdr, skb, off) и увеличивает off += ipv6_authlen(hp). Если skb_maybe_pull_tail не обеспечивает достаточного линейного пространства под весь AH-заголовок, последующее чтение hp->nexthdr и вычисление ipv6_authlen(hp) могут выйти за пределы выделенной памяти.

Сборка и разделение skb здесь опираются на несколько функций. skb_cow_data при клонировании или разбиении head на страницы вытаскивает все страницы в линейную часть. __pskb_pull_tail при нехватке места перераспределяет head с дополнительным запасом. pskb_expand_head обновляет указатели после перераспределения. skb_realloc_headroom создаёт такую копию.

Отдельный слой — работа с frag_list и gso_size. skb_segment_list обрабатывает skb, у которого в skb_shinfo(skb)->frag_list лежит цепочка отдельных skb, присоединённых к головному. Она обнуляет frag_list у головного skb, затем в цикле переносит каждый элемент в цепочку skb->next, при необходимости клонируя разделяемые элементы через skb_clone(nskb, GFP_ATOMIC), и в конце вычитает суммарную длину перенесённых данных из головного skb и сбрасывает GSO-поля через skb_gso_reset. skb_segment читает mss из skb_shinfo(head_skb)->gso_size, отдельно обрабатывает случай gso_by_frags, когда mss равен GSO_BY_FRAGS, и проверяет флаг SKB_GSO_DODGY. Комментарий «gso_size is untrusted» относится к ветке, где при SKB_GSO_DODGY и наличии в frag_list линейного не-head_frag элемента отключается NETIF_F_SG, чтобы не разделять страницы по границам gso_size. skb_shift переносит до shiftlen байт страничных данных из skb в tgt, работая с frags и nr_frags, требует отсутствия линейной части у источника и отказывается работать с zerocopy-буферами.

TUNderflow: арифметика headroom в драйвере TUN

Уязвимость в драйвере TUN. tun_set_headroom() сохранял receive headroom прямо в tun->align, а tun_get_user() использовал то же поле для выбора объёма данных пакета, остающегося в head. Одно поле обслуживало две разные задачи — отсюда переполнение арифметики и уход skb->data за пределы выделенного буфера.

Резерв под заголовки задан как TUN_RX_PAD (NET_IP_ALIGN + NET_SKB_PAD). В tun_build_skb начальный pad равен TUN_RX_PAD, а если активна XDP-программа, к нему добавляется XDP_PACKET_HEADROOM; затем buflen вычисляется как SKB_DATA_ALIGN(sizeof(struct skb_shared_info)) плюс SKB_DATA_ALIGN(len + pad) — то есть учитывает и служебную структуру, и запас перед данными. Копирование из userspace идёт в позицию alloc_frag->offset + pad, данные размещаются после зарезервированного headroom, а в __tun_build_skb вызывается skb_reserve(skb, pad) и skb_put(skb, len).

Решение о сборке skb поверх уже выделенного буфера принимает tun_can_build_skb: только для IFF_TAP, при sk_sndbuf == INT_MAX, noblock и не-zerocopy, и лишь если суммарный размер укладывается в страницу:

if (SKB_DATA_ALIGN(len + TUN_RX_PAD + XDP_PACKET_HEADROOM) +
    SKB_DATA_ALIGN(sizeof(struct skb_shared_info)) > PAGE_SIZE)
	return false;

Если условие не выполнено, tun_alloc_skb выделяет skb через sock_alloc_send_pskb. При prepad + len < PAGE_SIZE весь пакет принудительно делается линейным (linear = len), иначе линейная часть ограничивается так, чтобы хвост не превышал MAX_SKB_FRAGS * (PAGE_SIZE << PAGE_ALLOC_COSTLY_ORDER). Затем идут skb_reserve(skb, prepad), skb_put(skb, linear), skb->data_len = len - linear и skb->len += len - linear.

Границы при этом проверяются: skb_put увеличивает skb->tail и skb->len и при skb->tail > skb->end вызывает skb_over_panic; skb_push уменьшает skb->data, увеличивает skb->len и при skb->data < skb->head вызывает skb_under_panic; pskb_pull сначала вызывает pskb_may_pull и только при успехе уменьшает skb->len и увеличивает skb->data.

Работа с фрагментами идёт через NAPI. tun_napi_alloc_frags выделяет skb через napi_get_frags(&tfile->napi) и заполняет его фрагментами: вычисляет linear = iov_iter_single_seg_count(it), вызывает __skb_grow(skb, linear), затем для каждого iov с i=1..nr_segs-1 берёт fragsz = iov->iov_len, отклоняя fragsz == 0 или fragsz > PAGE_SIZE, и выделяет фрагмент через netdev_alloc_frag(fragsz). При ошибке всё освобождается через napi_free_frags(&tfile->napi). tun_napi_receive забирает пакеты из очереди tfile->sk.sk_write_queue, перенося их в локальную process_queue под spin_lock(&queue->lock) через skb_queue_splice_tail_init, затем в цикле while (received < budget && (skb = __skb_dequeue(&process_queue))) вызывает napi_gro_receive(napi, skb) и увеличивает received; остаток возвращает обратно через skb_queue_splice. tun_napi_poll вызывает tun_napi_receive(napi, budget) и, если received < budget, вызывает napi_complete_done(napi, received).

Потоки (flow) хранятся в tun->flows[TUN_NUM_FLOW_ENTRIES], где TUN_NUM_FLOW_ENTRIES = 1024, а индекс вычисляется как rxhash & TUN_MASK_FLOW_ENTRIES. tun_flow_find ищет запись по e->rxhash == rxhash; tun_flow_create создаёт запись с e->updated = jiffies, e->rxhash, e->rps_rxhash = 0, e->queue_index, e->tun и увеличивает tun->flow_count; tun_flow_update при наличии записи обновляет queue_index и updated, иначе под tun->lock создаёт запись только если tun->flow_count < MAX_TAP_FLOWS, и запускает flow_gc_timer. tun_flow_delete удаляет запись (hlist_del_rcu, kfree_rcu) и уменьшает tun->flow_count; tun_flow_cleanup — таймер, который для каждой записи вычисляет this_timer = e->updated + delay и при time_before_eq(this_timer, jiffies) вызывает tun_flow_delete, иначе считает count и переносит next_timer, а при count != 0 перезапускает mod_timer(&tun->flow_gc_timer, round_jiffies_up(next_timer)).

PPPoEject: устаревший указатель после dev_hard_header()

Уязвимость в подсистеме PPPoE. pppoe_sendmsg() сохранял указатель внутрь головы skb через dev_hard_header(), хотя callback устройства мог вызвать pskb_expand_head() и освободить эту голову. Блокировка копирования полезной нагрузки на FUSE при добавлении первого порта GRE/IP6GRE к пустому team или bonding-устройству вызывала перераспределение, «выбрасывая» старую голову skb, пока PPPoE всё ещё держал в неё указатель, и последующие записи заголовка и длины использовали устаревший указатель. Документация pskb_expand_head это прямо фиксирует: «All the pointers pointing into skb header may change and must be reloaded after call to this function.»

Чтобы понять, почему это вообще возможно, полезно устройство generic-слоя PPP. Подключение канала к generic-слою инициируется со стороны кода канала, а не generic-слоя: канал должен иметь способ управления со стороны пользовательского процесса независимо от generic-слоя, например через файловый дескриптор последовательного порта. Пользовательский процесс сначала выполняет discovery (инициализацию среды, настройку tty, модемные команды, диалог с удалённой системой), затем велит среде стать PPP-каналом и зарегистрироваться в generic-слое, после чего канал сообщает назначенный ему номер обратно пользовательскому процессу, и дальнейшие PPP-переговоры ведёт pppd через /dev/ppp. Канал инициализирует поля mtu и hdrlen до регистрации в generic-слое и не меняет их до отключения от него; поле mtu задаёт максимальный размер данных PPP-кадра без 2-байтового номера протокола.

PPPIOCBRIDGECHAN связывает канал с другим: аргумент указывает на int с номером канала, с которым нужно связать, и после связывания кадры, поданные в один канал, передаются экземпляру моста для дальнейшей передачи — это позволяет переключать кадры из одного канала в другой, например PPPoE в сессию PPPoL2TP. Канал не может одновременно быть частью моста и частью unit, иначе возвращается EALREADY, а если запрошенного канала нет — ENXIO. PPPIOCSCOMPRESS задаёт параметры сжатия или разжатия пакетов: аргумент указывает на структуру ppp_option_data с парой указатель/длина на блок памяти с CCP-опцией, задающей метод сжатия и его параметры, а поле transmit определяет, затронет ли ioctl приёмный путь (0) или передающий.

Буферизация в направлении передачи минимизируется: поддерживается очередь пакетов на PPP-устройство и очередь на каждый присоединённый канал, причём обычно очередь устройства содержит не более одного пакета. Канал, которому нужен headroom (свободное место в буфере перед началом PPP-кадра), устанавливает поле hdrlen, а generic-слой старается предоставить столько места, но канал всё равно должен сам проверить достаточность headroom и при нехватке скопировать skbuff. На входе каналы желательно должны предоставлять не менее 2 байт headroom в буферах, передаваемых в generic-слой. При multilink generic-слой делит пакет на фрагменты, решая их число по длине пакета и числу каналов, потенциально способных принять фрагмент (канал способен, если у него нет уже поставленных в очередь фрагментов); канал может отказаться, и тогда фрагмент ставится в очередь на более позднюю передачу. SMP-safety обеспечивается блокировками вокруг внутренних структур, при этом канал обязан гарантировать существование и валидность памяти своего объекта: объект должен существовать с момента регистрации в generic-слое до возврата из отключения, и ни один поток не должен находиться в обработке канала в момент его отключения. PPPoEject нарушает именно инвариант с указателем на head skb.

DiagSpill: переполнение счётчика транспортов в SCTP

Уязвимость в SCTP/sctp_diag. Ассоциация SCTP может иметь 65 536 транспортных адресов удалённой стороны, с которыми установлена ассоциация, но счётчик транспортов в структуре ассоциации — 16-битное поле, и 65 536-й транспорт обнуляет его. После этого sctp_diag резервирует нулевой объём места под список транспортов в ответе Netlink, но копирует полный список, переполняя ответ примерно на 8 MiB: «sctp_diag then reserved no peer payload but copied the full list, spilling about 8 MiB past the end of the Netlink response.»

Слой диагностики сокетов устроен так. Для каждого сокета формируется 64-битное значение cookie: если оно ещё не задано, генерируется новое и записывается в сокет, причём повторное чтение учитывает, что другой поток мог записать своё значение раньше. При сохранении cookie в ответ оно раскладывается на два 32-битных поля — младшие и старшие биты. При проверке cookie сначала принимается случай, когда оба поля равны INET_DIAG_NOCOOKIE; иначе значение сокета сверяется с сохранёнными полями, и при несовпадении младших или старших 32 бит возвращается -ESTALE.

Обработка запроса к диагностике сокетов идёт по типу сообщения: для TCPDIAG_GETSOCK задействуется совместимый обработчик, для SOCK_DIAG_BY_FAMILY и SOCK_DESTROY — общий путь обработки команд, иначе запрос отклоняется как недопустимый. В общем пути сначала проверяется, что длина сообщения не меньше размера запроса, иначе запрос отклоняется; затем по семейству адресов выбирается обработчик, и для SOCK_DIAG_BY_FAMILY выполняется дамп, для SOCK_DESTROY — уничтожение, иначе операция не поддерживается.

Netlink и skb: общие инварианты для DiagSpill и TUNderflow

Обе уязвимости опираются на одни и те же механизмы Netlink. netlink_sendmsg отклоняет пустые сообщения и сообщения с MSG_OOB, проверяет длину против sk_sndbuf, выделяет skb через netlink_alloc_large_skb и копирует данные через skb_put(skb, len), после чего рассылает или отправляет unicast. netlink_recvmsg получает skb через skb_recv_datagram, при нехватке буфера ставит MSG_TRUNC и копирует только len байт, а также запоминает максимальную длину recvmsg в nlk->max_recvmsg_len. netlink_dump выделяет skb размером max(min_dump_alloc, max_recvmsg_len) и обрезает его через skb_reserve(skb, skb_tailroom(skb) - alloc_size), ожидая, что пользователь предоставит буфер такого размера. __netlink_dump_start увеличивает счётчик ссылок skb (refcount_inc(&skb->users)), сохраняет его в cb->skb и запускает дамп, возвращая -EINTR, чтобы не отправлять ACK. netlink_ack формирует ответ размером payload + tlvlen, где payload включает исходный запрос, если не установлен NETLINK_F_CAP_ACK.

Нарушаемые предположения: skb_put паникует при превышении skb->end, а netlink_dump рассчитывает, что пользовательский буфер не меньше max(min_dump_alloc, 32KiB), иначе возникает MSG_TRUNC.

Границы буфера в skb-хелперах проверяются по-разному. skb_put расширяет область в хвосте, увеличивая skb->tail и skb->len, и при skb->tail > skb->end вызывает skb_over_panic. skb_push расширяет область в начале, уменьшая skb->data и увеличивая skb->len, и при skb->data < skb->head вызывает skb_under_panic. skb_pull удаляет данные из начала буфера, возвращая память в headroom, и реализована через skb_pull_inline. skb_trim урезает длину только если skb->len > len, вызывая __skb_trim, и требует линейности буфера. __skb_pad дополняет хвост нулями: при неразделяемом буфере и достаточном tailroom просто зануляет область, иначе вычисляет ntail, вызывает pskb_expand_head, а затем skb_linearize. pskb_trim_rcsum_complete при непроверяемых фрагментах возвращает -EFAULT, если len > skb_headlen(skb), иначе устанавливает skb->ip_summed = CHECKSUM_NONE. В патчах для всех четырёх уязвимостей фигурируют skb_put, skb_push, skb_pull, skb_trim, __skb_pad и pskb_trim_rcsum_complete.

Компромиссы аллокации: почему TUNderflow и DirtyAH6 вообще возможны

В tun.c размер резерва под заголовки задан как TUN_RX_PAD (NET_IP_ALIGN + NET_SKB_PAD), а решение о сборке skb поверх уже выделенного буфера принимает tun_can_build_skb: только для IFF_TAP, при sk_sndbuf == INT_MAX, noblock и не-zerocopy, и лишь если выровненные len + TUN_RX_PAD + XDP_PACKET_HEADROOM и sizeof(struct skb_shared_info) вместе укладываются в PAGE_SIZE. Если условие не выполнено, tun_alloc_skb выделяет skb через sock_alloc_send_pskb, а при prepad + len < PAGE_SIZE принудительно делает весь пакет линейным (linear = len), иначе ограничивает линейную часть так, чтобы хвост не превышал MAX_SKB_FRAGS * (PAGE_SIZE << PAGE_ALLOC_COSTLY_ORDER).

В skbuff.c малые заголовки skb обслуживаются отдельным кэшем: если obj_size = SKB_HEAD_ALIGN(*size) не больше SKB_SMALL_HEAD_CACHE_SIZE и не выставлены KMALLOC_NOT_NORMAL_BITS, берётся kmem_cache_alloc_node(net_hotdata.skb_small_head_cache, ...), а *size принудительно становится SKB_SMALL_HEAD_CACHE_SIZE; при неудаче используется kmalloc_pfmemalloc(0, ...), то есть тот же малый кэш с резервами pfmemalloc. Для крупных объектов размер округляется kmalloc_size_roundup и берётся kmalloc_node_track_caller, а при неудаче — kmalloc_pfmemalloc(obj_size, ...). Выравнивание объясняется так: «We do our best to align skb_shared_info on a separate cache line. It usually works because kmalloc(X > SMP_CACHE_BYTES) gives aligned memory blocks, unless SLUB/SLAB debug is enabled. Both skb->head and skb_shared_info are cache line aligned.»

Эксплуатация: что нужно атакующему

Требования к привилегиям и доступу различаются по уязвимостям:

  • DirtyAH6 — поддержка AH6/XFRM и либо непривилегированные user/network namespaces, либо CAP_NET_ADMIN и CAP_NET_RAW над контролируемым сетевым пространством имён.
  • TUNderflow — поддержка TUN и путь сетевого устройства, способный передать ему увеличенный receive headroom, плюс непривилегированные user/network namespaces либо CAP_NET_ADMIN над контролируемым сетевым пространством имён.
  • PPPoEject — поддержка PPPoE и callback заголовка нижнего устройства, способный перераспределить skb head во время dev_hard_header(), плюс непривилегированные user/network namespaces либо CAP_NET_ADMIN над контролируемым сетевым пространством имён.
  • DiagSpill — SCTP и sctp_diag, при этом «no unprivileged user namespace or CAP requirements».

Все четыре PoC дают непривилегированному локальному пользователю выполнение кода от root: «All four PoCs provide unprivileged local user -> root code execution on their targets.»

Универсальной кнопкой root эти эксплойты не являются. Все четыре ошибки повреждают память, поэтому PoC настраиваются под конкретное ядро, процессор и расположение объектов в памяти. Успех зависит от версии ядра: уязвимыми являются конкретные диапазоны релизов, а первые стабильные версии со всеми исправлениями — 5.10.270, 5.15.221, 6.1.188, 6.6.157, 6.12.109, 6.18.50 и 7.2.4. Важна конфигурация и наличие модулей: DirtyAH6 требует AH6/XFRM, TUNderflow — TUN и путь сетевого устройства, PPPoEject — PPPoE и callback нижнего устройства, DiagSpill — SCTP и sctp_diag. Для первых трёх нужны непривилегированные user/network namespaces или CAP_NET_ADMIN и CAP_NET_RAW, тогда как DiagSpill не требует ни user namespaces, ни специальных capability. Отключение непривилегированных user namespaces закрывает обычному пользователю путь к первым трём ошибкам, но не защищает от DiagSpill; если AH6, TUN, PPPoE или SCTP не нужны, соответствующие возможности также можно отключить. SELinux и AppArmor сами по себе не остановили эксплуатацию после выполнения необходимых условий.

Что из этого следует на практике

  • Один и тот же класс ошибки живёт в разных подсистемах. DirtyAH6 — арифметика указателя, TUNderflow — переполнение headroom, PPPoEject — устаревший указатель после перераспределения, DiagSpill — переполнение 16-битного счётчика. Паттерн общий: неверная арифметика или устаревшее владение памятью вокруг skb.
  • DiagSpill — самая доступная из четырёх. Она не требует ни непривилегированных user namespaces, ни capability: достаточно SCTP и sctp_diag. Отключение user namespaces её не закрывает.
  • Первые три закрываются ужесточением namespaces и capability. Если непривилегированные user/network namespaces отключены, а CAP_NET_ADMIN/CAP_NET_RAW не выданы, путь к DirtyAH6, TUNderflow и PPPoEject для обычного пользователя перекрыт.
  • Отключение ненужных подсистем — действенная мера. Если AH6, TUN, PPPoE или SCTP не используются, соответствующие возможности можно отключить, и уязвимость перестаёт быть достижимой.
  • SELinux и AppArmor не заменяют патч. После выполнения необходимых условий они сами по себе эксплуатацию не остановили.
  • Порог исправления — конкретные стабильные релизы. Первые версии со всеми четырьмя исправлениями: 5.10.270, 5.15.221, 6.1.188, 6.6.157, 6.12.109, 6.18.50 и 7.2.4.
  • PoC не переносимы между средами. Из-за повреждения памяти эксплойт настраивается под конкретное ядро, процессор и раскладку объектов в памяти, поэтому воспроизводимость зависит от среды и не гарантируется «из коробки».

Где смотреть в коде

Источники

Похожее