Назад к блогу

Локальное повышение привилегий в Linux через AF_ALG: как устроена гонка, дающая произвольную запись в ядре

Локальное повышение привилегий в Linux через AF_ALG: как устроена гонка, дающая произвольную запись в ядре

В ядре Linux обнаружена уязвимость повышения привилегий через криптографический интерфейс AF_ALG, позволяющая обычному пользователю получить права root и выйти за пределы Docker-контейнера. Корректный код содержал изъян около четырнадцати лет, а его эксплуатация строится не на классической гонке с отсутствующей блокировкой, а на нарушении неявного инварианта о состоянии входного буфера. Разбор показывает, как устроен путь от sendmsg до произвольной записи в память ядра.

AF_ALG — давно известная поверхность атаки. Документация ядра прямо признаёт, что интерфейс не выдерживает современных инструментов поиска уязвимостей, таких как syzbot и большие языковые модели, и получает постоянный поток CVE. Разберём, как устроена конкретная уязвимость, найденная в 2025 году: обычный пользователь мог повысить привилегии до root и выйти из Docker-контейнера, а уязвимый код присутствовал в Linux примерно с 2011 года. Нетривиальность здесь в том, что корнем оказывается не отсутствие блокировки, а нарушение неявного инварианта о состоянии буфера ввода.

Что экспонирует AF_ALG

Интерфейс предоставляет в пользовательское пространство четыре семейства алгоритмов: message digest, включая keyed message digest (HMAC, CMAC), симметричные шифры, AEAD-шифры, и генераторы случайных чисел. Keyed message digest — это хеш, который считает дайджест с использованием секретного ключа, поэтому его результат нельзя подделать без знания ключа; обычный message digest ключа не использует. Симметричный шифр только преобразует данные ключом и сам по себе не подтверждает их подлинность, а AEAD-шифр добавляет к этому проверку целостности. Семейство выбирается полем salg_type: "hash" для хешей, "skcipher" для симметричных шифров, "aead" для AEAD, "rng" для генераторов. Конкретный алгоритм задаётся полем salg_name — например, "sha1", "cbc(aes)", "gcm(aes)" или "stdrng". Интерфейс предоставляется через сокет с типом AF_ALG, а опция setsockopt имеет тип SOL_ALG.

Исторически AF_ALG был плодотворной поверхностью атаки, и ядро постепенно её урезало. Zero-copy поддержку удалили, потому что она была частым источником уязвимостей; для обратной совместимости splice() и sendfile() всё ещё поддерживаются, но ядро делает внутреннюю копию данных перед передачей в криптокод. Чтобы уменьшить поверхность атаки, AF_ALG теперь даёт доступ только к алгоритмам, реализованным в программном обеспечении, из-за чего больше не выполняет своё исходное назначение. Документация рекомендует по возможности переносить программы в пользовательский криптокод и отключать CONFIG_CRYPTO_USER_API_*, а на системах с SELinux ограничивать использование AF_ALG доверенными программами.

Как программа взаимодействует с ядром

Сначала создаётся сокет AF_ALG типа SOCK_SEQPACKET. Затем выполняется bind со структурой sockaddr_alg, где salg_type="skcipher" выбирает симметричный шифр, а salg_name задаёт имя шифра. После bind вызывается accept, возвращающий новый файловый дескриптор для взаимодействия с конкретным экземпляром шифра — именно его нужно использовать в send/write и recv/read.

Операция шифрования выполняется через sendmsg на дескрипторе, полученном от accept. В управляющем поле заголовка сообщения передаются IV и запрошенная операция вместе с обрабатываемыми байтами. Результат получается через read()/recv().

Управляющие сообщения помещаются в буфер msg_control структуры msghdr как цепочка cmsghdr. Тип операции задаётся cmsg с типом ALG_SET_OP и значением ALG_OP_ENCRYPT или ALG_OP_DECRYPT. IV передаётся через cmsg с типом ALG_SET_IV, в данных которого лежит struct af_alg_iv: поле ivlen задаёт длину IV, а iv — сами байты. Для AEAD длина ассоциированных данных указывается через cmsg с типом ALG_SET_AEAD_ASSOCLEN, а размер тега аутентификации задаётся отдельно через setsockopt с опцией ALG_SET_AEAD_AUTHSIZE.

Путь sendmsg в algif_skcipher

Когда клиент вызывает sendmsg, управление попадает в skcipher_sendmsg(). Она достаёт из родительского сокета объект трансформации шифра — внутреннее представление выбранного алгоритма, созданное ядром при привязке сокета к алгоритму; именно родительский сокет хранит это представление, а сокет, полученный от accept, лишь ссылается на него. Затем она вычисляет размер IV вызовом crypto_skcipher_ivsize(tfm) и передаёт управление в общую функцию af_alg_sendmsg() вместе с этим размером.

В af_alg_sendmsg() вычисленный ivsize используется для проверки длины IV, пришедшего от пользователя: если пользователь передал IV и его длина не совпадает с ivsize, возвращается -EINVAL. Если проверка пройдена, IV копируется в контекст.

Дальше функция работает с буфером ввода. Вход хранится в виде списка объектов af_alg_tsgl. Каждый такой объект состоит из трёх частей: элемента двусвязного списка, счётчика заполненных записей cur и гибкого массива записей scatterlist sg[]. Поле cur хранит число заполненных записей: если cur равно 3, валидны sg[0], sg[1] и sg[2], то есть последняя используемая запись — sg[cur-1]. В памяти элемент списка занимает 16 байт по смещению 0x00, cur — 4 байта по смещению 0x10, затем 4 байта padding, а sg[0] начинается по смещению 0x18.

Слияние и инвариант

Контекст ctx — это общее состояние сокета, в котором AF_ALG накапливает вход между вызовами. Флаг ctx->merge означает, что следующие данные можно дописать в последний элемент scatterlist, то есть в уже выделенную страницу, вместо того чтобы заводить новую запись: так вход собирается по частям, не выделяя память на каждый вызов. После копирования AF_ALG запоминает, использовал ли вызов MSG_MORE, в ctx->more; истинное значение означает, что пользователь ещё не завершил ввод для этой операции, и последующий sendmsg() может продолжить тот же ввод без повторной отправки всех метаданных.

Ветка слияния предполагает, что у последнего af_alg_tsgl есть хотя бы один элемент для добавления. Это записано как инвариант:

ctx->merge == true  =>  last_sgl->cur > 0

Если условие выполняется, вычисление sgl->cur - 1 безопасно, потому что cur не может быть нулём. Именно на это опирается код, берущий последнюю заполненную запись.

Выделение и копирование

Если ctx->merge не ноль, берётся последний af_alg_tsgl из списка, вычисляется свободное место в его последней странице как PAGE_SIZE - sg->offset - sg->length, и в неё копируются данные. Затем длина записи увеличивается на скопированное, ctx->merge пересчитывается как (sg->offset + sg->length) & (PAGE_SIZE - 1), а счётчики использованного объёма растут.

Если ctx->merge равен нулю, он сбрасывается, проверяется доступность памяти и при необходимости поток ждёт освобождения места, после чего вызывается af_alg_alloc_tsgl() для выделения нового af_alg_tsgl. Эта функция берёт последний элемент списка и, если список пуст или в текущем af_alg_tsgl уже cur >= MAX_SGL_ENTS (предельное число записей scatterlist в одном объекте), выделяет новый объект, инициализирует его таблицу записей и устанавливает cur = 0, а при наличии предыдущего связывает их. Новый объект добавляется в конец списка.

Гонка на сокете

af_alg_sendmsg() вызывает lock_sock() перед изменением контекста. На первый взгляд это сериализует множественные вызовы sendmsg(). Но когда буфер отправки заполнен, функция может войти в af_alg_wait_for_wmem() и ждать появления места.

Снятие и повторный захват сокет-локировки происходят не в самой af_alg_wait_for_wmem(), а внутри реализации sk_wait_event(): макрос выполняет release_sock, ждёт, пока условие ложно, затем снова захватывает лок и проверяет условие. Освобождение блокировки намеренное — чтобы другая операция могла потребить данные и освободить место. Это открывает окно: пока поток спит, второй поток может выполнить sendmsg() на том же дескрипторе, и после пробуждения первый поток может увидеть иное состояние контекста, чем до ожидания. Строго одновременно контекст не меняется — сокет-локировка всё ещё его защищает, — но два потока могут иметь незавершённые вызовы sendmsg() на одном дескрипторе.

Как ломается инвариант

Начальное состояние: last_sgl->cur = MAX_SGL_ENTS - 1, ctx->merge = false, буфер отправки полон. Два потока вызывают sendmsg() и оба блокируются в ожидании места.

После освобождения буферизованных данных первый поток просыпается. В последнем af_alg_tsgl ещё есть одна свободная запись, и поток её использует. Вход копируется короче PAGE_SIZE, поэтому последняя страница остаётся частично пустой, а ctx->merge устанавливается в true. Состояние становится: last_sgl->cur = MAX_SGL_ENTS, ctx->merge = true.

Второй поток передаёт некорректный пользовательский адрес, из-за чего копирование завершается ошибкой. Только что выделенная страница освобождается, и функция выходит по пути обработки ошибки. Однако новый af_alg_tsgl всё равно остаётся последним элементом списка, а ctx->merge, установленный первым потоком, сохраняется как true. Контекст впервые приходит в состояние, где ctx->merge = true и last_sgl->cur = 0 — инвариант нарушен.

Из OOB-чтения в произвольную запись

Когда ctx->merge истинно, а cur последнего объекта равен нулю, вычисление последней записи даёт sg = sgl->sg + 0 - 1 = &sgl->sg[-1]. Поскольку sizeof(struct scatterlist) = 0x20, элемент sg[-1] располагается на 0x18 - 0x20 = -0x8 относительно начала объекта, то есть за восемь байт до него. Каждый af_alg_tsgl выделяется как объект размером 4096 байт, поэтому если аллокатор размещает другой объект непосредственно перед уязвимым, то sg[-1].page_link читает последние восемь байт предшествующего объекта по смещению 0xff8.

Целевой адрес копирования вычисляется как:

dest = page_address(sg_page(sg)) + sg->offset + sg->length;

Управляя sg[-1].page_link, можно влиять на страницу, возвращаемую sg_page(sg). Но sg[-1].offset и sg[-1].length читаются из указателя list.next в начале текущего af_alg_tsgl и отражают адрес связного списка, а не значения, которые мы задали в page_link. Эти поля тоже входят в адрес назначения, поэтому одного page_link недостаточно, чтобы точно задать адрес.

Почему получается примитив записи

Копирование идёт через memcpy_from_msg(), которая просто передаёт работу в copy_from_iter_full(). Приёмником служит вычисленный адрес, источником — пользовательский итератор. Проверка доступности выполняется только для источника, а не для приёмника. Поэтому неверное вычисленное назначение не приводит к обычному memcpy() и падению ядра, а обрабатывается через этот usercopy-путь. Это даёт оракул: можно перебирать значения page_link и наблюдать, попадает ли копирование куда надо.

Превращение контроля над page_link в произвольную запись затруднено: ядро работает со страницами через vmemmap, а KASLR.

Цель — core_pattern

В качестве цели записи выбирается core_pattern, потому что этот параметр управляет обработкой core dump: если его значение начинается с символа вертикальной черты, ядро запускает указанную после него программу и передаёт ей core dump. Эксплойт с помощью произвольной записи заменяет core_pattern на команду, указывающую обратно на бинарник эксплойта, после чего намеренно вызывает падение дочернего процесса. Ядро исполняет этот бинарник как обработчик core dump с привилегиями root — так примитив записи превращается в локальное повышение привилегий.

Что видит клиент в момент гонки

При гонке двух потоков, вызывающих sendmsg() на одном дескрипторе, оба потока входят в ожидание освобождения места в буфере отправки. Ошибку от sendmsg() приложение в момент гонки не получает. Вместо этого ядро сообщает о выходе за границы slab при последующей работе af_alg_sendmsg с повреждённым состоянием списка и флага слияния:

BUG: KASAN: slab-out-of-bounds in af_alg_sendmsg Read of size 8 by task exploit

Блокировка recvmsg() в разобранных источниках не описывается.

Исправление

Исправление вводит в af_alg_sendmsg под захваченным сокет-локом проверку флага ctx->write. Этот флаг отличается от ctx->more: ctx->more описывает незавершённость ввода, а ctx->write — занятость контекста записью. Если флаг уже установлен, лок освобождается и возвращается -EBUSY, иначе флаг выставляется в true. Тем самым один sendmsg получает исключительное владение контекстом: пока он выполняется, другой писатель на том же сокете получает -EBUSY. Хотя сокет-лок по-прежнему снимается во время ожидания памяти, второй поток уже не может начать вторую запись в тот же контекст, поскольку флаг остаётся установленным до завершения первой операции. Перед возвратом функция сбрасывает ctx->write обратно в false.

Так прежнее неявное предположение, что вход строит только один писатель, превращается в правило, которое ядро реально обеспечивает.

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

  • Корень уязвимости — нарушенный инвариант, а не отсутствие блокировки. Сокет-локировка присутствовала и защищала контекст от одновременного изменения. Проблема в том, что код полагался на неявное предположение «вход строит только один писатель», которое ядро не обеспечивало. Патч превращает это предположение в явное правило через флаг владения.
  • Освобождение блокировки на время ожидания памяти — законная и необходимая практика. Без него поток, ждущий места, не дал бы другому потоку потребить данные и освободить буфер. Опасность возникает не из самого факта снятия лока, а из того, что после пробуждения поток видит изменённое состояние контекста и должен корректно с ним работать.
  • Ошибка на пути обработки ошибок может оставить структуру в несогласованном состоянии. В разобранном случае сбой копирования освободил страницу, но новый объект остался в списке, а флаг слияния — установленным. Пути обработки ошибок нужно проверять так же тщательно, как и успешные.
  • Проверка доступности только для источника превращает ошибку адресации в оракул. Поскольку usercopy-путь проверяет лишь пользовательский источник, неверный адрес назначения не роняет ядро, а позволяет перебирать значения и постепенно уточнять цель записи.
  • Готовый эксплойт выглядит проще, чем работа над ним. Автор потратил время на понимание кода и тестирование гонки до получения полезного краша, а затем гораздо больше времени на превращение краша в надёжный эксплойт. Исследование началось задолго до появления KASAN-краша: нужно было выбрать поверхность атаки, достижимую непривилегированным пользователем, понять, как AF_ALG хранит вход, и построить модель состояния, которое всегда должно оставаться валидным. Первый вопрос был не «как получить root?», а «действительно ли merge всегда означает, что существует финальная SG-запись?».
  • Защита на уровне конфигурации работает. Отключение CONFIG_CRYPTO_USER_API_* и ограничение AF_ALG доверенными программами через SELinux убирают саму поверхность атаки для непривилегированных пользователей.

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

Источники

Похожее